Skip to content

fix(shared): stop onFinish from keeping the last result alive - #2142

Open
dinwwwh wants to merge 4 commits into
mainfrom
claude/kind-curie-3o4vr5-shared-onfinish-state
Open

dinwwwh wants to merge 4 commits into
mainfrom
claude/kind-curie-3o4vr5-shared-onfinish-state

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

onFinish declared let state in the factory closure, not inside the function it returns. Every interceptor or middleware built with onFinish(...) therefore kept its most recent call's result or error reachable for as long as the interceptor lived. Interceptors are usually module-level and live for the whole process, so this could pin a large response object or an error with its cause chain. state is now declared per call.

Notes

  • The callback receives the same state tuple as before. Concurrent calls were never mixed up, because nothing awaits between setting state and passing it to the callback. The only change is that the result or error can be collected after the call.

Testing

  • New test: a slow call and a failing call overlap, and the callback receives each call's own state. It pins the per-call contract. It also passes on main, because the shared variable never mixed calls up; the retention itself would only show under forced garbage collection, which isn't tested.
  • pnpm vitest run packages/shared, pnpm type:check and pnpm lint pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NZzg6qmouJqzvpNAwfnYxU

onFinish declared `state` in the factory closure, so every interceptor
it created kept the last result or error reachable for as long as the
interceptor itself lived. `state` is now scoped to each call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZzg6qmouJqzvpNAwfnYxU
@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing claude/kind-curie-3o4vr5-shared-onfinish-state (8cba8db) with main (0d42e65)

Open in CodSpeed

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2142

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2142

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2142

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2142

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2142

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2142

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2142

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2142

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2142

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2142

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2142

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2142

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2142

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2142

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2142

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2142

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2142

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2142

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2142

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2142

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2142

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2142

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2142

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2142

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2142

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2142

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2142

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2142

commit: 8cba8db

@codecov

codecov Bot commented Sep 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Move state into the per-call closure in onFinish — let state: any is now declared inside the returned async function in packages/shared/src/interceptor.ts instead of the factory closure, so the last call's result/error tuple is no longer pinned for the lifetime of the interceptor.

The fix is correct and minimally scoped. There is no await between assigning state and passing it into callback in the finally block, so concurrent calls never raced on the shared binding before; each invocation now gets its own tuple and the previous result/error becomes collectable once the call returns. The callback contract (tuple shape, options, rest args) and observable behavior are unchanged, so existing tests in packages/shared/src/interceptor.test.ts remain valid.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc 8cba8db Commit Preview URL

Branch Preview URL
Sep 30 2026, 02:07 PM

Two tests force full garbage collections (exposing gc through
v8.setFlagsFromString) and check through a WeakRef that neither the last
result nor the last error stays reachable from the interceptor once the
call ends. Both fail before the fix. A third test pins that overlapping
calls each get their own state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZzg6qmouJqzvpNAwfnYxU

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Added GC-based leak tests — packages/shared/src/interceptor.test.ts now asserts, via a WeakRef and forced full GCs (isGarbageCollected), that the last call's result and thrown error become collectable after the call ends while the interceptor itself stays alive.
  • Added an overlapping-calls test — a failing call and a slow successful call interleave, pinning that each invocation's callback receives its own [error, undefined, false] / [null, result, true] tuple.

The new tests are genuine regression coverage: I reverted state to the factory closure and both result and error cases fail at interceptor.test.ts:434, then pass with the fix restored. The overlapping test passes on main as expected — it pins the per-call contract rather than the leak. The fix itself was reviewed and approved in the prior run; this delta is test-only.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Expose gc() through `--expose-gc` in the vitest node project instead of
enabling it from inside the test with node:v8 and node:vm, collect once
after a macrotask instead of retrying, and drop the overlapping calls
test, which passed before the fix too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZzg6qmouJqzvpNAwfnYxU

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

This run covers only the delta since the prior pullfrog review (b6c4eb7b): commit cf4cd70, a test-simplification pass. The underlying fix was reviewed and approved earlier and is unchanged.

  • Simplified the GC leak test — removed the isGarbageCollected helper (setFlagsFromString + runInNewContext) and the retry loop; both result/error cases now do one setTimeout macrotask followed by a single globalThis.gc!() before asserting the WeakRef is cleared.
  • Dropped the overlapping-calls test — the per-call tuple contract it pinned is still covered by interceptor.test.ts:292-373 (via intercept) and multiple cases in iterator.test.ts, so no coverage gap is left.
  • Enabled --expose-gc for the root node test project — vitest.config.ts adds execArgv: ['--expose-gc'], scoped to the node project (jsdom/bun/cloudflare unaffected).

I re-verified the simplified test is genuine regression coverage: reverting state to the factory closure makes both cases fail at interceptor.test.ts:403, and 15/15 local repetitions pass with the fix in place. The single-gc() approach is not obviously flakier than the previous loop — a full GC collects the now-unreachable value deterministically once the interceptor no longer pins it.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Drop the garbage collection tests and the `--expose-gc` vitest flag they
needed. Instead, check that overlapping calls each pass their own state
to the callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZzg6qmouJqzvpNAwfnYxU

@pullfrog pullfrog 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.

ℹ️ No critical issues — one test-coverage observation inline.

Reviewed changes

This run covers only the delta since the prior pullfrog review (cf4cd70): commit 8cba8db, a test-only pass that drops the GC leak tests and the --expose-gc flag they needed.

  • Replaced the GC leak tests — removed the WeakRef / globalThis.gc!() it.each cases and the execArgv: ['--expose-gc'] entry in vitest.config.ts.
  • Added an overlapping-calls test — a slow successful call and an immediate failing call interleave, asserting each invocation's callback receives its own [error, undefined, false] / [null, result, true] tuple.

The source fix itself is unchanged and remains correct. The only point worth a decision is that the new test cannot observe the leak.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +387 to +399
it('gives each call its own state when calls overlap', async () => {
const callback = vi.fn()
const interceptor = onFinish(callback)
const error = new Error('__error__')
const { promise, resolve } = promiseWithResolvers<string>()

const first = interceptor({ next: () => promise })
await expect(interceptor({ next: () => Promise.reject(error) })).rejects.toBe(error)
resolve('__first__')
await expect(first).resolves.toBe('__first__')

expect(callback).toHaveBeenNthCalledWith(1, [error, undefined, false], expect.anything())
expect(callback).toHaveBeenNthCalledWith(2, [null, '__first__', true], expect.anything())

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.

This test passes even when state is reverted to the factory closure (I verified locally), so it does not guard against the leak this PR fixes — it passes on main too. The GC-based test it replaced did fail when the fix was reverted, which leaves the fix with no regression coverage. If dropping GC-based testing is intentional (avoiding --expose-gc flakiness), a note that the leak is uncovered would help; otherwise keep one WeakRef/gc() case for it.

Technical details
# `onFinish` leak test no longer fails when the leak returns

## Affected sites
- `packages/shared/src/interceptor.test.ts:387-400` — new overlap test only asserts callback invocation order; the shared-`state` bug produces the same order and passes.
- `packages/shared/src/interceptor.ts:89` — the fix under test (per-call `let state`).

## Required outcome
- Either restore regression coverage that fails when `state` lives in the factory closure (needs `globalThis.gc!()` + `--expose-gc`, as the removed `it.each` did), or explicitly document that the fix is intentionally covered only by the contract/ordering test.

## Open questions for the human
- Was the GC test dropped for flakiness, or because `--expose-gc` was unwanted on the root node project? The two have different remedies (tighten the GC test vs. accept no leak coverage).

This branch has not been deployed

No deployments
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.

2 participants