protocol: terminate a pull-paced grid cycle on the wire #321

Merged
natsukium merged 6 commits from grid-cycle-end into main 2026-09-21 17:14:10 +09:00
Owner

Closes #307

Stacking

This stacks on PR #319 (connection-mode-minor, issue #312) and must merge after it: it branches from
connection-mode-minor and takes PROTOCOL_MINOR 12, the next contiguous minor after #319's 11. The ledger sync tests
require contiguity. If #319 changes during review this branch needs a rebase. (#313 later folds every minor to 0.)

What lands

GridMsg::CycleEnd (minor 12, modes=window,ops, phases=attached, proto tag 21) makes the end of one pull-paced
compose cycle explicit.

Daemon — ship_sub_at (crates/felis-daemon/src/serve/session_task.rs) appends the marker after the
out.is_empty() check and after everything compose_diffs produced, which includes the registry drip tail and every
split RowDelta frame. Emitted only when sub.pull_pacing and only when the subscriber's effective minor admits the
arm; an older subscriber has it suppressed, not refused, on the downgrade_push precedent. An empty cycle keeps
today's behavior exactly: no message, pull_pending stays armed, the client's pull stays outstanding.

Client — PullScheduler (crates/felis-client-core/src/pull.rs) is minor-aware. Below minor 12 it keeps today's
answers_pull release. At or above it, the first pull-answering message opens cycle_open; CycleEnd closes it and
clears outstanding. App's RedrawRequested arm now goes through may_paint_grid(has_surface, rehydrating, cycle_open), so the grid paint is suppressed for the whole cycle whatever asked for the redraw — a pending redraw, a
cursor blink, an animation tick, a facet push. The marker's own frame requests one redraw through the existing
redraw.request(). The effective minor is threaded to both scheduler construction sites (main.rs and
install_new_session), so a retarget onto a differently-vintaged daemon re-gates correctly.

Rehydrate keeps RehydrateEnd: the burst is eager, answers no pull, and no marker follows it, so RehydrateBegin /
RehydrateEnd explicitly close any open cycle rather than opening one that nothing would terminate. A connection lost
mid-cycle calls abandon_cycle() in on_daemon_closed, or the window would stop painting the chrome that says it is
disconnected.

Tests

  • felis-client-core::pull: only_the_marker_frees_the_next_pull, multiple_frames_one_iteration_yield_one_pull
    (rewritten for the marker), a_cycle_stays_open_across_every_frame_it_carries,
    a_cycle_without_rows_is_still_bounded_by_the_marker (the Ops-shaped cycle),
    the_rehydrate_burst_is_closed_by_its_own_boundary, a_peer_below_the_marker_minor_releases_on_the_first_frame,
    abandoning_an_open_cycle_releases_the_paint, plus the updated a_facet_push_does_not_answer_the_pull.
  • felis-client::event_handler::paint_gate_tests: no_redraw_source_paints_a_cycle_before_its_marker (the adversarial
    case: open a cycle, then inject facet/blink redraws, assert nothing paints until CycleEnd) and
    a_rehydrating_shadow_paints_nothing_even_with_no_cycle_open.
  • felis-daemon::serve::session_task: a_pull_cycle_ends_with_one_marker_after_its_last_frame,
    an_eager_push_peer_gets_no_cycle_marker, a_subscriber_below_the_marker_minor_is_sent_none.
    pull_with_nothing_dirty_emits_no_redundant_frame stays and now also pins that an empty cycle ships no marker.
  • the_registry_drain_does_not_manufacture_a_frame_on_a_quiet_cycle and
    a_row_batch_past_the_budget_splits_across_frames are unchanged: both sit at the compose_diffs /
    push_row_deltas layer, below where the marker is appended.
  • Fuzz seed fuzz/seeds/ipc_body/proto-grid-cycle-end.bin.

Gates run

just proto (regenerated codegen committed), just proto-compat (wire-compatible), just prose-check,
just docs-links, cargo clippy --workspace --all-targets (zero warnings), cargo nextest run --all-features over
every crate (2816 tests, all green).

Notes

  • The marker is appended by push_cycle_end, driven from ship_sub_at after everything compose_diffs produced. the_marker_follows_the_last_frame_of_a_split_cycle drives push_row_deltas over a batch that splits past OUTBOUND_BODY_BUDGET_BYTES and then push_cycle_end, asserting at least two RowDelta frames and exactly one final CycleEnd; no admitted geometry can split end to end at the 32 MiB budget, so the test covers the seam ship_sub_at composes rather than a live session.
  • PullScheduler tracks cycles only when pull pacing is enabled and the effective minor admits the marker, the same condition the daemon emits on, so an eager-push (SSH) window never opens a cycle.
  • A cycle cut short by a disconnect keeps the grid paint gated: the gate is handed to the replacement scheduler on reconnect or retarget and lifts on that connection's rehydrate burst (RehydrateBegin/RehydrateEnd), on any peer kind, so a partially applied cycle is never painted and the window title still reports the disconnect. Buffering the cycle's mutations and discarding them on disconnect was rejected: a second copy of every diff on the hot path, for a screen the burst overwrites anyway.
  • just fuzz-smoke was not run locally (disk); the seed fuzz/seeds/ipc_body/proto-grid-cycle-end.bin is committed and CI runs the smoke.
Closes #307 ## Stacking This stacks on PR #319 (`connection-mode-minor`, issue #312) and **must merge after it**: it branches from `connection-mode-minor` and takes `PROTOCOL_MINOR` 12, the next contiguous minor after #319's 11. The ledger sync tests require contiguity. If #319 changes during review this branch needs a rebase. (#313 later folds every minor to 0.) ## What lands `GridMsg::CycleEnd` (minor 12, `modes=window,ops`, `phases=attached`, proto tag 21) makes the end of one pull-paced compose cycle explicit. **Daemon** — `ship_sub_at` (`crates/felis-daemon/src/serve/session_task.rs`) appends the marker after the `out.is_empty()` check and after everything `compose_diffs` produced, which includes the registry drip tail and every split `RowDelta` frame. Emitted only when `sub.pull_pacing` and only when the subscriber's effective minor admits the arm; an older subscriber has it **suppressed**, not refused, on the `downgrade_push` precedent. An empty cycle keeps today's behavior exactly: no message, `pull_pending` stays armed, the client's pull stays outstanding. **Client** — `PullScheduler` (`crates/felis-client-core/src/pull.rs`) is minor-aware. Below minor 12 it keeps today's `answers_pull` release. At or above it, the first pull-answering message opens `cycle_open`; `CycleEnd` closes it and clears `outstanding`. `App`'s `RedrawRequested` arm now goes through `may_paint_grid(has_surface, rehydrating, cycle_open)`, so the grid paint is suppressed for the whole cycle whatever asked for the redraw — a pending redraw, a cursor blink, an animation tick, a facet push. The marker's own frame requests one redraw through the existing `redraw.request()`. The effective minor is threaded to both scheduler construction sites (`main.rs` and `install_new_session`), so a retarget onto a differently-vintaged daemon re-gates correctly. **Rehydrate** keeps `RehydrateEnd`: the burst is eager, answers no pull, and no marker follows it, so `RehydrateBegin` / `RehydrateEnd` explicitly close any open cycle rather than opening one that nothing would terminate. A connection lost mid-cycle calls `abandon_cycle()` in `on_daemon_closed`, or the window would stop painting the chrome that says it is disconnected. ## Tests - `felis-client-core::pull`: `only_the_marker_frees_the_next_pull`, `multiple_frames_one_iteration_yield_one_pull` (rewritten for the marker), `a_cycle_stays_open_across_every_frame_it_carries`, `a_cycle_without_rows_is_still_bounded_by_the_marker` (the Ops-shaped cycle), `the_rehydrate_burst_is_closed_by_its_own_boundary`, `a_peer_below_the_marker_minor_releases_on_the_first_frame`, `abandoning_an_open_cycle_releases_the_paint`, plus the updated `a_facet_push_does_not_answer_the_pull`. - `felis-client::event_handler::paint_gate_tests`: `no_redraw_source_paints_a_cycle_before_its_marker` (the adversarial case: open a cycle, then inject facet/blink redraws, assert nothing paints until `CycleEnd`) and `a_rehydrating_shadow_paints_nothing_even_with_no_cycle_open`. - `felis-daemon::serve::session_task`: `a_pull_cycle_ends_with_one_marker_after_its_last_frame`, `an_eager_push_peer_gets_no_cycle_marker`, `a_subscriber_below_the_marker_minor_is_sent_none`. `pull_with_nothing_dirty_emits_no_redundant_frame` stays and now also pins that an empty cycle ships no marker. - `the_registry_drain_does_not_manufacture_a_frame_on_a_quiet_cycle` and `a_row_batch_past_the_budget_splits_across_frames` are unchanged: both sit at the `compose_diffs` / `push_row_deltas` layer, below where the marker is appended. - Fuzz seed `fuzz/seeds/ipc_body/proto-grid-cycle-end.bin`. ## Gates run `just proto` (regenerated codegen committed), `just proto-compat` (wire-compatible), `just prose-check`, `just docs-links`, `cargo clippy --workspace --all-targets` (zero warnings), `cargo nextest run --all-features` over every crate (2816 tests, all green). ## Notes - The marker is appended by `push_cycle_end`, driven from `ship_sub_at` after everything `compose_diffs` produced. `the_marker_follows_the_last_frame_of_a_split_cycle` drives `push_row_deltas` over a batch that splits past `OUTBOUND_BODY_BUDGET_BYTES` and then `push_cycle_end`, asserting at least two `RowDelta` frames and exactly one final `CycleEnd`; no admitted geometry can split end to end at the 32 MiB budget, so the test covers the seam `ship_sub_at` composes rather than a live session. - `PullScheduler` tracks cycles only when pull pacing is enabled and the effective minor admits the marker, the same condition the daemon emits on, so an eager-push (SSH) window never opens a cycle. - A cycle cut short by a disconnect keeps the grid paint gated: the gate is handed to the replacement scheduler on reconnect or retarget and lifts on that connection's rehydrate burst (`RehydrateBegin`/`RehydrateEnd`), on any peer kind, so a partially applied cycle is never painted and the window title still reports the disconnect. Buffering the cycle's mutations and discarding them on disconnect was rejected: a second copy of every diff on the hot path, for a screen the burst overwrites anyway. - `just fuzz-smoke` was not run locally (disk); the seed `fuzz/seeds/ipc_body/proto-grid-cycle-end.bin` is committed and CI runs the smoke.
protocol: version connection modes through the minor ledger
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
f5fe7e14e0
A mode is the extension path for a whole surface: the design says a new
surface earns a new mode rather than a per-feature bit, so mode growth
is an expected additive change, not a corner case. Until now the gate
covered `ConnWelcome.identity` alone, `ConnectionMode` carried no
`since_minor`, and a client naming a mode an older daemon predates lost
the connection at that daemon's decode with no sender-side verdict and
no frame back.

Every mode now carries a ledger row the schema walk enforces, the
`Hello` folds its mode's requirement into what the send gate reads, and
the client refuses the dial locally when the effective minor does not
define the mode it asks for. The receiving half is a typed refusal:
`RefusalReason::UnknownMode` (minor 11) names the skew before the close,
so the peer reports a daemon too old instead of an EOF it would retry.
Widening `Role` to cover it was rejected, since re-reading an existing
enum value is a semantic change the minor rule does not admit.

No mode down-maps: answering a peer with a mode it did not name would
wire up a surface it did not ask for, so the send gate and the refusal
are the whole accommodation.

Assisted-by: Claude Code
protocol: terminate a pull-paced grid cycle on the wire
Some checks failed
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) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
7897fc66bb
One compose cycle had no explicit end. The daemon cleared `pull_pending`
before the cycle's events reached the outbox, and the client released its
outstanding pull on the first message matching `answers_pull`, which is
almost every compose variant. A redraw landing between the first message
and the rest therefore issued the next pull while the cycle was still in
transit, and could paint a `Scrolled` without the rows and cursor from
the same cycle. Neither property the pacing design rests on — one
outstanding pull means one complete cycle, and no partial cycle reaches
the screen — was guaranteed by the wire.

`GridMsg::CycleEnd` (minor 12) states the boundary. The daemon appends it
in `ship_sub_at`, after the empty-cycle check and after everything
`compose_diffs` produced, so the registry tail and every split `RowDelta`
frame precede it and an empty cycle still ships nothing at all. The
client holds both the pull and the grid paint until the marker arrives,
and the paint gate reads the cycle state rather than the redraw's source:
a blink, an animation tick and a facet push are the same hazard as a
pending redraw. A sequence number was rejected: the transport is ordered
and only one pull is ever outstanding, so there is nothing to correlate.

Rehydrate keeps `RehydrateEnd` as its own boundary rather than being
folded into the cycle rule. The burst is eager, answers no pull, and no
marker follows it, so a cycle opened on its rows would never close.

An eager-push peer is sent no marker, since it has no pull gate to
release, and a subscriber below minor 12 has it suppressed rather than
refused: that peer paces as every build before this minor did, where
refusing the send would cost it a session over an arm it never named.

Assisted-by: Claude Code
protocol: track cycles only where the marker is sent
Some checks failed
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) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
2cd0142f62
The daemon emits `GridMsg::CycleEnd` only to a pull-paced subscriber, but
the client opened a cycle on any pull-answering message once the
effective minor defined the arm. An eager-push window — an SSH-carried
attach, which builds the scheduler disabled — therefore opened a cycle on
its first live `RowDelta` that no marker could ever close, and the paint
gate froze the window for the rest of the session. The two halves now
read the same condition: pull pacing on, and the minor that defines the
arm.

The split-cycle case gets the coverage the end-to-end test could not
give it. One `printf` produces a single frame, so the marker landing
after the *last* of several split `RowDelta` frames went unpinned, and a
real grid cannot reach the budget: 2048 x 2048 is the largest admitted
geometry and its rows encode to a few MiB against a 32 MiB ceiling.
Lifting the terminator out of `ship_sub_at` lets the test drive the two
halves that function composes over a batch that does split.

Assisted-by: Claude Code
Author
Owner

Closes #307

Stacking

This stacks on PR #319 (connection-mode-minor, issue #312) and must merge after it: it branches from
connection-mode-minor and takes PROTOCOL_MINOR 12, the next contiguous minor after #319's 11. The ledger sync tests
require contiguity. If #319 changes during review this branch needs a rebase. (#313 later folds every minor to 0.)

What lands

GridMsg::CycleEnd (minor 12, modes=window,ops, phases=attached, proto tag 21) makes the end of one pull-paced
compose cycle explicit.

Daemon — ship_sub_at (crates/felis-daemon/src/serve/session_task.rs) appends the marker through
push_cycle_end, after the out.is_empty() check and after everything compose_diffs produced, which includes the
registry drip tail and every split RowDelta frame. Emitted only when sub.pull_pacing and only when the subscriber's
effective minor admits the arm; an older subscriber has it suppressed, not refused, on the downgrade_push
precedent. An empty cycle keeps today's behavior exactly: no message, pull_pending stays armed, the client's pull
stays outstanding.

Client — PullScheduler (crates/felis-client-core/src/pull.rs) tracks marker-delimited cycles only when
marks_cycles() holds: pull pacing enabled and the effective minor defines the arm. Both conditions are needed,
because the daemon sends the marker on exactly that intersection — an eager-push (SSH-carried) window builds the
scheduler disabled and receives no marker however new the daemon is. Otherwise it keeps today's answers_pull release.
When cycles are marked, the first pull-answering message opens cycle_open; CycleEnd closes it and clears
outstanding.

App's RedrawRequested arm now goes through may_paint_grid(has_surface, rehydrating, cycle_open), so the grid paint
is suppressed for the whole cycle whatever asked for the redraw: a pending redraw, a cursor blink, an animation tick, a
facet push. The marker's own frame requests one redraw through the existing redraw.request(). The effective minor is
threaded to both scheduler construction sites (main.rs and install_new_session), so a retarget onto a
differently-vintaged or differently-paced daemon re-gates, and the rebuild is what drops a cycle left open on the
connection the window came from.

Rehydrate keeps RehydrateEnd: the burst is eager, answers no pull, and no marker follows it, so RehydrateBegin /
RehydrateEnd explicitly close any open cycle rather than opening one that nothing would terminate. A connection lost
mid-cycle calls abandon_cycle() in on_daemon_closed, or the window would stop painting the chrome that says it is
disconnected.

Tests

felis-client-core::pull:

  • only_the_marker_frees_the_next_pull, multiple_frames_one_iteration_yield_one_pull (rewritten for the marker),
    a_cycle_stays_open_across_every_frame_it_carries, a_cycle_without_rows_is_still_bounded_by_the_marker (the
    Ops-shaped cycle), the_rehydrate_burst_is_closed_by_its_own_boundary,
    a_peer_below_the_marker_minor_releases_on_the_first_frame, abandoning_an_open_cycle_releases_the_paint, and the
    updated a_facet_push_does_not_answer_the_pull.
  • an_eager_push_connection_opens_no_cycle: a disabled scheduler at the marker's minor stays closed over a live
    RowDelta and Scrolled, so an SSH-carried window cannot freeze.
  • a_rebuilt_scheduler_carries_no_cycle_across: a retarget's fresh scheduler starts closed.

felis-client::event_handler::paint_gate_tests:

  • no_redraw_source_paints_a_cycle_before_its_marker (the adversarial case: open a cycle, inject facet/blink redraws,
    assert nothing paints until CycleEnd) and a_rehydrating_shadow_paints_nothing_even_with_no_cycle_open.

felis-daemon::serve::session_task:

  • a_pull_cycle_ends_with_one_marker_after_its_last_frame, an_eager_push_peer_gets_no_cycle_marker,
    a_subscriber_below_the_marker_minor_is_sent_none. pull_with_nothing_dirty_emits_no_redundant_frame stays and now
    also pins that an empty cycle ships no marker.
  • the_marker_follows_the_last_frame_of_a_split_cycle: a budget-splitting batch yields several RowDelta frames and
    exactly one CycleEnd, last. It drives push_row_deltas and push_cycle_end, the two halves ship_sub_at
    composes, rather than a real grid: 2048 x 2048 is the largest admitted geometry and its rows encode to a few MiB
    against the 32 MiB ceiling, so no session produces a splitting cycle end to end.

the_registry_drain_does_not_manufacture_a_frame_on_a_quiet_cycle and a_row_batch_past_the_budget_splits_across_frames
are unchanged: both sit at the compose_diffs / push_row_deltas layer, below where the marker is appended.

Fuzz seed fuzz/seeds/ipc_body/proto-grid-cycle-end.bin.

Gates run

just proto (regenerated codegen committed), just proto-compat (wire-compatible), just prose-check,
just docs-links, cargo clippy --workspace --all-targets (zero warnings), cargo nextest run --all-features over
every crate, all green.

Not done

just fuzz-smoke was skipped: the root filesystem is at 95%, and the sanitizer build is the most expensive artifact
available to add. The seed is committed and its bytes match the generated tag = 21 framing.

Closes #307 ## Stacking This stacks on PR #319 (`connection-mode-minor`, issue #312) and **must merge after it**: it branches from `connection-mode-minor` and takes `PROTOCOL_MINOR` 12, the next contiguous minor after #319's 11. The ledger sync tests require contiguity. If #319 changes during review this branch needs a rebase. (#313 later folds every minor to 0.) ## What lands `GridMsg::CycleEnd` (minor 12, `modes=window,ops`, `phases=attached`, proto tag 21) makes the end of one pull-paced compose cycle explicit. **Daemon** — `ship_sub_at` (`crates/felis-daemon/src/serve/session_task.rs`) appends the marker through `push_cycle_end`, after the `out.is_empty()` check and after everything `compose_diffs` produced, which includes the registry drip tail and every split `RowDelta` frame. Emitted only when `sub.pull_pacing` and only when the subscriber's effective minor admits the arm; an older subscriber has it **suppressed**, not refused, on the `downgrade_push` precedent. An empty cycle keeps today's behavior exactly: no message, `pull_pending` stays armed, the client's pull stays outstanding. **Client** — `PullScheduler` (`crates/felis-client-core/src/pull.rs`) tracks marker-delimited cycles only when `marks_cycles()` holds: pull pacing enabled **and** the effective minor defines the arm. Both conditions are needed, because the daemon sends the marker on exactly that intersection — an eager-push (SSH-carried) window builds the scheduler disabled and receives no marker however new the daemon is. Otherwise it keeps today's `answers_pull` release. When cycles are marked, the first pull-answering message opens `cycle_open`; `CycleEnd` closes it and clears `outstanding`. `App`'s `RedrawRequested` arm now goes through `may_paint_grid(has_surface, rehydrating, cycle_open)`, so the grid paint is suppressed for the whole cycle whatever asked for the redraw: a pending redraw, a cursor blink, an animation tick, a facet push. The marker's own frame requests one redraw through the existing `redraw.request()`. The effective minor is threaded to both scheduler construction sites (`main.rs` and `install_new_session`), so a retarget onto a differently-vintaged or differently-paced daemon re-gates, and the rebuild is what drops a cycle left open on the connection the window came from. **Rehydrate** keeps `RehydrateEnd`: the burst is eager, answers no pull, and no marker follows it, so `RehydrateBegin` / `RehydrateEnd` explicitly close any open cycle rather than opening one that nothing would terminate. A connection lost mid-cycle calls `abandon_cycle()` in `on_daemon_closed`, or the window would stop painting the chrome that says it is disconnected. ## Tests `felis-client-core::pull`: - `only_the_marker_frees_the_next_pull`, `multiple_frames_one_iteration_yield_one_pull` (rewritten for the marker), `a_cycle_stays_open_across_every_frame_it_carries`, `a_cycle_without_rows_is_still_bounded_by_the_marker` (the Ops-shaped cycle), `the_rehydrate_burst_is_closed_by_its_own_boundary`, `a_peer_below_the_marker_minor_releases_on_the_first_frame`, `abandoning_an_open_cycle_releases_the_paint`, and the updated `a_facet_push_does_not_answer_the_pull`. - `an_eager_push_connection_opens_no_cycle`: a disabled scheduler at the marker's minor stays closed over a live `RowDelta` and `Scrolled`, so an SSH-carried window cannot freeze. - `a_rebuilt_scheduler_carries_no_cycle_across`: a retarget's fresh scheduler starts closed. `felis-client::event_handler::paint_gate_tests`: - `no_redraw_source_paints_a_cycle_before_its_marker` (the adversarial case: open a cycle, inject facet/blink redraws, assert nothing paints until `CycleEnd`) and `a_rehydrating_shadow_paints_nothing_even_with_no_cycle_open`. `felis-daemon::serve::session_task`: - `a_pull_cycle_ends_with_one_marker_after_its_last_frame`, `an_eager_push_peer_gets_no_cycle_marker`, `a_subscriber_below_the_marker_minor_is_sent_none`. `pull_with_nothing_dirty_emits_no_redundant_frame` stays and now also pins that an empty cycle ships no marker. - `the_marker_follows_the_last_frame_of_a_split_cycle`: a budget-splitting batch yields several `RowDelta` frames and exactly one `CycleEnd`, last. It drives `push_row_deltas` and `push_cycle_end`, the two halves `ship_sub_at` composes, rather than a real grid: 2048 x 2048 is the largest admitted geometry and its rows encode to a few MiB against the 32 MiB ceiling, so no session produces a splitting cycle end to end. `the_registry_drain_does_not_manufacture_a_frame_on_a_quiet_cycle` and `a_row_batch_past_the_budget_splits_across_frames` are unchanged: both sit at the `compose_diffs` / `push_row_deltas` layer, below where the marker is appended. Fuzz seed `fuzz/seeds/ipc_body/proto-grid-cycle-end.bin`. ## Gates run `just proto` (regenerated codegen committed), `just proto-compat` (wire-compatible), `just prose-check`, `just docs-links`, `cargo clippy --workspace --all-targets` (zero warnings), `cargo nextest run --all-features` over every crate, all green. ## Not done `just fuzz-smoke` was skipped: the root filesystem is at 95%, and the sanitizer build is the most expensive artifact available to add. The seed is committed and its bytes match the generated `tag = 21` framing.
client: hold the cycle gate until the reconnect rehydrates
Some checks failed
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) Failing after 2m50s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 12s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m21s
pr / nix flake check (pull_request) Successful in 41s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m0s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m37s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 7m25s
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / frontend smoke (Windows) (pull_request) Successful in 1m59s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
755cc7294d
A cycle the transport cuts short has already reached the shadow: its
`Scrolled` shifted the rows and the `RowDelta`s behind it never arrived.
Clearing the gate when the connection died therefore painted exactly the
screen the marker exists to prevent, and rebuilding the scheduler on the
replacement connection cleared it a second time, before `RehydrateBegin`
restated anything.

The gate now outlives both. It is handed to the landing's scheduler and
lifted by the rehydrate burst, which is the first frame that redescribes
the whole screen. The burst lifts it whatever the landing is: an
eager-push or pre-minor-12 peer sends no marker, and nothing else would
ever clear a carried gate.

Nothing is owed to the disconnected window in the meantime. Its title
carries the state and rides `set_title` rather than a paint, so gating
the grid costs the user no feedback. Buffering the cycle's mutations and
discarding them on disconnect was the alternative: it would let the
window keep painting the pre-cycle screen, at the price of a second copy
of every diff on the hot path, and the burst that follows overwrites
that screen regardless.

Assisted-by: Claude Code
`GridMsg::CycleEnd` keeps the next free minor on top of the two that
landed: this branch carried its own copy of #319, which `main` merged as
a squash, so every file that PR touched conflicted against the reviewed
version of itself.

Those files take `main` whole, since this branch never changed them
beyond the copy it was cut from: their review rounds happened after the
copy, and a hand-merge would have silently reverted them. The auto-merge
duplicated `a_hello_naming_an_unknown_mode_is_refused_by_name` and split
a doc comment in the daemon's serve tests for the same reason.

What belongs to this branch stays: `PROTOCOL_MINOR` 12 over the merged
11, ledger row 12 after the reviewed row 11, and the `CycleEnd` arm. The
generated codec is regenerated from the merged schema rather than
resolved by hand.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into grid-cycle-end
Some checks failed
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) Failing after 2m22s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 46s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m9s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 51s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m16s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m14s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 5m39s
windows / frontend smoke (Windows) (pull_request) Successful in 2m24s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
9b6b51b27c
natsukium deleted branch grid-cycle-end 2026-09-21 17:14:10 +09:00
Commenting is not possible because the repository is archived.
No description provided.