Skip to content

[Draft] feat: macOS 原生支持(长期适配分支)——内置 loopback NFSv3 导出,普通用户 mount_nfs 挂载 Workspace(无 Docker / 无 macFUSE / 无 root) - #427

Draft
jiangkoumo wants to merge 70 commits into
Ephemeral-AI-Lab:mainfrom
jiangkoumo:feat/macos-native-on-main

Conversation

@jiangkoumo

Copy link
Copy Markdown
Contributor

Draft PR,长期适配分支:jiangkoumo:feat/macos-native-on-main → main。目的是让 macOS 支持有一个可以随时拉下来测试、找问题的完整版本,不急于合并。
本 PR 包含 #402(本地 Sandbox 运行时)的内容:#402 仍单独等评审,它合并后这部分 diff 会自动从本 PR 消失;#402 以后有改动,同样 merge 进本分支。
分支 = 当前 main b5814bf3a + #402 头 8c22d1754(一个本地合并提交 5f83fabe1,无冲突)+ 本 PR 自己的 5 个提交(e63af4c10 → 757e4480c → 6c8ca0e96 → 1deb780d1 → 5169c307b,最终 tree 6ebd2735)。看本 PR 自己的改动,请看这 5 个提交。


这是长期适配分支,会持续跟进 main 和 #402

怎么参与测试(Mac 最小步骤)

需要:Apple Silicon 或 Intel Mac、普通用户、Node ≥ 22.19、Xcode Command Line Tools。不需要 root、Docker、macFUSE。

# 1. Rust 1.85.1(仓库锁定的工具链)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none
. ~/.cargo/env && rustup toolchain install 1.85.1 --profile minimal
# 2. 拉分支
git clone https://github.com/jiangkoumo/layerfs.git && cd layerfs && git checkout feat/macos-native-on-main
# 3. 构建(macOS 27 + rustc 1.85.1 必须关 strip,否则 dyld 拒绝加载 layerfs.node)
export CARGO_PROFILE_RELEASE_STRIP=none
cargo +1.85.1 build --release --manifest-path core/Cargo.toml -p layerfs-daemon -p layerfs-node
cp core/target/release/liblayerfs_node.dylib sdk/js/native/layerfs.node
# 4. 单测(macOS 相关)
cargo +1.85.1 test --manifest-path core/Cargo.toml -p layerfs-nfs -p layerfs-sandbox -p layerfs-store-service -p layerfs-node
# 5. SDK native:建一个项目、挂 Workspace、执行、提交(见 sdk/js/README.md 的 "Native Sandboxes (macOS)")
mkdir -p ~/lfs-try/local && chmod 755 ~/lfs-try/local
export LAYERFS_SANDBOX=native LAYERFS_LOCAL_DIR=~/lfs-try/local LAYERFS_HOME=~/lfs-try/home \
       LAYERFS_STORE_PATH=~/lfs-try/vol/global/store.sqlite LAYERFS_DAEMON=$PWD/core/target/release/layerfs-daemon
node --input-type=module -e '
  const { LayerFS } = await import("./sdk/js/src/index.js");
  const lf = new LayerFS("demo");
  await lf.project.init("./sdk/js/src"); await lf.branch.fork("try");
  const ws = await lf.workspace.mount("try");
  console.log(await lf.workspace.exec(ws, "pwd -P; command -v node; node -v; echo hi > HI.txt"));
  console.log(await lf.workspace.commit(ws, "try")); await lf.workspace.unmount(ws); await lf.close();'
# 6. DSH(dsh 0.2.1-rc.2):layerfs-host 配置里写 sandbox: native 和 daemon: <上面的 layerfs-daemon 绝对路径>,见 dsh/README.md

遇到问题请直接在本 PR 下评论,附:sw_vers、uname -m、rustc +1.85.1 -V、node -v、失败命令的完整输出、mount | grep 127.0.0.1、pgrep -lf layerfs-daemon,以及 Sandbox 目录下的 daemon.log 末尾几十行(在 LAYERFS_LOCAL_DIR/<sandbox>/daemon.log)。

背景

