Summary
There is a race condition in the async event channel architecture introduced in commit 219bc18a ("refactor: Initial implementation using channels instead of mutable state"). Under CPU pressure, pactffi_mock_server_matched() / pactffi_mock_server_mismatches() can return incorrect results (reporting the request was never received) even though the mock server correctly processed the request and returned a matching response.
This is the root cause of the intermittent failures reported in:
Root Cause
In hyper_server.rs, handle_request() sends the RequestMatch event to a buffered async MPSC channel (capacity 256) before returning the HTTP response:
// Step 1: puts result in channel buffer — completes immediately, does NOT wait for processing
event_send.send(MockServerEvent::RequestMatch(match_result.clone())).await;
// Step 2: HTTP response delivered to client
match_result_to_hyper_response(&pact_request, &match_result, local_addr, config).await
The event loop (a separate tokio::spawn task in mock_server.rs) processes the RequestMatch event asynchronously:
MockServerEvent::RequestMatch(result) => {
let mut guard = matches.lock().unwrap();
guard.push(result.clone());
}
The race window is:
Tokio task A (request handler):
1. send(RequestMatch) → channel buffer [immediate]
2. return HTTP response to client [client receives response]
Tokio task B (event loop):
3. receive RequestMatch from channel
4. push to matches mutex
Client code (JS/FFI):
5. call pactffi_mock_server_matched() ← may run BEFORE steps 3 & 4
Under normal load the Tokio scheduler runs task B quickly enough that step 5 sees the result. Under parallel CPU pressure, task B is delayed and step 5 sees an empty matches vec — reporting "request not received" even though the response was 200 OK.
Why It Appeared in pact-core 19.1.0 / pact-js 16.3.0
This architecture was introduced in pact_mock_server ~2.0.0. Before that, match results were stored synchronously in the request handler — there was no race.
The codebase already recognised a similar timing gap at shutdown: commits 47559ee1 ("Add 100ms delay to give the mock server event loop time to complete on shutdown") and 1b377dd3 ("Use a channel to signal when the mock server task is done") added synchronisation for the teardown path. The pre-shutdown matched()/mismatches() query path was not covered.
Workaround
Adding a short sleep between the HTTP call and the state query works around the issue by yielding the thread so the Tokio event loop can drain its queue. This is demonstrated in pact_ffi/tests/tests.rs line 124 in pact-reference:
sleep(Duration::from_millis(100));
let mismatches = unsafe { ... pactffi_mock_server_mismatches(port) ... };
Proper Fix
Update matches synchronously in handle_request() by passing Arc<Mutex<Vec<MatchResult>>> directly to the handler, and remove the RequestMatch event variant from the channel. The event channel then only needs to carry RequestReceived (for metrics) and ServerShutdown.
// In handle_request(), replace:
event_send.send(MockServerEvent::RequestMatch(match_result.clone())).await;
// With direct synchronous update:
{
let mut guard = matches.lock().unwrap();
guard.push(match_result.clone());
}
By the time the HTTP response bytes reach the client, the match is already committed to the mutex. Any subsequent call to mismatches() or all_matched() will see the correct state regardless of CPU load or Tokio scheduling delays.
Summary
There is a race condition in the async event channel architecture introduced in commit
219bc18a("refactor: Initial implementation using channels instead of mutable state"). Under CPU pressure,pactffi_mock_server_matched()/pactffi_mock_server_mismatches()can return incorrect results (reporting the request was never received) even though the mock server correctly processed the request and returned a matching response.This is the root cause of the intermittent failures reported in:
Root Cause
In
hyper_server.rs,handle_request()sends theRequestMatchevent to a buffered async MPSC channel (capacity 256) before returning the HTTP response:The event loop (a separate
tokio::spawntask inmock_server.rs) processes theRequestMatchevent asynchronously:The race window is:
Under normal load the Tokio scheduler runs task B quickly enough that step 5 sees the result. Under parallel CPU pressure, task B is delayed and step 5 sees an empty
matchesvec — reporting "request not received" even though the response was 200 OK.Why It Appeared in pact-core 19.1.0 / pact-js 16.3.0
This architecture was introduced in pact_mock_server ~2.0.0. Before that, match results were stored synchronously in the request handler — there was no race.
The codebase already recognised a similar timing gap at shutdown: commits
47559ee1("Add 100ms delay to give the mock server event loop time to complete on shutdown") and1b377dd3("Use a channel to signal when the mock server task is done") added synchronisation for the teardown path. The pre-shutdownmatched()/mismatches()query path was not covered.Workaround
Adding a short sleep between the HTTP call and the state query works around the issue by yielding the thread so the Tokio event loop can drain its queue. This is demonstrated in
pact_ffi/tests/tests.rsline 124 in pact-reference:Proper Fix
Update
matchessynchronously inhandle_request()by passingArc<Mutex<Vec<MatchResult>>>directly to the handler, and remove theRequestMatchevent variant from the channel. The event channel then only needs to carryRequestReceived(for metrics) andServerShutdown.By the time the HTTP response bytes reach the client, the match is already committed to the mutex. Any subsequent call to
mismatches()orall_matched()will see the correct state regardless of CPU load or Tokio scheduling delays.