Appendix: Runtime Reference¶
The contracts behind Runtime Adapters, Concurrency and
Caching & AOT Compilation: how a request body is staged, counted and parsed, how
forwarded headers and Lambda events become a request’s identity, what
each adapter does at its edges, what a custom adapter must do, how
concurrently() schedules Fibers, and how the AOT artifact is published
and read. The guides cover choosing and running a runtime, running
independent I/O side by side, and deploying the artifact.
Request bodies¶
An adapter normalizes its transport into a raw PSR-7 request and stops
there. Everything a body means is settled once, inside the Kernel, by
Kinetis\Http\Middleware\RequestBodyMiddleware — staging, the byte
ceiling, and the multipart/form-data and
application/x-www-form-urlencoded parse under Kinetis\Http\Form. The
middleware is global and unconditional. All four adapters deliver the
same bytes to it, so the same form is accepted by all four or refused by
all four with the same status.
Every body is staged before the handler runs¶
Staging happens for every request, form or not. The declared
Content-Length is checked first, so an honestly-labeled oversized
request is refused without being read; then the body is read once,
incrementally, counted, into a replayable in-memory stream
(php://memory), and the request the handler receives carries that
stream, rewound and complete. Reading stops as soon as the count passes
the ceiling, which is a 413, and the handler never runs. A body that is
not a form goes no further than this — nothing parses it, and nothing
invents a getParsedBody() for it.
The byte ceiling is checked against the bytes in hand as well
as the declared Content-Length: a request that understates its length,
or declares none, is bounded only by the first.
Everything downstream therefore sees one body and one length, and no way
of reading it can fail — by then there is no cap left to enforce.
read() and getContents() answer from wherever the cursor stands, so
code that needs the whole body, after another middleware may already
have read it, uses a plain (string) cast — which rewinds first — or
rewinds explicitly.
Settling it in front of the handler is the only way to get that. The
alternative — a stream wrapper that counts as the handler reads — cannot
be made safe. Stringable forbids __toString() from throwing, so such
a wrapper has to answer a cast with something, and the only things
available are a lie or an empty string. An empty string is the dangerous
one: a handler, or any vendor middleware between the wrapper and it,
reads an oversized request as an absent optional body and carries on.
A staging stream that will not open, a read that stalls, or a write that
stops short is this worker’s failure rather than the client’s, so it is a
FormStagingException and a server error — never a 400 or a 413,
and never a body that reaches a handler shorter than it was sent.
Counts come from the raw body¶
FormLimits holds six structural ceilings as class constants — they
describe the shape this framework will hydrate at all — and the byte
ceiling, built once from MAX_BODY_SIZE at the entry point and bound on
AppScope. Nothing reads the environment on its own, so nothing can
disagree about where the edge is.
Every count is taken from the raw body, before anything parses it — the only place the real numbers exist:
a=1repeated a thousand times is a thousand pairs on the wire and one leaf in the parsed form. A limit checked on the parsed result reads that body as a one-field form.A multipart part carrying no
Content-Dispositionname builds neither a field nor a file, so it appears nowhere in the result — while still costing a parser a part.A part repeating one header a thousand times has one entry in any parser’s header map and a thousand lines on the wire.
MultipartEnvelope is the bounded scan that sees all three. A parser
expands the whole body and reports its shape afterwards, so a ceiling
checked on that result is checked after the cost it exists to bound has
been paid. The scan allocates nothing per part beyond its own offsets and
refuses at the first part or header line past a ceiling; the parts it
returns are the ones the parse then builds from.
A runtime configured below the contract¶
The counts sit under PHP’s own defaults (max_input_vars 1000,
max_input_nesting_level 64), but a deployment is free to set either
lower — and parse_str() answers a list past max_input_vars with a
shorter array, and a name nested past max_input_nesting_level by
dropping that variable in complete silence. So the names a parse is about
to be handed are checked against both the contract and this runtime’s own
settings first: past the contract is the usual 413, and past a local
setting while still inside the contract is a 413 naming the setting an
operator can fix. Either way the form is refused before it is parsed
rather than handed on shortened.
Why the SAPI adapters need enable_post_data_reading=0¶
Left at its default, PHP reads and parses the body before any Kinetis
code exists: it populates $_POST/$_FILES for a POST form, empties
php://input doing so, drops everything past max_input_vars with only
a warning, and answers a body over post_max_size with an empty $_POST
and no error at all. None of that is observable afterwards — a form
truncated to its first 1000 fields is indistinguishable from a form that
had 1000 fields. With the setting off, php://input carries the whole
body for every method including POST.
Kinetis\Runtime\SuperglobalsBridge checks the setting before it builds
a request and refuses to serve one without it, with a message naming the
setting. request_parse_body() is not used and cannot be: it reads the
same input stream, so it would leave nothing for the middleware that owns
the body.
Why arg_separator.input must be &¶
parse_str() splits a body on whatever arg_separator.input names,
which is a set of characters rather than a single one. Every count, name
and depth taken here is read by splitting on &, so any other value
parses a different form from the one that was measured. Set to ;, a
body of a=1&b=2&… becomes one field whose value is the rest of the
request. Set to &;, a body of a=1;b=2;… is one pair to the count and
as many as the client likes to the parser — past the ceilings, then cut
back to this runtime’s own max_input_vars in silence.
Kinetis\Http\Form\FormPairs checks the setting whenever it parses a
form — before parse_str() consumes a pair and before a handler is
handed a form; for a multipart body, after its envelope has been split
into parts and expanded. Anything but exactly & is a
FormParserConfigurationException, a server error naming the setting.
The setting is PHP_INI_PERDIR, so nothing at request time can move it.
What a multipart/form-data body may say¶
The same scan enforces what the body means. multipart/form-data is
not one language: parsers disagree about where a part ends, whether its
bytes are decoded on the way out, and what its Content-Disposition
says. Kinetis accepts one reading — the byte-literal RFC 7578 subset —
and refuses, on every runtime, everything a second reading exists for.
The root
Content-Typenames exactly one boundary. Its parameter section is read whole, under the same grammar a part’s own headers meet, and a section that is not a complete list of distinct parameters is a400:boundary=A; boundary=Bis the first boundary to one parser and the second to another, andboundary="A"junkisAto one andAjunkor a failure to the next. A header naming no boundary at all is the separate, ordinary case — nothing to split the body at rather than two ways to split it.A delimiter is
CRLF--boundary, followed by CRLF, or by--and then CRLF or the end of the body. Nothing else is one. A line whose boundary token is only a prefix (--boundaryX) or that carries transport padding before its CRLF is payload — kept byte for byte, not a split point and not an error. A line a parser splitting on\nwould take as a delimiter while this one does not — a boundary after a bare LF, a stray CR before the CRLF — is a400: two readings of one body are two different forms.A part’s bytes are the bytes on the wire.
Content-Transfer-Encodingmay only be7bitorbinary, the two spellings that decode to themselves.base64,quoted-printableand8biteach send a parser that implements them down a decoding or charset-conversion path a parser that doesn’t will never take; RFC 7578 §4.7 does not use the header at all.A part’s metadata is the text on the wire. No RFC 2047 encoded words, no RFC 5987
name*=/filename*=extended parameters, no escapes, surrounding spaces or semicolons inside a quoted value, and each parameter named once, in lowercase. A plainform-data; name="user[address][city]"; filename="café.txt"— what a browser sends — is unaffected.A part is not itself a multipart body. A nested envelope is a whole further form to a parser that recurses into it, one part’s bytes to one that does not, and counted by no ceiling either way. RFC 7578 §4.3 settles multiple files as repeated parts under one name.
A part’s header lines are ordinary, complete header lines. No obs-fold continuation, no line without a name, no control characters, and at most one each of
Content-Disposition,Content-TypeandContent-Transfer-Encoding.A file part that declares no
Content-Typehas no client media type —getClientMediaType()isnull, not theapplication/octet-streama parser’s own default would invent.
Each rule is a place two real parsers disagree, so each is a 400 rather
than a normalization: whichever reading this framework picked would be
the other parser’s answer to the same bytes. The shared runtime
conformance suite sends every one of them at every adapter and requires
the identical answer — see Conformance-testing a runtime adapter.
Refusals and what is logged¶
A body that cannot be parsed is a 400 carrying the fixed
RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE. A body past any
ceiling is a 413 naming the limit and its configured number, which is
safe to return because it contains nothing from the request. Both happen
before the handler runs, and nothing is ever truncated: a form that meets
a ceiling is refused whole, never handed on missing exactly the fields an
attacker chose to push past the edge.
What is logged for a 400 is a fixed category — no-boundary,
ambiguous-boundary, no-parts, unreadable-multipart,
undecodable-part, nested-multipart, ambiguous-delimiter — and never
a parser’s own message. Two refusals outside the middleware log fixed
categories the same way: invalid-base64 for a Lambda body that is not
strict base64, and unreadable-forwarded-header for a trusted edge’s
unreadable scheme. A parser message is assembled from the input that
failed, so it quotes header names, part names, charset labels and body
fragments a client chose; a log is read, searched, shipped and rendered
somewhere. What is lost is which byte offset upset which parser, which
no operator can act on; what is kept is the category, which is what an
operator triages on.
The empty file control¶
A file input the user left alone is still submitted — an empty part with
filename="" — and PHP reports it in $_FILES as present with
UPLOAD_ERR_NO_FILE, no name, no type and no bytes. Every adapter
reports the same, so upload validation written against PHP reads
“nothing was chosen” under all four rather than accepting a successful
zero-byte upload under two of them.
Forwarded headers¶
X-Forwarded-Proto is an ordinary request header: any client that can
reach the listener can send it. A client that can choose the scheme its
own request appears to have arrived over can choose whether a Secure
cookie is set, what every absolute URL the application generates points
at, and whether an OAuth redirect target validates.
Kinetis\Http\TrustedProxies::fromConfig() builds the policy from
TRUSTED_PROXIES, a comma-separated list of addresses and CIDR ranges.
An empty list trusts no peer. An entry that is not an address or a CIDR
range throws InvalidTrustedProxyException at startup rather than
silently matching nothing, since a range that never matches looks
exactly like a correct one that is never reached.
Kinetis\Runtime\HttpStartup registers the policy on AppScope before
the bootstrap chain runs, so bootstrap.php or a package bootstrap can
replace it, and reads it back after boot() to hand to
RuntimeDetector::detect(). An adapter settles a request’s scheme before
the Kernel or its container exist, so it cannot resolve the policy and
must not invent one.
SuperglobalsBridge, under FrankenPHP and PHP-FPM, rebuilds the scheme from what the environment serves — PSR-7’s own server-request creation appliesX-Forwarded-Protounconditionally — and moves it only when the connecting peer matches the policy.RoadRunnerAdapterappliesX-Forwarded-Prototo the request URI only when the connecting peer matches the policy.A trusted peer that sends anything other than exactly
httporhttps, including two schemes folded into one header, is a400carryingRuntimeAdapterInterface::MALFORMED_BODY_MESSAGE, answered while the request is still being built and before any middleware exists. The reason is logged asunreadable-forwarded-headerand never returned. There is no rule that picks the right answer out of two, and the peer that could have gotten it right is the one that got it wrong.
No adapter rewrites REMOTE_ADDR from X-Forwarded-For: the request’s
client address stays the peer that connected. RateLimitMiddleware
walks the forwarded chain under its own policy — see
Appendix: Middleware’s “Forwarded client identity”.
Lambda is the one runtime this policy does not reach, and
BrefLambdaAdapter is the one adapter that takes no TrustedProxies at
all. An invocation arrives over the Runtime API with no connecting client
to weigh: x-forwarded-proto is API Gateway’s own field on an event it
built, so the gateway is the edge by construction. What replaces the
policy there is a platform fact — an HTTP API and a Function URL are
TLS-only, so the scheme is https and a forwarded header cannot move it.
An event claiming http describes an invocation the platform cannot have
delivered, and is refused as malformed rather than honored or ignored.
FrankenPHP and PHP-FPM¶
FrankenPhpAdapter loops on frankenphp_handle_request() and lets an
uncaught exception propagate, which ends the worker. FpmAdapter serves
one request per script run. Both build their request through
SuperglobalsBridge.
A streamed response’s failing emitter is the one throwable the bridge
contains. By then the status, the headers and some number of body bytes
have left the process, so there is no replacement response to send — and
under a persistent worker an escaping throwable ends the worker itself,
taking the warm state every later request on that thread would have used
down with one client’s broken stream. SuperglobalsBridge::emit() catches
it, writes the exception class, message and file:line to the SAPI error
log, and returns to the loop; the client sees the truncated body a
half-sent response can only end as. The Kernel’s own wrapper still
releases the request scope and re-raises the emitter’s failure to whoever
invoked it, and a request that fails before emission begins is still a
500 from ExceptionHandlerMiddleware.
AWS Lambda events¶
Kinetis\BrefAdapter\BrefLambdaAdapter speaks the Lambda Runtime API
directly, with plain stream-context HTTP rather than ext-curl or the
bref/bref package: poll /2018-06-01/runtime/invocation/next, run the
request, post the response to .../invocation/{id}/response, or report a
rejected event to .../invocation/{id}/error. It converts to and from
API Gateway’s HTTP API payload format 2.0 event shape — the format a
Function URL or an HTTP API integration sends. ALB and the older REST
API’s payload format 1.0 aren’t handled.
Event validation¶
Every field this depends on is validated before an event is routed. A
direct Lambda invocation can carry arbitrary JSON. "version": "2.0"
is required to identify a payload-v2 event; a payload-format-1 event
with a similar requestContext.http shape is still refused.
rawPath and requestContext.http.method are required as non-empty
strings; every other field this adapter reads (rawQueryString,
headers, queryStringParameters, body, isBase64Encoded,
requestContext.http.sourceIp, cookies) is optional but, when present,
is checked for the right type — including the exact collection shape,
not only “is this an array”: headers and queryStringParameters must
each be a genuine JSON object with string values (an array-valued
entry, or the field being a JSON list instead of an object, is
rejected), and cookies must be a genuine JSON list of strings (a JSON
object — {"session": "abc"} rather than ["session=abc"] — is
rejected, not silently accepted as a one-entry list).
This distinction only exists at the raw JSON level:
json_decode(..., associative: true) collapses {} and [], and an
object-valued and a string-valued map entry, into the identical shape of
plain PHP array — so validation runs against a separate,
non-associative decode of the same body first, which is the only decode
mode where a JSON object and a JSON list stay distinguishable.
Anything that fails any of these checks is reported to the Runtime API’s
invocation error endpoint — never silently degraded into a
plausible-looking request built from whichever fields happen to be
missing or malformed. An event naming one header under two spellings, a
header name that is not an RFC 9110 token, or a header value with a
control character is refused the same way.
Request identity¶
A payload-v2 event describes where the request was addressed in five
places that can disagree: requestContext.domainName, the host
header, x-forwarded-proto, x-forwarded-port, and
requestContext.http.protocol. Reading each one wherever it happens to
be needed produces a request whose URI, Host header and request target
are three different answers to the same question — and an application
generating an absolute URL, signing a canonical request, or comparing an
origin then behaves differently under Lambda than under any other
runtime, for no reason it can see.
So one field decides each part, every other field must agree with it, and an event where they don’t is rejected before anything is dispatched:
Host —
requestContext.domainName, the one field a client cannot write. Ahostheader is accepted only if it names that same domain, with or without a port; one naming a different domain is refused. TheHostheader the application reads is rebuilt from the domain, so it cannot disagree with the URI.Port —
x-forwarded-port, or the port in thehostheader, and they must match when both are present. A port that is the scheme’s default is not part of the authority, exactly as PSR-7’s own URI treats it.Scheme —
https, decided by the platform rather than by the event: an HTTP API and a Function URL have no plaintext mode at all, so there is no listener a plaintext request could have arrived on.x-forwarded-protois checked against that instead of deciding it — absent orhttpsis what API Gateway sends, and any other value,httpincluded, is refused with everything else that contradicts itself.Protocol version —
requestContext.http.protocol.Request target —
rawPathandrawQueryString, byte for byte, set as the request target rather than rebuilt from a parsed path and a re-encoded query.
Every string that ends up in the URI must be valid UTF-8 with no control
characters or spaces, and rawPath must be an absolute path carrying no
query or fragment of its own. Invalid UTF-8 in a path would otherwise
travel as far as encoding the response payload and fail there, turning a
bad request into a failed invocation; a control character in a request
target is request smuggling looking for somewhere to land.
Field mapping¶
Method, path, query string, and headers — straight from the event’s own
requestContext.http.method/rawPath/rawQueryString/headers. A purely-numeric header name ("123", valid per RFC 9110 — digits are ordinary token characters) is mapped correctly: PHP’s ownjson_decode(..., associative: true)coerces a canonical-integer JSON object key into a real PHP int array key, so it’s cast back to a string before reaching PSR-7’swithHeader(), which requires one.Query parameters —
parse_str()overrawQueryString, the same bytes with the same function every other runtime uses. The event’s ownqueryStringParametersis API Gateway’s lossy summary of that query: it comma-joins a repeated parameter into one value, which PHP would then read as a single parameter whose value contains a comma. It is validated as part of the event’s shape and read nowhere. A purely-numeric parameter name ends up as an int array key here as it does on every adapter, and PHP’s array-lookup semantics coerce a numeric-string read the identical way, so$request->getQueryParams()['123']still finds the value.Cookies — payload format 2.0 carries these as their own top-level
cookies: string[]list, never folded intoheaders. Reconstructed into a realCookieheader and intogetCookieParams(), so cookie- and session-based authentication works the same as it does under FrankenPHP or FPM.The client’s IP address —
requestContext.http.sourceIpis mapped to the request’sREMOTE_ADDRserver parameter. Every invocation arrives over the Runtime API, not a socket PHP itself accepted, so without this every request would look identical to code readingREMOTE_ADDR.The request body — a base64-encoded body (
isBase64Encoded: true) is decoded strictly: invalid base64 is a400rather than an empty body. The decoded bytes are handed on raw toRequestBodyMiddleware.The response body — checked for valid UTF-8 before being handed to the Runtime API, which receives the whole response as one JSON document. A body that isn’t valid UTF-8 (an image, a PDF, any binary payload) is base64-encoded and
isBase64Encoded: trueis set on the payload — API Gateway decodes it again on the way out. A body that’s already valid UTF-8 is sent as-is.Response cookies — every
Set-Cookieheader value is emitted as its own entry in the payload’scookiesarray, never comma-joined with any otherSet-Cookievalue. A cookie’s own attributes (Expires, in particular) already contain a comma, so folding two cookies together would produce a value no client could parse back into distinct cookies.
Streaming and Runtime API failures¶
A controller returning a Kinetis\Runtime\StreamableResponseInterface
throws immediately rather than silently buffering or dropping the stream.
The response is abandoned first, so the request scope behind it is
released on the invocation that created it rather than surviving the
container’s freeze. The Runtime API’s poll/respond contract is one
invocation, one response payload; Lambda response streaming needs a
Function URL configured with InvokeMode: RESPONSE_STREAM, a different
invocation model this adapter doesn’t implement.
A poll or a response POST that fails outright (connection refused, a non-2xx status) throws instead of being treated as an empty response — there is no invocation to serve and nothing meaningful to fall back to, so the function erroring, visible in CloudWatch, is the outcome.
RoadRunner¶
Kinetis\RoadRunnerAdapter\RoadRunnerAdapter builds a
Spiral\RoadRunner\Http\PSR7Worker over Spiral\RoadRunner\Worker::create()
and loops waitRequest()/respond(). It folds a repeated header into
one comma-joined value first, since PSR7Worker presents repeats as
separate array values.
http.raw_body detection¶
Without http.raw_body: true, RoadRunner parses
multipart/form-data/application/x-www-form-urlencoded bodies itself,
in Go, before the PHP worker is invoked, and a body it can’t parse never
reaches PHP — the client gets RoadRunner’s own error response instead of
this framework’s 400. The adapter reads the rr_parsed_body attribute
PSR7Worker stamps on every request, on every request rather than only
on a form one. true is the misconfiguration, reported as an error
naming http.raw_body: true. An attribute that is absent — a worker
library that doesn’t set it — doesn’t mean raw_body is on; it means
nothing here can tell, and that is refused too rather than assumed good,
since assuming it good is exactly how the first case would go undetected.
http.max_request_size¶
RoadRunner reads the whole body into memory as one string before any PHP
runs, and there is no SAPI to enforce upload_max_filesize or
post_max_size. http.max_request_size is enforced in Go, wrapping the
request in http.MaxBytesReader before the PHP worker is invoked — the
only place a body with no declared Content-Length (a chunked request)
can be bounded, since by the time the adapter runs it holds the whole
body with nothing left to read incrementally. RoadRunner’s own default is
1000 MB.
Worker processes¶
Each RoadRunner worker is a separate OS process rather than a thread sharing one process, so nothing is shared between workers to contend over — but process creation has its own, different overhead. FrankenPHP’s measured thread-sizing ratios come from load testing threads and haven’t been re-measured against RoadRunner’s process model.
ext-sockets in the package’s CI¶
kinetis/roadrunner-adapter’s Alpine-based checks (install, PHPStan,
Psalm, the committed unit suite) each run in a separate, stateless
container that never loads ext-sockets at runtime, so compiling it
repeatedly would be pure cost; Composer’s platform check is bypassed
there instead. kinetis/persistence and kinetis/database-bridge
compile it in their PHPUnit steps, because the native Postgres driver
refuses to construct without the extension. The roadrunner-conformance
job gets a prebuilt extension from shivammathur/setup-php — see
Appendix: Continuous Integration.
Writing a runtime adapter¶
To target another environment, implement this interface and Kinetis drives it the same way it drives the four built-in adapters:
namespace Kinetis\Runtime;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RuntimeAdapterInterface
{
/**
* @param callable(ServerRequestInterface): ResponseInterface $handler
*/
public function run(callable $handler): void;
public function isPersistent(): bool;
}
isPersistent() tells Kinetis whether to force a memory cleanup pass at
the end of every request — worth doing in a worker that keeps serving,
pure waste under a boot-per-request SAPI, where request shutdown releases
that memory anyway.
Hold the adapter to the contract the built-in ones meet: implement a
Kinetis\Testing\Runtime\RuntimeAdapterDriver for it and extend
RuntimeAdapterConformanceTestCase — see
Conformance-testing a runtime adapter. Every behavior the core adapters
agree on (how a repeated header folds, where cookies land, the URI’s
scheme, authority and request target, form and binary bodies, the
form-complexity ceilings, response cookies, streaming, and the 400 for
a body the environment can’t parse, whose fixed message is
RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE) runs against it with
no further test code.
An adapter never parses a form body itself. It delivers the raw bytes,
and RequestBodyMiddleware applies Kinetis\Http\Form to them inside
the Kernel — which is what keeps the accepted spellings, the nesting, and
the point at which a client is refused identical under every runtime.
An adapter handed a Kinetis\Runtime\StreamableResponseInterface sends
the status and headers from the response itself, then invokes
getEmitter() — that closure writes body bytes and nothing else. The
request’s RequestScope is still alive while it runs, so a controller’s
streaming code resolves from its own container, and the scope is released
as soon as the emitter returns. An adapter that can’t stream calls
abandon() on the response and answers with its own instead: that
releases the same scope, on the same request, without writing a byte of
the body. Settling one of those two ways is the whole contract — the
Kernel’s own release at the start of the next request is the defensive
path for a response that reached neither, and it logs a warning naming
the method and path when it fires.
An emitter that throws is the one failure an adapter contains rather than lets propagate, for the reason given under “FrankenPHP and PHP-FPM” above.
Fiber scheduling¶
Kinetis\Async is a thin layer over Revolt, the
AMPHP v3 event loop. PHP Fibers are cooperative coroutines; they need an
event loop scheduling I/O around them.
The suspend/resume pattern¶
Every non-blocking primitive in Kinetis\Async is built the same way:
capture the currently-running Fiber, register a Revolt watcher for
whatever condition it waits on, then suspend — and let the watcher’s
callback resume the Fiber once that condition is met.
use Revolt\EventLoop;
$fiber = Fiber::getCurrent();
EventLoop::onReadable($stream, static function (string $watcherId) use ($fiber): void {
EventLoop::cancel($watcherId);
$fiber?->resume();
});
Fiber::suspend();
Called outside a Fiber there is nothing to resume, and Fiber::suspend()
throws PHP’s own FiberError. concurrently() does not need a
surrounding Fiber: its caller waits on a Revolt suspension, which runs
the loop from ordinary code.
Resident Fibers¶
Each task runs in its own Fiber, drawn from Kinetis\Async\FiberPool,
a pool of resident Fibers that park between tasks instead of terminating.
Constructing a Fiber allocates a whole C stack and destroying it frees
one, so reusing a parked resident keeps a fan-out from paying that cost
per task. The pool is per PHP thread and keeps at most 64 idle Fibers. A
task suspended on I/O keeps its Fiber until it finishes, and a wider
burst still runs, on fresh Fibers that aren’t retained afterwards.
A resident Fiber outlives the task that parked it, so the next task to run on it may belong to a later batch — and, in a persistent worker, to a later request. Anything that attaches Fiber-local or Fiber-keyed state must detach or release it before the task returns, on both the success and the failure path, or a later task inherits it. Fiber identity is the carrier that executes a task, not an identifier for that task, its batch, or its request; do not key anything on it that has to outlive the task. Span scopes follow this rule — see Scope ownership across Fibers.
Waiting and completion¶
While tasks are in flight, the caller waits on a Revolt suspension that
the last task to finish resumes — the event loop drives every suspended
task no matter how many times each one suspends internally, or in what
order they finish. Results come back in task order. A task that fails
does not abort the others; the first failure in task order is rethrown
once every task has finished. A task that suspends with nothing
registered to resume it surfaces as Kinetis\Async\Exception\DeadlockException
rather than a hang.
What each client waits on¶
A task overlaps with its siblings only at the points where it suspends:
The native PostgreSQL driver registers readable and writable watchers on its socket.
The native MySQL driver has no socket to watch — mysqli exposes none — and polls
mysqli_poll()from a repeating loop timer. That poll carries aselect()-based descriptor ceiling of its own; see Performance tuning’s “mysqli’s poll limit”.Kinetis\Redis\ClientandAmp\Redis\RedisClientover it wait onAmp\Future.Kinetis\RevoltHttpClient\Httpwaits through Symfony’sAmpHttpClient.A PDO client never suspends. A task that runs PDO statements finishes before
FiberPoolhands the next task a Fiber, so PDO tasks run one after another whatever else the batch holds.
What blocks on the native drivers describes the native drivers’ waits in full.
The AOT artifact¶
Format¶
.kinetis-cache/compiled.php is plain PHP returning a literal array, so
a boot requires it and has the data with no decoding step, and
OPcache’s shared opcode cache — keyed by realpath, shared across every
worker process on a host — skips re-parsing it from the second request
on.
It is one file rather than one per discovery section because a boot
needs the same compile pass throughout: an HTTP boot reconstructs routes,
event listeners and plugin data, and the CLI reconstructs commands, event
listeners and plugin data. Reading them from separate files makes
“routes from one build, listeners from another” a state a mid-deploy
request can land in. Reading them from one file makes it
unrepresentable. Reconstruction is still only what an entry point uses —
an HTTP boot never builds a CommandRegistry — but never from a file the
rest of the artifact didn’t come with.
A compiled artifact carries scalars, arrays and enum cases, and the
build refuses any other object anywhere in one with
UnexportableArtifactException, a compile defect rather than a
persistence failure.
Contents¶
The route table.
Command definitions.
The
#[AsGlobalMiddleware]-discovered class list, already priority-sorted.The
#[AsOpenApiMiddleware]-discovered class list, published as the built-inopenapimiddleware group, already priority-sorted.The
#[AsMiddlewareGroup]-declared groups, each group’s own members already priority-sorted.The
#[Listener]-discovered event listener list, grouped by event class, already priority-sorted.HTTP parameter-binding plans (how each request’s data maps onto your controller method’s parameters).
DTO validation plans.
The installed packages’ bootstrap-class list (declared via
extra.kinetis— see CLI), so production never re-readsvendor/composer/installed.jsonper request.Every installed package’s own
CacheableDiscoveryInterfacedata — declared viaextra.kinetis’sdiscoverykey, also see CLI. MCP tools and resources are one such entry — see Model Context Protocol (MCP).
GlobalMiddlewareDiscovery::discoverAll() performs exactly one
project-wide scan for all three middleware attributes, not three — see
Middleware.
Installed Latte or Twig view adapters use their own generated directories
under the same cache root. They are separate cache layers and are never
folded into compiled.php:
.kinetis-cache/
├── compiled.php routes + global/openapi middleware + named middleware
│ groups + HTTP binding plans + validation plans for
│ DTOs reachable from HTTP routes + command definitions
│ + event listeners grouped by event class + every
│ installed package's own CacheableDiscoveryInterface
│ data + the package bootstrap-class list
└── views/ generated only by a compiled-template adapter
├── latte/ present when kinetis/views-latte is selected
└── twig/ present when kinetis/views-twig is selected
Publishing atomically¶
CacheStore::write() never modifies the live file. It renders the whole
artifact into a uniquely-named temporary file beside it, requires that
file back to confirm it returns the array it was rendered from, and only
then rename()s it onto compiled.php — atomic within one directory on
POSIX, a directory-entry swap rather than a data copy. A reader sees the
complete previous artifact or the complete new one, never a partial
write. A publish that fails at any step leaves whatever was already
there untouched, and removes its own temporary file.
The rename is followed by opcache_invalidate() where OPcache is
loaded. It reaches the calling process’s own OPcache and nothing else. A
boot that compiles in memory and publishes the result goes on serving
that in-memory bundle, so invalidation is not what makes the fallback
publish usable. What it covers is narrower: this process may already
have required a stale or rejected artifact from this path, and clearing
that entry makes a later include by this same process eligible to see
the replacement. It does not reach a separate serving pool, which is why
Caching & AOT Compilation builds the artifact before workers start.
Concurrent publishers are not serialized. Workers cold-starting against a missing artifact each compile once and each publish their own complete copy; whichever rename lands last is what later readers get. Every racing compile discovers the same classes, so the cost is bounded, one-time per process, rather than a difference in what gets served.
Missing, rejected and unwritable artifacts¶
An artifact that is missing, will not parse, does not return an array, or
carries a CacheFormat::VERSION this build does not speak is treated as
absent. So is one whose section a fromArray() rejects with a classified
artifact exception (CacheArtifactExceptionInterface). In each case the
boot compiles in memory and publishes the result. Any other exception
from reconstruction propagates. Nothing is retained, pinned, or
garbage-collected — there is one file, replaced in place.
A boot publishes only what it is already serving: the fresh compile is
reconstructed into live objects first, so one that cannot become them
fails the request instead of being published for the next process to
reject and recompile into the identical failure. That covers the
sections the entry point uses. kinetis build, which has no boot of its
own to serve, reconstructs the whole artifact — the route table, the
command list, the event listeners and every installed package’s
CacheableDiscoveryInterface data — before writing, and a rejected
section fails the command and leaves the previous artifact in place.
A publish that fails with CacheWriteException — a read-only mount, a
full disk — does not take the application down. The boot serves the
value it compiled in memory and writes one line to the error log naming
the artifact it could not publish. Every later boot on that machine pays
the compile again and reports again, so a permanently unwritable cache
directory is visible rather than silent.
MCP tool schemas¶
A tool’s generated inputSchema is the one place this format needs care.
JSON Schema distinguishes the empty object {} from the empty array
[] — an empty properties map is one, an empty required list the
other — and a PHP array expresses only the second, so JsonSchema spells
{} as a live stdClass, which an artifact cannot carry. McpRegistry
therefore stores the schema as its own JSON text, in inputSchemaJson:
a plain string, and the one notation that already carries the
distinction, so every empty object and every empty array comes back the
type it went in as, at any depth. That text is the cache’s own
representation of the schema, not the bytes a transport puts on the
wire. Text that will not parse, or a document whose root is not a JSON
object, is rejected as an invalid artifact, like a malformed route
entry: the boot falls back to live discovery and recompiles, rather than
serving a tool schema that no longer matches what the application
declares. The full rule is in Appendix: Satellite Packages’s McpRegistry
entry.
Performance characteristics¶
For a persistent worker (FrankenPHP or RoadRunner), Router::register()
only ever runs once regardless of caching, since boot happens once for
the whole worker’s lifetime. What still runs on every dispatch, cached or
not, is Dispatcher/Hydrator’s parameter-binding and validation-plan
derivation — precomputing that is typically 10-30% faster, with
query-parameter and validated-body-DTO routes seeing more benefit than
plain path-parameter routes.
For a boot-and-die runtime (PHP-FPM), the picture is different.
Reflecting an already-compiled class’s metadata is cheap and roughly
constant per method, regardless of that method’s body size — a
controller’s file size isn’t the lever. What matters is that live mode’s
Router::register() has to autoload every registered controller class
on every request, because it can’t know which route will match until
the whole table is built. The cached path never touches those files at
boot — Router::fromArray() holds class names as plain strings — so only
the one controller dispatched to gets autoloaded. For an
application with many controller classes, this makes the cached path
several times faster overall: controller-class count and file size are
the real lever at scale, not DTO or binding-plan count.
Cold-start time (container or process startup) dominates the application-level difference at that scale, but a lazily compiled deployment is still consistently the slowest cold configuration: its first request pays the compile-and-write cost a pre-warmed deployment already paid ahead of time.
See also¶
Runtime Adapters — choosing and running a runtime, and the request-body and forwarded-header settings each one needs.
Concurrency — running independent I/O side by side, and when it overlaps.
Caching & AOT Compilation — building and deploying the AOT artifact.
Appendix: Testing Reference — the conformance suite that holds every adapter to this contract.
Appendix: System Layout — the framework’s runtime, async and cache namespaces.