JS-2409 Implement rule S9382: Promises should not be awaited sequentially in a loop - #7900
martin-strecker-sonarsource wants to merge 8 commits into
Conversation
Pins rspec.sha to SonarSource/rspec#8127 (still unmerged) so CI's prepare_rspec_rule_data job fetches the real RSPEC metadata for S9382 instead of failing with ENOENT on resources/rule-data/javascript/S9382.json, per docs/BUILD.md's documented process for a SonarJS PR that depends on an in-flight RSPEC change. Remove this file once SonarSource/rspec#8127 merges to the RSPEC default branch.
This comment has been minimized.
This comment has been minimized.
|
Ruling results: 78 TP / 169 FP across 247 findings (32% TP rate) Ran the full ruling corpus and manually classified every finding. Full breakdown in JS-2409. The FP volume is expected — One category is different: ~14 of the 169 FPs (~8%) are "first-match-wins" early-exit searches — the loop's only
Draft spec for a suppression decorator (unsettled in places — see below) Both the Sketch: walk forward from the await's containing statement; if every reachable path hits Two correctness knobs still open, both narrow edge cases:
Nested loops (a Given this is a non-Sonar-Way, opt-in rule — do we want to dig deeper into (1)/(2) here, or ship the passthrough as-is (matching S878/S1751/S1763 — no decorator, just the vanilla wrap) and revisit only if it comes up in practice? |
…header cases Per gitar-bot review: pins two behaviors that distinguish no-await-in-loop from "any await under a loop" - an await inside a nested function/arrow expression in the loop body (not reported, function nodes are traversal boundaries), and an await in a for-await-of header's iterable expression (not reported, since node.await === true makes the whole ForOfStatement a boundary before the body/header distinction is even checked). Confirmed both behaviors empirically by running against ESLint's actual rule.
|
guillemsarda
left a comment
There was a problem hiding this comment.
Requesting changes until we decide if we cover the FP.
| @@ -0,0 +1,119 @@ | |||
| /* | |||
There was a problem hiding this comment.
There's a known false positive that isn't mitigated: await immediately followed by a loop-terminating return/break still gets flagged, even though there's no real sequential-execution cost since the loop won't continue.
async function foo(arr) {
for (const x of arr) {
const r = await fetch(x);
if (r.ok) return r; // loop ends right after — flagging this await is noisy
}
}This matches upstream eslint/eslint#14700, which was closed as "working as intended" since it's out of scope for the base no-await-in-loop rule. Since we're wrapping it for a Sonar-specific rule, could we add a decorator to suppress the report when the await is the last statement before a return/break that exits the loop? Otherwise this will be a live source of noise on early-exit-after-await code, which is a pretty common pattern.
There was a problem hiding this comment.
Thinking through how to implement the suppression, I see three options:
- Only suppress when the await is unconditionally followed by return/break (no branching). This means the loop always exits after its first iteration — nonsense code, since it wouldn't be written as a loop.
- Suppress whenever a return/break appears anywhere later in the loop body, regardless of whether it's on the same path as the await, e.g.:
Easy to implement, but carries the potential for a high false-negative rate, since the presence of a later return/break doesn't tell us whether that path is actually taken on a given iteration.
for (const id of ids) { const item = await fetch(id); if (item.matches(query)) return item; }
- A heuristic somewhere in between 1 and 2
- Real reachability/CFG analysis to determine whether all paths after the await exit the loop. SonarJS doesn't currently have a control-flow-graph engine for JS/TS rules, so this would require building that piece first.
See also eslint/eslint#14700 where this FP was discussed, but no heuristic was ever really speced.
What did you have in mind? If it's 4), that's a separate SE-engine question we'd want to scope on its own rather than bundle into this rule's first cut.
There was a problem hiding this comment.
I'd lean toward option 2. Even a return/break later in the loop body that isn't strictly on the await's execution path is a strong enough signal. That shape of code (search-and-early-exit) is exactly where sequential awaiting is idiomatic, not accidental. It shows the author already intended each promise to resolve one at a time before deciding whether to keep going, so I'd rather accept the occasional false negative than keep flagging that pattern as a bug. What do you think?
There was a problem hiding this comment.
Ran the actual numbers instead of guessing. Classified all 247 ruling findings by control-flow shape relative to the flagged await:
- 8/247 (3.2%) are option-1 territory: every path after the await unconditionally exits the loop.
- 30/247 (12.1%) are option-2-only: a return/break exists later in the loop body, but on a different path than the await's own.
- 206/247 (83.4%) have no later return/break at all.
Option 2 would additionally suppress those 30. I classified each of the 30 for TP/FP against the independent-iteration test (shared state / dependency between iterations / retry-backoff / rate-limited / resource-bounded / inherently-sequential = FP; independent = TP): 14 TP / 16 FP.
So option 2 would introduce ~14 new false negatives (real, independent-iteration cases it would wrongly suppress) — things like for (const path of paths) { if (await pathExists(path)) return path; } in editors/darwin.ts/linux.ts/win32.ts, find-account.ts, dev_build_cache.ts — the "search a small list, return on first hit" idiom, occurring independently across several codebases in the corpus, not a one-off. It would also incidentally suppress 16 findings that were already FP for unrelated reasons (pagination, listener chains, retry loops, etc.) — those would go away regardless.
Does a ~14-in-247 (5.7% of all findings) false-negative rate seem like it's worth taking, against the noise reduction it buys?
| @@ -0,0 +1,119 @@ | |||
| /* | |||
| * SonarQube JavaScript Plugin | |||
There was a problem hiding this comment.
Test coverage gap: unit.test.ts doesn't exercise a few cases that the underlying ESLint no-await-in-loop logic does handle:
awaitin the test/update clause of a classicfor(;;)loop, e.g.for (let i = 0; await cond(i); i++) { ... }await usingdeclarations inside a loop, e.g.for (const x of arr) { await using r = acquire(); }awaitnested inside intermediate control flow (e.g.if) within the loop body, to confirm detection still works through non-function nesting:async function foo(arr) { for (let i = 0; i < arr.length; i++) { if (arr[i]) { await bar(arr[i]); } } }
Could we add valid/invalid cases for these? Without them, a future ESLint upgrade or refactor of the decorator could silently regress any of these without any test catching it.
Wraps the ESLint core rule through interceptReport with a passthrough report handler, so a suppression decorator can be added without touching index.ts/meta.ts again. All existing unit tests stay green.
…uppress Adds two invalid cases, still correctly flagged before the decorator lands: the early-exit-after-await FP guillemsarda raised (await followed by a loop-terminating return elsewhere in the body), and a known accepted false negative it shares the same shape with (independent per-item search-and-return-first-match).
Suppresses the report when a return/break appears later in the loop body, on any path, regardless of whether it's the await's own path. Agreed with guillemsarda (PR #7900 review) after quantifying the trade-off against the ruling corpus: ~14/247 findings (5.7%) become accepted false negatives (independent-iteration cases sharing the same shape), against removing the early-exit FP class plus 16 other findings that were already FP for unrelated reasons. Flips the two pinned cases from the previous commit to valid.
| function hasLaterLoopExit( | ||
| loop: LoopLike, | ||
| afterNode: estree.Node, | ||
| visitorKeys: SourceCode.VisitorKeys, | ||
| ): boolean { | ||
| const afterEnd = afterNode.range![1]; | ||
|
|
||
| function search(node: estree.Node): boolean { | ||
| if (isBoundary(node)) { | ||
| return false; | ||
| } | ||
| if ( | ||
| (node.type === 'ReturnStatement' || node.type === 'BreakStatement') && | ||
| node.range![0] > afterEnd | ||
| ) { |
There was a problem hiding this comment.
⚠️ Bug: Ruling baselines still contain issues the decorator now suppresses
The 11 expected-result files were generated in commit 4098098 against the undecorated no-await-in-loop, and this commit changes reported issues without regenerating them. desktop:app/src/lib/find-account.ts:102 is exactly the shape the decorator now suppresses (const canAccess = await canAccessRepository(...); if (canAccess) { return account } inside for (const account of sortedAccounts)), and the new unit test comment itself names find-account.ts and editors/darwin.ts as suppressed cases while desktop/typescript-S9382.json still expects lines 102 and 141/151/176. The ruling IT will fail on these projects until npm run ruling-sync is re-run after the decorator change and the removed entries are committed.
Regenerate the S9382 expected results so they reflect the decorated behavior:
# Re-run the ruling sync after the decorator change and commit the updated baselines:
# npm run ruling && npm run ruling-sync
# Entries such as desktop:app/src/lib/find-account.ts:102 and
# desktop:app/src/lib/editors/darwin.ts:[141,151,176] are now suppressed and must be removed.
- Apply fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
| function hasLaterLoopExit( | ||
| loop: LoopLike, | ||
| afterNode: estree.Node, | ||
| visitorKeys: SourceCode.VisitorKeys, | ||
| ): boolean { | ||
| const afterEnd = afterNode.range![1]; | ||
|
|
||
| function search(node: estree.Node): boolean { | ||
| if (isBoundary(node)) { | ||
| return false; | ||
| } | ||
| if ( | ||
| (node.type === 'ReturnStatement' || node.type === 'BreakStatement') && | ||
| node.range![0] > afterEnd | ||
| ) { |
There was a problem hiding this comment.
💡 Bug: An unlabeled break of a switch suppresses the finding
hasLaterLoopExit matches any BreakStatement in the loop body positioned after the await, and only functions / for await are boundaries, so a plain switch after the await silences the rule even though its break exits the switch, not the loop: for (const x of xs) { await f(x); switch (x.k) { case 'a': break; } } is no longer reported. This shape is common in the corpus, so it is a broader false-negative class than the "break may target another loop" trade-off documented in the doc comment. Treat an unlabeled break whose nearest breakable ancestor is a SwitchStatement as not exiting the loop.
Track whether we are inside a switch (and not inside a nested loop) so an unlabeled switch break no longer counts as a loop exit:
function search(node: estree.Node, inSwitch = false): boolean {
if (isBoundary(node)) {
return false;
}
if (node.range![0] > afterEnd) {
if (node.type === 'ReturnStatement') {
return true;
}
if (node.type === 'BreakStatement' && (!inSwitch || node.label)) {
return true;
}
}
const nested = node.type === 'SwitchStatement' || (inSwitch && !isLoopLike(node));
return childrenOf(node, visitorKeys).some(child => search(child, nested));
}
- Apply fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
| const afterEnd = afterNode.range![1]; | ||
|
|
||
| function search(node: estree.Node): boolean { | ||
| if (isBoundary(node)) { | ||
| return false; | ||
| } | ||
| if ( | ||
| (node.type === 'ReturnStatement' || node.type === 'BreakStatement') && | ||
| node.range![0] > afterEnd | ||
| ) { | ||
| return true; | ||
| } | ||
| return childrenOf(node, visitorKeys).some(search); | ||
| } | ||
|
|
There was a problem hiding this comment.
💡 Edge Case: Header awaits are suppressed by any body exit, unlike do-while
For awaits in a loop header (while (await next()) { ... }, for (; await cond(); )) every return/break in the body has range[0] > afterEnd, so such loops are suppressed whenever the body contains any exit at all, regardless of position relative to the awaited call. The semantically equivalent do { ... if (x) break; } while (await next()) is still reported because the test comes lexically last, so two identical patterns get opposite verdicts. Consider restricting the later-exit search to awaits located inside loop.body (headers always re-evaluate per iteration) or comparing against the loop body start for header awaits.
Was this helpful? React with 👍 / 👎
CI failed: Build failure caused by a missing rule data file (S9380.json) during the rule data deployment step.Overview1 build failure encountered across 4 analyzed logs, caused by a missing rule data file during the deployment script execution. FailuresMissing Rule Data File S9380.json (confidence: high)
Summary
Code Review
|





Fixes JS-2409.
Summary
S9382flagsawaitused inside a loop body, which forces each iteration's async operation to run sequentially instead of in parallel (matches Biome'snoAwaitInLoops, which is itself just a re-exposure of ESLint core'sno-await-in-loop).no-await-in-loop(external/core.ts), following the existingS1751/S1763/S878pattern — no custom decorator/behavior change for now. That's a deliberate scope decision, not an oversight: see "Open design question" in JS-2409 about whether to add a Sonar-side decorator suppressing the early-exit false-positive case ESLint's own maintainers declined to fix (eslint#14700).defaultQualityProfiles: []), matching ESLint's ownrecommended: falseand Biome's off-by-default posture — this rule has a known high-noise profile on intentionally-sequential code (dependent iterations, retries, rate limiting, ordered writes), documented in the rule'sExceptionssection.quickfix: infeasible— rewriting toPromise.all()changes execution/error semantics, not a safe mechanical fix.Dependency
S9382reserved via SonarSource/rspec#8127 (still in review as of this PR). This PR includes a local mirror of that rule'smetadata.jsonatsonar-plugin/javascript-checks/.../rules/javascript/S9382.json, sincenpm run generate-metareads from this vendored copy rather than the live rspec repo. If the rspec content changes during review, this file needs to stay in sync.Test plan
unit.test.tscovers thefor-await-ofexclusion plusfor...of/classicfor/while/do...while/for...ininvalid cases.npm run bridge:testsuite: 2371/2388 passing; the 17 failures are all pre-existing, unrelated gRPC server port-binding flakiness inanalyze-project-server.test.ts, not touched by this change.npm run rulingrun against the full third-party corpus; new findings spot-checked for correctness (e.g.vuetify/.../form.ts:89sequential validation loop,sizzle/tasks/karma-tests.js:30sequential subprocess spawning). Synced vianpm run ruling-sync— 11 new expected-result files added, no existing baselines changed.🤖 Generated with Claude Code