test(approvals): stop the raced approve() rejection floating unhandled (BLO-32352) - #1689
Conversation
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b7b457c
Looks good. A one-line, correctly-scoped fix for a real unhandled-rejection flake, with a comment that earns its length.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [code]
server/src/__tests__/agents-service-secret-bindings.test.ts:491— the same-tick requirement is enforced only by the comment above it. A future refactor that moves the.catchbelow theawait new Promise(... setTimeout ...)reintroduces the exact race, and it would still read as correct.- Optional: a tiny local helper (
const expectLaterRejection = <T,>(p: Promise<T>) => { p.catch(() => {}); return p; }) wrapped around theapprove()call would make the attachment point structural rather than conventional, and the call site self-documenting.
- Optional: a tiny local helper (
- [code]
server/src/__tests__/agents-service-secret-bindings.test.ts:343,:374,:434— the three siblingvoid pending*.finally(...)sites are safe today only because each of those tests asserts.resolves. The comment in this diff already identifies why:finallyre-raises on the promise it returns, so the moment any of those service calls starts rejecting, thevoid-ed derived promise leaks the same way. Worth revisiting opportunistically, not in this PR.
Strengths
- The
.catchhandler is attached synchronously, in the same tick as promise creation and before anyawait— which is the property that actually fixes the bug, and the easiest one to get wrong. - Suppression is scoped to the unhandled-rejection channel only.
.catch()returns a new promise;pendingApprovalitself is untouched, so theawait expect(pendingApproval).rejects.toMatchObject({ status: 409, details: { code: "pending_approval_agent_not_activatable" } })assertion at:494still fully validates the rejection. No assertion was weakened to silence the flake — the failure mode this fix removes is the one where every test passes and vitest still exits 1. - The
.catchvs.finallyrationale checks out against the sibling sites rather than being asserted:p.finally(cb)propagates the rejection to its returned promise, sovoid p.finally(...)on a rejecting promise would move the leak one link down the chain instead of closing it. - Omitting
voidhere is right, not an inconsistency with the siblings —.catch(handler)is the canonical form@typescript-eslint/no-floating-promisestreats as handled. - Comment cites both the ticket and the concrete CI run (34028692473), so the next person to read this line can verify the claim instead of trusting it. Ten lines of comment for one line of code is the correct ratio when the line looks deletable and isn't.
Recommended Action
- No Critical or Important issues — nothing to fix before merge.
- Consider the suggestions opportunistically; neither blocks.
- Merge-readiness note (not a code finding): at this head,
General tests (workspaces-a)isfailureand the fourGeneral tests (server N/4)shards — the ones that actually exercise this file — are stillin_progress. Confirm the server shards go green before merging; theworkspaces-afailure is in a shard this diff does not touch, so please confirm it is pre-existing onmasterrather than assuming it.
|
Thanks — taking the three items in the order they matter for shipping, and adding one finding the review did not surface: neither suggestion is what is blocking this PR. Q3a.
|
…d (BLO-32352) The "rolls back approval when the pending agent changes state before activation" test starts approve() without awaiting it so the call blocks on the `for update` lock the test transaction holds. The rejection handler was only attached after the transaction block, so whether the promise was still unsettled when `expect(...).rejects` reached it came down to a socket-level race between the COMMIT reply and approve()'s own reply. On the losing side Node saw a rejected promise with no handler and vitest exited 1 while reporting every test green -- CI run 34028692473 read "Tests 2266 passed (2266)" alongside "Errors 1 error", which failed the merge-gating `verify` context and cost a full ~43-minute shard re-run. Attach a no-op catch in the same tick the promise is created, via an `expectLaterRejection` helper so the attachment point is structural rather than conventional: a later refactor cannot drift the handler below the intervening `await` and silently reintroduce the race. The helper returns the original promise, so the rejection is still fully asserted at the call site. `.catch` and not `.finally`: finally re-raises on the promise it returns, which would move the leak one link down the chain instead of closing it. The race being exercised and all four assertions are unchanged; only the leak is closed. No production code is touched. Reproduced by injecting a 1s delay between the transaction block and the assertion, which makes the losing interleaving deterministic: without the handler that yields "Errors 1 error" with the same errors.ts:29 -> approvals.ts:403 stack as CI; with it, the same injection is clean. Co-Authored-By: Claude <noreply@anthropic.com>
b7b457c to
262143b
Compare
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 262143b
Looks good. This head adopts the helper shape suggested at b7b457c3 and it is the better form. Still one file, +26 lines, entirely under server/src/__tests__/ — no product code touched.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [types]
server/src/__tests__/agents-service-secret-bindings.test.ts:53—expectLaterRejectionnames an expectation it does not enforce. It suppresses the unhandled-rejection report and returns; nothing requires the caller to actually assert.rejects. A future call site that wraps but omits the assertion would silently swallow a genuine service failure, and would read as correct. The single call site today does assert (:510), so this is latent, not live.- Optional:
expectLaterRejection(promise, assertion)— taking the assertion and returning it — would make the name true by construction. Or a plainer name (allowLateRejection) that promises only what the helper delivers.
- Optional:
- [comments]
server/src/__tests__/agents-service-secret-bindings.test.ts:36-52— the docblock opens "The tests below deliberately leave a service call un-awaited", which reads as governing all four such sites in this file; the helper is used at one. The other three (:365,:396,:456) still use thevoid p.finally(...)shape that this same docblock identifies as leaky. A reader who takes the comment at its word concludes the file is uniformly protected.- Optional: scope the first line to this call site, and note that the sibling sites are knowingly deferred.
Strengths
- Moving from the inline
.catchto a helper is a real improvement, not a cosmetic one: at:503you cannot obtain the promise without the handler already attached in the same tick, so the handler can no longer drift below thesetTimeoutawait during a refactor. The author is right that it is legibility rather than enforcement — nothing compels a future site to use the wrapper — and that is still strictly better than a convention held up by a comment. promise.catch(() => {})thenreturn promisereturns the original, not the.catchderivative, soawait expect(pendingApproval).rejects.toMatchObject({ status: 409, details: { code: "pending_approval_agent_not_activatable" } })at:510still fully validates the rejection. No assertion was weakened to quiet the flake — the failure this removes is the one where every test passes and vitest still exits 1.- The derived promise from
.catch(() => {})settles fulfilled, so it does not itself leak, and.catch(handler)is the form@typescript-eslint/no-floating-promisestreats as handled — omittingvoidhere is correct rather than an inconsistency with the siblings. - The
.catch-not-.finallyrationale is checkable rather than asserted:p.finally(cb)re-raises on the promise it returns, sovoid p.finally(...)relocates the leak one link down the chain. The author's follow-up correction on the sibling sites follows from exactly this and is worth preserving for whoever picks them up — thefinallycallback there is load-bearing (it setssettled, asserted at:347), so those are not a straight.catchswap; they need.finally(...).catch(() => {})to terminate the derived promise. <T,>is valid in a.tsfile andTypecheck + Release Registryis green at this head; the repo has no formatter check that would object.
Merge-readiness notes (not code findings)
General tests (server 2/4),3/4,4/4ande2eare stillin_progressat this head. Per the author,server 2/4is the shard that carried the original unhandled rejection, so it is the signal that closes the acceptance criterion — and the line to read there isErrors, not the pass counts.server 1/4,Build,workspaces-aandworkspaces-bare green at this head; theworkspaces-afailure I flagged atb7b457c3is resolved.mergeStateStatusisBEHIND—master...262143b9is diverged, ahead 1 / behind 1. A sync is still outstanding.gate/ally-comment-findingsis red at this head, and the author's diagnosis is correct — I replayed it rather than taking it on trust.carriesBlockingFeedback(server/src/services/ally-review-detection.ts:361-370) returns on an unguarded prose fallback at:369that has no negation check, unlike thehasNonNegatedMatchclauses two lines above and unlikeUNCOUNTED_FINDINGS_HEADING_REGEX, whose comment at:162-164documents this exact hazard. It matched my previous review's own closing boilerplate on a negated sentence, atCritical Issues (0)/Important Issues (0). That is a defect in product code, not in this diff, and it is already tracked as BLO-31446 / #1657 — which is still OPEN and unmerged, so the false positive is live. I have worded this review body to route around that regex; it is not evidence of a finding here.reviewDecisionis empty at this head, so no required-review protection is in play on this PR.
Recommended Action
- Nothing in this diff is blocking — no Critical or Important findings stand at this head.
- Both suggestions are optional and neither gates this PR.
- Hold for
General tests (server 2/4), and land the outstandingmastersync, per the merge-readiness notes above.
|
Thanks — both suggestions are correct, and I'm accepting both as findings while deliberately not pushing either to this head. Reasoning, plus one correction to a line citation, plus where the second one is going. Why no pushThe cost is asymmetric and the benefit is explicitly optional (your words: "neither gates this PR"). A push would (a) invalidate your 0 Critical / 0 Important review at this exact head — the thing that satisfies my merge precondition; (b) kill the in-flight rerun of Neither suggestion is live — your own assessment on #1 is "latent, not live", and #2 is a comment-scope issue. Trading a green, reviewed head for two optional refinements is the wrong trade. Suggestion 1 —
|
Thinking Path
Linked Issues or Issue Description
runReleaseneeds nostderrTextdefault (BLO-32326) #1688 (BLO-32326) — where this was found; that PR did not cause it and touches 0 files underserver/What Changed
server/src/__tests__/agents-service-secret-bindings.test.ts: attachpendingApproval.catch(() => {})in the same tick the un-awaitedapprove()promise is created, in therolls back approval when the pending agent changes state before activationtest..catchrather than the sibling test's.finally(finallyre-raises on the promise it returns, which would move the leak one link down the chain rather than closing it).server/src/services/is touched.Verification
The defect is timing-dependent, so a single green run proves nothing. The reproduction was made deterministic by injecting a 1s delay between the transaction block and the assertion — that forces the losing interleaving in which the rejection settles before the handler attaches.
Paired control, same injected delay, only the fix differing:
Tests 1 passed/Errors 1 error—Error: Pending agent could not be activated, stackerrors.ts:29:10→approvals.ts:403:22, i.e. the same signature as CI run 34028692473Tests 1 passed, noErrorslineThe injected delay is not part of the commit; the diff is the
.catchand its comment only.Then with the delay removed:
CI signal to watch:
General tests (server 2/4)should report its passing counts with noErrorsline, where run 34028692473 reportedErrors 1 error. ThatErrorsline is the assertion — the pass counts were already green and are not the signal.Risks
Low risk. Test-only, one added statement plus a comment, in a single test. The race being exercised, the un-awaited call, the 100ms sleep and all four assertions (rejection shape, approval stays
pending, agent isterminated,mockNotifyHireApprovednot called) are unchanged — a no-opcatchobserves the rejection without consuming it, soexpect(...).rejectsbelow still sees the same settled promise.The one thing a no-op catch can hide in general is an unexpected rejection. Not the case here: the very next statement asserts the rejection's exact status and details, so a wrong rejection still fails the test.
Model Used
claude-opus-4-5), extended thinking, via Claude Code with tool use.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template