protocol: terminate a pull-paced grid cycle on the wire #321
No reviewers
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
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis!321
Loading…
Reference in a new issue
No description provided.
Delete branch "grid-cycle-end"
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?
Closes #307
Stacking
This stacks on PR #319 (
connection-mode-minor, issue #312) and must merge after it: it branches fromconnection-mode-minorand takesPROTOCOL_MINOR12, the next contiguous minor after #319's 11. The ledger sync testsrequire 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-pacedcompose cycle explicit.
Daemon —
ship_sub_at(crates/felis-daemon/src/serve/session_task.rs) appends the marker after theout.is_empty()check and after everythingcompose_diffsproduced, which includes the registry drip tail and everysplit
RowDeltaframe. Emitted only whensub.pull_pacingand only when the subscriber's effective minor admits thearm; an older subscriber has it suppressed, not refused, on the
downgrade_pushprecedent. An empty cycle keepstoday's behavior exactly: no message,
pull_pendingstays 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'sanswers_pullrelease. At or above it, the first pull-answering message openscycle_open;CycleEndcloses it andclears
outstanding.App'sRedrawRequestedarm now goes throughmay_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, acursor 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.rsandinstall_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, soRehydrateBegin/RehydrateEndexplicitly close any open cycle rather than opening one that nothing would terminate. A connection lostmid-cycle calls
abandon_cycle()inon_daemon_closed, or the window would stop painting the chrome that says it isdisconnected.
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 updateda_facet_push_does_not_answer_the_pull.felis-client::event_handler::paint_gate_tests:no_redraw_source_paints_a_cycle_before_its_marker(the adversarialcase: open a cycle, then inject facet/blink redraws, assert nothing paints until
CycleEnd) anda_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_framestays and now also pins that an empty cycle ships no marker.the_registry_drain_does_not_manufacture_a_frame_on_a_quiet_cycleanda_row_batch_past_the_budget_splits_across_framesare unchanged: both sit at thecompose_diffs/push_row_deltaslayer, below where the marker is appended.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-featuresoverevery crate (2816 tests, all green).
Notes
push_cycle_end, driven fromship_sub_atafter everythingcompose_diffsproduced.the_marker_follows_the_last_frame_of_a_split_cycledrivespush_row_deltasover a batch that splits pastOUTBOUND_BODY_BUDGET_BYTESand thenpush_cycle_end, asserting at least twoRowDeltaframes and exactly one finalCycleEnd; no admitted geometry can split end to end at the 32 MiB budget, so the test covers the seamship_sub_atcomposes rather than a live session.PullSchedulertracks 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.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-smokewas not run locally (disk); the seedfuzz/seeds/ipc_body/proto-grid-cycle-end.binis 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 fromconnection-mode-minorand takesPROTOCOL_MINOR12, the next contiguous minor after #319's 11. The ledger sync testsrequire 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-pacedcompose cycle explicit.
Daemon —
ship_sub_at(crates/felis-daemon/src/serve/session_task.rs) appends the marker throughpush_cycle_end, after theout.is_empty()check and after everythingcompose_diffsproduced, which includes theregistry drip tail and every split
RowDeltaframe. Emitted only whensub.pull_pacingand only when the subscriber'seffective minor admits the arm; an older subscriber has it suppressed, not refused, on the
downgrade_pushprecedent. An empty cycle keeps today's behavior exactly: no message,
pull_pendingstays armed, the client's pullstays outstanding.
Client —
PullScheduler(crates/felis-client-core/src/pull.rs) tracks marker-delimited cycles only whenmarks_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_pullrelease.When cycles are marked, the first pull-answering message opens
cycle_open;CycleEndcloses it and clearsoutstanding.App'sRedrawRequestedarm now goes throughmay_paint_grid(has_surface, rehydrating, cycle_open), so the grid paintis 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 isthreaded to both scheduler construction sites (
main.rsandinstall_new_session), so a retarget onto adifferently-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, soRehydrateBegin/RehydrateEndexplicitly close any open cycle rather than opening one that nothing would terminate. A connection lostmid-cycle calls
abandon_cycle()inon_daemon_closed, or the window would stop painting the chrome that says it isdisconnected.
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(theOps-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 theupdated
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 liveRowDeltaandScrolled, 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) anda_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_framestays and nowalso pins that an empty cycle ships no marker.
the_marker_follows_the_last_frame_of_a_split_cycle: a budget-splitting batch yields severalRowDeltaframes andexactly one
CycleEnd, last. It drivespush_row_deltasandpush_cycle_end, the two halvesship_sub_atcomposes, 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_cycleanda_row_batch_past_the_budget_splits_across_framesare unchanged: both sit at the
compose_diffs/push_row_deltaslayer, 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-featuresoverevery crate, all green.
Not done
just fuzz-smokewas skipped: the root filesystem is at 95%, and the sanitizer build is the most expensive artifactavailable to add. The seed is committed and its bytes match the generated
tag = 21framing.