Skip to content

ProcessJsonRpcClient never clears the Process output buffers, leaking memory/disk until every request times out #102

Description

@poman

Affected version: dev-main (2026-07)
Severity: critical — long-running processes eventually fail every request with a misleading timeout

Summary

ProcessJsonRpcClient::readProcessOutput() reads newly produced bytes from the Node bridge process with Process::getIncrementalOutput() / getIncrementalErrorOutput(), but never calls the matching Process::clearOutput() / clearErrorOutput(). Symfony's Process keeps the entire stdout/stderr history in an internal buffer (a php://temp stream that spills to a disk-backed temp file once it exceeds a small in-memory threshold) for as long as the process object is alive — getIncrementalOutput() only tracks a read cursor, it does not discard what was already consumed. On a long-lived Playwright session (a multi-hour Behat regression run, for example) every JSON-RPC response — page HTML, base64 screenshots, ARIA snapshots — accumulates forever in that buffer and is never freed.

Once the temp file grows large enough to fill the available disk space (observed on CI nodes running several thousand scenarios in one process), Process::addOutput() starts silently failing to write further bytes. From that point on, no more bytes are ever visible to getIncrementalOutput(), so every in-flight and subsequent request in ProcessJsonRpcClient::waitForResponse() spins until its deadline and fails with the generic:

Playwright\Exception\TimeoutException: JSON-RPC request N timed out

which looks identical to a slow browser action or a genuine hang, making the real cause (disk full because of an unbounded internal buffer) very hard to diagnose from the PHP side alone.

Reproduction

Not practical to reproduce end-to-end in a short snippet (requires filling disk or running for a long time), but the leak itself is directly observable:

$client = PlaywrightFactory::create();
// ... perform any moderate number of operations that produce output (navigations,
// screenshots, evaluate calls) ...

$process = /* the underlying Symfony Process, via reflection */;
// $process->getOutput() (the *full*, non-incremental buffer) keeps growing without
// bound across the lifetime of the client, even though every chunk was already
// consumed via getIncrementalOutput().

Root cause

src/Transport/JsonRpc/ProcessJsonRpcClient.php, readProcessOutput() (~line 124): calls getIncrementalOutput()/getIncrementalErrorOutput() without the corresponding clearOutput()/clearErrorOutput() calls that Symfony's Process API expects call sites to make when they are done with a chunk.

Proposed fix

     private function readProcessOutput(): void
     {
         $stdout = $this->process->getIncrementalOutput();
+        $this->process->clearOutput();
         if ('' !== $stdout) {
             $this->outputBuffer .= $stdout;
         }

         $stderr = $this->process->getIncrementalErrorOutput();
+        $this->process->clearErrorOutput();
         if ('' !== $stderr) {
             $this->logger->warning('Process stderr', ['stderr' => $stderr]);
         }

         $this->processAndDispatchMessages();
     }

Test

Run several thousand JSON-RPC round trips (or a mocked Process double that returns increasing content from getOutput()), and assert that $process->getOutput()/getErrorOutput() — the full buffers, not the incremental read — stay near-empty rather than growing monotonically with the number of requests served.

Discovered while running the OroCommerce Behat regression suite on this driver: several nodes started failing late in the run with unexplained JSON-RPC request N timed out errors, traced back to full CI-node disks.

A PR with this fix and a regression test is ready and will be linked here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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