[v0.1/P1] Extend the shared driver through application phases #46
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#46
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?
Problem
The shared driver is described as the single phase/direction/mode/correlation state machine, but its phase model stops after the application handshake.
At review snapshot
5077d74b:ConnectionDriver::handshake_donemoves directly toPhase::SteadyafterWelcome.wait_for_attach,route_frame, and observer code add manual guards afterward.Connection::recv_msgchecks a kind and callscodec::decodedirectly for uncorrelated session replies, bypassing the driver's direction and correlation path.Session::Attachafter attachment, a pre-attach grid push, and observer/session traffic are therefore not rejected by one reusable state transition table.This is an API contract problem for future clients as much as an internal cleanup: the docs promise one validator, while an implementer currently has to reconstruct caller-specific sequencing.
Required change
Extend the typed connection state machine through application setup and terminal roles, then make every inbound frame pass through it exactly once. Coordinate the shape with #20's atomic create/attach redesign before freezing protocol 2.0.
Acceptance criteria
matchguards in daemon/client call sites.(side, mode, phase, kind/arm)boundary plus malformed transitions.docs/reference/ipc.mdstates the actual phases and teardown result;docs/explanation/architecture/ipc.mdkeeps the rationale for a shared sans-I/O driver.Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.Claim check
Accurate against HEAD; nothing post-snapshot touched the driver or the routing loops.
PhaseisPreface | Handshake | Steady(crates/felis-transport/src/driver.rs:43-48);handshake_donemoves straight toSteady(:219-222).admit_kind(:567-605) gates on phase only forPreface/Handshake(:574-587); after that the only filters aresole_sender(:615-626) andmode_admits(:631-646).Steadystate serves the daemon's pre-attach loopwait_for_attach(crates/felis-daemon/src/serve.rs:440-716), the attached pump'sroute_frame(:1123-1233), and the observer (:642-707). Each re-derives legality by hand: pre-attach refuses a secondHelloat:445-451,Sessionreply arms at:621-623, non-SubscribeNotifyarms at:699-705, "any other kind" at:708-713; post-attachroute_framerefuses a secondAttach/Createat:1211-1216and unknown kinds at:1228-1231.ops_denied(:787-790) runs both pre-attach (:631) and in the pump (:1329).Connection::recv_msg(crates/felis-client-core/src/connector.rs:1007-1013) checksframe.kindand callscodec::decodedirectly; it servesrequest()(:791-795), i.e.attachandspawn_session/create_with(:915-932). ThoseSessionreplies never passConnectionDriver::decode's direction check (driver.rs:396-403).correlated_request(:797-858) does go through the driver;is_reply_to(:868-898) is a peek and re-classifies, as its comment says. One more direct decode in non-test code:crates/felis-cli/src/cli_bridge.rs:1950-1955decodesGridMsgafterclassifywithoutdriver.decode(harmless today:Gridis a one-way family checked bysole_sender, but it is the pattern the issue objects to). The GUI (crates/felis-client/src/main.rs:1773-1870),cli_notifications.rs:230, andcli_sessions.rs:1836are clean.from_halves(connector.rs:756-773) builds a driver already inSteady, so the bridge's attached connections start with no phase history at all.Attachare therefore rejected by call-sitematcharms, not by a transition table; the docs promise the latter (docs/reference/ipc.md:440-444,docs/explanation/architecture/ipc.md:686-689).each_phase_admits_only_its_own_frames(driver/tests.rs:485),a_second_hello_in_steady_state_closes_the_connection(:523),a_mode_refuses_a_surface_it_never_asked_for(:540). Nothing covers post-handshake states.One nuance the issue underplays:
Steadyis not one state on the daemon but three call sites with hand-rolled guards, and on the client it is two (requestvscorrelated_request). The fix is the same either way.Verdict
accept-with-changes. Do it, but bound the shape: a small explicit phase enum plus a
(side, mode, phase, kind)admission table in the driver, and per-arm phase legality carried on the arm metadata that #47 introduces. Do not build a typestate API (the driver is shared behind aMutexin the GUI,main.rs:1780, and behind&mutin three daemon loops; a typestate would force three drivers, whichdocs/explanation/architecture/ipc.md:723-727already rejects). Sequencing changes from the issue text: this lands after #47/#48 (arm metadata) and #20 (the create/attach flow it must model), as #52's order says.Approach
felis-transport/src/driver.rs:Phase::{Preface, Handshake, Setup, Attached, Observing}.Setupis post-Welcome, pre-attach/subscribe (the daemon'swait_for_attachloop, the client betweenhandshake_overandattach). Transitions:handshake_done(mode)→Setup;attached()→Attached(daemon: after the subscribe reply atserve.rs:610, beforeReadyis written; client: on decodingSession::Ready);observing(stream)→Observing(daemon: afterNotify::Subscribedat:667-672; client:cli_notifications);detached()→ back toSetuponly if #20 keeps a re-attach on one connection (todayRoute::Detachends the pump; if so, no transition, the connection closes). AClosingphase is unnecessary: teardown is the connection ending.mode_admitsand the phasematchinadmit_kind:const fn admits(side, mode, phase, kind) -> boolwritten as an exhaustivematch(each(phase, kind)row states the modes). Rows:SetupadmitsConn(lifecycle only),Session(openers),Ops(queries+mutations per mode),Notify(Subscribe, Observer only);AttachedadmitsConn,Input,Grid,Image,Push,Session(Detach,ConfigureTheme),Ops,Region,Search;ObservingadmitsConnandNotifyitems. Arm-level rows ("openers only in Setup", "reply arms only inbound to the client") come from the #47 arm metadata (phase: PhaseSet) and are checked indecodeafter the direction check (driver.rs:396-403), sowait_for_attach:621-623,:699-705,route_frame:1211-1216and theunreachable!arms collapse intoDelivery::Deliver/ typedDriverError::OutOfPhase { arm, phase }.DriverErrorgainsOutOfPhase;ModeDeniedkeeps its pre-attach shape (Refused+ close) and post-attach shape (typed request error) but the choice is made by the driver fromphase, returningDelivery::RefuseRequest { reply }inAttached(mirrors the existingRefuseStream) soops_deniedandrefuse_modebecome data-driven.correlated_requestparks frames and re-feeds them viapending(connector.rs:862-866,:903-908), classifying only when consumed; keep that, and add a debug counter/test that a parked frame is classified exactly once (the driver test can feed the sameOwnedFrametwice and expect the second to be a correlation error, whicha_second_terminal_closes_the_connectionalready models).Daemon (
serve.rs):wait_for_attachandroute_frameshrink to routing onDelivery::Deliver(msg); remove the hand guards listed above; calldriver.attached()/driver.observing()at the two points named. Client: replacerecv_msgwithclassify+driver.decode::<Q::Msg>(mirrorcorrelated_request:848-856);from_halvestakes aPhaseargument (bridge passesSetupfor a fresh connection andAttachedonly where it re-wraps an already-attached pair).cli_bridge.rs:1950-1955: decode through the driver.Tests: a table-driven driver test enumerating every
(side, mode, phase, kind)cell (10 kinds × 3 modes × 5 phases × 2 sides = 300 rows) generated from the sameadmitsfunction's inverse expectation list, plus arm-level cases: secondAttachinAttached,Gridpush inSetup(client side),Session::Readyinbound at the daemon,Notify::Eventto anAttachedwindow,Ops::Destroyfrom aWindowinSetup(Refused) vsAttached(typed error). Daemon integration tests already atserve/tests.rs:3259,:3511,:3623extend naturally.Docs:
docs/reference/ipc.md:440-444name the five phases and their transitions (a small table), and state that teardown is the connection ending;:1754-1777"Handshake" gets theSetupname;docs/explanation/architecture/ipc.md:686-703keep the rationale, add why phases are data not typestate (three hosts, one driver,:723-727);docs/reference/spec.mdREQ-114 wording if it enumerates the corruption sources. CHANGELOG: not user-visible unless error strings change; one line under Changed ifRefusedvs typed-error timing moves.skills/felis: no CLI change.Dependencies
Decide #47 (arm metadata shape) and #48 (correlation class on that metadata) first; land #20 before this so
Setup→Attachedmodels the atomic create+attach rather than today'sCreatethenAttachloop (serve.rs:551-553). #30 after. #52's order (item 2) holds.Risk/effort
M (2-3 days after #47/#48/#20). Main risk: the GUI keeps the driver behind a mutex and transitions must be applied on that instance by the reader thread, not by the event loop, or a frame classified between
Readyandattached()is refused; second risk is the bridge'sfrom_halvescallers assumingSteady.Labels
Keep
priority/P1,release/v0.1.0: it is the contract a non-Rust peer implements against, and the freeze boundary (#12) includes "observable session/window/mirror/update behavior".