protocol: FrameWriter::send accepts a correlated arm and writes a frame the peer must refuse #196
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#196
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
FrameWriter::sendaccepts a correlated arm and writes a frame the peer is obliged to refuseWhat happens
FrameWriter::sendis generic overM: WireCodec + MinorGated + Sync, which admits everydomain message — including arms whose
ArmMetadeclaresCorrelationClass::StreamOpener,StreamItem,RequestOpenerorRequestReply. The frame it writes carries no correlationenvelope, 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 —
sendrefuses anaddition 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-coreconsumer (here felis-web-gateway's notification observer) thatspells the subscribe the obvious way:
The daemon logs:
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 bridgeeach hand-roll:Suggested shape
Bound
sendonDirectedand refuse an arm whose class is notUncorrelated, the way italready refuses an unauthorized minor — a
TransportErrornaming the arm and the id it wantsturns a silent connection death into a message at the call site.
send_correlatedstays theone way to write a correlated arm.
A helper for the common case (
Connection::open_stream(&msg)returning the allocatedStreamId, doing the phase transition) would additionally stop each consumer fromre-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 sidethat caused it.
Triage plan (2026-09-07)
Verdict: accepted,
priority/P2(no wire byte, CLI, config, or default changes; the crates arepublish = 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::newdefault minor, adjacent hunks in the same file, no overlap), or #164.Verified:
FrameWriter::send/send_unflushed(crates/felis-transport/src/framing.rs) have noDirectedbound and go throughCheckedFrame::encode, which runs onlyvalidate()andrequires();codec::encodewrites no envelope. The driver'sadmit_class(driver.rs) refuses the frame with exactly the loggedDriverError::Correlation, and the daemon's first-op path propagates it with no frame written, so the client sees a clean EOF. All tenwire_codec!families implementDirected, so the bound excludes no real type. Symmetric hole not named above:SessionMsgis in thecorrelated!list yet everySessionarm isUncorrelated, sosend_correlated(&SessionMsg::…)compiles and writes an envelope the driver refuses; same for aRequestOpenersent withCorrelation::stream(..). The fix point isCheckedFrame::encode/encode_correlated, notsend: the client's queue (felis-client-core/src/outgoing.rsOutgoingFrame::ordered/input) and the daemon fan-out (serve/streaming.rs) never callsend.Approach (~150–200 lines + docs):
framing.rs: boundCheckedFrame::encodeon+ Directedand refusemeta().correlation != Uncorrelated; boundencode_correlatedon+ Directedand refuseUncorrelatedplus a class/id-kind mismatch (Request id on a stream class and vice versa).send*,OutgoingFrame::*, streaming fan-out, andconnector.rssend_msginherit the bound. Key the check onmeta().correlation, never on "has an id" —ConnMsg::Cancel/End/Errorcarry astream_idinline and areUncorrelated.TransportError::Correlation { arm, expected, found }reusingCorrelationClass::expects()so sender and receiver print the same sentence.framing.rs: opener viasendrefused; uncorrelated viasend_correlatedrefused; wrong id kind refused; honest pairings go out; oneoutgoing.rstest that the queue path refuses. Raw test paths (CheckedFrame::raw,write_frame_unchecked) stay the only escape hatch.docs/reference/ipc.md"Correlation, requests, and streams" and theFrameWriter::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.mdsend-gate row. No CHANGELOG.Connection::open_stream(&msg)helper is split out (#199): it cannot be generic becauseobserving()is Observer/Setup-only whileRegion::Rows/Search::Queryopen streams fromAttached.