Streaming responses declare Transfer-Encoding: chunked and the data goes out
framed by chunked_content(), but on close the zero length chunk that
terminates the response was never sent.
With apache it went unnoticed: the daemon owned the client socket and simply
closed it. nginx instead parses the response, and every stream that renews
itself (RD_ENDTIME_DELTA, 240 seconds) looked truncated to it: "upstream
prematurely closed connection while reading upstream", one error line per
connected player per cycle.
The final chunk is added in User::stream_close(), which is already the place
where the daemon writes its last bytes before closing, and only for the
transports that really are chunked: websockets are not, and keep sending
their own close frame.
Measured: with 150 players the renewal of the streams produced 150 errors;
after the fix, 30 players and 30 stream reopenings give zero errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014M1jiEq9cHdE5SE5j6vFuE
function stream_close()
{
- return ($this->rd_transp->close());
+ $clo = $this->rd_transp->close();
+
+ /* Streaming responses declare Transfer-Encoding: chunked (see
+ headers_render) and the data goes out framed by chunked_content(), but
+ the final chunk was missing on close: whoever sits in the middle sees
+ the connection drop halfway through the response. With apache it went
+ unnoticed, because the daemon owned the client socket; nginx instead
+ parses the response and logs "upstream prematurely closed connection"
+ for every stream that renews itself. */
+ if ($this->is_chunked()) {
+ if ($clo != "") {
+ $clo = sprintf("%X\r\n", mb_strlen($clo, "ASCII")).$clo."\r\n";
+ }
+ $clo .= "0\r\n\r\n";
+ }
+
+ return ($clo);
}
function stream_postclose_get($sock, $curtime)