T M4.5 frame-loop reorder: tick_async last (1-frame LSP await latency)

The async bridge settles awaiters inside `tick_lsp`/`tick_mcp` by
posting to the message bus; `tick_async` drains that bus and resumes
the parked coroutine. With `tick_async` running *first* (historical
accretion from M3.3, predating processes/LSP/MCP), every LSP/MCP
`:await()` resumption was deferred a full frame: the response
absorbed in frame N's `tick_lsp` wasn't observed until frame N+1's
`tick_async` (~33ms structural floor @ 60Hz, plus a render frame).

Reordering both production loops (`editor::run` and the daemon loop)
to `processes → lsp → mcp → async` makes settle→resume happen in the
same frame, halving the floor to one frame. The only documented
ordering invariant — `tick_processes → tick_lsp → tick_mcp` for
same-batch supervisor I/O — is preserved; settle (bus post) and
resume (bus drain) are bus-decoupled, so the move cannot regress
correctness in either direction.

Acceptance tests open-code their own per-test tick orders and never
drive `editor::run`, so none covered production ordering. Added
`m4_5_await_resolves_same_frame_as_response_absorbed`, which drives
the exact production order and asserts the awaited request resolves
in the same frame its response is absorbed (absorbed_cycle ==
done_cycle); it fails if anyone reverts to `tick_async`-first.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1223/0; m4_acceptance 59/0; m9_1 18/0; m8_1/m8_9/m8_10 green
(SP-7 outline-aggregate "one async tick" pin unaffected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 19:58:04 -04:00
parent dbaacad755
commit 0201943c97
3 changed files with 94 additions and 2 deletions

View File

@ -1092,9 +1092,13 @@ fn dispatcher_loop(
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
editor.tick_async();
// `tick_async` last: the M4.5 async bridge settles awaiters
// inside `tick_lsp` (via the message bus); draining + resuming
// in the same frame keeps LSP `:await()` latency at one frame
// instead of two. Mirrors the in-process loop in `editor::run`.
editor.tick_processes();
editor.tick_lsp();
editor.tick_async();
}
Ok(())

View File

@ -938,10 +938,20 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
}
}
}
state.tick_async();
// `tick_async` runs *last*, after the supervisor/LSP/MCP
// ticks have absorbed this frame's inbound I/O. The async
// bridge (T M4.5) settles an awaiter inside `tick_lsp`/
// `tick_mcp` by posting to the message bus; `tick_async`
// drains that bus and resumes the parked coroutine. With
// `tick_async` last, settle→resume happens in the *same*
// frame; running it first would defer every LSP/MCP await
// resumption by a full frame. The documented invariant is
// only `tick_processes → tick_lsp → tick_mcp` (same-batch
// supervisor I/O ordering), which is preserved.
state.tick_processes();
state.tick_lsp();
state.tick_mcp();
state.tick_async();
}
let _ = frontend.poll_event(Duration::from_millis(0));
Ok(())

View File

@ -3646,3 +3646,81 @@ fn m4_5_await_superseded_request_is_cancelled() {
);
let _ = state.lua_host.lua().load("pmacs.lsp.stop(_G._lsp)").exec();
}
/// Regression for the T M4.5 frame-loop reorder. Drives the *exact*
/// production tick order (`processes → lsp → mcp → async`) and asserts
/// an awaited request resolves in the SAME frame its response was
/// absorbed by `tick_lsp` — not the next one. Under the pre-reorder
/// order (`async` first) this gap is 2 frames; here it must be 0.
/// No other test drives production ordering (the suite open-codes
/// per-test orders), so this is the only guard against a regression
/// to `tick_async`-first.
#[test]
fn m4_5_await_resolves_same_frame_as_response_absorbed() {
use pmacs::editor::EditorState;
let mut state = EditorState::new();
spawn_lsp_and_init(&mut state, None);
state
.lua_host
.lua()
.load(
"_G._done=false
pmacs.async(function()
pmacs.lsp.request_completion(_G._lsp,'file:///x.rs',0,0):await()
_G._done=true
end)",
)
.exec()
.expect("dispatch await coroutine");
let deadline = Instant::now() + Duration::from_secs(5);
let mut absorbed_cycle: Option<u32> = None;
let mut done_cycle: Option<u32> = None;
let mut cycle: u32 = 0;
while done_cycle.is_none() {
assert!(Instant::now() < deadline, "await never resolved");
cycle += 1;
// Production order: processes → lsp → mcp → async.
state.tick_processes();
state.tick_lsp();
// `tick_lsp`'s `handle_response` both absorbs into the store
// and settles the awaiter (same call), so store-population is
// a faithful proxy for "response absorbed this frame".
if absorbed_cycle.is_none() {
let n: i64 = state
.lua_host
.lua()
.load(
"local it = pmacs.completion.items(_G._lsp,'file:///x.rs') \
return (it and #it) or 0",
)
.eval()
.unwrap_or(0);
if n > 0 {
absorbed_cycle = Some(cycle);
}
}
state.tick_mcp();
state.tick_async();
if done_cycle.is_none() {
let done: bool = state
.lua_host
.lua()
.load("return _G._done == true")
.eval()
.unwrap_or(false);
if done {
done_cycle = Some(cycle);
}
}
std::thread::sleep(Duration::from_millis(5));
}
let absorbed = absorbed_cycle.expect("completion store must populate");
let done = done_cycle.expect("coroutine must finish");
assert_eq!(
absorbed, done,
"await must resolve in the same frame the response is absorbed \
(absorbed @cycle {absorbed}, done @cycle {done}); a positive gap \
means tick_async ran before tick_lsp the reorder regressed"
);
}