Skip to content

HTTP/2: process crashes (nil pointer SIGSEGV in x/net/http2 deadline callback) after fastcgi_finish_request() when the request body was not read, with Caddy's default read_body_idle timeout #2694

Closed

Description

@gabebruno

What happened?

Disclosure: this report was drafted with the assistance of an AI tool. All reproduction steps, test results and logs below were executed and reviewed in our local environment. All suggestions was made by AI Assistance and should be considered only as a guidance.

What happened?

With HTTP/2 enabled (the Caddy default), FrankenPHP in classic mode (no worker) crashes the whole process when a PHP script calls fastcgi_finish_request() (or frankenphp_finish_request()) before the request body has been read. Laravel/Symfony do this on every request: Response::send() calls fastcgi_finish_request() when the function exists, and the body is often read later or not at all.

The process exits with:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xc5754e]

Expected: finishing the request early must never crash the server; at worst, late reads should return EOF/0 bytes.

Minimal reproduction (no framework, no database)

probe.php:

<?php

// read=none     -> finish without reading the body (crashes)
// read=after    -> finish, then read php://input (crashes)
// read=before   -> read php://input before finishing (no crash)
// read=nofinish -> never call fastcgi_finish_request() (no crash)
$read = $_GET['read'] ?? 'after';

http_response_code(204);

if ($read === 'before') {
    file_get_contents('php://input');
}

if ($read !== 'nofinish') {
    fastcgi_finish_request();
}

if ($read === 'after' || $read === 'before') {
    file_get_contents('php://input');
}

Caddyfile:

{
    admin off
    auto_https disable_redirects
    servers {
        protocols h1 h2
    }
    frankenphp {
        num_threads 4
        max_threads 4
    }
}

https://localhost:18443 {
    tls internal
    root * /diagnostic
    php_server
}

Steps:

  1. Start FrankenPHP with the files above mounted in /diagnostic:

    docker run -d --name probe --network none \
      -v "$PWD":/diagnostic:ro \
      <frankenphp-image> frankenphp run --config /diagnostic/Caddyfile --adapter caddyfile
  2. Send concurrent HTTP/2 requests from inside the container:

    docker exec probe curl -ksS --http2 --parallel --parallel-max 16 -o /dev/null \
      "https://localhost:18443/probe.php?read=none&n=[1-500]"
  3. The container exits with code 2 after only a few requests and logs the panic above.

Results

Each line is a fresh container, 500 requests, concurrency 16, same image.

Scenario Result
HTTP/1.1 only (protocols h1), read=after (earlier build of the same probe) 200/200 OK, no crash
HTTP/2, read=nofinish 200/200 OK, no crash
HTTP/2, read=none, default Caddy timeouts crash after 4 requests (4/4 runs at concurrency 16; 90 requests in a run at concurrency 8)
HTTP/2, read=after crash after 39 requests
HTTP/2, read=before, GET and POST JSON 500/500 OK (3 runs each, 3,000 requests total)
HTTP/2, read=none, timeouts { write_idle -1s } crash after 4 requests (2/2 runs)
HTTP/2, read=none, timeouts { read_body_idle -1s } 500/500 OK (2/2 runs)
HTTP/2, read=none, both idle timeouts disabled 500/500 OK (2/2 runs)

In the real application (Laravel 11 on the same image), the server crashed after 2 HTTP/2 requests. With $request->getContent() called before $response->send(), it handled 486 HTTP/2 requests without crashing.

Analysis

  • The crash only happens with HTTP/2. As explained in fix: don't set request body read deadline after the request is finished #2538, HTTP/2 deadlines live on the stream, which is torn down (handlerDone() sets responseWriterState to nil) once ServeHTTP returns. On HTTP/1 they live on the net.Conn.
  • Disabling Caddy's read_body_idle alone removes the crash; disabling write_idle alone does not. This suggests the body is read after the request is finished, during PHP request shutdown (we did not confirm the exact caller; likely SAPI deactivation draining unread input through go_read_post → fc.request.Body.Read). That read goes through Caddy's IdleTimeoutReader.Read, which calls http.ResponseController.SetReadDeadline on the finished HTTP/2 stream.
  • go_read_post already skips its own deadline when fc.isDone (fix: don't set request body read deadline after the request is finished #2538), but still reads fc.request.Body, which is wrapped by Caddy.
  • Consuming the body before finishing (file_get_contents('php://input')) avoids any late body read, so the crash disappears.

Symbolization caveat: go tool addr2line on the stripped binary maps pc=0xc5754e to golang.org/x/net/http2.(*responseWriter).SetWriteDeadline.func1 (golang.org/x/net@v0.59.0/http2/server.go:2744), the closure run on the HTTP/2 serve loop. This conflicts with the timeout experiment above, which points to the read wrapper. We could not capture a goroutine stack: with GOTRACEBACK=system or GOTRACEBACK=all, only the two panic lines are printed. The exact call chain is therefore unconfirmed.

Suggested fix

In go_read_post, return 0 without touching fc.request.Body once fc.isDone, since the body must not be used after ServeHTTP returns. Alternatively, avoid draining the request body during request shutdown after an early finish.

Workarounds we confirmed

  1. In the application, read the body before finishing, e.g. in Laravel public/index.php: $request->getContent(); before $response->send();.
  2. Disable Caddy's body idle timeout: servers { timeouts { read_body_idle -1s } }. This loses slowloris protection on request bodies.
  3. Serve the PHP backend over HTTP/1.1 only (protocols h1).
  4. disable_functions = fastcgi_finish_request,frankenphp_finish_request also avoids it, but then terminable middleware runs before the response completes, adding latency.

Build Type

Custom Docker image based on Chainguard (Wolfi, glibc 2.44). PHP is built by Chainguard's melange toolchain (see Build System in the phpinfo() below). We have not confirmed whether the FrankenPHP binary is Chainguard's package or a separate build. It reports:

  • FrankenPHP v1.12.7
  • Caddy v2.11.6
  • Go go1.26.8
  • golang.org/x/net v0.59.0
  • PHP 8.2.33 ZTS (Zend Max Execution Timers => enabled, Zend Signal Handling => disabled)

We could not test the official dunglas/frankenphp image: our network blocks Docker Hub. Since the trigger is Caddy's default read_body_idle together with HTTP/2, we expect it to reproduce there, but this is not verified.

Operating System

  • Reproduction above: Linux container (Chainguard/Wolfi) on Docker/Rancher Desktop. The x86_64 image was emulated with Rosetta on an Apple Silicon host. The crash does not depend on Rosetta: disabling read_body_idle or reading the body before finishing removes it under identical emulation.
  • The same panic message was observed by our team in production on native Linux x86_64 (Azure). We have not confirmed the crash address there.

Build Type

Custom (tell us more in the description)

Worker Mode

No

Operating System

Other (tell us more in the description)

CPU Architecture

x86_64

PHP configuration

Sanitized excerpt from CLI phpinfo(), not a full dump. Credentials, connection details, session security settings, environment variables, host identifiers and unrelated configuration have been omitted. CLI values may differ from the effective web request configuration.

Sanitized PHP configuration (CLI): build, extensions and request handling
phpinfo()
PHP Version => 8.2.33

System => Linux <redacted-host> <kernel> x86_64
Build Date => Oct  1 2026 23:02:09
Build System => Linux chainguard 6.18.54-r1-qemu-melange #Chainguard SMP PREEMPT_DYNAMIC Sun Sep 27 17:18:15 UTC 2026 x86_64 Linux
Configure Command =>  './configure'  '--prefix=/usr' '--libdir=/usr/lib/php' '--datadir=/usr/share/php' '--sysconfdir=/etc/php' '--with-config-file-path=/etc/php' '--with-config-file-scan-dir=/etc/php/conf.d' '--with-layout=GNU' '--with-pic' '--enable-embed' '--enable-zts' '--disable-zend-signals' '--enable-zend-max-execution-timers' '--disable-cgi' '--disable-fpm' '--disable-phpdbg' '--enable-cli' '--with-curl' '--with-openssl' '--with-readline' '--with-sodium' '--with-zlib' '--enable-opcache' '--enable-mbstring' '--enable-sockets' '--enable-pcntl' '--enable-mysqlnd' '--with-pdo-mysql=mysqlnd' '--with-pdo-sqlite' '--enable-pdo' '--enable-intl' '--enable-xml' '--enable-dom' '--enable-simplexml' '--enable-xmlreader' '--enable-xmlwriter' '--enable-ftp' '--enable-bcmath' '--enable-ctype' '--with-bz2' '--enable-calendar' '--enable-exif' '--with-gettext' '--with-gmp' '--enable-phar' '--enable-fileinfo' '--enable-posix' '--with-external-pcre' '--with-pear'
Server API => Command Line Interface
Virtual Directory Support => enabled

PHP API => 20220829
PHP Extension => 20220829
Zend Extension => 420220829
Zend Extension Build => API420220829,TS
PHP Extension Build => API20220829,TS
Debug Build => no
Thread Safety => enabled
Thread API => POSIX Threads
Zend Signal Handling => disabled
Zend Memory Manager => enabled
Zend Multibyte Support => provided by mbstring
Zend Max Execution Timers => enabled
IPv6 Support => enabled
DTrace Support => disabled

Extensions shown in the original CLI output (names only):
bcmath, bz2, calendar, ctype, curl, date, dom, exif, fileinfo, filter,
ftp, gd, gettext, gmp, hash, iconv, intl, json, ldap, libxml, mbstring,
mysqlnd, openssl, opentelemetry, pcntl, pcre, PDO, pdo_mysql, pdo_pgsql,
pdo_sqlite, pgsql, Phar, posix, random, readline, redis, Reflection,
session, SimpleXML, soap, sockets, sodium, SPL, sqlite3, standard,
tokenizer, xml, xmlreader, xmlwriter, Zend OPcache, zip, zlib

Core (selected directives)

Directive => Local Value => Master Value
disable_functions => no value => no value
enable_post_data_reading => On => On
file_uploads => On => On
hard_timeout => 2 => 2
ignore_user_abort => Off => Off
implicit_flush => On => On
max_execution_time => 0 => 0
max_input_time => -1 => -1
memory_limit => 1024M => 1024M
output_buffering => 0 => 0
output_handler => no value => no value
post_max_size => 80M => 80M
upload_max_filesize => 80M => 80M

Zend OPcache v8.2.33 (CLI)

Opcode Caching => Disabled
Optimization => Disabled
SHM Cache => Enabled
File Cache => Disabled
JIT => On
Startup Failed => Opcode Caching is disabled for CLI

Directive => Local Value => Master Value
opcache.enable => On => On
opcache.enable_cli => Off => Off
opcache.jit => tracing => tracing
opcache.jit_buffer_size => 0 => 0

zlib (selected directives)

Directive => Local Value => Master Value
zlib.output_compression => Off => Off
zlib.output_compression_level => -1 => -1
zlib.output_handler => no value => no value

Relevant log output

Relevant log output
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xc5754e]


Container exit code `2` (`OOMKilled=false`). No goroutine stack is printed, even with `GOTRACEBACK=system` or `GOTRACEBACK=all`.

`go tool addr2line` on the same binary:


golang.org/x/net/http2.(*responseWriter).SetWriteDeadline.func1
/root/go/pkg/mod/golang.org/x/net@v0.59.0/http2/server.go:2744
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions