Skip to content

Race condition: mock server match result not recorded before client reads matched/mismatches state #13

Description

@rholshausen

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions