shadow: refuse daemon-impossible grid values instead of normalizing them #318
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!318
Loading…
Reference in a new issue
No description provided.
Delete branch "shadow-admission"
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?
One policy at the wire-to-shadow boundary: a
GridMsgvalue the daemon could not have produced isShadowError, andthe client closes only that attachment. Reconnect and rehydrate recover it; the daemon session and the other
subscribers are untouched. No clamping, no silent drop, no degraded rendering.
The admission rules
RowDeltarow indexMAX_GRID_ROWS(the protocol-wide bound;MAX_SHADOW_ROWSis gone)RowDeltabody width (the row codec'scols)MAX_GRID_COLS, checked beforedecode_rowreserves its cellsCluster/HyperlinkhandleScrolled.region_top/region_bottomregion_top <= region_bottom, both inside the announced gridScrolled.n_rowsCursorState.row/colViewportState.lines_from_bottommaxCluster.id/Hyperlink.idCluster.text,Hyperlink.anchor/uriClusterText::CAP/LinkText::CAPTwo judgement calls the issues left open:
its geometry before composing anything at a new one, while a client resizes its shadow optimistically ahead of the
daemon honoring the resize; measuring against the shadow would kill an attachment on every window-resize drag.
stands either way, and
ModeFlagscan reveal it without a new coordinate.The resize race keeps its tolerance, which is why the row index and the body width are the only grid values bounded
protocol-wide: a row lower or wider than the mirror grows it, a narrower one pads it.
MAX_CELLS_PER_ROWnarrows from theu16field width toMAX_GRID_COLS, so the over-wide row is refused indecode_rowbefore theVec<Cell>reserve rather than truncated after it;MAX_ATTR_RUNSandMAX_SIZED_CELLSfollow.
ScreenBuffer::apply_scroll_directiveandset_cursor_stateno longer clamp, andinstall_cluster/install_hyperlinkreport a refusal instead of swallowing it.Tests
felis-client-coreshadow tests:the_last_admitted_row_is_accepted_and_one_past_it_is_refused,a_row_wider_than_the_admitted_grid_is_refused_before_it_allocates,a_sized_cell_outside_the_decoded_row_is_refused,scroll_regions_and_counts_outside_the_grid_are_refused,cursor_coordinates_outside_the_grid_are_refused_visible_or_not,a_resize_race_still_applies_in_either_direction,a_viewport_past_its_own_maximum_is_refused,a_zero_registry_id_is_refused,an_over_length_registry_entry_is_refused,a_cluster_id_at_the_table_cap_installs_and_one_past_it_is_refused,a_flood_of_hyperlinks_from_a_peer_fails_at_the_table_budget,a_row_naming_an_unarrived_registry_entry_ends_the_attachment,a_row_applies_when_its_entries_arrived_first_despite_unrelated_holes.felis-grid:a_sized_cell_past_the_decoded_row_is_rejected, and the scroll-directive property now asserts that adirective outside the grid is refused rather than normalized.
felis-daemon:an_attachment_that_closes_leaves_the_session_and_its_peers_runningpins that one refusal costs theattachment alone.
proptest_cluster_convergencestates the policy it used to contradict: a permuted entry order still converges, and arow before its entries is refused.
proptest_row_delta_batchandrehydration_snapshotpass, the former now shippingthe registry entries its rows name first, as the daemon does.
Docs
docs/reference/ipc.mdgains a "Grid admission" section under Corruption with one rule per value, and its semanticlimits, registry delivery, and
GridMsgentries agree with it. The row-codec reference carries the narrowed counts andthe side-band column rule.
docs/explanation/architecture/ipc.mdrecords why normalizing was rejected;docs/explanation/data-model/grid-and-cells.mdno longer promises a degraded cell.docs/reference/testing.mdandCHANGELOG.mdfollow.just checkgates run:cargo nextest run --workspace --all-features(3343 passed),cargo clippy --workspace --all-targets(0 warnings),just prose-check,just docs-links,treefmt.Closes #304
Closes #308
Closes #315
Refusing daemon-impossible grid values exposed two ways the daemon and the shadow could produce them honestly. A queued `GridMsg::Scrolled` describes the geometry it was recorded under, and nothing retired it: a pull-paced subscriber could receive `Size { rows: 10 }` followed by a directive naming row 23, which the admission rule refuses and an honest attachment pays for with a reconnect. An in-bounds stale directive is worse, shifting rows the reflow already moved with nothing to report. Every `PtyEffect::Scrolled` now carries the grid generation it was produced under, which the session task compares against the grid it is about to compose from; a stale one downgrades to a row replay. Stamping at production rather than at drain is what covers the path where a resize runs before the effects it preceded are drained. The resize itself clears what each subscriber has queued, the reflow having marked every row dirty in their place. The cursor the daemon states outlives the shadow's own geometry for the same reason. A client shrinks its shadow optimistically, which clamps the grid's cursor; when another subscriber owns the PTY size the daemon answers with the authoritative `Size` and a full row replay but no `CursorState`, since its cursor never moved, so the clamped position would stand for good. The shadow keeps the stated cursor beside the grid's and re-places it after every geometry change, which also lets a coordinate that arrives while the local shadow is too small keep its visibility, style, and blink instead of being dropped. Assisted-by: Claude Code