A reopened editor awaits any unanswered write, settles only while editing, and states its awaiting flag (improvise-mdfc, improvise-4cb5, improvise-mgis)

improvise-mdfc: an untouched commit seeded the reopened editor from the
cache even while an earlier write from this client was unanswered, so
Tab Tab at the rightmost column stored 505 where the TUI stores 5095.
`advanced` now awaits when this frame writes or any in-flight frame is
awaited. The lockstep test sends three Tabs before answering any.

improvise-4cb5: `settle_awaited_seed` also requires an editing mode, so
an Esc before the answer no longer leaves the seed in Normal's buffer.

improvise-mgis: `OpenedEditor::awaiting` loses `serde(default)`; a frame
missing it fails to parse as the missing field (protocol.md §3, 1wl0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-23 16:58:39 -07:00
parent baabe1a9b1
commit 3fcd407b08
5 changed files with 233 additions and 13 deletions
+24 -3
View File
@@ -2033,8 +2033,27 @@ stated once, over the write, never over a list of cases:
client holds, but only once no frame it sent is still unanswered. The answer
folds the host's projection before `Accepted`, so the grid is the host's by
then. `accepted()` therefore settles.
- **A commit that writes nothing** still seeds from the cache at once. Nothing
changed, so the cache is current.
- **A commit that writes nothing** awaits as well while any earlier frame's
write is unanswered. Otherwise it seeds from the cache at once. "Nothing
changed, so the cache is current" was the rule here, and it was false
(improvise-mdfc). *This* commit changed nothing, but an earlier one might
have, and the cache predates it until the answer arrives. Tab Tab pressed
quickly at the rightmost column opened the second editor on the value from
before the first Tab's write: 505 where the TUI stores 5095. So the test is
"does this frame write, or is a write still in flight", which is
`writes || in_flight.iter().any(|sent| sent.awaited)`. It checks *any*
in-flight write, not the newest frame: a frame that writes nothing stays in
the queue behind an unanswered write, so the newest frame can be one of
those.
- **The seed settles only into an open editor** (improvise-4cb5). An Esc typed
before the answer arrives leaves the awaiting `opened_editor` in place. The
late seed would then fill the edit buffer in `Normal` mode, where the TUI's is
empty.
- **`awaiting` has no `serde(default)`** (improvise-mgis), for the reason
`AxisFrame::first` has none (§3a, improvise-1wl0). `awaiting: false` is a
claim, "the seed is settled". A peer that dropped the field would hand over an
editor that stays empty forever, and whose Enter writes nothing. A missing
field is therefore a parse failure.
Asking the host to send the seed was the alternative. It would have needed a
new message, and the host already sends it: the landed cell's `edit_text` in
@@ -2043,7 +2062,9 @@ the projection it owes this client anyway. Two things are accepted costs:
well under a frame.
- A key typed before the answer arrives marks the editor touched, and the late
seed then does not overwrite it. What the user sees is then what gets
written, which is never another record's number.
written. That is never another record's number only because of the mdfc
rule above: before it, an untouched commit behind an unanswered write opened
on the stale cache, and typing then extended the wrong number.
The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor`
already reads the renumbered grid (gotcha 23). This also took
+56
View File
@@ -5222,3 +5222,59 @@ The fold half of improvise-3uzg, without a host. The steps:
tests.
- `accepted()` not settling fails the same four.
- Ignoring `touched` fails this test alone. It is the one guard on type-ahead.
### `a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write`
**improvise-mdfc**, found by the #227 review at `baabe1a9`. Fixture:
`precise_host`, cursor (0,1) on North/Revenue = 2 (the rightmost column), window
2×2. `i 9` go through `sent_and_answered`. Then **three** Tabs are sent through
`sent`, and none is answered until all three are out. The first writes 29 and
the other two write nothing. After the answers are delivered in order, the
browser's buffers must equal the TUI host's. `5 Enter` then has to store 295 on
both ends.
Every earlier lockstep test answered each key before the next was sent, so the
client's cache was always the host's by the time it resolved a key. That is why
none of them saw this. The hazard needs a key resolved while a write is still
unanswered.
Red at `baabe1a9`: after the answers the browser buffer was `"2"` (the cache from
before the write) and the host's was `"29"`. In Chromium over the worker, with
`tall.improv` R01/Cost = 50 and `i 9 Tab Tab 5 Enter`, the browser stored 505
where the TUI stores 5095.
**Rivals:**
- The fix reverted (await only when this commit writes) fails this test alone.
The workspace was otherwise green at `baabe1a9`.
- Awaiting only when the **newest** in-flight frame is awaited
(`in_flight.back()`) fails this test, and the other 53 lockstep tests pass. That is why it sends three Tabs,
not two: with two, the newest in-flight frame *is* the write, and `back()` and
`any()` cannot be told apart.
### `client::tests::an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty`
**improvise-4cb5**. A write frame with an awaiting editor is sent, and then Esc
(`SetBuffer edit ""`, `ChangeMode Normal`) is sent before the answer arrives.
The host's projection says `3`, and then `Accepted` arrives. The mode must be
`Normal` and the edit buffer must be empty.
Red before the fix: the buffer was `"3"`. `settle_awaited_seed` asked only
whether the opened editor was awaiting and untouched, and Esc leaves
`opened_editor` in place. The seed `3` is distinct from the empty buffer Esc
leaves, so the assertion can only pass if nothing was written.
**Rival:** the mode check removed (the code at `baabe1a9`) fails this test, and
the rest of `improvise-protocol`'s lib passes.
### `resolve::opened_editor_wire_tests::a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse`
**improvise-mgis**. A `ClientFrame::Commands` carrying
`SetOpenedEditor(Some(OpenedEditor::awaiting()))` is serialized, and the
`awaiting` key is removed from the JSON. The test asserts the key was there, and
that `seed` is still there, so the mutated value is a well-formed editor minus
one field. It must then fail to parse **as the missing field**, not as broken
JSON.
**Rival:** `#[serde(default)]` restored. The frame then parses, with
`awaiting: false`, and the test fails at `expect_err`. Before this test existed,
deleting the attribute left the whole workspace green, so nothing pinned it.
+29 -5
View File
@@ -183,11 +183,12 @@ impl ClientState {
/// An editor opened after a write takes its seed from the grid the host
/// answered with, never from the cache the write made stale (improvise-3uzg).
fn settle_awaited_seed(&mut self) {
let waiting = self
.view
.opened_editor
.as_ref()
.is_some_and(|opened| opened.awaiting && !opened.touched);
let waiting = self.view.mode.is_editing()
&& self
.view
.opened_editor
.as_ref()
.is_some_and(|opened| opened.awaiting && !opened.touched);
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) {
self.view.buffers.insert("edit".to_string(), seed.clone());
@@ -489,6 +490,29 @@ mod tests {
);
}
/// BUG (improvise-4cb5) — see `context/test-evidence/clients.md`.
#[test]
fn an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty() {
let mut state = ClientState::default();
answered_with(&mut state, "2");
state.fold_outgoing(&write_then_awaiting_editor());
state.fold_outgoing(&[
Command::SetBuffer {
name: "edit".into(),
value: String::new(),
},
Command::ChangeMode(crate::AppMode::Normal),
]);
answered_with(&mut state, "3");
state.accepted();
assert_eq!(state.view.mode, crate::AppMode::Normal);
assert_eq!(
state.view.buffers.get("edit").map(String::as_str),
Some(""),
"the late seed was written into the buffer of an editor Esc had closed"
);
}
/// A frame the host does not answer must not consume an answer, or the
/// queue addresses the wrong frame. Evidence file.
#[test]
+36 -5
View File
@@ -1009,9 +1009,10 @@ fn advanced(
return Some(Vec::new());
};
let mut commands = scrolled_to(state, nr, nc);
// After a write the cache may name other cells than the host's grid does, so
// the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3).
commands.extend(match writes {
// After a write — this frame's or an unanswered earlier one's — the cache may
// be stale, so the seed waits for the host (improvise-3uzg/mdfc, `protocol.md` §3).
let stale = writes || state.in_flight.iter().any(|sent| sent.awaited);
commands.extend(match stale {
true => awaiting_seed(edit_mode),
false => opened_on(seed_at(state, nr, nc), edit_mode),
});
@@ -1071,8 +1072,8 @@ pub struct OpenedEditor {
pub seed: String,
/// Whether the user has edited the buffer since it opened (improvise-2054).
pub touched: bool,
/// The seed is still to come from the host's answer (improvise-3uzg).
#[serde(default)]
/// The seed is still to come from the host's answer (improvise-3uzg). Not
/// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis.
pub awaiting: bool,
}
@@ -4064,3 +4065,33 @@ mod tests {
);
}
}
#[cfg(test)]
mod opened_editor_wire_tests {
use super::OpenedEditor;
use crate::{ClientFrame, Command};
/// improvise-mgis: `awaiting: false` is the claim that the seed is settled,
/// so a frame that dropped it is refused. `context/test-evidence/clients.md`.
#[test]
fn a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse() {
let frame = ClientFrame::Commands(vec![Command::SetOpenedEditor(Some(
OpenedEditor::awaiting(),
))]);
let mut json = serde_json::to_value(&frame).expect("a ClientFrame serializes");
let opened = json
.pointer_mut("/Commands/0/SetOpenedEditor")
.and_then(serde_json::Value::as_object_mut)
.expect("the editor is where this test expects it");
assert!(
opened.remove("awaiting").is_some() && opened.contains_key("seed"),
"the field must have been there, and the rest of the editor must remain"
);
let err = serde_json::from_value::<ClientFrame>(json)
.expect_err("an editor that states no awaiting flag states no claim");
assert!(
err.to_string().contains("missing field `awaiting`"),
"it must fail as the MISSING FIELD, not as broken JSON: {err}"
);
}
}
@@ -2549,6 +2549,94 @@ fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() {
);
}
/// What the host owes a frame it has applied: its projection, its basis, and
/// `Accepted` when the frame was awaited. Held back so the next key overtakes it.
struct Unanswered {
grid: improvise_protocol::GridCache,
basis: improvise_protocol::Basis,
awaited: bool,
}
fn sent(
client: &mut ClientState,
host: &mut App,
event: KeyEvent,
window: (usize, usize),
) -> Unanswered {
let KeyOutcome::Local(cmds) = resolve(client, event) else {
panic!("{event:?}: the client must answer this key locally");
};
client.fold_outgoing(&cmds);
apply_on(host, &cmds);
let awaited = cmds
.iter()
.any(|c| matches!(c, improvise_protocol::Command::Model { .. }));
let (grid, basis) = (client_of(host, window).grid, published_basis(host, window));
Unanswered {
grid,
basis,
awaited,
}
}
fn answered(client: &mut ClientState, answer: Unanswered) {
client.grid = answer.grid;
improvise_protocol::WidgetCache::apply(&mut client.basis, &answer.basis);
if answer.awaited {
client.accepted();
}
}
/// BUG (improvise-mdfc) — see `context/test-evidence/clients.md`.
#[test]
fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() {
let window = (2, 2);
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
client_host.cursor_mut().selected = (0, 1);
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
key_host.cursor_mut().selected = (0, 1);
let mut client = client_ready_to_edit(&client_host, window);
let tab = BrowserKey::new("Tab").to_key_event().unwrap();
for key in ["i", "9"] {
let event = BrowserKey::new(key).to_key_event().unwrap();
sent_and_answered(&mut client, &mut client_host, event, window);
reduce_key(&mut key_host, event);
}
// Three Tabs, so a frame that writes nothing sits between the write and the last.
let answers: Vec<Unanswered> = (0..3)
.map(|_| sent(&mut client, &mut client_host, tab, window))
.collect();
let awaited: Vec<bool> = answers.iter().map(|a| a.awaited).collect();
assert_eq!(
awaited,
[true, false, false],
"setup: only the first Tab writes"
);
for answer in answers {
reduce_key(&mut key_host, tab);
answered(&mut client, answer);
}
assert_eq!(
client.view.buffers, key_host.view_state.buffers,
"the second editor opened on the cache the first write had made stale"
);
for key in ["5", "Enter"] {
let event = BrowserKey::new(key).to_key_event().unwrap();
sent_and_answered(&mut client, &mut client_host, event, window);
reduce_key(&mut key_host, event);
}
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&region_measure("North", "Revenue")),
Some(&improvise_protocol::CellValue::Number(295.0)),
"{end}: the browser overwrote the value Tab had just written"
);
}
}
const FORMULAS: [&str; 3] = [
"Profit = Revenue * 2",
"Tax = Revenue * 0.1",