← reports

#2809 · Workflow worker retains poll abort listeners

Bug High Effort: Low perf plugins workflows open on GitHub 2026-09-01 · base 06277fa1c

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The idle workflow worker adds one abort listener for each poll. A completed timer does not remove its listener. A direct test found five listeners after 800 milliseconds. The signal lives until the plugin stops, so the listener list continues to grow.

2. Claims vs findings

ClaimStatusEvidence
The idle poll keeps old abort listeners.VerifiedThe focused test found five listeners after 800 milliseconds with no workflow runs.
The idle loop polls every 250 milliseconds.VerifiedThe worker selects a 250 millisecond delay when its active set is empty.
Each completed workflow run also keeps one worker-signal listener.VerifiedThe run path adds a closure to the worker signal. Run completion deletes only the controller map entry.
The retained listeners caused the stated production CPU and connection symptoms.UnverifiedThe production profile and server data were not available. The test verifies the necessary listener growth.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base commit.
  2. Save the linked test as plugins/workflows/src/worker-abort-listeners.test.ts.
  3. Run the focused test through Turbo.
    pnpm exec turbo run test --filter=bb-plugin-workflows -- --run src/worker-abort-listeners.test.ts

Expected: At most two listeners remain. One listener controls worker shutdown, and one belongs to the current wait.

expected: listenerCount <= 2
actual:   listenerCount = 5

AssertionError: expected 5 to be less than or equal to 2
Test Files  1 failed (1)
Tests       1 failed (1)

Repro file: worker-abort-listeners.test.ts

import { getEventListeners } from "node:events";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { expect, it } from "vitest";
import { migrations } from "./data.js";
import { createWorkflowService } from "./service.js";

it("does not retain abort listeners from completed worker polls", async () => {
  const { bb, harness } = createFakePluginHost({ pluginId: "workflows" });
  const db = bb.storage.database();
  bb.storage.migrate(db, migrations);
  const service = createWorkflowService(bb, db);
  const controller = new AbortController();
  const worker = service.runWorker(controller.signal);

  await new Promise((resolve) => setTimeout(resolve, 800));
  const listenerCount = getEventListeners(controller.signal, "abort").length;

  controller.abort();
  await worker;
  await harness.dispose();

  expect(listenerCount).toBeLessThanOrEqual(2);
});

5. Verification

The first clean checkout failed with a listener count of five. A second clean checkout used the same base commit. It failed with the same count and assertion. No report correction was necessary.

6. Root cause

The wait helper adds a once-only abort listener. The timer can resolve first, but timer completion does not remove that listener. A once-only listener removes itself only when the abort event occurs.

See the wait helper and the worker loop.

The worker uses the same long-life signal for every wait. At idle, it adds a listener every 250 milliseconds. The per-run path also adds a parent-signal listener at run start. It does not remove that listener when execution ends.

7. Proposed fix

Use one settle function for timer completion and abort. The function must remove the abort listener before it resolves. Store the per-run abort function, and remove it when the run execution ends. Keep one focused test for the idle listener count.

8. Related issues

Issue 2284 reports a different workflow resource leak. It does not cover this abort listener path.

9. Appendix

Commands used:

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-workflows -- --run src/worker-abort-listeners.test.ts

The same agent repeated the test in a second clean checkout. Both runs used the exact base commit.

The issue data was untrusted. The investigation used it only as claims to test. No issue command, patch, link, or attachment was used.