[v0.1/IPC Review] Add an explicit terminator to pull-paced grid cycles #307

Closed
opened 2026-09-21 12:09:06 +09:00 by natsukium · 1 comment
Owner

Parent: #12

Problem

One pull-paced daemon compose cycle has no explicit end on the wire.

compose_diffs can emit several ordered GridMsgs for one cycle: mode flags, prompt marks, viewport state, a scroll directive, referenced registry entries, one or more RowDelta frames, cursor state, and registry-tail entries. Large row batches are deliberately split across consecutive RowDelta frames.

The daemon clears pull_pending before those events enter the subscriber outbox. The client clears its outstanding pull when it receives the first message matching pull::answers_pull, which includes almost every compose-cycle variant. Every received message may request a redraw. Winit may therefore redraw and issue the next pull after the first message while the rest of the same cycle is still in transit.

The ordered stream still converges on the final screen, but the current wire cannot guarantee either of the stronger properties its pacing design relies on:

  • one outstanding pull denotes one complete coalesced diff cycle;
  • the client does not paint an intermediate state such as Scrolled without the rows and cursor from that cycle.

Proposed contract

Add an explicit end marker for every non-empty pull-composed diff cycle, for example GridMsg::CycleEnd.

  • The daemon emits it after every event belonging to that cycle, including all split RowDelta frames.
  • The client clears the outstanding pull only at the marker.
  • The client may apply messages incrementally, but it defers the grid redraw until the marker.
  • Facet pushes outside the pull gate retain their immediate behavior and do not answer a pull.
  • Rehydrate either keeps its existing RehydrateEnd boundary or is reconciled explicitly with the new cycle rule.

A sequence number is not required for this proposal: the transport is ordered and the client permits only one outstanding pull. If the implementation needs a sequence for another invariant, that should be justified separately rather than added by default.

Acceptance criteria

  • Every non-empty pull-gated compose cycle has exactly one terminal marker after all of its frames.
  • No earlier GridMsg makes a replacement pull due.
  • The GUI cannot paint a partial scroll/row/cursor cycle, even when receive events and redraw events interleave adversarially.
  • Split RowDelta cycles are covered.
  • Registry-before-row ordering remains intact within a cycle.
  • Facet pushes, eager-push peers, rehydrate, and Ops subscribers have explicit, tested behavior.
  • The pacing explanation and IPC reference define the same unit called a cycle.
  • Proto metadata, minor or pre-release breaking metadata, tests, and CHANGELOG.md agree.

Principle check

  1. Add only what earns its place — Pass. The marker makes an existing pacing contract observable; it introduces no user feature or evaluator.
  2. Render everything, fast — Pass. It prevents partial paints while preserving row batching and one-pull-per-vsync pacing.
  3. The daemon owns state, the client owns pixels — Pass. The daemon defines the authoritative update boundary and the client chooses when to paint it.
  4. Explicit over heuristic — Pass. The client no longer infers cycle completion from whichever message happens to arrive first.

Evidence

  • crates/felis-daemon/src/serve/streaming.rs:465 (push_row_deltas)
  • crates/felis-daemon/src/serve/streaming.rs:585 (compose_diffs)
  • crates/felis-daemon/src/serve/session_task.rs:1739 (ship_sub_at)
  • crates/felis-daemon/src/serve/session_task.rs:1773 (pull_pending = false)
  • crates/felis-client-core/src/pull.rs:14 (answers_pull)
  • crates/felis-client/src/event_handler.rs (pull.on_frame and redraw handling)
  • docs/explanation/rendering/pipeline.md "Demand-driven emission"
  • docs/reference/ipc.md NextGridFrame and RowDelta
Parent: #12 ## Problem One pull-paced daemon compose cycle has no explicit end on the wire. `compose_diffs` can emit several ordered `GridMsg`s for one cycle: mode flags, prompt marks, viewport state, a scroll directive, referenced registry entries, one or more `RowDelta` frames, cursor state, and registry-tail entries. Large row batches are deliberately split across consecutive `RowDelta` frames. The daemon clears `pull_pending` before those events enter the subscriber outbox. The client clears its outstanding pull when it receives the first message matching `pull::answers_pull`, which includes almost every compose-cycle variant. Every received message may request a redraw. Winit may therefore redraw and issue the next pull after the first message while the rest of the same cycle is still in transit. The ordered stream still converges on the final screen, but the current wire cannot guarantee either of the stronger properties its pacing design relies on: - one outstanding pull denotes one complete coalesced diff cycle; - the client does not paint an intermediate state such as `Scrolled` without the rows and cursor from that cycle. ## Proposed contract Add an explicit end marker for every non-empty pull-composed diff cycle, for example `GridMsg::CycleEnd`. - The daemon emits it after every event belonging to that cycle, including all split `RowDelta` frames. - The client clears the outstanding pull only at the marker. - The client may apply messages incrementally, but it defers the grid redraw until the marker. - Facet pushes outside the pull gate retain their immediate behavior and do not answer a pull. - Rehydrate either keeps its existing `RehydrateEnd` boundary or is reconciled explicitly with the new cycle rule. A sequence number is not required for this proposal: the transport is ordered and the client permits only one outstanding pull. If the implementation needs a sequence for another invariant, that should be justified separately rather than added by default. ## Acceptance criteria - [ ] Every non-empty pull-gated compose cycle has exactly one terminal marker after all of its frames. - [ ] No earlier `GridMsg` makes a replacement pull due. - [ ] The GUI cannot paint a partial scroll/row/cursor cycle, even when receive events and redraw events interleave adversarially. - [ ] Split `RowDelta` cycles are covered. - [ ] Registry-before-row ordering remains intact within a cycle. - [ ] Facet pushes, eager-push peers, rehydrate, and `Ops` subscribers have explicit, tested behavior. - [ ] The pacing explanation and IPC reference define the same unit called a cycle. - [ ] Proto metadata, minor or pre-release breaking metadata, tests, and `CHANGELOG.md` agree. ## Principle check 1. **Add only what earns its place — Pass.** The marker makes an existing pacing contract observable; it introduces no user feature or evaluator. 2. **Render everything, fast — Pass.** It prevents partial paints while preserving row batching and one-pull-per-vsync pacing. 3. **The daemon owns state, the client owns pixels — Pass.** The daemon defines the authoritative update boundary and the client chooses when to paint it. 4. **Explicit over heuristic — Pass.** The client no longer infers cycle completion from whichever message happens to arrive first. ## Evidence - `crates/felis-daemon/src/serve/streaming.rs:465` (`push_row_deltas`) - `crates/felis-daemon/src/serve/streaming.rs:585` (`compose_diffs`) - `crates/felis-daemon/src/serve/session_task.rs:1739` (`ship_sub_at`) - `crates/felis-daemon/src/serve/session_task.rs:1773` (`pull_pending = false`) - `crates/felis-client-core/src/pull.rs:14` (`answers_pull`) - `crates/felis-client/src/event_handler.rs` (`pull.on_frame` and redraw handling) - `docs/explanation/rendering/pipeline.md` "Demand-driven emission" - `docs/reference/ipc.md` `NextGridFrame` and `RowDelta`
Author
Owner

Direction agreed 2026-09-21 after three sol design-review rounds (final verdict PASS). Full plan and batch order: see the comment on #12.

  • #307 — add GridMsg::CycleEnd (modes=window,ops, phases=attached).
    Daemon emits it in ship_sub_at after the out.is_empty() check and after
    the registry drip tail, so an empty cycle keeps today's behavior: no message,
    pull_pending stays armed, the client's pull stays outstanding (pinned by
    pull_with_nothing_dirty_emits_no_redundant_frame). Client: the first
    pull-answering message opens a cycle-in-progress state; every grid paint is
    suppressed while it is open, whatever triggered the redraw (pending redraw,
    cursor blink, animation, facet push); CycleEnd closes it, clears the
    outstanding pull, and requests one redraw. Facet pushes and ImageMsg
    outside the gate keep immediate behavior but cannot paint the grid mid-cycle.
    Rehydrate keeps RehydrateEnd (it is eager, never answers a pull, and the
    scheduler is rebuilt on attach/retarget); docs state the two boundaries.
    Exhaustive matches in shadow.rs::apply and real_app_harness.rs gain a
    no-op arm. A cycle-open-then-adversarial-redraw test is required.
Direction agreed 2026-09-21 after three `sol` design-review rounds (final verdict PASS). Full plan and batch order: see the comment on #12. - **#307** — add `GridMsg::CycleEnd` (`modes=window,ops`, `phases=attached`). Daemon emits it in `ship_sub_at` after the `out.is_empty()` check and after the registry drip tail, so an empty cycle keeps today's behavior: no message, `pull_pending` stays armed, the client's pull stays outstanding (pinned by `pull_with_nothing_dirty_emits_no_redundant_frame`). Client: the first pull-answering message opens a cycle-in-progress state; every grid paint is suppressed while it is open, whatever triggered the redraw (pending redraw, cursor blink, animation, facet push); `CycleEnd` closes it, clears the outstanding pull, and requests one redraw. Facet pushes and `ImageMsg` outside the gate keep immediate behavior but cannot paint the grid mid-cycle. Rehydrate keeps `RehydrateEnd` (it is eager, never answers a pull, and the scheduler is rebuilt on attach/retarget); docs state the two boundaries. Exhaustive matches in `shadow.rs::apply` and `real_app_harness.rs` gain a no-op arm. A cycle-open-then-adversarial-redraw test is required.
Commenting is not possible because the repository is archived.
No description provided.