Resolve attach targets on the daemon, and answer sessions info in one reply #322
No reviewers
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!322
Loading…
Reference in a new issue
No description provided.
Delete branch "attach-by-prefix"
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?
Closes #310
What
Attach targets are now resolved by the daemon, and
sessions infogets its row and itsshort_idfrom one reply.SessionMsg::Attachcarriestarget: either a full id or anid_prefix. The wire keepsSessionAttach.id(bytes, field 1) and addsSessionAttach.id_prefix(string, field 3); exactly one must be set, and a frame setting both or neither is refused at decode. A sibling field rather than foldingidinto aoneof, which would have renumbered a released field for nothing the validator does not already give.NoMatchon the prefix, never an attach to a row the pool has dropped. Two newAttachFailurevariants,NoMatchandAmbiguous(count indetail), both downgrading toUNKNOWN_SESSIONfor an older peer.Ops::Info { id_prefix }/Ops::InfoReply { outcome }with a closed union in theOpsSpawnedstyle:found { session, short_id }|no_match {}|ambiguous { matches }. Only the successful arm carries session data. The daemon builds all three — resolution, row andshort_id— from one pool snapshot, so they cannot disagree about which sessions exist.sessions infoand the bridge'ssessions.infouseOps::Info;send,capture,searchattach by prefix; the bridge'sresolve()usesOps::Info(its session slots are keyed by full id, so it needs the id rather than a prefix attach) and the comment claiming attach-scoped verbs cannot use daemon-side resolution is gone.--sessionstartup andLanding::ResolveOnTargetattach by prefix. The roster fetch stays: it seeds the window's switch ring, which the prefix no longer needs. Since the prefix no longer depends on it, a roster-query failure is no longer fatal for that landing (it still is forPickExit, which genuinely needs the roster).Landing::AttachExactand the reconnect ladder are unchanged: they already hold a full id and would otherwise pay a resolution for an id nobody shortened.Wire shapes
OpsMsgarmsinfo = 17/info_reply = 18(appended, soStop/StopReplykeep tags 15/16).AttachFailuregainsNO_MATCH = 8,AMBIGUOUS = 9.just proto-compatreports wire-compatible with the merge-base; noBREAKING.mdentry needed.The control-surfaces decision
The rejection "no shortening against the daemon's whole roster" is rewritten in place, not deleted. What it ruled out was the CLI owing a second round trip and emitting an unchecked prefix; a prefix the daemon checked against the authoritative pool in the reply it was already making is neither, and the pool's owner is the only party that can shorten correctly.
sessions infotherefore keeps its documented always-presentshort_idwithout anOps::List.Minor numbering
This takes PROTOCOL_MINOR 12, not 13 as the batch plan assigned.
The plan asked for 13 with a placeholder row at 12 held for #307 (
grid-cycle-end). That is not expressible:the_ledger_covers_every_executable_additionrequires every ledger row to name at least one identifier that some arm, field or enum value declares at that minor, and a row reserved for work on another branch declares nothing. A placeholder fails the build, so 12 is the only contiguous number this branch can take.#307 should take 13. If #307 lands first instead, this branch rebases onto it and moves to 13 — a one-line change to
MINOR_ATTACH_BY_PREFIX, the proto header, thesince=lines and the ledger row. Either way #313 folds every minor to 0 before release.Stacking
Branched from
origin/connection-mode-minor(PR #319, issue #312, minor 11), which is not merged. This must merge after #319. The diff againstmaintherefore contains #319's commit too; reviewd71fc127alone.Compatibility
Both additions are gated client-side on the effective minor. Below 12 the CLI, the bridge and the GUI fall back to the
Ops::Listround trip and the client-side resolver they used before, so nothing regresses against an older daemon; the ledger row indocs/reference/ipc.mdstates it.Tests
Protocol:
an_attach_naming_both_an_id_and_a_prefix_is_rejectedan_attach_naming_no_target_at_all_is_rejectedan_attach_naming_exactly_one_target_decodes_to_that_armInfoOutcomearmsSessionAttach.id_prefixgated-field row andNO_MATCH/AMBIGUOUSgated-enum rowsDaemon:
an_attach_by_prefix_lands_on_the_session_the_daemon_resolvesa_prefix_whose_session_was_destroyed_refuses_with_no_match(the race test)an_ambiguous_prefix_on_attach_refuses_with_the_match_countops_info_answers_a_row_and_a_short_id_checked_against_the_pool(the reportedshort_idmust resolve back against the pool)ops_info_reports_no_match_and_ambiguity_as_outcome_armsClient core:
the_minor_gates_refuse_against_a_minor_zero_daemonextended toattach_by_prefixandsession_infoCLI:
info_json_still_carries_a_short_id_without_a_roster_fetchcrates/felis-cli/tests/golden/error_kinds.txtgainsNoMatch -> no_matchandAmbiguous -> ambiguous, so a daemon-resolved prefix failure reports the same token and exit 1 the client-side resolver didFuzz: seeds
proto-session-attach-by-prefix.binandproto-ops-info-found.binunderfuzz/seeds/ipc_body/.Gates:
cargo nextest run --workspace --all-features3345/3345 pass;cargo clippy --workspace --all-targetsclean;just proto,just schema(no diff),just proto-compat,just docs-links, andprose-checkagainst the stack base all clean.An empty prefix had no wire shape. `AttachTarget::Prefix("")` encodes as an attach with neither `id` nor `id_prefix` set, which is exactly the frame the decoder refuses, so a caller passing one lost the connection instead of being told. `SessionMsg::validate` now holds every attach prefix to the same definition the clap parser and the resolver use, and `Connection::attach_by_prefix` refuses one eagerly, so a prefix no session id can start with never reaches the socket. Session id `0` is unaffected: sixteen zero bytes is a set field, not an absent one. The GUI still paid for a roster it had stopped reading. `run_client` fetched one before every attach and aborted on a failed query, so `felis attach <prefix>` spent a round trip that only the client-side resolver ever used, and a roster error killed a window whose attach would have succeeded. The fetch now happens only where the resolution still needs it, below the minor that moved resolution into the daemon. The CLI reported every failed attach as `no_match`. A socket that closed mid-attach and a daemon that answered "no such session" then looked identical, though the exit contract gives the first `2` (retry) and the second `1` (pick again). `no_match` and `ambiguous` are now reserved for the daemon's typed refusals; connection loss maps to `daemon_lost` and a protocol violation to `protocol`. Assisted-by: Claude CodeAll four blockers are addressed on
attach-by-prefix, as four additive commits on top ofd71fc127(no force-push). Head is now5b9cd72d.1. Empty prefix bypassed the exactly-one-target rule
Two layers, because the hole existed at two:
SessionMsg::validate(messages/limits.rs) holds anAttachTarget::Prefixtovalidate_session_id_prefix, the same definition the clap value parser and the daemon's resolver use.Validateruns on both encode and decode, so an invalid prefix is refused before a byte leaves and at the daemon's decode.Connection::attach_by_prefixrefuses eagerly with a newConnectError::InvalidSessionPrefix { prefix, reason }(non-transient), so a library caller gets a typed error naming the prefix rather than a transport failure thatis_transient()would have told it to retry.This makes the attach prefix stricter than the
Opsverbs'id_prefix, which still answer a malformed prefix withNoMatch. That is deliberate and now documented atdocs/reference/ipc.md(Session family): an attach shares one target slot betweenidandid_prefix, so it has noNoMatchreply to fall back on — a malformed prefix there is a malformed frame.Exact id
0is unaffected:id_to_bytes(0)is sixteen zero bytes, a set field, so it still reads as the exact arm.Tests:
an_empty_attach_prefix_is_refused_before_it_is_sent(empty, non-hex, 33 digits rejected;"0"accepted)an_attach_by_the_zero_id_round_trips_as_an_exact_targetattach_by_prefix_refuses_a_prefix_no_session_id_can_start_with(client-core; the duplex peer never answers, so a guard that failed to fire would hang rather than pass)2. GUI attach still did the roster round trip
Right —
initial_sessionsis used for exactly one thing (attach_for_mode's client-side resolution) and nothing else, so at minor ≥ 12 the fetch was pure waste and a failed query aborted a window whose attach would have succeeded.run_clientnow fetches the roster only when the resolution needs it:No
Ops::ListprecedesSessionAttachat minor ≥ 12 at all; the window keeps no roster, so nothing else needed seeding. (Landing::ResolveOnTargetindial.rskeeps its fetch, which genuinely seeds the switch ring there, and it was already non-gating for the prefix.)3. Attach transport failures reported as
NoMatchNew
attach_failure_kind(&ConnectError) -> ErrorKind, used by bothattach_by_prefixandattach_exact(the exact path had the same bug):ConnectErrorAttachFailed { reason }from_attach_failure(reason)Refused { reason }from_refusal(reason)InvalidSessionPrefixinvalid_requestMinorTooOldunsupportedIo/Connect/Transport/Preface/EofBeforeWelcome/EofMidAttachdaemon_lostprotocolTests:
a_daemon_closing_mid_attach_exits_two—cmd_sendagainst a scripted daemon that reads theAttachframe and hangs up, with a JSON reporter; asserts exit2only_a_typed_refusal_reports_a_which_session_failure— the full classifier table, including thatEofMidAttachis notno_match4. Prose gate
python3 tools/prose_check.py --range f5fe7e14...HEAD→ 0 hits. Fifteen fixed: five over-length comments trimmed (connector.rs×2,felis.proto×2,messages/session.rs,preface.rs), three "no longer" phrasings rewritten in the present tense (dial.rs×2,serve/tests.rs), and five em-dashes replaced (control-surfaces.md,explanation/architecture/ipc.md×2,reference/cli.md,reference/ipc.md) plus the one "no longer" in the explanation prose.just protore-run after the schema comment edits; the regeneratedsrc/generatedis in09bb8b3b.Gates
cargo clippy --workspace --all-targetsclean (onemissing_const_for_fnon the new classifier fixed in5b9cd72d).cargo nextest run --all-features: felis-protocol + felis-client-core 768/768, felis-daemon + felis-client 649/649, felis-cli 323/323.just proto-compatwire-compatible with the merge-base;just docs-linksclean.Round 2 blockers addressed on
attach-by-prefix(additive, no force-push). Head:39bfc04e.1. Bridge attach-scoped verbs no longer resolve then attach by exact id.
Coregainedreserve_link,acquire_by_prefix,connect_attached_by_prefix,adoptandtake_live;with_sessionwas replaced bywith_session_by_prefix, which returns(id, T)so the slot is keyed from the attach ack rather than from a preliminary answer. At minor >=MINOR_ATTACH_BY_PREFIXthe auxiliary link reserves a permit, dials, and attaches withConnection::attach_by_prefix; the id comes fromAttached.info.id. TheOps::Info+ exact-id path is kept only below the minor.adoptchecks for a live link beforedrain_rehydrate, so a session that already has a link costs one round trip rather than a rehydrate.2.
connect_attachedno longer flattens everyConnectErrortoNoMatch.attach_failure_kindmoved out ofcli_sessions.rsintocli_output.rsasErrorKind::from_connect_error(&ConnectError). Typed refusals map to their domain kinds, EOF/transport/dial todaemon_lost, codec and driver violations toprotocol. Both bridge attach paths and everycli_sessions.rscall site use it.Tests added (
crates/felis-cli/tests/cli_bridge.rs):a_prefix_that_turns_ambiguous_before_the_attach_is_reported_ambiguous— a scripted daemon answersOps::Info/Ops::Listas exactly one match, asserts theSessionAttachcarriesAttachTarget::Prefix, and refuses withAmbiguous; the op must report kindambiguous. Under the old shape the daemon would see an exact id and the assertion fires.a_daemon_closing_on_the_attach_is_reported_as_a_lost_daemon— the same daemon hangs up after reading the attach; the op must reportdaemon_lost, notno_match.Both share
scripted_attach_daemon/AttachScript/await_socket, which accept in a loop so the anchor and the auxiliary link are both served.Gates:
python3 tools/prose_check.py --range f5fe7e14...HEAD-> 0 hits;cargo clippy -p felis-cli --all-targets --all-featuresclean;cargo nextest run -p felis-cli --all-features-> 325/325 passed.Round 2 blockers addressed on
attach-by-prefix(additive, no force-push). Head:39bfc04e.1. Bridge attach-scoped verbs no longer resolve then attach by exact id.
Coregainedreserve_link,acquire_by_prefix,connect_attached_by_prefix,adoptandtake_live;with_sessionwas replaced bywith_session_by_prefix, which returns(id, T)so the slot is keyed from the attach ack rather than from a preliminary answer. At minor >=MINOR_ATTACH_BY_PREFIXthe auxiliary link reserves a permit, dials, and attaches withConnection::attach_by_prefix; the id comes fromAttached.info.id. TheOps::Info+ exact-id path is kept only below the minor.adoptchecks for a live link beforedrain_rehydrate, so a session that already has a link costs one round trip rather than a rehydrate.2.
connect_attachedno longer flattens everyConnectErrortoNoMatch.attach_failure_kindmoved out ofcli_sessions.rsintocli_output.rsasErrorKind::from_connect_error(&ConnectError). Typed refusals map to their domain kinds, EOF/transport/dial todaemon_lost, codec and driver violations toprotocol. Both bridge attach paths and everycli_sessions.rscall site use it.Tests added (
crates/felis-cli/tests/cli_bridge.rs):a_prefix_that_turns_ambiguous_before_the_attach_is_reported_ambiguous— a scripted daemon answersOps::Info/Ops::Listas exactly one match, asserts theSessionAttachcarriesAttachTarget::Prefix, and refuses withAmbiguous; the op must report kindambiguous. Under the old shape the daemon would see an exact id and the assertion fires.a_daemon_closing_on_the_attach_is_reported_as_a_lost_daemon— the same daemon hangs up after reading the attach; the op must reportdaemon_lost, notno_match.Both share
scripted_attach_daemon/AttachScript/await_socket, which accept in a loop so the anchor and the auxiliary link are both served.Gates:
python3 tools/prose_check.py --range f5fe7e14...HEAD-> 0 hits;cargo clippy -p felis-cli --all-targets --all-featuresclean;cargo nextest run -p felis-cli --all-features-> 325/325 passed.Round 3 blockers addressed on
attach-by-prefix(additive, no rebase or force-push). Head:7b595293.1.
adoptgives up its connection before its capacity (24a32f31).New free fn
discard(conn, permit)incli_bridge.rs: it wraps the connection the adoption is giving up in aLink, sendsDetach, runs the carrier shutdown, and only then drops the permit. All three exits ofadoptnow go through it: bothtake_liveearly returns and thedrain_rehydratefailure, which became an explicitif let Err(err)so the connection can be torn down before the error propagates. This is whatdocs/reference/cli.md(the auxiliary-link paragraph) already promised.Test added:
cli_bridge::tests::a_redundant_adoption_yields_its_capacity_only_after_it_closes. A semaphore of 2 is fully taken (one by the installed slot, one by the redundant dial); a spawned replacement blocks onacquire_owned, so it can only run afteradoptreleases the permit, and it then asserts the redundant connection's peer already has aSessionMsg::Detachframe followed by EOF. Verified failing against the old shape ("the redundant connection detaches before it closes") and passing with the fix.2.
short_iddocs cascade (7b595293).docs/explanation/architecture/control-surfaces.md: the display-only argument now names both rosters, the roster carried in the same reply onlistand the daemon's pool at resolution time oninfo, and keeps the "a script that stores one has stored an ambiguity" advice.skills/felis/SKILL.md: same split, "never persist it" kept.docs/reference/cli.md(Session identity) already said exactly this and was left alone;docs/explanation/architecture/ipc.mdand theCHANGELOG.mdhistory entries are consistent. Sweptdocs/,skills/andREADME.mdfor other repeats of the phrasing; there were none.Gates:
python3 tools/prose_check.py --range f5fe7e14...HEAD-> 0 hits;cargo clippy -p felis-cli --all-targets --all-featuresclean;cargo nextest run -p felis-cli --all-features-> 326/326 passed.One note on the gate range:
--range main...HEADreports 2 comment-length hits, butgit log -Lattributes both (crates/felis-client-core/src/connector.rs:239,crates/felis-protocol/proto/felis.proto:162) tof5fe7e14on theconnection-mode-minorbase, not to this branch. I left them for that PR rather than editing its lines from here.Merged
mainand addressed the round-4 findings. Head:0b04fb36.Merge (
7caf844e, a merge commit, no rebase)connection-mode-minorlanded squashed as #319, so this branch's copy of that work conflicted with the reviewed version. Resolutions:MINOR_ATTACH_BY_PREFIXconstant, itsMINOR_LEDGERrow after main'sMINOR_UNKNOWN_MODE(11), itsdocs/reference/ipc.mdledger row, and the frozen preface bytes at0x0C. Main's revised row-11 "Older peer" cell replaced this branch's copy. Ready to renumber to 13 once #321 lands.check_mode_minordoc comment inconnector.rs, and the future-mode decode inserve.rs(thepeek_correlationguard and thevalue != 0condition that keepsUNSPECIFIEDa malformedHellorather than a future mode).ConnectionModeledger paragraph infelis.proto(which main folded into the following sentence), and the daemon testa_hello_naming_an_unknown_mode_is_refused_by_name, whose second copy had been spliced into the doc comment ofmalformed_input_frame_does_not_orphan_the_session.just protore-run after the proto comment change.This also removes the two
comment-lengthhits the earliermain...HEADrun reported, since both belonged tof5fe7e14.Blocker: the live check and the install were not atomic (
0b04fb36)The second
take_liveand thesessions.lock()install were separate lock acquisitions, so two adopters could both find no link and then serialize on the lock, the loser replacingslot.linkwhile bumpingusers. The replaced link stayed owned by its reader pump, keeping its carrier and its daemon subscriber alive with no permit held for them.New
Core::install_or_borrow(id, conn, permit)does the check and the install under onesessionslock and returns the link the slot ends up with; a connection that finds a live link is routed throughdiscard, so its admission comes back only afterDetachand carrier shutdown.adoptnow ends in it. The exact-idacquirepath got the same treatment: its install re-checksslot.live()under the install's own lock, and both that path and theCanceledpath close their connection through the newclose_carrier(whichdiscardis now built on) instead of dropping it.Test added:
cli_bridge::tests::concurrent_adoptions_install_one_link_and_close_the_other. Two adopters with their own connections and permits meet at atokio::sync::Barrier, then both callinstall_or_borrowfor the same id. It asserts both get the sameArc<Link>, the slot countsusers == 2, exactly one of the two peers is closed and its last bytes are aSessionMsg::Detach, and exactly one permit came back. Verified failing against the replacing shape ("both adopters borrow the one installed link").Non-blocking: the waiting assertion
a_redundant_adoption_yields_its_capacity_only_after_it_closesno longer awaits anything after the permit is acquired. A new helperdrained()reads the peer through a non-blockingstd::os::unix::net::UnixStream, returningSome(bytes)only once it has already seen EOF andNonewhile it is still open, so an early release fails instead of waiting for the teardown it was supposed to follow. Both tests use it, along with smallpeer_of/carrier/detach_bodyhelpers.Gates
python3 tools/prose_check.py --range main...HEAD-> 0 hits;cargo clippy --workspace --all-targets --all-featuresclean;cargo nextest run -p felis-cli --all-features-> 328/328;cargo nextest run -p felis-daemon -p felis-client-core --all-features-> 1041/1041;felis-protocol-> 206/206;just proto;treefmtand the pre-commit hooks on every commit.Round-5 doc finding folded in. Head:
1c87ce97.docs/reference/ipc.md(theAttachbullet) claimed "nothing can reap the session between the resolution and the subscription".take_attach_handleresolves the prefix and clones the handle under one pool lock, but releases it beforesubscribe_to, so a teardown still races the subscription. Reworded:The CHANGELOG entry carried the same overstatement ("a session reaped mid-request is reported as
no_matchrather than acted on from a stale snapshot") and now reads: resolution happening where the pool is means no roster a client holds can go stale between matching a prefix and acting on it, and a session reaped in flight isno_match"unless the teardown wins the race with the subscription that follows, which is refused the way an attach by full id has always been". It names no error kind there deliberately:AttachFailure::SessionEndingsurfaces asinvalid_requestin the CLI table, not as a kind of its own.Minor stays at the provisional 12.
Gates:
python3 tools/prose_check.py --range main...HEAD-> 0 hits;cargo nextest run -p felis-protocol --all-features-> 206/206 (the ledger/table cross-check); pre-commit hooks (lychee, prose check, treefmt) green on the commit.Merged
main(through #321,ead2f97d) and renumbered this branch to minor 13. Head:af5851d4, a merge commit; no rebase, no force-push.Conflicts were only where the two branches both claimed 12:
crates/felis-protocol/src/preface.rs:PROTOCOL_MINORis now 13,MINOR_GRID_CYCLE_END = 12(main's) is kept ahead ofMINOR_ATTACH_BY_PREFIX = 13, theMINOR_LEDGERrows follow in that order, and the two frozen preface byte arrays carry0x0D.docs/reference/ipc.md: main's row 12 kept, this branch's row moved to 13 with its "below minor 12" prose updated, and theAttachbullet's "(a lowercase-hex prefix, minor 12)" now reads 13.CHANGELOG.md: both entries kept, this branch's first, its heading now "protocol minor 13" (and its older-daemon sentence with it).Also renumbered, none of which conflicted: the proto header comment, the
since=on theOpsInfo/OpsInfoReplyarm lines, and the three "Minor 12" prose comments infelis.proto(the twoAttachFailurevalues andSessionAttach.id_prefix), followed byjust prototo regenerate. Everything #321 owns (GridMsg::CycleEnd, the pull/shadow changes, its fuzz seed, its docs) came through as main has it.Gates:
cargo clippy --workspace --all-targets --all-featuresclean;felis-protocol206/206;felis-cli328/328;felis-daemon475/475;felis-client-core+felis-transport732/732;just proto-compatwire-compatible against the new merge baseead2f97d;python3 tools/prose_check.py --range origin/main...HEAD-> 0 hits;treefmtclean.