Skip to content

Generic heartbeat hardening on top of the Vitest addon timeout fix#35419

Closed
valentinpalkovic wants to merge 1 commit into
norbert/fix-timeout-vitest-addonfrom
valentin/heartbeat-priority-hardening
Closed

Generic heartbeat hardening on top of the Vitest addon timeout fix#35419
valentinpalkovic wants to merge 1 commit into
norbert/fix-timeout-vitest-addonfrom
valentin/heartbeat-priority-hardening

Conversation

@valentinpalkovic

@valentinpalkovic valentinpalkovic commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What I did

Stacked on top of #35400 (norbert/fix-timeout-vitest-addon), which fixed the "Server timed out" disconnect on large @storybook/addon-vitest runs by reordering WebsocketTransport.onmessage to handle ping before the channel handler. That fix, plus a STORYBOOK_TEST_STATUS_FLUSH_INTERVAL env var to reduce flush frequency, is a solid, low-risk improvement on its own.

This PR adds two more generic changes on top, so the heartbeat is resilient to any provider flooding the channel, not just addon-vitest, and without needing per-project tuning:

1. Bound how many test-case results are applied per flush (code/addons/vitest/src/node/test-manager.ts, constants.ts)

Previously a single flush applied the entire accumulated batch. flushTestCaseResults now takes at most maxTestCaseResultsPerFlush (default 200) per call, draining any remainder via setImmediate in bounded slices instead of one unbounded burst. onTestRunEnd is now async and waits for the drain to fully complete (also in bounded slices) before the run is considered finished, instead of flushing everything synchronously at the end.

This makes STORYBOOK_TEST_STATUS_FLUSH_INTERVAL optional rather than load-bearing: raising the interval no longer trades "queue depth risk" for "bigger single-burst risk," because no single flush can grow unbounded regardless of how much backlog accumulates.

2. Give the dev-server's heartbeat ping priority over relay traffic (code/core/src/core-server/utils/get-server-channel.ts)

ServerChannelTransport sends its keep-alive ping via a bare setInterval, in the same Node process that relays every event from a Vitest child process (or any other provider) to connected clients. If that relay work is heavy enough for long enough, the scheduled ping can slip on the server side too - the mirror image of the client-side bug #35400 fixes, just unaddressed until now.

send() now opportunistically broadcasts a ping first (and updates its own "last ping sent" bookkeeping) whenever it's asked to relay a non-ping event and a full HEARTBEAT_INTERVAL has elapsed since the last one. Since send() is called far more often than the interval ticks during a busy relay, heartbeat delivery no longer depends on that interval callback getting a turn on a busy event loop - it piggybacks on whatever traffic is already flowing. The existing setInterval is left untouched as the baseline driver for idle periods.

Notes for reviewers

  • Both changes are purely additive on top of Addon-Vitest: Prevent manager "Server timed out" during large test runs #35400's diff; nothing there was modified.
  • The bounded-flush re-arm intentionally does not go through the throttle wrapper (this.throttledFlushTestCaseResults()) - doing so can synchronously re-enter flushTestCaseResults when the throttle's interval has already elapsed, draining multiple chunks in one burst instead of yielding between them. Caught this empirically during development; there's a dedicated regression test for it (test-manager.test.ts: "drains at most one bounded chunk per elapsed interval...").
  • New/updated tests: code/addons/vitest/src/node/test-manager.test.ts (bounded flush + run-end drain), code/core/src/core-server/utils/__tests__/server-channel.test.ts (opportunistic heartbeat priority, 3 new tests using vi.setSystemTime() to jump the clock without advancing timers, isolating the new code path from the pre-existing setInterval).
  • maxTestCaseResultsPerFlush is a constructor option (for testability) but deliberately has no env var - the point is it shouldn't need tuning.

Testing

yarn nx run addon-vitest:check and yarn nx run core:check both pass with no type errors. Formatted with oxfmt; lint has no new warnings beyond what already existed on these files.

Summary by CodeRabbit

  • New Features

    • Improved test result updates by sending them in smaller batches, making long runs more responsive and predictable.
    • Heartbeat pings can now be sent alongside normal outgoing messages when needed, helping keep connections active during busy periods.
  • Bug Fixes

    • Test runs now wait for all pending result updates to finish before fully ending.
    • Added coverage to verify heartbeat and batched update behavior.

… flush interval

Builds on #35400: bounds how many test-case results are applied per
flush so a backlog can't turn into one oversized synchronous burst
regardless of how STORYBOOK_TEST_STATUS_FLUSH_INTERVAL is tuned, and
makes the dev-server's ServerChannelTransport opportunistically top up
its heartbeat ping on any outgoing broadcast once one is overdue, so a
busy relay path (or any other provider flooding the channel) can no
longer starve ping delivery the way the ping-sending setInterval alone
could.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫

PR is not labeled with one of: ["cleanup","BREAKING CHANGE","feature request","bug","documentation","maintenance","build","dependencies"]

🚫

PR is not labeled with one of: ["ci:normal","ci:merged","ci:daily","ci:docs"]

🚫

PR is not labeled with one of: ["qa:needed","qa:skip","qa:success"]

🚫 PR title must be in the format of "Area: Summary", With both Area and Summary starting with a capital letter Good examples: - "Docs: Describe Canvas Doc Block" - "Svelte: Support Svelte v4" Bad examples: - "add new api docs" - "fix: Svelte 4 support" - "Vue: improve docs"
🚫 PR description is missing the mandatory "#### Manual testing" section. Please add it so that reviewers know how to manually test your changes.
Warnings
⚠️

This PR targets norbert/fix-timeout-vitest-addon. The default branch for contributions is next. Please make sure you are targeting the correct branch.

Generated by 🚫 dangerJS against d891ce3

@valentinpalkovic valentinpalkovic marked this pull request as draft July 8, 2026 18:04
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a configurable cap (DEFAULT_MAX_TEST_CASE_RESULTS_PER_FLUSH) limiting batched test-case results applied per flush in the Vitest addon's TestManager, reworks onTestRunEnd to asynchronously drain remaining results, and adds heartbeat ping piggybacking to ServerChannelTransport.send(). Corresponding tests were added for both changes.

Changes

Vitest Bounded Flush Batching

Layer / File(s) Summary
Flush cap constant
code/addons/vitest/src/constants.ts
Adds DEFAULT_MAX_TEST_CASE_RESULTS_PER_FLUSH constant defaulting to 200.
Bounded flush and async run-end draining
code/addons/vitest/src/node/test-manager.ts, code/addons/vitest/src/node/reporter.ts
Adds maxTestCaseResultsPerFlush option, drains only a capped chunk per flushTestCaseResults() call via splice, reworks onTestRunEnd into an async method that polls via setImmediate until the backlog is empty, and updates the reporter to await it.
Flush batching tests
code/addons/vitest/src/node/test-manager.test.ts
Adds tests covering single bounded chunk per interval, multi-tick capped draining, and full backlog drainage on run end.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Server Channel Heartbeat Priority

Layer / File(s) Summary
Heartbeat piggybacking in send()
code/core/src/core-server/utils/get-server-channel.ts
Adds lastPingSentAt tracking and broadcasts a ping before non-ping events when HEARTBEAT_INTERVAL has elapsed.
Heartbeat priority tests
code/core/src/core-server/utils/__tests__/server-channel.test.ts
Adds fake-timer tests validating overdue ping triggering, no redundant pings, and no duplicate pings when explicitly sending ping.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Reporter
  participant TestManager
  participant StatusStore

  Reporter->>TestManager: await onTestRunEnd(endResult)
  TestManager->>TestManager: cancel throttled flush
  TestManager->>TestManager: flushTestCaseResults()
  TestManager->>StatusStore: set(bounded batch)
  loop while backlog remains
    TestManager->>TestManager: await setImmediate
    TestManager->>StatusStore: set(next bounded batch)
  end
  TestManager-->>Reporter: resolved
  Reporter->>Reporter: clearVitestState()
Loading
sequenceDiagram
  participant Caller
  participant ServerChannelTransport
  participant Client

  Caller->>ServerChannelTransport: send(event)
  alt event is ping
    ServerChannelTransport->>Client: broadcast ping
  else heartbeat overdue
    ServerChannelTransport->>Client: broadcast ping
    ServerChannelTransport->>Client: broadcast event
  else not overdue
    ServerChannelTransport->>Client: broadcast event
  end
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@code/addons/vitest/src/node/test-manager.ts`:
- Around line 261-269: The backlog drain in flushTestCaseResults can stall
forever if an exception is thrown after items are spliced but before the next
setImmediate reschedule happens. Update TestManager.flushTestCaseResults (and
the setImmediate continuation around the drain loop) so errors are caught and
surfaced while still guaranteeing the drain chain completes or stops cleanly;
use the existing batchedTestCaseResults / maxTestCaseResultsPerFlush flow and
ensure the while loop in onTestRunEnd cannot spin indefinitely when a flush
fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce7c35e8-d6bb-4e2c-b1a6-48c889680537

📥 Commits

Reviewing files that changed from the base of the PR and between 5d87183 and d891ce3.

📒 Files selected for processing (6)
  • code/addons/vitest/src/constants.ts
  • code/addons/vitest/src/node/reporter.ts
  • code/addons/vitest/src/node/test-manager.test.ts
  • code/addons/vitest/src/node/test-manager.ts
  • code/core/src/core-server/utils/__tests__/server-channel.test.ts
  • code/core/src/core-server/utils/get-server-channel.ts

Comment on lines 261 to +269
private flushTestCaseResults = () => {
const testCaseResultsToFlush = this.batchedTestCaseResults;
this.batchedTestCaseResults = [];
const testCaseResultsToFlush = this.batchedTestCaseResults.splice(
0,
this.maxTestCaseResultsPerFlush
);

if (testCaseResultsToFlush.length === 0) {
return;
}

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Exception during backlog drain permanently stalls onTestRunEnd.

flushTestCaseResults splices items from the backlog first (Lines 262-265), then does status-store/store updates that can throw. If a self-rescheduled continuation (Line 371, setImmediate(this.flushTestCaseResults)) throws mid-drain, the if (batchedTestCaseResults.length > 0) reschedule check is never reached, so nothing will ever drain the remaining backlog again. Since the exception occurs inside a bare setImmediate callback (outside any promise onTestRunEnd awaits), it's never surfaced — instead the polling loop at Lines 383-385 (while (this.batchedTestCaseResults.length > 0) await ...) spins indefinitely, hanging the run-end flow (and, transitively, reporter.ts's await this.testManager.onTestRunEnd(...) and clearVitestState()).

Previously, an error during the single-shot flush would simply throw synchronously out of onTestRunEnd; this change silently converts that into a permanent hang, which is a more severe failure mode.

🛡️ Suggested guard to ensure the drain chain always terminates or surfaces
   private flushTestCaseResults = () => {
     const testCaseResultsToFlush = this.batchedTestCaseResults.splice(
       0,
       this.maxTestCaseResultsPerFlush
     );

     if (testCaseResultsToFlush.length === 0) {
       return;
     }
-
-    const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({
-      ...
-    }));
-    ...
-    if (this.batchedTestCaseResults.length > 0) {
-      setImmediate(this.flushTestCaseResults);
-    }
+    try {
+      const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({
+        ...
+      }));
+      ...
+    } catch (err) {
+      this.reportFatalError('Failed to flush test case results', err as Error);
+    } finally {
+      if (this.batchedTestCaseResults.length > 0) {
+        setImmediate(this.flushTestCaseResults);
+      }
+    }
   };

Also applies to: 369-385

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/addons/vitest/src/node/test-manager.ts` around lines 261 - 269, The
backlog drain in flushTestCaseResults can stall forever if an exception is
thrown after items are spliced but before the next setImmediate reschedule
happens. Update TestManager.flushTestCaseResults (and the setImmediate
continuation around the drain loop) so errors are caught and surfaced while
still guaranteeing the drain chain completes or stops cleanly; use the existing
batchedTestCaseResults / maxTestCaseResultsPerFlush flow and ensure the while
loop in onTestRunEnd cannot spin indefinitely when a flush fails.

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.

1 participant