protocol, transport: split the two-way IPC families into a wrapper per direction #473

Merged
natsukium merged 6 commits from cleanup/wire-split into main 2026-09-26 02:56:17 +09:00
Owner

Why

Six IPC families (Conn, Session, Ops, Region, Notify, Search) carried both directions' arms in one oneof, told apart only by the per-arm direction option. The transport driver refused a wrong-direction arm at runtime, but each side still held the two-way enum, so the daemon and client matches needed unreachable! or catch-all arms for arms that can never reach them. Before v0.1.0 there is no compatibility baseline, so this is the cheapest point to make those states unrepresentable.

What

  • felis.proto: each two-way family is split into <Family>ToDaemonMsg / <Family>ToClientMsg under its unchanged frame kind. Every arm keeps its field number, so wire bytes are identical: buf breaking (WIRE_JSON) passes, no golden/seed/--format json change, no protocol version bump. One-way families keep <Family>Msg.
  • felis-transport: ConnectionDriver<R: Role> (ClientDriver / DaemonDriver); decode::<M> const-asserts M travels toward R.
  • Mixed-direction bodies: the split turned a foreign arm into an unknown field that prost would skip, so the driver walks each split-family body's top-level keys (allocation-free codec::arm_in, not run for one-way families) and refuses any body naming an arm of the other direction, in either order. This is slightly stricter than the old last-arm-wins behavior. decode_either (felis-json) returns MixedDirections.
  • Paths that read Conn frames around the driver now apply the same rule: the client-core correlated-reply peek (a protocol or codec failure there now retires the Connection), the parked-frame check, the daemon's unknown-mode Hello fallback, and the liveness probe behind felis doctor.
  • Removed catch-alls: daemon Ops/Region unreachable!, the Notify catch-all, the client Search catch-all, a dead CLI Drained::Item(Query) arm.
  • Docs: docs/reference/ipc.md (wrapper naming, shared arm numbering, the mixed-body rule), docs/explanation/architecture/ipc.md (decision record with the rejected one-wrapper-per-family shape and a Revisit if), glossary and related explanation pages; extend-ipc skill; CHANGELOG.

Verification

  • cargo nextest run --workspace --all-features, workspace clippy -D warnings, Windows cross clippy, just proto-compat, just schema / just golden clean, just prose-check, fuzz cargo check --bins (ipc_body now also drives arm_in).
  • New tests: mixed bodies in both orders for Conn/Ops/Search (driver and drain path), malformed walks, a wrong-direction Conn frame during a correlated request, unknown-mode Hello with a client-bound arm, probe with a daemon-bound arm.
  • pi review (sol): round 1 blocked (mixed body accepted after the split) → key walk; round 2 blocked (Conn frames decoded around the driver in client-core; non-blocking: unknown-mode fallback) → fixed with a sweep; round 3 PASS. Its one non-blocking note, a race in the daemon mirroring test's second attach, predates this branch and is left for a follow-up.
## Why Six IPC families (Conn, Session, Ops, Region, Notify, Search) carried both directions' arms in one oneof, told apart only by the per-arm `direction` option. The transport driver refused a wrong-direction arm at runtime, but each side still held the two-way enum, so the daemon and client matches needed `unreachable!` or catch-all arms for arms that can never reach them. Before v0.1.0 there is no compatibility baseline, so this is the cheapest point to make those states unrepresentable. ## What - `felis.proto`: each two-way family is split into `<Family>ToDaemonMsg` / `<Family>ToClientMsg` under its unchanged frame kind. Every arm keeps its field number, so **wire bytes are identical**: `buf breaking` (WIRE_JSON) passes, no golden/seed/`--format json` change, no protocol version bump. One-way families keep `<Family>Msg`. - felis-transport: `ConnectionDriver<R: Role>` (`ClientDriver` / `DaemonDriver`); `decode::<M>` const-asserts `M` travels toward `R`. - Mixed-direction bodies: the split turned a foreign arm into an unknown field that prost would skip, so the driver walks each split-family body's top-level keys (allocation-free `codec::arm_in`, not run for one-way families) and refuses any body naming an arm of the other direction, in either order. This is slightly stricter than the old last-arm-wins behavior. `decode_either` (felis-json) returns `MixedDirections`. - Paths that read Conn frames around the driver now apply the same rule: the client-core correlated-reply peek (a protocol or codec failure there now retires the `Connection`), the parked-frame check, the daemon's unknown-mode Hello fallback, and the liveness probe behind `felis doctor`. - Removed catch-alls: daemon Ops/Region `unreachable!`, the Notify catch-all, the client Search catch-all, a dead CLI `Drained::Item(Query)` arm. - Docs: `docs/reference/ipc.md` (wrapper naming, shared arm numbering, the mixed-body rule), `docs/explanation/architecture/ipc.md` (decision record with the rejected one-wrapper-per-family shape and a Revisit if), glossary and related explanation pages; `extend-ipc` skill; CHANGELOG. ## Verification - `cargo nextest run --workspace --all-features`, workspace clippy `-D warnings`, Windows cross clippy, `just proto-compat`, `just schema` / `just golden` clean, `just prose-check`, fuzz `cargo check --bins` (`ipc_body` now also drives `arm_in`). - New tests: mixed bodies in both orders for Conn/Ops/Search (driver and drain path), malformed walks, a wrong-direction Conn frame during a correlated request, unknown-mode Hello with a client-bound arm, probe with a daemon-bound arm. - pi review (sol): round 1 blocked (mixed body accepted after the split) → key walk; round 2 blocked (Conn frames decoded around the driver in client-core; non-blocking: unknown-mode fallback) → fixed with a sweep; round 3 PASS. Its one non-blocking note, a race in the daemon mirroring test's second attach, predates this branch and is left for a follow-up.
Six families (Conn, Session, Ops, Region, Notify, Search) carried the
arms of both directions in one oneof, so every receive-side match had
to spell the other direction's arms as unreachable!/catch-all guards:
the driver refused a wrong-direction arm before any handler saw it,
but nothing in the type said so. Each of them is now two wrappers
under its unchanged frame kind, <Family>ToDaemonMsg and
<Family>ToClientMsg, and a receiver decodes only the one that can
reach it, so those guards are gone rather than asserted.

The split is uniform across all six instead of limited to the
families with the most guards, so the model is one rule (every
wrapper travels one way) for the driver, the docs and non-Rust
generators. The driver becomes ConnectionDriver<R: Role> with
ClientSide/DaemonSide markers: decode::<M> const-asserts that M
travels toward R, and the Conn wrapper a side reads is its
Role::Control type. Each half keeps its arms' original field numbers,
which keeps the bytes identical (no protocol minor or major bump, no
golden, seed, or --format json change, buf breaking passes) and keeps
the two halves' numbers disjoint: a wrong-direction body decodes as
"no arm", and the driver still reports it as WrongDirection by
reading it with the other half via codec::arm_of.

The per-arm (felis.v1.arm) direction option stays, although the
wrapper now implies it: the row stays self-contained for a descriptor
reader, and Directed::DIRECTION is derived from it with a const panic
on a wrapper that mixes directions. felis-json keeps its
direction-agnostic DTO through EitherHalf/decode_either, since a
recording does not know which way a frame traveled.

The phase-only catch-alls (a Hello after the handshake, a setup-phase
opener once attached) and the reply-arm-mismatch arms in
client-core's interpret stay: they are not direction guards and the
split cannot make them unrepresentable.

Assisted-by: Claude Code
The wire now carries <Family>ToDaemonMsg / <Family>ToClientMsg for the
six families with arms in both directions, so every doc and skill
naming an arm as <Family>Msg::<Variant> pointed at a message that no
longer exists; each reference now names the wrapper the arm lives in.

The IPC explanation used to reject "a direction split" outright, which
now contradicts the schema. The rejection still holds for the kind
taxonomy (a request and its reply must share a kind so a phase can
refuse the conversation whole); the entry now says so and records the
split one level down, inside a family, with the one-wrapper-per-family
alternative it replaced and when to revisit. The reference states the
resulting facts: the wrapper naming, the shared arm numbering, and
what a body of the other wrapper decodes as.

The extend-ipc skill tells an author which wrapper a new arm joins and
that its number is shared across both halves; the CHANGELOG entry is
for peers generating code from felis.proto, whose message names
change although the bytes do not.

Assisted-by: Claude Code
# Conflicts:
#	crates/felis-client/src/event_handler.rs
#	crates/felis-client/src/main.rs
Splitting each two-way family into a wrapper per direction dropped a
check the single oneof used to make. A body holding a ConnHello and a
ConnWelcome now decodes on the daemon as Hello, because prost skips the
Welcome field as unknown. The same holds for any request/reply pair, so
a wrong-direction arm could hide behind an admitted one. That is the
corruption the driver exists to refuse (REQ-114).

The driver now walks every body of a two-wrapper family, on decode,
on the control path and on the drain/park path. The walk
(codec::arm_in) reads top-level field keys and skips values by wire
type. It compares each field number against the other wrapper's arm
numbers and ends the connection with WrongDirection on the first hit.
The undecodable-body diagnosis uses the same walk in place of a full
decode as the other wrapper. The walk allocates nothing, and one-way
families (Grid, Image, Input, Push) have no other wrapper, so they are
not walked at all; the hot grid path pays nothing. Each wrapper's arm
field numbers live beside its codec (WireCodec::ARM_FIELDS). A const
assert keeps them row for row with the arm table, and a test checks
them against felis.proto.

This is slightly stricter than the pre-split behavior. There,
last-wins meant a Welcome followed by a Hello read as Hello, and only
the reverse order was caught. Now either order is refused. The walk
also refuses a group-typed field, which prost would skip. proto3 has
no groups, no felis peer writes one, and a body the walk cannot read
is not one the driver can vouch for.

codec::decode_either, the direction-agnostic read used by felis-json,
had the same gap: it took whichever half decoded first. It now reports
a body naming both halves as WireError::MixedDirections instead of
choosing one. The ipc_body fuzz target runs the walk over every kind
and direction, so the new parser is held to the never-panic rule.

Assisted-by: Claude Code
Three paths read a Conn body without the driver's direction check.

The client's correlated-request loop peeks each frame to see whether
it answers the awaited request. A Conn body that did not decode as
ConnToClientMsg (after the split, any daemon-bound arm) returned the
codec error straight out of the loop. The frame was lost, the error
was not a direction fault, and the connection stayed usable. Before
the split the combined ConnMsg decoded such an arm, parked it, and the
driver refused it on the next read. So the loss is new. The
still-usable connection after a protocol error, and an undecodable
Conn body being dropped the same way, were already on main. A peek
failure now goes to the driver's stateless verdict (admit_parked),
which names the fault. A Driver or Codec error from a correlated
request retires the connection, as an inadmissible refusal already
did.

admit_parked used to let every Conn frame through on the assumption
that control frames are never parked, but this loop parks them. A
mixed Welcome+Cancel body therefore waited in the park queue while
the verb completed, which was also true of main's order-dependent
decode. It now decodes the Conn body as the side's control wrapper,
refuses any other-direction arm, and applies the envelope, arm and
class checks like any other family, so the fault ends the verb that
read it.

The daemon's unknown-mode fallback re-decoded the first frame as
ConnToDaemonMsg to answer a newer peer's Hello by name. A Hello with
an unknown mode beside a client-bound arm was therefore answered with
Refused { UnknownMode } instead of being closed unanswered as
corruption. On main this depended on arm order: a Welcome last won
the oneof and closed, a Welcome first did not. It now declines the
fallback whenever the body carries a client-bound arm.

The transport's liveness probe (felis doctor) decodes the first reply
itself, so a Welcome carrying a daemon-bound arm reported the peer as
live. It now reports it as connected but unverified.

The other direct decodes of a split wrapper are left alone:
- felis-grid's json_v1 already goes through decode_either, which
  refuses mixed bodies.
- The bridge's body_to_json renders a body the driver has already
  admitted.
- The remaining hits are tests.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into cleanup/wire-split
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m2s
windows / detect relevant changes (pull_request) Successful in 14s
darwin / detect relevant changes (pull_request) Successful in 14s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
windows / cargo nextest (Windows) (pull_request) Successful in 3m15s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m36s
windows / frontend smoke (Windows) (pull_request) Successful in 33s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 34s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 23s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
efc7639dca
natsukium deleted branch cleanup/wire-split 2026-09-26 02:56:18 +09:00
Commenting is not possible because the repository is archived.
No description provided.