Repository navigation
eslint: extend the timer polling rule to more shapes and to test helpers - #343740
Draft
Alexandru Dima (alexdima) wants to merge 2 commits into
Draft
Alexandru Dima (alexdima) wants to merge 2 commits into
Alexandru Dima (alexdima) wants to merge 2 commits into
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The new classifiers introduce false positives for work-bearing code and miss interval waiters whose success branches end with a return.
4 open issues
What changed in this PR
Extends VS Code’s timer-polling lint rule to detect more polling patterns and cover unit-test helpers, following up on #342698.
Changes:
- Recognizes additional sleep helpers, bookkeeping and retry bodies, recursive polls, and interval waiters.
- Expands lint coverage to test utilities, fixtures, and harnesses, with integration exclusions and an updated existing-violation list.
- Adds regression cases and documents the expanded behavior and scope.
| File | Description |
|---|---|
eslint.config.js |
Expands the rule’s test scope and updates exclusions. |
.eslint-plugin-local/tests/code-no-timeout-polling-loop.test.ts |
Adds positive and negative cases for new polling patterns. |
.eslint-plugin-local/README.md |
Documents supported patterns, exceptions, and scope. |
.eslint-plugin-local/code-no-timeout-polling-loop.ts |
Implements broader sleep and polling classification. |
Review effort: Balanced
local/code-no-timeout-polling-loop now also recognizes sleeps behind same-file helpers, member calls such as async.timeout, setImmediate and requestAnimationFrame promises, and raceTimeout on a promise that never settles. Poll bodies may contain counters, logging and try/catch retries of reads and assertions. Recursive polls and setInterval waiters are reported as well. The rule now covers src/vs/**/test/**/*.ts, except e2e and integration tests, so fixtures, harnesses and test utilities are linted too. Files that poll today are listed in the rule's exclude option. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Require never-settling executors to be empty, retry assertions only in a try with a catch, classify return-ending branches of setInterval waiters, and check the arguments of rescheduled calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Alexandru Dima (alexdima)
force-pushed
the
agents/no-timeout-polling-rule-v2
branch
from
October 23, 2026 13:44
ea0573b to
eb2af82
Compare
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Classification errors still produce false polling warnings for work-bearing code, non-retrying assertions and intervals without conditional promise completion.
4 open issues
4 resolved since last review
Review effort: Balanced
Comment on lines
+177
to
+179
| return sleepFunctions.has(callee.name) || callee.name === 'setTimeout' || callee.name === 'setImmediate' || this.isSleepHelper(callee); | ||
| } | ||
| return !!name && memberSleepFunctions.has(name) && node.arguments.length > 0; |
Comment on lines
+312
to
+313
| return this.waits(statement.block.body, inTry || !!statement.handler, true) | ||
| && (!statement.handler || this.waits(statement.handler.body.body, inTry, true)) |
Comment on lines
+361
to
+362
| const repeats = last.type === 'ContinueStatement' || (last.type === 'ReturnStatement' && this.isSelfCall(last.argument)); | ||
| return repeats || inTry || this.isInterval ? this.waits(statements, inTry, true) : true; |
| if (!this.isSettle(node)) { | ||
| return false; | ||
| } | ||
| this.settles ||= nested; |
This branch has not been deployed
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.

Follow-up to #342698.
Problem
local/code-no-timeout-polling-looponly reports a loop whose body awaits a known sleep directly and otherwise only checks exit guards and assigns reads. An audit ofmainfound timer polls in unit tests that it cannot see:function tick() { return new Promise(resolve => setImmediate(resolve)); }inclaudeAgent.test.ts, behindsetImmediateorrequestAnimationFramepromises, or called on a namespace, such asasync.timeout(10);attempts++,console.log(...)or atry { read; assert; break; } catch { }retry around the sleep;setIntervalwaiters, and functions that sleep and then call themselves;*.test.ts, such aswaitForandsettleinfakeLlamaServer.tsand the component fixtures, which the config did not lint.Of 16 audit probes, the rule reported only the plain
while (!cond()) { await timeout(10); }.Change
The rule stays syntax-only. It now also recognizes:
x.timeout(n),x.sleep(n)andx.delay(n)on a namespace, but notx.wait(), which waits on barriers and gates. Also the awaitedsetImmediate()oftimers/promises, andnew Promiseexecutors that only resolve on asetTimeout,setImmediateorrequestAnimationFrame, also nested or behindvoid. AlsoraceTimeouton a promise that never settles (an empty executor), and calls of same-file helpers that only sleep or of a renamed import of a sleep, resolved through ESLint's scope manager.console.*calls, nested blocks,if/elsebranches that only do the same, andtryblocks with acatchthat retry reads and assertions. Assertions count as reads only in such atryblock, since elsewhere they test each sample. Calling a parameter of the polling function counts as a read, aspredicateinwaitFor(predicate)orfninpoll(fn, accept), unless its result is returned directly, as inretry(task).closest,containsandmatchesare added to the read names.return waitUntil()) or reschedules itself (setTimeout(check, n)) with arguments that only read, and aconst handle = setInterval(...)whose callback only checks state, clears the interval and conditionally resolves an enclosing promise or completes aDeferredPromise, also in a branch that ends withreturn.Microtask spins (
await Promise.resolve(),await null) are still not reported. Neither are loops that do work between sleeps (streaming mock data, scrolling or clicking until something appears, per-frame sampling that is itself the assertion), parameterized tests, or code withinrunWithFakedTimers.The rule moves to its own config block for
src/vs/**/test/**/*.ts, which adds 333 fixtures, harnesses and test utilities to its scope. Integration code is ignored:**/test/**/e2e/**,*IntegrationTestHelpers.tsand*.integrationTest.ts. The README entry describes the new shapes and the scope.Each audit probe has a test case:
while (!cond()) { await timeout(10); }await async.timeout(10)constandfunctionhelpers, also when declared after use, and forimport { timeout as pause }.setImmediatepromiseattempts++attempts += 1try/catchretrytryblock only reads and asserts. Retrying an operation is not reported.console.login the bodyraceTimeout(neverResolving, 10)new Promise(() => { }), inline or in a same-fileconst. An executor that does work or throws is not a sleep.setTimeout.setIntervalwaiterawait nullnew Promise(r => void setTimeout(r, 10)){ }blockReviewer notes
False positives: The extended rule was run on all 10,787 TypeScript files in
src/vs, product code included, with the exclude list disabled. Reports went from 14 in 14 files to 41 in 29 files. Every report was classified by reading the code, and all 41 wait for state. 9 are in product code, waiting on processes, files, ports, globals or UI state, and 6 are in e2e and integration tests; the config lints neither. Of the 26 reports in the new scope, 2 were already reported and excluded. The 24 others are new: 12tick()andsetImmediatepolls inclaudeAgent.test.ts, 9 animation-frame waits, 2try/catchretries and 1setIntervalwaiter. One false positive was found and fixed along the way:retry(task, delay, retries)inasync.ts, which returns the callback's result. Review feedback found three more shapes that would have been false positives, none of which occurs in the repository: a parameterless executor that does work, assertions in atrywithout acatch, and a rescheduled call whose arguments do work. Each is fixed and has a test.False negatives: All 160 loops in the new scope that await a sleep but are not reported were listed with the statement that makes each one count as work. They fire events, click, scroll, change configuration, stream data, sample into arrays or run parameterized tests. The one exception was a fixture poll that was missed because
closestwas not a read name. It is fixed and now reported.Existing violations: The 14 files that poll today are listed in the rule's
excludeoption, computed on0a6743a962b. agentHost: await debug-log artifact expiry instead of polling the temp folder #343723, test: await DOM signals instead of polling in component fixtures #343734 and agentHost: await real signals instead of polling in the local model bridge tests #343737 had merged by then and cleared 7 files, which need no entry. The heads of the open fix PRs were linted with this rule.src/vs/base/test/browser/animationSync.test.tssrc/vs/platform/agentHost/test/node/agentService.test.tssrc/vs/platform/agentHost/test/node/claudeAgent.test.ts7e76185fe8e) keeps the fixed 20-ticksettle()helpersrc/vs/platform/agentHost/test/node/claudeProxyService.test.tssrc/vs/sessions/contrib/layout/test/browser/chatLayoutOwnership.test.tssrc/vs/sessions/contrib/layout/test/browser/desktopLayoutController.test.tssrc/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.tssrc/vs/sessions/test/browser/mobileMultiDiffView.test.tssrc/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.tssrc/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.tssrc/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.tssrc/vs/workbench/test/browser/componentFixtures/chat/chatScrollAnchoring.fixture.tssrc/vs/workbench/test/browser/componentFixtures/fixtureUtils.tswaitForFixtureConditionon purposesrc/vs/workbench/test/browser/componentFixtures/imageCarousel.fixture.tsThe eight files without a fix in flight wait for animation frames, which the rule did not recognize before. sessions: fire the cloud commit once the provider listens #343720 and chat: end the pet landing wait on the landing, not a frame budget #343721 need no entry: the rule does not report their loops, which fire events or sample every frame. sessions: await a layout whenIdle signal instead of polling in layout tests #343730 also removes its entries from the exclude list, so it or this PR will have a small conflict in
eslint.config.js.Scope: As in eslint: flag timer polling loops in unit tests #342698, product code and tests that wait on other processes, files or ports are not linted, since there is no event to await there. No e2e file matched
*.test.ts, so the new ignores remove no existing coverage. The ignore is*IntegrationTestHelpers.tsrather than*IntegrationTestHelpers*, soserverIntegrationTestHelpers.test.ts, a unit test with fake processes that the rule already lints, stays in scope.**/fixtures/**is ignored globally, butcomponentFixtures/is linted.Cost: The rule is syntax-only, apart from scope lookups that resolve same-file helpers. With the full repo config,
TIMING=allover all 3,162src/vs/**/test/**/*.tsfiles gives 79.0 ms of 11.8 s total rule time (15th of 70 rules); an earlier run on56649cd4334gave 52.7 ms (18th), against 22.6 ms (34th) for the rule onmain. To stay cheap, the rule does not visit every call or function. It classifies only named functions that refer to themselves, and findssetIntervalwaiters through their handle declarations. WithoutTIMING's per-listener overhead, the rule alone takes 90 ms over the 3,045 linted files, against 48 ms for the rule onmain, measured as the sum of per-file minima over 5 runs. The timings come from a shared, loaded machine and vary by about 30% between runs.Validation
node --test .eslint-plugin-local/tests/code-no-timeout-polling-loop.test.ts: passed (105/105), including a case for each audit probe. The 7 cases added for review feedback fail against the previous revision of the rule.npm run test-build-scripts(on56649cd4334): thecode-no-timeout-polling-loopsuite and the other lint rule suites passed. Overall, 1,161 of 1,172 tests passed. 3 failed and 7 were cancelled inbuild/lib/test/apiProposalNames.test.ts,build/next/test/devTunnelsWeb.test.tsandbuild/next/test/resources.test.ts, which fail the same way onorigin/mainin this worktree (node --teston those three files: 3 failed, 7 cancelled). The Dev Tunnels failure is caused by this worktree'snode_modulesresolving outside the repository.npx tsc -p .eslint-plugin-local/tsconfig.json --noEmit: no errors in the changed files. The 19 errors in 11 other rule files exist onmainas well.0a6743a962b, with an ESLint config that enables only this rule without itsexcludeoption, ongit ls-files 'src/vs/**/*.ts': 14 reports in 14 files before, 41 in 29 after. Within the old scope (src/vs/**/*.test.ts), 2 in 2 files before and 23 in 11 after. Within the new scope, 2 in 2 files before and 26 in 14 after.TIMING=all node node_modules/eslint/bin/eslint.js $(git ls-files 'src/vs/**/test/**/*.ts'): 0 warnings from this rule, with the timings above. Probes through--stdin-filenamewith the repo config are reported in a unit test, a test utility and a component fixture. They are ignored in an excluded file, undere2e/, in*.integrationTest.ts, in*IntegrationTestHelpers.tsand in product code.npm run eslint -- .eslint-plugin-local/code-no-timeout-polling-loop.ts .eslint-plugin-local/tests/code-no-timeout-polling-loop.test.ts eslint.config.js: passed.code-reviewskill: fixed a scope lookup that could resolve a parameter to its function, and requiredsetIntervalwaiters to clear the interval, so that a callback that only logs is not reported.