Skip to content

Fix the flaky Composio boot test: record only the vendor's own requests - #1035

Merged
davidmckayv merged 1 commit into
mainfrom
david/composio-flake
Nov 5, 2026
Merged

davidmckayv merged 1 commit into
mainfrom
david/composio-flake

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

The flake

server/tests/composio-adapter.test.ts, "what constructing the vendor is allowed to do on boot > the key it was given is the key the vendor sends, to Composio's own host", failed with Expected: "never-dialled" / Received: null on PR #1011's first CI attempt and on main at af42cc6, and passed on rerun both times. It takes about 1.3 seconds.

Cause

The test replaces globalThis.fetch and process.on with recorders, then lets Composio's raw client try toolkits.list against a transport that refuses. The client retries a refused connection twice with backoff, so the recorders stay installed for about 1.3 seconds.

bun test runs every test file in one process, and the browser extension's tests (which run earlier in CI's file order) leave timers running after their files finish: the Recorder's activity heartbeat, its connection check, and the offscreen streamer's reconnect. Those keep calling fetch on 127.0.0.1 for minutes. When one fired inside the window, the recorder logged it as the vendor's attempt: no x-api-key, so null. Composio's client was sending its key every time.

A probe that held the recorder open for 30 seconds at the same point in CI's file order caught three of these calls, with stacks through extension/src/background/activity.ts, extension/src/background/recording.ts (checkConnection) and extension/src/offscreen/streamer.ts.

There was a second way in. The test waited with await expect(broker.listApps()).rejects.toThrow(). In Bun 1.4.3, expect(promise).rejects waits by running the event loop inside the call, so another file's timer that falls due during that wait runs nested inside the test's own stack. Scoping the recorders alone still failed 2 of 40 runs, with the extension streamer's call recorded and its stack running through the test body.

Fix (test harness only, no product change)

  • The recorders apply only inside an AsyncLocalStorage scope around the test body. The client the body builds, its retry timers and the SDK's deferred telemetry all inherit that scope. Calls from anywhere else pass through to the real fetch and process.on, as if the test were not running.
  • The body waits for the refusal with a plain await and the test asserts it afterwards. A comment on the helper says why expect(...).rejects must not be used inside it.

Nothing the describe guards was weakened. Each product mutation was run against the old and new test in a Linux bun 1.4.3 container, and both fail the same way:

Mutation to createComposioClient Old test New test
allowTracking: false deleted fails (telemetry POST + 3 exit handlers) fails (same)
disableVersionCheck: true deleted fails (npm registry check) fails (same)
apiKey replaced by another key fails fails
baseURL added fails fails
none passes passes

Evidence

Reproduction: the 66 extension test files in CI's order, then 40 copies of composio-adapter.test.ts, all in one bun 1.4.3 process the way bun test runs them in CI. Each copy gives the race one chance.

Run Failures of this test
Before, macOS 3 / 40
Before, Linux container (oven/bun:1.4.3) 2 / 40
Recorders scoped only, still using expect().rejects (macOS) 2 / 40
After, macOS (5 loops) 0 / 200
After, Linux container (5 loops) 0 / 200

The Linux container also has about 47 extension test failures caused by the container environment (IndexedDB, git work tree checks). They are the same before and after, and they all come from extension files that run before any copy of this test.

bun run lint, bun run format:check, bun run typecheck, bun run typecheck:tests and bun test server/tests/composio-adapter.test.ts pass.

Not changed here

The extension tests still leave those timers running after their files finish. With this change they can no longer fail this test.

…test

The "key it was given is the key the vendor sends" test replaced the
process-wide fetch and process.on for the second and a half the raw client
spends retrying a refused connection. bun test runs every file in one
process, and the browser extension's tests leave timers running (its
activity heartbeat, connection check and streamer reconnect) that keep
calling fetch long after their files finish. When one of them fell inside
that window it was recorded as a keyless attempt to 127.0.0.1, and the test
failed with "Received: null" while Composio's client was behaving.

The recorders now apply only inside an AsyncLocalStorage scope around the
test body; calls from anywhere else pass through to the real globals. The
test also waits for the refusal with a plain await instead of
expect().rejects, because Bun waits on that by running the event loop
inside the call, which runs other files' timers nested in the scope.
@davidmckayv
davidmckayv merged commit 8e4ef7b into main Nov 5, 2026
20 checks passed
@davidmckayv
davidmckayv deleted the david/composio-flake branch November 5, 2026 22:59
davidmckayv added a commit that referenced this pull request Nov 5, 2026
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