Search by

phasync / hooked

frodeborli

Cooperative blocking PHP function replacements for phasync

Package info

github.com/phasync/hooked

pkg:composer/phasync/hooked

Statistics

Installs: 5

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-23 07:36 UTC

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

  1. Same signature as the native function (parameter names, types, defaults, by-reference parameters, return type).
  2. Outside a coroutine (\Fiber::getCurrent() === null): call the native function and return its result. Nothing else.
  3. 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: ''/false on a read that isn't at EOF, 0 bytes 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).
  4. 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).
  5. 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).
  6. 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_bytes is not checked specially to decide whether to wait; trying first subsumes it.
  7. 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.
  8. 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\hooked ob_* replacement hides it outside a request: ob_get_level(), ob_get_status() and ob_list_handlers() read as if it did not exist, and ob_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 is REMOVABLE/FLUSHABLE/ CLEANABLE itself (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 native ob_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 native ob_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 $code has 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' implies 303 for a non-GET/HEAD request whose protocol ($sapi->request_info()['protocol'], '1.0'/'1.1') is newer than HTTP/1.0, matching main/SAPI.c's own rule exactly; 302 otherwise, including when request_info() has no 'protocol' entry at all (an $sapi that 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 always E_USER_WARNING/E_USER_NOTICE -- userland code has no way to raise a true E_NOTICE/E_WARNING -- and names this package's own source line, since trigger_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 explicit ob_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()'s buffer_size is always 16384 (a constant, not a real, growing allocation); a \Closure handler's reported name is a best-effort {closure:file:line} (there is no userland zend_get_callable_name()); ob_get_clean()'s/ob_get_flush()'s confirmed native double-notice when a level is not REMOVABLE is 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_path always equals name; max_multipart_body_parts has no php.ini fallback (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 own request_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_sid is not supported: only a cookie carries the session id, never a URL parameter, and the SID constant is never defined.
  • private/private_no_expire/public cache limiters skip the Last-Modified header native also sends (confirmed against php -S): it stats the running script's own file, which this package's $sapi contract has no path for.
  • php/php_binary encode each $_SESSION entry with its own, independent serialize() 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 $_SESSION writes 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 $offset is 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. Calling fseek() from hooked userland code makes the engine's own warning name fseek() instead of stream_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 the false return value still match exactly. (stream_get_contents()'s $offset, by contrast, checks seekable first and falls back to discarding bytes by reading, exactly like the native function does silently -- no warning either way, matching.)
  • stream_select()'s $except is never cooperatively waited on. phasync has no wait primitive for an "exceptional condition" (out-of-band data); $except streams 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/$write stream also becoming ready at the same moment, is missed. Separately, when $read and $write are both empty/null (nothing to race), the native function runs directly, which blocks the process for $seconds if given -- real translated I/O code never calls stream_select() this way (as a timer with no streams), so this is accepted rather than specially handled.
  • stream_socket_client()/fsockopen() depend on ext-sockets for exact failure reporting. Reproducing the native blocking connect's precise $error_code/$error_message on a failed asynchronous connect (STREAM_CLIENT_ASYNC_CONNECT) uses socket_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-sockets is 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 on proc_open(). The native popen() 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 hooked popen() starts the command with proc_open() instead, using \STDIN/\STDOUT/\STDERR as the descriptor spec entries for whichever two standard descriptors $mode does not capture (confirmed empirically to match native popen()'s own fd inheritance: a child's stderr under popen($command, 'r') reaches this process's own stderr, not the returned pipe), and records the association between the returned pipe and the process so pclose() can poll it cooperatively, the same way proc_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 $command with its own internal proc_open() (a string command already runs through /bin/sh -c, same as the native functions), inheriting stdin/stderr the same way popen() does and capturing only stdout, which is read to EOF with the same chunked loop stream_get_contents()/fpassthru() use (hookedReadLoop()), then reaps the child the way proc_close() does. exec()'s $output and system()'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. A null === array_key_last($array) check is used instead of $array[array_key_last($array)] ?? '' for the empty-output case, since the latter indexes with a null key, 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, or null for shell_exec()/passthru()) if the internal proc_open() call fails outright -- essentially unreachable through a real shell command, the same caveat the native functions have (sh -c anything always "runs" even when anything itself fails).