// m9_2_acceptance.rs --- T M9.2 resource fetch + caching acceptance. //! Acceptance tests for T M9.2 (`spec/pmacs-tasks.tex:3914`): //! //! 1. Read-resource round-trip works through a real MCP test server. //! 2. Cache hit on repeated read of an unchanged resource. //! 3. Cache invalidation by explicit `invalidate_resource` //! call; subsequent reads refetch. //! //! Plus three architectural-correctness tests called out during the //! M9.2 design review: //! //! 4. Coalescing under load: 10 concurrent `read_resource` calls //! produce 1 wire request and 10 awaiters that all settle with //! the same result. //! 5. In-flight failure: server crashes mid-request; primary + //! attached awaiters all settle with errors; subsequent read //! re-dispatches (cache state is Absent, not stuck in `InFlight`). //! 6. Invalidation during in-flight: invalidate while a request is //! on the wire; the in-flight response settles awaiters but //! doesn't cache; a fresh read after invalidation re-dispatches. use std::cell::RefCell; use std::rc::Rc; use std::time::{Duration, Instant}; use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime}; use pmacs::lua_bindings::SharedProcessSupervisor; use pmacs::mcp::{ McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId, McpServerSpec, SharedMcpManager, }; use pmacs::process::ProcessSupervisor; fn fake_mcp_path() -> String { env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned() } fn make_test_triple() -> ( SharedProcessSupervisor, SharedAsyncRuntime, SharedMcpManager, ) { let sup = Rc::new(RefCell::new(ProcessSupervisor::new())); let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1)); let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone()))); (sup, runtime, mgr) } fn fake_spec(label: &str) -> McpServerSpec { let mut spec = McpServerSpec::new(label, fake_mcp_path()); spec.restart = McpRestartPolicy::Never; spec } /// Drain the supervisor + manager + runtime ticks until either the /// predicate is true or the deadline lapses. Used both for waiting /// on lifecycle events and for waiting on runtime jobs to settle. fn pump_until bool>( sup: &SharedProcessSupervisor, runtime: &SharedAsyncRuntime, mgr: &SharedMcpManager, deadline: Duration, mut pred: F, ) { let stop = Instant::now() + deadline; while Instant::now() < stop { sup.borrow_mut().tick(); mgr.borrow_mut().tick(); runtime.tick(); if pred() { return; } std::thread::sleep(Duration::from_millis(15)); } } /// Drain MCP events for `sid` until `pred` is satisfied. fn drain_mcp_until bool>( sup: &SharedProcessSupervisor, runtime: &SharedAsyncRuntime, mgr: &SharedMcpManager, sid: McpServerId, deadline: Duration, pred: F, ) -> Vec { let stop = Instant::now() + deadline; let mut all: Vec = Vec::new(); while Instant::now() < stop { sup.borrow_mut().tick(); mgr.borrow_mut().tick(); runtime.tick(); let mut evs = mgr.borrow_mut().take_events(sid); all.append(&mut evs); if pred(&all) { return all; } std::thread::sleep(Duration::from_millis(15)); } all } /// Drive an Initialized server. fn spawn_initialized( sup: &SharedProcessSupervisor, runtime: &SharedAsyncRuntime, mgr: &SharedMcpManager, spec: McpServerSpec, ) -> McpServerId { let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); assert!(matches!( mgr.borrow().state(sid), Some(McpClientState::Initialized { .. }) )); sid } fn await_job( sup: &SharedProcessSupervisor, runtime: &SharedAsyncRuntime, mgr: &SharedMcpManager, job_id: JobId, deadline: Duration, ) -> JobOutcome { pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id)); runtime .take_result(job_id) .unwrap_or_else(|| panic!("job {job_id} did not settle within deadline")) } fn unwrap_text(outcome: JobOutcome) -> String { match outcome { JobOutcome::Complete(JobResult::Json(v)) => { let arr = v .get("contents") .and_then(|c| c.as_array()) .expect("contents array"); assert_eq!(arr.len(), 1, "fake returns one content entry"); arr[0] .get("text") .and_then(|t| t.as_str()) .expect("text field") .to_owned() } other => panic!("expected Complete(Json(...)), got {other:?}"), } } // =========================================================================== // Spec acceptance bullets // =========================================================================== /// Bullet 1: read-resource round-trip works. #[test] fn m9_2_read_resource_round_trip() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("read")); let job_id = mgr .borrow_mut() .read_resource(sid, "file:///foo") .expect("read_resource"); let text = unwrap_text(await_job( &sup, &runtime, &mgr, job_id, Duration::from_secs(5), )); // Fake's response includes a counter and the URI. assert!( text.contains("synthetic-1-for-file:///foo"), "first read should produce synthetic-1; got {text:?}" ); let _ = mgr.borrow_mut().stop(sid); } /// Bullet 2: a second read of the same URI is a cache hit. The fake's /// per-process counter increments on every wire `resources/read`, so /// "two reads, same text" proves the second read didn't go on the wire. #[test] fn m9_2_cache_hit_on_repeated_read() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("hit")); let job1 = mgr .borrow_mut() .read_resource(sid, "file:///x") .expect("read 1"); let text1 = unwrap_text(await_job( &sup, &runtime, &mgr, job1, Duration::from_secs(5), )); let job2 = mgr .borrow_mut() .read_resource(sid, "file:///x") .expect("read 2"); let text2 = unwrap_text(await_job( &sup, &runtime, &mgr, job2, Duration::from_secs(5), )); assert_eq!( text1, text2, "second read must hit the cache (same counter value); got {text1:?} vs {text2:?}" ); assert!( text1.contains("synthetic-1-for-"), "fake's counter should still be 1 after a single wire fetch; got {text1:?}" ); let _ = mgr.borrow_mut().stop(sid); } /// Bullet 3: `invalidate_resource` forces a refetch on the next read. #[test] fn m9_2_invalidation_forces_refetch() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("invalidate")); let job1 = mgr .borrow_mut() .read_resource(sid, "file:///y") .expect("read 1"); let text1 = unwrap_text(await_job( &sup, &runtime, &mgr, job1, Duration::from_secs(5), )); mgr.borrow_mut().invalidate_resource(sid, "file:///y"); let job2 = mgr .borrow_mut() .read_resource(sid, "file:///y") .expect("read after invalidate"); let text2 = unwrap_text(await_job( &sup, &runtime, &mgr, job2, Duration::from_secs(5), )); assert_ne!( text1, text2, "post-invalidation read must refetch (different counter); got {text1:?} both times" ); assert!( text1.contains("synthetic-1-for-"), "first counter value should be 1; got {text1:?}" ); assert!( text2.contains("synthetic-2-for-"), "post-invalidation counter should be 2 (fresh wire fetch); got {text2:?}" ); let _ = mgr.borrow_mut().stop(sid); } // =========================================================================== // Architectural-correctness tests // =========================================================================== /// (4) M3.5 coalescing: 10 concurrent `read_resource` calls for the /// same URI produce 1 wire request and 10 awaiters that settle with /// the same result. Verifies the `InFlight` state attaches siblings. #[test] fn m9_2_coalescing_under_load() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("coalesce")); let mut jobs: Vec = Vec::with_capacity(10); for _ in 0..10 { let j = mgr .borrow_mut() .read_resource(sid, "file:///shared") .expect("read"); jobs.push(j); } pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || { jobs.iter().all(|&j| runtime.is_complete(j)) }); let mut texts: Vec = Vec::with_capacity(jobs.len()); for j in jobs { let outcome = runtime.take_result(j).expect("settled"); texts.push(unwrap_text(outcome)); } let first = &texts[0]; for t in &texts[1..] { assert_eq!( first, t, "all coalesced awaiters must settle with the same value" ); } // Server-side counter visible in the text proves only one wire // fetch landed despite 10 client-side reads. assert!( first.contains("synthetic-1-for-"), "counter must be 1 for a single wire fetch coalescing 10 reads; got {first:?}" ); let _ = mgr.borrow_mut().stop(sid); } /// (5) In-flight failure: server crashes mid-request; primary + /// attached awaiters all settle with errors; the cache transitions /// `InFlight` → Absent (not stuck), so a subsequent read /// re-dispatches once a fresh server is in place. #[test] fn m9_2_in_flight_failure_settles_all_awaiters_and_clears_cache() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("crash"); spec.env = vec![( "PMACS_FAKE_MCP_MODE".into(), "crash_after_first_request".into(), )]; let sid = spawn_initialized(&sup, &runtime, &mgr, spec); // 5 concurrent reads coalesce onto one in-flight request that // never completes — the fake exits with code 77 instead. let mut jobs: Vec = Vec::with_capacity(5); for _ in 0..5 { let j = mgr .borrow_mut() .read_resource(sid, "file:///doomed") .expect("read"); jobs.push(j); } pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || { jobs.iter().all(|&j| runtime.is_complete(j)) }); for j in jobs { let outcome = runtime.take_result(j).expect("settled"); assert!( matches!(outcome, JobOutcome::Cancelled | JobOutcome::Failed(_)), "every awaiter must settle non-Ok after server crash; got {outcome:?}" ); } // Server is now Crashed; cache should not be stuck in InFlight. // We can't directly inspect the cache from outside the manager, // but a subsequent read_resource attempt would return an error // (server not Initialized), which is the right negative signal: // the cache didn't pin a stale InFlight entry. let res = mgr.borrow_mut().read_resource(sid, "file:///doomed"); assert!( res.is_err(), "reads against a crashed server must error (not return a stale handle); got {res:?}" ); } /// (6) Invalidation during in-flight: invalidate while a request is /// on the wire. The in-flight response still settles awaiters with /// the result, but **does not** cache. A fresh read after invalidation /// re-dispatches. #[test] fn m9_2_invalidation_during_in_flight_does_not_cache() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("inv-in-flight")); // Dispatch the first read. Don't drive it to completion yet. let job1 = mgr .borrow_mut() .read_resource(sid, "file:///z") .expect("read 1"); // Invalidate immediately (request is still on the wire). mgr.borrow_mut().invalidate_resource(sid, "file:///z"); // Drive job 1 to completion. It should still settle with a // result — invalidation does not abort in-flight requests. let outcome1 = await_job(&sup, &runtime, &mgr, job1, Duration::from_secs(5)); let text1 = unwrap_text(outcome1); // Now dispatch a fresh read. With finding-correct behavior, // this re-dispatches (fake counter increments). Without it, a // bug could either return the stale "cached" value (counter=1) // or return whatever the in-flight request stashed. let job2 = mgr .borrow_mut() .read_resource(sid, "file:///z") .expect("read 2"); let text2 = unwrap_text(await_job( &sup, &runtime, &mgr, job2, Duration::from_secs(5), )); assert!( text1.contains("synthetic-1-for-"), "first read produced counter 1; got {text1:?}" ); assert!( text2.contains("synthetic-2-for-"), "post-invalidation read must refetch (counter 2); got {text2:?} (cache-after-invalidation bug?)" ); let _ = mgr.borrow_mut().stop(sid); } // =========================================================================== // Pass-2 findings: lifecycle + per-sibling cancellation // =========================================================================== /// Pass-2 finding 1: a cached resource read against a stopped or /// forgotten server must error rather than return stale cache data. /// `read_resource` checks the server's state before consulting the /// cache, and `on_exit` / `forget` clear the cache for that sid so /// no stale entries can survive. #[test] fn m9_2_cached_read_after_stop_rejects_stale_sid() { let (sup, runtime, mgr) = make_test_triple(); let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("stop-cache")); // Populate the cache. let job1 = mgr .borrow_mut() .read_resource(sid, "file:///stale") .expect("read 1"); let _ = unwrap_text(await_job( &sup, &runtime, &mgr, job1, Duration::from_secs(5), )); // Stop and pump to terminal. let _ = mgr.borrow_mut().stop(sid); drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| { matches!( mgr.borrow().state(sid), Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. }) ) }); // The post-stop read must error: the server is not Initialized. let res = mgr.borrow_mut().read_resource(sid, "file:///stale"); assert!( res.is_err(), "post-stop read_resource must error (not return cached data); got {res:?}" ); // Forget and try again — same expectation, different code path. mgr.borrow_mut() .forget(sid) .expect("forget should succeed in terminal state"); let res2 = mgr.borrow_mut().read_resource(sid, "file:///stale"); assert!( res2.is_err(), "post-forget read_resource must error (server unknown); got {res2:?}" ); } /// Pass-2 finding 2: per-awaiter cancellation tokens. With three /// awaiters coalesced onto one in-flight request, cancelling one /// must: /// (a) settle that awaiter as Cancelled, /// (b) leave the other two awaiters waiting, /// (c) NOT abort the in-flight wire request, /// (d) deliver the eventual response to the surviving awaiters as Ok. #[test] fn m9_2_per_sibling_cancellation_does_not_disturb_others() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("per-sibling-cancel"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "slow_resources_read".into())]; let sid = spawn_initialized(&sup, &runtime, &mgr, spec); // Three concurrent reads — coalesce onto one in-flight request. // The fake delays 250ms before responding, so we have a window // to cancel one awaiter before the response arrives. let job_a = mgr .borrow_mut() .read_resource(sid, "file:///shared") .expect("read a"); let job_b = mgr .borrow_mut() .read_resource(sid, "file:///shared") .expect("read b"); let job_c = mgr .borrow_mut() .read_resource(sid, "file:///shared") .expect("read c"); // Cancel only b. Use the runtime's cancel API directly (this is // what `pmacs.workers._cancel(id)` ultimately calls). runtime.cancel(job_b); // Pump until all three settle. pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || { runtime.is_complete(job_a) && runtime.is_complete(job_b) && runtime.is_complete(job_c) }); let outcome_a = runtime.take_result(job_a).expect("a settled"); let outcome_b = runtime.take_result(job_b).expect("b settled"); let outcome_c = runtime.take_result(job_c).expect("c settled"); // (a) and (c) must settle Ok. let text_a = unwrap_text(outcome_a); let text_c = unwrap_text(outcome_c); assert_eq!( text_a, text_c, "uncancelled awaiters must settle Ok with the same value" ); assert!( text_a.contains("synthetic-1-for-"), "wire fetch should have produced counter 1; got {text_a:?}" ); // (b) must settle Cancelled. assert!( matches!(outcome_b, JobOutcome::Cancelled), "the cancelled awaiter must settle as Cancelled; got {outcome_b:?}" ); let _ = mgr.borrow_mut().stop(sid); } /// Same contract as the previous test, but with the response already /// queued before the manager observes cancellation. This catches the /// tick-order race where process events were drained before cancelled /// awaiters and a cancelled handle could receive `Ok`. #[test] fn m9_2_cancelled_sibling_wins_over_queued_response() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("cancel-response-race"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "slow_resources_read".into())]; let sid = spawn_initialized(&sup, &runtime, &mgr, spec); let job_a = mgr .borrow_mut() .read_resource(sid, "file:///race") .expect("read a"); let job_b = mgr .borrow_mut() .read_resource(sid, "file:///race") .expect("read b"); let job_c = mgr .borrow_mut() .read_resource(sid, "file:///race") .expect("read c"); // Let the fake server finish its delayed response (it sleeps // 250ms), then harvest the supervisor event queue without giving // McpManager a chance to process it yet. The margin over the // fake's delay must absorb two pipe transits plus the request's // queued-stdin-writer hop under CI load (a 100ms margin flaked on // macOS runners); a generous wait does not weaken the contract — // the race under test is cancel-AFTER-queue-BEFORE-manager-tick, // which holds for any wait long enough for the response to land. std::thread::sleep(Duration::from_secs(1)); sup.borrow_mut().tick(); // Cancel only b after the response is queued but before // McpManager::tick drains that response. runtime.cancel(job_b); mgr.borrow_mut().tick(); runtime.tick(); assert!( runtime.is_complete(job_a) && runtime.is_complete(job_b) && runtime.is_complete(job_c), "one manager tick should settle the queued response and cancellation" ); let outcome_a = runtime.take_result(job_a).expect("a settled"); let outcome_b = runtime.take_result(job_b).expect("b settled"); let outcome_c = runtime.take_result(job_c).expect("c settled"); let text_a = unwrap_text(outcome_a); let text_c = unwrap_text(outcome_c); assert_eq!(text_a, text_c, "surviving awaiters receive the response"); assert!( matches!(outcome_b, JobOutcome::Cancelled), "cancelled awaiter must remain Cancelled even when response was queued first; got {outcome_b:?}" ); let _ = mgr.borrow_mut().stop(sid); } // =========================================================================== // Lua surface // =========================================================================== /// `pmacs.mcp.read_resource(server, uri):await()` resolves to the /// response's `result` table. The fake's `resources/read` returns /// `{ contents = [{ uri, mimeType, text }] }`. #[test] #[allow( clippy::too_many_lines, reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative" )] fn m9_2_lua_read_resource_returns_awaitable_handle() { use pmacs::editor::EditorState; let mut state = EditorState::new(); let fake = fake_mcp_path(); state .lua_host .lua() .load(format!( " _G._mcp_test_server = pmacs.mcp.spawn({{ label = 'lua-read', command = '{fake}', restart = 'never', }}) ", )) .exec() .expect("spawn via Lua"); // Pump until Initialized. let stop = Instant::now() + Duration::from_secs(5); let mut initialized = false; while Instant::now() < stop && !initialized { state.tick_processes(); state.tick_mcp(); state.tick_async(); let kinds: Vec = state .lua_host .lua() .load( " local out = {} for _, row in ipairs(pmacs.mcp.list()) do out[#out+1] = row.state.kind end return out ", ) .eval() .expect("list"); if kinds.iter().any(|k| k == "initialized") { initialized = true; } if !initialized { std::thread::sleep(Duration::from_millis(15)); } } assert!(initialized, "server must reach Initialized"); // Spawn the awaiting coroutine. state .lua_host .lua() .load( " _G._mcp_test_done = false _G._mcp_test_text = nil pmacs.async(function() local result = pmacs.mcp.read_resource(_G._mcp_test_server, 'file:///lua'):await() _G._mcp_test_text = result.contents[1].text _G._mcp_test_done = true end) ", ) .exec() .expect("dispatch awaiting coroutine"); // Pump until the coroutine completes. let stop = Instant::now() + Duration::from_secs(5); let mut done = false; while Instant::now() < stop && !done { state.tick_processes(); state.tick_mcp(); state.tick_async(); done = state .lua_host .lua() .load("return _G._mcp_test_done") .eval::() .unwrap_or(false); if !done { std::thread::sleep(Duration::from_millis(15)); } } assert!(done, "awaiting coroutine must complete"); let text: String = state .lua_host .lua() .load("return _G._mcp_test_text") .eval() .expect("read result text"); assert!( text.contains("file:///lua"), "read_resource result must contain the URI; got {text:?}" ); // Verify cache hit: invalidate via Lua, then read again, the new // text differs (counter incremented). state .lua_host .lua() .load( " _G._mcp_test_done2 = false _G._mcp_test_text2 = nil pmacs.mcp.invalidate_resource(_G._mcp_test_server, 'file:///lua') pmacs.async(function() local result = pmacs.mcp.read_resource(_G._mcp_test_server, 'file:///lua'):await() _G._mcp_test_text2 = result.contents[1].text _G._mcp_test_done2 = true end) ", ) .exec() .expect("invalidate + re-read"); let stop = Instant::now() + Duration::from_secs(5); let mut done2 = false; while Instant::now() < stop && !done2 { state.tick_processes(); state.tick_mcp(); state.tick_async(); done2 = state .lua_host .lua() .load("return _G._mcp_test_done2") .eval::() .unwrap_or(false); if !done2 { std::thread::sleep(Duration::from_millis(15)); } } assert!(done2, "post-invalidation read must complete"); let text2: String = state .lua_host .lua() .load("return _G._mcp_test_text2") .eval() .expect("read result text 2"); assert_ne!( text, text2, "Lua-side invalidate_resource must force refetch" ); let _ = state .lua_host .lua() .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); }