protocol: FrameWriter::send accepts a correlated arm and writes a frame the peer must refuse #196

Closed
opened 2026-09-07 20:52:53 +09:00 by natsukium · 1 comment
Owner

FrameWriter::send accepts a correlated arm and writes a frame the peer is obliged to refuse

What happens

FrameWriter::send is generic over M: WireCodec + MinorGated + Sync, which admits every
domain message — including arms whose ArmMeta declares CorrelationClass::StreamOpener,
StreamItem, RequestOpener or RequestReply. The frame it writes carries no correlation
envelope, so the receiving driver refuses it and ends the connection. Nothing on the
sending side says so: it compiles, the write succeeds, and the only symptom is an EOF the
sender reads as "the peer went away".

The send boundary already authorizes the other half of the same contract — send refuses an
addition above the negotiated minor through MinorGated — so the asymmetry is what surprises:
one wire rule is enforced where the frame is written, the neighbouring one is not.

Reproduction

felis at 105b089979369fd310757375af3df7900ae7e523, felis-daemon serve --socket <path>,
and a felis-client-core consumer (here felis-web-gateway's notification observer) that
spells the subscribe the obvious way:

let mut conn = connect_carrier(carrier, Offer::observer(), RemoteSpawn::Allow).await?;
conn.writer
    .send(&NotifyMsg::Subscribe { session_prefix: None })
    .await?;              // Ok(())
while let Ok(Some(frame)) = conn.reader.next_frame().await { /* never entered */ }

The daemon logs:

INFO felis_daemon::serve: handshake established mode=Observer effective_minor=10
WARN felis_daemon::serve: connection ended with error err=Driver(Correlation { kind: Notify,
  expected: "a stream_id allocating the next unopened stream",
  found: "no correlation envelope on Notify::Subscribe" })

The client sees a closed connection with no error of its own. Under a redial loop — which is
what a standing subscription needs — this is an invisible failure: notifications simply never
arrive, on every host, forever.

The working spelling is the three-step dance felis sessions/felis bridge each hand-roll:

let stream = conn.driver.open_stream()?;
conn.driver.observing();
conn.writer
    .send_correlated(&NotifyMsg::Subscribe { session_prefix: None }, Correlation::stream(stream))
    .await?;

Suggested shape

Bound send on Directed and refuse an arm whose class is not Uncorrelated, the way it
already refuses an unauthorized minor — a TransportError naming the arm and the id it wants
turns a silent connection death into a message at the call site. send_correlated stays the
one way to write a correlated arm.

A helper for the common case (Connection::open_stream(&msg) returning the allocated
StreamId, doing the phase transition) would additionally stop each consumer from
re-deriving the ordering rule — that the phase must move before the write, or the ack
itself is judged out of phase.

Why it is not felis-web-gateway's fix

The gateway is patched (it now opens the stream properly), so nothing is blocked. What is
reported here is that the wrong spelling is the reachable one for any satellite client
written against felis-client-core, and that its failure carries no diagnosis on the side
that caused it.

`FrameWriter::send` accepts a correlated arm and writes a frame the peer is obliged to refuse ## What happens `FrameWriter::send` is generic over `M: WireCodec + MinorGated + Sync`, which admits every domain message — including arms whose `ArmMeta` declares `CorrelationClass::StreamOpener`, `StreamItem`, `RequestOpener` or `RequestReply`. The frame it writes carries no correlation envelope, so the receiving driver refuses it and **ends the connection**. Nothing on the sending side says so: it compiles, the write succeeds, and the only symptom is an EOF the sender reads as "the peer went away". The send boundary already authorizes the other half of the same contract — `send` refuses an addition above the negotiated minor through `MinorGated` — so the asymmetry is what surprises: one wire rule is enforced where the frame is written, the neighbouring one is not. ## Reproduction felis at `105b089979369fd310757375af3df7900ae7e523`, `felis-daemon serve --socket <path>`, and a `felis-client-core` consumer (here felis-web-gateway's notification observer) that spells the subscribe the obvious way: ```rust let mut conn = connect_carrier(carrier, Offer::observer(), RemoteSpawn::Allow).await?; conn.writer .send(&NotifyMsg::Subscribe { session_prefix: None }) .await?; // Ok(()) while let Ok(Some(frame)) = conn.reader.next_frame().await { /* never entered */ } ``` The daemon logs: ``` INFO felis_daemon::serve: handshake established mode=Observer effective_minor=10 WARN felis_daemon::serve: connection ended with error err=Driver(Correlation { kind: Notify, expected: "a stream_id allocating the next unopened stream", found: "no correlation envelope on Notify::Subscribe" }) ``` The client sees a closed connection with no error of its own. Under a redial loop — which is what a standing subscription needs — this is an invisible failure: notifications simply never arrive, on every host, forever. The working spelling is the three-step dance `felis sessions`/`felis bridge` each hand-roll: ```rust let stream = conn.driver.open_stream()?; conn.driver.observing(); conn.writer .send_correlated(&NotifyMsg::Subscribe { session_prefix: None }, Correlation::stream(stream)) .await?; ``` ## Suggested shape Bound `send` on `Directed` and refuse an arm whose class is not `Uncorrelated`, the way it already refuses an unauthorized minor — a `TransportError` naming the arm and the id it wants turns a silent connection death into a message at the call site. `send_correlated` stays the one way to write a correlated arm. A helper for the common case (`Connection::open_stream(&msg)` returning the allocated `StreamId`, doing the phase transition) would additionally stop each consumer from re-deriving the ordering rule — that the phase must move *before* the write, or the ack itself is judged out of phase. ## Why it is not felis-web-gateway's fix The gateway is patched (it now opens the stream properly), so nothing is blocked. What is reported here is that the wrong spelling is the reachable one for any satellite client written against `felis-client-core`, and that its failure carries no diagnosis on the side that caused it.
Author
Owner

Triage plan (2026-09-07)

Verdict: accepted, priority/P2 (no wire byte, CLI, config, or default changes; the crates are publish = false, so a new trait bound has no semver cost), landing right after the v0.1 queue as the cheapest hardening in the #50 family. Not a duplicate of #175 (GATED_FIELDS ↔ call sites), #176 (FrameWriter::new default minor, adjacent hunks in the same file, no overlap), or #164.

Verified: FrameWriter::send/send_unflushed (crates/felis-transport/src/framing.rs) have no Directed bound and go through CheckedFrame::encode, which runs only validate() and requires(); codec::encode writes no envelope. The driver's admit_class (driver.rs) refuses the frame with exactly the logged DriverError::Correlation, and the daemon's first-op path propagates it with no frame written, so the client sees a clean EOF. All ten wire_codec! families implement Directed, so the bound excludes no real type. Symmetric hole not named above: SessionMsg is in the correlated! list yet every Session arm is Uncorrelated, so send_correlated(&SessionMsg::…) compiles and writes an envelope the driver refuses; same for a RequestOpener sent with Correlation::stream(..). The fix point is CheckedFrame::encode/encode_correlated, not send: the client's queue (felis-client-core/src/outgoing.rs OutgoingFrame::ordered/input) and the daemon fan-out (serve/streaming.rs) never call send.

Approach (~150–200 lines + docs):

  1. framing.rs: bound CheckedFrame::encode on + Directed and refuse meta().correlation != Uncorrelated; bound encode_correlated on + Directed and refuse Uncorrelated plus a class/id-kind mismatch (Request id on a stream class and vice versa). send*, OutgoingFrame::*, streaming fan-out, and connector.rs send_msg inherit the bound. Key the check on meta().correlation, never on "has an id" — ConnMsg::Cancel/End/Error carry a stream_id inline and are Uncorrelated.
  2. New TransportError::Correlation { arm, expected, found } reusing CorrelationClass::expects() so sender and receiver print the same sentence.
  3. Tests beside the send-gate tests in framing.rs: opener via send refused; uncorrelated via send_correlated refused; wrong id kind refused; honest pairings go out; one outgoing.rs test that the queue path refuses. Raw test paths (CheckedFrame::raw, write_frame_unchecked) stay the only escape hatch.
  4. Docs: docs/reference/ipc.md "Correlation, requests, and streams" and the FrameWriter::send* sentence (the encoder refuses a frame whose envelope does not match its class — class, not identity: the next-unopened id and phase ordering stay the driver's job); docs/explanation/architecture/ipc.md "One identity per arm" (one paragraph on why both ends check); docs/reference/testing.md send-gate row. No CHANGELOG.
  5. The Connection::open_stream(&msg) helper is split out (#199): it cannot be generic because observing() is Observer/Setup-only while Region::Rows/Search::Query open streams from Attached.
## Triage plan (2026-09-07) **Verdict:** accepted, `priority/P2` (no wire byte, CLI, config, or default changes; the crates are `publish = false`, so a new trait bound has no semver cost), landing right after the v0.1 queue as the cheapest hardening in the #50 family. Not a duplicate of #175 (`GATED_FIELDS` ↔ call sites), #176 (`FrameWriter::new` default minor, adjacent hunks in the same file, no overlap), or #164. **Verified:** `FrameWriter::send`/`send_unflushed` (`crates/felis-transport/src/framing.rs`) have no `Directed` bound and go through `CheckedFrame::encode`, which runs only `validate()` and `requires()`; `codec::encode` writes no envelope. The driver's `admit_class` (`driver.rs`) refuses the frame with exactly the logged `DriverError::Correlation`, and the daemon's first-op path propagates it with no frame written, so the client sees a clean EOF. All ten `wire_codec!` families implement `Directed`, so the bound excludes no real type. **Symmetric hole not named above:** `SessionMsg` is in the `correlated!` list yet every `Session` arm is `Uncorrelated`, so `send_correlated(&SessionMsg::…)` compiles and writes an envelope the driver refuses; same for a `RequestOpener` sent with `Correlation::stream(..)`. **The fix point is `CheckedFrame::encode`/`encode_correlated`, not `send`:** the client's queue (`felis-client-core/src/outgoing.rs` `OutgoingFrame::ordered`/`input`) and the daemon fan-out (`serve/streaming.rs`) never call `send`. **Approach (~150–200 lines + docs):** 1. `framing.rs`: bound `CheckedFrame::encode` on `+ Directed` and refuse `meta().correlation != Uncorrelated`; bound `encode_correlated` on `+ Directed` and refuse `Uncorrelated` plus a class/id-kind mismatch (Request id on a stream class and vice versa). `send*`, `OutgoingFrame::*`, streaming fan-out, and `connector.rs` `send_msg` inherit the bound. Key the check on `meta().correlation`, never on "has an id" — `ConnMsg::Cancel/End/Error` carry a `stream_id` inline and are `Uncorrelated`. 2. New `TransportError::Correlation { arm, expected, found }` reusing `CorrelationClass::expects()` so sender and receiver print the same sentence. 3. Tests beside the send-gate tests in `framing.rs`: opener via `send` refused; uncorrelated via `send_correlated` refused; wrong id kind refused; honest pairings go out; one `outgoing.rs` test that the queue path refuses. Raw test paths (`CheckedFrame::raw`, `write_frame_unchecked`) stay the only escape hatch. 4. Docs: `docs/reference/ipc.md` "Correlation, requests, and streams" and the `FrameWriter::send*` sentence (the encoder refuses a frame whose envelope does not match its class — class, not identity: the next-unopened id and phase ordering stay the driver's job); `docs/explanation/architecture/ipc.md` "One identity per arm" (one paragraph on why both ends check); `docs/reference/testing.md` send-gate row. No CHANGELOG. 5. The `Connection::open_stream(&msg)` helper is **split out** (#199): it cannot be generic because `observing()` is Observer/`Setup`-only while `Region::Rows`/`Search::Query` open streams from `Attached`.
Sign in to join this conversation.
No description provided.