Resolve attach targets on the daemon, and answer sessions info in one reply #322

Merged
natsukium merged 15 commits from attach-by-prefix into main 2026-09-21 18:08:19 +09:00
Owner

Closes #310

What

Attach targets are now resolved by the daemon, and sessions info gets its row and its short_id from one reply.

  • SessionMsg::Attach carries target: either a full id or an id_prefix. The wire keeps SessionAttach.id (bytes, field 1) and adds SessionAttach.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 folding id into a oneof, which would have renumbered a released field for nothing the validator does not already give.
  • The daemon resolves the prefix and takes the session handle under one lock, so a session reaped before the attach arrives is a NoMatch on the prefix, never an attach to a row the pool has dropped. Two new AttachFailure variants, NoMatch and Ambiguous (count in detail), both downgrading to UNKNOWN_SESSION for an older peer.
  • New Ops::Info { id_prefix } / Ops::InfoReply { outcome } with a closed union in the OpsSpawned style: found { session, short_id } | no_match {} | ambiguous { matches }. Only the successful arm carries session data. The daemon builds all three — resolution, row and short_id — from one pool snapshot, so they cannot disagree about which sessions exist.
  • sessions info and the bridge's sessions.info use Ops::Info; send, capture, search attach by prefix; the bridge's resolve() uses Ops::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.
  • The GUI's --session startup and Landing::ResolveOnTarget attach 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 for PickExit, which genuinely needs the roster).
  • Landing::AttachExact and the reconnect ladder are unchanged: they already hold a full id and would otherwise pay a resolution for an id nobody shortened.

Wire shapes

message SessionAttach {
  bytes id = 1;          // empty when id_prefix carries the target
  bool live_only = 2;
  string id_prefix = 3;  // minor 12
}

message OpsInfo { string id_prefix = 1; }
message OpsInfoReply {
  oneof outcome {
    OpsInfoFound found = 1;      // SessionInfo session = 1; string short_id = 2;
    OpsInfoNoMatch no_match = 2; // empty
    OpsInfoAmbiguous ambiguous = 3;  // uint32 matches = 1;
  }
}

OpsMsg arms info = 17 / info_reply = 18 (appended, so Stop/StopReply keep tags 15/16). AttachFailure gains NO_MATCH = 8, AMBIGUOUS = 9. just proto-compat reports wire-compatible with the merge-base; no BREAKING.md entry 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 info therefore keeps its documented always-present short_id without an Ops::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_addition requires 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, the since= 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 against main therefore contains #319's commit too; review d71fc127 alone.

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::List round trip and the client-side resolver they used before, so nothing regresses against an older daemon; the ledger row in docs/reference/ipc.md states it.

Tests

Protocol:

  • an_attach_naming_both_an_id_and_a_prefix_is_rejected
  • an_attach_naming_no_target_at_all_is_rejected
  • an_attach_naming_exactly_one_target_decodes_to_that_arm
  • new round-trip corpus cases for the prefix attach, both new refusals, and all three InfoOutcome arms
  • new SessionAttach.id_prefix gated-field row and NO_MATCH / AMBIGUOUS gated-enum rows

Daemon:

  • an_attach_by_prefix_lands_on_the_session_the_daemon_resolves
  • a_prefix_whose_session_was_destroyed_refuses_with_no_match (the race test)
  • an_ambiguous_prefix_on_attach_refuses_with_the_match_count
  • ops_info_answers_a_row_and_a_short_id_checked_against_the_pool (the reported short_id must resolve back against the pool)
  • ops_info_reports_no_match_and_ambiguity_as_outcome_arms

Client core:

  • the_minor_gates_refuse_against_a_minor_zero_daemon extended to attach_by_prefix and session_info

CLI:

  • info_json_still_carries_a_short_id_without_a_roster_fetch
  • crates/felis-cli/tests/golden/error_kinds.txt gains NoMatch -> no_match and Ambiguous -> ambiguous, so a daemon-resolved prefix failure reports the same token and exit 1 the client-side resolver did

Fuzz: seeds proto-session-attach-by-prefix.bin and proto-ops-info-found.bin under fuzz/seeds/ipc_body/.

Gates: cargo nextest run --workspace --all-features 3345/3345 pass; cargo clippy --workspace --all-targets clean; just proto, just schema (no diff), just proto-compat, just docs-links, and prose-check against the stack base all clean.

