From 0078fdbc5343ddc7d04c4308f19375d3ae4f9731 Mon Sep 17 00:00:00 2001 From: theraw Date: Tue, 30 Jun 2026 21:20:31 +0000 Subject: [PATCH] zstd patch --- ...td-nginx-module-0.1.1-flush-cpu-spin.patch | 76 +++++++++++++++++++ build/raccoon.sh | 15 ++++ build/trixie.sh | 15 ++++ 3 files changed, 106 insertions(+) create mode 100644 build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch diff --git a/build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch b/build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch new file mode 100644 index 0000000..d99f96c --- /dev/null +++ b/build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch @@ -0,0 +1,76 @@ +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; + } + diff --git a/build/raccoon.sh b/build/raccoon.sh index b130846..95e05a7 100644 --- a/build/raccoon.sh +++ b/build/raccoon.sh @@ -250,6 +250,21 @@ function clean_install() { if [ ! -d /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} ]; then cd /opt/mod/; wget https://github.com/tokers/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz cd /opt/mod/; tar xf ${NGX_MOD_ZSTD}.tar.gz; rm -Rf ${NGX_MOD_ZSTD}.tar.gz + + # Local fix for the zstd-nginx-module 0.1.1 worker CPU spin (100% CPU on + # a flushed compressible response: proxy_buffering off, SSE, PHP flush()). + # Upstream is unmaintained (0.1.1 is the latest tag), so we carry it. + # Applied here rather than in apply_patches() because the module lives + # outside the nginx source tree; the [ ! -d ] guard above makes this a + # fresh-extract-only operation (rm the module dir to force a re-patch). + zstd_fix="${SCRIPT_DIR}/patches/zstd-nginx-module-${NGX_MOD_ZSTD}-flush-cpu-spin.patch" + if [ "${APPLY_PATCH_ZSTD_FLUSH_FIX:-1}" = "1" ]; then + [ -f "$zstd_fix" ] || { echo "[patch] MISSING $zstd_fix"; exit 1; } + echo "[patch] applying zstd-nginx-module-${NGX_MOD_ZSTD}-flush-cpu-spin.patch" + ( cd /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} && patch -p1 < "$zstd_fix" ) + else + echo "[patch] skip zstd flush fix (APPLY_PATCH_ZSTD_FLUSH_FIX=0)" + fi fi # END OF NGINX MODULES diff --git a/build/trixie.sh b/build/trixie.sh index 6676c2d..3c8e4ed 100644 --- a/build/trixie.sh +++ b/build/trixie.sh @@ -250,6 +250,21 @@ function clean_install() { if [ ! -d /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} ]; then cd /opt/mod/; wget https://github.com/tokers/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz cd /opt/mod/; tar xf ${NGX_MOD_ZSTD}.tar.gz; rm -Rf ${NGX_MOD_ZSTD}.tar.gz + + # Local fix for the zstd-nginx-module 0.1.1 worker CPU spin (100% CPU on + # a flushed compressible response: proxy_buffering off, SSE, PHP flush()). + # Upstream is unmaintained (0.1.1 is the latest tag), so we carry it. + # Applied here rather than in apply_patches() because the module lives + # outside the nginx source tree; the [ ! -d ] guard above makes this a + # fresh-extract-only operation (rm the module dir to force a re-patch). + zstd_fix="${SCRIPT_DIR}/patches/zstd-nginx-module-${NGX_MOD_ZSTD}-flush-cpu-spin.patch" + if [ "${APPLY_PATCH_ZSTD_FLUSH_FIX:-1}" = "1" ]; then + [ -f "$zstd_fix" ] || { echo "[patch] MISSING $zstd_fix"; exit 1; } + echo "[patch] applying zstd-nginx-module-${NGX_MOD_ZSTD}-flush-cpu-spin.patch" + ( cd /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} && patch -p1 < "$zstd_fix" ) + else + echo "[patch] skip zstd flush fix (APPLY_PATCH_ZSTD_FLUSH_FIX=0)" + fi fi # END OF NGINX MODULES