fix(frontend): handle orphaned api cleanup promise rejection - #53
Merged
Merged
Conversation
…le-rejecting api() does `promise.finally(() => _inflight.delete(path))` and discards the derived promise it returns. `.finally()` mirrors the outcome of the promise it's attached to, so when a request rejects, that derived promise rejects too, with nothing ever attached to observe it -- a second, orphaned rejection alongside the one every real caller already handles via the returned `promise`. Browsers only surface this as a benign `Uncaught (in promise)` console warning; it was caught here because Node's stricter default (an unhandled rejection crashes the process) has no browser equivalent, and surfaced while building a behavioral test for Kpa-clawbot#1375's scope-stats fetch caching. Fix: attach a no-op `.catch(() => {})` to the discarded `.finally()` promise. This only consumes that derived promise's mirrored rejection -- the `promise` returned to callers is a different object and its resolution/rejection to them is completely unaffected. Reproduced fail-before / pass-after with `node --unhandled-rejections =strict` against a minimal harness loading the real api(). New test covers success, failure semantics, the no-extra-rejection fix itself (via a child-process check under the same strict flag, with a negative control proving that same check does catch a genuine unhandled rejection), in-flight cleanup after both outcomes, retry-after-failure, concurrent-request dedup, and TTL cache behavior -- all against the real, unmodified api()/_apiCache/_inflight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-2 (Opus) review of d3c2483 blocked test registration on two harness bugs, both confirmed by reproduction before this fix and again after it: 1. Broken in-flight dedup (removing `if (_inflight.has(path)) return _inflight.get(path);`) left scenario F's second Promise.all() branch permanently pending. Nothing else kept the event loop alive, so Node drained and exited 0 without ever reaching G/H or printing a summary -- a silent false pass. Fixed two ways: process.exitCode is now set to 1 immediately and only flipped to 0 after explicitly confirming all 8 named scenarios (A-H) ran to completion and passed; scenario F also now asserts exactly 1 fetch happened before resolving, catching this mutation instantly instead of relying on the fallback. A non-unref'd watchdog timer additionally guards against a genuine hang (a real pending macrotask keeps the event loop alive, so Node cannot silently exit early while it's armed) and is cleared on the normal completion path. 2. Scenario C's child script accepted ANY thrown error via a bare catch, so pointing it at a nonexistent `ctx.apiTypo` (or making api() throw before ever calling fetch) still printed a pass. The child now explicitly verifies api is a function, checks the exact rejection message, checks the exact fetch count and URL, and only then writes a unique success marker; the parent requires that exact marker plus a clean exit, no signal, and no stderr. Scenario H was narrowed to what it actually proves (the strict-mode child-process technique detects a deliberate unhandled rejection) and now checks for that rejection's specific message in stderr, so an unrelated child crash can no longer count as a passing negative control; its dead `exitCode = 0` line is removed. Re-verified: 12/12 clean runs (~0.1s each, full A-H every time). Both original false positives now fail correctly. Two new mutations checked too: api() failing before its fetch call, and removing the production fix's `.catch()` on the discarded `.finally()` promise -- the latter's failure output names the exact same bug (`API 500: ...` at the orphaned-rejection site), not a generic crash. No leftover processes after any run. public/app.js is untouched (byte-identical to d3c2483). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scenario H asserted only that stderr *contained* the marker string, but when a child crashes for any reason (ReferenceError, SyntaxError, etc.) Node echoes the offending source line -- which itself contains the marker -- into stderr. That let H "pass" even for crashes unrelated to the deliberate unhandled rejection the scenario exists to detect. Replace it with an exact `^Error: <marker>$` (multiline) match against the thrown-message line, plus explicit signal/status checks so a signal kill (e.g. our own timeout) can never masquerade as the real crash. Both C's and H's spawnSync calls used only the default SIGTERM on timeout, which a misbehaving child can ignore and become orphaned, and a timed-out call surfaced as a generic "failed to spawn: ETIMEDOUT" message rather than a clear timeout. Add killSignal: 'SIGKILL' to both and check `error.code === 'ETIMEDOUT'` before the generic spawn-error assertion. Finally, reword the watchdog's comment/message: it is an event-loop safety bound for async stalls only, and firing does not mean "the loop was otherwise alive" (it also fires when it's the sole handle); make clear it cannot interrupt spawnSync or other synchronous blocking, so an outer runner-level timeout remains the real backstop for that case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the already-reviewed test-app-api-inflight-cleanup-rejection.js to test-all.sh and the deploy.yml "Run JS unit tests (packet-filter)" step, so it actually runs in CI. No other lines changed in either file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
public/app.js'sapi()helper does:.finally()returns its own derived promise that mirrors the outcome of the promise it's attached to. That derived promise was discarded uncaught, so when a request rejects, both the promise returned to callers (correctly awaited/caught by every real caller) and the orphaned.finally()promise reject — the second one with nothing ever attached to observe it. In a browser this only shows up as a benignUncaught (in promise)console warning; it surfaced here because Node's stricter default (an unhandled rejection crashes the process) has no browser equivalent, while building a behavioral test for issue Kpa-clawbot#1375's scope-stats fetch caching.Fix (one line,
public/app.js):The added
.catch()only consumes the discarded.finally()promise's mirrored rejection. It is a different promise object from the one returned to callers, so:Commits in this PR
d3c24830— the production fix itself, plus the first version of the regression test.86645756— hardens the test after an independent review found two test-harness false positives (an incomplete-suite exit-0 path, and scenario C accepting an unrelated child crash as a pass).98fbdbe2— closes a further false positive in scenario H (a source-line echo containing the marker was mistaken for the real rejection) and fixes child-timeout labeling/SIGKILLhandling, found in a second independent review round.34061595— registers the now-approved test intotest-all.shand the CI JS-tests step indeploy.yml, immediately before issue bug(analytics): Scopes tab fails JSON.parse — '/api/api/scope-stats' duplicate prefix (#915 fix never merged) Kpa-clawbot/CoreScope#1375's existing (currently failing, unrelated) test.Evidence
node --unhandled-rejections=strictagainst a minimal harness loading the real, unmodifiedapi()— before the fix, a caller correctly catches the rejection and the process still crashes afterward from the orphaned promise; after the fix, no crash.test-app-api-inflight-cleanup-rejection.js) covers 8 scenarios (A–H): success, unchanged failure semantics, no extra unhandled rejection (via a self-checking child process under--unhandled-rejections=strict), in-flight cleanup after both success and failure, concurrent-request dedup, TTL cache behavior, and a self-check that the strict-mode detection technique itself actually detects a deliberate unhandled rejection (and doesn't just pass on any unrelated crash).api()reference,api()throwing before its fetch call, and reverting the production.catch()were each confirmed to produce a relevant, correctly-diagnosed test failure — not a vacuous pass or generic crash.SIGKILLon timeout with a clear timeout diagnosis rather than a generic spawn-error message. (This is an event-loop-based safety bound for async stalls — it cannot interruptspawnSyncor other synchronous blocking in the main process; an outer runner-level timeout remains the relevant backstop for that case.)98fbdbe2) were both reviewed and approved in separate passes before the test was registered into CI.test-all.shanddeploy.yml's JS-tests step, placed immediately before issue bug(analytics): Scopes tab fails JSON.parse — '/api/api/scope-stats' duplicate prefix (#915 fix never merged) Kpa-clawbot/CoreScope#1375's existing test so it runs and is visible regardless of that unrelated, pre-existing failure.Known baseline / out of scope
test-frontend-helpers.jshas 2 pre-existingfavStarassertion failures on unmodifiedmaster, unrelated to this change.test-live-dedup.jsdoesn't run in this environment (missingplaywrightdependency) — not a failure, just not run.api(). It does not claim that all unhandled rejections are eliminated, or that the rest of CI is green.🤖 Generated with Claude Code