Skip to content

fix(daemon): 原生 NFS 卸载超时后回收 helper 并完成 teardown(layerfs-project#136) - #644

Open
jiangkoumo wants to merge 17 commits into
Ephemeral-AI-Lab:layerfs_v2_0_0from
jiangkoumo:fix/136-native-unmount-reap
Open

jiangkoumo wants to merge 17 commits into
Ephemeral-AI-Lab:layerfs_v2_0_0from
jiangkoumo:fix/136-native-unmount-reap

Conversation

@jiangkoumo

@jiangkoumo jiangkoumo commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Tier: P1
Risks: risk:kernel risk:security risk:format
Docs: docs/architecture/api/sdk/workspace.md, docs/architecture/control/sandboxes/daemon-channel-and-exec.md, docs/architecture/engine/daemon/control-calls.md, docs/architecture/engine/kernel-routes/nfs.md

原生 NFS 卸载:超时 helper 不再变僵尸,失败/惰性卸载不再误判为已卸完

修的是什么

原生(NFS)Workspace 的 unmount 用宿主机的 mount helper 卸载。helper 卡在
内核里超时后,旧代码会同步 wait(),daemon 的 teardown 就一直挂着;helper
变成僵尸。另一个方向的错误同样致命:Linux 的强制 helper 是 umount -f -l,
惰性 detach 之后挂载从 mountinfo 消失,但内核还拿着 superblock——旧代码只看
「表里没了」,于是停 NFS server、关 Workspace,把仍在被内核持有的挂载丢在
后面。

这个 PR:

  • helper 超过 HELPER_WAIT(60 s)被 KILL;KILL 后 REAP_WAIT(5 s)内仍
    观察不到退出就交给 retained queue,由一个具名后台线程轮询回收并记录退出
    (共享给 macOS driver 的 umount -f)。不再同步等待、不重复发信号;队列的
    界是一个线程和 64 条退出记录,不是时间。try_wait 出错时子进程留在队列里
    继续观察,连续 25 次(约 0.5 s)仍失败才记录一条 exit unobservable 放弃。
  • helper 失败/超时后,只把「Attach 时确实见过它在挂载表里」+「这个 helper 不会
    惰性卸载」+「现在表里没有」三条同时成立当作已卸载。Linux 的 umount -f -l
    失败后即使表里已消失也保持 Retained(Detach),export 继续服务。macOS 与普通
    helper 仍以表为证。
  • 强制卸载:客户端只放行「daemon 明确回了 Retained,且 stage 是 Detach」的
    记录;Busy/Dirty/未发送的调用会原样恢复上一条记录。daemon 侧的闸门不变。
  • 卸载记录写入版本:v: 1,在版本 0 的 state/startedMs/path/code/
    message/detail 之后追加 forced 与结果里的 stage。

旧记录怎么处理(以代码为准)

  • 版本 0 记录(没有 v/forced/stage,即本 PR 之前写下的记录)继续按字段名
    读取、继续按原样回答(Retained + 原 code/message);但不可强制,因为它
    没有 daemon 回的 stage。PR 早期正文里「旧记录当作没强制过」的说法不准确,以
    这条为准。
  • 版本 1 记录被旧版本读取时,旧 reader 只看它认识的六个字段,值与旧版本写入的
    完全一致(多出的字段被忽略),因此旧版本读新记录是「读对」。
  • 版本号高于 1 的记录:按原样回答、never forceable——本版本不执行唯一可能破坏
    未知 custody 的那一步。

测试

  • fast 1987/1987、slow 155/155、mount 45 个二进制 0 失败;PG lane 25/6/1/2/2/3
    全过(最终提交 89116b177)。

  • 新增:a_lazily_detached_force_helper_never_completes_teardown、
    a_record_force_cannot_replace_keeps_its_answer、
    a_retained_answer_carries_its_stage_into_the_record、
    a_version_zero_record_is_answered_and_never_forced、
    a_current_record_answers_every_version_zero_field、
    a_newer_record_is_answered_and_not_forced;record_codec 增加无 stage、版本 0、
    版本 2 三个用例。每个新测试都做过反证(改回旧行为确认失败,再还原)。

  • macOS 真挂载(M2 Air,macOS 27.2,最终头 89116b177,Disposable SQLite Store,用
    LAYERFS_NFS_UNMOUNT 包装脚本模拟慢卸载):

    场景 结果
    S1b 卸载已完成但 helper 超时 rc=0,Unmounted(60.05 s),之后 NotMounted
    S1a 60 s 超时、70 s 后挂载才消失,再 --force 先 rc=125 Retained/stage: Detach;--force rc=0 ForceUnmounted,之后 NotMounted
    S2 卸载失败、挂载仍在,再 --force 先 rc=125 Retained/stage: Detach、挂载保留;--force rc=0 ForceUnmounted,挂载已卸
    S3 正常卸载 rc=0,0.03 s,写读一致

    各场景 daemon 下均无僵尸子进程。卸载记录解码后:第一次卸载 v: 1、stage: Detach、
    forced: false,--force 后 forced: true。

  • macOS 目标 cargo clippy --workspace --all-targets -- -D warnings:通过,0 告警。

  • NOT_RUN:遍历中卸载(S4);Durable(owner 关闭)。

…rdown

When an NFS unmount helper exceeds its wait budget and remains in kernel wait past the post-KILL grace window, start a bounded background reaper thread on Helpers (and darwin force_unmount) that polls try_wait() until the retained queue empties and records the observed exit status instead of leaving a zombie child until the next helper launch.

Verify the mount table after a failed or interrupted unmount/force helper when the mount was tracked at Attach: if the mount has already disappeared from the mount table, proceed with export teardown (stopping and joining the NFS Server, releasing Kernel holdings, revoking the engine mount, and closing the Workspace) rather than unconditionally returning Retained(Detach). Keep conservative Retained(Detach) custody when the mount remains present, and allow explicit ForceUnmount to complete teardown of an unforced ExportKept(Detach) entry while reporting real Server loop exit and join counts.

Ref: layerfs-project#136

Production LOC: 156,309 -> 156,485 (delta +176)
Method: python3 tools/production_loc.py (58c548b -> HEAD)
Subtotals: core 39,749 -> 39,749 (delta 0); engine 73,079 -> 73,215 (delta +136); control 38,511 -> 38,551 (delta +40); api 4,970 -> 4,970 (delta 0); reference absent (retired R9c; last counted 65,417 at tree 498dd19); dsh 0 -> 0 (delta 0).
…eable rules

Address review findings M1, M2, and M3 on native NFS unmount teardown:
- M1: check /sbin/mount exit status in mounted_under on macOS and treat only NotFound as an empty table file so mount-listing failures never falsely mark a still-mounted Workspace as detached.
- M2: match both literal and canonicalized paths in mounted_under and ExportServing::is_mounted so symlinked macOS mount roots (/var, /tmp -> /private/var, /private/tmp) are tracked and reconciled accurately.
- M3: record forced in unmount_guard::begin and inspect, and restrict unmount_guard::forceable to completed non-forced Failed records so crashed or Unknown unmounts are never re-sent as ForceUnmount.

Ref: layerfs-project#136

Production LOC: 156,485 -> 156,510 (delta +25)
Method: python3 tools/production_loc.py (38af9c0 -> HEAD)
Subtotals: core 39,749 -> 39,749 (delta 0); engine 73,215 -> 73,231 (delta +16); control 38,551 -> 38,560 (delta +9); api 4,970 -> 4,970 (delta 0); reference absent (retired R9c; last counted 65,417 at tree 498dd19); dsh 0 -> 0 (delta 0).
@jiangkoumo
jiangkoumo marked this pull request as ready for review November 10, 2026 08:30
Copilot AI balanced review requested due to automatic review settings November 10, 2026 08:30

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. Try requesting a review at a lower effort level.

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.

@yifanxuaaa

Copy link
Copy Markdown
Contributor

Review (static, at 7e501eb). Request changes. The custody design is right: teardown after a failed or timed-out helper requires that Attach saw the mount (tracked), that the table is readable now, and that the mount is gone. Otherwise the record stays Retained(Detach). Helper reaping is pid-safe, and the forced unmount is an explicit single step, not a retry. One hole needs closing.

Blocking

  1. ExportServing::is_mounted (control/export.rs:185-191) canonicalizes the mount point itself, which looks up the path through the live, possibly wedged NFS mount.
    • Hang: against a hung client, that lookup can block the unmount call with no bound.
    • Wrong answer: if it errors (EIO, ETIMEDOUT), real is None and only the literal path is compared. With the mount home under /var or /tmp, the literal /var/... never matches the table's /private/var/.... is_mounted then returns Ok(false) and detached_export tears down the server and Workspace while the kernel still holds the mount.
    • Fix: canonicalize the mount home (as mounted_under does) and join the namespace name, or store the exact table path matched at Attach and compare only against it. Add a test for the still-mounted canonical case; the current test covers only "table empty, so gone".

To fix
2. Docs not updated: docs/architecture/engine/daemon/forced-teardown.md, docs/architecture/api/sdk/workspace/unmount.md (still says force works only after a Join stall) and kernel-routes/nfs.md. The comment at force.rs:53-55 still calls the Join stall "the one exception".

Smaller

  • unmount_guard::forceable (unmount_guard.rs:231) accepts any unforced Failed record, including send or connect failures. The daemon remains the real gate, but begin() overwrites the earlier record, and a Busy or Dirty answer to the force deletes it, losing the original "do not resend" record.
  • The description says old records without forced are treated as unforced. That applies only to in-progress Unknown records; a completed pre-PR Retained record is not forceable. That is conservative, but the claim is wrong.
  • Every native Attach now spawns /sbin/mount and canonicalizes through the fresh mount (export.rs:541). Only unmount timing is reported.
  • Test hooks (mount_table, reap_wait=ZERO, reaped_helpers()) sit in the production config undocumented. The reaper loop is duplicated in darwin.rs.

Merges cleanly on the current base, but conflicts with #636 in control/export.rs at reclaim().

First-parent comparison. Production LOC: 156510 -> 160178 (delta +3668), first parent 7e501eb, merge tree 7f6a8084; counter tools/production_loc.py at the merge tree, combined scope, all four layers plus api/ front ends.
The bounded background reaper that polls a helper child past its KILL and
post-KILL grace window existed twice: in the daemon's NFS `Helpers` and,
with its own statics and its own copy of the loop, in the macOS driver's
`umount -f`. Move the queue, the polling thread and the record of observed
exits into `layerfs_bridge::process::Retained`, one implementation both
callers hold. Semantics are unchanged: nothing waits for a retained child,
nothing signals it twice, and at most 64 observed exits are kept.

`Helpers` keeps its own deadlines and its `reap`/`reaped` accessors over the
shared queue; the driver keeps only its `umount -f` deadline, stderr drain
and mount re-check.

Production LOC: 160178 -> 160198 (delta +20)
Method: python3 tools/production_loc.py, combined scope, first parent 09f9e4b and this commit's staged tree; core 39787 -> 39787 (0), engine 74571 -> 74618 (+47), control 39844 -> 39817 (-27), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
`ExportServing::is_mounted` canonicalized the mount directory to compare it
with the host's table. That lookup goes through the mount itself, so against
a wedged NFS client it can block an unmount with no bound, and it fails
(EIO, ETIMEDOUT) exactly while the mount is in trouble: the literal
`/var/...` then never matches the table's `/private/var/...`, `is_mounted`
answered `Ok(false)`, and `detached_export` tore down the export and the
Workspace while the kernel still held the mount.

Resolve only the mount home, which the daemon owns, and compare the entry
name below it (`export_helper::listed`); the configured path is still
compared as it is. The mount table itself is unchanged: one read per check,
an unreadable table still keeping `Retained(Detach)`.

Test: `a_listed_canonical_mount_stays_retained_when_the_mount_point_does_not_resolve`
(a symlinked mount home like macOS `/var` -> `/private/var`, the mount point
replaced by a dangling link, the table still listing the resolved point).
It fails before this change: the reply was `Unmounted` and the entry was torn
down while the table still listed the mount.

Production LOC: 160198 -> 160207 (delta +9)
Method: python3 tools/production_loc.py, combined scope, first parent 03204bb and this commit's staged tree; core 39787 -> 39787 (0), engine 74618 -> 74627 (+9), control 39817 -> 39817 (0), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
A forced attempt admitted over a completed `Failed` record wrote its own
`Unmounting` record, and a `Busy` or `Dirty` answer to that force deleted the
file: the earlier record - possibly one whose answer is "do not resend" - was
lost with the attempt that changed nothing.

`begin` now keeps the record it replaces, and `restore` puts it back exactly
when an attempt has no effect of its own: a `Busy` or `Dirty` finish, and a
call that sent nothing (`cancel`). With no earlier record both cases still
remove the file, as before.

The forceable rule is unchanged and now says what it is: a completed
`Failed` record without `forced: true`, because the daemon's own guard is the
real gate; a record that is still `Unmounting`, one of unknown outcome and one
an earlier force produced keep their answer. A record written before `forced`
was stored has no such field, so it is answered as it stands and is not
forceable - the description claimed otherwise and is corrected.

Tests (`tests/unmount_record.rs`): a busy force, a dirty force and a call that
sent nothing each leave the earlier record as it was, still forceable; a first
attempt that does not run still leaves no record.

Production LOC: 160207 -> 160215 (delta +8)
Method: python3 tools/production_loc.py, combined scope, first parent 20b4faf and this commit's staged tree; core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39817 -> 39825 (+8), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
The native route's forced teardown and the mount-table checks the review
asked for were only in source. Document them where the guides already are:

- `engine/daemon/forced-teardown.md`: a "Native NFS route" section - the one
  forced client unmount standing in for the abort write, the two shapes of
  `Retained(Detach)` and when the mount counts as gone, why the mount point is
  never resolved, and the shared retained queue; the guard step 1 and the
  rule table name the forceable export custody; the test-only hooks
  (`mount_table`, `reap_wait`, `reaped_helpers`) are named as test-only.
- `engine/daemon/control-calls.md`: the NFS route's Attach (table read once),
  Unmount (failure settled by the table) and Helper run rows.
- `engine/kernel-routes/nfs.md`: the Attach-state read and its one extra
  `/sbin/mount` run (or `/proc/self/mountinfo` read) per Attach, the mount
  point never being resolved, the failure/deadline rule and the retained
  queue.
- `api/sdk/workspace/unmount.md`: `force` also finishes an entry kept at
  Detach on the native NFS route; the recorded-outcome rule says which record
  a force may replace and that an older record without `forced` is not
  forceable.
- `force.rs`: the guard comment names both exceptions to "stored custody
  answers first", and the module header names the NFS abort.

Production LOC: 160215 -> 160215 (delta 0); comments and docs only.
Method: python3 tools/production_loc.py, combined scope, first parent 523462a and this commit's staged tree; core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39825 -> 39825 (0), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
…ding

Say which record a forced unmount may replace in one sentence, give the
native route's Alpine flow one witness per step, and cite each mount-rule
row's source consistently.

Production LOC: 160215 -> 160215 (delta 0); docs only.
Method: python3 tools/production_loc.py, combined scope, first parent a7ca6c9 and this commit's staged tree; core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39825 -> 39825 (0), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
Accept upstream deletions of docs/architecture/api/sdk/workspace/unmount.md and docs/architecture/engine/daemon/forced-teardown.md, and take upstream's reorganized structure in control-calls.md and kernel-routes/nfs.md before porting Ephemeral-AI-Lab#644 doc updates.

First-parent comparison. Production LOC: 160215 -> 160215 (delta 0); core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39825 -> 39825 (0), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).
Method: python3 tools/production_loc.py, combined scope, first parent 6dc8a81 and merge tree.

Ref: layerfs-project#136
…yout

Port the Ephemeral-AI-Lab#644 native NFS mount-table, retained helper queue, ExportKept force, and unmount_guard record-retention updates into the reorganized upstream architecture docs:

- engine/daemon/control-calls.md: Export table (Attach mount-table read, Unmount mount-table settlement, ForceUnmount for Detach-kept exports, Helper run retained queue), Force diagram step 1, and Force rules (ExportKept::forceable, Retained(Detach) custody shapes, listed without resolving mount point, shared retained queue, test-only ExportServingConfig/reaped_helpers hooks).
- engine/kernel-routes/nfs.md: Mount diagram and rules (Attach mount-table read and cost, listed without resolving mount point, Unmount/ForceUnmount settlement, shared retained queue, link to Control calls Force).
- api/sdk/workspace.md: workspace.unmount force option on the native NFS route, and recorded-outcome replacement/restore rules for workspace/<id>.unmount.

Docs: updated Daemon, Kernel routes (NFS), and SDK workspace topics.
Production LOC: 160215 -> 160215 (delta 0); docs only.
Method: python3 tools/production_loc.py, combined scope, first parent 9a9012e and this commit's staged tree; core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39825 -> 39825 (0), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
Address the two optional P3 items from the Ephemeral-AI-Lab#644 re-review:

- unmount_guard.rs: remove the contradictory "forced or not" phrase from the
  forceable() comment (completed Failed records with forced == false only).
- darwin.rs: call RETAINED.record(pid, status) in the 5 s post-KILL
  synchronous reap branch, matching the background queue branch and
  daemon export_helper.rs.

Docs: no architecture impact (comment wording and in-memory diagnostic exit record alignment).
Production LOC: 160215 -> 160218 (delta +3)
Method: python3 tools/production_loc.py, combined scope, first parent bbd15c3 and this commit's staged tree; core 39787 -> 39787 (0), engine 74627 -> 74627 (0), control 39825 -> 39828 (+3), api 5976 -> 5976 (0), reference absent (retired R9c; last counted 65,417 at tree 498dd19), dsh 0 -> 0 (0).

Ref: layerfs-project#136
@jiangkoumo

Copy link
Copy Markdown
Collaborator Author

更新到 b49690d:

  • 合入最新 layerfs_v2_0_0(de1d77e13)。冲突只在文档:2.0 docs: delete most docs and reorganize by layer (core, engine, control, api) #671 删掉的 workspace/unmount.md 和 daemon/forced-teardown.md 已接受删除,本 PR 的说明移植到新结构的 api/sdk/workspace.md、engine/daemon/control-calls.md(Export 与 Force 两节)和 engine/kernel-routes/nfs.md(Mount 一节)。
  • 复审的两处小问题:unmount_guard.rs 里 forceable 的注释去掉了与实际规则不符的 "forced or not";macOS darwin.rs 里 KILL 后 5 秒内同步收割到的分支也调用 RETAINED.record,和后台队列及 daemon 侧 export_helper.rs 一致。

验证(Linux,cargo +1.85.1 --locked,head b49690d):

  • fast 1901/1901,slow 150/150,mount 二进制 45 过 0 失败
  • PG:persistence 25、store-service 6、project 1、api 2、cli 2、control 3,全过
  • macOS:clippy --all-targets -D warnings(sandbox-backend、sandbox、bridge、daemon、nfs)通过
  • fmt --check 只剩上游基线 api/crates/layerfs-cli/tests/concurrent_reads.rs:16

未跑:macOS 上的实际挂载卸载复测。

@yifanxuaaa yifanxuaaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review record

  • Head reviewed: b49690d119 (merge-base de1d77e130).
  • Tier: P1. Risks: risk:kernel (layerfs-nfs/src/server.rs) and risk:security (layerfs-bridge/src/process/retained.rs is under process/**). I would add risk:format by hand for the new persisted forced field in the unmount record. Gate: yes.
  • Read: the whole diff. Re-run: tools/check_product_boundary.py PASS; tools/production_loc.py gives 159977 -> 160218 (+241). Not run: cargo, mount tests.
  • Since then, layerfs_v2_0_0 has gained #642 and #647. This branch now conflicts with it in docs/architecture/engine/daemon/control-calls.md and docs/architecture/engine/kernel-routes/nfs.md.

The macOS reasoning holds. XNU removes a mount from the mount list only after VFS_UNMOUNT succeeds, and only mounts that Attach saw (tracked) qualify. Two changes reach beyond macOS, though:

1. A lazy unmount on Linux can be taken as finished (blocking)

control/export.rs:128: the Linux force helper is umount -f -l. A lazy detach removes the mount from mountinfo while the kernel still holds the superblock, and the Linux mount is hard (no soft).

detached_export (export.rs:717) runs after a failed or timed-out force (:888) and after a failed normal unmount (:703). It treats "absent from the table" as proof, then stops the NFS server and closes the Workspace. Nothing restricts this to macOS, and tracked is set on every platform (:561).

Scenario: on Linux NFS, umount -f -l times out after the lazy detach. The table no longer lists the mount, and teardown runs. Clients still hold the superblock and send RPCs to a server that is gone, so they hang, or reach a new export that reuses the port.

Fix: trust the table only where the unmount is not lazy. That means macOS, or the normal helper only, never the Linux -l force. Please add a test of the refusal on that path.

2. The force bypass is wider than Retained(Detach) (blocking)

unmount_guard::forceable (control/crates/layerfs-sandbox/src/unmount_guard.rs:234), used at workspace/serve.rs:253,261, now admits any finished, unforced Retained/Failed record. That applies on every kernel route.

  • On Linux FUSE, the Join-stall exception in force.rs becomes reachable from control after a failed normal unmount. On the base it was not.
  • Failed also covers control-side failures, for example a send-phase exchange error, where the daemon never answered. If the daemon no longer holds the Workspace, a later force gets Missing, which maps to Unmounted, and the Workspace record is deleted: success reported without evidence.

Fix: persist the stage, and admit a force only for a record whose daemon reply was Retained custody at Detach. Alternatively, state and test the Linux FUSE behaviour explicitly.

3. Record compatibility for forced

The unmount record gains a persisted field. Please state the record version, and show that an older release reads a newer record correctly or refuses it cleanly. The PR body says old records without the field count as unforced. The code (unmount_guard.rs:238-242) and the commit say they are not forceable. Please fix the body, and add a test for an old record.

Smaller points

  • Reaper: retained.rs bounds threads, not time. A child that never leaves the kernel is polled every 20 ms for the life of the daemon. On a try_wait error the Child is dropped without being reaped. Please drop the "bounded" wording or add a deadline.
  • Attach cost: every native Attach now runs /sbin/mount once (export.rs:561). Mount speed per tool call is a stated priority; please report the added Attach time, or a count.
  • Unfinishable record: an Attach-failure ExportKept has tracked: false. It is forceable, but a failed umount -f re-keeps it with forced set, permanently. This is not a regression; worth a note.
  • File size: export.rs is 974 lines, 25 under the 999 ceiling.

Policy

The PR body has no Tier: line and no Docs: line. The macOS mount proof was run at 7e501eb28, not at the head. The Linux evidence is test suites only, with no run of the Linux NFS route.

Reviewer: Claude Opus 5.5 (independent-context review, findings spot-checked against the code). Requesting changes for 1 and 2.

Brings the Ephemeral-AI-Lab#136 helper reaping work onto upstream after the reviewer's
first pass: Ephemeral-AI-Lab#656 force-stop of machines and native Sandboxes, Ephemeral-AI-Lab#647 macOS
export-volume flush and exact Busy, Ephemeral-AI-Lab#682 image-loaded Sandboxes, Ephemeral-AI-Lab#681 GC
design, Ephemeral-AI-Lab#642 one-read NFS records.

Conflicts were documentation only, and are resolved into upstream's
structure:
- engine/daemon/control-calls.md: upstream's macOS `sync_volume_np` flush
  row kept; this branch's Attach (mount table read once, `tracked`),
  Unmount (mount-table settlement), ForceUnmount (finishing an export kept
  at Detach) and Helper run (retained queue) rows merged into upstream's
  table.
- engine/kernel-routes/nfs.md: upstream's Commit line kept inside this
  branch's Attach/Unmount/ForceUnmount diagram.
Code merged without conflicts; Ephemeral-AI-Lab#656 touches Sandbox/machine force-stop and
the Store lane, not the NFS export teardown, so the two do not overlap in
the data path. `cargo +1.85.1 check --locked --all-targets` on the changed
crates passes at this tree.

Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 160218 -> 163746 (delta +3528); first parent b49690d.
  core 39787 -> 39787, engine 74627 -> 75078, control 39828 -> 42804,
  api 5976 -> 6077. Reference absent (retired R9c; last counted 65,417 at
  tree 498dd19). Method: python3 tools/production_loc.py --root
  <git archive of b49690d> and --root <the merged working tree>.
Review item 1 (blocking): on Linux the forced helper is `umount -f -l`.
Its lazy detach removes the mount from mountinfo while the kernel still
holds the superblock, so a vanished table entry never proves the client's
mount ended; `detached_export` treated it as done, then stopped the NFS
server and closed the Workspace.

- `Helper::lazy()` reads the helper's own words (`-l`, `--lazy`, or a
  combined short word such as `-fl`).
- `detached_export` now requires three things: Attach saw the mount listed
  (`tracked`), the helper cannot detach lazily, and the table does not list
  the mount now. A failed or timed-out lazy helper keeps `Retained(Detach)`
  with the export still serving, on Unmount and on ForceUnmount.
- The ordinary helper (no `-l`) and macOS keep the table as witness, so the
  existing exit-and-table tests are unchanged.
- Docs: control-calls.md (Force and Unmount rules) and core nfs.md state
  that a lazy detach is never proof.

Test: `a_lazily_detached_force_helper_never_completes_teardown`
(engine/crates/layerfs-nfs/tests/helper_deadline.rs): the forced helper
removes the table entry, fails, and carries `-l`; ForceUnmount must answer
`Retained(Detach)` with `detach: Failed`, keep the Workspace `Retained`, the
mount directory and the export, and release nothing. Reversed (lazy guard
removed) the same test fails: ForceUnmount answers `ForceUnmounted` with
`detach: Detached` and `cleanup: Queued`.

Checks: `cargo +1.85.1 test --locked -p layerfs-nfs --test helper_deadline`
8 passed, 0 failed.
Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 163746 -> 163755 (delta +9); parent 8bc995f.
  core 39787, engine 75078 -> 75087, control 42804, api 6077. Reference
  absent (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root .
Review item 2 (blocking): `unmount_guard::forceable` admitted every
completed, never-forced `Retained`/`Failed` record on every platform. That
made two wrong records forceable: a failed or lost control exchange (code
`Failed`, never answered by the daemon) where a later `force` that gets
`Missing` is taken as unmounted and deletes the Workspace record; and on
Linux FUSE any failed plain unmount, which reaches the daemon's Join-stall
exception again although the plain unmount already made its one abort.

- The daemon's `Retained` reply now travels with the custody's stage:
  `lifecycle::operation` maps it to code `Failed` (unchanged) with
  `detail.stage` and `detail.detached` (`daemon/values.rs`).
- `unmount_guard::finish` persists that stage in the record as `stage`
  (`Null` when the failure never got a daemon answer), and `forceable`
  admits only `state: Retained`, `forced: false`, `code: Failed`,
  `stage: Detach` — an NFS export still held, where the one forced client
  unmount is what finishes it. Every other stage, a staged-less failure, an
  `Unmounting`/`Unknown` record and an already-forced record keep their
  answer.
- Docs: api/sdk/workspace.md states the narrowed rule and what a `Failed`
  answer without a stage is.

Tests:
- `a_record_force_cannot_replace_keeps_its_answer` (unmount_record.rs):
  `Join`, `Revoke`, `Close`, `Abort` and a record without `stage` are not
  forceable and repeat their `Retained`/`Failed` answer.
- `a_retained_answer_carries_its_stage_into_the_record` (unmount_record.rs):
  a `Retained(Detach)` cause becomes code `Failed` with `detail.stage:
  Detach`, and the written record keeps that stage while a `Join` one is
  not forceable.
- record_codec.rs: the forceable fixture carries `stage: Detach`; a new
  case proves the same record without a stage is not forceable.
Reverse checks (each run, then reverted): with the `stage` condition removed
from `forceable`, `a_record_force_cannot_replace_keeps_its_answer` fails at
the `Join` case (`stage: Join` is forceable) and record_codec fails at the
staged-less case; with the `Retained` branch removed from `values::refusal`,
`a_retained_answer_carries_its_stage_into_the_record` fails with
`left: None, right: Some("Detach")`.

Checks: `cargo +1.85.1 test --locked -p layerfs-sandbox --test unmount_record
--test record_codec`: 6 passed and 5 passed, 0 failed.
Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 163755 -> 163788 (delta +33); parent e710662.
  core 39787, engine 75087, control 42804 -> 42837, api 6077. Reference
  absent (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root .
…ll reads

Review item 3: the unmount record gained a persisted field (`forced`) with
no stated version and no compatibility evidence, and the PR body claimed
pre-`forced` records were treated as unforced while the code answered them
as not forceable.

- The record now names its version: `v: 1`, written with every attempt and
  every published result. Version 0 (no `v`) is the format before Ephemeral-AI-Lab#644:
  `state`, `startedMs`, `path`, `code`, `message`, `detail`. Version 1 adds
  `forced` and, on a result, `stage`. The module doc in `unmount_guard.rs`
  states the format and the rules.
- Compatibility, both directions:
  - A version-0 record is read as before and answered as before; it is not
    forceable (it has no daemon `stage`), which is what the code did and
    what the docs now say.
  - A version-1 record keeps every version-0 field with the values a
    version-0 writer wrote; the added fields are ignored by an older reader
    (all reads are by field name), and the repeated answer is identical.
  - A record from a newer version (`v` above 1) is answered as it stands
    and is never forceable: this build does not take the only step that
    could destroy unknown custody.
- `forceable` requires `v == 1`; the `Unmounting` record written by
  `begin_at` and the crashed-caller record `inspect` synthesizes carry `v`.
- Docs: api/sdk/workspace.md names the record version and the version-0
  field set.

Tests (control/crates/layerfs-sandbox/tests/unmount_record.rs):
- `a_version_zero_record_is_answered_and_never_forced`: a record without
  `v`/`forced`/`stage` is found by `find_in`, is not forceable, and repeats
  its own `Failed` message unchanged.
- `a_current_record_answers_every_version_zero_field`: a written version-1
  record answers `state`, `startedMs`, `path`, `code`, `message` and
  `detail` exactly as a version-0 reader reads them, and repeats the same
  answer when only those fields are kept.
- `a_newer_record_is_answered_and_not_forced`: a `v: 2` record with a
  `Detach` stage is answered and not forceable.
- record_codec.rs adds the version-0 and version-2 read cases; every raw
  fixture now names `v: 1` so each case tests only its own condition.
Reverse checks (each run, then reverted): without the `v` write in `finish`
and the `v == 1` condition, `a_current_record_answers_every_version_zero_field`
fails with `left: None, right: Some(1)` and
`a_newer_record_is_answered_and_not_forced` fails (a `v: 2` record becomes
forceable); with `find_in` skipping records that have no `v`,
`a_version_zero_record_is_answered_and_never_forced` fails at
`find_in(...).expect("version-0 record")`.

Checks: `cargo +1.85.1 test --locked -p layerfs-sandbox --test unmount_record
--test record_codec`: 9 passed and 5 passed, 0 failed.
Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 163788 -> 163793 (delta +5); parent 6f3cf77.
  core 39787, engine 75087, control 42837 -> 42842, api 6077. Reference
  absent (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root .
…he child on a failed wait

Review item 4 (smaller items):

- Retained children (`process/retained.rs`): the queue is bounded in threads
  and recorded exits, not in time. The docs now say so: one background
  thread polls every 20 ms for as long as custody lasts, because a deadline
  would drop the only handle that can reap the child. A failed `try_wait`
  no longer drops the child unreaped: it stays queued so its exit is still
  observed, and after 25 consecutive failed waits (~0.5 s) the entry is
  given up with one recorded `exit unobservable after ...` line instead of
  polling a wait that cannot work. Export helper and control-calls wording
  updated to match.
- The extra `/sbin/mount` per native Attach is stated in the report: it runs
  on macOS only (Linux reads `/proc/self/mountinfo`), once after the mount
  helper returns, plus once per failed/timed-out unmount-helper settlement
  and per ForceUnmount; Attach adds exactly one process run.
- Custody an interrupted Attach left (`ExportKept` with `ready: None`, its
  export `tracked: false`) is documented as never forceable and never
  unmounted: no receipt was published, so there is no mount to force off by
  name; it stays retained until the daemon exits and the next start's
  `reclaim` forces the leftover host mount off. Pre-existing behaviour, now
  written down on `ExportKept::forceable`.
- `export.rs` was over the 999-line ceiling (1002) after the review fixes.
  The kernel-half custody types (`Exported`, `ExportLeaving`, `ExportKept`,
  their `forceable`/`custody` and `Debug` impls) move to the new
  `control/export_custody.rs`; `export.rs` re-exports them under their old
  paths and is 938 lines. No logic change; `mod.rs` declares the module.
- Docs: control-calls.md helper rule names the real bound and links
  `export_custody.rs`; nfs.md links `Exported::tracked` there.

Checks: `cargo +1.85.1 clippy --locked -p layerfs-daemon -p layerfs-bridge
-p layerfs-nfs -p layerfs-sandbox --all-targets -- -D warnings` clean;
`cargo +1.85.1 test --locked -p layerfs-nfs --test helper_deadline` 8 passed;
`-p layerfs-bridge --test process_identity` 2 passed. No new test here: the
reaper's exit path is covered by `wait_reaped`/`assert_not_zombie` in
helper_deadline, and the `try_wait` error path cannot be provoked without
fault injection; the error branch is exercised only by inspection.
Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 163793 -> 163827 (delta +34); parent 3468655.
  core 39787, engine 75092 -> 75126, control 42842, api 6077. Reference
  absent (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root .
The sandbox daemon-channel table still folded a stopped terminal step
(`Reply::Retained`) into the transport-failure row, which says `Failed`
carries a phase and `attempted`. Since the unmount record keeps the
custody's stage, a `Retained` reply is now its own row: code `Failed` with
`detail.stage` and `detail.detached`, the stage the machine persists, and
only `Detach` forceable. Found by the doc grep for stale wording after the
review fixes; no code or behaviour change.

Checks: docs only; `git diff` reviewed.
Durable: NOT_RUN - disabled by owner until explicit reauthorization.
Production LOC: 163827 -> 163827 (delta 0); parent f148f20.
  core 39787, engine 75126, control 42842, api 6077. Reference absent
  (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root .
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.

3 participants