Skip to content

fix(fastify): bound request-pump re-entrancy to avoid MAX_TRY_DEPTH - #5698

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
machineloop:fix/fastify-pump-reentrancy-deferred-queue
Jun 26, 2026
Merged

proggeramlug merged 1 commit into
PerryTS:mainfrom
machineloop:fix/fastify-pump-reentrancy-deferred-queue

Conversation

@machineloop

@machineloop machineloop commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

js_fastify_process_pending re-enters itself when a dispatched handler awaits (process_request → wait_for_promise → js_run_stdlib_pump → back into the pump), and each nested dispatch pushes a call_closure2_catching try-frame (setjmp). The pump had no re-entrancy guard, so under sustained awaited-handler load the recursion could blow MAX_TRY_DEPTH (128, "Try block nesting too deep") and abort the process. This adds an IN_PROGRESS guard + a thread-local DEFERRED queue so nested entries capture pending requests instead of dispatching them, and the outer frame drains the queue at its tail — every dispatch stays at the outer (bounded) try-depth, with no added latency. Cap-bounded (4096) with drop-newest backpressure.

Changes

  • crates/perry-ext-fastify/src/server.rs
    • js_fastify_process_pending gains a thread-local IN_PROGRESS re-entrancy guard and a thread-local DEFERRED: VecDeque<(Handle, FastifyPendingRequest)>. Nested entry drains every server's pending into DEFERRED (no dispatch) and returns; a ResetReentryGuard (Drop) clears IN_PROGRESS on every exit; a tail loop dispatches DEFERRED in the outer frame.
    • New helpers try_recv_pending_request (locks request_rx only for the try_recv, never across dispatch) and drain_server_requests_into_deferred (cap-respecting drain; over-cap pending are dropped → response_tx Drop → service-fn Err → error response).
    • Upgrade priority preserved: the WebSocket-upgrade drain is extracted into drain_server_upgrades and re-run before every deferred-request dispatch in the tail loop, so a long (nested-await-replenished) deferred drain can't starve a late upgrade (FastifyInstance.server returns a number — blocks ws/http2 upgrade piggybacking #1113).
    • The normal (non-nested) top-level dispatch path is unchanged — the inlined try_recv was replaced by the identical-semantics helper call.
  • New #[cfg(test)] mod tests in server.rs (4 tests, below).

Related issue

n/a — robustness fix for the request pump's re-entrancy.

Test plan

This touches the hot request-pump path, so it was verified comprehensively — three novel checks beyond the usual, plus end-to-end and an adversarial audit:

1. Real-channel drain (not a mirror). Builds a live FastifyServerHandle + mpsc channel and exercises the actual drain_server_requests_into_deferred/try_recv_pending_request helpers — asserts the cap is enforced, FIFO order is preserved, and the channel is fully drained even past the cap (no request stranded).

2. Backpressure contract. Proves that an over-cap (dropped) request's response_rx resolves Closed — i.e. the client is signalled with an error response, never a hang or a leak.

3. No-loss / no-duplication property. Across multiple servers and repeated nested-entry drains, asserts every queued request lands in the deferred queue exactly once in per-server FIFO order, and a second pass captures nothing.

$ cargo test -p perry-ext-fastify
test server::tests::deferred_drain_respects_cap_and_drains_channel_fully ... ok
test server::tests::deferred_drain_under_cap_moves_all_without_dropping ... ok
test server::tests::deferred_drain_over_cap_signals_dropped_clients ... ok
test server::tests::deferred_drain_no_loss_across_servers_and_repeated_passes ... ok
test result: ok. 26 passed; 0 failed; ...

$ rm -rf target/perry-auto-* && scripts/run_fastify_tests.sh   # async handlers drive the guard
fastify-tests: 12 passed, 0 failed

$ cargo fmt --check -p perry-ext-fastify     # clean

End-to-end: run_fastify_tests.sh exercises async route handlers and an async error handler, which drive the exact re-entry path the guard wraps — 12/12 with the guard in place (no regression in real serving).

Adversarial soundness audit (independent multi-agent review of the change) confirmed, with line-level evidence: the guard prevents nested dispatch and resets on every exit (incl. unwind); the tail-drain runs at outer try-depth; no request_rx lock is held across dispatch and no get_handle runs inside iter_handle_ids_of; the DEFERRED RefCell is never double-borrowed; no request is lost or double-dispatched; and the top-level path is behaviorally unchanged.

  • cargo build --release clean — built -p perry; the full-workspace build needs the GTK/gdk-pixbuf libs for perry-ui-*, which CI provides.
  • cargo test --workspace --exclude perry-ui-ios --exclude perry-ui-tvos --exclude perry-ui-watchos --exclude perry-ui-gtk4 --exclude perry-ui-android --exclude perry-ui-windows passes — ran the affected crate (cargo test -p perry-ext-fastify, 26/26) plus scripts/run_fastify_tests.sh (12/12); the full workspace test is deferred to CI (the base perry-ui crate needs gdk-pixbuf, unavailable locally).
  • (if user-facing) Added or updated a test under test-files/ or a #[test] in the affected crate — 4 new #[test]s for the deferred-queue drain/cap/backpressure/no-loss invariants.
  • (if CLI / stdlib / runtime API changed) Updated docs/src/ — n/a, internal pump change, no public API surface.
  • (if touching a platform UI backend) Built -p perry-ui-<backend> locally on that platform — n/a.

Screenshots / output

n/a — internal request-pump robustness change (no user-visible output difference; correctness/latency under sustained awaited-handler load).

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md (maintainer handles these at merge)
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention used in the log — fix(fastify): …
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Summary by CodeRabbit

  • Bug Fixes

    • Improved pending-request processing during high async load to avoid recursive/overlapping dispatch behavior.
    • Added bounded, thread-local deferral for pending requests to prevent excessive “try-frame” explosions and ensure more consistent handling.
    • Preserved upgrade prioritization while draining deferred requests in FIFO order across servers.
  • Tests

    • Added unit tests covering cap-respecting draining, under/over-cap behavior, channel-closure effects, and no-loss/no-duplication across multiple servers and repeated passes.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ce0c3ef0-2ba3-4228-b671-2cc1d0f9a90b

📥 Commits

Reviewing files that changed from the base of the PR and between 542b150 and e2fab89.

📒 Files selected for processing (1)
  • crates/perry-ext-fastify/src/server.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-ext-fastify/src/server.rs

📝 Walkthrough

Walkthrough

This PR changes js_fastify_process_pending to defer nested pump re-entry into a bounded thread-local queue, then drains deferred requests after the outer dispatch finishes. It also adds tests for queue capacity, ordering, closure, and repeated multi-server draining.

Changes

Deferred pending pump

Layer / File(s) Summary
Deferred queue helpers
crates/perry-ext-fastify/src/server.rs
Introduces the deferred-queue cap, non-blocking pending-receive helper, and server-level drain helper.
Re-entrancy guard
crates/perry-ext-fastify/src/server.rs
Adds thread-local in-progress tracking and deferred capture on nested pump entry.
Outer drain loop
crates/perry-ext-fastify/src/server.rs
Uses the non-blocking receive path in the outer pump and drains deferred requests before returning.
Deferred queue tests
crates/perry-ext-fastify/src/server.rs
Adds tests for cap handling, ordering, closure behavior, and multi-server draining.

Sequence Diagram(s)

sequenceDiagram
  participant js_fastify_process_pending as js_fastify_process_pending
  participant try_recv_pending_request as try_recv_pending_request
  participant drain_server_requests_into_deferred as drain_server_requests_into_deferred
  participant DEFERRED as DEFERRED

  js_fastify_process_pending->>try_recv_pending_request: receive the next pending request
  js_fastify_process_pending->>js_fastify_process_pending: re-enter while a handler awaits
  js_fastify_process_pending->>drain_server_requests_into_deferred: capture queued requests from each server
  drain_server_requests_into_deferred->>DEFERRED: append captured requests
  js_fastify_process_pending->>DEFERRED: tail-drain deferred requests
  js_fastify_process_pending->>js_fastify_process_pending: dispatch each deferred request
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hopped through the pending queue tonight,
Deferred the whirl with a bounded light.
FIFO carrots lined up neat and true,
No lost little nibbles slipped out of view.
Hop, drain, and bounce—the loop feels bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: bounding Fastify request-pump re-entrancy to avoid MAX_TRY_DEPTH.
Description check ✅ Passed The description follows the template well, with summary, concrete changes, related issue, test plan, screenshots, and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-ext-fastify/src/server.rs`:
- Around line 557-571: The deferred-request tail loop in server.rs can keep
dispatching nested work without giving newly arrived upgrades a chance to run,
which weakens the intended upgrade-first behavior. Refactor the existing
upgrade-drain logic into a reusable helper from the main request-pump flow, and
invoke it again while draining DEFERRED in the same outer frame before
processing more deferred requests. Keep the fix centered around the
process_request path and the upgrade_rx drain so upgrade handling is re-checked
between deferred batches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 30d527a7-f236-4e0b-bfee-d958cebd0fc8

📥 Commits

Reviewing files that changed from the base of the PR and between 920afa7 and 542b150.

📒 Files selected for processing (1)
  • crates/perry-ext-fastify/src/server.rs

Comment thread crates/perry-ext-fastify/src/server.rs Resolved
`js_fastify_process_pending` dispatches each pending request by running its
handler, and a handler that `await`s drives `wait_for_promise` →
`js_run_stdlib_pump`, which re-enters this same pump. Every nested dispatch pushes
another `call_closure2_catching` try-frame (setjmp), so under sustained
awaited-handler load the recursion could blow `MAX_TRY_DEPTH` (128 — "Try block
nesting too deep") and abort the process. The pump had no guard against this.

Add a thread-local `IN_PROGRESS` re-entrancy guard plus a thread-local `DEFERRED`
queue. On a nested entry the pump captures every server's pending requests into
`DEFERRED` (without dispatching) and returns; the OUTER frame drains `DEFERRED` at
the tail of its loop, so every dispatch happens at the outer (bounded) try-frame
depth instead of recursively. A nested entry that occurs during the tail drain is
itself deferred and picked up by the same loop, so depth stays bounded regardless
of arrival timing. WebSocket upgrades keep priority throughout: the tail loop
re-drains each server's upgrades before every deferred request (via the extracted
`drain_server_upgrades` helper), so a long deferred drain can't starve a late
upgrade (PerryTS#1113).

`DEFERRED` is capped at 4096 entries: a pathological async storm that exceeds the
cap drops the newest pending, whose `response_tx` Drop makes the awaiting hyper
service-fn resolve `Err` → an error response. Memory is bounded (backpressure),
not leaked. The deferred dispatch happens in the same outer pump tick, so an
awaited request observed by a nested pump call is served without waiting for the
next event-loop iteration.

The drain/cap/backpressure logic is extracted into `try_recv_pending_request` and
`drain_server_requests_into_deferred` so it is unit-testable without a runtime.
Adds tests:
- `deferred_drain_respects_cap_and_drains_channel_fully`: over-cap entries are
  dropped, kept entries stay in FIFO order, and the channel is fully drained
  (nothing stranded in the mpsc).
- `deferred_drain_under_cap_moves_all_without_dropping`: under cap, all captured
  in arrival order with no response channel closed.
- `deferred_drain_over_cap_signals_dropped_clients`: a dropped (over-cap) request
  closes its response channel, so the client gets an error response, never a hang.
- `deferred_drain_no_loss_across_servers_and_repeated_passes`: across servers and
  repeated nested-entry drains, every request is captured exactly once in
  per-server FIFO order; a second pass captures nothing.
The full guard → defer → outer-tail-dispatch path is exercised end to end by the
async handlers in `scripts/run_fastify_tests.sh`.
@machineloop
machineloop force-pushed the fix/fastify-pump-reentrancy-deferred-queue branch from 542b150 to e2fab89 Compare June 26, 2026 09:05
@proggeramlug
proggeramlug merged commit f37d71a into PerryTS:main Jun 26, 2026
15 checks passed
@machineloop
machineloop deleted the fix/fastify-pump-reentrancy-deferred-queue branch June 26, 2026 09:43
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.

2 participants