Long-Running Runtimes

Under classic PHP-FPM the process is torn down once the response is sent, so every allocation is reclaimed for free. Persistent runtimes — FrankenPHP worker mode, RoadRunner, Swoole / OpenSwoole — keep the PHP process alive and loop it over many requests. State that survives one request survives into the next, which turns two questions into deployment concerns: does anything accumulate across requests, and does a single expensive request leave the worker bloated for the rest of its life?

Short answer: Chainsaw carries no per-request global state and no in-library cache that grows with traffic, so it runs cleanly under all three. The memory you actually have to reason about is the decoded raster, and it is governed by two constructor knobs plus your worker-recycling policy.

Safe by construction

Nothing in the library mutates process-global or PHP-runtime state per request — no ini_set, putenv, setlocale, output buffering, superglobal writes, or shutdown-function registration. The factory, providers, and renderers are built once and reused; ->image() and every fluent ->with() step return small immutable value objects that are released as soon as the request’s references drop.

The caches that do persist across requests are all bounded by a fixed set, never by traffic:

  • Format-capability probes (EncoderCapabilities, CachedFormatSupport) — keyed by the ~10 Format enum cases. Persisting them across requests is a win: each format is probed at most once per worker.
  • Hash-identity resolution (HashInputNormalizer) — keyed by value-object class, so it tops out at the number of manipulator / anchor / source classes, regardless of how many distinct images you process.
  • Handler and factory registries — populated once at construction.

None of these grow as you serve more images. What can grow is memory you opt into (an in-process cache pool) and memory a single request peaks at (the raster).

Peak raster becomes the worker’s floor

Image decoding is memory-heavy, and PHP does not hand freed memory back to the OS eagerly — a worker’s resident size tends to stay at the high-water mark of the largest request it has served. So the number that matters under a worker is not the average footprint but the worst case: one request that decodes a near-budget image sets the floor for that worker’s remaining life.

A decoded raster costs roughly width × height × 4 bytes (RGBA). Both local providers ship with a ~100 MP default budget on each end:

Knob Guards against Default Disable
maxSourcePixels a source that decodes huge (decompression bomb) ~100 MP pass null
maxOutputPixels a small source a resize / DPR blows up (upscale bomb) ~100 MP pass null
maxSourceBytes a huge source file exhausting memory before it is even decoded ~100 MB pass null

At the 100 MP default a single raster is ~400 MB. That is a safety ceiling, not a target — for a worker deployment set both budgets to the largest output you actually serve. If your biggest variant is 4000×3000 (12 MP), a 24 MP ceiling leaves generous headroom while capping a worker’s raster floor at ~96 MB instead of ~400 MB:

$provider = new InterventionProvider(
    sourceReader: new FlysystemSourceAdapter($sourceFilesystem),
    cache: $cacheFilesystem,
    cachePublicUrl: '/images/cache',
    manager: $imageManager,
    maxSourcePixels: 24_000_000,
    maxOutputPixels: 24_000_000,
);

Set the worker’s memory_limit above the worst-case raster plus the source bytes plus the encode buffer — comfortably more than one raster, since a chain briefly holds the source and the working image at once.

Cap in-process cache pools

The one way to make Chainsaw itself grow unbounded under a worker is to hand it an in-process cache pool that never evicts. Two seams take a PSR-6 pool:

  • existsPool on the local providers’ VariantCache — one boolean per variant path served (details).
  • The pool behind CachedMetadataResolver — one entry per source, and per version if you version (orphan growth).

Both default to no in-process growth (existsPool defaults to null; the metadata resolver is opt-in), so out of the box there is nothing to bound. If you add a pool under a persistent worker, back it with a store that has its own eviction — APCu, Redis, Memcached — or a shared PhpFilesAdapter, not an unbounded in-memory ArrayAdapter. A TTL (existsCacheTtl, or the resolver’s $ttl) bounds growth only on a store that actually prunes expired entries; an in-memory array prunes lazily and will still climb.

Recycle workers

Even with tight budgets and bounded pools, periodic worker recycling is cheap insurance against allocator fragmentation and third-party drift (Imagick especially — see below). Every runtime can restart a worker after N requests:

Runtime Knob Where
FrankenPHP bound the frankenphp_handle_request() loop, or MAX_REQUESTS with the Symfony runtime worker script / env
RoadRunner pool.max_jobs .rr.yaml
Swoole / OpenSwoole max_request server set([...])

A minimal FrankenPHP worker loop that recycles and collects cycles between requests:

// Boot the container / factory ONCE, outside the loop.
$factory = require __DIR__ . '/bootstrap.php';
$handler = static function () use ($factory) { /* handle one request */ };

$max = (int) ($_SERVER['MAX_REQUESTS'] ?? 500);
for ($served = 0; $served < $max; $served++) {
    if (! \frankenphp_handle_request($handler)) {
        break;
    }
    \gc_collect_cycles(); // reclaim any cycles the request left behind
}

gc_collect_cycles() is optional hygiene: Chainsaw’s own value objects are acyclic, but the imaging drivers allocate native handles worth releasing promptly.

Prefer Vips; watch Imagick

The library holds no reference to the decoded image past the request that made it, so native handles (GD, Imagick, Vips) are released by refcount when generation returns. That said, the Imagick PECL extension has a long-standing reputation for not returning all memory in long-running processes, independent of how the caller behaves. For worker deployments:

  • Prefer the Vips driver (intervention/image-driver-vips) — lowest memory, designed for streaming, fastest on the effect-heavy manipulators.
  • GD is a fine second choice and frees predictably.
  • If you must run Imagick, lean on worker recycling and set ImageMagick’s own resource limits (Imagick::setResourceLimit(), or the MAGICK_MEMORY_LIMIT / MAGICK_AREA_LIMIT environment variables) so a single operation can’t balloon.

Single-flight blocks the worker it runs on

If you enable the optional SingleFlight de-duplication, note that its wait is a busy-loop (usleep) up to its timeout seconds. A worker handles one request at a time, so a waiter ties up that worker for the wait — the same cost as an FPM child, but worth sizing your worker pool around if encodes are slow and cache-miss storms are possible. Back the lock with a real shared store (Redis, a database) so the de-dup actually spans workers; the default is best-effort and never gates correctness.