LayerFS 在 Linux 上用 FUSE 把 Workspace 呈现为普通目录。macOS 没有内置 FUSE,目前在 Mac 上只能:

  • macFUSE:要装内核扩展,Apple Silicon 上需进恢复模式降低安全级别,普通用户、公司管控的 Mac 基本不可行;
  • FUSE-T:不需要内核扩展,但挂载不稳定(feat(macos): add native macOS support across Store, FUSE-T, Sandbox, SDK, and DSH #403 评审的根因 2),我们也复现了;
  • Docker Desktop 里的 Linux:可用,但所有 Workspace I/O 都要过虚拟机文件共享,还要求安装 Docker。

本 PR 走第四条路:daemon 自己提供一个只监听 127.0.0.1 的 NFSv3 服务,macOS 自带的 /sbin/mount_nfs 以普通用户身份挂载。 这与 Meta 的 EdenFS 在 macOS 上的做法相同(EdenFS 同样提供 NFSv3 loopback,并用"保持 mode 的 chmod"让客户端丢缓存)。不需要 root、内核扩展、虚拟机或 Docker。clonefile/拷贝方案也评估过,但会丢掉 LayerFS 的核心语义(实时共享视图、增量 Commit),没有采用。

开关:SDK LAYERFS_SANDBOX=native;DSH 插件 sandbox: native。Linux 现有的 FUSE / Docker / local 路线默认行为不变(见"Linux 默认行为"一节)。

架构

agent 进程 ──文件系统调用──▶ macOS NFS 客户端 (mount_nfs, 普通用户)
                                   │ 127.0.0.1:<port>, NFSv3/TCP
                                   ▼
layerfs-daemon (本用户进程, LAYERFS_KERNEL=nfs)
  └─ layerfs-nfs: XDR/RPC/MOUNT/NFSv3 ──▶ 与 FUSE 相同的 kernel request ports (layerfs-fuse / layerfs-workspace)
                                          └─ Overlay / Store / Commit:完全复用,核心零改动
SDK (LAYERFS_SANDBOX=native):在本机起 daemon(local 运行时,#402),命令以当前用户运行,沿用调用方的 PATH
DSH 插件 (sandbox: native):不起 Store 容器、不调 docker,Store 放本地 APFS 目录
  • 每个变更类 NFS 过程都是一次 KernelMutation::perform,先记录回执再回复:丢失的回复不会导致变更重放。
  • .nfs*(silly rename)与 ._*(AppleDouble)在 NFS 层处理,永远不进 Workspace / Commit。
  • Commit 前 daemon 先让客户端写回缓冲(flush 助手,默认 sync(2)),语义与 FUSE "write 返回即到 Workspace" 对齐。
  • Commit/merge 安装别的 root 之后,daemon 对过期 inode 经挂载点做保持原 mode 的 chmod;导出侧的"刷新标记"让这个 SETATTR 不改任何东西、只生效一次(30 s 过期),macOS 客户端随即丢缓存。LAYERFS_NFS_REFRESH=0 可关(关掉后要等 actimeo=60 才可见,Mac 实测约 77 s)。
  • 控制协议与线格式零改动:NFS 的回执用 abi_major = 0、mount_id = 导出端口 表示,Ready/Status/Dirty/Committed/Conflict/Unmounted/ForceUnmounted 与 FUSE 同形同义。

改动(按层,5 个提交)

# 提交 内容 生产 LOC
① e63af4c10 feat(nfs): a loopback NFSv3 export of one mounted Workspace (layerfs-nfs) 新 crate core/crates/layerfs-nfs:自写 XDR/RPC/MOUNT/NFSv3,零第三方依赖,#![forbid(unsafe_code)];有界内核引用(默认 65 536,LRU 叶子回收、按名字重新解析);刷新标记;边界脚本登记(只能用 content/overlay/workspace/fuse,不能用 daemon) 110,624 → 113,293(+2,669)
② 757e4480c feat(daemon): NFS as a kernel mount entry beside FUSE (LAYERFS_KERNEL=nfs) control/export.rs(Attach/flush/Unmount 可逆 busy/ForceUnmount/保留 custody/状态/诊断);LAYERFS_NFS_{MOUNT,UNMOUNT,FORCE,FLUSH} 助手;macOS 默认挂载参数;merge 后刷新;macOS 的 secure_process(umask 077 + RLIMIT_CORE=0);Kernel::Attaching 去掉 Linux 限定、settle 挪到 operations.rs;nix 打开已有 crate 的 resource feature → 114,316(+1,023)
③ 6c8ca0e96 feat(sandbox): local Sandboxes on macOS and the SDK's native route (LAYERFS_SANDBOX=native) backend/local/darwin.rs:proc_pidinfo/proc_listallpids 取进程事实,sysctl kern.bootsessionuuid 取启动身份,mount/umount -f 处理挂载;macOS 上 daemon 和命令沿用调用方进程的 PATH(backend/local/search.rs,见下"需要作者重点看"第 6 条);SDK native:本机 daemon + loopback NFS,命令以当前用户运行,LAYERFS_NFS_* 透传;sdk/js/README.md 新增 "Native Sandboxes (macOS)" → 114,591(+275)
④ 1deb780d1 feat(store-service): open the Store on macOS for the native route; bundled SQLite on macOS install.rs / kernel_guard.rs 在 macOS 放行(见下);overlay/persistence 的 rusqlite bundled 扩到 macOS;Cargo.lock 不变 → 114,619(+28)
⑤ 5169c307b feat(dsh): the LayerFS Host on the native macOS route (sandbox: native, no Docker) dsh/packages/host:native 配置与默认目录(都在 dsh Seatbelt 可写根内)、Store 目录代替 Docker volume、removeNative(daemon 在跑或有活挂载就 busy,检查本身失败也拒绝,绝不在活 NFS 挂载上 rm)、overview 不问 Docker、status 多 sandbox/localDir/stores;sdk/js/src/client.js 的 DSH_LAYERFS_NFS_* 兜底;dsh/README.md 新增一段 → 114,693(+74)

生产 LOC 用 tools/production_loc.py(combined core+sdk+dsh),每个提交信息里都有 before/after/delta。没有删除或放宽任何既有测试断言;tests/kernel_guard.rs 的 macOS 用例从"拒绝"改为"apfs/hfs 放行"(这是行为本身的变化,见下第 2 条)。

新增第三方 crate:无(layerfs-nfs 是一方 crate;daemon 只多开 nix 已有的 resource feature;sandbox 的 libc 原本就在)。没有 vendor/fork/patch 第三方代码。

需要作者重点看的 6 处

  1. layerfs-store-service/src/install.rs:installer_user() 在 macOS 直接放行(#[cfg(target_os = "macos")]),Linux 版本与 DAEMON_UID 原样保留、只在非 macOS 编译。理由:Linux 上 daemon 在容器里以 root 运行,所以要求安装者同为 root(靠 /proc/self 属主判断);macOS 只有 native 路线,daemon 就是安装 Store 的那个用户本人在跑,没有 root daemon 可对齐,也没有 /proc。daemon 自己"只打开自己拥有的 Store 文件/目录"的检查仍然生效。
  2. layerfs-store-service/src/kernel_guard.rs——与 dsh history replay: LayerFS load test over deepseek-harness's git history #352 F-020 冲突,最需要拍板:F-020 有意规定"Store 调用只在 Linux、其他平台一律 Unsupported"(AGENTS.md 也这样写)。本 PR 在 macOS 上对 apfs/hfs 放行(statfs 判定),其他文件系统仍拒绝;Linux 分支与其他平台不变。理由:native 路线下 daemon、SDK 与所有命令都在同一个 macOS 内核上,SQLite 的锁与 Linux 同语义(F-020 要防的是"锁跨不过内核/虚拟机边界")。若接受,AGENTS.md 的 Store 平台说明和 core/docs/issues/352/DIRECT_STORE.md 需要作者按 owner 口径补一条 macOS native 例外——本 PR 没有改这些 owner 文本。若更希望显式开关(例如只有 LAYERFS_SANDBOX=native 时才放行),可以改。
  3. layerfs-sandbox 的 unsafe 策略:#![forbid(unsafe_code)] → #![deny(unsafe_code)],只有 backend/local/darwin.rs 的两个函数 #[allow(unsafe_code)](调用 libc::proc_pidinfo(PROC_PIDTBSDINFO) 与 libc::proc_listallpids,带 SAFETY 注释、长度/pid 校验);边界脚本把它登记为该 crate 唯一的审计模块(与 persistence 的 file_control.rs 同一形式),自测覆盖"兄弟模块仍禁止 unsafe"。为什么需要:最初用 /bin/ps,但它是 setuid root,dsh 给 agent bash 的 Seatbelt 配置拒绝执行它,导致 Sandbox 起不来(Mac 实测)。按 core/AGENTS.md,新增 unsafe 例外需要 owner 授权。替代方案:Cargo.lock 里已有的 libproc 0.14.11(目前是 telemetry 的可选依赖),可保持 forbid,但它构建时要 bindgen + libclang。
  4. rusqlite bundled 扩到 macOS(layerfs-overlay/Cargo.toml、layerfs-persistence/Cargo.toml):macOS 27.2 系统 SQLite 3.54.0 不在 overlay 合格的增长配置里(unqualified SQLite physical growth profile,与 feat(macos): add native macOS support across Store, FUSE-T, Sandbox, SDK, and DSH #403 评审根因 1 相同)。Cargo.lock 不变。代价:macOS 构建多编译一份 sqlite3.c。另外 persistence 的 macOS seal 模块(file_control.rs,SQLITE_FCNTL_PERSIST_WAL)原本针对系统 SQLite,bundled 下行为是否仍符合该授权的前提,请作者确认(Mac 上相关测试已通过)。
  5. macOS 27 的 strip 坑(不在本 PR 代码里,但上游打包会踩):macOS 27.2 + Xcode ld-27037.1 + rustc 1.85.1,release 默认 strip 后 cdylib layerfs.node 的 LC_SYMTAB 字符串池不 8 字节对齐,dyld 拒绝加载("mis-aligned LINKEDIT string pool"),可执行文件不受影响。规避:CARGO_PROFILE_RELEASE_STRIP=none。sdk/js/scripts/build-native.mjs 在 macOS 27 上应该同样会遇到,本 PR 没有改它(是否改、改成什么由作者定)。
  6. macOS 上 local Sandbox 的 daemon 和命令沿用调用方进程的 PATH(backend/local/search.rs,exec.rs/create.rs 改为调用 search_path())。
    • 改了什么:feat(sandbox): 本地进程 Sandbox 后端 LAYERFS_SANDBOX=local——在没有 Docker 的 Linux VM / CI 里使用 workspace.* #402 的 local 后端模仿容器,daemon 和每条命令都 env_clear() 后只给一个写死的 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。本 PR 在 cfg(target_os = "macos")(即 native 路线)上改为 std::env::var_os("PATH")——也就是 SDK 所在进程(Node)自己的 PATH,daemon 启动时同样拿到它;PATH 不存在或为空时回落到原来那串再加 :/opt/homebrew/bin。仍然只传 PATH,调用方的其他环境变量一个都不传;命令自己的 environment 里若给了 PATH,照旧以它为准。
    • 为什么:native 命令跑的是这个用户自己的工具,没有镜像替它准备 /usr/local/bin/node。Mac 实测:DSH 会话里 lfs run … "node main.mjs" 在 native Workspace 里报 127——node 在 ~/.local/bin(指向版本管理器目录),Homebrew 在 /opt/homebrew/bin,都不在写死的列表里。容器路线有镜像保证工具位置,native 路线只能用用户自己 shell 里的 PATH。
    • 链路:用户终端 → dsh web(继承终端环境)→ agent 的 bash(deepseek-harness 0.2.1-rc.2 的 scrubbedParentEnv() 只去掉名字像 KEY/PASSWORD/SECRET/TOKEN 的变量和 DSH_*,PATH 保留,再叠加 DSH_*)→ node "$DSH_LAYERFS_CLI" run …(SDK 和 addon 在这个 Node 进程里)→ addon 起 daemon / 执行命令。中间没有 env -i;唯一清掉 PATH 的地方就是 local 后端自己的 env_clear(),所以只需改这两处,不需要在 SDK/DSH 补传。
    • Linux 不变:cfg(not(target_os = "macos")) 仍返回同一串,tests/local_runtime.rs 新增用例锁住"daemon 和命令的 PATH 恰为这串,与测试进程自己的 PATH 无关";macOS 用例在 tests/local_search_path.rs(#![cfg(target_os = "macos")],Darwin 交叉 clippy 覆盖编译,Mac 上运行)。
    • 代价/取舍:native 命令能看到调用方 PATH 上的任何东西——这本来就是"命令以当前用户运行、不隔离"的 native 路线的含义(README 已写明 "it isolates nothing")。如果作者更希望显式配置(例如 LAYERFS_NATIVE_PATH),可以改成"有它用它、没有才继承"。

Linux 默认行为(不变)

  • 不设 LAYERFS_KERNEL=nfs 时 daemon 不构建导出,installed_export 返回 false,FUSE 失效路径照旧;LAYERFS_KERNEL 在 macOS 上恒为 NFS。
  • config.rs 的"命令身份与 daemon 身份必须不同"只在 NFS 路线放开(native 命令就是本用户);Linux 默认仍拒绝。
  • local Sandbox 的 daemon 和命令在 Linux 上仍然只拿容器那串 PATH(单测锁住)。
  • SDK 只有 deploy.sandbox 为 native 时才走新分支;local/docker 不变(错误信息文本多了"(or native)";local 记录多存 uid/gid 两个字段,读取兼容)。
  • DSH 插件不配 sandbox 且 dsh 进程环境没有 LAYERFS_SANDBOX=native 时,所有代码路径与原来相同(原有 host/bindings-shared/sim/runtime 测试不改、全过)。

Linux 验证(测试机 x86_64,cargo 1.85.1 --offline --locked,每个测试二进制 120 s 墙钟上限)

对照组 mainbase = main b5814bf3a + #402 头 8c22d1754(5f83fabe1),不含本 PR。下表是 PATH 修复折入后的结果(2026-10-23 11:05 CST),与修复前(12992df51)逐项相同,只有 sandbox 多 1 个 Linux 用例。

检查 结果 结论
rustfmt:本 PR 改动的 65 个 .rs;cargo fmt --all --check(core、sdk/js/native) 0 diff ✅
clippy -D warnings,Linux,nfs/daemon/sandbox/store-service/overlay/persistence/node --all-targets 0 ✅
Darwin 交叉 check / clippy -D warnings(aarch64-apple-darwin,同 7 个包 --all-targets,含 macOS 专用测试) 都过 ✅
产品边界检查 check_product_boundary.py 5 条,与 mainbase 相同 5 条(行号下移);自测 51 OK ✅ 无新增违规
单测:layerfs-nfs / sandbox / store-service / node 13 / 39 / 3 / 13,全过 ✅(sandbox 38 → 39:新增 Linux PATH 用例)
persistence、overlay 相关测试 24 / 22,全过 ✅
layerfs-daemon 全套(root,57 个二进制;PATH 修复前跑) 154 过 7 败 mainbase 155 过 6 败,6 个失败逐条相同(fusectl 已挂、/data/tmp mktemp 权限、时序窗口等环境原因);多出的 1 个是 mounted_install 既有抖动(重跑 10 次:本分支 0/10、mainbase 3/10)。PATH 修复不涉及 daemon crate(daemon 不用 layerfs-sandbox)
DSH host 插件测试 host/bindings-shared/sim/runtime/native/windows/frames 7/7 ✅
SDK JS:exec_output、trace 9 种模式、binary_write、init_refusals(root,源目录在 overlayfs) 全过 ✅
SDK smoke,LAYERFS_SANDBOX=local(Linux FUSE,root,本分支 release 构建;走改过的 exec/create) 49/49 ✅
DSH × LayerFS native 端到端(Store 一侧,release daemon,root) ok:init → fork → write → cat → merge → publish,trace 6 种方法,mounts=0 daemons=0 ✅
文件范围 86 个文件,只在 core/crates、core/tools、dsh/packages/host、sdk/js 与两个 README;不含任何测试机 / VM 脚本 ✅

完整命令与输出见附带的 VERIFY.md。上游没有 CI,以上均为本地运行结果。

Mac 真机结果(MacBook Air M2 16 GB,macOS 27.2,普通用户;无 root、无 Docker、无虚拟机、无 macFUSE/FUSE-T)

本 PR 的 PATH 修复之前的版本(12992df51,与现在只差第 6 条)在 Mac 上的结果:

项目 结果
单测 layerfs-nfs 13、sandbox 28、store-service 3、node 10,persistence/overlay 相关 22/22,全过
真实 mount_nfs e2e 默认参数(含 soft,nobrowse)被接受;git/cp/硬链/符号链/Unicode 正常;.nfs*/._* 不可见;Commit 约 0.2–0.5 s;Commit 视图与实时一致;busy → 释放后正常卸载;无残留
SDK native smoke 49/49(merge 后 0.02 s 可见);LAYERFS_NFS_REFRESH=0 对照 48/49,只有该项要约 77 s 才可见
语义对比(同一组操作在 NFS 挂载与 APFS 上) 18/18 一致
性能 1000 个小文件写约为 APFS 的 3–6 倍耗时(两轮实测);热读接近 APFS
韧性(杀 daemon) 死挂载 7 s 超时返回、不卡;自动回收并重新挂载;已提交数据保留
DSH × LayerFS(Seatbelt 强制) daemon 启动、mount_nfs、exec/write/commit、merge/publish 全过,leftovers=0;removeNative 真机检查 13/13(pgrep 缺失、被 Seatbelt 拒绝、mount 缺失都拒绝删除);经真实 dsh web 路由删除:检查失败 409、正常 200
用户自己的 DSH Web(0.2.1-rc.2)全链路 导入 → 开分支 → native Workspace → 提交 → 发布到 main 全部成功;但 lfs run 里不写绝对路径执行 node 报 127——这就是第 6 条修复的问题

PATH 修复后(本 PR 头 5169c307b)的 Mac 增量复验,2026-10-23:

项目 结果
构建 / 单测 release 构建通过,layerfs.node 可加载;sandbox 29(含新的 local_search_path,在 Mac 上真实运行)、node 10,全过
SDK native smoke 49/49
native Workspace 内 PATH 不写绝对路径:$PATH 与调用方一致,command -v node → ~/.local/bin/node,node -v → v22.22.2(rc=0,修复前 127),brew 可用(7/7);env -i 无 PATH 时回落 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin(4/4)
用户自己的 DSH Web 全链路 lfs run 里直接 node main.mjs 成功,提交并发布到 main;daemon 确认为新二进制;三个已有项目均可读;回滚正常,无残留

已知限制

  • operator.process.*(后台进程)在 macOS 仍读 /proc,agent 用 & 起后台进程会失败;workspace.exec / lfs run 不受影响。后续处理。
  • 大量小文件写比 APFS 慢(约 3–6 倍);大仓库首次导入/冷 status 慢 2–3 倍。
  • DSH All projects 页在 native 下不显示运行中的 Sandbox 卡片(单项目 Sandboxes 页正常)。
  • Linux 上 native 只能验证 Store 一侧:Linux 的 Store 安装要求 root,而 native Sandbox 的命令身份不能是 uid 0,所以 Workspace 部分只在 Mac 上验证。
  • 仅在一台 M2 / macOS 27.2 上实测;Intel Mac 与更早的 macOS 未测(欢迎按"怎么参与测试"补测)。
  • ForceUnmount 在 NFS 上没有 FUSE 的 abort 写,回执里 abort=Written 表示"强制卸载助手已执行",fenced=0。
  • 上游 host 测试中有一条在 Darwin 上不可移植(按 Linux 模拟却生成 bundle),非本 PR 回归。

想请作者决定

  1. 是否接受 macOS 走"内置 NFSv3 loopback"作为官方原生路线(与 feat(macos): add native macOS support across Store, FUSE-T, Sandbox, SDK, and DSH #403 的 FUSE-T 路线互补还是替代)?
  2. kernel_guard / installer_user 在 macOS 的放行方式是否可接受,还是希望换成显式开关?
  3. layerfs-sandbox 用 deny(unsafe_code) + 一个审计模块,还是改用 libproc crate?
  4. native 命令继承调用方 PATH,还是改成显式配置项(见第 6 条)?
  5. 后台进程(operator.process)的 macOS 支持放到后续?
  6. 将来想合并时,希望按什么形状整理(一个 PR / 按层拆分)?在那之前本分支保持 Draft、只做 merge 同步。

…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/Cargo.toml (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 108,451 -> 108,451 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production).
No tests changed or deleted.

Production LOC: 108,451 -> 108,460 (delta +9)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production).
No tests changed or deleted.

Production LOC: 108,460 -> 108,505 (delta +45)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production).
No tests changed or deleted.

Production LOC: 108,505 -> 108,576 (delta +71)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production).
No tests changed or deleted.

Production LOC: 108,576 -> 108,834 (delta +258)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production).
No tests changed or deleted.

Production LOC: 108,834 -> 109,057 (delta +223)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production).
No tests changed or deleted.

Production LOC: 109,057 -> 109,166 (delta +109)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production).
No tests changed or deleted.

