Skip to content

Enable @typescript-eslint/no-floating-promises so an unhandled rejection can't fail the gate again #1959

Description

@cliffhall

What problem does this solve?

#1947 made npm run ci unpassable: two tests fired client.callTool(...) without holding the promise, disconnect() rejected the pending request with Connection closed, and the unhandled rejection failed the whole vitest run — aborting the chain at coverage and silently skipping verify:build-gate, smoke, and Storybook. #1958 fixes those two call sites.

What it does not do is stop the next one. A floating promise is invisible at review time: the call looks like every awaited call minus four characters, and the failure it causes surfaces in a different test, in a different file, as a stack that points at SDK internals. #1947 took a full investigation to attribute; the fix was two lines.

@typescript-eslint/no-floating-promises catches the class outright. It is not currently enabled anywhere — all five ESLint scopes (clients/{web,cli,tui,launcher} and the root core/ + shared gate) extend tseslint.configs.recommended, which is the non-type-aware set. The rule needs type information, so enabling it means turning on parserOptions.projectService for the linted globs.

Measured surface

Probed against clients/web with the rule on (recommendedTypeChecked + projectService), on top of #1958:

Bucket Violations
*.stories.tsx (play functions) 73
Tests 5
server/ production code 2
Total 80

So the production surface is ~2 sites, and the overwhelming bulk is Storybook play functions — almost certainly un-awaited userEvent.* calls, which are mechanical to fix and arguably worth fixing on their own (an un-awaited userEvent is a real source of interaction-test flake). The other three clients and core/ are unmeasured; this probe covered web only.

The 80 are why this wasn't folded into #1958 — it is a cleanup pass, not a config toggle.

Proposed approach

  1. Turn on projectService per ESLint scope and enable only no-floating-promises (not the whole recommendedTypeChecked set — that is a much larger and separate argument).
  2. Measure the remaining four scopes the same way.
  3. Fix the sites. For a deliberately un-awaited call, the fix is to hold and settle the promise — clients/web/src/test/integration/mcp/inspectorClient.test.ts has a settleInFlight() helper from fix: settle the deliberately in-flight tool calls in the progress tests #1958 that documents the pattern and the reasoning.
  4. Land the rule as error once the count is zero.

Things to decide during the work

  • Scope. Test/story globs only, or production code too? Production is where an unhandled rejection actually hurts a user, and it is only ~2 sites in web — probably worth including rather than deferring.
  • Lint runtime. Type-aware linting is materially slower, and lint runs inside validate, the fast inner-loop check. Worth timing before/after; if the hit is bad, scoping projectService to the globs that need it is the lever.
  • void as an escape hatch. The rule accepts void somePromise() as "deliberately ignored". That is strictly better than nothing (it is visible at review time), but it still discards the rejection. Prefer holding and settling the promise where a rejection is actually possible, and reserve void for calls that genuinely cannot reject.

Additional context

Discovered while fixing #1947 (PR #1958) — flagged there as a follow-up rather than folded in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature requestv2Issues and PRs for v2

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions