diff --git a/src/daemon.rs b/src/daemon.rs index d80c349..1c02184 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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(()) diff --git a/src/editor.rs b/src/editor.rs index 1c02deb..0971c1e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -938,10 +938,20 @@ pub fn run(file: Option) -> 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(()) diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index b2e26c9..6107aa4 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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 = None; + let mut done_cycle: Option = 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" + ); +}