From 3ad055d4a8084431da7f199cdcf0a848622bbf38 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:53:11 +0200 Subject: [PATCH] docs: capture Grav double Content-Encoding garbage-page fix (ce-compound) Non-FastCGI host + Apache mod_deflate: Grav's shutdown early-close emits Content-Encoding: identity while mod_deflate adds gzip -> two conflicting headers -> browsers render raw gzip bytes. Fix: debugger.shutdown.close_ connection:false in the prod env override. Documents the dead-end (cache.gzip/allow_webserver_gzip take the same code path) and the browser-style curl + screenshot verification. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- ...av-double-content-encoding-garbage-page.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md diff --git a/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md new file mode 100644 index 0000000..975d623 --- /dev/null +++ b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md @@ -0,0 +1,122 @@ +--- +title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)" +date: 2026-07-04 +category: docs/solutions/integration-issues +module: "grav / production deploy" +problem_type: integration_issue +component: tooling +severity: high +symptoms: + - "Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser" + - "curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl" + - "curl -H \"Accept-Encoding: gzip\" (a browser-style request) returns raw gzip bytes" + - "Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip" + - "Only appeared after switching prod to production Twig mode (twig.debug: false)" +root_cause: config_error +resolution_type: config_change +related_components: + - "Apache mod_deflate" + - "DirectAdmin shared host" + - "Grav shutdown handler (system/src/Grav/Common/Grav.php)" + - "deploy/env/prod/system.yaml" + - "make remote-apply-env-prod" +tags: + - grav + - content-encoding + - gzip + - mod-deflate + - fastcgi-finish-request + - apache + - production-deploy + - twig-debug +--- + +# Grav garbage page from two conflicting Content-Encoding headers (identity + gzip) + +## Problem + +Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting `Content-Encoding` headers. The response body is valid gzip, but because the server advertises both `content-encoding: identity` and `content-encoding: gzip`, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (`twig.debug: false`). + +## Symptoms + +- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine. +- A naive `curl https://site/` (with **no** `Accept-Encoding` header) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug. +- A browser-style request exposes it. `curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/` shows **two** `Content-Encoding` response headers: + ``` + content-encoding: identity + content-encoding: gzip + ``` + The body is valid gzip and `gunzip`s to the correct HTML. +- A static asset served by the webserver alone (e.g. a CSS/JS file) shows a **single** clean `content-encoding: gzip` under the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated. +- The bug only appeared after switching the site to production Twig mode (`twig.debug: false`), which activates Grav's full shutdown/output path. + +## What Didn't Work + +- **First fix attempt: `cache.gzip: false` + `allow_webserver_gzip: true`.** This was the key dead end. It had **no effect** — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires on `if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip'))`. Setting `allow_webserver_gzip: true` satisfies the **same** `||` condition, so Grav still takes the identical `header('Content-Encoding: identity')` code path. The two knobs that *look* like they control this are both on the wrong side of the problem. +- **Verifying with a naive `curl` (no `Accept-Encoding: gzip`).** This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits `Accept-Encoding: gzip` reports a false "all clear." Reproduce it the way a browser does — send `Accept-Encoding: gzip`, or take a real headless-browser (Playwright) screenshot. + +## Solution + +Root the fix in Grav's shutdown handler, `system/src/Grav/Common/Grav.php` (~lines 615–631): + +```php +if ($config->get('system.debugger.shutdown.close_connection', true)) { + $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false; + if (!$success) { + if (!ini_get('zlib.output_compression')) { + if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) { + header('Content-Encoding: identity'); // <-- the bogus header + } elseif (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } else { + header('Content-Encoding: none'); + } + header('Content-Length: ' . ob_get_length()); + } + header('Connection: close'); + ob_end_flush(); + } +} +``` + +The entire problematic block is gated by `system.debugger.shutdown.close_connection` (default `true`). Disable it so the whole branch is skipped and Grav never touches `Content-Encoding` at all. + +In this project it is applied as a **per-environment (prod-only) override** so local dev is untouched — `deploy/env/prod/system.yaml`, deployed via `make remote-apply-env-prod`: + +```yaml +debugger: + shutdown: + close_connection: false +``` + +Verify — the response must show **exactly one** `content-encoding`: + +```bash +curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding +# content-encoding: gzip +``` + +Then take a screenshot of the rendered page to confirm it displays correctly. A `curl`-without-gzip check is **not** sufficient proof — it would have passed even while the bug was live. + +## Why This Works + +`debugger.shutdown.close_connection` (default `true`) makes Grav flush the full response and close the connection to the browser **early**, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via `fastcgi_finish_request()` and never manipulates headers — which is why the bug is invisible on most stacks. + +On a **non-FastCGI** host (LiteSpeed, suPHP, plain CGI), `fastcgi_finish_request()` does not exist, so `$success` is `false` and Grav falls back to closing the connection *manually*. To do that it must set an explicit `Content-Length`, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the `cache.gzip`/`allow_webserver_gzip` branch, it expresses as `header('Content-Encoding: identity')`. + +But `identity` is not a real content transformation and is **not** a recognized "suppress compression" signal to Apache `mod_deflate`. `mod_deflate` ignores it, compresses the body anyway, and appends its **own** `Content-Encoding: gzip`. The response now carries two contradictory `Content-Encoding` headers (`identity` and `gzip`). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen. + +Setting `close_connection: false` means Grav never enters the manual connection-close path, never emits `Content-Encoding: identity`, and leaves the webserver as the **sole** authority on compression. The webserver then sends a single, correct `Content-Encoding: gzip`, and the browser inflates and renders normally. + +## Prevention + +- On **non-FastCGI PHP hosts with server-side gzip** (Apache `mod_deflate`, LiteSpeed), set `debugger.shutdown.close_connection: false` for that environment. Deliver it as a **per-environment override**, never by editing the committed `system.yaml` (which would silently change dev behavior too). +- **Reproduce compression bugs the way a browser sees them.** Always test with `curl -H "Accept-Encoding: gzip" -D -` and/or a headless-browser screenshot. A plain `curl` negotiates no compression and silently masks encoding bugs. +- **Health check:** dynamic pages must return **exactly one** `Content-Encoding` header. Two of them (`identity` + `gzip`) is the unambiguous signature of this bug. Add this assertion to any smoke test. +- **Know the trigger.** This can stay completely hidden in development mode and only appear once a site is switched to production mode (`twig.debug: false`), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover. + +## Related + +- **Grav per-environment override mechanism** — `deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`. +- **`docs/working/git-sync-notes.md`** — documents the `user/env//config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only. +- **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy.