The Day php -S Ran Out of File Descriptors

这篇内容暂时只有 English 版本,以下是原文。

PHP 8.2.13, built-in server (php -S), PHP-FPM not involved. A throwaway internal tool: a router script that serves uploaded files, roughly 40k small JSON files on disk, one developer machine, macOS 14. It worked for a week, then every few hours the server stopped answering, and the terminal showed nothing — the process was alive, curl just hung until timeout. lsof -p $(pgrep -f 'php -S') | wc -l told the story: the count climbed steadily and never came down.

The built-in server is single-threaded. Every request that returns a file body keeps the underlying stream open until the response is fully written — but if the client disconnects first, the stream isn’t always closed promptly. My router script opened files itself with fopen and handed the handle to readfile, which duplicates and manages its own pointer. Two handles per request, one of which I owned and never closed. Fixed by not being clever:

$path = realpath(__DIR__ . '/storage/' . $name);
if ($path === false || !str_starts_with($path, __DIR__ . '/storage/')) {
    http_response_code(404);
    return true;
}
header('Content-Type: application/json');
header('Content-Length: ' . filesize($path));
readfile($path);   // no fopen/fclose here
return true;

Mechanically: readfile opens, streams, and closes on its own, and Content-Length lets the server know when the response is done so it can release the connection. When I omitted the length header, chunked output meant the socket lingered longer and the FD count still drifted up under aborted requests — not as fast, but up. The trade-off is that readfile reads the whole file into the output buffer in chunks and you can’t stream-transform; for 2–20KB JSON that’s fine, for 500MB videos it isn’t, and you’d want fpassthru with a capped buffer or, honestly, nginx.

Where this breaks: php -S is not a production server and I’m not pretending otherwise. It also doesn’t help when the leak is in an extension or when you hold a PDO connection open per request — I hit a second smaller climb from an unclosed PDO in a debug endpoint. And realpath caching bit me once: after moving a file, the old path kept resolving until the process restarted, so the prefix check passed against a stale path. That one took an hour to see.

What’s the first thing you check when a long-running PHP process quietly stops responding but never exits?

0
赞同度 100% · 0 条评论 · 4 阅读

0 条评论

支持 Markdown · 回复会实时推送给对方

还没有评论,写下第一条。