Merge pull request #10 from levineuwirth/lsp-frameloop-order

T M4.5 frame-loop reorder: tick_async last (1-frame LSP await latency)
This commit is contained in:
Levi Neuwirth 2026-05-19 00:00:19 +00:00 committed by GitHub
commit 83db560f48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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"
);
}