Closes #310 ## What Attach targets are now resolved by the daemon, and `sessions info` gets its row and its `short_id` from one reply. - `SessionMsg::Attach` carries `target`: either a full id or an `id_prefix`. The wire keeps `SessionAttach.id` (bytes, field 1) and adds `SessionAttach.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 folding `id` into a `oneof`, which would have renumbered a released field for nothing the validator does not already give. - The daemon resolves the prefix and takes the session handle **under one lock**, so a session reaped before the attach arrives is a `NoMatch` on the prefix, never an attach to a row the pool has dropped. Two new `AttachFailure` variants, `NoMatch` and `Ambiguous` (count in `detail`), both downgrading to `UNKNOWN_SESSION` for an older peer. - New `Ops::Info { id_prefix }` / `Ops::InfoReply { outcome }` with a closed union in the `OpsSpawned` style: `found { session, short_id }` | `no_match {}` | `ambiguous { matches }`. Only the successful arm carries session data. The daemon builds all three — resolution, row and `short_id` — from **one pool snapshot**, so they cannot disagree about which sessions exist. - `sessions info` and the bridge's `sessions.info` use `Ops::Info`; `send`, `capture`, `search` attach by prefix; the bridge's `resolve()` uses `Ops::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. - The GUI's `--session` startup and `Landing::ResolveOnTarget` attach 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 for `PickExit`, which genuinely needs the roster). - `Landing::AttachExact` and the reconnect ladder are unchanged: they already hold a full id and would otherwise pay a resolution for an id nobody shortened. ## Wire shapes ```proto message SessionAttach { bytes id = 1; // empty when id_prefix carries the target bool live_only = 2; string id_prefix = 3; // minor 12 } message OpsInfo { string id_prefix = 1; } message OpsInfoReply { oneof outcome { OpsInfoFound found = 1; // SessionInfo session = 1; string short_id = 2; OpsInfoNoMatch no_match = 2; // empty OpsInfoAmbiguous ambiguous = 3; // uint32 matches = 1; } } ``` `OpsMsg` arms `info = 17` / `info_reply = 18` (appended, so `Stop`/`StopReply` keep tags 15/16). `AttachFailure` gains `NO_MATCH = 8`, `AMBIGUOUS = 9`. `just proto-compat` reports wire-compatible with the merge-base; no `BREAKING.md` entry 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 info` therefore keeps its documented always-present `short_id` without an `Ops::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_addition` requires 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, the `since=` 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 against `main` therefore contains #319's commit too; review `d71fc127` alone. ## 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::List` round trip and the client-side resolver they used before, so nothing regresses against an older daemon; the ledger row in `docs/reference/ipc.md` states it. ## Tests Protocol: - `an_attach_naming_both_an_id_and_a_prefix_is_rejected` - `an_attach_naming_no_target_at_all_is_rejected` - `an_attach_naming_exactly_one_target_decodes_to_that_arm` - new round-trip corpus cases for the prefix attach, both new refusals, and all three `InfoOutcome` arms - new `SessionAttach.id_prefix` gated-field row and `NO_MATCH` / `AMBIGUOUS` gated-enum rows Daemon: - `an_attach_by_prefix_lands_on_the_session_the_daemon_resolves` - `a_prefix_whose_session_was_destroyed_refuses_with_no_match` (the race test) - `an_ambiguous_prefix_on_attach_refuses_with_the_match_count` - `ops_info_answers_a_row_and_a_short_id_checked_against_the_pool` (the reported `short_id` must resolve back against the pool) - `ops_info_reports_no_match_and_ambiguity_as_outcome_arms` Client core: - `the_minor_gates_refuse_against_a_minor_zero_daemon` extended to `attach_by_prefix` and `session_info` CLI: - `info_json_still_carries_a_short_id_without_a_roster_fetch` - `crates/felis-cli/tests/golden/error_kinds.txt` gains `NoMatch -> no_match` and `Ambiguous -> ambiguous`, so a daemon-resolved prefix failure reports the same token and exit 1 the client-side resolver did Fuzz: seeds `proto-session-attach-by-prefix.bin` and `proto-ops-info-found.bin` under `fuzz/seeds/ipc_body/`. Gates: `cargo nextest run --workspace --all-features` 3345/3345 pass; `cargo clippy --workspace --all-targets` clean; `just proto`, `just schema` (no diff), `just proto-compat`, `just docs-links`, and `prose-check` against the stack base all clean.
protocol: version connection modes through the minor ledger
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
f5fe7e14e0
A mode is the extension path for a whole surface: the design says a new
surface earns a new mode rather than a per-feature bit, so mode growth
is an expected additive change, not a corner case. Until now the gate
covered `ConnWelcome.identity` alone, `ConnectionMode` carried no
`since_minor`, and a client naming a mode an older daemon predates lost
the connection at that daemon's decode with no sender-side verdict and
no frame back.

Every mode now carries a ledger row the schema walk enforces, the
`Hello` folds its mode's requirement into what the send gate reads, and
the client refuses the dial locally when the effective minor does not
define the mode it asks for. The receiving half is a typed refusal:
`RefusalReason::UnknownMode` (minor 11) names the skew before the close,
so the peer reports a daemon too old instead of an EOF it would retry.
Widening `Role` to cover it was rejected, since re-reading an existing
enum value is a semantic change the minor rule does not admit.

No mode down-maps: answering a peer with a mode it did not name would
wire up a surface it did not ask for, so the send gate and the refusal
are the whole accommodation.

Assisted-by: Claude Code
treewide: resolve attach targets on the daemon
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
d71fc1274c
The design docs say a caller names a session by prefix and the daemon
resolves it, but every attach-scoped path did the opposite: an
`Ops::List`, a client-side match, then an attach by full id. That is a
second round trip, a resolver every non-Rust bridge consumer has to
reimplement, and a window in which the matched session can be reaped
between the list and the attach, so `info` could report a row that no
longer existed.

`SessionMsg::Attach` now names its target as either a full id or a
prefix, and the daemon takes the resolution and the session handle under
one lock: a session destroyed before the attach arrives is a `NoMatch`
on the prefix rather than an attach to a row the pool has dropped. The
exact form stays for the callers that already hold an id and never
shortened it — the reconnect ladder and a roster-driven switch — which
would otherwise pay a resolution for nothing.

`sessions info` owes its reader a `short_id` as well as a row, and only
the pool's owner can shorten correctly, so `Ops::Info` answers both from
one snapshot. Shortening against the daemon's whole roster was rejected
before on the grounds that it cost a second round trip and emitted an
unchecked prefix; neither holds for a prefix the daemon checked against
the authoritative pool in the reply it was already making, and the
decision record now says so.

The additions take protocol minor 12. Against a daemon below it the CLI
and the GUI fall back to the roster round trip they used before, so
nothing regresses on an older daemon.

Assisted-by: Claude Code
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 Code
The prose gate caps a comment at five lines; both `# Errors` blocks named every error class in prose where the type links already say it.

Assisted-by: Claude Code
The prose gate counts the summary and the `# Errors` block as one comment; the step the resolution happens in is stated in felis.proto and docs/reference/ipc.md.

Assisted-by: Claude Code
cli: make the attach-failure classifier const
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
5b9cd72dc3
clippy: :missing_const_for_fn; the map is a pure match over the error.
Assisted-by: Claude Code
Author
Owner

All four blockers are addressed on attach-by-prefix, as four additive commits on top of d71fc127 (no force-push). Head is now 5b9cd72d.

1. Empty prefix bypassed the exactly-one-target rule

Two layers, because the hole existed at two:

  • SessionMsg::validate (messages/limits.rs) holds an AttachTarget::Prefix to validate_session_id_prefix, the same definition the clap value parser and the daemon's resolver use. Validate runs on both encode and decode, so an invalid prefix is refused before a byte leaves and at the daemon's decode.
  • Connection::attach_by_prefix refuses eagerly with a new ConnectError::InvalidSessionPrefix { prefix, reason } (non-transient), so a library caller gets a typed error naming the prefix rather than a transport failure that is_transient() would have told it to retry.

This makes the attach prefix stricter than the Ops verbs' id_prefix, which still answer a malformed prefix with NoMatch. That is deliberate and now documented at docs/reference/ipc.md (Session family): an attach shares one target slot between id and id_prefix, so it has no NoMatch reply to fall back on — a malformed prefix there is a malformed frame.

Exact id 0 is 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_target
  • attach_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_sessions is 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_client now fetches the roster only when the resolution needs it:

let resolve_here = matches!(mode, AttachMode::Attach(_))
    && connection.effective_minor < felis_protocol::preface::MINOR_ATTACH_BY_PREFIX;
let initial_sessions = if resolve_here { ...fetch...? } else { Vec::new() };

No Ops::List precedes SessionAttach at minor ≥ 12 at all; the window keeps no roster, so nothing else needed seeding. (Landing::ResolveOnTarget in dial.rs keeps its fetch, which genuinely seeds the switch ring there, and it was already non-gating for the prefix.)

3. Attach transport failures reported as NoMatch

New attach_failure_kind(&ConnectError) -> ErrorKind, used by both attach_by_prefix and attach_exact (the exact path had the same bug):

ConnectError kind exit
AttachFailed { reason } from_attach_failure(reason) 1
Refused { reason } from_refusal(reason) 1 / 2
InvalidSessionPrefix invalid_request 1
MinorTooOld unsupported 2
Io / Connect / Transport / Preface / EofBeforeWelcome / EofMidAttach daemon_lost 2
everything else protocol 2

Tests:

  • a_daemon_closing_mid_attach_exits_two — cmd_send against a scripted daemon that reads the Attach frame and hangs up, with a JSON reporter; asserts exit 2
  • only_a_typed_refusal_reports_a_which_session_failure — the full classifier table, including that EofMidAttach is not no_match

4. 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 proto re-run after the schema comment edits; the regenerated src/generated is in 09bb8b3b.

Gates

cargo clippy --workspace --all-targets clean (one missing_const_for_fn on the new classifier fixed in 5b9cd72d). cargo nextest run --all-features: felis-protocol + felis-client-core 768/768, felis-daemon + felis-client 649/649, felis-cli 323/323. just proto-compat wire-compatible with the merge-base; just docs-links clean.

All four blockers are addressed on `attach-by-prefix`, as four additive commits on top of `d71fc127` (no force-push). Head is now **`5b9cd72d`**. ## 1. Empty prefix bypassed the exactly-one-target rule Two layers, because the hole existed at two: - `SessionMsg::validate` (`messages/limits.rs`) holds an `AttachTarget::Prefix` to `validate_session_id_prefix`, the same definition the clap value parser and the daemon's resolver use. `Validate` runs on both encode and decode, so an invalid prefix is refused before a byte leaves *and* at the daemon's decode. - `Connection::attach_by_prefix` refuses eagerly with a new `ConnectError::InvalidSessionPrefix { prefix, reason }` (non-transient), so a library caller gets a typed error naming the prefix rather than a transport failure that `is_transient()` would have told it to retry. This makes the attach prefix stricter than the `Ops` verbs' `id_prefix`, which still answer a malformed prefix with `NoMatch`. That is deliberate and now documented at `docs/reference/ipc.md` (Session family): an attach shares one target slot between `id` and `id_prefix`, so it has no `NoMatch` reply to fall back on — a malformed prefix there is a malformed frame. Exact id `0` is 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_target` - `attach_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_sessions` is 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_client` now fetches the roster only when the resolution needs it: ```rust let resolve_here = matches!(mode, AttachMode::Attach(_)) && connection.effective_minor < felis_protocol::preface::MINOR_ATTACH_BY_PREFIX; let initial_sessions = if resolve_here { ...fetch...? } else { Vec::new() }; ``` No `Ops::List` precedes `SessionAttach` at minor ≥ 12 at all; the window keeps no roster, so nothing else needed seeding. (`Landing::ResolveOnTarget` in `dial.rs` keeps its fetch, which genuinely seeds the switch ring there, and it was already non-gating for the prefix.) ## 3. Attach transport failures reported as `NoMatch` New `attach_failure_kind(&ConnectError) -> ErrorKind`, used by both `attach_by_prefix` and `attach_exact` (the exact path had the same bug): | `ConnectError` | kind | exit | | --- | --- | --- | | `AttachFailed { reason }` | `from_attach_failure(reason)` | 1 | | `Refused { reason }` | `from_refusal(reason)` | 1 / 2 | | `InvalidSessionPrefix` | `invalid_request` | 1 | | `MinorTooOld` | `unsupported` | 2 | | `Io` / `Connect` / `Transport` / `Preface` / `EofBeforeWelcome` / `EofMidAttach` | `daemon_lost` | 2 | | everything else | `protocol` | 2 | Tests: - `a_daemon_closing_mid_attach_exits_two` — `cmd_send` against a scripted daemon that reads the `Attach` frame and hangs up, with a JSON reporter; asserts exit `2` - `only_a_typed_refusal_reports_a_which_session_failure` — the full classifier table, including that `EofMidAttach` is not `no_match` ## 4. 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 proto` re-run after the schema comment edits; the regenerated `src/generated` is in `09bb8b3b`. ## Gates `cargo clippy --workspace --all-targets` clean (one `missing_const_for_fn` on the new classifier fixed in `5b9cd72d`). `cargo nextest run --all-features`: felis-protocol + felis-client-core 768/768, felis-daemon + felis-client 649/649, felis-cli 323/323. `just proto-compat` wire-compatible with the merge-base; `just docs-links` clean.
A bridge op that resolved a prefix and then attached by the id it got
back could not answer a session that appeared in between, and every
attach error read as a missing session regardless of cause. Both
shapes now have a scripted daemon holding them to the contract.

Assisted-by: Claude Code
The prose gate caps a comment at five lines; the rejected alternative
fits in two.

Assisted-by: Claude Code
test(cli): const-qualify the scripted session row
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m28s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m22s
pr / nix flake check (pull_request) Successful in 59s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m33s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
39bfc04e50
clippy: :nursery flags a const-capable helper.
Assisted-by: Claude Code
Author
Owner

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.
Core gained reserve_link, acquire_by_prefix, connect_attached_by_prefix, adopt and take_live; with_session was replaced by with_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_PREFIX the auxiliary link reserves a permit, dials, and attaches with Connection::attach_by_prefix; the id comes from Attached.info.id. The Ops::Info + exact-id path is kept only below the minor. adopt checks for a live link before drain_rehydrate, so a session that already has a link costs one round trip rather than a rehydrate.

2. connect_attached no longer flattens every ConnectError to NoMatch.
attach_failure_kind moved out of cli_sessions.rs into cli_output.rs as ErrorKind::from_connect_error(&ConnectError). Typed refusals map to their domain kinds, EOF/transport/dial to daemon_lost, codec and driver violations to protocol. Both bridge attach paths and every cli_sessions.rs call 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 answers Ops::Info/Ops::List as exactly one match, asserts the SessionAttach carries AttachTarget::Prefix, and refuses with Ambiguous; the op must report kind ambiguous. 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 report daemon_lost, not no_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-features clean; 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.** `Core` gained `reserve_link`, `acquire_by_prefix`, `connect_attached_by_prefix`, `adopt` and `take_live`; `with_session` was replaced by `with_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_PREFIX` the auxiliary link reserves a permit, dials, and attaches with `Connection::attach_by_prefix`; the id comes from `Attached.info.id`. The `Ops::Info` + exact-id path is kept only below the minor. `adopt` checks for a live link before `drain_rehydrate`, so a session that already has a link costs one round trip rather than a rehydrate. **2. `connect_attached` no longer flattens every `ConnectError` to `NoMatch`.** `attach_failure_kind` moved out of `cli_sessions.rs` into `cli_output.rs` as `ErrorKind::from_connect_error(&ConnectError)`. Typed refusals map to their domain kinds, EOF/transport/dial to `daemon_lost`, codec and driver violations to `protocol`. Both bridge attach paths and every `cli_sessions.rs` call 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 answers `Ops::Info`/`Ops::List` as exactly one match, asserts the `SessionAttach` carries `AttachTarget::Prefix`, and refuses with `Ambiguous`; the op must report kind `ambiguous`. 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 report `daemon_lost`, not `no_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-features` clean; `cargo nextest run -p felis-cli --all-features` -> 325/325 passed.
Author
Owner

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.
Core gained reserve_link, acquire_by_prefix, connect_attached_by_prefix, adopt and take_live; with_session was replaced by with_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_PREFIX the auxiliary link reserves a permit, dials, and attaches with Connection::attach_by_prefix; the id comes from Attached.info.id. The Ops::Info + exact-id path is kept only below the minor. adopt checks for a live link before drain_rehydrate, so a session that already has a link costs one round trip rather than a rehydrate.

2. connect_attached no longer flattens every ConnectError to NoMatch.
attach_failure_kind moved out of cli_sessions.rs into cli_output.rs as ErrorKind::from_connect_error(&ConnectError). Typed refusals map to their domain kinds, EOF/transport/dial to daemon_lost, codec and driver violations to protocol. Both bridge attach paths and every cli_sessions.rs call 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 answers Ops::Info/Ops::List as exactly one match, asserts the SessionAttach carries AttachTarget::Prefix, and refuses with Ambiguous; the op must report kind ambiguous. 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 report daemon_lost, not no_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-features clean; 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.** `Core` gained `reserve_link`, `acquire_by_prefix`, `connect_attached_by_prefix`, `adopt` and `take_live`; `with_session` was replaced by `with_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_PREFIX` the auxiliary link reserves a permit, dials, and attaches with `Connection::attach_by_prefix`; the id comes from `Attached.info.id`. The `Ops::Info` + exact-id path is kept only below the minor. `adopt` checks for a live link before `drain_rehydrate`, so a session that already has a link costs one round trip rather than a rehydrate. **2. `connect_attached` no longer flattens every `ConnectError` to `NoMatch`.** `attach_failure_kind` moved out of `cli_sessions.rs` into `cli_output.rs` as `ErrorKind::from_connect_error(&ConnectError)`. Typed refusals map to their domain kinds, EOF/transport/dial to `daemon_lost`, codec and driver violations to `protocol`. Both bridge attach paths and every `cli_sessions.rs` call 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 answers `Ops::Info`/`Ops::List` as exactly one match, asserts the `SessionAttach` carries `AttachTarget::Prefix`, and refuses with `Ambiguous`; the op must report kind `ambiguous`. 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 report `daemon_lost`, not `no_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-features` clean; `cargo nextest run -p felis-cli --all-features` -> 325/325 passed.
A dial that lost the race to install a session's link returned with its
parameters dropped in declaration order, so the link permit was
released before the connection it admitted. Another operation could
take that capacity while the redundant subscriber was still registered
with the daemon, which never saw a Detach for it either. The reference
page already promises the permit outlives detach and carrier shutdown.

Assisted-by: Claude Code
docs: name both rosters a short_id is shortened against
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m52s
bench / Criterion regression gate (pull_request) Failing after 2m31s
darwin / detect relevant changes (pull_request) Successful in 7s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
pr / nix flake check (pull_request) Successful in 1m36s
pr / cargo build / clippy / test / deny (pull_request) Successful in 3m15s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m57s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 6s
windows / windows docs-only no-op (pull_request) Has been skipped
pr / wire schema is compatible with the base (pull_request) Successful in 10s
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
7b5952930e
Ops::InfoReply carries one session's row, so the claim that a short_id
is unique against the roster in the same reply held only for list; info
is shortened against the daemon's pool at resolution time.

Assisted-by: Claude Code
Author
Owner

Round 3 blockers addressed on attach-by-prefix (additive, no rebase or force-push). Head: 7b595293.

1. adopt gives up its connection before its capacity (24a32f31).
New free fn discard(conn, permit) in cli_bridge.rs: it wraps the connection the adoption is giving up in a Link, sends Detach, runs the carrier shutdown, and only then drops the permit. All three exits of adopt now go through it: both take_live early returns and the drain_rehydrate failure, which became an explicit if let Err(err) so the connection can be torn down before the error propagates. This is what docs/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 on acquire_owned, so it can only run after adopt releases the permit, and it then asserts the redundant connection's peer already has a SessionMsg::Detach frame followed by EOF. Verified failing against the old shape ("the redundant connection detaches before it closes") and passing with the fix.

2. short_id docs cascade (7b595293).

  • docs/explanation/architecture/control-surfaces.md: the display-only argument now names both rosters, the roster carried in the same reply on list and the daemon's pool at resolution time on info, 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.md and the CHANGELOG.md history entries are consistent. Swept docs/, skills/ and README.md for 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-features clean; cargo nextest run -p felis-cli --all-features -> 326/326 passed.

One note on the gate range: --range main...HEAD reports 2 comment-length hits, but git log -L attributes both (crates/felis-client-core/src/connector.rs:239, crates/felis-protocol/proto/felis.proto:162) to f5fe7e14 on the connection-mode-minor base, not to this branch. I left them for that PR rather than editing its lines from here.

