Add a correlated input-admission fence for sessions send #306
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
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#306
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?
Problem
felis sessions sendneeds to keep its connection alive until the daemon has admitted every preceding input frame. It currently establishes that ordering by issuing a secondOps::Listquery and waiting for the roster reply. The IPC reference documents the mechanism only as "reads a query back across the same connection".This works because the inbound pump reads and admits frames in socket order before it handles the later query. The query itself is unrelated to input, however:
This is not a v0.1 blocker because the existing ordering is functional and documented. A typed fence would make the guarantee explicit when the IPC next grows.
Proposed contract
Add a correlated attached-session request/reply such as
InputFence/InputAccepted.The reply should mean:
Processing the fence through the session actor after earlier input commands would give it a stronger and easier-to-test meaning than the current roster reply while preserving bounded backpressure.
sessions sendcan use the fence at an effective minor that defines it and retain the roster-query fallback for an older daemon.Acceptance criteria
sessions send --timeoutcovers the fence wait without changing its documented exit classes.Principle check
sessions sendis an existing consumer, and the proposal replaces an unrelated roster operation with a typed IPC contract.Evidence
crates/felis-cli/src/cli_sessions.rs(cmd_sendpost-write roster query)crates/felis-daemon/src/serve.rs(pump_inboundinput reservation andOpsrouting)crates/felis-daemon/src/serve/session_task.rs(SessionCmd::Input)crates/felis-daemon/src/serve/streaming.rs(write_pty_reserved)docs/reference/ipc.md"Client → daemon: the input budget"docs/reference/cli.mdsessions sendDirection agreed 2026-09-21 after three
soldesign-review rounds (final verdict PASS). Full plan and batch order: see the comment on #12.and #309 preserves it for structured keys through the budget reservation.
The final pre-freeze interface audit makes this a v0.1/P1 item rather than a post-release cleanup.
There is no published daemon that requires the
Ops::Listfallback. Shipping that fallback would permanently couple input admission to an unrelated roster query and would make every external implementation learn an accidental ordering trick. Land the typedInputFence/ acknowledgement in protocol 1.0 and remove the roster-query barrier rather than carrying mixed-minor fallback code into the first baseline.This also resolves finding 1 in #326:
SessionMsgcorrelation has a concrete use, so retain the field-100 correlation envelope instead of removing it.The completion boundary in this issue remains correct: acknowledgement means daemon admission/ownership, not child consumption.
Triage plan (2026-09-22)
Direction reviewed through nine
soldesign rounds (final verdict PASS). The plan below is the agreed shape; the PR implements it as written and records deviations in its body.Wire (
crates/felis-protocol/proto/felis.proto):SessionMsggainsSessionInputFence input_fence = 8with(felis.v1.arm)= TO_DAEMON, CORRELATION_REQUEST_OPENER, modes WINDOW+OPS, phase ATTACHED, since 0; andSessionInputAccepted input_accepted = 9= TO_CLIENT, CORRELATION_REQUEST_REPLY. Both messages are empty. The field-100 correlation slot comment ("no arm uses it") and the ipc.md correlation table Session row (ipc.md:389) change to name this pair.messages/session.rsgains the two variants withCorrelationClass::RequestOpener/RequestReplyinDirected::ARMS;convert/session.rsmaps them;minor.rsMinorGated for SessionMsgfolds the new arms througharm_requires(since 0, so no ledger row).Meaning of
InputAccepted: everyInputframe that preceded the fence on this connection has been processed by the session actor. For the byte-carrying arms (KeyBytes,Paste,Key,Mouse) the bytes they encoded to were admitted against the input budget and queued to the PTY writer. The other arms (Resize,FocusChange,ColorScheme, viewport, frame pull, prompt jump) carry no budget reservation; the reports some of them write to the PTY under DECSET 1004 / 2031 / 2048 are unreserved writes that the actor attempts before it reaches the fence (write_ptyreturns success even whenPtyWriteranswersWriteOutcome::Droppedon a saturated gauge), so they are ordered ahead of the reply but best-effort: a report can be dropped and the fence still completes. The reference says exactly that: attempt order is guaranteed, delivery of generated reports is not. It does not mean the child read anything. Tests: a resize before the fence (no report); a focus change with DECSET 1004 enabled before the fence on an unsaturated writer, asserting that the report was enqueued (WriteOutcome::Queued) before the fence reply was enqueued to the outbox, and that the report is eventually read from the PTY (no ordering against the client's receipt of the reply, which travels independently through the subscriber outbox and can arrive first); the same on a saturated writer, asserting the fence still completes while the report was dropped.Daemon:
serve.rs::route_frameMessageKind::Sessionarm:InputFencebecomesRoute::Cmd(SessionCmd::InputFence { sub, request: delivered.request() }).session_task.rs: newSessionCmd::InputFence { sub, request }; the actor answers withOutEvent::Session { msg: SessionMsg::InputAccepted, correlation: Correlation::request(request) }throughpush_to, thehandle_regionpattern.OutEventhas noSessionvariant: addOutEvent::Session { msg, correlation }and its encoding arm instreaming::body_for(serve/streaming.rsaround line 916, the exhaustive match thatwrite_out_eventdelegates to) usingCheckedFrame::encode_correlated.pump_inboundacquires the input-budget permit for anInputframe before it forwardsSessionCmd::Inputon the actor channel, and it does not read the next frame until then. A fence therefore cannot overtake a budget-stalled input:FrameReadermay have read the fence's bytes ahead into its buffer (wait_for_hangupkeeps bounded read-ahead going while admission stalls), but the fence is not taken from the reader or dispatched to the session actor until the preceding input's reservation succeeds and that input is forwarded. Once forwarded, the actor processes commands in channel order, so by the time it sees the fence it has runhandle_admitted_inputfor every earlier input.wait_for_hangupandcmd.closed(); a session exit closes the channel and the connection ends, which the client reports assession_ended/daemon_lostas today.Structural JSON (
felis_json: 1): the format carries no correlation envelope (json_v1::Frame::from_bodydecodes the body only andinto_bodyusescodec::encode), and ipc.md says the families the bridge serves belong to the CLI output contract instead. The fence pair is a correlated request/reply driven by the CLI and bridge, so it is excluded fromfelis_jsonrather than added: theSessionMsg→SessionJsonconversion incrates/felis-grid/src/json_v1/session.rsmapsInputFenceandInputAcceptedto the error the format already uses for a body outside it (find the variant ofJsonErrorfor an unrepresentable body, or add one), a test pins that both arms are refused with that error, and the ipc.mdfelis-jsonsection lists the two arms as outside the format with the reason (no correlation slot). v1 stays unchanged: no schema or golden regeneration.Protocol fixtures:
session_cases()incrates/felis-protocol/src/messages/session.rs(around lines 270-433) is exhaustive andsession_cases_cover_every_variantfails without the new arms; add both, with the correlation-class assertions the other correlated families' fixtures make (request opener / request reply), and a codec round trip throughencode_correlatedwith a nonzero request id.Client-core:
connector.rsgainsinput_fence()besidelist_sessions(), acorrelated_requestover aFenceReqshaped likeListReq.CLI:
cli_sessions.rs::cmd_sendreplacesroster(&mut conn)at the barrier withconn.input_fence(); the timeout and error-kind mapping are unchanged (timeouton elapsed deadline,daemon_loston failure). No roster fallback: no daemon is published, so the fence is part of the base schema. The doc comment at cli_sessions.rs:49 loses the "List keeps the connection open" role forsend.Bridge:
cli_bridge.rsLink::send_input(around line 2585) has its own copy of the barrier (self.request(&OpsMsg::List)after the input). It switches to the fence through the same correlated-request path (self.request(&SessionMsg::InputFence)).Link::decodetoday decodes every correlatedSessionMsgtoNone, whichdispatch_payloadreports as a droppable frame, so the reply would be dropped as written: addDaemonMsg::Session(SessionMsg), routeMessageKind::Sessionthrough it inLink::decode, and havesend_inputmatchSessionMsg::InputAccepted(any other session reply on that correlation is a protocol error). Bridge tests provesessions.sendwaits forInputAcceptedand that noOps::Listframe follows the input (the fake-daemon harness in the bridge tests reads the next frame after the input and asserts it is the fence).Tests:
crates/felis-cli/tests/cli_sessions.rsrogue_daemon: the frame after the input is now the fence.send_waits_for_admission_rather_than_dropping_its_inputandsend_wait_timeout_bounds_the_admission_of_a_wedged_childkeep passing unchanged in intent.serve/tests.rsor a new integration test): empty fence on a fresh attach answers immediately; input then fence answers after the input reached the writer; a budget-saturated input followed by a fence is not answered until the budget frees (the fence never overtakes); a client that closes afterInputAccepteddoes not lose the admitted bytes (the child reads them).run_sessionselect is biased toward commands, so a fence that is already queued when the PTY reaches EOF may legitimately be answered before the exit. The guaranteed boundary is actor-channel closure: once the session has ended (the client has seenSessionExited, or the actor channel is closed), a fence gets no reply and the connection ends. The test pins that boundary: sendSessionExitedto be observed first (or close the actor), then send the fence, and assert the connection ends withoutInputAccepted. No new lifecycle rule; the reference states that a fence answered during exit means only that the bytes were admitted before the session ended.Docs: ipc.md Session (kind = 4) arm list, correlation table row, the input-budget paragraph (ipc.md:1824 "reads a query back"), and the
SessionMsgproto comments; cli.mdsendbullet (the admission sentence);docs/explanation/architecture/ipc.mdwherever the roster barrier is discussed;skills/felis/SKILL.mdif it describes the barrier.