Skip to content

Intermittent 500 errors: Object pool proxy releases worker before streamed response finishes #3492

@chubes4

Description

@chubes4

Summary

The createObjectPoolProxy releases a worker back to the pool as soon as requestStreamed() resolves its promise (when the response starts streaming), but the worker's PHP instance isn't freed until response.finished fires (when streaming finishes). This creates a window where a "free" worker still has its SinglePHPInstanceManager instance acquired, causing the next request routed to it to throw "The PHP instance already acquired" → HTTP 500.

Impact

This affects any downstream consumer using @wp-playground/cli to serve HTTP requests — most notably WordPress Studio, where users see intermittent "Internal Server Error" pages during normal browsing. Under modest concurrency, the server can become completely unresponsive.

Root Cause

Mismatched lifecycles between the pool proxy and the PHP instance manager.

In createObjectPoolProxy (object-pool-proxy.ts), withInstance releases the worker when the wrapped function's promise resolves:

return (result as Promise<R>).then((val) => {
    release(instance);  // ← Worker released back to pool
    return val;
});

In run-cli.ts, the HTTP handler calls:

const response = await playgroundPool.requestStreamed(request);

This means the pool proxy releases the worker when requestStreamed() resolves — which is when the StreamedPHPResponse object is created and streaming begins.

But in php-request-handler.ts, the PHP instance is held until the stream finishes:

response.finished.finally(() => {
    spawnedPHP?.reap();  // ← PHP instance freed when stream FINISHES
});

return response;  // ← Promise resolves IMMEDIATELY (stream just started)

The gap between these two release points is the bug. During that window:

  1. Pool proxy thinks the worker is free → routes a new request to it
  2. SinglePHPInstanceManager.acquirePHPInstance() finds isAcquired === true → throws
  3. The catch block returns StreamedPHPResponse.forHttpCode(500)

Reproduction

Environment

  • macOS with WordPress Studio v1.7.8
  • @wp-playground/cli v3.1.18
  • Any Studio site running (e.g. http://localhost:8881)

Steps

Guaranteed failure — 10 concurrent requests:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " 'http://localhost:8881/' &
done
wait

Expected: all 200. Actual: ~40-60% return 500.

Threshold test — 7 concurrent:

for i in $(seq 1 7); do
  curl -s -o /dev/null -w "%{http_code} " 'http://localhost:8881/' &
done
wait

Expected: all 200. Actual: 1+ requests consistently return 500.

Even 3 concurrent can fail (~20% of runs):

for i in $(seq 1 3); do
  curl -s -o /dev/null -w "%{http_code} " 'http://localhost:8881/' &
done
wait

Sequential baseline (always passes):

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " 'http://localhost:8881/'
done

Always returns all 200s.

Real-world trigger

A single WordPress admin page load fires 5-10+ concurrent HTTP requests (HTML, CSS via load-styles.php, JS via load-scripts.php, REST API prefetches, heartbeat AJAX). This is enough to trigger the bug. Rapid refreshing (F5) makes it near-certain by overlapping requests from the old and new page loads.

Under sustained concurrent load (e.g. 10+ parallel requests in a loop), the server process can crash entirely and stop responding on the port.

Suggested Fix Direction

The pool proxy should not release a worker until the streamed response is fully consumed. Options:

  1. In run-cli.ts handleRequest: Don't return until response.finished resolves, so the pool proxy holds the worker for the full response lifecycle.

  2. In createObjectPoolProxy: Support a "deferred release" pattern where the wrapped function can signal when the worker should actually be released (rather than on promise resolution).

  3. In PHPRequestHandler: Have requestStreamed not resolve its promise until response.finished, though this would change the streaming semantics.

Option 1 seems simplest and most localized.

Related

  • createObjectPoolProxy in packages/php-wasm/universal/src/lib/object-pool-proxy.ts
  • #spawnPHPAndDispatchRequest in packages/php-wasm/universal/src/lib/php-request-handler.ts
  • SinglePHPInstanceManager in packages/php-wasm/universal/src/lib/single-php-instance-manager.ts
  • Server setup in packages/playground/cli/src/run-cli.ts (lines ~1619-1667)
  • Follow-up items for Playground CLI multi-worker support #3289 (follow-up items for multi-worker support)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions