Repository navigation
fix(daemon): reclaim control slots after retained connection failure (#540) - #543
Conversation
…phemeral-AI-Lab#540) A worker that failed after decoding a request left its admission slot 'Retained' forever: no product path called take_connection_failure, nothing logged the retained cause, and after all 8 slots were retained the daemon closed every new connection. sandbox.get/close/workspace.* then failed Busy and only killing the daemon recovered. Release the slot immediately for every worker result and move retained failures into a bounded, TTL-pruned operator-visible store (limit 64, TTL 1h). Log every retained failure to stderr/daemon.log in the same ConnectionFailure style as released ones, including request kind, whether a reply was sent, and the bounded cause. Expose the store through backend.daemon.connection_failures alongside vacant/running slot counts. Make the client refusal name only what it observed: a daemon that closes before Hello has no vacant control slot (Busy), while a broken/reset connection is a connection failure (Unavailable); stop claiming that other processes hold all connections. Bound sandbox.close's phase-stop status refresh to 2s so a refusing daemon yields an exact refusal instead of holding the op intent for the full 60s admission wait. Tests: an end-to-end daemon test sends 12 failing requests that die before a reply, asserts every slot is reclaimed, the failures are stored/queryable and a new authenticated client is admitted; unit tests cover the bounded ring, TTL pruning and the precise client refusal messages.
…eral-AI-Lab#540) Extend the slot reclamation fix to the full recovery design while keeping the released-slot invariant. - Slot table: pool and reserved classes with generation-checked SlotTicket. Admission scans only its own class; a pool claim never takes the reserved installer slot and vice versa. A stale or duplicate finish changes nothing. - Panic/spawn safety: the worker runs under catch_unwind and owns a SlotGuard. Exactly one finish happens on the normal path, a panic, a dropped thread or a spawn failure; the synthetic cause is recorded, not lost. - Retained store: records now carry class, generation, owner (instance, pid, thread), visible count and expiry; the ring pre-reserves capacity, tracks recorded and is reaped lazily when admission is refused. Still 64 / 1h. - Capacity notice: a fixed advisory pre-handshake LAYERFS-CAPACITY frame (version/reason/retained/pool_vacant/reserved_vacant) is written before closing when no slot is free. The client decodes it before the Noise message and returns Capacity { resource: "daemon_control_slot", retained, retryable: true }; a bare EOF still maps to Capacity with retained null, resets map to Unavailable, and a random record is never accepted. - Host query: backend.daemon.connection_failures is dispatched to the host, works for forwarded Sandboxes, and sandbox.get exposes it best-effort as daemon_capacity. The operator query is nested admission/retained. - Close/unmount deadline: refresh uses one total deadline for all Workspace status probes (close 2s, unmount 5s), and force close skips the probe so it can stop a refusing daemon instead of waiting N times. Tests: daemon 13 (class isolation, stale finish, guard release, capacity notice, 16 concurrent failures, retained store); control 8 (notice -> precise Capacity, EOF fallback, reset -> Unavailable, garbage rejected); bridge 2.
…Lab#540) The new best-effort daemon capacity read model must not inherit the ordinary 60 s admission wait, or sandbox.get itself could stall behind a refusing daemon. It now uses a 2 s control wait; product calls keep the ordinary wait.
…#540) The doctor now asks each running Sandbox whose daemon endpoint its record or control.json names for the same backend.daemon.connection_failures read model sandbox.get exposes as daemon_capacity. The query is best-effort and strictly bounded to 2 s (connect, Noise Hello and one exchange), reuses Dial::open_within, and runs under a hard wall so an endpoint that accepts and stalls cannot hold the doctor. A daemon that does not answer is a warning naming its endpoint and the operation, never an error: the doctor stays read-only and does not fail on an idle or stopped daemon, and it starts no machine or Sandbox. An admission-capacity refusal is reported as Capacity on resource daemon_control_slot with its vacant-slot counts; a successful model adds warnings for retained connection failures (count of limit, dropped) and for a pool with no vacant control slot (pool_vacant/running), pointing at backend.daemon.connection_failures. Tests: layerfs-control --lib doctor 3 passed; --test doctor 6 passed (stalled endpoint bounded in ~2 s, no home writes, no endpoint means no query); layerfs-cli --test doctor 2 passed.
…pacity model (Ephemeral-AI-Lab#543) - Remove every inline #[cfg(test)] module from product src/: the bridge admission encoding/classification tests move to engine/crates/layerfs-bridge/tests/admission.rs; the private pool, doctor, daemon connection/owner/retained unit tests are deleted because they cannot reach the code through a public API without new test-only surface. Their coverage is replaced by public-behavior tests (doctor capacity warnings via a fake authenticated daemon, bridge capacity DTO) and the existing real-TCP/in-process suites. - Remove the never-constructed NotAdmitted::Starting variant and its match arms; AdmissionReason::Starting (the daemon notice) stays. - Box Finished::Retained's record to satisfy clippy::large_enum_variant now that the Starting error no longer masks it. - Add a typed CapacityModel/AdmissionModel/RetainedModel DTO to layerfs-bridge as the one wire contract: the daemon producer builds it, sandbox.get and doctor parse it once, and OPERATION replaces the duplicated method-name literal. - Relocate the doctor capacity-warning tests to public doctor() calls against a fake authenticated daemon (healthy, retained+exhausted, and Capacity refusal).
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…base-proof claim (Ephemeral-AI-Lab#547) - Commit the base half of the proof as core/docs/issues/547/pre543_repro.rs, with the command that runs it at d2c3f2c. It is not built with this branch: the restored suite names slot types that do not exist on the base. - Record the actual result of that run: nine deaths, then the tenth client is refused with Io(Kind(UnexpectedEof)) at pre543_repro.rs:166:6. - Correct the slice-1 claims: the real-process deaths fail at `InitialRecord::decode`, so they are not a decoded-request/reply-lost path; the guard-drop test covers `Slots::abandon`, not the `Drop` wiring; `NoPoolSlot` has no real-process case; and the client's decoded-notice mapping lost its test when the refusal message builders went private. Production LOC: 138203 -> 138203 (delta 0). Documentation and a test fixture that is not built with the branch.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
…phemeral-AI-Lab#543) These failed at d2c3f2c before any Ephemeral-AI-Lab#543 change and are test-only: - workspace_names pointed the native machine's daemon at /nonexistent, which the pre-launch is_file check now refuses (Unavailable) before the name/NoSuchSandbox assertions. Write a placeholder daemon file instead. - sqlite_publication expected SqliteHandles::create to return BackendUnavailable, a leftover from the removed fake-PostgreSQL backend selection (Ephemeral-AI-Lab#535). SQLite always has an engine on macOS/Linux, so the stale assertion is removed and the test keeps asserting the real profile. No product behavior is changed to make these pass.
|
Hey @mengmengmengqiang, PR #543 is currently conflicting with recent layerfs_v2_0_0 updates. Could you rebase onto the latest v2 base when convenient? Thanks! |
…eless-conversion lint (Ephemeral-AI-Lab#543)
CI noteThe single failing check on this PR is not caused by these changes: It is a pre-existing base regression from #517 (commit This PR's own checks pass: fmt, clippy |
…phemeral-AI-Lab#540) `sandbox.get` now carries `daemon_capacity`: the daemon's `backend.daemon.connection_failures` read model, the one `layerfs doctor` explains and its fix text already pointed at. The field is best-effort and bounded to 2 s: the query runs in its own thread behind a hard wall, so a daemon that accepts and stalls cannot hold the read, and a daemon that does not answer (stopped, gone, at no known endpoint, or holding no vacant control slot) leaves the field out instead of failing `sandbox.get`. The field is only added for a running Sandbox, and nothing is started to find out. The one query itself moved out of the doctor into `layerfs_sandbox::daemon::capacity` (`endpoint`, `read`, `OPERATION`, `WAIT`), so the doctor and the read model share one implementation: where the daemon is, how long an answer may take, and what an unanswered query is are decided once. Tests: layerfs-api --test sandbox_capacity 2 passed — a stand-in daemon's model reaches `sandbox.get` with its admission counts and retained failure record, and a daemon that reads the query and never answers leaves the field out after the bounded wait while the get still answers. layerfs-control --test doctor unchanged and passing (6 passed). Clippy -D warnings clean on layerfs-api, layerfs-control, layerfs-bridge and layerfs-sandbox; no test failures in those four or layerfs-daemon. Production LOC: 156991 -> 157011 (delta +20); api 4966 -> 4975 (+9), control +11. Counted with tools/production_loc.py over core/crates/*/src, engine/crates/*/src, control/crates/*/src and api/crates/*/src (tests, docs, manifests and generated artifacts excluded).
…ads (Ephemeral-AI-Lab#540) A daemon whose pool has no vacant slot answers the next connection with its advisory notice before any handshake. This pins what a client makes of it: `Dial::open_within` refuses `Capacity` on resource `daemon_control_slot` with the notice's vacant-slot and retained counts, retryable because nothing was attempted; a daemon that is still starting is `Unavailable` with admission `starting` instead, so a full pool and a starting one stay distinguishable. Measured against a real macOS daemon before this test existed (LP543-PATCH §4): with eight admitted clients holding every pool slot, the ninth raw connection read `notice version=1 reason=NoPoolSlot retained=0 pool_vacant=0 reserved_vacant=1`. Tests: layerfs-sandbox --test pool_admission 2 passed. Clippy -D warnings clean. Production LOC: 157011 -> 157011 (delta 0); a test-only commit.
…ral-AI-Lab#543) `sandbox.get` shows `daemon_capacity`, the daemon's `backend.daemon.connection_failures` read model, but only through the half of it that survived the `layerfs_v2_0_0` merge (82165e6): the API layer parsed an endpoint and dialed it itself. That half reaches neither a `docker` nor an `apple-container` Sandbox, whose daemon is behind a machine's worker; it dials a forwarded Sandbox's old port after its machine stopped or its forward exited because it skips `direct::check`; and it opened a second control connection per read, so one `sandbox.get` took two of the daemon's slots and, with seven slots held by other clients, reported no capacity at all - the field the doctor's own fix text tells an operator to read. This restores the operation author's design from 7cfe446, 52f9c16 and 31375c9 (all present before that merge, lost when it resolved Ephemeral-AI-Lab#540's conflicts): the read is a host operation again (`Call::Capacity`, `backend.daemon.connection_failures`), routed like the Workspace list it follows - `direct::capacity` for a forwarded Sandbox, its machine's worker for the rest - so `native`, `lima`, `docker` and `apple-container` all answer it, and `api/crates/layerfs-api/src/sandbox/get.rs` is a thin shell over the typed `Got` again. It is asked only for a `running` Sandbox whose machine and forward were reached, on the connection the Workspace list lent from the Sandbox's pool (one connection, one slot), within 2 s for admission and for the exchange, and fenced at that deadline (`daemon/fence.rs`, the pattern the doctor's bounded observation uses): a daemon that trickles its handshake or its reply is shut down rather than left holding a thread, a connection or a slot. Doctor behavior and wording are unchanged. Its own query now ends its control session instead of dropping the socket, which the daemon logged as an unexpected end. Docs: `docs/architecture/api/sdk/sandbox/get.md`, `docs/architecture/api/sdk/sandbox/README.md`, `docs/architecture/control/routing/daemon-channel.md`. Production LOC: 157011 -> 157130 (delta +119) Scope and method: first-party production Rust/SQL/JavaScript in core, engine, control and api (tests, fixtures, tools and docs excluded), counted with `tools/production_loc.py` at the parent commit 8978c2e and the committed tree. control 38406 -> 38524 (+118), api 4975 -> 4976 (+1); core 39749 and engine 73881 unchanged. Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
…phemeral-AI-Lab#543) `sandbox.get`'s `daemon_capacity` (Ephemeral-AI-Lab#540) had one test of the shown answer and one of the 2 s bound, both on a forwarded Sandbox. This covers what the restored route decides: - `api/crates/layerfs-api/tests/sandbox_capacity.rs`: the Workspace list and the capacity read share one connection (the stand-in daemon counts them), a stopped machine with no worker record, an ended forward with a recorded process, and a Sandbox with no id are never dialed and answer in well under a second, and a daemon that reads the query and never answers still returns the get at its bound. - `control/crates/layerfs-sandbox/tests/capacity_read.rs`: a query ends its control session (`EndSession`) instead of dropping the socket, a trickling daemon is fenced at the deadline (its own write fails, so the connection was shut down), two pooled reads take the one connection the pool holds, and the host operation a machine's worker runs (`dispatch::call`, `Call::Capacity`) returns the model. - `control/crates/layerfs-sandbox/tests/host_frame.rs`: the `sandbox.host` frame carries `backend.daemon.connection_failures` and reads back as `Call::Capacity`. Production LOC: 157130 -> 157130 (delta 0) Scope and method: first-party production Rust/SQL/JavaScript in core, engine, control and api (tests, fixtures, tools and docs excluded), counted with `tools/production_loc.py` at the parent commit 88c88fb and the committed tree; this commit changes tests only, so the production total is unchanged. Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
…ld table (Ephemeral-AI-Lab#543) The `daemon_capacity` row linked an issue URL that the guides do not have a record of; it cites the issue the way the surrounding documents do, `(Ephemeral-AI-Lab#540)`. Production LOC: 157130 -> 157130 (delta 0) Scope and method: first-party production Rust/SQL/JavaScript in core, engine, control and api (tests, fixtures, tools and docs excluded), counted with `tools/production_loc.py` at the parent commit 811f59e and the committed tree; this commit changes one documentation line, so the production total is unchanged. Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
…eral-AI-Lab#543) `capacity::lent` (the `sandbox.get` capacity read, Ephemeral-AI-Lab#543) arms a fence on the pool connection it just borrowed, and `Lease::drop` decides reuse from `Control::is_failed()`. That answer was only `Control.failed`, which a fence's end never sets: `CloseHandle::close` (layerfs-bridge) sets the channel's own shared `failed` bit and shuts its socket down, and the `Control` above it learned of that only when a call it sent subsequently failed. The window, in order: 1. `trace::control_within` borrows a connection and `Fence::channel` arms it at the read's deadline. 2. `exchange` returns `Ok(answer)`: `Control.failed` is still false and the channel is healthy. 3. Before `drop(fence)` sends its stop, the fence thread's deadline expires and `CloseHandle::close()` quarantines the channel and shuts both directions down. `drop(fence)` joins that thread, so the close has completed before the lease is returned. 4. `Lease::drop` asks `Control::is_failed()`, hears false, and pushes the dead connection back into `pool.idle`. 5. The next call in the same long-lived process (a machine's worker, an SDK binding) pops it. `Sender::send` refuses at `shared.ready()` with `Quarantined` before writing a byte, but `Control::call_with` had already set `attempted = true`, so the caller is told `phase: Send, attempted: true` for an operation that never left. `capacity::lent` widened that window: its deadline was taken before `control_within(controls, WAIT)`, which re-reads the clock and may use the whole 2 s, so a read admitted at the boundary armed a fence already 0 ms from firing - a shutdown racing a reply that took microseconds. Fix, at the three points the ordering above crosses: - `layerfs-bridge`: `Connection::is_quarantined()` reads the one shared bit that every direction and close handle write. - `Control::is_failed()` is that bit or `Control.failed`, and `call_with` and `install_file` refuse on it before setting `attempted`. A quarantined connection now reports `phase: Encode, attempted: false` (nothing can have taken effect), and `Lease::drop` closes it instead of lending it again. - `capacity::lent` checks the window left after admission and refuses (`Unavailable`, nothing sent) instead of arming a spent deadline, which would shut a healthy connection down for nothing. Tests (`control/crates/layerfs-sandbox/tests/capacity_read.rs`): a fenced pool connection is not lent again (the next read opens the second connection and answers instead of reporting a quarantine), and a quarantined connection's call is `Encode` with `attempted: false`. With `is_failed` reverted to its previous form both fail; the first reproduces the reviewed symptom exactly (`phase: Send, attempted: true, cause: Channel(Quarantined)`). Docs: `docs/architecture/control/routing/daemon-channel.md` (the pool's drop line and the `Control` row) and `docs/architecture/api/sdk/sandbox/get.md` (what the fence and the spent admission window decide). Production LOC: 157130 -> 157143 (delta +13) Scope and method: first-party production Rust/SQL/JavaScript in core, engine, control and api (tests, fixtures, tools, docs, manifests and generated artifacts excluded), counted with `tools/production_loc.py` at the parent commit de009a8 and the committed tree. engine 73881 -> 73884 (+3, `duplex::Connection::is_quarantined`), control 38524 -> 38534 (+10, `daemon/capacity.rs` and `daemon/channel.rs`); core 39749 and api 4976 unchanged. Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
|
@mengmengmengqiang 你好,我给这个 PR 补了几处缺口,开在你的分支上:tokenaissance#68 主要是: |
fix(sandbox): 恢复 sandbox.get 的 daemon_capacity 读取,并修隔离连接回池问题(Ephemeral-AI-Lab#543)
…l-AI-Lab#641/Ephemeral-AI-Lab#621) into fix/540-daemon-slots
…ral-AI-Lab#543) # Conflicts: # engine/crates/layerfs-daemon/src/application/mod.rs
…sh the state machine (Ephemeral-AI-Lab#547) Slice 1: restore the four deleted daemon assertions (the pre-Ephemeral-AI-Lab#543 connection tests) as package integration tests, and make the pure Slots/RetainedFailures state machine public behind the #[doc(hidden)] internals module so it is unit-testable from tests/. No behavior change. - engine/crates/layerfs-daemon/tests/daemon_slots.rs: the four real-daemon custody assertions over TCP (undecodable records free their slot, the capacity notice on a full pool, and a concurrent failure storm). - engine/crates/layerfs-daemon/tests/slot_state.rs: the deleted owner/retained unit assertions plus the guard-drop release, via the public state machine. - application/internals.rs: #[doc(hidden)] re-export of the pub'd state-machine values; application/{owner,retained}.rs: Slots/RetainedFailures and their methods are now pub.
…sh the state machine (Ephemeral-AI-Lab#547) Slice 1: restore the four deleted daemon assertions (the pre-Ephemeral-AI-Lab#543 connection tests) as package integration tests, and make the pure Slots/RetainedFailures state machine public behind the #[doc(hidden)] internals module so it is unit-testable from tests/. No behavior change. - engine/crates/layerfs-daemon/tests/daemon_slots.rs: the four real-daemon custody assertions over TCP (undecodable records free their slot, the capacity notice on a full pool, and a concurrent failure storm). - engine/crates/layerfs-daemon/tests/slot_state.rs: the deleted owner/retained unit assertions plus the guard-drop release, via the public state machine. - application/internals.rs: #[doc(hidden)] re-export of the pub'd state-machine values; application/{owner,retained}.rs: Slots/RetainedFailures and their methods are now pub.
Problems this PR (and its follow-ups) fixThis PR is the base of the daemon-slot stack: #543 -> #650 -> #651 -> #652 -> #655. Together they close:
Position in the stack: base. Merge order is #543 -> #650 -> #651 -> #652 -> #655; each follow-up is stacked on the previous one and retargeted to Why split into separate PRs
|
Status on head
37cc7711a: GitHub reports the PRMERGEABLE/CLEAN, and no CI lane runs for this PR any more —ci.ymlandformat.ymlskip a pull request whose base islayerfs_v2_0_0(branches-ignore, owner decision 2026-11-07), so run 41800502973 on4dbbcb7a3is history, not a verdict on this head. The checks stated below were run locally on37cc7711a(macOS; Linux-only legs marked NOT RUN). No human review has been submitted yet.The head is
37cc7711a, 21 commits ahead oflayerfs_v2_0_0and 0 behind it (base headbdeb9e91f). The earlier run's one failure,sdk_blame_coarse, is fixed by #639, which is now in this branch. #68 (jiangkoumo) is merged into this branch: it restoressandbox.get'sdaemon_capacityand keeps a fenced control connection out of the pool.Review focus
Slots::finishnow always leaves its slotVacantand converts a failure that used to keep custody into a compact record in the bounded store; every such failure is also logged. Check that no result path can leave a slot out ofVacantonce its worker has exited, and that a stale or duplicateSlotTicketfinish changes nothing.Capacitynotice. When no slot is free the daemon writes one fixed advisory pre-handshakeLAYERFS-CAPACITYframe and closes; the client maps it toCapacity { resource: "daemon_control_slot", retained, pool_vacant, reserved_vacant, retryable }. Legacy EOF still maps toCapacitywithretained: null, a reset maps toUnavailable, and a random 24-byte record is never accepted. Check the notice is treated as advisory and unauthenticated, and that it admits nothing.layerfs doctorasks only Sandboxes whose record says they run and whose daemon endpoint is known forbackend.daemon.connection_failures, inside a hard 2 s bound; an unreachable daemon is a warning withokstill true, and nothing is started or written.sandbox.closeshare one total 2 s deadline (api/crates/layerfs-control/src/sandbox/serve.rs:43); unmount keeps 5 s, andforceskips the probe. Check it is a close-phase budget, not a whole-close deadline.cargo test -p layerfs-daemon --libis 0 tests on this head: the inline daemon suites this fix needs were removed by the product-boundary guard, and nothing in-tree replaces them. The Docker E2E below proves slot reclamation, not the retained-record path. Review the diff for the invariants rather than treating green CI as coverage.What changed in this revision
The initial reclaim fix is extended to the full slot-recovery design:
Pool(8) plusReserved(1, installer-only today: it is admitted only while the daemon is inInstallPending/Installing, andAdmissionClass::Leaseis unused placeholder code). The reserved slot is not a recovery or lease slot, so a pool-exhaustedsandbox.closecannot borrow it; giving it a recovery role is follow-up issue daemon: bound an admitted control slot's lifetime and keep recovery admissible when the pool is exhausted (#540 follow-up) #547. Admission is class-scoped, so ordinary connections cannot take it; each admission carries a generation-checkedSlotTicket, and a stale or duplicate finish changes nothing.catch_unwindand owns aSlotGuard; exactly one finish runs on the normal, error, panic, dropped-thread and spawn-failure paths, recording a synthetic bounded cause.instance:pid:thread), visible count,expires_in,recordedand the bounded failure list; the ring pre-reserves capacity (64 / 1 h) and is reaped lazily. A record is still never a slot.Capacitynotice — when no slot is free the daemon writes a fixed advisory pre-handshakeLAYERFS-CAPACITYframe before closing. The client maps it toCapacity { resource: "daemon_control_slot", retained, pool_vacant, reserved_vacant, retryable: true }. Legacy EOF still maps toCapacitywithretained: null; resets map toUnavailable; a random 24-byte record is never accepted. The notice is unauthenticated, advisory, and admits nothing.backend.daemon.connection_failuresis dispatched to the host (including forwarded Sandboxes) andsandbox.getexposes it best-effort asdaemon_capacitywith a bounded 2 s wait; the payload is nestedadmission(pool_vacant,pool_running,reserved_vacant,reserved_running) /retained(limit,ttl_ms,count,dropped,recorded,failures). The read was implemented in7cfe4460e,52f9c1666and31375c942(then atapi/crates/layerfs-control/src/sandbox/get.rs); the merge82165e666, resolving the Split the control layer into one crate per concern (layerfs-project#101) #601 control split, rebuilt that file from the base copy and dropped this branch's hunk, so the newcontrol/crates/layerfs-sandbox/src/get.rshad none of it. [v0.1.3] Optimize git_tool_workflow: cold reads, metadata traversal, and backing round trips #68 (88c88fbec) restores it on this head.forceclose skips the probe so it can stop a refusing daemon.Tests are stated once, below: on this head
cargo test -p layerfs-daemon --libis 0 tests — the daemon-side invariants still have no in-tree test and are being restored in the stacked follow-up. What this head does carry from #68 is client-side coverage: the pre-Hello capacity notice and its counts (layerfs-sandbox/tests/pool_admission.rs), the bounded and pooled read, the fence and quarantine of a failed control connection (capacity_read.rs), and whensandbox.getshows or withholdsdaemon_capacity(layerfs-api/tests/sandbox_capacity.rs). Neither set asserts the daemon-side "a retained failure holds no slot" invariant.Refs #540: do not close it on merge; issue #547 tracks the residual — an admitted in-flight worker still has no deadline or reclaim verb, and with the reserved slot installer-only a saturated control plane can still require a
--forceclose and lose uncommitted Workspaces.Doctor capacity wiring (delta)
On top of
52f9c1666(commitc9457e3d4):layerfs doctornow asks each Sandbox whose record says it runs and whose daemon endpoint is known (a machine forward, else the endpoint the start recorded incontrol.json) forbackend.daemon.connection_failures, the read modelsandbox.getexposes asdaemon_capacity. A Sandbox with no recorded endpoint is skipped; nothing is observed, started or written.Dial::open_withincaps the connect, the Noise Hello and one exchange at 2 s, and a hard 2 s wall around the whole probe means an endpoint that accepts and then stalls returns a warning in about 2 s, not after a 10 s connect timeout or a 60 s admission wait.backend.daemon.connection_failures), withokstill true; the doctor stays read-only and does not fail on an idle or stopped daemon. A capacity refusal is a warning namingCapacityon resourcedaemon_control_slotwith itspool_vacant/reserved_vacant/retainedcounts.retained <count> of <limit> ... <dropped> dropped) and, when no pool slot is vacant, for the exhausted pool (pool_vacant/pool_running/ reserved counts), both pointing atbackend.daemon.connection_failuresandsandbox.get'sdaemon_capacity.warningon thesandboxentity, so no schema redesign was needed.Tests for this delta: the doctor capacity tests live in
api/crates/layerfs-control/tests/doctor.rs(a fake authenticated Noise daemon answers Hello andbackend.daemon.connection_failures); on the final headcargo test -p layerfs-control --test doctor→ 9 passed (below), including a stalled endpoint bounded to ~2 s with no home writes and no query for a Sandbox without a recorded endpoint.cargo test -p layerfs-cli --test doctor→ 2 passed, andcargo check -p layerfs-control -p layerfs-api -p layerfs-cli→ clean.Root cause
Application::servegave each worker one of the daemon's fixed control slots. When a worker failed after a request had been decoded, the slot becameSlot::Retained(cause)and stayed there:Okresults, andChannel/Iofailures with no request and no received bytes, released the slot;answer_callfails) was retained;take_connection_failurewas the only path fromRetainedback toVacant, and no product/operator call made it;Each such client death permanently cost one of the 8 ordinary control slots. After 8, the accept loop closed every new socket and
sandbox.get/close/workspace.*failedBusyafter 60 s; only killing the daemon recovered. The client message attributed this to "other processes hold all 8 connections", which was not knowledge the client had.Fix
Slots::finishalways leaves the slotVacant; a retained failure is converted to a compact record in a boundedRetainedFailuresstore. Admission capacity can no longer leak.daemon.login the sameConnectionFailure { ... }style as released failures. The line names the slot, received bytes, request kind, whether the reply was sent, the bounded cause, and fence/diagnostic errors.droppedcount. It is exposed through the existing daemon operator/control path asbackend.daemon.connection_failures, returningadmission { pool_vacant, pool_running, reserved_vacant, reserved_running },retained { limit, ttl_ms, count, dropped, recorded, failures }, and each failure record.Capacitywithdetail.admission = "no-vacant-slot"(the accept loop's capacity refusal) orUnavailablewithdetail.admission = "connection-failed"forBrokenPipe/ConnectionReset, plusdetail.observedwith the I/O kind. It no longer claims other processes hold the connections.sandbox.closephasestopis bounded. Its Workspace status refresh now uses a 2 s control-admission wait (Controls::lend_within) instead of the ordinary 60 s wait, so a refusing daemon returns an exact refusal promptly instead of holding the close'sopintent.Tests
What runs on this head
cargo test -p layerfs-daemon --lib→ 0 tests. The daemon crate has no inline#[cfg(test)] mod testsleft: the product-boundary guard (tools/check_product_boundary.py) forbids test-only attributes in productsrc/, and the daemon tests that asserted this PR's invariants lived there. The crate compiles clean, but a clean build is not coverage.37cc7711a:cargo test -p layerfs-sandbox --test pool_admission(2 passed),--test capacity_read(6 passed),--test host_frame(5 passed),cargo test -p layerfs-api --test sandbox_capacity(5 passed);cargo fmt --all -- --checkclean,check_product_boundary.py --skip-sdkPASS (1349 files),check_fuser_integrity.pyexit 0,cargo clippy -p layerfs-sandbox -p layerfs-api --all-targets -- -D warningsclean. Previously on4dbbcb7a3:cargo test -p layerfs-bridge --test admission --test capacity_model(4 passed),cargo test -p layerfs-bridge --test duplex(9 passed),cargo test -p layerfs-control --test doctor(9 passed),cargo test -p layerfs-api --test workspace_names(2 passed),cargo test -p layerfs-persistence --test sqlite_publication(13 passed).cargo +1.85.1 fmt --all -- --check→ clean (pinned 1.85.1);cargo +1.85.1 clippy -p layerfs-control -p layerfs-daemon -p layerfs-bridge --all-targets -- -D warnings→ clean,-p layerfs-api -p layerfs-persistence --all-targets→ clean. The full workspace clippy was not run locally.python3.12 -B tools/check_product_boundary.py --skip-sdk→PASS: scanned 1238 production Rust/SQL/JavaScript files.The coverage gap, and where it is being restored
The deleted inline tests were the only direct automated assertions of: a retained failure not consuming a slot (real
Application+ TCP + Noise, 12 failing requests),finishfreeing a retained failure, stale/duplicate finish, guard drop and spawn failure finishing exactly once, the pre-Hello capacity notice, the 16-way concurrent failure storm, and the ring's limit/TTL/eviction/cause bounds. Nothing in-tree asserts those daemon-side invariants today; #68 (merged into this branch as4f4906aef) adds client-side assertions for the pre-Hello notice and the connection fence, not for slot retention. The Docker E2E below exercises the released-after-logging class (retained.countstays 0), not the retained-record path, and the E2E scripts are not committed.Issue #547 tracks the restoration of this coverage on the stacked branch
fix/547-slot-regression-tests; its PR will be opened separately: package-level tests underengine/crates/layerfs-daemon/tests/that drive a real daemon process and real TCP clients, so the guard is satisfied without test-only product surface or#[path]recompiles of private modules. This PR does not wait on that PR for the leak fix, but the gap is part of this PR's accounting, and green CI does not close it.Risk
Box<ConnectionFailure>. The record keeps the deciding cause, request kind, reply state, and error text, but not the full decoded request/manifest or body bytes. The previous custody was never readable by any product path; the daemon log and operator query now are.servejoins it before spawning the new worker), so no two workers share a slot.Limitation / follow-up
UnexpectedEofcapacity signal from a transport failure. The daemon now exposes its slot/failure state, so an operator can confirm it.--forcelast resort remains until that follow-up lands. Do not close Daemon control slots leak: a connection that fails after a request stays Retained forever, and the daemon then refuses every connection #540 for it.Real Docker E2E (both local engines)
Both runs use real daemons, real SIGKILLed clients, and the same 22-assertion probe. The invariant: a client that dies after its request was sent frees its control slot; a brand-new client is admitted; commit/unmount/close succeed; data survives; the 8 pool slots stay accounted for; one daemon.log line per failure.
OrbStack 29.8.2 — PASS 22/22, 9/9 rounds
build-linux-4fa4864778644346b6aee8de91010a7e,tools/worktree.py run.workspace.commit(64 MiB change), gets SIGKILLed at +150 ms after the daemon admits it; commit completes, slot returns 295–766 ms later, exactly +1ConnectionFailureline, a new client process answerssandbox.getin 1.9–6.8 ms.pool_vacant 6 + pool_running 2 = 8,reserved_vacant 1,reserved_running 0; no false "other processes hold all 8". The reserved count is the installer-only slot and stayed vacant throughout; it is not a recovery reserve and nothing borrowed it.NoSuchSandbox.Docker Desktop 29.9.2 — PASS 22/22, 9/9 rounds
host.docker.internal; macOS bundlebuild-macos-adf82922369f41ffa2189792546da006.lfs-882b74ef-slots540andlfs-882b74ef-docker; cleanup 204.Harness caveat
The pinned Linux-container harness cannot drive
machine dockeron Docker Desktop: the v2 worker runs with--network hostand Desktop does not expose random published loopback ports to that namespace (fixed ports and bridge +host.docker.internalwork; minimal repro included). This is a harness limitation, not a #540 product failure. Docker Desktop evidence therefore uses the product's macOS-client topology.Note on
retained.count = 0A SIGKILLed client whose request was decoded leaves a plain channel EOF: the daemon writes the reply to the dead socket, reads EOF, frees the slot, and logs one
ConnectionFailure. That class is released-after-logging, not stored in the boundedRetainedFailuresstore; the store/retention path has no in-tree test on this head (see the coverage gap) and is the reasonretained.countstays 0. The E2E proves slot reclamation, not a retained record.Install-path retained record — not reachable in this build
We attempted the requested install-path retained-record E2E on both OrbStack and Docker Desktop. Both fail for a product-path reason, not nondeterminism:
project.init/store.pushwrite the project record with aninstalledmanifest;sandbox::serve::start_claimedbuildsDaemonStore::Sqlite { install: Some(existing) }, and the launcher opens the already-pushed Store (setup.opens_existing()), skipping install./layerfs-store/store.sqliteexists before the container is created (same inode as the worker's pushed file); nostore.sqlite.installingtransfer file appears; the SIGKILLed create leaves only a channel EOF (original_kind=None), andretained.countstays 0.InitialRecord::Install,install: None) is constructed only by daemon unit fixtures, never by a product launch. Install failures are held as startup custody (startup.install_failure, phase retained), not written to the boundedRetainedFailuresstore.So the caveat cannot be removed in this build. Making it testable needs a product path that streams an install, or propagating install failures into the retained record path; both are out of scope for this PR.
CI fix (#543) — head
20d0d48a3The
fmt and guardsandclippy -D warningsfailures are fixed, and the read model is now one typed contract. All changes are on this Branch only (#544/#542 untouched).Guard
fmt and guardspython3 -B tools/check_product_boundary.py --skip-sdkreported 32test-only attribute in product sourcelines from inline#[cfg(test)] mod testsin six product files. All inline test modules are gone from productsrc/:tests/(public APIs only):layerfs-bridgeadmission encoding/classification →engine/crates/layerfs-bridge/tests/admission.rs; the capacity DTO's round-trip/tolerance →engine/crates/layerfs-bridge/tests/capacity_model.rs; doctor capacity warnings →api/crates/layerfs-control/tests/doctor.rs(a fake authenticated Noise daemon answers Hello andbackend.daemon.connection_failures, covering healthy, retained + exhausted pool, and aCapacityrefusal through the publicdoctor()call). No test-only public API, no#[path]recompilation of private source, no#[allow].daemon/pool.rs,doctor/capacity.rs, and the daemon'sapplication/{connection,owner,retained}.rsunit tests. Honest coverage loss: those deleted daemon tests were the only direct tests of retained-failure slot release, the dropped-SlotGuardpath, the concurrent failure storm, the pre-Hello capacity notice, and the ring's limit/TTL/cause bounding. The daemon's own--libtarget is now empty (0 tests); the specific invariants are covered by review and the real Docker E2E matrix below, not by an automated in-tree test, until the follow-up restores them. The pool's classification messages (EOF →Capacitywith unknown retained, reset →Unavailable, notice → exact retained) also lose their unit tests;sandbox.get/doctor still cover the refusal shape indirectly.Clippy
-D warningsNotAdmitted::Startingvariant and its match arms (api/crates/layerfs-control/src/daemon/pool.rs);AdmissionReason::Starting(the advisory notice) is still produced and handled.clippy::large_enum_variantonFinished::Retained(RetainedFailure)inlayerfs-daemon/application/owner.rs(not present on the base). The record is now boxed, as clippy suggests.Typed capacity model (clean-architecture delta)
engine/crates/layerfs-bridge/src/control_capacity.rs:CapacityModel(AdmissionModel+RetainedModel+ typed failure records),parse(&SdkValue)andto_sdk(), plus the singleCAPACITY_OPERATIONconstant. It is a plain DTO — no trait, repository, or test-only surface.application/connection.rs) now builds that model instead of hand-assemblingSdkValues;sandbox.getparses it once and re-publishes exactly the typed shape;doctor/capacity.rsparses once and reads typed fields. The duplicated"backend.daemon.connection_failures"literals in doctor,sandbox.get,sandbox.dispatch,daemon.operator, and the daemon producer now all useCAPACITY_OPERATION.RetainedModel::exhausted/AdmissionModel::exhaustedkeep the doctor logic small; no probe/findings file split was needed.Verification on this head
python3.12 -B tools/check_product_boundary.py --skip-sdk→PASS: scanned 1238 production Rust/SQL/JavaScript files.cargo +1.85.1 fmt --all -- --check→ clean (pinned 1.85.1).cargo +1.85.1 clippy -p layerfs-control -p layerfs-daemon -p layerfs-bridge --all-targets -- -D warnings→ clean;-p layerfs-api -p layerfs-persistence --all-targets→ clean (the two edited test crates).cargo +1.85.1 test -p layerfs-bridge --test admission --test capacity_model→ 4 passed;--test duplex→ 9 passed.cargo +1.85.1 test -p layerfs-control --test doctor→ 9 passed (6 existing + 3 new capacity tests).cargo +1.85.1 test -p layerfs-daemon --lib→ 0 tests (inline unit tests removed; compiles clean).cargo +1.85.1 test -p layerfs-api --test workspace_names→ 2 passed;cargo +1.85.1 test -p layerfs-persistence --test sqlite_publication→ 13 passed.Test-job baseline evidence (pristine
layerfs_v2_0_0@d2c3f2c97)Run from a clean
git worktree addof the base commit, before any #543 edit:cargo test -p layerfs-api --test workspace_names→ FAILED, 0 passed / 2 failed: both tests panicked onmachine native's recorded daemon /nonexistent is not there(Unavailablewhere the test expectedExists/NoSuchSandbox).cargo test -p layerfs-persistence --test sqlite_publication→ FAILED, 12 passed / 1 failed:wal_full_settings_and_unavailable_engine_are_explicitpanicked atsqlite_publication.rs:63,left: None, right: Some(BackendUnavailable).Both are stale test fixtures, not product regressions, and were fixed in a separate test-only commit
20d0d48a3with no product behavior changed:workspace_names: the machine record now points at a placeholder daemon file (never launched, because the Sandbox has no daemon keys) instead of/nonexistent, so the call reaches the name/NoSuchSandboxassertions the test is about.sqlite_publication: removed the leftoverBackendUnavailableassertion that selected the fake PostgreSQL backend removed in The connect layer: machines, Sandboxes and Stores as typed, extensible units #535; the test keeps asserting the real SQLite profile. It was renamedwal_full_settings_are_explicit.After the fix both commands pass (2/2 and 13/13).
Diagrams
Architecture — final implementation, typed
layerfs-bridge::ControlCapacityconsumed bydoctorandsandbox.get:Recovery sequence — 8 failures, no daemon kill, slot freed, fresh client admitted: