Skip to content

eslint: extend the timer polling rule to more shapes and to test helpers - #343740

Draft
Alexandru Dima (alexdima) wants to merge 2 commits into
mainfrom
agents/no-timeout-polling-rule-v2
Draft

Alexandru Dima (alexdima) wants to merge 2 commits into
mainfrom
agents/no-timeout-polling-rule-v2

Conversation

@alexdima

@alexdima Alexandru Dima (alexdima) commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #342698.

Problem

local/code-no-timeout-polling-loop only reports a loop whose body awaits a known sleep directly and otherwise only checks exit guards and assigns reads. An audit of main found timer polls in unit tests that it cannot see:

  • sleeps behind a same-file helper, such as function tick() { return new Promise(resolve => setImmediate(resolve)); } in claudeAgent.test.ts, behind setImmediate or requestAnimationFrame promises, or called on a namespace, such as async.timeout(10);
  • loop bodies that only add bookkeeping, such as attempts++, console.log(...) or a try { read; assert; break; } catch { } retry around the sleep;
  • setInterval waiters, and functions that sleep and then call themselves;
  • test helpers outside *.test.ts, such as waitFor and settle in fakeLlamaServer.ts and 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:

  • Sleeps: x.timeout(n), x.sleep(n) and x.delay(n) on a namespace, but not x.wait(), which waits on barriers and gates. Also the awaited setImmediate() of timers/promises, and new Promise executors that only resolve on a setTimeout, setImmediate or requestAnimationFrame, also nested or behind void. Also raceTimeout on 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.
  • Bodies: counter updates, console.* calls, nested blocks, if/else branches that only do the same, and try blocks with a catch that retry reads and assertions. Assertions count as reads only in such a try block, since elsewhere they test each sample. Calling a parameter of the polling function counts as a read, as predicate in waitFor(predicate) or fn in poll(fn, accept), unless its result is returned directly, as in retry(task). closest, contains and matches are added to the read names.
  • Shapes: a function that only waits and then calls itself (return waitUntil()) or reschedules itself (setTimeout(check, n)) with arguments that only read, and a const handle = setInterval(...) whose callback only checks state, clears the interval and conditionally resolves an enclosing promise or completes a DeferredPromise, also in a branch that ends with return.

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 within runWithFakedTimers.

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.ts and *.integrationTest.ts. The README entry describes the new shapes and the scope.

Each audit probe has a test case:

Probe Shape Result
P01 while (!cond()) { await timeout(10); } Reported, as before.
P02 await async.timeout(10) Reported.
P03 Renamed sleep helper Reported for same-file const and function helpers, also when declared after use, and for import { timeout as pause }.
P04 Microtask spin Not reported: deterministic.
P05 Fixed microtask flush Not reported: deterministic.
P06 setImmediate promise Reported.
P07 attempts++ Reported.
P08 attempts += 1 Reported.
P09 try/catch retry Reported when the try block only reads and asserts. Retrying an operation is not reported.
P10 console.log in the body Reported: logging does not change the polled state, so the loop still only waits.
P11 raceTimeout(neverResolving, 10) Reported when the promise is new Promise(() => { }), inline or in a same-file const. An executor that does work or throws is not a sleep.
P12 Recursion Reported for a function that calls itself after a sleep, or reschedules itself with setTimeout.
P13 setInterval waiter Reported.
P14 await null Not reported: deterministic.
P15 new Promise(r => void setTimeout(r, 10)) Reported.
P16 Sleep in a { } block Reported.

Reviewer 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: 12 tick() and setImmediate polls in claudeAgent.test.ts, 9 animation-frame waits, 2 try/catch retries and 1 setInterval waiter. One false positive was found and fixed along the way: retry(task, delay, retries) in async.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 a try without a catch, 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 closest was not a read name. It is fixed and now reported.

  • Existing violations: The 14 files that poll today are listed in the rule's exclude option, computed on 0a6743a962b. 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.

    File Removed by
    src/vs/base/test/browser/animationSync.test.ts No fix in flight
    src/vs/platform/agentHost/test/node/agentService.test.ts agentHost: await real signals instead of hidden polling waits in agent host tests #343728
    src/vs/platform/agentHost/test/node/claudeAgent.test.ts Stays: agentHost: await real signals instead of hidden polling waits in agent host tests #343728 (at 7e76185fe8e) keeps the fixed 20-tick settle() helper
    src/vs/platform/agentHost/test/node/claudeProxyService.test.ts agentHost: await real signals instead of hidden polling waits in agent host tests #343728
    src/vs/sessions/contrib/layout/test/browser/chatLayoutOwnership.test.ts sessions: await a layout whenIdle signal instead of polling in layout tests #343730
    src/vs/sessions/contrib/layout/test/browser/desktopLayoutController.test.ts sessions: await a layout whenIdle signal instead of polling in layout tests #343730
    src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts No fix in flight
    src/vs/sessions/test/browser/mobileMultiDiffView.test.ts No fix in flight
    src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts No fix in flight
    src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts No fix in flight
    src/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.ts tasks: assert terminal status directly instead of polling in tests #343718
    src/vs/workbench/test/browser/componentFixtures/chat/chatScrollAnchoring.fixture.ts No fix in flight
    src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts No fix in flight: test: await DOM signals instead of polling in component fixtures #343734 kept the frame-stability poll waitForFixtureCondition on purpose
    src/vs/workbench/test/browser/componentFixtures/imageCarousel.fixture.ts No fix in flight

    The 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.ts rather than *IntegrationTestHelpers*, so serverIntegrationTestHelpers.test.ts, a unit test with fake processes that the rule already lints, stays in scope. **/fixtures/** is ignored globally, but componentFixtures/ is linted.

  • Cost: The rule is syntax-only, apart from scope lookups that resolve same-file helpers. With the full repo config, TIMING=all over all 3,162 src/vs/**/test/**/*.ts files gives 79.0 ms of 11.8 s total rule time (15th of 70 rules); an earlier run on 56649cd4334 gave 52.7 ms (18th), against 22.6 ms (34th) for the rule on main. To stay cheap, the rule does not visit every call or function. It classifies only named functions that refer to themselves, and finds setInterval waiters through their handle declarations. Without TIMING's per-listener overhead, the rule alone takes 90 ms over the 3,045 linted files, against 48 ms for the rule on main, 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 (on 56649cd4334): the code-no-timeout-polling-loop suite and the other lint rule suites passed. Overall, 1,161 of 1,172 tests passed. 3 failed and 7 were cancelled in build/lib/test/apiProposalNames.test.ts, build/next/test/devTunnelsWeb.test.ts and build/next/test/resources.test.ts, which fail the same way on origin/main in this worktree (node --test on those three files: 3 failed, 7 cancelled). The Dev Tunnels failure is caused by this worktree's node_modules resolving 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 on main as well.
  • Repo-wide counts on 0a6743a962b, with an ESLint config that enables only this rule without its exclude option, on git 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-filename with the repo config are reported in a unit test, a test utility and a component fixture. They are ignored in an excluded file, under e2e/, in *.integrationTest.ts, in *IntegrationTestHelpers.ts and 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.
  • Self-review with the code-review skill: fixed a scope lookup that could resolve a parameter to its function, and required setInterval waiters to clear the interval, so that a callback that only logs is not reported.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 09:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants