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:
parent
fd7d1cd2a5
commit
b103a9aa64
4 changed files with 56 additions and 44 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -3425,6 +3425,7 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"which",
|
"which",
|
||||||
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue