Fix a 100% CPU worker spin in zstd-nginx-module 0.1.1 (tokers), triggered when a compressible response is flushed mid-stream while being zstd-compressed. Trigger: nginx injects an empty flush buffer via ngx_http_send_special(r, NGX_HTTP_FLUSH) whenever the body is flushed without new data -- `proxy_buffering off`, an upstream sending "X-Accel-Buffering: no", Server-Sent Events / long-poll, FastCGI buffering off, or PHP flush()/ob_flush() -- on a content type in zstd_types, to a client advertising "Accept-Encoding: zstd" (Chrome 123+ and Firefox 126+ do by default). Root cause: in ngx_http_zstd_filter_compress(), ZSTD_compressStream() returns a *size hint* (the recommended next input size), not the amount of pending output, so it is almost always > 0. The module misreads rc > 0 as "more output pending", flips COMPRESS -> FLUSH and re-enters. ZSTD_flushStream() then returns 0 having produced no bytes, the action is restored to COMPRESS, and ctx->flush is never cleared (it is only cleared on the output-producing path). COMPRESS <-> FLUSH then toggles forever: the worker pins one core at 100%, never returns to the event loop (so a configuration reload cannot reap it) and survives until killed. Fix: when a flush or end completes (ZSTD returned 0) without producing output, clear ctx->flush / the GZIP_BUFFERED flag and stop instead of looping. No buffer is manufactured -- a zero-length data buffer would be rejected by ngx_http_write_filter ("zero size buf in writer"); the already-compressed data has been emitted on the output-producing path, so there is nothing left to send. Upstream tokers/zstd-nginx-module is effectively unmaintained (0.1.1 is the latest tag, 2023-10-23), so this is carried as a local patch. Verified against vanilla nginx 1.31.2 + module 0.1.1: unpatched pins a worker at 100% CPU (state R) on an unbuffered flushed response and never recovers; patched keeps the worker idle (state S) and the streamed response decodes to a complete, valid zstd frame (zstd -t: FRAME OK). --- a/filter/ngx_http_zstd_filter_module.c 2026-06-30 20:51:33.329153852 +0000 +++ b/filter/ngx_http_zstd_filter_module.c 2026-06-30 21:05:09.666179333 +0000 @@ -460,6 +460,40 @@ } if (ngx_buf_size(ctx->out_buf) == 0) { + + if (rc == 0 && (ctx->flush || ctx->last)) { + /* + * twiy/raweb fix for the zstd-nginx-module 0.1.1 worker CPU spin. + * + * A flush or end has just completed (ZSTD_flushStream()/ + * ZSTD_endStream() returned 0) but produced no output: the zstd + * stream buffer was already drained, so there is nothing left to + * emit downstream. Clear the pending flush bookkeeping here instead + * of looping; do NOT manufacture a buffer (a zero-length data buf + * is rejected by ngx_http_write_filter with "zero size buf in + * writer"). + * + * Without this an empty flush buffer -- injected by nginx via + * ngx_http_send_special(NGX_HTTP_FLUSH): proxy_buffering off, + * "X-Accel-Buffering: no", SSE, FastCGI flush, PHP flush() -- + * wedges the worker at 100% CPU forever. ZSTD_compressStream() + * returns a non-zero *size hint* (recommended next input size, not + * pending output) that is misread as "redo", flipping + * COMPRESS->FLUSH; ZSTD_flushStream() then returns 0 with no + * output; ctx->flush is only cleared on the output-producing path + * below, which is never reached, so COMPRESS<->FLUSH toggles + * indefinitely. + */ + + r->connection->buffered &= ~NGX_HTTP_GZIP_BUFFERED; + ctx->flush = 0; + + if (ctx->last) { + ctx->done = 1; + return NGX_OK; + } + } + return NGX_AGAIN; }