Production LOC: 109,166 -> 109,167 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: core/crates/layerfs-sandbox/tests/local_runtime.rs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,167 -> 109,167 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/runtime.rs (production).
No tests changed or deleted.

Production LOC: 109,167 -> 109,274 (delta +107)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/local.rs (production).
No tests changed or deleted.

Production LOC: 109,274 -> 109,527 (delta +253)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/registry.rs (production).
No tests changed or deleted.

Production LOC: 109,527 -> 109,527 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/discovery.rs (production).
No tests changed or deleted.

Production LOC: 109,527 -> 109,644 (delta +117)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/exec.rs (production).
No tests changed or deleted.

Production LOC: 109,644 -> 109,633 (delta -11)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/sandbox.rs (production).
No tests changed or deleted.

Production LOC: 109,633 -> 109,641 (delta +8)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/workspace.rs (production).
No tests changed or deleted.

Production LOC: 109,641 -> 109,636 (delta -5)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/src/lib.rs (production).
No tests changed or deleted.

Production LOC: 109,636 -> 109,638 (delta +2)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/native/tests/unit/runtime.rs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,638 -> 109,638 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/src/client.js (production).
No tests changed or deleted.

Production LOC: 109,638 -> 109,656 (delta +18)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/src/index.js (production).
No tests changed or deleted.

Production LOC: 109,656 -> 109,658 (delta +2)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/test/local_deployment.mjs (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,658 -> 109,658 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…=local, for a VM or CI box without Docker

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch).
This commit: sdk/js/README.md (not production (test, manifest or docs)).
No tests changed or deleted.

Production LOC: 109,658 -> 109,658 (delta 0)
Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68dd.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,658 -> 109,696 (delta +38)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/groups.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,696 -> 109,818 (delta +122)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,818 -> 109,835 (delta +17)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,835 -> 109,868 (delta +33)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,868 -> 109,882 (delta +14)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,882 -> 109,870 (delta -12)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,870 -> 109,871 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.
…l-AI-Lab#402)

Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch).
This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production).
No tests changed or deleted except additions.

Production LOC: 109,871 -> 109,872 (delta +1)
Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@jiangkoumo
jiangkoumo marked this pull request as draft October 24, 2026 03:39
…irectory attributes

The macOS mount adds dumbtimer,timeo=300 and mutejukebox. Against a
loopback server the round-trip estimator settles near its floor, so a
soft call failed with ETIMEDOUT after a few seconds of a slow answer: a
daemon paused for 20 s made a stat fail after 8.3 s, and with these
options it succeeded after the pause. A dead export still fails calls
within seconds (connection refused).

LOOKUP answers, found or not, carry the directory post-op attributes as
RFC 1813 allows, so the client need not ask for them.
…ral-AI-Lab#440) into feat/macos-native-on-main

Conflicts in core/tools/check_product_boundary.py and its test: kept this
branch's changes and reformatted with ruff 0.17.2 (upstream side was
format-only; AST of the result equals this branch's version).
@jiangkoumo jiangkoumo mentioned this pull request Oct 24, 2026
47 tasks
… refusing them

On NFSv3 the macOS client writes extended attributes into a `._<name>`
companion file. The export refused creating one with NFS3ERR_ACCES, which
fails every copy whose source carries an attribute. On macOS 27 that is
almost every file: anything a downloader wrote carries
com.apple.provenance, including the whole cargo registry, so a
`cargo build` with its target in a Workspace failed in the build scripts of
libsqlite3-sys and zstd-sys (std::fs::copy: Operation not permitted).

A CREATE of such a regular file now makes a companion that lives only in
the export's memory, under its own serial range (apple_double.rs). LOOKUP,
GETATTR, ACCESS, READ, WRITE, SETATTR, COMMIT, REMOVE and a RENAME to
another AppleDouble name work on it; READDIR does not list it; no mutation
of it reaches the engine, so the Workspace stays clean (unmount is not
refused as Dirty) and no Commit captures it. Companions are dropped when
the export is released. Memory is bounded: 4096 companions of at most
1 MiB, 64 MiB in all; beyond the count a creation is refused as before.
A directory, symlink, link or rename target under such a name is still
refused.
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

新提交 a700af8:macOS 上 ._*(AppleDouble)文件改为只放在导出进程内存里,不再拒绝

问题
macOS 的 NFSv3 客户端会把文件的扩展属性写进 ._<文件名>。之前导出端直接拒绝创建这类文件(NFS3ERR_ACCES),导致只要源文件带扩展属性,复制就会失败。macOS 27 下几乎所有下载来的文件都带 com.apple.provenance,包括整个 cargo registry,所以把 target 放在工作区里跑 cargo build 时,libsqlite3-sys、zstd-sys 的 build script 会在 std::fs::copy 处报 Operation not permitted。

改法(新模块 core/crates/layerfs-nfs/src/apple_double.rs)
• .* 普通文件的 CREATE 成功,但文件只存在导出进程的内存里,用独立的序号段(≥ 1<<62),不会进引擎。
• LOOKUP / GETATTR / ACCESS / READ / WRITE / SETATTR / COMMIT / REMOVE 都能正常用,也可以 RENAME 成另一个 .
* 名字;READDIR 不列出。
• 因为不进引擎,工作区不会被标成有改动(unmount 不会报 Dirty),Commit 也不会带上它;导出释放时一起丢弃。
• 有上限:最多 4096 个,单个最大 1 MiB,总共 64 MiB;超出数量仍按原来的方式拒绝。
• 目录、symlink、硬链接、把普通文件 rename 成 ._* 仍然拒绝。

验证
• Linux:cargo fmt --check、clippy -D warnings、layerfs-nfs 全部测试通过(新增 apple_double_files_stay_out_of_the_workspace,覆盖创建、读回、再次打开截断、GUARDED 冲突、READDIR 不可见、改名限制、REMOVE、Commit 隔离、释放计数)。
• Mac(M2,macOS 27.2):release 构建通过,单测全过,smoke 48/48。
• 真实场景:target 放在 LayerFS 工作区内跑 cargo build

  • 冷编译 14.8 s(target 478 MB),提交 4.4 s(Store 增加约 184 MB)
  • 从它 fork 新工作区 + 挂载 0.02 s,新工作区直接复用产物,编译 0.6 s
  • 在新工作区注入语法错误,正确报 unclosed delimiter;原工作区不受影响,仍是 Finished
  • 挂载里看不到任何 ._ 文件
    对照 git worktree(每个 worktree 独立 target):每个约 11 s 冷编译,额外占 126 MB 源码 + 约 490 MB 产物。

Replace the unconditional wait after helper KILL with a five-second
try_wait observation; retain unreaped children and report their PID.
Read helper pipes nonblockingly with capped diagnostics, and keep the
export and engine custody when a mount helper times out after spawn.

Detach local Sandbox mounts while the daemon still serves NFS. Continue
stopping after detach failure; keep Delete's checked detach as its fence.
Bound macOS force-unmount helpers too, including stderr capture.
Keep mount-table process imports macOS-only so Linux has no unused imports.

Give discovery ownership acquisition one 70-second deadline, covering a
single 60+5-second helper and return headroom. Preserve lock scope, ordering
and Busy refusal; do not release ownership around an in-flight mutation.
A real SDK lock test refuses at 60s before and succeeds after release at
62s with the new budget. Multiple queued holders can still yield Busy.

Add process-helper regression tests and an opt-in real macOS mount proof.
Mark the actual 60-second deadline test slow. No mount-option changes.
Original kernel U-state hang remains unreproduced on this Mac.

Validation: daemon/NFS/sandbox all-target tests 102 passed, 0 failed,
4 ignored; native smoke 48/48; macOS all-target Clippy including binding
and Rust formatting pass. Prior real Stop fixture failed before and passed
after. Existing product guard reports five binding inline-test violations.
Linux cross-Clippy could not build SQLite/zstd without x86_64-linux-gnu-gcc;
the cfg correction still needs native Linux confirmation. Fresh-context
read-only review of the teardown fix found no P0/P1.

Production LOC: 108058 -> 108240 (delta +182)
Scope: first-party core Rust/runtime SQL plus api Rust/JS; core 101993 ->
102175, api 6065 unchanged. Daemon 7058 -> 7157 (+99), sandbox 4476 ->
4559 (+83). Other packages unchanged; helper relocation included.
Method: tools/production_loc.py at a700af8, same counter over git archive
parent a700af8 and exact staged tree 0edfb78.
Excludes tests/docs/tooling and test-only source. Reference absent (retired
R9c; last counted 65,417 at tree 498dd19).
Register a Workspace-scoped unmount intent and exclusive flock under the
Sandbox guard, then release Sandbox ownership during the daemon exchange.
Publish the known result under the Workspace lease before shared-record
cleanup. Repeated requests read progress or the saved outcome, never resend.

Use existing Busy/Unknown refusals with Unmounting/Retained state and elapsed
milliseconds. Gate token/directory and backend reads for that Workspace while
other Workspaces and discovery reads proceed. Keep the 60+5-second helper
budget and restore the discovery readiness wait to 60 seconds.

Explicit Sandbox close may take over orphaned or retained unmounts but still
refuses unrelated active Workspaces. Preserve Stop's detach-before-termination
and Delete's checked detach. Keep known daemon success on cleanup failure and
report cleanupError separately. Nonblocking shared lease probes cannot block
one another; known-token lookup avoids scanning historical result records.

Add a real native SDK regression with paused helpers, cross-process progress,
Busy/Dirty reversibility, publication during a held discovery lock, caller
crash, saved failure, and explicit close takeover. The old binding fails its
900ms discovery-read assertion; the new binding completes unrelated calls in
milliseconds. Update lifecycle documentation and describe interface details.

Validation: daemon/NFS/sandbox all-target tests 102 passed/0 failed/4 ignored;
binding 9/9; native smoke 48/48; real concurrency fixture PASS; macOS Clippy
(all targets, including binding), fmt, JS syntax and diff checks pass.
Linux cross-Clippy blocked by missing x86_64-linux-gnu-gcc in SQLite/zstd;
existing five binding product-boundary violations remain unchanged in kind.
Fresh-context review identified two recovery issues, both fixed and verified.
No public method or daemon wire change; new detail shapes and close takeover
semantics require maintainer alignment before upstream publication.

Production LOC: 108240 -> 108557 (delta +317)
Scope: first-party core Rust/runtime SQL plus api Rust/JS; core 102175 unchanged,
api 6065 -> 6382, binding 3412 -> 3729; other packages unchanged.
Method: tools/production_loc.py over git archive first parent 4c1648e and
exact staged tree 5394d16, same counter,
excluding tests/docs/tooling and test-only source. Reference absent (retired
R9c; last counted 65,417 at tree 498dd19).
The Workspace unmount lease opened its record with a hand-written
O_NOFOLLOW: 0x100 on macOS and 0x20000 on Linux. 0x20000 is O_NOFOLLOW
only on x86_64 Linux; on aarch64 Linux O_NOFOLLOW is 0x8000 and 0x20000 is
O_LARGEFILE, so the lease would silently follow a symlink there. Take the
constant from libc (already in the lock at =0.2.189 through other crates)
so every target gets its own value.

No behavior change on macOS or x86_64 Linux. The binding's existing
discovery::no_follow() carries the same hand-written values and is left
as it is (it predates this branch).
Sandbox::stop() ended the daemon first and left the Sandbox's mounts in
place until Delete detached them. Detach them first, while the daemon can
still answer, then stop the daemon as before. A failed detach is reported
and does not block the crash teardown; Delete keeps its own checked detach
before removing anything through a mount.

On Linux FUSE this is ordering only: Stop now returns with the mounts
already gone (measured 21.6 ms with a live holder and a dirty writer; before
the change the mounts stayed until Delete), and reclaim is unchanged
(0.09-0.10 s). MNT_DETACH does not flush dirty pages, so it adds no data
guarantee; Stop stays the crash teardown it was.

Same hunk as the local-backend part of the macOS native branch's NFS
unmount-hang fix, so merging this branch there stays clean.
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

新提交(3 个,叠在 a700af8 上):修复 macOS NFS 卸载卡住时 daemon 跟着挂死、并拖住整个 Sandbox 的问题

• 4c1648e fix(native): bound NFS helper reaping and detach before daemon stop
• b3f8f80 fix(binding): release Sandbox ownership while unmount waits
• d3a2a8c fix(binding): use libc::O_NOFOLLOW for the unmount lease

问题

  1. 卸载 NFS 时调用的 umount helper 如果卡在内核等待里(SIGKILL 也杀不掉),daemon 超时 KILL 后会无条件 wait(),线程永久卡死。export.rs 只在本 PR 里,这是本 PR 引入的问题。
  2. 本地后端的 Sandbox::stop() 先停 daemon,挂载要等 Delete 才摘(backend/local/sandbox.rs,feat(sandbox): 本地进程 Sandbox 后端 LAYERFS_SANDBOX=local——在没有 Docker 的 Linux VM / CI 里使用 workspace.* #402/本 PR 引入)。
  3. Node binding 在整个控制操作期间都持有 Sandbox 的 ownership.lock(上游原有设计)。卸载一卡,同一个 Sandbox 上的 mount / close / get_log 等全部排队,60 秒后报 Busy。Docker 路线上卸载很快所以不明显,NFS 路线上会暴露出来。

改法
• helper 等待有上限:60 秒,超时 KILL 后再观察 5 秒;仍未退出就返回 Retained(TeardownCustody, stage Detach),helper 由 daemon 托管,下次调用 helper 时顺带回收。挂载 helper 超时同样返回 Retained 并保留 export。
• Stop 先 detach 挂载再停 daemon;detach 失败不阻止崩溃式回收,Delete 仍保留原来的带检查 detach。
• binding 新增 api/binding/src/unmount.rs:在 Sandbox 锁内登记 Workspace 级的卸载意图(workspace-.unmount)并拿该 Workspace 的独占 flock(workspace-.unmount-lock),然后放开 Sandbox 锁去等 daemon;完成后先在 Workspace 锁下保存结果,再重新拿 Sandbox 锁清理共享记录。观察者只用非阻塞共享 flock 探测,不会互相等。discovery 等待上限保持 60 秒。
• 锁文件用 libc::O_NOFOLLOW 打开(原先手写 0x20000 只在 x86_64 Linux 正确,aarch64 Linux 上是 O_LARGEFILE)。顺带:discovery::no_follow() 里也是同样的手写值(上游原有代码),这次没动,要不要一起改听你的。

需要你确认的三处对外行为变化(没有新方法、新错误码,daemon 协议不变)

  1. 错误 detail 新增字段:卸载进行中,同一 Workspace 的调用返回已有的 Busy,detail 带 state: "Unmounting" 和 elapsedMs;调用方崩溃或卸载失败后返回 Unknown(或原错误码),detail 带 state: "Retained"、elapsedMs、original;daemon 已成功但 SDK 清理记录失败时仍返回成功,另带 cleanupError。
  2. 重复 unmount 返回上一次的结果,不重发:上一次还在进行就返回 Unmounting;已经有结果就直接返回保存的结果。原因是卡住的卸载是否已在内核生效无法判断,重放不安全。
  3. sandbox.close 可以接管"失主"或 Retained 的卸载(发起方进程已不在),走已有的 Stop/Delete 收掉整个 Sandbox,不重放那次卸载;还有其他活着的 Workspace 时照旧返回 MountsActive。

复现
• Linux(测试机内核没有 NFSv3 客户端,用假挂载 + 不可杀 helper):daemon 环境设 LAYERFS_KERNEL=nfs、LAYERFS_NFS_MOUNT="/bin/true {dir}"、LAYERFS_NFS_UNMOUNT="/usr/bin/touch /x", 是一个先 fsfreeze -f 冻住的 16 MiB loop ext4(touch 进程是 D 态,SIGKILL 无效)。然后从 SDK 调 workspace.unmount。
修复前:不解冻就永远不返回(150 秒被 timeout 杀掉);解冻后 112–127 秒才返回;daemon 线程停在 wait4。
修复后:65.01 秒返回 Retained(Detach),detail 带 pid、KILL=Ok、"exit not observed after 5s";daemon 没有线程卡在 wait4;重复 unmount 0.05–0.09 秒返回同样结果,不起新 helper。
• macOS:api/binding/tests/unmount-concurrency.mjs(真实 SDK + 真实 NFS + 暂停的 helper)
node api/binding/tests/unmount-concurrency.mjs <隔离根目录> <layerfs.node>
旧 binding(4c1648ed2):第一个需要 Sandbox 锁的 get_log 超过 900 ms 断言失败。
新 binding:PASS。helper 卡住期间,同 Sandbox 的 get_log 1 ms,另一个 Workspace 的 status 5 ms / exec 12 ms,新建第三个 Workspace 28 ms;当前 Workspace 的调用立即返回 Busy/Unmounting;跨进程重复 unmount 不重发;Busy/Dirty 可恢复;卸载调用方 SIGKILL 后 sandbox.close 72 ms 接管成功;helper exit 1 后重复 unmount 返回保存的同一错误,close 66 ms 接管成功。

验证(头 d3a2a8c)
• Mac:cargo fmt --check 通过;cargo clippy -p layerfs-daemon -p layerfs-nfs -p layerfs-sandbox -p layerfs-node --all-targets -- -D warnings 通过;cargo test -p layerfs-node 9/9;unmount-concurrency.mjs PASS;native smoke 48/48,无挂载/daemon 残留。daemon/nfs/sandbox 全部测试(b3f8f80a5 时)102 通过/0 失败/4 忽略。
• Linux x86_64:
cargo +1.85.1 fmt --manifest-path core/Cargo.toml --all -- --check → 通过
cargo +1.85.1 clippy --manifest-path core/Cargo.toml --offline --locked -p layerfs-nfs -p layerfs-daemon -p layerfs-sandbox -p layerfs-store-service -p layerfs-overlay -p layerfs-persistence -p layerfs-node --all-targets -- -D warnings → 通过
同上加 --target aarch64-apple-darwin → 通过
同上加 --target aarch64-unknown-linux-gnu(不含 --all-targets) → 通过
cargo test -p layerfs-nfs / -p layerfs-sandbox / -p layerfs-node --all-targets → 17/17(含 helper_deadline 3 个,约 60 秒)、39/39、12/12
core/tools/check_product_boundary.py → 5 条,均为 binding 里已有的 test-only 标注,和 main 上一致,非新增

没覆盖的
• 真实 Linux NFSv3 卸载(测试机内核无 NFSv3 客户端)。
• 之前在 Mac 上遇到的一次 10–40 分钟 U 态长挂,根因没能复现确认,这组提交只是保证 daemon 不再跟着卡死。
• unmount-concurrency.mjs 不在 cargo test 里,需要 macOS 单独用 node 跑。
• helper_deadline 会让 CI 多约 60 秒(已登记到 ci_tests.py 的 SLOW)。

#402 那边只放了本地后端 "Stop 先 detach" 这一段(和这里的 hunk 完全相同),NFS helper 和 binding 放锁的部分只在本 PR。

@jiangkoumo
jiangkoumo marked this pull request as ready for review October 26, 2026 07:04
@jiangkoumo
jiangkoumo marked this pull request as draft October 26, 2026 07:04
yifanxuaaa added a commit that referenced this pull request Oct 26, 2026
Lists herdr-layerfs (jiangkoumo) at plugins/herdr-layerfs/: versions 0.2.1
through 0.3.3 (latest d2bf1a854b), all on LayerFS ad78914 (v1.1.0 plus the
#436 binding fix). Linux docker mode only, status preview; the macOS native
mode waits for #427. plugin_check.py --online prints ok. The listing matches
layerfs.ai.

Production LOC: 102162 -> 102162 (delta 0)
  First-parent comparison (main 0f7054e -> this merge). core 38241, engine
  61288, api 2633, unchanged; reference absent (retired R9c; last counted
  65,417 at tree 498dd19). Counted with tools/production_loc.py;
  listing files only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…meral-AI-Lab#402)

Upstream now keeps the Node binding's tests outside the crate source: they
load the ordinary built addon and call its exported methods, never including
private binding source (engine/AGENTS.md, engine/crates/layerfs-node/tests).
The Ephemeral-AI-Lab#402 unit tests that were included by runtime.rs and sandbox.rs
(tests/unit/{runtime,reclaim}.rs before D8) are rewritten as
engine/crates/layerfs-node/tests/native_local.rs, which writes a Sandbox
record as an earlier call would and calls the public
`runtime.sandbox.reclaim` with a local deployment:

- a local record naming any other directory (a data directory, another
  LAYERFS_LOCAL_DIR, another Sandbox or name, a relative or `..` path, `/`)
  is refused InvalidInput, "neither used nor deleted", and nothing is
  removed;
- a local record is used only with an absolute, plain deploy.localDir;
- a stopped local Sandbox where this deployment puts it is deleted and its
  number freed; a record from before boot ids is read without one; a
  damaged boot or port is refused and removes nothing;
- a Docker record is not read as a local Sandbox.

The Docker reclaim cases of the old reclaim.rs are covered by upstream's
native_discovery.rs. Selection of a misspelled deploy.sandbox is refused only
by sandbox.create after the project's Store is installed, so it is not
reachable without a Store and is left to the SDK test (local_deployment.mjs).

Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; test-only commit)
…e built addon) into PR Ephemeral-AI-Lab#427 (local port)

Brings Ephemeral-AI-Lab#402's engine/crates/layerfs-node/tests/native_local.rs. One
conflict: api/binding/tests/unit/runtime.rs, deleted on Ephemeral-AI-Lab#402 and kept by
Ephemeral-AI-Lab#427's D8 merge, is deleted here too. api/binding/ no longer exists after
D8, the file was included by nothing, and its cases move to native_local.rs
in the next commit.

Production LOC: first-parent comparison 1931032 -> this merge, see
tools/production_loc.py --root .; tests only, delta 0.
Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; formatting of a test file only)
…fter D8 (Ephemeral-AI-Lab#427)

- engine/crates/layerfs-node/tests/native_local.rs gains
  local_and_native_sandboxes_are_never_mixed: through the built addon's
  public runtime.sandbox.reclaim, a native record is refused (InvalidInput,
  "never mixed") by a local deployment and a local record by a native one,
  nothing is removed, and a native deployment reclaims its own stopped
  record. It replaces the native cases of the old included
  api/binding/tests/unit/runtime.rs (removed in the previous merge).
- The real native SDK regression of the unmount lease moves from
  api/binding/tests/unmount-concurrency.mjs (api/binding/ is gone after D8)
  to api/agent/test/verify/native_unmount_real.mjs, next to upstream's other
  *_real.mjs verifications, with its imports updated to D8's layout
  (api/agent/src, api/backend/src). It stays macOS-only and explicit.
- discovery.rs: the "never mixed" refusal lost its line continuation in the
  v1.1 port and carried a run of spaces inside the message; restored.

Production LOC: 108595 -> 108596 (delta +1)
(tools/production_loc.py --root ., core+engine+api; the restored message
continuation splits one source line in two)
…Ephemeral-AI-Lab#427)

D8 (Ephemeral-AI-Lab#465) made tools/check_product_boundary.py fail closed on any LayerFS
crate missing from its core/engine/api layer map. Ephemeral-AI-Lab#427's layerfs-nfs (the
loopback NFSv3 export, beside layerfs-fuse under engine/crates) was not in
it, so the full and the CI (--skip-sdk) runs reported
"crate missing from layer map: layerfs-nfs" for layerfs-nfs and for
layerfs-daemon, which depends on it. It is an engine crate like
layerfs-fuse; its dependencies (content, overlay, workspace, fuse) are at
or below that layer.

Production LOC: 108596 -> 108596 (delta 0)
(tools/production_loc.py --root ., core+engine+api; development tool only)
…-Lab#402)

Production LOC: 103762 -> 103762 (delta 0)
(tools/production_loc.py --root ., core+engine+api; documentation only)
…nto PR Ephemeral-AI-Lab#427 (local port)

Production LOC: 108596 -> 108596 (delta 0), first-parent comparison
(tools/production_loc.py --root ., core+engine+api; documentation only)
…b#427)

Production LOC: 108596 -> 108596 (delta 0)
(tools/production_loc.py --root ., core+engine+api; documentation only)
@jiangkoumo

Copy link
Copy Markdown
Contributor Author

同步一下:本分支已跟上 D8 三层结构(main caebd26,#465),#402 也同步更新了。

主要变化
• layerfs-nfs 整个 crate 搬到 engine/crates/layerfs-nfs,路径依赖改指 core/crates;在 tools/check_product_boundary.py 的分层表里登记为 engine 层(不登记的话完整检查和 CI 检查会各报 2 条)。
• 按 engine/AGENTS.md 的新规则,测试不再 include 私有源码:原来的 tests/unit/{reclaim,runtime}.rs 改写成 engine/crates/layerfs-node/tests/native_local.rs,通过公开的 runtime.sandbox.reclaim 走 addon 验证(#402 上 6 个用例,本 PR 7 个)。原 reclaim 里的 Docker 用例已由上游 native_discovery.rs 覆盖,就没有重复写。
• unmount 租约的真实回归测试搬到 api/agent/test/verify/native_unmount_real.mjs(D8 后原位置已不存在)。
• 生产代码只改了 1 行:恢复 discovery.rs 里 "never mixed" 报错丢掉的续行反斜杠。

验证
• Linux:fmt / clippy / 边界检查全过;node 测试 15/15;CI fast lane 1098 过,唯一 1 个失败是 concurrent_first_installs,在干净 main 上交替跑 50 次同样失败 4 次,属于原有的偶发问题;local smoke 48/48。
• macOS(M2,macOS 27.2):release 构建通过;nfs/sandbox/store-service 单测 53/53;clippy -D warnings 0 警告;native smoke 48/48、pathcheck 11/11、unmount 租约回归通过,无残留挂载或进程。

有一处想请教:deploy.sandbox 拼错(例如 Local)时的拒绝,在 binding 里只有带 Store 的 sandbox.create 才会检查,addon 测试不带 Store 走不到,目前只由 SDK 的 local_deployment.mjs 覆盖。这样是否可以,还是希望在 binding 这层补一个测试?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants