Skip to content

linters/eslint-rules/no-over-cap-wait-in-eval-in-obsidian

ESLint rule: no-over-cap-wait-in-eval-in-obsidian

Reports an evalInObsidian / pollInObsidian closure that declares more waiting than the transport can ever honour.

A single closure is evaluated through one transport call, and that call is capped at roughly 30 seconds — Appium surfaces the cap as a bare WebDriverError: script timeout naming only AppiumTransport.evaluate, CDP as a command timeout. Neither names the wait that actually blew the budget, so the failure reads as a broken device rather than as a closure asking for more time than exists. That misdiagnosis cost two days.

The cap the rule assumes when capInMilliseconds is not configured is DEFAULT_EVAL_CAP_IN_MILLISECONDS (30 000), the cap both obsidian-integration-testing transports enforce. It is restated in ../../test-runners/performance-eval-cap.ts rather than imported from the harness, because the harness is an OPTIONAL peer and the shared ESLint config loads this rule in every consuming repo, including one that does not run integration tests; a unit test there pins the two numbers together, so the rule still cannot drift from the thing it is predicting.

ONE KIND OF FILE DEFAULTS HIGHER: a *.desktop-performance.integration.test.ts is measured against PERFORMANCE_EVAL_CAP_IN_MILLISECONDS (600 000), the per-eval cap the shared vitest config gives the integration-tests:desktop-performance project, imported from that same module rather than restated. That raise is a deliberate BUDGET: a perf suite’s measurement is one long in-page evaluation, and its files run on no other project. Every other file keeps the single transport default, and that is the convention rather than a gap: a repo that raises its own desktop commandTimeoutInMilliseconds is setting a backstop, NOT a budget a closure may spend, so the rule does not read that raise and keeps enforcing 30 000 against it. A cross-platform suite could not spend it anyway: Appium caps every closure at 30 000 whatever a desktop raise says. An explicit capInMilliseconds still overrides both defaults, for every file the rule is configured on.

A closure declaring a 30 000 ms waitUntil, or two 20 000 ms ones, therefore cannot succeed on any machine slow enough to need the time it asks for. The fix is to move the waiting to Node: pollInObsidian runs a SHORT DOM-reading poll closure repeatedly, evaluates until in Node, and carries the long budget in timeoutInMilliseconds, so no single transport call is ever long.

WHAT COUNTS toward a closure’s budget, summed lexically: every waitUntil inside it — taking the helper’s documented 5 000 ms default when timeoutInMilliseconds is omitted, which is what makes several small sibling waits add up honestly — plus every sleep(n) settle. Waits inside a helper declared within the closure count too; the enclosing closure is found by walking outwards, so a nested function attributes to the closure it runs inside – once per CALL SITE, per the HELPERS paragraph below.

WHAT DOES NOT. A budget that cannot be resolved to a number contributes nothing and is never reported (owner, 2026-09-09). Scope analysis resolves the shapes that actually occur — a literal, a const in any enclosing scope, and a destructured callback parameter followed back through the call’s own input object — and resolving through scope rather than by regex is the whole reason this is a rule and not a script. But a value arriving from a caller (options.timeout ?? DEFAULT, threaded through a parameter) would need analysis across call boundaries, and reporting every such site would make each legitimately parameterized helper red across thirty repos.

LOOPS were once out of scope wholesale, on the reasoning that a sleep in a while has no statically declared ceiling. That is true of while (true) and false of the loop these suites actually write, which declares its ceiling as plainly as any literal timeout — and leaving them all out hid 22 over-cap closures across six repos, one of them ten times over. Two shapes are read now, both through the same scope walk a budget uses:

  • A DEADLINE loop — const deadline = Date.now() + BUDGET, guarded by Date.now() < deadline — cannot run longer than BUDGET whatever its poll interval, so BUDGET is charged to the closure ONCE per loop, on top of the single iteration its inner waits are already charged (a poll that starts just under the deadline still runs to completion, so the sum of the two is the honest worst case rather than a double count). performance.now() counts as well as Date.now(): a performance-written deadline read as unbounded is why one 245 025 ms closure was absent from every roster produced before this. - A COUNTING for — for (let attempt = 0; attempt < ATTEMPTS; attempt++) — multiplies every wait inside it by its iteration count, read from the loop’s own start, bound and step.

Either ceiling may be ONE CONJUNCT of a compound test (while (count < expected && Date.now() < deadline), for (let attempt = 0; attempt < ATTEMPTS && !isOpen; attempt++)), which is how both are actually written: a progress condition beside the guard does not remove the ceiling the other conjunct declares.

Anything else keeps the old per-iteration charge rather than a guess — while (true), a for…of, a bound or deadline that does not resolve — which is what keeps a genuinely unbounded loop silent.

A HELPER declared inside the closure is charged once per CALL SITE, which is how many times its waiting actually runs. Its own waits are summed once and multiplied by the calls that reach it, each call carrying the counting loops it is itself written inside — so a local waitUntil(check) of for (attempt < 40) await sleep(250) called three times declares 30 000 ms rather than the 10 000 ms a single attribution reported. Three bounds keep that honest:

  • A helper declared OUTSIDE the closure stays out of scope, for the same reason a budget arriving from a caller is: reading it needs analysis across call boundaries, and widening there would make every legitimately parameterized helper in every consuming repo red. - RECURSION terminates by charging a re-entered helper nothing, so neither a self-call nor a mutually recursive pair is charged forever. A recursive call contributes the silence an unbounded loop does. - A helper the closure never REACHES — dead, or called through something this rule cannot resolve, such as a bare reference handed to forEach — keeps the flat single charge it has always had. So no closure this rule reports today falls silent because a call site could not be read.

What the per-call-site count does NOT fix is a BRANCHY helper: an if / else is summed lexically like any two sibling waits, so both arms are charged at every call site. A helper whose two directions wait differently is therefore better written as two helpers, which is what this repo’s own src/obsidian/components/syntax-highlighting-component.obsidian.integration.test.ts now does.

Not every over-cap closure can become a poll / until pair, so the rule is meant to be disabled — with a reason — at the sites that have one. require-description makes that reason mandatory, which is the point: it turns an invisible assumption into a written one.

A CLOSURE DELIBERATELY LEFT UNDER THE CAP SAYS SO, in a comment opening with the exact line

Under the transport’s ~30s per-closure cap, not at it.

and then saying, for that closure, what the budget is made of and why it stays. This rule is silent on such a closure by construction — it is under the cap — so without the line there is no way to tell a budget somebody read and kept from one nobody has looked at, and a sweep run with a lower capInMilliseconds re-opens the same decisions every time. The line is the whole of the convention, which is what lets such a sweep subtract the closures already read and drive the remainder to zero.

Two placements, because a ceiling lives in two places. A comment INSIDE the closure, over that closure’s own ceiling constant, speaks for that closure alone; one at MODULE scope, over a constant the whole file shares, speaks for every closure in the file. A closure with no ceiling constant at all — several waitUntil calls taking the documented default — takes the in-closure form at the top of its body.

Saying it is NOT the same as clearing it. A budget under the cap is not a defect, and “read, and left as it is” is a correct outcome: tightening a ceiling to bring a number down is how a suite starts failing legitimately on a cold machine.

Variable Description
MESSAGE_ID Message ID reported when an in-Obsidian closure declares more waiting than the transport’s cap allows.
noOverCapWaitInEvalInObsidian ESLint rule disallowing an in-Obsidian closure whose declared waiting exceeds the transport’s script-timeout cap.