fix(admin): abort hung webhook drain fetches - #73
Conversation
Each subscriber POST now carries AbortSignal.timeout so a stalled admin-supplied URL cannot pin waitUntil after POST /admin/*. Timeouts use the existing unreachable last_status path. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
🦞👀 Pull request received. I will update this pull request when review starts. |
|
Codex review: blocked before merge. Reviewed September 3, 2026, 9:39 AM ET / 13:39 UTC. ClawSweeper reviewWhat this changesBounds each background admin-webhook POST to two seconds, records timeouts, advances the notify-only cursor, and adds a hanging-subscriber regression test. Merge readiness⛔ Blocked before merge - 6 items remain Keep open: the hang fix has credible real-behavior proof, but the newly fixed two-second cutoff irreversibly skips slow subscriber deliveries and needs an explicit maintainer delivery-policy decision before merge. Priority: P1 Review scores
Verification
How this fits togetherThe admin API appends successful mutations to an audit change log, then schedules a background webhook drain through the Worker execution context. The drain POSTs each entry to enabled operator-configured subscriber URLs and stores a cursor and delivery status. flowchart LR
A[Admin mutation] --> B[Change log]
B --> C[Background webhook drain]
C --> D[Subscriber POST]
D --> E{Response before cutoff?}
E -->|Yes| F[Advance cursor and status]
E -->|No| G[Timeout status and cursor advance]
F --> H[Webhook list]
G --> H[Webhook list]
Decision needed
Why: The existing notify-only contract resolves retries after failure but does not establish an acceptable subscriber response deadline; choosing one changes what existing webhook consumers can receive. Before merge
Findings
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Adopt and document a maintainer-approved bounded at-most-once webhook-delivery SLA, including the chosen deadline and timeout outcome, then record the user-visible change in Unreleased. Do we have a high-confidence way to reproduce the issue? Yes. Current main visibly performs the subscriber POST without an abort signal, and the supplied real TCP-hang trace shows the unbounded fetch remains pending while the patched path returns on timeout. Is this the best way to solve the issue? Unclear. Bounding a hung background fetch is narrow and maintainable, but the fixed two-second irreversible cutoff is a delivery-policy choice that needs explicit approval before it is the best solution. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found, but no applicable review policy affected this item. Codex review notes: model internal, reasoning high; reviewed against 59f724c8d5aa. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (26 earlier review cycles; latest 8 shown)
|
A fetch timeout now records last_status=timeout and leaves the cursor in place so the next drain retries. HTTP and unreachable failures still advance, matching the existing notify-only contract. Drop the release-owned changelog edit. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
Abort hung subscriber POSTs, record last_status=timeout, and advance the cursor like every other failed notification. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
A 2s cutoff still aborts a hung POST so drain can finish. Timeout no longer advances the cursor, so a slow subscriber is retried instead of permanently skipping the batch. HTTP and unreachable outcomes stay notify-only. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Pushed @clawsweeper re-review |
The released drain contract advances the cursor on failed posts so subscribers are not retried. Timeout now matches HTTP and unreachable: advance, record timeout, stop the batch. That avoids duplicating a post that completed after the cutoff. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Pushed @clawsweeper re-review |
Claw P2 on c2f74de: each drain made two unbounded Cloudflare DoH requests before delivery. A stalled resolver could pin waitUntil. Each A and AAAA lookup now uses AbortSignal.timeout (2s), matching the openclaw#73 subscriber POST bound. Hung preflight is lastStatus timeout and leaves the cursor put. Tests cover signal presence and a 20ms hang-until-abort drain. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
What Problem This Solves
Fixes an issue where operators who save a webhook URL that never responds would stall the admin Worker after an otherwise successful mutation. Every successful
POST /admin/*scheduleswaitUntil(drainWebhooks), which POSTs new change-log entries to each enabled subscriber. Thatfetchhad noAbortSignal, so one hung peer kept the isolate busy until the Worker waitUntil budget expired.Why This Change Was Made
Each subscriber POST now uses
AbortSignal.timeout(2 seconds by default). Delivery stays notify-only: a timeout recordslast_status=timeoutand still advances the cursor, same as HTTP and unreachable failures. A hung URL cannot pin waitUntil or block later events.User Impact
A stalled webhook no longer pins the post-mutation drain. The hung hook shows
timeouton the webhook list. Later change-log entries still move.Evidence
Live Node v26.7.0 against a TCP server that accepts the connection and never writes an HTTP response:
drainWebhooksin the Cloudflare Workers isolate then recordedlast_status=timeoutand the next drain had nothing left to send to that URL, so the cursor advanced.Real behavior proof
Behavior or issue addressed: Hung webhook drain fetch after POST /admin/* blocked waitUntil. Timeouts keep the shipped notify-only cursor contract.
Real environment tested: macOS Darwin 25.6.0 arm64, Node v26.7.0, worktree
/tmp/oc-impl-krillswitch-webhookonfix/webhook-drain-timeout. Cloudflare Workers isolate viadrainWebhooks(the same functionwaitUntilcalls).Exact steps or command run after this patch: Started a TCP listener that never writes HTTP and called
fetchwithAbortSignal.timeout(200). Then randrainWebhooksin the Worker isolate against a subscriber that waits for that abort, and readlast_statusplus a second drain.Evidence after fix: terminal output from the patched worktree:
Observed result after fix: The signaled POST returned TimeoutError at about 200ms. The Worker drain recorded timeout and advanced the cursor, so later events are not stuck behind the hung URL.
What was not tested: A live production isolate on
switch.openclaw.aiagainst a public webhook host.Origin
The unbounded
fetchlanded infeeefc40on 2026-07-29 as part of #46. The React SDK already documentsAbortSignal.timeoutindocs/react-sdk.md(#43).