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:
parent
dbaacad755
commit
0201943c97
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue