Skip to content

Stop promising the sleep hooks always suspend the fiber - #368

Merged
diolektor merged 1 commit into
mainfrom
fix/fiber-multiplexing-docs-promise-sleep-always-suspends
Sep 19, 2026
Merged

diolektor merged 1 commit into
mainfrom
fix/fiber-multiplexing-docs-promise-sleep-always-suspends

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

Problem

docs/features/fiber-multiplexing.md told the reader that RUNTIME_HOOKS=sleep makes native sleep()/usleep() suspend the fiber, with no condition attached. The hooked pair suspends only where a fiber switch is possible; where it is not, oxphp_fiber_sleep_us() returns 0 and the hook hands the call to the builtin it replaced, which waits on the worker thread — the exact behaviour the note offered to remove. That page is what a reader sizes PHP_WORKERS from.

The same unconditional promise stood in three more public artifacts, and one of them was not an omission but an outright false statement in released text. docs/operations/configuration.md asserted that "a hooked sleep() always returns 0 (the signal-interruption return value of the native builtin does not arise)". On the fallback path the call is the native builtin — PHP_FUNCTION(sleep)php_sleep() → libc sleep(3) — so a signal-interrupted sleep gives back the seconds it did not sleep. That sentence shipped in 0.11.0.

Two further things the text got wrong, both in the direction that costs the reader something:

  • The places where switching is blocked were presented as a closed list. zend_fiber_switch_block() is called in six places; two were named. The other four run under zend_catch and execute userland destructors while switching is blocked, so a __destruct() that sleeps to back off holds the thread. The comment beside one of them says as much outright: "a destructor that sleeps or reads a socket does it without leaving this frame."
  • A fiber a userland scheduler started was filed as another entry in that list. It is a different mechanism: switching is not blocked there, and Fiber::suspend() works normally inside an AMPHP or Revolt fiber. What refuses is OxPHP's own ownership check — it cannot resume a context it does not own. Reading it as "no fiber can be suspended at all" told a Revolt user their scheduler cannot work inside a request.

The remedy the pages recommended did not work either: oxphp_sleep()/oxphp_usleep() take the identical fallback, so pointing at them is not a way around the limit. And the troubleshooting entry titled with this exact symptom — "oxphp_sleep() not yielding to other requests" — named one cause and prescribed enabling a flag the reader has already enabled by the time they are reading it.

What changed

  • A rule instead of a count. All four artifacts now say switching is blocked wherever leaving the current frame would be unsafe, and give the places as examples rather than as an enumeration that closes. The teardown-destructor case is named. The count that the first draft of this change carried — "five contexts" — is gone from every file, because it was not exhaustive.
  • Where the reader is safe is stated too. The ordinary end of a request is not switch-blocked: destructors and shutdown functions run there with switching allowed. Only the walks that run after a request comes apart — after a fatal, or after a write to a request that was already cancelled — block it. Without that sentence the correction would have traded one wrong belief for another.
  • The userland-scheduler case is separated from switch-blocking, and scoped to a fiber started inside a request rather than to the shape of a deployment. A worker-mode request is itself a fiber OxPHP drives, which is why Revolt refuses to run its loop from inside one — so the hooks apply to the request's own fiber normally. This brings the paragraph into line with what the streams hook section of the same page already said.
  • configuration.md: the false always returns 0 absolute is replaced by what the fallback path actually does; the sleep category row gains its condition; the bullet at the decision point no longer says the hooks suspend "unconditionally"; and the argument check is named as a real divergence — the hook rejects anything above 4294967295, which the unhooked builtin accepts and truncates.
  • docs/php/functions.md and oxphp.stub.php: the fallback condition was given as "outside a fiber" only. It now also names a refused switch and a context OxPHP does not own.
  • The troubleshooting entry points at the real causes instead of at a flag that is already on.
  • oxphp_async_await() carried the same guard and the same unqualified table row; it is qualified with the rest.
  • CHANGELOG.md, 0.11.0: the list of contexts gains the FILTER_CALLBACK case that shipped in that same release, is marked as non-exhaustive, and stops filing the userland scheduler under "suspending is refused".
  • llms-full.txt regenerated.

No behaviour changes — documentation only.

Verification

  • scripts/gen-llms-txt.sh --check — up to date, 58 pages.
  • scripts/check-links.sh — 648 in-repo links, none broken.
  • cargo fmt --check, cargo clippy --no-default-features -D warnings, cargo test --no-default-features — 1170 passed, 0 failed. No Rust or C source is touched on this branch, so these cannot be affected by it; CI runs them regardless.
  • A repo-wide grep for every shape the promise took — "suspends the fiber automatically", "always returns 0", "unconditionally", "no fiber can be suspended", "the engine will not let switch" — across docs/, README.md, CHANGELOG.md, llms.txt, llms-full.txt and oxphp.stub.php returns nothing that is still unqualified. The two README.md feature bullets are left as they are: they summarize and link to the page that now carries the condition.
  • Every place the new text describes was read in ext/oxphp_sapi.c and ext/oxphp_fiber.c: the three guards in oxphp_fiber_sleep_us(), the rc == 0 delegation in both hooked functions, the identical bare-usleep() fallback in oxphp_sleep()/oxphp_usleep(), the same guard in oxphp_fiber_suspend_for_await(), and all six zend_fiber_switch_block() call sites — including which of them sit in a zend_catch arm, which is what makes the teardown cases post-bailout only and the ordinary request end safe.
  • The upstream facts were read at the pinned PHP version rather than from memory: php_sleep is sleep(3) on every platform this server runs on; PHP_FUNCTION(sleep) rejects only negative arguments and then returns what libc returns; PHP_FUNCTION(usleep) sets no return value, so no text here claims one for it; Fiber::suspend() refuses only for no active fiber, a destroyed fiber, or blocked switching, none of which holds inside a userland scheduler's fiber; and the ticks handler and pcntl's signal dispatch raise the block themselves, which is why only those two are attributed to upstream and the rest are not.

Docs:
  - Where a fiber switch is refused, the hooked native sleep()/usleep() hand the call to the builtin they replaced and wait on the worker thread. Four public artifacts promised suspension unconditionally, and the reference page went further, asserting that a hooked sleep() always returns 0 — on that path it returns whatever the builtin returns, which for a signal-interrupted sleep() is the seconds it did not sleep. That sentence shipped in 0.11.0.
  - The places where switching is blocked are not a closable list: two of the six were named, and the rest run destructors after a request comes apart, where a __destruct() that sleeps holds the thread. The pages now state the rule — switching is blocked wherever leaving the current frame would be unsafe — with the places as examples, and say where the reader is safe: the ordinary end of a request runs destructors and shutdown functions with switching allowed.
  - A fiber a userland scheduler started is a separate case rather than another entry in that list: switching is not blocked there, OxPHP simply cannot resume a context it does not own. It is also about a fiber started inside a request rather than the shape of a whole deployment, since a worker-mode request cannot drive an event loop of its own.
  - oxphp_sleep() and oxphp_usleep() share that same fallback, so reaching for them is not a way around it — which is what these pages used to recommend.
@diolektor
diolektor merged commit 27f037b into main Sep 19, 2026
7 checks passed
@diolektor
diolektor deleted the fix/fiber-multiplexing-docs-promise-sleep-always-suspends branch September 19, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant