Skip to content

Commit bb6ec45

Browse files
authored
Patch the premature "request in progress" semaphore release (#2455)
Guards against releasing the "request in progress" semaphore too early in the php.runStream() call. A single PHP runtime can only handle one request at a time. The PHP class calls a `wasm_sapi_handle_request` C function that initializes the PHP runtime and starts the request. That function is asynchronous and may yield back to the event loop before the request is fully handled, the exit code known, and the runtime is cleaned up and prepared for another request. The PHP class uses an async semaphore to protect against calling `wasm_sapi_handle_request` again while a previous call is still running. However, PR 2266 [1] introduced a regression where the semaphore was released too early. As a result, it opened the runtime to a race condition where a subsequent runStream() call tried to run PHP code on a runtime that was in a middle of handling a request. This test ensures that two runStream() calls can be made without crashing the runtime. [1] #2266
1 parent 86ad59e commit bb6ec45

File tree

4 files changed

+150
-23
lines changed

4 files changed

+150
-23
lines changed

packages/php-wasm/node/src/test/php.spec.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,18 @@ phpLoaderOptions.forEach((options) => {
178178
const exitCode1Http200 = await php.runStream({
179179
code: '<?php trigger_error("Fatal error", E_USER_ERROR);',
180180
});
181-
expect(await exitCode1Http200.ok()).toBe(false);
181+
182+
/**
183+
* trigger_error does not affect the HTTP status code set by PHP.
184+
*
185+
* Some dev servers that buffer the response will notice the
186+
* HTTP status code is 200, the exit code is 1, and will replace
187+
* the HTTP status code with 500.
188+
*
189+
* However, from the PHP process perspective, the status code is
190+
* still 200.
191+
*/
192+
expect(await exitCode1Http200.ok()).toBe(true);
182193

183194
const http500 = await php.runStream({
184195
code: '<?php http_response_code(500); ',
@@ -349,6 +360,86 @@ phpLoaderOptions.forEach((options) => {
349360
'stderr second'
350361
);
351362
});
363+
364+
/**
365+
* Guards against releasing the "request in progress" semaphore
366+
* too early in the php.runStream() call.
367+
*
368+
* ## Context
369+
*
370+
* A single PHP runtime can only handle one request at a time.
371+
* The PHP class calls a `wasm_sapi_handle_request` C function that
372+
* initializes the PHP runtime and starts the request. That function is
373+
* asynchronous and may yield back to the event loop before the request
374+
* is fully handled, the exit code known, and the runtime is cleaned up
375+
* and prepared for another request.
376+
*
377+
* The PHP class uses an async semaphore to protect against calling
378+
* `wasm_sapi_handle_request` again while a previous call is still
379+
* running.
380+
*
381+
* However, PR 2266 [1] introduced a regression where the semaphore
382+
* was released too early. As a result, it opened the runtime to a
383+
* race condition where a subsequent runStream() call tried to run
384+
* PHP code on a runtime that was in a middle of handling a request.
385+
*
386+
* This test ensures that two runStream() calls can be made without
387+
* crashing the runtime.
388+
*
389+
* [1] https://github.com/WordPress/wordpress-playground/pull/2266
390+
*/
391+
it('should stagger concurrent runStream() calls', async () => {
392+
/**
393+
* Call runStream() twice without waiting for the first one to finish.
394+
*/
395+
const response1Promise = php.runStream({
396+
code: `<?php
397+
// Declare a new function to ensure a name collision if
398+
// the second runStream() call actually runs before this
399+
// one concludes.
400+
function unique_fn_name(){};
401+
402+
// Yield back to the event loop.
403+
sleep(3);
404+
405+
// Output some stdout information just to be extra sure the
406+
// execution actually got here.
407+
echo "response 1";`,
408+
});
409+
const response2Promise = php.runStream({
410+
code: `<?php
411+
// Ensure a name collision if the first PHP request haven't
412+
// concluded yet and the symbol is already registered in the
413+
// runtime.
414+
function unique_fn_name(){};
415+
416+
// Yield back to the event loop. Technically we don't need to
417+
// do that, but switching stacks catalyzes the crash.
418+
sleep(1);
419+
420+
// Output some stdout information just to be extra sure the
421+
// execution actually got here.
422+
echo "response 2";`,
423+
});
424+
425+
// Only now start awaiting things.
426+
// First, the streaming response objects
427+
const response1 = await response1Promise;
428+
const response2 = await response2Promise;
429+
430+
// Then, wait for the requests to conclude.
431+
await response1.finished;
432+
await response2.finished;
433+
434+
console.log(await response1.stderrText);
435+
// Ensure both requests succeeded.
436+
expect(await response1.ok()).toBe(true);
437+
expect(await response2.ok()).toBe(true);
438+
439+
// Confirm the STDOUT output.
440+
expect(await response1.stdoutText).toBe('response 1');
441+
expect(await response2.stdoutText).toBe('response 2');
442+
});
352443
});
353444

354445
describe('ENV variables', { skip: options.withXdebug }, () => {

packages/php-wasm/universal/src/lib/php-request-handler.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,26 @@ export class PHPRequestHandler implements AsyncDisposable {
449449
// Pass along URL with the #fragment filtered out
450450
url: requestedUrl.toString(),
451451
};
452-
return this.#spawnPHPAndDispatchRequest(
452+
const response = await this.#spawnPHPAndDispatchRequest(
453453
effectiveRequest,
454454
fsPath
455455
);
456+
457+
/**
458+
* If the response is but the exit code is non-zero, let's rewrite the
459+
* HTTP status code as 500. We're acting as a HTTP server here and
460+
* this behavior is in line with what Nginx and Apache do.
461+
*/
462+
if (response.ok() && response.exitCode !== 0) {
463+
return new PHPResponse(
464+
500,
465+
response.headers,
466+
response.bytes,
467+
response.errors,
468+
response.exitCode
469+
);
470+
}
471+
return response;
456472
} else {
457473
return this.#serveStaticFile(primaryPhp, fsPath);
458474
}
@@ -568,6 +584,7 @@ export class PHPRequestHandler implements AsyncDisposable {
568584
response.headers
569585
);
570586
}
587+
571588
return response;
572589
} catch (error) {
573590
const executionError = error as PHPExecutionFailureError;

packages/php-wasm/universal/src/lib/php-response.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,8 @@ export class StreamedPHPResponse {
123123
* Resolves once HTTP status code is available.
124124
*/
125125
get httpStatusCode(): Promise<number> {
126-
return Promise.race([
127-
this.getParsedHeaders().then((headers) => headers.httpStatusCode),
128-
this.exitCode.then((exitCode) =>
129-
exitCode !== 0 ? 500 : undefined
130-
),
131-
])
126+
return this.getParsedHeaders()
127+
.then((headers) => headers.httpStatusCode)
132128
.then((result) => {
133129
if (result !== undefined) {
134130
return result;
@@ -293,6 +289,14 @@ export class PHPResponse implements PHPResponseData {
293289
);
294290
}
295291

292+
/**
293+
* True if the response is successful (HTTP status code 200-399),
294+
* false otherwise.
295+
*/
296+
ok(): boolean {
297+
return this.httpStatusCode >= 200 && this.httpStatusCode < 400;
298+
}
299+
296300
toRawData(): PHPResponseData {
297301
return {
298302
headers: this.headers,

packages/php-wasm/universal/src/lib/php.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -654,24 +654,39 @@ export class PHP implements Disposable {
654654
}
655655
);
656656

657-
// Free up resources when the response is done
658-
await streamedResponsePromise
659-
.finally(() => {
660-
if (heapBodyPointer) {
657+
const cleanup = () => {
658+
if (heapBodyPointer) {
659+
try {
661660
this[__private__dont__use].free(heapBodyPointer);
661+
} catch (e) {
662+
logger.error(e);
662663
}
663-
})
664-
.finally(() => {
665-
release();
666-
/**
667-
* Notify the filesystem journal and rotatePHPRuntime() that we've handled
668-
* another request.
669-
*/
670-
this.dispatchEvent({
671-
type: 'request.end',
672-
});
664+
}
665+
666+
// Release the "request in progress" semaphore.
667+
release();
668+
669+
/**
670+
* Notify the filesystem journal and rotatePHPRuntime() that we've handled
671+
* another request.
672+
*/
673+
this.dispatchEvent({
674+
type: 'request.end',
673675
});
674-
return streamedResponsePromise;
676+
};
677+
678+
// Free up resources once the request is fully handled.
679+
return streamedResponsePromise.then(
680+
(streamedResponse) => {
681+
streamedResponse.finished.finally(cleanup);
682+
return streamedResponse;
683+
},
684+
(error) => {
685+
// If we couldn't even get the response,
686+
cleanup();
687+
throw error;
688+
}
689+
);
675690
}
676691

677692
/**

0 commit comments

Comments
 (0)