Skip to content

fix(sandbox): 恢复 sandbox.get 的 daemon_capacity 读取,并修隔离连接回池问题(#543) - #68

Merged
mengmengmengqiang merged 6 commits into
tokenaissance:fix/540-daemon-slotsfrom
jiangkoumo:fix/543-gaps
Nov 11, 2026
Merged

mengmengmengqiang merged 6 commits into
tokenaissance:fix/540-daemon-slotsfrom
jiangkoumo:fix/543-gaps

Conversation

@jiangkoumo

Copy link
Copy Markdown

给 Ephemeral-AI-Lab#543 补几处缺口,目标分支是 Ephemeral-AI-Lab#543 本身(fix/540-daemon-slots),合进来后 Ephemeral-AI-Lab#543 即可继续评审。

改了什么

  1. 恢复 sandbox.get 的 daemon_capacity(88c88fbec)。这条读取路径作者在 7cfe4460e、52f9c1666、31375c942 已经实现,82165e666 合并时遗落了,导致 PR 正文说有、源码里却没有。本提交按原设计恢复,不是新设计。
  2. 补测试:读取的路由、超时墙、池槽占用(811f59e02);文档表格里直接引用 Daemon control slots leak: a connection that fails after a request stays Retained forever, and the daemon then refuses every connection Ephemeral-AI-Lab/layerfs#540(de009a8b8)。
  3. 隔离的控制连接不再回池(49e540386):Fence 关闭时同步 Control.failed;从池里借连接后再检查 deadline。补两条回归测试。

验证

说明

  • retained 信息只保存在内存,sandbox.close 后消失,这是现设计。
  • 前两个旧提交(5002922ea、8978c2eb4)的提交消息未整理,按不改写历史的惯例保留。

Refs Ephemeral-AI-Lab#540

…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).
Copilot AI balanced review requested due to automatic review settings November 11, 2026 11:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@mengmengmengqiang
mengmengmengqiang merged commit 4f4906a into tokenaissance:fix/540-daemon-slots Nov 11, 2026
2 of 4 checks passed
@mengmengmengqiang

Copy link
Copy Markdown
Collaborator

谢谢,我把这个 PR 合进本地 fix/540-daemon-slots(未推送)实测了一遍:13 个新测试全部通过,合并无冲突。

先说一处更正 —— 之前有一版评审意见认为「全满时按上界返回 down_reason」与代码不符,那是不成立的,你这句是对的:

我用一个只写 LAYERFS-CAPACITY notice 的 stand-in daemon 实测 sandbox.get:60.1 s 后成功返回,state: running、workspaces: [],down_reason 是「…no vacant control slot: 0 pool slot(s) and 1 reserved slot(s) are vacant, with 3 retained failure record(s)…」,且没有 daemon_capacity。所以确实是「按上界返回 down_reason、不挂起」。那版意见只看了 get.rs / pool.rs,没跟到 direct::unanswered。

真正值得改的是满池时计数全丢,链路我逐段确认过:

  1. 拨号层 60 s 后给出精确的 Capacity:resource: daemon_control_slot、pool_vacant、reserved_vacant、retained 都在 detail 里;
  2. direct::unanswered() 把 Busy / StaleSandbox 之外的拒绝重写成 Unavailable,把原 message 拼进新 message,detail 丢掉;
  3. direct::list() 再把 Unavailable 变成 Unreached;
  4. sandbox.get 写成 down_reason(纯文本),且因 down 为真,capacity() 直接 None,所以 daemon_capacity 也不挂。

也就是说,恰恰在最需要这三个计数的时候,它们只剩一句人话。两个不改 SDK 形状的修法:
a. direct::unanswered 重写时把原 detail 的 resource / pool_vacant / reserved_vacant / retained 带过去(Unavailable 也可以带 detail);
b. 或者 sandbox.get 在 down_reason 有值时仍做一次有界的 capacity 读并挂 daemon_capacity(现在 capacity() 对 down 直接 None),这正是这个字段存在的场景。

反过来不建议:让 Capacity 直接穿过 unanswered —— 那样 sandbox.get 会整个失败、连 record 都拿不到,比现在更差。

另附一条实测现象(不是缺陷):满池时 sandbox.get 会等满 60 s 准入窗口才返回 down_reason,你正文里「按上界」是准确的;如果觉得 60 s 偏长,可以另开 issue 讨论 sandbox.get 是否该用更短窗口。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants