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:
@@ -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/<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.
|
||||
Reference in New Issue
Block a user