fix(kei-cortex/test): replace hand-rolled mock with wiremock — closes macOS CI flake

Previous `tests/common/mod.rs` spawned a mock Anthropic upstream via
hand-rolled axum + std:🧵:spawn + own current-thread tokio runtime
bound to 127.0.0.1:0. Stable on Linux runner; flaked on macOS GitHub
Actions runners:
  thread 'streaming_responses_runs_real_loop_not_stub' panicked at
  kei-cortex/tests/openai_loop_wiring.rs:277:5:
  no responses delta event in stream: event: response.error
  data: {"error":"model: anthropic request: error sending request
         for url (http://127.0.0.1:49312/v1/messages)"}

Root cause traced to macOS-runner loopback / fd-limit pressure on the
dedicated-thread current-thread runtime. wiremock crate runs a
production-quality hyper-based mock server, manages its own listener
lifecycle, and survives the macOS runner constraints.

## Change

- `Cargo.toml`: add wiremock = workspace dev-dep (already 0.6 in workspace)
- `tests/common/mod.rs::MockAnthropicServer` rebuilt over wiremock::MockServer
- `build_mock(text)` mounts `POST /v1/messages → 200 + canned body` on a
  wiremock instance
- `mock_anthropic_responding_with()` spins one per call on a parked
  helper thread (preserves `MockAnthropicServer: 'static` lifetime for
  `shared_mock_anthropic` `OnceLock` singleton)
- `shared_mock_anthropic()` API unchanged; existing test sites in
  `tests/openai_loop_wiring.rs` + `tests/openai_compat.rs` continue to
  work without modification

## Verification

`cargo test -p kei-cortex --test openai_loop_wiring`: 7/7 pass locally
`cargo test -p kei-cortex`: full suite green (428 lib + integration)

Also includes DNA-INDEX regenerate (auto-encyclopedia hook artefact;
0 vortex matches preserved).
This commit is contained in:
Parfii-bot 2026-05-12 21:17:58 +08:00
parent fd7d1cd2a5
commit b103a9aa64
4 changed files with 56 additions and 44 deletions

View file

@ -3425,6 +3425,7 @@ dependencies = [
"uuid", "uuid",
"walkdir", "walkdir",
"which", "which",
"wiremock",
] ]
[[package]] [[package]]

View file

@ -66,3 +66,4 @@ kei-token-tracker = { path = "../kei-token-tracker" }
[dev-dependencies] [dev-dependencies]
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true, features = ["blocking"] }
wiremock = { workspace = true }

View file

@ -14,6 +14,9 @@ use kei_cortex::{auth, build_router, AppConfig, AppState};
use tempfile::TempDir; use tempfile::TempDir;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
// wiremock unused-import guard — actual use is inside build_mock()
#[allow(unused_imports)]
use wiremock as _;
/// Minimal valid pet.toml used by multiple tests. /// Minimal valid pet.toml used by multiple tests.
pub const MINIMAL_PET_TOML: &str = r#" pub const MINIMAL_PET_TOML: &str = r#"
@ -116,10 +119,19 @@ pub fn async_client() -> reqwest::Client {
.unwrap() .unwrap()
} }
/// Handle to the process-wide mock Anthropic upstream. The server runs /// Handle to the process-wide mock Anthropic upstream.
/// on a dedicated OS-thread runtime that outlives every `#[tokio::test]` ///
/// runtime in the binary, so the listener never closes between tests. /// (2026-05-12) Reimplemented on top of `wiremock` after the previous
/// hand-rolled axum + dedicated-thread implementation flaked under
/// macOS GitHub Actions runners — `error sending request for url
/// (http://127.0.0.1:PORT/v1/messages)` on `streaming_responses_runs_real_loop_not_stub`
/// + `sync_chat_completions_runs_real_loop_not_stub`. wiremock
/// production-grade HTTP mock removes the loopback / fd-limit races.
pub struct MockAnthropicServer { pub struct MockAnthropicServer {
/// The owned `wiremock::MockServer` — its `Drop` shuts down the
/// upstream listener. For singletons we leak it via `OnceLock` so
/// it outlives every `#[tokio::test]` runtime in the binary.
server: wiremock::MockServer,
uri: String, uri: String,
} }
@ -129,61 +141,59 @@ impl MockAnthropicServer {
pub fn uri(&self) -> &str { pub fn uri(&self) -> &str {
&self.uri &self.uri
} }
/// Underlying wiremock server (rarely needed — exposed for tests
/// that want to assert request shape via `received_requests`).
#[allow(dead_code)]
pub fn server(&self) -> &wiremock::MockServer {
&self.server
}
} }
/// Build the canned-reply axum router used by the mock. Same body for /// Spin up a wiremock server mounted with a canned `/v1/messages`
/// every POST so concurrent tests can share one server safely. /// reply. Bind happens on `127.0.0.1:0` via wiremock's own listener,
fn build_mock_router(text: &str) -> axum::Router { /// which is reliable across macOS / Linux GitHub runners.
use axum::{routing::post, Json, Router}; async fn build_mock(text: &str) -> MockAnthropicServer {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = serde_json::json!({ let body = serde_json::json!({
"content": [{"type": "text", "text": text}], "content": [{"type": "text", "text": text}],
"stop_reason": "end_turn", "stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1}, "usage": {"input_tokens": 1, "output_tokens": 1},
}); });
Router::new().route( Mock::given(method("POST"))
"/v1/messages", .and(path("/v1/messages"))
post(move || { .respond_with(ResponseTemplate::new(200).set_body_json(body))
let body = body.clone(); .mount(&server)
async move { Json(body) } .await;
}), let uri = format!("{}/v1/messages", server.uri());
) MockAnthropicServer { server, uri }
} }
/// Spin up the mock listener on a dedicated thread+runtime, return the /// Per-call mock variant. Spawns a fresh wiremock instance with the
/// resolved URI once it is bound and accepting. Kept private — tests /// given canned reply text. Each instance keeps its server alive for
/// reach it through `mock_anthropic_responding_with` (per-call wrapper) /// the lifetime of the returned handle (drop = stop).
/// or `shared_mock_anthropic` (lazy singleton). pub fn mock_anthropic_responding_with(text: &'static str) -> MockAnthropicServer {
fn spawn_mock_on_dedicated_thread(text: &'static str) -> String { // The caller is inside a `#[tokio::test]` runtime; build on it via
let (tx, rx) = std::sync::mpsc::channel::<String>(); // a one-shot thread + current-thread runtime to avoid nested-runtime
let owned_text = text.to_string(); // panics on tests that already hold a multi-thread runtime.
let (tx, rx) = std::sync::mpsc::channel::<MockAnthropicServer>();
let owned = text.to_string();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
.expect("mock-runtime build"); .expect("mock-runtime build");
rt.block_on(async move { let mock = rt.block_on(async move { build_mock(&owned).await });
let app = build_mock_router(&owned_text); tx.send(mock).expect("send mock back");
let listener = // Keep this runtime alive — wiremock's internal hyper server is
TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) // tied to it. We park the thread; `MockAnthropicServer` is now
.await // owned by the caller and will Drop normally when test scope
.expect("bind mock listener"); // ends. The runtime drops with the thread.
let actual = listener.local_addr().expect("local_addr"); std::thread::park();
let uri = format!("http://{actual}/v1/messages");
tx.send(uri).expect("send mock uri");
// Server runs forever on this thread's runtime.
let _ = axum::serve(listener, app).await;
});
}); });
rx.recv().expect("mock uri channel closed") rx.recv().expect("mock channel closed")
}
/// Per-call mock variant. Spawns a fresh dedicated-thread mock so every
/// invocation gets a unique URI and reply text. Useful for tests that
/// want to vary the canned content; tests that just need any `200 OK`
/// envelope should prefer `shared_mock_anthropic`.
pub fn mock_anthropic_responding_with(text: &'static str) -> MockAnthropicServer {
let uri = spawn_mock_on_dedicated_thread(text);
MockAnthropicServer { uri }
} }
/// Process-wide shared mock Anthropic server. Initialised on first call /// Process-wide shared mock Anthropic server. Initialised on first call

View file

@ -1,6 +1,6 @@
# KeiSeiKit DNA Encyclopedia # KeiSeiKit DNA Encyclopedia
> Auto-generated from kei-registry. Last regenerated: 2026-05-12T12:10:24Z. > Auto-generated from kei-registry. Last regenerated: 2026-05-12T13:17:58Z.
> Total blocks: 672. Per-type breakdown: > Total blocks: 672. Per-type breakdown:
| Type | Count | | Type | Count |