Repository navigation
fix(daemon): 原生 NFS 卸载超时后回收 helper 并完成 teardown(layerfs-project#136) - #644
jiangkoumo wants to merge 17 commits into
Conversation
…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).
There was a problem hiding this comment.
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.
|
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 ( Blocking
To fix Smaller
Merges cleanly on the current base, but conflicts with #636 in |
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
|
更新到 b49690d:
验证(Linux,
未跑:macOS 上的实际挂载卸载复测。 |
yifanxuaaa
left a comment
There was a problem hiding this comment.
Review record
- Head reviewed:
b49690d119(merge-basede1d77e130). - Tier: P1. Risks:
risk:kernel(layerfs-nfs/src/server.rs) andrisk:security(layerfs-bridge/src/process/retained.rsis underprocess/**). I would addrisk:formatby hand for the new persistedforcedfield in the unmount record. Gate: yes. - Read: the whole diff. Re-run:
tools/check_product_boundary.pyPASS;tools/production_loc.pygives 159977 -> 160218 (+241). Not run: cargo, mount tests. - Since then,
layerfs_v2_0_0has gained #642 and #647. This branch now conflicts with it indocs/architecture/engine/daemon/control-calls.mdanddocs/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.rsbecomes reachable from control after a failed normal unmount. On the base it was not. Failedalso 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 getsMissing, which maps toUnmounted, 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.rsbounds threads, not time. A child that never leaves the kernel is polled every 20 ms for the life of the daemon. On atry_waiterror theChildis dropped without being reaped. Please drop the "bounded" wording or add a deadline. - Attach cost: every native Attach now runs
/sbin/mountonce (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
ExportKepthastracked: false. It is forceable, but a failedumount -fre-keeps it withforcedset, permanently. This is not a regression; worth a note. - File size:
export.rsis 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 .
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_WAIT(60 s)被 KILL;KILL 后REAP_WAIT(5 s)内仍观察不到退出就交给 retained queue,由一个具名后台线程轮询回收并记录退出
(共享给 macOS driver 的
umount -f)。不再同步等待、不重复发信号;队列的界是一个线程和 64 条退出记录,不是时间。
try_wait出错时子进程留在队列里继续观察,连续 25 次(约 0.5 s)仍失败才记录一条
exit unobservable放弃。惰性卸载」+「现在表里没有」三条同时成立当作已卸载。Linux 的
umount -f -l失败后即使表里已消失也保持
Retained(Detach),export 继续服务。macOS 与普通helper 仍以表为证。
Retained,且 stage 是Detach」的记录;
Busy/Dirty/未发送的调用会原样恢复上一条记录。daemon 侧的闸门不变。v: 1,在版本 0 的state/startedMs/path/code/message/detail之后追加forced与结果里的stage。旧记录怎么处理(以代码为准)
v/forced/stage,即本 PR 之前写下的记录)继续按字段名读取、继续按原样回答(
Retained+ 原 code/message);但不可强制,因为它没有 daemon 回的 stage。PR 早期正文里「旧记录当作没强制过」的说法不准确,以
这条为准。
完全一致(多出的字段被忽略),因此旧版本读新记录是「读对」。
未知 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包装脚本模拟慢卸载):Unmounted(60.05 s),之后NotMounted--forceRetained/stage: Detach;--forcerc=0ForceUnmounted,之后NotMounted--forceRetained/stage: Detach、挂载保留;--forcerc=0ForceUnmounted,挂载已卸各场景 daemon 下均无僵尸子进程。卸载记录解码后:第一次卸载
v: 1、stage: Detach、forced: false,--force后forced: true。macOS 目标
cargo clippy --workspace --all-targets -- -D warnings:通过,0 告警。NOT_RUN:遍历中卸载(S4);Durable(owner 关闭)。