pmacs/tests/m9_1_acceptance.rs

934 lines
34 KiB
Rust

// m9_1_acceptance.rs --- T M9.1 MCP worker variant acceptance.
//! Acceptance tests for T M9.1 (`spec/pmacs-tasks.tex:3891`):
//!
//! 1. MCP server supervised through the same supervisor as LSP.
//! 2. Initial `initialize` handshake completes; the server's
//! declared capabilities are discoverable through the worker.
//! 3. Server crash and restart are handled identically to LSP.
//!
//! Plus the framing-claim cross-check called out during the M9.1
//! design review: the supervisor is protocol-agnostic, which is the
//! load-bearing property behind the "no new dispatch path" claim.
//! `lsp_and_mcp_can_coexist_on_one_supervisor` is the explicit test
//! for that — multiple MCPs is the easy case, LSP+MCP exercises the
//! protocol-agnostic property.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use pmacs::async_runtime::{AsyncRuntime, JobOutcome, JobResult, SharedAsyncRuntime};
use pmacs::lsp::{LspEventKind, LspManager, LspRestartPolicy, LspServerSpec};
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 fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
fn make_test_triple() -> (
SharedProcessSupervisor,
SharedAsyncRuntime,
SharedMcpManager,
) {
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
// Pool of 1 is enough — MCP requests don't dispatch through the
// pool (Pass-2 finding 1 wires response delivery via
// register_external + complete_external_*), so the pool is
// sized for tests that may want to mix MCP with another worker
// dispatch in the same harness.
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)
}
/// Drain MCP events until `pred` is satisfied or the deadline lapses.
/// Mirrors `drain_lsp_until` from the M4 acceptance tests; also
/// drives the async runtime's tick so externally-settled jobs are
/// observable to callers that `take_result` on a `JobId`.
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
sup: &SharedProcessSupervisor,
runtime: &SharedAsyncRuntime,
mgr: &SharedMcpManager,
sid: McpServerId,
deadline: Duration,
pred: F,
) -> Vec<McpEvent> {
let stop = Instant::now() + deadline;
let mut all: Vec<McpEvent> = 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
}
fn fake_spec(label: &str) -> McpServerSpec {
let mut spec = McpServerSpec::new(label, fake_mcp_path());
spec.restart = McpRestartPolicy::Never;
spec
}
// ===========================================================================
// Acceptance bullet 2: initialize handshake + capability discovery
// ===========================================================================
/// The handshake completes and the server's declared capabilities
/// surface through the manager.
#[test]
fn m9_1_initialize_handshake_completes_and_capabilities_discoverable() {
let (sup, runtime, mgr) = make_test_triple();
let sid = mgr.borrow_mut().spawn(fake_spec("init")).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
let caps = evs
.iter()
.find_map(|e| match &e.kind {
McpEventKind::Initialized { capabilities } => Some(capabilities.clone()),
_ => None,
})
.expect("must observe Initialized event");
assert!(caps.is_object(), "capabilities should be a JSON object");
// The fake server advertises resources, tools, and prompts.
assert!(
caps.get("resources").is_some(),
"fake mcp must advertise resources, got {caps}"
);
assert!(
caps.get("tools").is_some(),
"fake mcp must advertise tools, got {caps}"
);
assert!(
caps.get("prompts").is_some(),
"fake mcp must advertise prompts, got {caps}"
);
// State surface mirrors event surface.
let state = mgr.borrow().state(sid).cloned();
assert!(matches!(state, Some(McpClientState::Initialized { .. })));
// Capabilities also reachable via the worker-level getter (M9.1
// acceptance: "discoverable through the worker").
let mgr_caps = mgr
.borrow()
.capabilities(sid)
.cloned()
.expect("capabilities must be discoverable");
assert_eq!(
mgr_caps, caps,
"manager capabilities must match the Initialized event's"
);
// Protocol version was echoed back. Pass-2 finding 3: pmacs
// sends 2025-11-25 (the latest revision) and the fake server
// echoes whatever the client sent.
assert_eq!(mgr.borrow().protocol_version(sid), Some("2025-11-25"));
let _ = mgr.borrow_mut().stop(sid);
}
#[test]
fn m9_1_server_info_surfaces_after_initialize() {
let (sup, runtime, mgr) = make_test_triple();
let sid = mgr.borrow_mut().spawn(fake_spec("info")).expect("spawn");
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
let info = mgr
.borrow()
.server_info(sid)
.cloned()
.expect("serverInfo must be reported");
assert_eq!(
info.get("name").and_then(|v| v.as_str()),
Some("pmacs-fake-mcp")
);
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Acceptance bullet 3: crash + restart parity with LSP
// ===========================================================================
/// `OnCrash` policy respawns after the fake server's `exit 7`. The
/// observable signal is two `Started` events bracketing a `Crashed`
/// + `Restarting` — exactly the LSP test's shape.
#[test]
fn m9_1_server_crash_auto_restarts() {
let (sup, runtime, mgr) = make_test_triple();
mgr.borrow_mut()
.set_restart_backoff(Duration::from_millis(50));
let mut spec = fake_spec("crasher");
spec.restart = McpRestartPolicy::OnCrash;
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| {
evs.iter()
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
.count()
>= 2
});
let started_count = evs
.iter()
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
.count();
assert!(
started_count >= 2,
"OnCrash policy should respawn after the fake MCP exits non-zero; saw Started count {started_count}"
);
assert!(
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Crashed { .. })),
"must observe Crashed event after the fake MCP's exit 7"
);
assert!(
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Restarting { .. })),
"must observe Restarting event when policy is OnCrash"
);
assert!(
mgr.borrow().attempt(sid).unwrap_or(0) >= 2,
"attempt count should be >= 2 after at least one restart"
);
let _ = mgr.borrow_mut().stop(sid);
}
/// After a crash + restart, the new generation re-runs `initialize`.
/// Two `Initialized` events prove the handshake replays, which is
/// the LSP-parity claim in concrete form.
#[test]
fn m9_1_restart_reruns_initialize_handshake() {
let (sup, runtime, mgr) = make_test_triple();
mgr.borrow_mut()
.set_restart_backoff(Duration::from_millis(50));
let mut spec = fake_spec("re-init");
spec.restart = McpRestartPolicy::OnCrash;
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| {
evs.iter()
.filter(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
.count()
>= 2
});
let init_count = evs
.iter()
.filter(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
.count();
assert!(
init_count >= 2,
"must see Initialized for the original generation and the restart; saw {init_count}"
);
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Acceptance bullet 1: same supervisor as LSP
// (multi-server coexistence; LSP+MCP cross-protocol)
// ===========================================================================
/// Two MCP servers spawned simultaneously through the same manager
/// reach `Initialized` independently. This is the easy case — both
/// peers speak the same wire format.
#[test]
fn m9_1_multiple_mcps_coexist_on_one_manager() {
let (sup, runtime, mgr) = make_test_triple();
let a = mgr.borrow_mut().spawn(fake_spec("a")).expect("spawn a");
let b = mgr.borrow_mut().spawn(fake_spec("b")).expect("spawn b");
drain_mcp_until(&sup, &runtime, &mgr, a, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
drain_mcp_until(&sup, &runtime, &mgr, b, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
assert!(matches!(
mgr.borrow().state(a),
Some(McpClientState::Initialized { .. })
));
assert!(matches!(
mgr.borrow().state(b),
Some(McpClientState::Initialized { .. })
));
let _ = mgr.borrow_mut().stop(a);
let _ = mgr.borrow_mut().stop(b);
}
/// **The protocol-uniformity test.** An LSP server and an MCP server
/// share one [`ProcessSupervisor`]. Both reach `Initialized`. Neither
/// observes events from the other's process. If this test passes
/// without special wiring, the supervisor is genuinely
/// protocol-agnostic and the spec's "no new dispatch path" claim
/// holds. A failure would be a real M9.1 finding worth surfacing.
#[test]
fn m9_1_lsp_and_mcp_can_coexist_on_one_supervisor() {
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
let lsp_mgr = Rc::new(RefCell::new(LspManager::new(sup.clone(), runtime.clone())));
let mcp_mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
// Spin up one LSP and one MCP through the same supervisor.
let mut lsp_spec = LspServerSpec::new("co-lsp", "rust", fake_lsp_path());
lsp_spec.restart = LspRestartPolicy::Never;
let lsp_sid = lsp_mgr.borrow_mut().spawn(lsp_spec).expect("spawn lsp");
let mcp_sid = mcp_mgr
.borrow_mut()
.spawn(fake_spec("co-mcp"))
.expect("spawn mcp");
// Drive both for up to 5s, each ticking through the shared
// supervisor. The deadline is shared; we exit early once both
// have initialized.
let stop = Instant::now() + Duration::from_secs(5);
let mut lsp_evs: Vec<LspEventKind> = Vec::new();
let mut mcp_evs: Vec<McpEventKind> = Vec::new();
while Instant::now() < stop {
sup.borrow_mut().tick();
lsp_mgr.borrow_mut().tick();
mcp_mgr.borrow_mut().tick();
let next_lsp: Vec<_> = lsp_mgr
.borrow_mut()
.take_events(lsp_sid)
.into_iter()
.map(|e| e.kind)
.collect();
let next_mcp: Vec<_> = mcp_mgr
.borrow_mut()
.take_events(mcp_sid)
.into_iter()
.map(|e| e.kind)
.collect();
lsp_evs.extend(next_lsp);
mcp_evs.extend(next_mcp);
let lsp_init = lsp_evs
.iter()
.any(|k| matches!(k, LspEventKind::Initialized { .. }));
let mcp_init = mcp_evs
.iter()
.any(|k| matches!(k, McpEventKind::Initialized { .. }));
if lsp_init && mcp_init {
break;
}
std::thread::sleep(Duration::from_millis(15));
}
assert!(
lsp_evs
.iter()
.any(|k| matches!(k, LspEventKind::Initialized { .. })),
"LSP server must complete its handshake when sharing a supervisor with MCP; events: {lsp_evs:?}"
);
assert!(
mcp_evs
.iter()
.any(|k| matches!(k, McpEventKind::Initialized { .. })),
"MCP server must complete its handshake when sharing a supervisor with LSP; events: {mcp_evs:?}"
);
// Cross-leakage check: ProtocolError on either side would mean
// bytes destined for one peer landed in the other's parser.
assert!(
!lsp_evs
.iter()
.any(|k| matches!(k, LspEventKind::ProtocolError { .. })),
"LSP saw ProtocolError under coexistence; cross-leak suspected"
);
assert!(
!mcp_evs
.iter()
.any(|k| matches!(k, McpEventKind::ProtocolError { .. })),
"MCP saw ProtocolError under coexistence; cross-leak suspected"
);
let _ = lsp_mgr.borrow_mut().stop(lsp_sid);
let _ = mcp_mgr.borrow_mut().stop(mcp_sid);
}
// ===========================================================================
// Auxiliary: send_request gating, forget, protocol violation
// ===========================================================================
/// `send_request` before the server is `Initialized` errors loudly
/// rather than silently dropping the bytes.
#[test]
fn m9_1_send_request_before_initialized_errors() {
let (_sup, _runtime, mgr) = make_test_triple();
let sid = mgr.borrow_mut().spawn(fake_spec("early")).expect("spawn");
// Don't drain — server is in Starting/Initializing state.
let res = mgr
.borrow_mut()
.send_request(sid, "ping", serde_json::Value::Null);
assert!(
res.is_err(),
"send_request before Initialized must error; got {res:?}"
);
let _ = mgr.borrow_mut().stop(sid);
}
/// `ping` round-trips through the async runtime. Pass-2 finding 1:
/// `send_request` returns a `JobId`; the response settles the
/// runtime's pending entry rather than landing as a poll-style event
/// the caller has to scan for. `Response` events still fire for
/// observers, but the canonical settle path is the async runtime.
#[test]
fn m9_1_ping_request_settles_runtime_job() {
let (sup, runtime, mgr) = make_test_triple();
let sid = mgr.borrow_mut().spawn(fake_spec("ping")).expect("spawn");
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
let job_id = mgr
.borrow_mut()
.send_request(sid, "ping", serde_json::Value::Null)
.expect("send_request");
// Drive the manager + runtime until the job settles. We can't
// use drain_mcp_until's predicate over events because the
// canonical settle is on the runtime side, not in the
// McpManager event queue.
let stop = Instant::now() + Duration::from_secs(5);
while Instant::now() < stop && !runtime.is_complete(job_id) {
sup.borrow_mut().tick();
mgr.borrow_mut().tick();
runtime.tick();
std::thread::sleep(Duration::from_millis(15));
}
assert!(
runtime.is_complete(job_id),
"ping job must settle within deadline"
);
let outcome = runtime.take_result(job_id).expect("take_result");
let value = match outcome {
JobOutcome::Complete(JobResult::Json(v)) => v,
other => panic!("expected Complete(Json(...)), got {other:?}"),
};
assert!(
value.is_object(),
"fake mcp returns `result: {{}}` for ping; got {value}"
);
let _ = mgr.borrow_mut().stop(sid);
}
/// A garbage line on the wire surfaces as a `ProtocolError` event.
/// Mirrors the LSP protocol-violation acceptance test.
#[test]
fn m9_1_protocol_violation_surfaces_as_structured_error() {
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("garbager");
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "garbage".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter().any(|e| {
matches!(
e.kind,
McpEventKind::ProtocolError { .. }
| McpEventKind::Crashed { .. }
| McpEventKind::Stopped
)
})
});
assert!(
evs.iter()
.any(|e| matches!(&e.kind, McpEventKind::ProtocolError { .. })),
"non-JSON line should surface as a ProtocolError; got events: {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
let _ = mgr.borrow_mut().stop(sid);
}
/// `forget` removes a terminal-state server from the registry; a
/// non-terminal server can't be forgotten.
#[test]
fn m9_1_forget_only_terminal_servers() {
let (sup, runtime, mgr) = make_test_triple();
let sid = mgr.borrow_mut().spawn(fake_spec("forget")).expect("spawn");
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
// Forgetting an Initialized server must error.
assert!(
mgr.borrow_mut().forget(sid).is_err(),
"forget should reject a non-terminal server"
);
// After stop, the server transitions through ShuttingDown to
// Stopped; once Stopped, forget succeeds.
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 { .. })
)
});
assert!(
mgr.borrow_mut().forget(sid).is_ok(),
"forget should succeed once the server is in a terminal state"
);
assert!(
mgr.borrow().state(sid).is_none(),
"forgotten server's state must be None"
);
}
// ===========================================================================
// Pass-2 findings 2 / 3 / 5: lifecycle correctness
// ===========================================================================
/// Pass-2 finding 2: a JSON-RPC error response to `initialize` must
/// **not** transition the client to `Initialized`. The fake server's
/// `init_error` mode replies with an error code; pmacs should
/// surface a `ProtocolError` event and terminate the process rather
/// than masquerading as a healthy server.
#[test]
fn m9_1_initialize_error_does_not_transition_to_initialized() {
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("init_error");
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "init_error".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
});
assert!(
evs.iter()
.any(|e| matches!(&e.kind, McpEventKind::ProtocolError { message } if message.contains("refused initialize"))),
"must observe ProtocolError citing the initialize refusal; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
assert!(
!evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. })),
"Initialized must not fire when initialize returned error"
);
// The state never transitions to Initialized; capabilities()
// returns None.
assert!(
!matches!(
mgr.borrow().state(sid),
Some(McpClientState::Initialized { .. })
),
"client state must not be Initialized after error response"
);
assert!(mgr.borrow().capabilities(sid).is_none());
let _ = mgr.borrow_mut().stop(sid);
}
/// Pass-2 finding 3: an unsupported `protocolVersion` in the
/// `initialize` response must terminate the connection rather than
/// silently accepting it. The fake's `bad_version` mode reports
/// `1999-01-01`, which is not in pmacs's supported set.
#[test]
fn m9_1_unsupported_protocol_version_is_rejected() {
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("bad_version");
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "bad_version".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
});
assert!(
evs.iter().any(|e| matches!(
&e.kind,
McpEventKind::ProtocolError { message }
if message.contains("unsupported protocolVersion")
)),
"must observe ProtocolError citing the unsupported protocolVersion; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
assert!(
!matches!(
mgr.borrow().state(sid),
Some(McpClientState::Initialized { .. })
),
"client must not reach Initialized for an unsupported protocolVersion"
);
let _ = mgr.borrow_mut().stop(sid);
}
/// Pass-3 finding 1: MCP stdio shutdown is stdin-EOF + live SIGTERM /
/// SIGKILL fallback. The client must
/// **not** send protocol-level `shutdown` requests or `exit`
/// notifications. The fake's `crash_on_protocol_shutdown` mode
/// exits with code 99 if it ever sees those methods on the wire,
/// so a Stopped event (clean exit code 0) proves pmacs took the
/// EOF path.
#[test]
fn m9_1_stop_uses_stdio_eof_not_protocol_shutdown() {
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("polite-stop");
spec.env = vec![(
"PMACS_FAKE_MCP_MODE".into(),
"crash_on_protocol_shutdown".into(),
)];
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 { .. }))
});
let _ = mgr.borrow_mut().stop(sid);
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
matches!(
mgr.borrow().state(sid),
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
)
});
let final_state = mgr.borrow().state(sid).cloned();
assert!(
matches!(final_state, Some(McpClientState::Stopped { .. })),
"stop() must transition to Stopped via stdin EOF (clean exit), not Crashed; got {:?}; events: {:?}",
final_state,
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
assert!(
!evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Crashed { .. })),
"no Crashed event — server must exit cleanly on stdin EOF; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
}
/// Pass-4 finding 1: EOF is the compliant first shutdown signal, but
/// a server that ignores EOF must still be stopped while the manager
/// is live. This fixture sleeps forever after stdin closes; the
/// shortened grace window lets the manager send SIGTERM and observe a
/// terminal Stopped state.
#[test]
fn m9_1_stop_escalates_when_server_ignores_stdin_eof() {
let (sup, runtime, mgr) = make_test_triple();
mgr.borrow_mut()
.set_shutdown_grace(Duration::from_millis(25));
let mut spec = fake_spec("ignore-eof");
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "ignore_eof_sleep".into())];
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 { .. }))
});
let _ = mgr.borrow_mut().stop(sid);
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
matches!(
mgr.borrow().state(sid),
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
)
});
let final_state = mgr.borrow().state(sid).cloned();
assert!(
matches!(final_state, Some(McpClientState::Stopped { .. })),
"stop() must keep escalating after stdin EOF until the process exits; got {:?}; events: {:?}",
final_state,
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
}
/// Pass-3 finding 2: an `initialize` response missing the required
/// `capabilities` field is a protocol violation. pmacs must surface
/// `ProtocolError` and refuse to transition to `Initialized`,
/// rather than defaulting capabilities to `Null` and proceeding.
#[test]
fn m9_1_missing_capabilities_in_initialize_is_rejected() {
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("missing_caps");
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "missing_caps".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
});
assert!(
evs.iter().any(|e| matches!(
&e.kind,
McpEventKind::ProtocolError { message }
if message.contains("missing required capabilities")
)),
"must observe ProtocolError citing missing capabilities; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
assert!(
!evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. })),
"Initialized must not fire when capabilities is missing"
);
assert!(
!matches!(
mgr.borrow().state(sid),
Some(McpClientState::Initialized { .. })
),
"client state must not be Initialized after a malformed result"
);
assert!(mgr.borrow().capabilities(sid).is_none());
let _ = mgr.borrow_mut().stop(sid);
}
/// Pass-2 finding 5: `OnCrash` policy must not restart on a clean
/// exit (code 0). The fake's `clean_exit_after_init` mode replies
/// to initialize then exits 0 without us asking. The manager
/// should observe `Stopped`, not `Crashed`/`Restarting`.
#[test]
fn m9_1_oncrash_policy_does_not_restart_on_clean_exit() {
let (sup, runtime, mgr) = make_test_triple();
mgr.borrow_mut()
.set_restart_backoff(Duration::from_millis(50));
let mut spec = fake_spec("clean_exit");
spec.restart = McpRestartPolicy::OnCrash;
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "clean_exit_after_init".into())];
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
// Drain for a while to give a buggy implementation room to
// restart; the assertion is about what didn't happen.
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(2), |evs| {
evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped))
});
assert!(
evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped)),
"clean exit + OnCrash must surface as Stopped, not Crashed/Restarting; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
assert!(
!evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Restarting { .. })),
"OnCrash must not respawn on clean exit; got {:?}",
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
);
let started_count = evs
.iter()
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
.count();
assert_eq!(
started_count, 1,
"exactly one Started event for a single clean-exit generation; got {started_count}"
);
}
// ===========================================================================
// Lua surface
// ===========================================================================
/// `pmacs.mcp.spawn` / `_tick` / `events_take` / `capabilities` agree
/// with the Rust-level view. Smoke test of the boundary marshalling.
#[test]
fn m9_1_lua_surface_drives_mcp_lifecycle() {
use pmacs::editor::EditorState;
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_mcp_path();
let lua = state.lua_host.lua();
let sid_raw: u64 = lua
.load(format!(
"
local id = pmacs.mcp.spawn({{
label = 'lua-fake',
command = '{fake}',
restart = 'never',
}})
return id:raw()
",
))
.eval()
.expect("spawn via Lua");
assert!(sid_raw > 0);
// 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();
let kinds: Vec<String> = 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, "lua-spawned MCP must reach Initialized");
// capabilities() should return a non-nil table.
let has_caps: bool = state
.lua_host
.lua()
.load(
"
local rows = pmacs.mcp.list()
assert(#rows == 1, 'expected one server')
local caps = pmacs.mcp.capabilities(rows[1].id)
return caps ~= nil and type(caps) == 'table' and caps.resources ~= nil
",
)
.eval()
.expect("capabilities");
assert!(
has_caps,
"pmacs.mcp.capabilities should return the server's caps table"
);
// Stop and let it drain.
let _ = state
.lua_host
.lua()
.load(
"
for _, row in ipairs(pmacs.mcp.list()) do
pmacs.mcp.stop(row.id)
end
",
)
.exec();
}
/// Pass-2 finding 1 acceptance: `pmacs.mcp.send_request` returns a
/// Handle, and awaiting that handle inside `pmacs.async(...)`
/// resumes with the response's `result` table — same dispatch shape
/// as `pmacs.workers.compute_sum`, `pmacs.fs.read_dir`, etc. This is
/// the shape M9.2/M9.3 will build on; verifying it here means the
/// follow-up tasks don't need to introduce a separate async layer.
#[test]
fn m9_1_lua_send_request_returns_awaitable_handle() {
use pmacs::editor::EditorState;
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_mcp_path();
let lua = state.lua_host.lua();
// Spawn the server and stash its id in a global the next
// `pmacs.async` block reads.
lua.load(format!(
"
_G._mcp_test_server = pmacs.mcp.spawn({{
label = 'lua-await',
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<String> = 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. The result lands in a global
// when the handle settles, which we observe from Rust below.
state
.lua_host
.lua()
.load(
"
_G._mcp_test_done = false
_G._mcp_test_result = nil
pmacs.async(function()
local result = pmacs.mcp.send_request(_G._mcp_test_server, 'ping', {}):await()
_G._mcp_test_result = result
_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::<bool>()
.unwrap_or(false);
if !done {
std::thread::sleep(Duration::from_millis(15));
}
}
assert!(
done,
"awaiting coroutine must complete (Handle.await() didn't resume)"
);
// The fake's `ping` reply is `result: {}`. The Lua side received
// an empty table, which we verify by checking the type.
let result_type: String = state
.lua_host
.lua()
.load("return type(_G._mcp_test_result)")
.eval()
.expect("read result type");
assert_eq!(
result_type, "table",
"ping response should marshal to a Lua table; got {result_type}"
);
// Stop the server.
let _ = state
.lua_host
.lua()
.load("pmacs.mcp.stop(_G._mcp_test_server)")
.exec();
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;