client-core: one phase-correct helper for opening a stream #235

Merged
natsukium merged 1 commit from issue-199 into main 2026-09-08 22:41:24 +09:00
Owner

A stream id is positional on the wire: the daemon accepts an opening request only when its stream_id is exactly the
next unopened one, so opening a stream means allocating that id, applying the phase transition the opener needs, and
stamping the stream_id envelope, in that order with nothing in between. Three CLI call sites re-derived the sequence
with the ordering rule repeated as a comment beside each — the shape where a future opener goes out with no envelope, or
leaves an id issued for a frame that never goes out, which the daemon answers by ending the connection.

felis-client-core now owns it: begin_stream issues the id plus the SetupObserving transition an observer's
Notify::Subscribe needs, and open_stream writes the opener under it with nothing in between. Connection::open_stream
and Connection::subscribe_notifications are the attached and observer entry points for felis sessions capture,
felis sessions search, and felis notifications subscribe; the felis bridge link, which must register a receiver
between the allocation and the write, hands that registration to the same helper as a callback and loses its separate
becoming_observer step. A single generic Connection::open_stream could not serve all three: the observer transition
is Setup-only while the region and search streams open from Attached, and the bridge multiplexes one connection
across concurrent tasks, so the phase is an argument and the caller's between-the-locks work a callback.

Tests: three unit tests in felis-client-core pin the id order from Attached (1, 2, 3, each stamped on its own
opener), the observer's open from Setup (id 1, driver left Observing, Notify::Subscribe correlated by stream id),
and that an unsendable body issues no id. A source guard in felis-cli fails if any CLI source hand-rolls
driver.open_stream() or Correlation::stream( again. No wire, CLI, or config change, so no CHANGELOG;
docs/reference/ipc.md "Opening a stream" names the helper.

The GUI client's search open (felis-client's commit_search_query) is left alone: it has no FrameWriter at hand and
sends through the outgoing queue, so it shares no sequence with these three.

Fixes #199

A stream id is positional on the wire: the daemon accepts an opening request only when its `stream_id` is exactly the next unopened one, so opening a stream means allocating that id, applying the phase transition the opener needs, and stamping the `stream_id` envelope, in that order with nothing in between. Three CLI call sites re-derived the sequence with the ordering rule repeated as a comment beside each — the shape where a future opener goes out with no envelope, or leaves an id issued for a frame that never goes out, which the daemon answers by ending the connection. `felis-client-core` now owns it: `begin_stream` issues the id plus the `Setup` → `Observing` transition an observer's `Notify::Subscribe` needs, and `open_stream` writes the opener under it with nothing in between. `Connection::open_stream` and `Connection::subscribe_notifications` are the attached and observer entry points for `felis sessions capture`, `felis sessions search`, and `felis notifications subscribe`; the `felis bridge` link, which must register a receiver between the allocation and the write, hands that registration to the same helper as a callback and loses its separate `becoming_observer` step. A single generic `Connection::open_stream` could not serve all three: the observer transition is `Setup`-only while the region and search streams open from `Attached`, and the bridge multiplexes one connection across concurrent tasks, so the phase is an argument and the caller's between-the-locks work a callback. Tests: three unit tests in `felis-client-core` pin the id order from `Attached` (1, 2, 3, each stamped on its own opener), the observer's open from `Setup` (id 1, driver left `Observing`, `Notify::Subscribe` correlated by stream id), and that an unsendable body issues no id. A source guard in `felis-cli` fails if any CLI source hand-rolls `driver.open_stream()` or `Correlation::stream(` again. No wire, CLI, or config change, so no CHANGELOG; `docs/reference/ipc.md` "Opening a stream" names the helper. The GUI client's search open (`felis-client`'s `commit_search_query`) is left alone: it has no `FrameWriter` at hand and sends through the outgoing queue, so it shares no sequence with these three. Fixes #199
client-core: own stream opening instead of three hand-rolled sequences
All checks were successful
darwin / darwin docs-only no-op (pull_request) Has been skipped
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) Successful in 2m7s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m8s
pr / nix flake check (pull_request) Successful in 31s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m47s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 58s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 4m39s
windows / frontend smoke (Windows) (pull_request) Successful in 2m4s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
31a96e03e4
A stream id is positional on the wire, so opening one means allocating
the next unopened id, applying the phase transition the opener needs,
and stamping the `stream_id` envelope, in that order and with nothing in
between. Three CLI call sites re-derived that sequence with the ordering
rule repeated as a comment beside each, which is the shape where a
future opener sends a `StreamOpener` arm under no envelope or, worse,
leaves an id issued for a frame that never goes out: a skip the daemon
answers by ending the connection.

`open_stream` now owns the sequence and `begin_stream` the allocation
plus the `Setup` -> `Observing` transition. `Connection::open_stream`
and `Connection::subscribe_notifications` are the attached and observer
entry points; the bridge link, which must register a receiver between
the allocation and the write, hands that registration to the same
helper, so its own `becoming_observer` step disappears.

A single generic opener on `Connection` cannot serve all three: the
observer transition is `Setup`-only while the region and search streams
open from `Attached`, and the bridge multiplexes one connection across
concurrent tasks. The phase is therefore an argument and the caller's
between-the-locks work a callback, rather than three separate helpers.

Assisted-by: Claude Code
natsukium deleted branch issue-199 2026-09-08 22:41:24 +09:00
Sign in to join this conversation.
No description provided.