Skip to content

fix(daemon): end a normal unmount stalled at Join with one forced unmount (#426, #460) - #481

Merged
yifanxuaaa merged 1 commit into
mainfrom
issue426-forced-unmount
Oct 27, 2026
Merged

yifanxuaaa merged 1 commit into
mainfrom
issue426-forced-unmount

Conversation

@yifanxuaaa

Copy link
Copy Markdown
Contributor

Fixes #426. Refs #460.

Problem

A FUSE superblock outlives the daemon's plain detach when another mount namespace holds a copy of the Workspace mount (an engine or VM agent outside the Sandbox). The receive loops stay blocked, the drain stops at Join, and the entry was kept as Retained with no way out: its owner was boxed as Debug-only evidence, ForceUnmount refused Retained entries, and the session's abort refused once detached.

Change

  • Retained custody keeps a Join-stalled drain with its real type (Evidence::Stalled). Kept::forceable admits exactly that case: detached, abort control bound, not yet aborted, never forced.
  • ForceUnmount on such an entry makes one abort write (KernelSession::abort_detached), then reuses the existing force drain, revoke and close. Removing the mount directory also detaches the copy. A refused abort keeps the custody at Join. Plain unmount is unchanged.
  • The daemon mounts its own fusectl at <overlay dir>/fusectl (/layerfs-local/overlay/fusectl in a Sandbox) when /sys/fs/fuse/connections has none. Docker gives containers a read-only /sys without it. Best effort: if refused (EPERM in a user namespace), the daemon still starts, logs LAYERFS_DAEMON_ABORT_UNBOUND, and forced unmount is refused as before.
  • workspace.unmount(ws, discard?, force?) in the SDK, layerfs workspace unmount --force in the CLI; the binding returns ForceUnmounted.
  • Docs: 80-forced-teardown.md, 76-native-mount-session.md, api/docs/agent.md, the layerfs-js-sdk skill; corrected the Sandbox SECCOMP comment.

Verification (at 888ae70, parent 08ded82)

  • Linux (pinned Rust 1.85.1 image, FUSE container): all 57 layerfs-daemon and layerfs-fuse test binaries pass, plus kernel_application, observed_application, r5_1_actual_daemon_commit in layerfs-api.
    • New stalled_unmount: reproduces the stall with unshare -m; plain unmount returns Retained at Join twice; one forced unmount gives abort Written, detach Detached, commit Absent, all loops joined; the connection ends while the copy still runs. 3 s.
    • New control_root: private fusectl mount, reuse, leftover directory refused, refused mount in a user namespace leaves no directory.
  • Clippy -D warnings (all targets: fuse, daemon, api, sandbox, node) on Linux and macOS; rustfmt clean.
  • JS: CLI 75/75, doc-tables, SDK calls 39/39.
  • Live, dsh web on macOS with this commit's sealed release bundle: 9 of 10 dsh-layerfs scenarios passed (shop, crew, parallel, checkpoints, conflicts, tour, sandbox, agents, merge). Depot's first run failed on the scenario's own timeout 110 around a cargo build (exit 124 at 110.8 s, host load average 48); every LayerFS call in that step succeeded. A second depot run is in progress.
  • Not run: OrbStack, real rootless Docker (a user-namespace daemon start was checked), an SDK-driven forced unmount against a real stalled Sandbox.

Tests added: stalled_unmount.rs, control_root.rs. Touched: support mounted.rs, host_install.rs, mounted_sqlite_full.rs, the three layerfs-api tests, the JS fixture. Deleted: none.

Production LOC: 102588 -> 102728 (delta +140); engine +140, core 0, api 0; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Method: tools/production_loc.py --root on git archive of parent and staged tree.

🤖 Generated with Claude Code

…ount (#426, #460)

A FUSE superblock outlives the daemon's plain detach when another mount
namespace holds a copy of the Workspace mount (an engine or VM agent outside
the Sandbox). The receive loops then stay blocked, the drain stops at Join,
and the entry was kept as Retained with no way out: its owner was boxed as
Debug-only evidence, ForceUnmount refused Retained entries, and the
session's abort refused once detached.

- Retained custody keeps a Join-stalled drain with its real type
  (Evidence::Stalled). Kept::forceable admits exactly that case: detached,
  abort control bound, not yet aborted, never forced.
- ForceUnmount on such an entry makes one abort write
  (KernelSession::abort_detached), then reuses the existing force drain,
  revoke and close. Removing the mount directory also detaches the copy.
  A refused abort keeps the custody at Join. Plain unmount is unchanged.
- The daemon mounts its own fusectl at <overlay dir>/fusectl when
  /sys/fs/fuse/connections has none (Docker gives containers a read-only
  /sys without it). The mount is best effort: if it is refused (EPERM in a
  user namespace) the daemon still starts, logs
  LAYERFS_DAEMON_ABORT_UNBOUND, and the abort control stays unbound, so
  forced unmount is refused as before.
- workspace.unmount(ws, discard?, force?) in the SDK, `layerfs workspace
  unmount --force` in the CLI, and the binding return ForceUnmounted.
- Docs: forced-teardown and native-mount-session architecture, the agent
  docs table and the layerfs-js-sdk skill.

Tests:
- added engine/crates/layerfs-daemon/tests/stalled_unmount.rs: reproduces
  the stall with `unshare -m` and ends it with one forced unmount.
- added engine/crates/layerfs-daemon/tests/control_root.rs: private fusectl
  mount, reuse, refusal of a leftover directory, and a refused mount in a
  user namespace leaves no directory.
- touched support/mounted.rs, support/host_install.rs (shared_control,
  once per process), mounted_sqlite_full.rs, and the layerfs-api tests
  kernel_application.rs, observed_application.rs,
  r5_1_actual_daemon_commit.rs; the JS fixture adds a forced-unmount case.
- deleted: none.

Production LOC: 102588 -> 102728 (delta +140)
  core 38395 -> 38395 (0), engine 61534 -> 61674 (+140), api 2659 -> 2659 (0);
  reference absent (retired R9c; last counted 65,417 at tree 498dd19).
  Method: python3 tools/production_loc.py --root on `git archive` of the
  parent (08ded82) and of the staged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yifanxuaaa
yifanxuaaa merged commit fabefdf into main Oct 27, 2026
3 checks passed
jiangkoumo added a commit to jiangkoumo/layerfs that referenced this pull request Oct 28, 2026
…-AI-Lab#427)

Brings main up to 582bc78 (Ephemeral-AI-Lab#488, Ephemeral-AI-Lab#481 forced unmount after a stalled
Join, Ephemeral-AI-Lab#479, Ephemeral-AI-Lab#471, Ephemeral-AI-Lab#473, Ephemeral-AI-Lab#445 fs modes, Ephemeral-AI-Lab#475, Ephemeral-AI-Lab#478, Ephemeral-AI-Lab#476 Docker API
negotiation, Ephemeral-AI-Lab#396, Ephemeral-AI-Lab#419, Ephemeral-AI-Lab#418, Ephemeral-AI-Lab#415, Ephemeral-AI-Lab#477, Ephemeral-AI-Lab#468).

Two conflicts, both from Ephemeral-AI-Lab#481 (forced unmount of a normal unmount stalled
at Join):
- engine/crates/layerfs-daemon/src/control/force.rs: guard() keeps this
  branch's ExportKept check (an NFS export kept after a failed teardown
  answers with its stored custody first) and takes main's comment and its
  forceable() exception for a retained FUSE session. The non-Linux
  `let _ = token;` stays removed: ExportKept uses the token on every
  platform.
- engine/crates/layerfs-node/src/workspace.rs: unmount() keeps this
  branch's per-Workspace unmount lease (repeat a published outcome, drop
  the admission guard before the daemon call, publish the outcome before
  cleanup) and takes main's `force` option inside it: forced, one
  api.unmount(token, true) whose None answer is Unmounted::Closed;
  otherwise unmount_checked with `discard` as before.

Production LOC: first-parent 9478190 108596 -> 109158
(tools/production_loc.py --root .; the delta is main's own changes)
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.

Workspace mounted while its Sandbox starts cannot be unmounted (TeardownCustody { stage: Join }) and stays wedged

1 participant