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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
This commit is contained in:
2026-07-04 23:53:11 +02:00
co-authored by Claude Opus 4.8
parent dcf9c13455
commit 3ad055d4a8
@@ -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 615631):
```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/<hostname>/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.