Repository navigation
dsh-layerfs 0.1.0 on OrbStack (kernel 7.0.14): 8/10 scripted scenarios stop at workspace.unmount (stage Join); Docker Desktop 4.22.0 cannot create Sandboxes (API 1.43 < 1.54) #466
Description
Summary
The dsh-layerfs 0.1.0 scripted scenario suite (dsh web, macOS worker + sealed Linux bundle, sibling Sandboxes) fails on 8 of 10 scenarios on macOS/OrbStack, every failure at workspace.unmount:
workspace.unmount → Failed
connection drain stopped at Join: loops { configured: 2, entered: 2, exited: 0, joined: 0 },
work { active: 0, retained: 0, requests: 65, completed: 63, terminal: true }
The mount is already gone (/proc/mounts has no fuse entry, no process holds /workspaces/*), but both FUSE receive loops stay blocked, the hard-coded 5 s DRAIN_WAIT expires, and sandbox.close then reports MountsActive. Unmounts without recent I/O sometimes succeed, so results vary run to run (3/10, 1/10, 2/10 across three OrbStack runs on the same install).
This is integration-level evidence for the defect family already tracked in:
- Workspace mounted while its Sandbox starts cannot be unmounted (
TeardownCustody { stage: Join }) and stays wedged #426 —TeardownCustody { stage: Join }wedge - FUSE readers remain blocked after successful unmount: independent reproducer and Linux comparison (related to #426) #460 — FUSE readers blocked after successful unmount (same OrbStack 7.0.14 kernel, minimal Go reproducer)
The new data point is the dsh-layerfs scenario matrix (real dsh agent loop, 10 scripted scenarios, both cli and sdk forms, 2 at a time) on this kernel, plus a second, unrelated environment blocker: Docker Desktop 4.22.0 cannot run LayerFS Sandboxes at all (engine API 1.43).
Filed first in the fork: tokenaissance#1
Environment
| Host | macOS 26.5.1 (Darwin 25.5.0, arm64) |
| Docker Engine | OrbStack, Docker 29.4.0, API 1.54, kernel 7.0.14-orbstack-00380-ga7e0a2dc9535 |
| Node / pnpm / python | 22.23.2 / 11.28.5 / 3.9.18 |
| dsh | 0.2.1-rc.2, commit 687f282e8625c6b8d11ff58211a90524be2e4317 |
| dsh-layerfs | 0.1.0, commit baac1b92b97f0bd492002c285f6a13401b4f4c0b |
| LayerFS | commit 9f97a7ec95b662e9cb288704afa165feff132cfa (v1.1.0), sealed build build-linux-a6848f09a5db4d528b9a68f9bd24a6c1, manifest sha256 f901a26d92c2b29b7ae6a14824136385a24ff869f1149d45b8ff9b775e9e6eb5 |
| Runtime image | sha256:d93570c6600be9eb0214efb2038a1f47f186f56e4715934188c61eace9a957c9 (plugin tools/runtime/Dockerfile, cargo 1.85.1 verified) |
| Profile | default ~/.dsh/profiles/web, layerfs-host with image, bundle, containerPrefix: layerfs-dsh- |
Reproduction
- Install per the README (LayerFS checkout at the pinned commit, sealed bundle via
tools/worktree.py build --platform linux --profile release, runtime image,pnpm install+pnpm run buildwithDSH_SOURCE,dsh plugin --profile web add,layerfs-hostsettings). - Start
dsh web --no-open --port 3080, open the LayerFS page → Tests. - Press Run all once (up to 2 at a time; 10 scenarios, 5
cli+ 5sdk).
GET /layerfs/status reports "missing":[]; All projects shows Docker 29.4.0 green and "Store calls · worker ready" green. Checkpoints (no Sandbox) always passes.
Results (three OrbStack runs, same install)
| Scenario | Run A (first) | Run B (17 Sandboxes accumulated) | Run C (clean state, 0 projects) |
|---|---|---|---|
| Tour | FAIL 4/14 | FAIL 4/14 | FAIL 6/14 |
| Parallel Branches | PASS | FAIL 5/14 | FAIL 3/14 |
| Conflicts | FAIL 6/14 | FAIL 6/14 | FAIL 6/14 |
| Commit & merge | PASS | FAIL 4/14 | PASS |
| One Sandbox, two Workspaces | FAIL 4/7 | FAIL 4/7 | FAIL 4/7 |
| 5 agents | FAIL 44/197 | FAIL 27/197 | FAIL 29/197 |
| Shop | FAIL 4/18 | FAIL 2/18 | FAIL 1/18 |
| Depot | FAIL 1/98 | FAIL 1/98 | FAIL 1/98 |
| Crew | FAIL 3/967 | FAIL 3/967 | FAIL 3/967 |
| Checkpoints | PASS | PASS | PASS |
Every failure is workspace.unmount:Failed followed by sandbox.close: MountsActive. Run C (clean, no leftover Sandboxes/volumes) still fails 8/10, so accumulated load is not the cause; it is timing-sensitive.
Exact daemon failure
OperationFailure { request: Unmount(UnmountControl { token: …, discard: false }),
cause: Retained(TeardownCustody { stage: Join, detached: true,
work: Some(MountWork { loops_configured: 2, loops_entered: 2, loops_exited: 0, loops_joined: 0,
active: 0, retained: 0, completed: 63, served: 63, inline: 0, refused: 2, terminal: 0, forget_units: 0 }),
detail: "connection drain stopped at Join: loops SessionSnapshot { phase: Serving, revision: 6,
configured: 2, created: 2, entered: 2, exited: 0, joined: 0 },
work Some(MountWork { active: 0, retained: 0, requests: 65, completed: 63, terminal: true })" }) }
workspace.unmount took 5 060 ms; the deadline is DRAIN_WAIT = 5 s (core/crates/layerfs-daemon/src/application/filesystem.rs:54, drain path core/crates/layerfs-daemon/src/control/detach.rs:85). After the failure, inside the Sandbox container:
grep fuse /proc/mounts→ none- no process with
cwd/fdunder/workspaces/* /dev/fusepresent; Sandbox runs withCAP_SYS_ADMIN,--device /dev/fuse,apparmor=unconfined
So plain umount2 succeeded (detached: true), but the receive loops never observed the connection close and joined nothing.
Separate environment blocker: Docker Desktop 4.22.0
Docker Desktop 4.22.0 (engine 24.0.5, API 1.43, kernel 5.15.49-linuxkit-pr) cannot create a Sandbox at all: the addon hard-codes /v1.54, and the engine answers
HTTP 400 {"message":"client version 1.54 is too new. Maximum supported API version is 1.43"}
Reproduced directly (POST /v1.54/volumes/create → 400, /v1.43/... → 201). Not a LayerFS bug, but the README's macOS requirement should state the minimum Docker API version (or Docker Desktop release) explicitly; "Docker Desktop" alone is not sufficient for an old install.
Proposed fix
- Make the FUSE drain deadline configurable (e.g.
LAYERFS_DRAIN_WAIT) so an operator can raise it on kernels where readers wake late; or - make detach actively wake/abort the receive loops (per FUSE readers remain blocked after successful unmount: independent reproducer and Linux comparison (related to #426) #460) so
joincannot wait on a connection the kernel no longer closes; and - document the required Docker API version (1.54) and tested kernel for macOS.
No LayerFS code was changed to produce this report; the scenario projects and traces were retained for inspection.
Activity
yifanxuaaa commentedon Oct 27, 2026
Thanks for the detailed matrix. The three runs and the exact
TeardownCustodyoutput are very useful.A lot has changed since v1.1.0 (
9f97a7ec95). Could you update to the latestmain(currentlyea11b00408) and rebuild the sealed bundle, then run Run all once more on OrbStack?/v1.54. Engines below API 1.48 (Docker Engine 28.0) are refused with a clearUnsupportederror, because Sandbox upload fails on 23.0–27.5. So 4.22 still won't run Sandboxes, but you should see that error instead of the raw 400. Please confirm that's what you get.mainyet. Workspace mounted while its Sandbox starts cannot be unmounted (TeardownCustody { stage: Join }) and stays wedged #426 and FUSE readers remain blocked after successful unmount: independent reproducer and Linux comparison (related to #426) #460 are still open. Note that D8 · Refactor core/ into core, engine and api layers #463 moved the daemon toengine/crates/layerfs-daemon/(DRAIN_WAITis nowengine/crates/layerfs-daemon/src/application/filesystem.rs:54).If it still fails at Join on the latest
main, could you open a PR with the fix? We don't have OrbStack (or a 7.0.14 kernel) to reproduce this, so you're in the best position to write and check it. Making detach wake or abort the receive loops (your second option, per #460) is the direction we'd prefer over only making the deadline configurable, since a longer wait just delays the same wedge. Please link #426, #460 and this issue. Include a before/after from your scenario matrix, plus a test that fails without the fix where that's practical. AGENTS.md has the repo rules for tests and commit messages.mengmengmengqiang commentedon Oct 28, 2026
Updated to the latest
mainand rebuilt the sealed bundle — the Join stall is still here on a newer OrbStack kernel, but #481 now recovers it.Answering the request to retest on latest
main. Environment below; probe uses the public SDK only.7.2.9-orbstack-00391-g2b298d05fbd4(newer than 7.0.14 in the report)9f97a7ec95(v1.1.0) →582bc786f9(main; includes #481, #488, #476, #463 layout)tools/worktree.py build --platform linux --profile release --jobs 6, sealed asbuild-linux-aa6c5132eb224caaa85dbbfb26a4b010sha256:d93570c6…(node/rust/chromium only; the daemon comes from the bundle)baac1b9→b9bf3bc(Follow LayerFS's #463 layout; pinsfabefdf87a)Probe
Same shape as the earlier #426 comment: for each iteration
sandbox.create()→workspace.mount(sandbox, branch)immediately →sleep 1500→workspace.unmount(ws); on refusalworkspace.unmount(ws, true, true); thensandbox.close().Results
unmount9f97a7ecFailed(Join), 5 064 mssandbox.close→MountsActivedocker rm -fclears it (1/1)582bc786×6Failed(Join), 5 044–5 504 ms — 6/6--force→ForceUnmountedclose→Closed6/6, 0/6 wedgedSame refusal signature as before the upgrade:
Control on the same upgraded build: delay 2 500 ms between
sandbox.create()and the mount → plainUnmounted3/3 (21–27 ms). On v1.1.0 the delayed loop was 7/7, and six Sandboxes in parallel with the 2.5 s delay were 6/6. So the stall tracks the early-mount window, not load; on this kernel it is deterministic there.What this says
Retainedentry, and the Sandbox then closes. A caller that does not passforcestill sees the failure —dsh-layerfs's scenario runner does not retry with--force, so I did not expect Run all to turn green and did not run it (the scripted-model plugin is not installed in this profile). The deterministic probe above is the path those scenarios take.Unsupportedmessage for Docker Desktop 4.22 is still unverified.Since the early-mount stall reproduces 6/6 here, I can also test a candidate fix for #460 (waking / aborting the receive loops) against these runs, and run Run all once the scripted model is enabled.
mengmengmengqiang commentedon Oct 28, 2026
Follow-up: I wrote the fix in the direction you suggested — the daemon now wakes the stalled connection itself instead of adding to the deadline.
PR: #496 (
fix(daemon): end a stalled normal unmount at Join with its own abort write).On a
Joinstop the normal unmount makes oneabort_detachedwrite on the already-detached connection and runs the same forced drain, then revokes and closes as usual. Only a refused or short write, or a second stop, keeps custody.Same probe as my previous comment, same machine (kernel 7.2.9-orbstack-00391), this branch's daemon:
unmountsandbox.close582bc786Failed(Join), 5.0–5.5 s — 6/6--force(6/6)Unmounted6/6 (5.05–5.08 s: the drain deadline, then the abort)Closed6/6The new
stalled_unmounttest fails on the parent (which keepsRetained { stage: Join }) and passes with the change; the docs say what--forceis still for. Happy to change the design (opt-in flag, or a different abort timing) if you'd prefer that over doing it inside a normal unmount.