Round 3 blockers addressed on `attach-by-prefix` (additive, no rebase or force-push). Head: `7b595293`. **1. `adopt` gives up its connection before its capacity** (`24a32f31`). New free fn `discard(conn, permit)` in `cli_bridge.rs`: it wraps the connection the adoption is giving up in a `Link`, sends `Detach`, runs the carrier shutdown, and only then drops the permit. All three exits of `adopt` now go through it: both `take_live` early returns and the `drain_rehydrate` failure, which became an explicit `if let Err(err)` so the connection can be torn down before the error propagates. This is what `docs/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 on `acquire_owned`, so it can only run after `adopt` releases the permit, and it then asserts the redundant connection's peer already has a `SessionMsg::Detach` frame followed by EOF. Verified failing against the old shape ("the redundant connection detaches before it closes") and passing with the fix. **2. `short_id` docs cascade** (`7b595293`). - `docs/explanation/architecture/control-surfaces.md`: the display-only argument now names both rosters, the roster carried in the same reply on `list` and the daemon's pool at resolution time on `info`, 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.md` and the `CHANGELOG.md` history entries are consistent. Swept `docs/`, `skills/` and `README.md` for 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-features` clean; `cargo nextest run -p felis-cli --all-features` -> 326/326 passed. One note on the gate range: `--range main...HEAD` reports 2 comment-length hits, but `git log -L` attributes both (`crates/felis-client-core/src/connector.rs:239`, `crates/felis-protocol/proto/felis.proto:162`) to `f5fe7e14` on the `connection-mode-minor` base, not to this branch. I left them for that PR rather than editing its lines from here.
#316 and #319 landed on main while this branch was stacked on
connection-mode-minor, which merged squashed, so the branch's copy of
that work conflicted with the reviewed version.

Resolutions: main's revised wording and its stricter future-mode
decode win everywhere #319 was amended in review; this branch keeps the
provisional minor 12 above main's 11, its ledger row, and its CHANGELOG
entry ahead of main's. The ConnectionMode ledger paragraph and the
unknown-mode daemon test each survived the auto-merge twice; the copy
main revised is the one kept.

Assisted-by: Claude Code
cli: install a session link under the lock that checked for one
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
0b04fb3603
Two dials for the same session could each find no link installed and
then serialize on the slot lock, where the loser replaced the winner's
link. The replaced link stayed owned by its reader pump, so its carrier
and its daemon subscriber outlived every permit accounting for them,
and repeated races walked past the auxiliary-link bound. The check and
the install now share one lock, and the connection that finds a live
link is torn down before its admission comes back.

Assisted-by: Claude Code
Author
Owner

Merged main and addressed the round-4 findings. Head: 0b04fb36.

Merge (7caf844e, a merge commit, no rebase)

connection-mode-minor landed squashed as #319, so this branch's copy of that work conflicted with the reviewed version. Resolutions:

  • Wire minor: this branch keeps the provisional 12 over main's 11, with its MINOR_ATTACH_BY_PREFIX constant, its MINOR_LEDGER row after main's MINOR_UNKNOWN_MODE (11), its docs/reference/ipc.md ledger row, and the frozen preface bytes at 0x0C. Main's revised row-11 "Older peer" cell replaced this branch's copy. Ready to renumber to 13 once #321 lands.
  • CHANGELOG: both entries kept, minor 12 first.
  • Where #319 was amended in review, main's version wins: the check_mode_minor doc comment in connector.rs, and the future-mode decode in serve.rs (the peek_correlation guard and the value != 0 condition that keeps UNSPECIFIED a malformed Hello rather than a future mode).
  • Two things survived the auto-merge twice and were de-duplicated to main's version: the ConnectionMode ledger paragraph in felis.proto (which main folded into the following sentence), and the daemon test a_hello_naming_an_unknown_mode_is_refused_by_name, whose second copy had been spliced into the doc comment of malformed_input_frame_does_not_orphan_the_session.
  • just proto re-run after the proto comment change.

This also removes the two comment-length hits the earlier main...HEAD run reported, since both belonged to f5fe7e14.

Blocker: the live check and the install were not atomic (0b04fb36)

The second take_live and the sessions.lock() install were separate lock acquisitions, so two adopters could both find no link and then serialize on the lock, the loser replacing slot.link while bumping users. 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 one sessions lock and returns the link the slot ends up with; a connection that finds a live link is routed through discard, so its admission comes back only after Detach and carrier shutdown. adopt now ends in it. The exact-id acquire path got the same treatment: its install re-checks slot.live() under the install's own lock, and both that path and the Canceled path close their connection through the new close_carrier (which discard is 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 a tokio::sync::Barrier, then both call install_or_borrow for the same id. It asserts both get the same Arc<Link>, the slot counts users == 2, exactly one of the two peers is closed and its last bytes are a SessionMsg::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_closes no longer awaits anything after the permit is acquired. A new helper drained() reads the peer through a non-blocking std::os::unix::net::UnixStream, returning Some(bytes) only once it has already seen EOF and None while 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 small peer_of / carrier / detach_body helpers.

Gates

python3 tools/prose_check.py --range main...HEAD -> 0 hits; cargo clippy --workspace --all-targets --all-features clean; 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; treefmt and the pre-commit hooks on every commit.

Merged `main` and addressed the round-4 findings. Head: `0b04fb36`. ## Merge (`7caf844e`, a merge commit, no rebase) `connection-mode-minor` landed squashed as #319, so this branch's copy of that work conflicted with the reviewed version. Resolutions: - **Wire minor**: this branch keeps the provisional **12** over main's 11, with its `MINOR_ATTACH_BY_PREFIX` constant, its `MINOR_LEDGER` row after main's `MINOR_UNKNOWN_MODE` (11), its `docs/reference/ipc.md` ledger row, and the frozen preface bytes at `0x0C`. Main's revised row-11 "Older peer" cell replaced this branch's copy. Ready to renumber to 13 once #321 lands. - **CHANGELOG**: both entries kept, minor 12 first. - Where #319 was amended in review, main's version wins: the `check_mode_minor` doc comment in `connector.rs`, and the future-mode decode in `serve.rs` (the `peek_correlation` guard and the `value != 0` condition that keeps `UNSPECIFIED` a malformed `Hello` rather than a future mode). - Two things survived the auto-merge twice and were de-duplicated to main's version: the `ConnectionMode` ledger paragraph in `felis.proto` (which main folded into the following sentence), and the daemon test `a_hello_naming_an_unknown_mode_is_refused_by_name`, whose second copy had been spliced into the doc comment of `malformed_input_frame_does_not_orphan_the_session`. - `just proto` re-run after the proto comment change. This also removes the two `comment-length` hits the earlier `main...HEAD` run reported, since both belonged to `f5fe7e14`. ## Blocker: the live check and the install were not atomic (`0b04fb36`) The second `take_live` and the `sessions.lock()` install were separate lock acquisitions, so two adopters could both find no link and then serialize on the lock, the loser replacing `slot.link` while bumping `users`. 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 one `sessions` lock and returns the link the slot ends up with; a connection that finds a live link is routed through `discard`, so its admission comes back only after `Detach` and carrier shutdown. `adopt` now ends in it. The exact-id `acquire` path got the same treatment: its install re-checks `slot.live()` under the install's own lock, and both that path and the `Canceled` path close their connection through the new `close_carrier` (which `discard` is 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 a `tokio::sync::Barrier`, then both call `install_or_borrow` for the same id. It asserts both get the same `Arc<Link>`, the slot counts `users == 2`, exactly one of the two peers is closed and its last bytes are a `SessionMsg::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_closes` no longer awaits anything after the permit is acquired. A new helper `drained()` reads the peer through a non-blocking `std::os::unix::net::UnixStream`, returning `Some(bytes)` only once it has already seen EOF and `None` while 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 small `peer_of` / `carrier` / `detach_body` helpers. ## Gates `python3 tools/prose_check.py --range main...HEAD` -> 0 hits; `cargo clippy --workspace --all-targets --all-features` clean; `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`; `treefmt` and the pre-commit hooks on every commit.
docs: stop claiming an attach cannot lose its session
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m29s
pr / nix flake check (pull_request) Successful in 36s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m7s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 8s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 51s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m17s
pr / wire schema is compatible with the base (pull_request) Successful in 10s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m8s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / cargo clippy (Windows cross) (pull_request) Successful in 18s
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
1c87ce9742
Resolution and the handle lookup share one pool lock, which is what
removes the client-side roster race, but the lock is released before
the subscription, so a teardown can still answer SessionEnding. The
reference page and the changelog entry read as if nothing could reap
the session at all.

Assisted-by: Claude Code
Author
Owner

Round-5 doc finding folded in. Head: 1c87ce97.

docs/reference/ipc.md (the Attach bullet) claimed "nothing can reap the session between the resolution and the subscription". take_attach_handle resolves the prefix and clones the handle under one pool lock, but releases it before subscribe_to, so a teardown still races the subscription. Reworded:

A prefix is resolved against the pool inside the daemon, under the lock that takes the session handle, so an attach costs one round trip and no roster the client holds can go stale between the two; Attached.info.id carries the full id back. A teardown still races the subscription that follows, which answers AttachFailure::SessionEnding, so a session reaped mid-request does not always read as NoMatch.

The CHANGELOG entry carried the same overstatement ("a session reaped mid-request is reported as no_match rather 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 is no_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::SessionEnding surfaces as invalid_request in 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.

Round-5 doc finding folded in. Head: `1c87ce97`. `docs/reference/ipc.md` (the `Attach` bullet) claimed "nothing can reap the session between the resolution and the subscription". `take_attach_handle` resolves the prefix and clones the handle under one pool lock, but releases it before `subscribe_to`, so a teardown still races the subscription. Reworded: > A prefix is resolved against the pool inside the daemon, under the lock that takes the session handle, so an attach costs one round trip and no roster the client holds can go stale between the two; `Attached.info.id` carries the full id back. A teardown still races the subscription that follows, which answers `AttachFailure::SessionEnding`, so a session reaped mid-request does not always read as `NoMatch`. The CHANGELOG entry carried the same overstatement ("a session reaped mid-request is reported as `no_match` rather 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 is `no_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::SessionEnding` surfaces as `invalid_request` in 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.
Merge origin/main into attach-by-prefix, taking minor 13
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 11s
darwin / darwin docs-only no-op (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 3m5s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m23s
pr / nix flake check (pull_request) Successful in 50s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m26s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m30s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 9m16s
windows / cargo clippy (Windows cross) (pull_request) Successful in 15s
windows / frontend smoke (Windows) (pull_request) Successful in 3m59s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
af5851d4fd
#321 landed on main as minor 12 (GridMsg::CycleEnd), the number this
branch held provisionally, so attach-by-prefix moves up to 13: the
constant, the ledger row and its docs table row, the proto header and
the arm lines' since=, the frozen preface bytes, and the changelog
heading. Main's version wins for everything #321 owns; the changelog
keeps both entries with this branch's first.

Assisted-by: Claude Code
Author
Owner

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_MINOR is now 13, MINOR_GRID_CYCLE_END = 12 (main's) is kept ahead of MINOR_ATTACH_BY_PREFIX = 13, the MINOR_LEDGER rows follow in that order, and the two frozen preface byte arrays carry 0x0D.
  • docs/reference/ipc.md: main's row 12 kept, this branch's row moved to 13 with its "below minor 12" prose updated, and the Attach bullet'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 the OpsInfo / OpsInfoReply arm lines, and the three "Minor 12" prose comments in felis.proto (the two AttachFailure values and SessionAttach.id_prefix), followed by just proto to 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-features clean; felis-protocol 206/206; felis-cli 328/328; felis-daemon 475/475; felis-client-core + felis-transport 732/732; just proto-compat wire-compatible against the new merge base ead2f97d; python3 tools/prose_check.py --range origin/main...HEAD -> 0 hits; treefmt clean.

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_MINOR` is now 13, `MINOR_GRID_CYCLE_END = 12` (main's) is kept ahead of `MINOR_ATTACH_BY_PREFIX = 13`, the `MINOR_LEDGER` rows follow in that order, and the two frozen preface byte arrays carry `0x0D`. - `docs/reference/ipc.md`: main's row 12 kept, this branch's row moved to 13 with its "below minor 12" prose updated, and the `Attach` bullet'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 the `OpsInfo` / `OpsInfoReply` arm lines, and the three "Minor 12" prose comments in `felis.proto` (the two `AttachFailure` values and `SessionAttach.id_prefix`), followed by `just proto` to 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-features` clean; `felis-protocol` 206/206; `felis-cli` 328/328; `felis-daemon` 475/475; `felis-client-core` + `felis-transport` 732/732; `just proto-compat` wire-compatible against the new merge base `ead2f97d`; `python3 tools/prose_check.py --range origin/main...HEAD` -> 0 hits; `treefmt` clean.
natsukium deleted branch attach-by-prefix 2026-09-21 18:08:19 +09:00
Commenting is not possible because the repository is archived.
No description provided.