[v0.1/P1] Bound bridge lifecycle and stop on stdout failure #21
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#21
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P1.2). Related to #3 and #5.
Why
The bridge has unbounded stdout/stream channels and one task per request with no in-flight cap. When stdout closes, its writer exits but the bridge can keep stdin and daemon links alive.
Scope
lagonly for the intentionally lossy stream.cwdbefore sending it.Acceptance criteria
Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Accurate against HEAD; every cited gap is present in
crates/felis-cli/src/cli_bridge.rs.:206-214Out(mpsc::UnboundedSender<Option<String>>), with the doc comment defending it ("dropping a protocol object would break the 'exactly one terminal' contract").:1651mpsc::unbounded_channel()inopen_streamfor every capture/search/notification stream; the pump delivers into it synchronously (dispatchis sync by design,:1721-1723).:696-727: every accepted line becomes atokio::spawnand anactiveHashMap entry; nothing counts them. Per-session attached links (Core::sessions,:632) and per-subscription observer dials (op_notifications,:1203-1210) are likewise uncapped.:118BufReader::new(tokio::io::stdin()).lines()thenserde_json::from_stron the whole line (:404). A line without a newline grows without bound before parsing.:420-421accepts anyis_number()(fractions, negatives, floats);parse_requestusesobject.getonly, so unknown top-level fields pass silently.:218-226: the writer task breaks and logs "further protocol output is dropped";serve's select loop (:119-129) only watches stdin andanchor.lost(), so stdin, the anchor, session links, and observers stay alive until the client closes stdin.cwdpasses through.:847-849sends it verbatim; the daemon refuses it (crates/felis-daemon/src/serve/tests.rs:4121spawn_with_args_refuses_a_relative_cwd, "cwd must be absolute"), so a bridge client that passes"cwd":"build"getsSpawnFailed, not the editor-relative directory the comment promises.lagonly on the lossy stream.:1243mapsNotifyMsg::Laggedtolag; capture/search have no lag path and no backpressure.Nothing here was fixed by the post-snapshot commits.
Verdict
accept-with-changes. All of it is bridge-local hardening with no principle question. Changes to the written scope:
felis_client_coreorfelis_cliconstants module), so the two issues do not each pick a line cap.at_capacityfor the in-flight and link caps rather than minting anoverloadedkind.at_capacityis already in the closederror.kindset (docs/reference/cli.md:109-112) with the meaning "the request was fine, retry after reaping"; adding a token touches #23's contract for no distinct consumer branch. The one new kind that is needed isoutput_failedfor the exit-reason taxonomy (Shutdownat:143-176hasEof/InputFailed/DaemonLost), and even that surfaces only in the exit code and stderr, since stdout is the thing that broke.Out).try_sendfails → park the reader on aNotify), so the daemon's outbox for that subscriber fills towardSUBSCRIBER_BUFFER_CAP(session_task.rs:46,:520) and the daemon evicts with a typed terminal. That makes a blocked consumer cost bounded memory on both ends and still yields one terminal; it is the existing eviction contract, not a new one.Approach
crates/felis-cli/src/cli_bridge.rs.lines()with a length-limited reader (read_until(b'\n')into aVeccapped at the #16 line limit; on overflow, discard to the next newline and emiterror_object(&Value::Null, malformed_request "line exceeds N bytes")). Inparse_request(:404-460): ids must be a string (cap bytes, e.g. the same limittagsget) or an integer in0..=2^53-1(as_u64and range check; rejectis_f64); reject any key outside{v,id,op,params}withmalformed_request. Reject unknown keys insideparamsper op too, sinceParamscurrently ignores them (:462+).Core::accept(:660-727): aMAX_IN_FLIGHTcheck onactive.len()before insert, answered withat_capacity;Core::sessionsand the observer dial gated byMAX_LINKSthe same way. Both checks happen before thetokio::spawn, so a refused request costs no task.Outbecomes a boundedmpsc::channelfor items plus an unbounded (or separately bounded) terminal lane;emit_openfor items awaits capacity on the stream task (it is async there), so a slow consumer stalls the stream task, which stalls the per-stream channel, which parks the pump per verdict item 4.Out::startstores anAtomicBool deadand aNotify; the writer sets both on the first write error.Shutdown::OutputFailed(String)(codeEXIT_FAILED); theserveselect (:119-129) gains anout.dead()arm.Core::shutdown(:1260-1272) order for this reason: cancel every stream (existing),settlewithSHUTDOWN_GRACEbut skip writing synthesized terminals (stdout is gone), thenanchor.shutdown(), everysessionslinkshutdown(), and observer links (they die with their tasks' abort). Stdin is not awaited.op_spawn(:834-852),std::path::absolute(cwd)(orenv::current_dir().join) before sending, so the bridge's promise (":847 the bridge's working directory is the editor's") is what the daemon sees. Keep the daemon's absolute-only rule.Docs cascade
docs/reference/cli.mdbridge section: the id grammar, the unknown-field rule, the line cap (cross-reference #16's table),at_capacityon the bridge, and the exit-reason table (0EOF,1stdin/stdout failed,2daemon lost).docs/reference/ipc.md"CLI clients" if the exit-code sentence lives there (cli_bridge.rs:77-78cites it).docs/explanation/architecture/control-surfaces.md(bridge rationale): record why stdout death is fatal and why capture backpressure rides the daemon's eviction instead of a bridge buffer; "Revisit if" an editor needs a lossy capture.skills/felis/SKILL.mdbridge section: id rules,at_capacity, no unknown fields.CHANGELOG.md(bridge contract change).Tests (in
cli_bridge.rstests, alongside:2114)malformed_requestwithid: null, next line still served.2^53refused;2^53-1accepted and echoed as an integer.sessions.listagainst a stub link → oneat_capacity.Outwhose receiver never drains; a stream of M items keeps memory bounded (channel capacity) and the pump stops reading (observable via the stub link's unread count).servereturnsEXIT_FAILEDwithout stdin EOF; every link'sis_lost()is true.:2114-2170); extend with concurrency (two streams, one canceled).Dependencies
Registry.sessionand the positional spawn; otherwise this issue's link/in-flight bounds have to special-case it and then be rewritten).Risk/effort
M. Contained to one file plus docs. Main risk: the bounded-output change interacting with the shutdown
settlepath (a bounded send inside shutdown is the deadlock the current comment warns about,:207-209); the terminal lane must stay unbounded or the shutdown path must usetry_send. Second risk: timing-based tests being flaky; use stub links, not real daemons.Labels
Keep
priority/P1,release/v0.1.0(bridge epoch 1 freezes at the tag).Review amendments (round 2)
MAX_TERMINALS_PENDING, sized to the in-flight cap plus the stream cap, so admission never waits on a slot it already accounted for); the reservation is released when the terminal is written (not merely queued). When no reservation is available, the bridge stops reading stdin (does not admit) until one frees. The terminal lane may bypass item ordering (a terminal jumps the queue ahead of buffered items) but not the total bound. The shutdownsettlepath uses the same reservations it already holds, so the deadlock the:207-209comment warns about cannot occur. A stdout consumer that stays open but stops reading therefore stalls admission rather than growing memory; the existing write-error path (Shutdown::OutputFailed) still covers a closed stdout.MAX_IN_FLIGHT = 4and a stdout sink that never drains, 1000 point requests on stdin leave at mostMAX_IN_FLIGHT + MAX_TERMINALS_PENDINGitems in memory and stdin unread beyond that.Review amendments (round 3)
Outis one bounded FIFO ofLineentries whose capacity is split into two accounted classes: item capacity (bounded, backpressures the stream task) and terminal capacity (one reserved slot per admitted operation, released when the line is written). An item send can never consume a terminal reservation, and a terminal send never waits on item capacity because its slot was reserved at admission, so the stream task'sitem, item, terminalenqueue order is the write order (ipc.md:400-405: an item after its terminal is corruption). Test: a stream with N items and a stalled stdout drains in exact order with the terminal last; a property test over interleaved streams asserts every stream's terminal is the last line carrying its id.Implemented in commit
07a77ae4. The bridge now bounds operations, auxiliary links, stream queues, and stdout backlog; reserves terminal output capacity; validates requests before admission; and shuts down promptly on stdout or daemon-link failure. The fulljust checkgate passes, and an independent Sol review returned PASS.