Skip to content

Runtime replay

Static analysis asks:

Could this code retain state between requests?

Runtime replay asks:

Can request B actually observe state left behind by request A?

Those are different questions, and the second catches things the first cannot. worker-safety test replays an ordered list of HTTP requests against an application that is already running, then reports whether a later request saw what an earlier one wrote.

This class ships with the package, in tests/Fixtures/Replay/static-safe/:

final class RequestContext
{
private ?string $user = null;
public function setUser(string $user): void
{
$this->user = $user;
}
public function user(): ?string
{
return $this->user;
}
}

Nothing about it is unsafe. No static property, no global, no singleton holder, no container binding — just a mutable instance property, like most objects in most applications. The scanner says so:

Terminal window
worker-safety scan tests/Fixtures/Replay/static-safe --fail-on=high
# No worker-safety risks found.
# Result: PASSED exit 0

That is the correct answer. Whether this class leaks depends on who constructs it and how long they keep it, and neither fact is in the file.

Now use it from a worker loop — one object, reused for every request:

$context = new RequestContext(); // once, outside the loop
while ($request = receiveRequest()) {
handle($request, $context); // every request shares it
}

Replay two requests. The first sets a user; the second asks for one and never mentions a name:

Terminal window
worker-safety test --scenario=scenario.yaml
Step 2 observe
✓ GET /context
✓ status = 200
✗ json.user
Expected:
null
Observed:
"alice"
Result: FAILED exit 1

Request B observed "alice", which only request A ever sent.

The package ships two worker fixtures that differ by one line — where new RequestContext() sits relative to the accept loop. The same unmodified scenario runs against both:

Result
Static scan of RequestContext PASS — 0 findings
Replay against the leaky worker FAIL — request B observed "alice"
Replay against the fixed worker PASS — request B observed null

The third row is what makes the second meaningful. Because nothing about the scenario changes between runs, the difference is attributable to the object’s lifetime and to nothing else. A permanently red assertion would otherwise be indistinguishable from a broken replay engine.

Both are pinned as permanent regression tests (ReplayStaticGapRegressionTest, ReplayFixedWorkerControlTest), which start real processes and make real HTTP requests.

Replay demonstrates cross-request behaviour only when consecutive requests reach the same persistent process. Against a load-balanced deployment, or any worker count above one, request B may be served by a process that never handled request A — so a leak that exists will often still report as clean.

This is not a performance setting. Treat replay as a test-environment tool.

Terminal window
php artisan octane:start --workers=1

frankenphp run on its own does not give you one worker: the worker thread count defaults to twice the number of CPUs. The count is part of the worker configuration:

{
frankenphp {
worker {
file ./public/index.php
num 1 # one PHP thread for this worker; default is 2x CPUs
}
}
}

The short form and the environment variable take the count as the second value:

Terminal window
# inside a Caddyfile frankenphp block
worker ./public/index.php 1
# or with the Docker image
docker run -e FRANKENPHP_CONFIG="worker ./public/index.php 1" ...

num is what provides the guarantee; the command used to start the server does not. These forms come from the FrankenPHP worker and configuration documentation. The explicit num 1 setup was exercised in the local 1.2.0 release checks above; the automated suite continues to use the portable fixture.

version: 1
name: shared request context leak
base_url: http://127.0.0.1:8080
steps:
- id: seed
request:
method: POST # GET | POST | PUT | PATCH | DELETE
path: /__worker-safety/context
headers:
Authorization: Bearer test-token
json: # sends application/json
user: alice
expect:
status: 200
- id: observe
request:
method: GET
path: /__worker-safety/context
expect:
status: 200
json:
user: null
Key Notes
version Must be 1. Anything else is rejected, not guessed at.
name Required, non-empty.
base_url Absolute http:// or https://. May be omitted when --base-url is passed.
steps[].id Optional; defaults to step-1, step-2, … Must be unique.
request.json / request.body Either one, never both.

Unknown keys are errors at every level, so a typo fails the run instead of silently doing nothing. Scenarios are read as data: the YAML is parsed without object or custom-tag support, exactly like worker-safety.yaml.

Key Meaning
status: 200 HTTP status equality.
json: {user: null} Value at a dot-path equals the given value.
headers: {X-Tenant: foo} Header equality, case-insensitive.
body_contains: [success] Raw body contains every string.
body_not_contains: [alice] Raw body contains none of them.

json paths are simple dot-paths, not JSONPath: user, tenant.id, items.0.id. A numeric segment indexes a list.

Comparison uses JSON’s type model, not PHP’s:

  • Object key order is irrelevant. {"a":1,"b":2} and {"b":2,"a":1} are the same document.
  • Array order matters. [1,2] is not [2,1].
  • An object is never an array. {} and [] stay distinct, on both the response side and the YAML side.
  • Types are preserved. 1, "1" and true are three different values.
  • 1 equals 1.0, applied at every depth — YAML and JSON disagree about integer versus float more often than anyone cares about.
  • Missing is not null. An absent path is reported as “no value at this path”, never as null, because {"user": null} and {} are different answers to the question replay asks.
Terminal window
worker-safety test --scenario=worker-safety.replay.yaml
worker-safety test worker-safety.replay.yaml --format=json
Option Meaning
--scenario, -s Scenario file. Also accepted as a positional argument.
--base-url, -b Override the scenario’s base_url.
--format, -f console (default) or json.
--timeout, -t Per-request inactivity timeout in seconds (default 10).
--quiet, -q Suppress output; the exit code still reports the result.

--timeout is an inactivity timeout, not a total deadline: it fires when no data arrives for that long. A response that keeps trickling bytes resets the window and is not cut off.

Code Meaning
0 Every expectation held.
1 At least one expectation did not hold.
2 The scenario or an option was invalid.
3 A request could not be completed — connection refused, timeout, incomplete or malformed response.

3 is deliberately not 1. A request that never completed observed nothing either way, so it is not evidence about state. In particular a response whose body stops short of its Content-Length, or that stalls mid-body, is a transport error: body_not_contains: [alice] cannot be satisfied by a body that was cut off before alice could appear.

On a transport error nothing is written to stdout, including in --format=json, so a failed run can never be mistaken for a successful report.

{
"version": 1,
"scenario": {
"name": "shared request context leak",
"target": "http://127.0.0.1:8080"
},
"passed": false,
"steps": [
{
"id": "observe",
"request": "GET /context",
"status": 200,
"passed": false,
"duration_seconds": 0.0012,
"assertions": [
{
"type": "json_equals",
"path": "user",
"expected": null,
"actual": "alice",
"actual_missing": false,
"passed": false,
"detail": null
}
]
}
]
}

version is the replay schema version, independent of both the tool version and the scan report’s schema. actual_missing distinguishes an absent path from an observed null; when it is true, actual is null only because JSON has no other way to say “nothing was there”. Reported values keep the type the response sent, so an empty object comes back as {} and an empty array as [].

Expose a test-only endpoint that reads and writes whatever request-scoped state you want to check, then:

Terminal window
php artisan octane:start --workers=1
worker-safety test --scenario=worker-safety.replay.yaml

Worker Safety does not generate routes for you. What counts as request state is yours to decide, and injecting endpoints would break the rule that this tool never modifies or executes your code.

Protect any such endpoint so it cannot be reached in production — register it only in a non-production environment, behind middleware, or in a route file production never loads. An endpoint that reports internal state is not something to ship.

  • It never loads your application. No bootstrap, no autoload, no container, no instantiation of your classes. test is an HTTP client, the same way scan is a parser.
  • It proves nothing about paths you did not replay. A pass covers the requests in the scenario against that instance, and nothing else.
  • It does not inspect memory, objects or the heap. What it sees is what the application chose to put in a response.
  • It does not correlate or pin workers. There is no worker pinning and no distributed tracing — which is why the single-worker requirement exists.
  • It does not analyze queue workers. Replay drives HTTP requests only.
  • Runtime coverage is limited. The automated suite uses a portable PHP fixture. Local release checks covered the specific FrankenPHP and Octane versions above; other versions and Octane backends were not exercised.

Scenarios can carry tokens and cookies. Neither reporter echoes request headers, in console or JSON output, and nothing from a scenario is written to a baseline. Worker Safety sends no telemetry.

Replay validates both Content-Length and chunked framing before evaluating assertions. A truncated chunk, missing final chunk, or unfinished trailer section is a transport error (exit 3), even if the partial body contains no leaked data. Valid chunk extensions, trailers, and empty chunked bodies are accepted. Unsupported transfer encodings are rejected. A response delimited solely by connection closure cannot tell the client whether the application intended more content.

The scenario’s request.json preserves mappings as JSON objects and sequences as arrays. For example, json: {config: {}, list: []} sends {"config":{},"list":[]}, including when these values are nested.