phasync / hooked
Cooperative blocking PHP function replacements for phasync
Requires
- php: >=8.1
- phasync/phasync: ^2.0.4
Requires (Dev)
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 07:37:36 UTC
README
PHP replacements for blocking global functions, so that code translated by phasync-babel (which rewrites calls to mapped global functions into calls to these) waits cooperatively inside phasync coroutines, without the phasync C extension.
Every replacement lives in namespace phasync\hooked and has the exact signature of the native
function it replaces. src/map.php returns the table phasync-babel reads to do the rewriting.
One pair, php_sapi()/php_sapi_name() (see "SAPI name" below), is not a wait at all and so
does not follow the rules below; everything else is.
Rules every replacement follows
- Same signature as the native function (parameter names, types, defaults, by-reference parameters, return type).
- Outside a coroutine (
\Fiber::getCurrent() === null): call the native function and return its result. Nothing else. - Inside a coroutine, never block the process: make one native, non-blocking call first.
If it already made progress (data read, room to write, or genuine EOF), that is the result --
returned without ever checking phasync's own cancellation, the same as a call that happened to
find the stream already ready would need no wait at all. Only when that attempt cannot make
progress (
EAGAIN-equivalent:''/falseon a read that isn't at EOF,0bytes written on a write) does this wait with phasync (phasync::readable(),phasync::writable(),phasync::sleep()), then make the same native call again, now that it should be able to proceed. Either way the result must equal what the native call would have returned: return value, warnings and errors (same message text), side effects (e.g.stream_select()modifying its array arguments). - Never leave a stream's blocking mode changed: if a replacement switches a stream to non-blocking for one call, it switches it back before returning, also on exceptions. A stream the caller already set to non-blocking themselves is left alone: the native call is used directly (it doesn't block).
- Regular files (
stream_get_meta_data()['wrapper_type'] === 'plainfile') don't block: the native function is called directly, same as an in-memory stream (php://memory,php://temp-- not backed by a real file descriptor, so there is nothing for phasync's poller to wait on). - Data already in PHP's stream read buffer, or already in the socket's kernel buffer, is picked
up by that first native attempt (rule 3) without any wait --
unread_bytesis not checked specially to decide whether to wait; trying first subsumes it. - A stream's own timeout (
stream_set_timeout()) is respected -- see "Timeouts" below for how, since PHP gives no public way to read a stream's configured timeout back. - No defensive guards, no catch-all fallbacks: misuse fails loudly, as the native function would.
Functions implemented
Time
sleep, usleep, time_nanosleep, time_sleep_until.
Streams
fread, fwrite, fputs, fgets, fgetc, fgetcsv, stream_get_contents,
stream_get_line, fpassthru, stream_copy_to_stream; stream_select; stream_socket_client,
fsockopen (connect with STREAM_CLIENT_ASYNC_CONNECT, wait writable, then report the exact
result or error the blocking connect would); stream_socket_accept; flock (a blocking lock
request becomes repeated LOCK_NB attempts with a phasync backoff); stream_set_timeout and
fclose (not cooperative waits themselves -- see "Timeouts" below for why they're hooked).
fgets(), stream_get_line() and fgetcsv() accumulate across as many underlying reads as a
line, delimiter, or CSV record (including one with a quoted field spanning more than one line)
takes to complete, matching the native functions' own blocking behaviour exactly rather than
returning a short result. fwrite()/fputs() do the same for writing: a blocking write places
all of $data, waiting for room as many times as it takes. fread() does not: a blocking
fread() on a socket or pipe returns after one underlying read with whatever arrived, even if
less than asked for and more is coming -- confirmed empirically to be genuine native behaviour,
not a limitation, so the hooked replacement matches it by doing the same.
Processes
exec, shell_exec, system, passthru (each runs $command cooperatively via its own
internal proc_open(), capturing only stdout -- stderr is inherited straight through, same as
the native functions); popen, pclose, proc_open, proc_close; pcntl_wait,
pcntl_waitpid (a blocking wait becomes repeated WNOHANG probes with a phasync backoff, the
same idea as flock()'s LOCK_NB backoff).
SAPI name
php_sapi_name(), and the PHP_SAPI constant via php_sapi() (phasync-babel maps a constant to
a function call, never to another constant -- see its README). Neither one follows the rules
above: there is nothing to wait for here, only state to read. A host running requests without the
phasync extension (Swerve, say) runs each request in its own \Scope (phasync's per-request
scope) and calls phasync\hooked\set_sapi_name(\Scope $scope, ?string $name) once per request
with that scope and its SAPI name; php_sapi()/php_sapi_name() then report that name for as
long as $scope is the current one (\Scope::getCurrent()), including from inside a coroutine
started with phasync::run()/phasync::go() while the scope is current, since those nest inside
it. Code that runs outside any such scope -- while the application boots, in a background
coroutine the host did not tie to a request -- keeps reading the real PHP_SAPI, same as without
this package. The override is held in a WeakMap keyed by the \Scope, so it is forgotten the
moment that scope is destroyed; set_sapi_name($scope, null) also clears it explicitly.
sapi()
phasync\hooked\sapi(\Closure $code, object $sapi): mixed is the userland match for
ext-phasync's phasync\ext\sapi() (its contract is the docblock of sapi()
in phasync.stub.php): runs $code as one web request, so a host without the extension (Swerve,
without --ext) can still run many requests concurrently in one worker. Keyed by
\Scope::getCurrent() at the call, the same WeakMap mechanism set_sapi_name() above already
uses -- which is why Swerve gives every request its own \Scope (see swerve-modes'
HttpConnection::serve()). Calling it again in the same \Scope before the first call returns
throws an Error, same as the extension. Unlike the extension (an engine-level intercept, usable
anywhere), this needs a coroutine to run its writer in: call it from inside
phasync::run()/phasync::go().
Per request: echo/print/inline HTML/printf/var_dump()/displayed errors/C extensions'
output (all of it, unrewritten -- see "The bottom buffer" below); header(), header_remove(),
headers_list(), headers_sent(), http_response_code(), setcookie(), setrawcookie(),
header_register_callback(); ob_start() and the rest of the ob_* family, and flush();
php://input (fopen(), file_get_contents(), readfile()), request_parse_body(),
is_uploaded_file(), move_uploaded_file(); connection_aborted()/connection_status();
PHP_SAPI/php_sapi_name() (via set_sapi_name(), above); the session (session_start() and
the rest of the session_* family, $_SESSION -- see "Sessions" below). Nothing else: statics,
other globals, declarations, shutdown functions and error handlers are the worker's, same as the
extension's sapi().
The bottom buffer. The first sapi() call in the process installs one real output handler,
ob_start($router, 1) -- chunk size 1, so every single write reaches it -- underneath everything
else, forever: every later request's echo etc. reaches it the normal way PHP already has, no
rewriting. The router looks up the current request by \Scope; inside one, bytes go to that
request's own, separately tracked ob_start() stack (never the real one again -- this package's
ob_* replacements only ever touch their request's own stack), cascading through it exactly like
nested real handlers do (chunk sizes, callback phase flags, CLEANABLE/FLUSHABLE/REMOVABLE),
until they leave it for $sapi->output(), written by a coroutine of the request's own, with
cooperative waits ($sapi->send_headers() runs once, just before the first byte, or at the end of
a request with none); outside a request, the router is a pure passthrough.
Limits, from that design:
- Once installed, this one real handler never goes away -- but every
phasync\hookedob_*replacement hides it outside a request:ob_get_level(),ob_get_status()andob_list_handlers()read as if it did not exist, andob_end_clean()/ob_end_flush()/ob_flush()/ob_clean()/ob_get_flush()/ob_get_clean(), with nothing else on the stack, report "no buffer at all" (native's own wording for that case, confirmed empirically, not the gated-level one) rather than actually flush, clean or pop it -- it isREMOVABLE/FLUSHABLE/CLEANABLEitself (ob_start()'s own default flags), so a native call there, unguarded, would. A level the application opens itself outside a request, with a plain nativeob_start(), still works exactly as it would without this package, right above the hidden router. Only reachable through this package's own replacements, not a raw nativeob_get_level()call that babel never rewrote (nothing between it and the real stack to hide anything from). - Output written by code that keeps running after
$codehas returned or thrown (a fiber of the request still alive) is discarded: the writer only exists for the request's own lifetime. header()'s'Location'implies303for a non-GET/HEAD request whose protocol ($sapi->request_info()['protocol'],'1.0'/'1.1') is newer than HTTP/1.0, matchingmain/SAPI.c's own rule exactly;302otherwise, including whenrequest_info()has no'protocol'entry at all (an$sapithat predates it, treated the same as "unknown", not as HTTP/1.0 itself).- A notice/warning this package itself raises (
ob_flush()'s "Failed to..." gating messages,header()'s "headers already sent", ...) is alwaysE_USER_WARNING/E_USER_NOTICE-- userland code has no way to raise a trueE_NOTICE/E_WARNING-- and names this package's own source line, sincetrigger_error()cannot attribute a different one; native names the caller's.headers_sent()'s own file/line (not the warning's) is the caller's, best-effort: captured from the real output handler when the very first byte reaches it directly from an echo/print/ inline-HTML statement, but not when it first leaves through an explicitob_flush()/ob_end_flush()/ob_get_flush()call or the implicit end-of-request flush of a still-open level -- those report no location (''/0) rather than guess wrong. ob_get_status()'sbuffer_sizeis always16384(a constant, not a real, growing allocation); a\Closurehandler's reportednameis a best-effort{closure:file:line}(there is no userlandzend_get_callable_name());ob_get_clean()'s/ob_get_flush()'s confirmed native double-notice when a level is notREMOVABLEis reproduced as one notice, not two.request_parse_body()(implemented here on every PHP version this package targets, not just the native 8.4+): no RFC 2231 (filename*=) decoding;full_pathalways equalsname;max_multipart_body_partshas nophp.inifallback (unlimited unless given in$options); the size-limit warnings' wording is a reasonable approximation, not guaranteed byte-identical to the engine's own.is_uploaded_file()/move_uploaded_file()only ever recognise a file this package's ownrequest_parse_body()made, for the current request.
Sessions
session_start() and the rest of the session_* family are a request's own, matching
ext-phasync's sapi(): "A request's session is written and closed the way RSHUTDOWN would when
it returns or throws; two requests on the same session id serialise on the files handler's lock
cooperatively instead of blocking the worker." $_SESSION -- a plain global, unlike $_GET/
$_POST/$_COOKIE/$_FILES, which \Scope itself already isolates (confirmed in its own
source) -- is made a request's own the same way swerve-modes' README documents for exactly this
("per-request process state, for framework authors"): a phasync::onScopeChange() hook
(ensureSessionScopeSwapInstalled(), installed lazily on the first session_start()) saves the
leaving scope's $_SESSION into a WeakMap slot and loads the entering scope's, for every scope
in the process (including the worker's own root one) once installed at all.
Unlike sapi()'s own output router, nothing here is ever left globally active outside a request:
native session_encode()/session_decode() need an active native session to work at all
(confirmed empirically -- "Cannot encode non-existent session"), and activating one for real would
mean one process-wide PS(id)/PS(session_status) shared by every concurrent request, so this
package never touches the engine's session module from inside a request. Instead,
session.serialize_handler's three formats (php, php_serialize, php_binary) are
reimplemented in pure PHP (session_serialize.php), confirmed byte-for-byte interoperable with
native both ways (a file either one writes, the other reads correctly) -- the one non-obvious
piece is finding where one serialized value ends inside a larger buffer (needed for php's
key|value and php_binary's length-prefixed-key formats): unserialize() has no public way to
report that, but reliably warns "Extra data starting at offset %d of %d bytes" for trailing
bytes after a complete value, and that offset is the answer.
The default 'files' backend is on-disk compatible with native (save_path's N;MODE;PATH form,
sess_<id> file names, N levels of one-character subdirectories from the id -- never created by
this package, same as native: an operator-managed sharding scheme) and locks the same way:
a blocking exclusive flock(), held from session_start() to session_write_close()/
session_abort(), through phasync\hooked\flock() -- so a second request on the same id waits as
a coroutine, other coroutines running meanwhile, exactly the contract's wording. A custom save
handler (a \SessionHandlerInterface object, optionally also \SessionIdInterface/
\SessionUpdateTimestampHandlerInterface, or the six-to-nine-callables form) is called exactly
the way native calls one, with no extra locking imposed on top -- same as native, where only the
files handler locks itself.
The session cookie and session.cache_limiter's response headers go through this package's own
setcookie()/header(), confirmed byte-for-byte against native (php -S, since the CLI SAPI
drops every header). Deviations:
session.use_trans_sidis not supported: only a cookie carries the session id, never a URL parameter, and theSIDconstant is never defined.private/private_no_expire/publiccache limiters skip theLast-Modifiedheader native also sends (confirmed againstphp -S): it stats the running script's own file, which this package's$sapicontract has no path for.php/php_binaryencode each$_SESSIONentry with its own, independentserialize()call: two entries that are the same object/array by reference lose that shared identity on decode (each becomes its own copy) -- native's single call across the whole array preserves it via a cross-entry backreference this package has no way to reproduce from pure PHP.session.lazy_write's skip-the-write optimisation is approximated by re-encoding and comparing to what was read, not a cheaper dirty flag (no hook on$_SESSIONwrites to maintain one).session_start()'s$options['read_and_close']closes the backend right after reading (releasing the lock early) and never writes back; a session already closed this way is not reactivated.
Timeouts
Current PHP's stream_get_meta_data() no longer exposes the sec/usec keys it once did
(confirmed empirically on PHP 8.5, and in php-src: PHP implements a socket stream's timeout with
its own poll()/select() in userland, storing the value only in the stream's internal C struct
-- not even ext-sockets' socket_get_option(SO_RCVTIMEO) sees it, since the kernel socket
option is never actually set). So this package tracks a stream's timeout itself:
phasync\hooked\stream_set_timeout() records what it's given in a table keyed by
get_resource_id(); phasync\hooked\fclose() drops the entry when the stream closes (resource
ids are reused); a stream never explicitly given one falls back to the default_socket_timeout
ini setting, read fresh on each use rather than once at the stream's creation (the way PHP itself
does it) -- this only differs from native when code changes that ini setting at runtime after
opening streams, which is rare -- but only for a socket stream
(stream_get_meta_data()['stream_type'] containing "socket", confirmed empirically for
stream_socket_pair(), a TCP/TLS or UDP client, and a Unix socket). A pipe
(proc_open()/popen()) or STDIN reports "STDIO" instead and has no timeout concept at all
natively (stream_set_timeout() on one is a confirmed no-op) -- those wait unbounded, same as a
real blocking read on a pipe does.
Every hooked stream read/write (fread, fwrite, fputs, fgets, fgetc, fgetcsv,
stream_get_contents, stream_get_line, fpassthru, stream_copy_to_stream) waits at most
that long, and reproduces the native function's exact result and timed_out state on a timeout.
Setting stream_get_meta_data()['timed_out'] is itself only possible by engaging PHP's own
timeout machinery for real, once: after phasync's cooperative wait already exhausts the
configured timeout, one genuinely blocking native call runs with the timeout forced to a small
non-zero value (confirmed empirically that an exact zero is specially treated as "don't wait at
all" and never sets the flag) -- nothing is actually waited for since phasync already confirmed
there's nothing ready, so this resolves immediately.
That forced value is deliberately not restored immediately: stream_set_timeout() itself
always resets timed_out back to false as a side effect of being called at all, which would
erase the very flag this is meant to set right before the caller observes it. Instead, every
hooked function that waits on a stream's timeout calls phasync\hooked\restoreStreamTimeout()
first, before anything else -- so the real timeout (the recorded one, or the socket default) is
back in place before the start of whichever hooked call touches that stream next, and
phasync\hooked\stream_set_timeout() does the same before setting a new one. This package's own
waits never depend on the native attribute anyway (they use their own table), so the forced-tiny
value never affects this package's own correctness either way. The window it is actually
observable in is narrow but real: between a hooked call timing out and whatever touches that
stream next, untranslated code (phasync itself, an excluded library) making a native call
directly on that same stream would see the forced-tiny timeout instead of the real one -- this
package has no way to act any earlier than its own next hooked call on that stream.
What a timeout returns varies by function, matching native exactly in each case (confirmed
empirically, since it is not consistent): fread, fgetc, stream_get_contents, fpassthru
return whatever had been read so far (possibly nothing); fgets, fgetcsv do the same, keeping
whatever line/record text had accumulated; stream_get_line, fwrite, fputs and
stream_copy_to_stream return false outright even when something had already been
read/written -- confirmed empirically to be real native behaviour (stream_copy_to_stream's
destination genuinely receives what had been copied before the timeout, while the function
itself still reports false).
Deviations from native behavior
Everything below is a real, confirmed gap between a hooked replacement and the native function it replaces, kept because closing it would require something PHP does not expose, or would cost far more implementation complexity than the case is worth. Nothing here is silent: each is also noted in the relevant source file's docblock.
stream_copy_to_stream()'s seek-failure warning text. When$offsetis given and the source cannot seek, the native function emits two warnings: the engine's own (from whichever built-in is executing) and the function's own wrapping one. Callingfseek()from hooked userland code makes the engine's own warning namefseek()instead ofstream_copy_to_stream()-- the one piece of warning text this cannot reproduce exactly, since it is generated by the engine for whichever built-in function is currently executing. The wrapping warning and thefalsereturn value still match exactly. (stream_get_contents()'s$offset, by contrast, checksseekablefirst and falls back to discarding bytes by reading, exactly like the native function does silently -- no warning either way, matching.)stream_select()'s$exceptis never cooperatively waited on. phasync has no wait primitive for an "exceptional condition" (out-of-band data);$exceptstreams are only checked at the final zero-timeout native poll that reproduces the overall result. An exceptional condition that arises and clears entirely during the wait, without a watched$read/$writestream also becoming ready at the same moment, is missed. Separately, when$readand$writeare both empty/null (nothing to race), the native function runs directly, which blocks the process for$secondsif given -- real translated I/O code never callsstream_select()this way (as a timer with no streams), so this is accepted rather than specially handled.stream_socket_client()/fsockopen()depend onext-socketsfor exact failure reporting. Reproducing the native blocking connect's precise$error_code/$error_messageon a failed asynchronous connect (STREAM_CLIENT_ASYNC_CONNECT) usessocket_import_stream()+socket_get_option(SOL_SOCKET, SO_ERROR)-- confirmed to match the native blocking failure exactly (errno 111, "Connection refused" for a real refusal).ext-socketsis a common but optional PHP extension; without it loaded, these two hooked functions cannot determine a failed connect's cause inside a coroutine.popen()/pclose()are reimplemented onproc_open(). The nativepopen()handle gives no public way to poll whether its process has exited without reaping it (pclose()is the only such operation, and it is exactly the blocking call this package exists to replace), so hookedpopen()starts the command withproc_open()instead, using\STDIN/\STDOUT/\STDERRas the descriptor spec entries for whichever two standard descriptors$modedoes not capture (confirmed empirically to match nativepopen()'s own fd inheritance: a child's stderr underpopen($command, 'r')reaches this process's own stderr, not the returned pipe), and records the association between the returned pipe and the process sopclose()can poll it cooperatively, the same wayproc_close()does.exec()/shell_exec()/system()/passthru()build their own child process. None of the four hand the caller a stream to wait on, so each starts$commandwith its own internalproc_open()(a string command already runs through/bin/sh -c, same as the native functions), inheriting stdin/stderr the same waypopen()does and capturing only stdout, which is read to EOF with the same chunked loopstream_get_contents()/fpassthru()use (hookedReadLoop()), then reaps the child the wayproc_close()does.exec()'s$outputandsystem()'s/exec()'s return value are built from the raw captured stdout with the exact algorithm the native functions use (confirmed empirically): split on"\n", drop exactly one trailing empty segment if the capture ended in"\n",rtrim()each remaining line, and take the last one. Anull === array_key_last($array)check is used instead of$array[array_key_last($array)] ?? ''for the empty-output case, since the latter indexes with anullkey, which PHP 8.5 deprecates ("Using null as array offset") where the native functions raise no such notice on empty output. All four return their documented failure value (false, ornullforshell_exec()/passthru()) if the internalproc_open()call fails outright -- essentially unreachable through a real shell command, the same caveat the native functions have (sh -c anythingalways "runs" even whenanythingitself fails).