Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/Drivers/LaravelHttpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,22 @@ private function handleRequest(AmpRequest $request): Response
}
}

$headers = $response->headers->all();

if ($response->isInformational() || $response->isEmpty()) {
// Symfony strips Content-Length from 1xx/204/304 responses and amphp
// chunk-encodes any HTTP/1.1 response without one, which appends a
// terminating chunk to a response that must not carry a body. Browsers
// consider such a response complete after the headers, so the stray
// bytes corrupt the next request on the same keep-alive connection.
unset($headers['transfer-encoding']);
$headers['content-length'] = ['0'];
$content = '';
}

return new Response(
$response->getStatusCode(),
$response->headers->all(), // @phpstan-ignore-line
$headers, // @phpstan-ignore-line
$content,
);
}
Expand Down
17 changes: 15 additions & 2 deletions src/Playwright/Servers/PlaywrightNpmServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ public function start(): void
return;
}

$this->systemProcess = SystemProcess::fromShellCommandline(sprintf(
// `exec` makes the shell replace itself with node, so stop() signals the
// server itself. Without it, PHP's `sh -c` wrapper receives the signal and
// node is re-parented to PID 1 together with the browsers it launched.
$this->systemProcess = SystemProcess::fromShellCommandline($this->execPrefix().sprintf(
$this->command,
$this->host,
$this->port,
Expand Down Expand Up @@ -94,7 +97,9 @@ public function start(): void
public function stop(): void
{
if ($this->systemProcess instanceof SystemProcess && $this->isRunning()) {
$this->systemProcess->stop(timeout: 0.1);
// Give node a moment to close its browsers on SIGTERM before Symfony
// escalates to SIGKILL; the browsers exit on their own once node is gone.
$this->systemProcess->stop(timeout: 2.0);
}

$this->systemProcess = null;
Expand Down Expand Up @@ -164,6 +169,14 @@ private function ensurePlaywrightIsInstalledAndVersionIsSupported(): void
}
}

/**
* The shell prefix that hands the process its own PID (POSIX only).
*/
private function execPrefix(): string
{
return DIRECTORY_SEPARATOR === '/' ? 'exec ' : '';
}

/**
* Checks
*/
Expand Down
33 changes: 33 additions & 0 deletions tests/Browser/Server/BodylessResponseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;

it('serves the next request on a keep-alive connection after a body-less response', function (int $status): void {
Route::post('/beacon', fn (): Response => new Response('', $status));

Route::get('/after-beacon.js', fn (): Response => new Response('window.afterBeaconLoaded = true;', 200, [
'Content-Type' => 'text/javascript',
]));

Route::get('/', fn (): string => <<<'HTML'
<html>
<body>
<script>
fetch('/beacon', {method: 'POST', keepalive: true}).then(() => {
const script = document.createElement('script');
script.src = '/after-beacon.js';
script.onerror = () => { window.afterBeaconFailed = true; };
document.body.appendChild(script);
});
</script>
</body>
</html>
HTML);

visit('/')->assertScript('window.afterBeaconLoaded === true || window.afterBeaconFailed === true');

visit('/')->assertScript('window.afterBeaconLoaded === true');
})->with([204, 304])->repeat(3);
45 changes: 45 additions & 0 deletions tests/Unit/Playwright/Servers/PlaywrightNpmServerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

use Pest\Browser\Playwright\Servers\AlreadyStartedPlaywrightServer;
use Pest\Browser\Playwright\Servers\PlaywrightNpmServer;
use Pest\Browser\Support\PackageJsonDirectory;
use Pest\Browser\Support\Port;

it('stops the node process it started instead of leaving it behind', function (): void {
$port = Port::find();

$server = PlaywrightNpmServer::create(
PackageJsonDirectory::find(),
'.'.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'.bin'.DIRECTORY_SEPARATOR.'playwright run-server --host %s --port %d --mode launchServer',
'127.0.0.1',
$port,
'Listening on',
);

$server->start();

try {
$pid = (int) new ReflectionProperty($server, 'systemProcess')->getValue($server)->getPid();
$commandLine = str_replace("\0", ' ', (string) file_get_contents("/proc/{$pid}/cmdline"));

expect($commandLine)->toStartWith('node ')
->and($commandLine)->toContain("--port {$port}");
} finally {
$server->stop();
AlreadyStartedPlaywrightServer::markAsStopped();
}

$survivors = [];

foreach (glob('/proc/[0-9]*', GLOB_NOSORT) ?: [] as $directory) {
$candidate = str_replace("\0", ' ', (string) @file_get_contents($directory.'/cmdline'));

if (str_contains($candidate, "run-server --host 127.0.0.1 --port {$port} ")) {
$survivors[] = basename($directory);
}
}

expect($survivors)->toBe([]);
})->skipOnWindows()->skipOnMac();