You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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(resultasPromise<R>).then((val)=>{release(instance);// ← Worker released back to poolreturnval;});
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});returnresponse;// ← Promise resolves IMMEDIATELY (stream just started)
The gap between these two release points is the bug. During that window:
Pool proxy thinks the worker is free → routes a new request to it
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)
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:
In run-cli.tshandleRequest: Don't return until response.finished resolves, so the pool proxy holds the worker for the full response lifecycle.
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).
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)
Summary
The
createObjectPoolProxyreleases a worker back to the pool as soon asrequestStreamed()resolves its promise (when the response starts streaming), but the worker's PHP instance isn't freed untilresponse.finishedfires (when streaming finishes). This creates a window where a "free" worker still has itsSinglePHPInstanceManagerinstance 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/clito 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),withInstancereleases the worker when the wrapped function's promise resolves:In
run-cli.ts, the HTTP handler calls:This means the pool proxy releases the worker when
requestStreamed()resolves — which is when theStreamedPHPResponseobject is created and streaming begins.But in
php-request-handler.ts, the PHP instance is held until the stream finishes:The gap between these two release points is the bug. During that window:
SinglePHPInstanceManager.acquirePHPInstance()findsisAcquired === true→ throwsStreamedPHPResponse.forHttpCode(500)Reproduction
Environment
@wp-playground/cliv3.1.18http://localhost:8881)Steps
Guaranteed failure — 10 concurrent requests:
Expected: all 200. Actual: ~40-60% return 500.
Threshold test — 7 concurrent:
Expected: all 200. Actual: 1+ requests consistently return 500.
Even 3 concurrent can fail (~20% of runs):
Sequential baseline (always passes):
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 viaload-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:
In
run-cli.tshandleRequest: Don't return untilresponse.finishedresolves, so the pool proxy holds the worker for the full response lifecycle.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).In
PHPRequestHandler: HaverequestStreamednot resolve its promise untilresponse.finished, though this would change the streaming semantics.Option 1 seems simplest and most localized.
Related
createObjectPoolProxyinpackages/php-wasm/universal/src/lib/object-pool-proxy.ts#spawnPHPAndDispatchRequestinpackages/php-wasm/universal/src/lib/php-request-handler.tsSinglePHPInstanceManagerinpackages/php-wasm/universal/src/lib/single-php-instance-manager.tspackages/playground/cli/src/run-cli.ts(lines ~1619-1667)