Files
fasthttp/prefork
RW 2c1590038f feat(prefork): graceful shutdown, leak fixes, hook robustness (re-open of #2180 follow-up) (#2199)
* feat(prefork): graceful shutdown, leak fixes, hook robustness

Addresses outstanding review concerns and several adjacent issues
surfaced during a follow-up review pass.

Lifecycle / supervision
- Track every per-child Wait goroutine via sync.WaitGroup and unblock
  pending sigCh sends through a context.Cancel so early-return paths
  (OnChildSpawn / OnMasterReady error, recovery doCommand error,
  ErrOverRecovery) can no longer leak goroutines or stall children.
- Install signal.Notify(SIGTERM, SIGINT) in the master so deploy/
  rolling-restart signals enter the shutdown path instead of killing
  the master without graceful teardown.
- Replace the unconditional SIGKILL defer with a SIGTERM-then-SIGKILL
  sequence gated by a configurable ShutdownGracePeriod (defaults to 5s,
  Windows path stays SIGKILL since Signal(SIGTERM) is unsupported).

API
- OnChildRecover now returns error so callers can implement recovery
  policies (circuit-breaker etc.); panic in any hook is recovered and
  surfaced as the returned error, with diagnostic logging.
- Add RecoverInterval (optional crash-loop backoff) and
  ShutdownGracePeriod fields with safe zero-value defaults.
- Export ErrCommandProducerNilCmd and ErrCommandProducerNotStarted
  sentinel errors so callers can errors.Is them.
- Rename oldPid/newPid to oldPID/newPID per Go initialism convention.
- Logger interface now declares an explicit compile-time compatibility
  check with fasthttp.Logger.

Resource hygiene
- Master closes both the original tcpListener and the duped fd in
  p.files when prefork() returns; previously the duped fd leaked once
  per call.
- doCommand wraps every error path with %w + fmt.Errorf so caller-side
  diagnostics keep stage context.
- Strip pre-existing FASTHTTP_PREFORK_CHILD entries before appending so
  child env never carries duplicate keys.
- Extract magic numbers as package constants
  (inheritedListenerFD, masterPollInterval, defaultShutdownGracePeriod,
  preforkChildEnvValue).
- Rename the inherited listener fd via os.NewFile so net.FileListener
  errors are diagnosable.

Tests
- Migrate to t.Setenv (drop the global setUp/tearDown helpers) — fixes
  the env-mutation-vs-parallel race.
- Replace rand.Intn port helper with `:0` + Listener.Addr() to remove
  port-collision flakes under -count and parallel runs.
- Collapse the three near-identical Test_ListenAndServe* tests into a
  single table-driven subtest that actually asserts the args forwarded
  to ServeFunc/ServeTLSFunc/ServeTLSEmbedFunc.
- Add coverage for the previously untested branches:
  CommandProducer returning err / nil cmd / unstarted cmd,
  initial OnChildSpawn error, OnMasterReady error,
  hook panic surfacing, RecoverInterval enforcement.
- noopChildProducer helper kills + waits any spawned child binaries
  during cleanup so failed tests no longer leave subprocesses around.

* fix(prefork): address Copilot review on #2199

- listen(): the *os.File wrapping the inherited fd was never closed.
  net.FileListener dups the fd, so the original was leaking on every
  child startup. Close it explicitly and return the dup'd listener.

- setTCPListenerFiles(): if tcpListener.File() failed, the bound
  net.Listener stayed open and p.ln pointed at it. Close the listener
  on the error path and only assign p.ln after the dup succeeds.

- prefork(): replace time.After in the RecoverInterval branch with a
  time.NewTimer that we Stop+drain when a shutdown signal wins the
  select, so the timer goroutine and channel allocation don't linger
  during crash-loop shutdown.

- invokeHook(): drop the panic log line. The hook caller logs the
  returned error already, so logging in the recover block produced
  duplicate output for the same panic.

* fix(prefork): harden recovery timer cleanup

* fix(prefork): align follow-up with review feedback

* fix(prefork): simplify child exit loop

* fix(prefork): address review on slice copy and recover backoff

- OnMasterReady now receives the internal childPIDs slice directly
  instead of a defensive copy; the doc states the slice is only valid
  for the call so callers copy it if they need to keep it.
- RecoverInterval backoff moves from an inline time.Sleep in the
  supervision loop into the per-child Wait goroutine. Concurrent crashes
  now each restart RecoverInterval after they exit instead of serializing
  the wait through the loop. The wait is interruptible via ctx so
  shutdown does not have to outlast a full interval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(prefork): silence modernize WaitGroup.Go suggestion

The CI linter analyzes at go1.26 and modernize suggests sync.WaitGroup.Go,
but that API needs go1.25 while the module targets go1.24 (so it would fail
the build/vet there). Suppress the suggestion with an inline nolint instead
of bumping the module's minimum Go version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(prefork): cancel Wait goroutines up front so shutdown is not blocked

Move cancel() to the top of shutdownChildren so a child that already
exited while parked on its RecoverInterval backoff (or a sigCh send)
cannot delay teardown for a full RecoverInterval or ShutdownGracePeriod.
cmd.Wait() is independent of the context, so the graceful SIGTERM wait
still tracks the real process exits. Return early on the graceful path so
the kill loop is skipped when every child is already gone.

Follow-up review polish:
- Clarify the RecoverInterval and OnChildRecover doc comments and document
  the previously undocumented Serve* fields.
- Use sync.WaitGroup.Go (module targets go1.25) and drop the now-stale
  nolint:modernize.
- Tests: replace the unreachable listener-close assertion with the real
  p.ln==nil contract, restore GOMAXPROCS in the child-path test, and add
  Test_childEnv plus Test_Prefork_ShutdownDoesNotBlockOnRecoverInterval.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 10:26:43 +02:00
..
2020-02-12 13:51:27 +01:00

Prefork

Server prefork implementation.

Preforks master process between several child processes increases performance, because Go doesn't have to share and manage memory between cores.

WARNING: using prefork prevents the use of any global state!. Things like in-memory caches won't work.

  • How it works:
import (
    "github.com/valyala/fasthttp"
    "github.com/valyala/fasthttp/prefork"
)

server := &fasthttp.Server{
    // Your configuration
}

// Wraps the server with prefork
preforkServer := prefork.New(server)

if err := preforkServer.ListenAndServe(":8080"); err != nil {
    panic(err)
}

Benchmarks

Environment:

  • Machine: MacBook Pro 13-inch, 2017
  • OS: MacOS 10.15.3
  • Go: go1.13.6 darwin/amd64

Handler code:

func requestHandler(ctx *fasthttp.RequestCtx) {
    // Simulates some hard work
    time.Sleep(100 * time.Millisecond)
}

Test command:

$ wrk -H 'Host: localhost' -H 'Accept: text/plain,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7' -H 'Connection: keep-alive' --latency -d 15 -c 512 --timeout 8 -t 4 http://localhost:8080

Results:

  • prefork
Running 15s test @ http://localhost:8080
  4 threads and 512 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.75ms    4.27ms 126.24ms   97.45%
    Req/Sec    26.46k     4.16k   71.18k    88.72%
  Latency Distribution
     50%    4.55ms
     75%    4.82ms
     90%    5.46ms
     99%   15.49ms
  1581916 requests in 15.09s, 140.30MB read
  Socket errors: connect 0, read 318, write 0, timeout 0
Requests/sec: 104861.58
Transfer/sec:      9.30MB
  • non-prefork
Running 15s test @ http://localhost:8080
  4 threads and 512 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     6.42ms   11.83ms 177.19ms   96.42%
    Req/Sec    24.96k     5.83k   56.83k    82.93%
  Latency Distribution
     50%    4.53ms
     75%    4.93ms
     90%    6.94ms
     99%   74.54ms
  1472441 requests in 15.09s, 130.59MB read
  Socket errors: connect 0, read 265, write 0, timeout 0
Requests/sec:  97553.34
Transfer/sec:      8.65MB