fix(protocol): a refused frame is put back by the fold that made it (improvise-nxah)
`Client::key` folds a locally-resolved batch before sending it, so the page redraws on the keystroke. A refusal left those folds in place: measured against a real `Host`, a refused commit left the client one row down with its edit buffer cleared while the host's mirror of that same client was still at the origin holding the user's typing. What rolls back is `ViewState` — the thing the fold produces — never a cache. A `Command::Model` is a no-op in `reduce_view`, so the optimistic fold never touched the caches, and the bug report's own evidence (`page_coords`) cannot diverge this way: it moves only under `Projection::Grid`. What diverges is the mode, cursor, status and buffers that ride along with the model command. The rollback needed a positive acknowledgement first, which the protocol did not have: with only `Rejected` on the wire, a client holding two frames in flight cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that — emitted once per accepted mutating frame, last, after everything the frame produced — the same shape as the basis-publication gap ADR 001 originally left. Two existing facts make the rule usable rather than a convention: only `Command::Model` can be refused, so a frame carrying none is answered by neither and needs no entry; and `Rejection::refused_an_edit` already separates the two `Save` refusals, so a failed write cannot consume the answer owed to an edit. A refusal drops one frame and replays the rest, because a single saved snapshot is unsound under pipelining — it would discard a later frame the host applied. This does not do improvise-3al1's force, and it does not contradict it: the 2026-09-21 wave recorded the two tickets as contradictory on the ground that a rollback would wipe the staged typing. It does the opposite — the typing frames carry no model command, are never refused, and have already settled, so rolling back the commit restores the buffer to the typing. Asserted, not inferred. Reasoning: context/code/clients.md, context/code/protocol.md §4. Evidence: context/test-evidence/clients.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
@@ -1237,6 +1237,55 @@ a fact about a browser window, which is why no `Command` carries it and why it i
|
||||
not on `ViewState`. `(0, 0)` means this client has not announced one yet, and a
|
||||
client in that state resolves no cursor key — it has been sent no grid either.
|
||||
|
||||
### The optimistic fold is put back by *the fold*, not by a saved cache (improvise-nxah)
|
||||
|
||||
`Client::key` folds a locally-resolved batch **before** sending it, so the page
|
||||
redraws on the keystroke rather than on the round trip. If the host then refuses
|
||||
the frame — ADR 001 clause 4 means it applied *none* of it, and restores every
|
||||
mirror to the pre-frame state — the client has moved and the host has not.
|
||||
Measured against a real `Host`: after a refused commit the client sat one row
|
||||
down in `Normal`-ish re-entry with its edit buffer already cleared for the next
|
||||
cell, while the host's mirror of that same client was still at the origin
|
||||
holding the user's typing.
|
||||
|
||||
Three things decide the shape, and the first is the one that is easy to get
|
||||
wrong. **What rolls back is `ViewState`, the thing the fold produces — never a
|
||||
cache.** Restoring a cache would be a second answer to "what does this client
|
||||
hold" beside the fold's, which is the defect design-principles §5 names and
|
||||
`improvise-edp` is the worked example of. The caches need nothing anyway: a
|
||||
`Command::Model` is a **no-op in `reduce_view`**, so the optimistic fold never
|
||||
touched them, and everything a *successful* frame does to them arrives as
|
||||
projections. Note the corollary that makes the bug report's own evidence wrong:
|
||||
`page_coords` cannot diverge this way, because it moves only under
|
||||
`Projection::Grid`. What diverges is the mode, the cursor, the status and the
|
||||
buffers — the view commands that ride along **with** the model command.
|
||||
|
||||
**The whole slice goes back, and that is what serves improvise-3al1 rather than
|
||||
fighting it.** The 2026-09-21 wave recorded these two tickets as in direct
|
||||
contradiction, on the ground that restoring a snapshot would wipe
|
||||
`ViewState::buffers` — the user's typing that 3al1 requires stay staged. That is
|
||||
measurably not so, and the reason is the first paragraph of §4 in
|
||||
`context/code/protocol.md`: **only a frame carrying a `Command::Model` can be
|
||||
refused.** The typing frames are `SetBuffer`s, carry no model command, are never
|
||||
refused, and have already settled. Rolling back the *commit* therefore restores
|
||||
the buffer to its pre-commit value — which is the typing, intact. The test
|
||||
asserts that explicitly rather than leaving it as an inference.
|
||||
|
||||
**A refusal drops one frame and replays the rest.** `ClientState` keeps a
|
||||
`settled` slice — everything the host has answered — and an `in_flight` queue of
|
||||
the frames sent since, each marked `awaited` exactly when the host will answer
|
||||
it. A refusal pops the head and rebuilds `view` from `settled` plus what remains.
|
||||
Restoring a single saved snapshot instead is **unsound under pipelining**: two
|
||||
mutating frames may be outstanding over a socket, and a snapshot taken before
|
||||
the first discards the second, which the host *did* apply. A frame the host will
|
||||
not answer settles as soon as nothing older is waiting — only from the front,
|
||||
because settling it out of turn would lose the order.
|
||||
|
||||
What this does **not** do is force. A rejected batch now keeps its values and
|
||||
leaves the user where they were; re-composing the same intent against a current
|
||||
basis at one keystroke is still `improvise-3al1`, and it is unblocked rather
|
||||
than done.
|
||||
|
||||
### `socket.rs` — the frames, the wording, and the one composition
|
||||
|
||||
**Where the frames live: not here.** `ClientFrame`/`ServerFrame` are
|
||||
|
||||
@@ -1227,6 +1227,35 @@ already holds what is current through the projections it has been folding all
|
||||
along. That is what makes rejection cheap enough to be a *normal outcome*
|
||||
rather than an error.
|
||||
|
||||
### `Accepted`, and why a rejection alone is not a protocol (improvise-nxah)
|
||||
|
||||
`ServerFrame::Rejected` told a client that a frame did nothing. Nothing told it
|
||||
that a frame *did* something. That asymmetry is not cosmetic, because the
|
||||
browser folds a frame's view commands **optimistically** before sending it: a
|
||||
refusal must put them back, and to know *which* frame's folds to put back the
|
||||
client has to be able to retire the ones that landed. With only a refusal on the
|
||||
wire, a client holding two frames in flight cannot tell which of them a
|
||||
`Rejected` answers — so the rollback either addresses the wrong frame or never
|
||||
happens. This is the same shape as the publication gap ADR 001 originally left:
|
||||
it said a batch carries a basis and never said how a client learns one, and
|
||||
`Projection::Basis` had to close it. Here it said a batch may be refused and
|
||||
never said how a client learns it was not.
|
||||
|
||||
So a **mutating** frame is answered exactly once, `Accepted` or `Rejected`, and
|
||||
the two facts that make that a usable rule are already load-bearing elsewhere.
|
||||
*Only* `Command::Model` can be refused — `Checked::against` says so exhaustively
|
||||
and `Host::commands` only enters `mutating_frame` when one is present — so a
|
||||
frame carrying no `Command::Model` is answered by neither, and a client can tell
|
||||
by inspecting the frame it is about to send rather than by remembering a rule.
|
||||
And `Rejection::refused_an_edit` already separates the two `Save` refusals from
|
||||
the batch refusals, so a failed write cannot consume the answer owed to an edit.
|
||||
|
||||
`Accepted` carries **no state**, and it goes **last** in the frame's answer, for
|
||||
the reason a published basis goes last (§5): everything the frame moved has
|
||||
already arrived as projections, so a client discarding its undo knows exactly
|
||||
what it is discarding. A client that folded the acknowledgement first would be
|
||||
retiring a frame whose effects it had not yet been shown.
|
||||
|
||||
---
|
||||
|
||||
## 5. `basis.rs` — what a batch was composed against
|
||||
|
||||
@@ -3812,3 +3812,80 @@ a walk that silently visited nothing would otherwise pass forever, which is the
|
||||
vacuous-assertion shape design-principles §7 names. The same test is what makes
|
||||
the refusal arm safe to leave unreachable instead of inventing a second frame
|
||||
for it.
|
||||
|
||||
---
|
||||
|
||||
## The optimistic fold's rollback (improvise-nxah)
|
||||
|
||||
The design argument is `context/code/clients.md`, "The optimistic fold is put
|
||||
back by *the fold*"; the wire half is `context/code/protocol.md` §4.
|
||||
|
||||
### a_refused_commit_leaves_the_client_where_the_host_says_it_is
|
||||
|
||||
**The bug.** `Client::key` folded a locally-resolved batch before sending it and
|
||||
`fold_frame` answered `ServerFrame::Rejected` with a sentence and nothing else,
|
||||
so a refused commit left the client's view slice ahead of the host's. Measured
|
||||
at `22ab58f` against a real `Host`: the client sat at cursor `(1, 0)` with
|
||||
`buffers {"edit": ""}` while the host's mirror of that same client was at the
|
||||
origin with `{"edit": "12007500"}` — the user's typing.
|
||||
|
||||
**Why it is not vacuous.** The comparison is against
|
||||
`Host::sessions().get(id).view()`, the host's **own** mirror, which ADR 001
|
||||
clause 4 restores to the pre-frame state on a refusal — so the test asserts the
|
||||
client landed where the authority says it is, not merely that two derivations
|
||||
agree (improvise-vw47). It is total over `ViewState` rather than a hand-kept
|
||||
list of fields: everything but `status_msg` is compared, and that one is carved
|
||||
out with its own assertion because the rejection sentence is this client's own
|
||||
notification and the mirror has no way to hold one. The fixture first asserts
|
||||
the two ends **agree before** the refusal, so a test that could never have
|
||||
disagreed is not passing by accident.
|
||||
|
||||
**The fault injected.** Dropping the `self.replay()` call from
|
||||
`ClientState::refused` — keeping the queue bookkeeping and doing no rollback —
|
||||
fails it.
|
||||
|
||||
**What it does not cover.** That the *ordering* of a rollback is right under
|
||||
pipelining; that is the next entry.
|
||||
|
||||
### refusing_one_of_two_frames_in_flight_keeps_the_other
|
||||
|
||||
**What it separates.** A single saved snapshot from a queue. Two mutating frames
|
||||
are folded before either is answered; the host refuses the first and accepts the
|
||||
second. Restoring a snapshot taken before the first would discard the second,
|
||||
which the host *did* apply — so the client would diverge in the other direction.
|
||||
The assertion is not "the state changed": it is equality against a second,
|
||||
independently built client that only ever sent the accepted frame, which is what
|
||||
"right" means here rather than "the two ends match".
|
||||
|
||||
**The fault injected.** Making `ClientState::replay` rebuild from `settled` and
|
||||
replay nothing — the single-snapshot design — fails this test and no other in
|
||||
the file, which is what shows it is aimed at the ordering and not at the
|
||||
rollback.
|
||||
|
||||
### a_frame_the_host_will_not_answer_settles_without_one
|
||||
|
||||
**The invariant the queue rests on.** Only `Command::Model` can be refused, so a
|
||||
frame carrying none is answered by neither `Accepted` nor `Rejected`; holding
|
||||
such a frame in the queue would make the next answer pop the wrong entry. The
|
||||
test sends a `SetStatus`-only frame, asserts nothing is held, then sends a
|
||||
commit and refuses it, asserting the earlier frame survived.
|
||||
|
||||
**The fault injected.** Hard-coding `awaited = true` in
|
||||
`ClientState::fold_outgoing` fails it.
|
||||
|
||||
### a_refused_save_does_not_roll_back_a_frame_it_never_answered
|
||||
|
||||
**The second way the queue could pop the wrong entry.** `ClientFrame::Save` is a
|
||||
request, not a `Command` (improvise-368h), and its two refusals answer no
|
||||
`Commands` frame. `Rejection::refused_an_edit` is the existing predicate that
|
||||
separates them, and `refused` consults it before popping.
|
||||
|
||||
**The fault injected.** Replacing `if rejection.refused_an_edit()` with
|
||||
`if true` fails it: a document with nowhere to write undoes an unrelated edit.
|
||||
|
||||
**And at the transport.** `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`
|
||||
(its own entry is above) was **extended, not added to**: after the edit comes back down the socket the test now
|
||||
asserts the next frame is `ServerFrame::Accepted` — so the acknowledgement is
|
||||
evidenced at the transport, in its position (last, after everything the frame
|
||||
produced), and not only in the host's unit tests. Deleting the `Accepted` push
|
||||
from `Host::mutating_frame` fails it.
|
||||
|
||||
@@ -46,6 +46,12 @@ impl Host {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
/// The mirrors, read-only. Not a second door: `&Sessions` exposes no way to
|
||||
/// fold, so a client's command still arrives through [`Host::handle`].
|
||||
pub fn sessions(&self) -> &Sessions {
|
||||
&self.sessions
|
||||
}
|
||||
|
||||
/// Attach a client that has reported an extent, and hand back its bootstrap.
|
||||
/// Total, and nothing is owed to the other clients.
|
||||
pub fn connect(&mut self, rows: usize, cols: usize) -> (SessionId, Addressed) {
|
||||
@@ -168,6 +174,12 @@ impl Host {
|
||||
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
|
||||
}
|
||||
}
|
||||
// Last, after everything the frame owed: a client folds the answer to
|
||||
// discard its undo, and the projections describe what it is discarding.
|
||||
out.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::Accepted,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -371,9 +383,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A rejection carries no state: nothing was applied, so
|
||||
// there is nothing for a grid to fold.
|
||||
ServerFrame::Rejected(_) => {}
|
||||
// Neither answer carries state: one applied nothing, and what
|
||||
// the other applied arrived as projections before it.
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
}
|
||||
}
|
||||
grid
|
||||
|
||||
@@ -897,6 +897,12 @@ impl Sessions {
|
||||
self.sessions.get_mut(&id)
|
||||
}
|
||||
|
||||
/// One mirror, read-only. `ClientSession::fold` is private, so this hands
|
||||
/// out the server's picture of a client without handing out a way to move it.
|
||||
pub fn get(&self, id: SessionId) -> Option<&ClientSession> {
|
||||
self.sessions.get(&id)
|
||||
}
|
||||
|
||||
/// One command answered and applied. A host answers the whole frame first, so
|
||||
/// this is the tests' door, not a host's (improvise-s17o, `context/code/command.md`).
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -35,6 +35,22 @@ pub struct ClientState {
|
||||
/// Bumped by every fold. A DOM renderer polls this instead of
|
||||
/// re-serializing the whole state to find out whether anything moved.
|
||||
pub revision: u64,
|
||||
/// The view slice as the host has answered it: everything this client sent
|
||||
/// and heard back about, and nothing it is still waiting on.
|
||||
#[serde(skip)]
|
||||
pub settled: ViewState,
|
||||
/// Every frame sent since, oldest first, so a refusal can drop one and
|
||||
/// replay the rest. Why a queue: `context/code/clients.md`.
|
||||
#[serde(skip)]
|
||||
pub in_flight: std::collections::VecDeque<Sent>,
|
||||
}
|
||||
|
||||
/// One frame this client has sent. `awaited` is true exactly when the host
|
||||
/// will answer it, which is exactly when it can be refused — `clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Sent {
|
||||
pub cmds: Vec<Command>,
|
||||
pub awaited: bool,
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
@@ -50,6 +66,10 @@ impl ClientState {
|
||||
self.tiles = TileBarCache::default();
|
||||
self.document = DocumentCache::default();
|
||||
self.basis = BasisCache::default();
|
||||
// A rebase replaces the document, so every frame still in flight names
|
||||
// a picture that has gone; there is nothing left to put back.
|
||||
self.in_flight.clear();
|
||||
self.settled = self.view.clone();
|
||||
for projection in &snapshot.seed {
|
||||
self.apply_projection(projection);
|
||||
}
|
||||
@@ -68,6 +88,70 @@ impl ClientState {
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Fold a batch this client is about to **send**, keeping it so a refusal
|
||||
/// can drop it. The one door for an outgoing frame — `clients.md`.
|
||||
pub fn fold_outgoing(&mut self, cmds: &[Command]) {
|
||||
let awaited = cmds.iter().any(|c| matches!(c, Command::Model { .. }));
|
||||
self.in_flight.push_back(Sent {
|
||||
cmds: cmds.to_vec(),
|
||||
awaited,
|
||||
});
|
||||
self.settle_sent();
|
||||
for cmd in cmds {
|
||||
self.fold(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host applied the oldest awaited frame whole, so it is the settled
|
||||
/// slice's now.
|
||||
pub fn accepted(&mut self) {
|
||||
if let Some(sent) = self.in_flight.pop_front() {
|
||||
for cmd in &sent.cmds {
|
||||
reduce_view(&mut self.settled, cmd);
|
||||
}
|
||||
}
|
||||
self.settle_sent();
|
||||
}
|
||||
|
||||
/// The host applied **none** of the oldest awaited frame (ADR 001 clause
|
||||
/// 4), so neither does this client. Why the whole slice: `clients.md`.
|
||||
pub fn refused(&mut self, rejection: Rejection) {
|
||||
// A refused *save* answers no `Commands` frame, so it must not consume
|
||||
// one's entry — `Rejection::refused_an_edit` is that question.
|
||||
if rejection.refused_an_edit() {
|
||||
self.in_flight.pop_front();
|
||||
self.replay();
|
||||
}
|
||||
self.last_rejection = Some(rejection);
|
||||
self.settle_mode();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// The settled slice plus every frame still in flight, in order. What a
|
||||
/// dropped frame leaves behind is the *rest* of them, not nothing.
|
||||
fn replay(&mut self) {
|
||||
self.view = self.settled.clone();
|
||||
let pending: Vec<Command> = self
|
||||
.in_flight
|
||||
.iter()
|
||||
.flat_map(|sent| sent.cmds.iter().cloned())
|
||||
.collect();
|
||||
for cmd in &pending {
|
||||
reduce_view(&mut self.view, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame the host will not answer settles as soon as nothing older is
|
||||
/// waiting: only the front may settle, or the order would be lost.
|
||||
fn settle_sent(&mut self) {
|
||||
while self.in_flight.front().is_some_and(|sent| !sent.awaited) {
|
||||
let sent = self.in_flight.pop_front().expect("just peeked");
|
||||
for cmd in &sent.cmds {
|
||||
reduce_view(&mut self.settled, cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle the `Normal` pair against the grid this client **holds**, after
|
||||
/// every fold. Observed, never composed — `context/code/clients.md`.
|
||||
fn settle_mode(&mut self) {
|
||||
@@ -253,6 +337,84 @@ mod tests {
|
||||
assert_eq!(state.view.status_msg, "saved");
|
||||
}
|
||||
|
||||
/// A mutating frame, composed the only way there is (gotcha 43), paired
|
||||
/// with the view command that rides along with it.
|
||||
fn commit_like(status: &str) -> Vec<Command> {
|
||||
let mut cache = crate::BasisCache::default();
|
||||
crate::WidgetCache::apply(
|
||||
&mut cache,
|
||||
&crate::basis::published(0, 0, Default::default()),
|
||||
);
|
||||
vec![
|
||||
Command::Model {
|
||||
basis: cache.claim([]).expect("seeded"),
|
||||
cmd: crate::ModelCommand::CleanEmptyRecords,
|
||||
},
|
||||
Command::SetStatus(status.into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Two frames in flight and the **first** refused: the second was applied
|
||||
/// by the host and must survive. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn refusing_one_of_two_frames_in_flight_keeps_the_other() {
|
||||
let mut state = ClientState::default();
|
||||
state.fold_outgoing(&commit_like("first"));
|
||||
state.fold_outgoing(&commit_like("second"));
|
||||
|
||||
state.refused(Rejection::LayoutMoved);
|
||||
state.accepted();
|
||||
|
||||
let mut only_second = ClientState::default();
|
||||
only_second.fold_outgoing(&commit_like("second"));
|
||||
assert_eq!(
|
||||
state.view, only_second.view,
|
||||
"dropping the refused frame must leave the client where a client \
|
||||
that had only ever sent the accepted one stands"
|
||||
);
|
||||
assert!(
|
||||
state.in_flight.is_empty(),
|
||||
"both frames were answered; nothing may still be held"
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame the host does not answer must not consume an answer, or the
|
||||
/// queue addresses the wrong frame. Evidence file.
|
||||
#[test]
|
||||
fn a_frame_the_host_will_not_answer_settles_without_one() {
|
||||
let mut state = ClientState::default();
|
||||
state.fold_outgoing(&[Command::SetStatus("typing".into())]);
|
||||
assert!(
|
||||
state.in_flight.is_empty(),
|
||||
"a frame carrying no Command::Model is never refused, so holding \
|
||||
it would make the next answer pop the wrong entry"
|
||||
);
|
||||
assert_eq!(state.settled.status_msg, "typing");
|
||||
|
||||
state.fold_outgoing(&commit_like("committed"));
|
||||
state.refused(Rejection::LayoutMoved);
|
||||
assert_eq!(
|
||||
state.view.status_msg, "typing",
|
||||
"the refusal drops the commit and keeps what already settled"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused *save* answers no `Commands` frame (improvise-368h), so it
|
||||
/// must not consume one's entry. Evidence file.
|
||||
#[test]
|
||||
fn a_refused_save_does_not_roll_back_a_frame_it_never_answered() {
|
||||
let mut state = ClientState::default();
|
||||
state.fold_outgoing(&commit_like("committed"));
|
||||
|
||||
state.refused(Rejection::NoDocumentPath);
|
||||
|
||||
assert_eq!(state.in_flight.len(), 1, "the commit is still unanswered");
|
||||
assert_eq!(
|
||||
state.view.status_msg, "committed",
|
||||
"a save that could not be written undid an edit it has no bearing on"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
|
||||
let mut state = ClientState::default();
|
||||
|
||||
@@ -39,8 +39,8 @@ impl ClientFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame to a client. Three variants for the three shapes
|
||||
/// `Sessions::apply` hands back, and no fourth (gotcha 39).
|
||||
/// A frame to a client. Three of the four are the shapes `Sessions::apply`
|
||||
/// hands back (gotcha 39); the fourth answers a frame — `protocol.md` §4.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerFrame {
|
||||
/// Drop everything and start from this: on connect, and again to *every*
|
||||
@@ -52,6 +52,9 @@ pub enum ServerFrame {
|
||||
/// Nothing was applied, and here is why. Addressed to the asker alone
|
||||
/// and carrying no new state — the [`Rejection`] variants are the list.
|
||||
Rejected(Rejection),
|
||||
/// The asker's mutating frame landed whole. Carries no state: everything
|
||||
/// it moved arrived as projections first — `context/code/protocol.md` §4.
|
||||
Accepted,
|
||||
}
|
||||
|
||||
impl ServerFrame {
|
||||
@@ -101,6 +104,7 @@ mod tests {
|
||||
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
|
||||
))]),
|
||||
ServerFrame::Rejected(Rejection::LayoutMoved),
|
||||
ServerFrame::Accepted,
|
||||
] {
|
||||
let json = serde_json::to_string(&frame).expect("serializes");
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,7 @@ pub mod variants;
|
||||
pub mod viewmodel;
|
||||
|
||||
pub use basis::{Basis, BasisCache, Rejection};
|
||||
pub use client::ClientState;
|
||||
pub use client::{ClientState, Sent};
|
||||
pub use frame::{ClientFrame, ServerFrame};
|
||||
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
||||
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Client {
|
||||
/// position", or `None` — which is a race, not a normal path.
|
||||
#[wasm_bindgen]
|
||||
pub fn typed_cell(
|
||||
&self,
|
||||
&mut self,
|
||||
row: usize,
|
||||
col: usize,
|
||||
text: &str,
|
||||
@@ -191,9 +191,7 @@ impl Client {
|
||||
if cmds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for cmd in &cmds {
|
||||
self.state.fold(cmd);
|
||||
}
|
||||
self.state.fold_outgoing(&cmds);
|
||||
Some(ClientFrame::Commands(cmds))
|
||||
}
|
||||
|
||||
@@ -209,9 +207,7 @@ impl Client {
|
||||
let event = browser.to_key_event()?;
|
||||
match resolve(&self.state, event) {
|
||||
KeyOutcome::Local(cmds) => {
|
||||
for cmd in &cmds {
|
||||
self.state.fold(cmd);
|
||||
}
|
||||
self.state.fold_outgoing(&cmds);
|
||||
Some(ClientFrame::Commands(cmds))
|
||||
}
|
||||
// Nothing to fold: a request moves no state of this client's, and
|
||||
|
||||
@@ -55,11 +55,14 @@ impl Client {
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(rejection) => {
|
||||
let message = rejection_message(&rejection);
|
||||
let state = self.state_mut();
|
||||
state.view.status_msg = rejection_message(&rejection);
|
||||
state.last_rejection = Some(rejection);
|
||||
state.revision += 1;
|
||||
// The sentence goes on *after* the rollback: it is this
|
||||
// client's own notification, and the slice it restores has none.
|
||||
state.refused(rejection);
|
||||
state.view.status_msg = message;
|
||||
}
|
||||
ServerFrame::Accepted => self.state_mut().accepted(),
|
||||
}
|
||||
self.revision()
|
||||
}
|
||||
@@ -72,13 +75,17 @@ impl Client {
|
||||
|
||||
/// Compose the write a user made by typing `text` into a viewport position.
|
||||
/// The four `None` shapes, and why nothing folds here: clients.md.
|
||||
pub fn typed_cell_frame(&self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
|
||||
pub fn typed_cell_frame(&mut self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
|
||||
let cell = self.record_cell_at(row, col)?;
|
||||
let basis = self.state().basis.claim([cell.record])?;
|
||||
Some(ClientFrame::command(Command::Model {
|
||||
let cmds = vec![Command::Model {
|
||||
basis,
|
||||
cmd: typed_cell_command(&improvise_protocol::CellId::Record(cell), text)?,
|
||||
}))
|
||||
}];
|
||||
// Folds nothing — a `Command::Model` is not the view slice's business —
|
||||
// but it **registers**, or the host's answers address the wrong frame.
|
||||
self.state_mut().fold_outgoing(&cmds);
|
||||
Some(ClientFrame::Commands(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +253,7 @@ mod tests {
|
||||
/// classification is `protocol::typed`'s alone (improvise-j7v6).
|
||||
#[test]
|
||||
fn a_typed_string_composes_the_frame_that_carries_it() {
|
||||
let client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
assert_eq!(
|
||||
client.typed_cell_frame(0, 0, "42.5"),
|
||||
Some(ClientFrame::command(Command::Model {
|
||||
|
||||
@@ -7,7 +7,7 @@ use improvise_core::view::Axis;
|
||||
use improvise_core::workbook::Workbook;
|
||||
use improvise_protocol::ClientFrame;
|
||||
use improvise_wasm_client::render::RenderRow;
|
||||
use improvise_wasm_client::{Client, RenderModel};
|
||||
use improvise_wasm_client::{BrowserKey, Client, RenderModel};
|
||||
use improvise_ws_server::host::{Addressed, Host};
|
||||
|
||||
/// The document the server opens: two regions by two measures, one cell
|
||||
@@ -560,3 +560,77 @@ fn ctrl_s_in_the_browser_writes_the_hosts_document() {
|
||||
"a save that landed is not a rejection: {answer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused frame must leave this client where the **host** says it is, and
|
||||
/// the host says so in its own mirror. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_refused_commit_leaves_the_client_where_the_host_says_it_is() {
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
|
||||
let handshake: ClientFrame =
|
||||
serde_json::from_str(&client.set_viewport(10, 10)).expect("a viewport frame");
|
||||
let ClientFrame::Viewport { rows, cols } = handshake else {
|
||||
panic!("set_viewport must produce a Viewport frame");
|
||||
};
|
||||
let (id, snapshot) = host.connect(rows, cols);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
// Typing carries no `Command::Model`, so none of it is refusable: each
|
||||
// frame lands on the host and moves both ends together.
|
||||
for key in ["i", "7", "5", "0", "0"] {
|
||||
let frame = client
|
||||
.key(&BrowserKey::new(key))
|
||||
.expect("every key in this run resolves locally");
|
||||
let answers = send(&mut host, id, &serde_json::to_string(&frame).expect("json"));
|
||||
deliver(&mut client, &answers);
|
||||
}
|
||||
assert_eq!(
|
||||
client.state().view.mode,
|
||||
host.sessions().get(id).expect("attached").view().mode,
|
||||
"the two ends must agree BEFORE the refusal, or the refusal proves nothing"
|
||||
);
|
||||
|
||||
// The host's layout moves under a client that is told nothing, so the
|
||||
// commit it is about to compose claims a basis that has gone.
|
||||
host.app_mut()
|
||||
.apply_effects(vec![Box::new(improvise_command::ui::effect::AddCategory(
|
||||
"Channel".into(),
|
||||
))]);
|
||||
|
||||
let commit = client
|
||||
.key(&BrowserKey::new("Enter"))
|
||||
.expect("a commit frame");
|
||||
let answers = send(
|
||||
&mut host,
|
||||
id,
|
||||
&serde_json::to_string(&commit).expect("json"),
|
||||
);
|
||||
assert!(
|
||||
answers
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, improvise_protocol::ServerFrame::Rejected(_))),
|
||||
"this fixture exists to be refused; it was answered {answers:?}"
|
||||
);
|
||||
deliver(&mut client, &answers);
|
||||
|
||||
// Everything but the sentence, which is this client's own notification and
|
||||
// is written after the rollback: the mirror has no way to hold one.
|
||||
let mut told = client.state().view.clone();
|
||||
let announced = std::mem::take(&mut told.status_msg);
|
||||
assert_eq!(
|
||||
&told,
|
||||
host.sessions().get(id).expect("attached").view(),
|
||||
"a refused frame moved the host's mirror not at all, so a client that \
|
||||
kept its optimistic fold holds a view slice the host disowns"
|
||||
);
|
||||
assert!(
|
||||
announced.contains("not applied"),
|
||||
"and the user must be told, not silently rewound: {announced:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
told.buffers.get("edit").map(String::as_str),
|
||||
Some("12007500"),
|
||||
"the rollback restores the typing rather than costing it (improvise-3al1)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1274,7 +1274,8 @@ fn deliver(
|
||||
client.fold(cmd);
|
||||
}
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(_) => {}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) => {}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
}
|
||||
}
|
||||
grid.clone()
|
||||
|
||||
@@ -112,7 +112,7 @@ impl MainThread {
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) => {}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,13 @@ fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() {
|
||||
grid.cells[0][0].text, "12.00",
|
||||
"the edit never came back down the socket"
|
||||
);
|
||||
// The acknowledgement follows everything the frame produced, so a
|
||||
// client discards its undo knowing what it is discarding (nxah).
|
||||
assert_eq!(
|
||||
recv(&mut ws).await,
|
||||
ServerFrame::Accepted,
|
||||
"a mutating frame that landed must be acknowledged, and last"
|
||||
);
|
||||
|
||||
// A frame the vocabulary does not cover is skipped, not fatal. The
|
||||
// resize that follows genuinely reframes a 2x2 fixture to 1x1.
|
||||
|
||||
Reference in New Issue
Block a user