fix: reset generator state on .resetHandlers()/.restoreHandlers()#2725
Conversation
📝 WalkthroughWalkthroughCentralizes handler restoration: adds reset()/restore() to RequestHandler, removes requestHandlerUtils, moves restore logic into HandlersController.restore(), and updates SetupApi to delegate restoration; also a minor formatting tweak in NetworkApi's generic declaration. Changes
Sequence Diagram(s)sequenceDiagram
participant SetupApi as SetupApi
participant HandlersController as HandlersController
participant RequestHandler as RequestHandler
SetupApi->>HandlersController: restore()
HandlersController->>HandlersController: currentHandlers() → iterate
loop per handler with restore()
HandlersController->>RequestHandler: restore()
RequestHandler->>RequestHandler: reset() (clear iterator/resolver)
RequestHandler->>RequestHandler: set isUsed = false (if options.once)
end
HandlersController-->>SetupApi: restore complete
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/core/experimental/handlers-controller.ts (1)
93-115:⚠️ Potential issue | 🟠 Major
resetHandlers()does not re-arm{ once: true }handlers from the initial set.The
reset()method on handlers only clears the resolver iterator state and does not clear theisUsedflag. WhenresetHandlers()is called without arguments, it reassigns the handlers map to{ ...initialHandlers }— preserving the original handler instances. If an initial handler with{ once: true }has already fired (isUsed === true), it will remain exhausted after reset, becauseisUsedis never cleared.For example:
- Initial handler:
http.get('/resource', () => {...}, { once: true })- First request: handler fires,
isUsedset totrue- Call
resetHandlers(): handlers map reset to{ ...initialHandlers }, but the same instance withisUsed === trueis reused- Second request: handler short-circuits at
this.isUsed && this.options?.onceand never firesTo fix this, either:
- Have
reset()also clearisUsed(making it equivalent torestore()semantically), or- Call
handler.restore()instead ofhandler.reset()in the reset flowThere is no existing test coverage for this scenario. The older
restoreHandlers()utility (separate fromresetHandlers()) explicitly clearedisUsed, but the reset flow has never tested or cleared it.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/core/experimental/handlers-controller.ts` around lines 93 - 115, resetHandlers() currently calls handler.reset() which doesn't clear the handler's isUsed flag, so once:true handlers remain exhausted after a reset; change the reset loop to call handler.restore() (or ensure reset() clears isUsed) so that each handler's isUsed is cleared when resetting: locate the loop in handlers-controller.ts that iterates this.currentHandlers() and replace handler['reset']() with handler['restore']() (or update the Handler.reset implementation to also set this.isUsed = false) so initialHandlers and handlers maps reuse freshly armed handlers.
🧹 Nitpick comments (2)
src/core/handlers/RequestHandler.ts (1)
174-193: Considerpublic(or a clearly marked internal method) instead ofprotectedfor lifecycle hooks invoked externally.
reset()andrestore()are declaredprotected, yetHandlersController(a sibling class, not a subclass) invokes them via bracket notation (handler['reset'](),handler['restore']()) specifically to bypass TypeScript's access checks. This works but couples two files with an implicit escape hatch that will silently drift if the methods are ever renamed or the signatures change — theinnarrowing + index-access pattern won't produce a type error.Either make these
public(they are part of the handler lifecycle contract with the controller), or expose them through an internal symbol-keyed method so the intent is explicit.protected+ bracket access is the least discoverable of the three options.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/core/handlers/RequestHandler.ts` around lines 174 - 193, RequestHandler's lifecycle hooks reset() and restore() are marked protected but are invoked by HandlersController via bracket-access; make their contract explicit by changing them to public (i.e., declare reset() and restore() as public methods on the RequestHandler class) so HandlersController can call handler.reset() and handler.restore() without using index-access, or alternatively define and export a shared internal Symbol (e.g., HANDLER_RESET/ HANDLER_RESTORE) and implement symbol-keyed methods on RequestHandler that HandlersController uses; update all calls in HandlersController that currently use handler['reset']() / handler['restore']() to the chosen public method names or symbol-keyed access to remove the protected index-access escape hatch.test/node/rest-api/response/generator.test.ts (1)
105-122: Consider broadening test coverage to lock in the intended semantics.The new test covers the plain generator +
resetHandlers()path, which is great. To prevent regressions in the adjacent paths that this PR also touches, consider adding two more cases:
restoreHandlers()resets generator state for a{ once: true }generator handler (the primary use case called out in the PR description).- A
{ once: true }(non-generator) handler is re-armed byresetHandlers()— or, if that's explicitly not intended, a negative test asserting it stays inert. Today it stays inert becausereset()doesn't clearisUsed(see the comment onhandlers-controller.ts), and this deserves an explicit contract either way.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/node/rest-api/response/generator.test.ts` around lines 105 - 122, Add two tests to broaden coverage: (1) ensure restoreHandlers() resets the state of a generator handler created with { once: true } by registering a generator http.get(..., { once: true }) handler, invoking it once, calling restoreHandlers(), then asserting the generator yields the same sequence again; (2) assert the intended behavior for non-generator handlers with { once: true } after resetHandlers(): either add a positive test that resetHandlers() re-arms a non-generator { once: true } handler (invoke, resetHandlers(), expect it to fire again) or add a negative test that it remains inert (invoke, resetHandlers(), expect no further responses), referencing restoreHandlers(), resetHandlers(), and the handlers-controller.ts isUsed semantics when implementing the assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/core/experimental/handlers-controller.ts`:
- Around line 93-115: resetHandlers() currently calls handler.reset() which
doesn't clear the handler's isUsed flag, so once:true handlers remain exhausted
after a reset; change the reset loop to call handler.restore() (or ensure
reset() clears isUsed) so that each handler's isUsed is cleared when resetting:
locate the loop in handlers-controller.ts that iterates this.currentHandlers()
and replace handler['reset']() with handler['restore']() (or update the
Handler.reset implementation to also set this.isUsed = false) so initialHandlers
and handlers maps reuse freshly armed handlers.
---
Nitpick comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 174-193: RequestHandler's lifecycle hooks reset() and restore()
are marked protected but are invoked by HandlersController via bracket-access;
make their contract explicit by changing them to public (i.e., declare reset()
and restore() as public methods on the RequestHandler class) so
HandlersController can call handler.reset() and handler.restore() without using
index-access, or alternatively define and export a shared internal Symbol (e.g.,
HANDLER_RESET/ HANDLER_RESTORE) and implement symbol-keyed methods on
RequestHandler that HandlersController uses; update all calls in
HandlersController that currently use handler['reset']() / handler['restore']()
to the chosen public method names or symbol-keyed access to remove the protected
index-access escape hatch.
In `@test/node/rest-api/response/generator.test.ts`:
- Around line 105-122: Add two tests to broaden coverage: (1) ensure
restoreHandlers() resets the state of a generator handler created with { once:
true } by registering a generator http.get(..., { once: true }) handler,
invoking it once, calling restoreHandlers(), then asserting the generator yields
the same sequence again; (2) assert the intended behavior for non-generator
handlers with { once: true } after resetHandlers(): either add a positive test
that resetHandlers() re-arms a non-generator { once: true } handler (invoke,
resetHandlers(), expect it to fire again) or add a negative test that it remains
inert (invoke, resetHandlers(), expect no further responses), referencing
restoreHandlers(), resetHandlers(), and the handlers-controller.ts isUsed
semantics when implementing the assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 47e2400c-0339-44e8-88fb-d3404e0bd70e
📒 Files selected for processing (6)
src/core/experimental/define-network.tssrc/core/experimental/handlers-controller.tssrc/core/experimental/setup-api.tssrc/core/handlers/RequestHandler.tssrc/core/utils/internal/requestHandlerUtils.tstest/node/rest-api/response/generator.test.ts
💤 Files with no reviewable changes (1)
- src/core/utils/internal/requestHandlerUtils.ts
commit: |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 190-195: restore() currently clears the public isUsed flag for all
handlers, causing regular exhausted generator handlers to be mutated; change
restore() so it only mutates state for one-time handlers: if this.options?.once
is true then call this.reset() and set this.isUsed = false; otherwise make
restore() a no-op (do not touch isUsed). Locate the protected restore() method
and update its control flow to guard both reset() and isUsed assignment behind
the options.once check so restoreHandlers() becomes a true no-op for non-once
handlers.
- Around line 180-183: The reset() method currently drops resolverIterator
without invoking its cleanup; modify reset() in RequestHandler to check if
this.resolverIterator is defined and has a return method (typeof
this.resolverIterator.return === "function") and call it to allow generator
cleanup before clearing this.resolverIterator and this.resolverIteratorResult;
ensure you guard the call in a try/catch to swallow or log any errors from
return() so reset() remains safe.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: d6fbd5bb-08c4-4660-978d-190fee097211
📒 Files selected for processing (2)
src/core/handlers/RequestHandler.tstest/node/rest-api/response/generator.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
test/node/rest-api/response/generator.test.ts (1)
160-167: Cover regular-handler restore while the generator is still mid-flight.This test currently calls
restoreHandlers()only after the generator has reached the stable result. Add a restore betweenYieldandStableto prove regular generators are not reset while active.Proposed coverage tweak
await server.boundary(async () => { await expect(fetchJson('http://localhost/resource')).resolves.toBe('Yield') - await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable') server.restoreHandlers() + await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable') + + server.restoreHandlers() + await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable') await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable') })()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/node/rest-api/response/generator.test.ts` around lines 160 - 167, Add a call to server.restoreHandlers() between the first fetchJson assertion that resolves to 'Yield' and the subsequent fetchJson that resolves to 'Stable' to verify that regular generator handlers are not reset while the generator is mid-flight; update the test inside the server.boundary block (which contains fetchJson('http://localhost/resource') and server.restoreHandlers()) so that server.restoreHandlers() is invoked immediately after the expect(...).resolves.toBe('Yield') assertion and before the expect(...).resolves.toBe('Stable') assertion, keeping the remaining assertions the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/node/rest-api/response/generator.test.ts`:
- Around line 15-22: The test teardown currently calls vi.clearAllMocks() in
afterEach and vi.restoreAllMocks() only in afterAll, so spies (e.g., the
console.error spy created around line 220) remain restored only at the end of
the file and leak into later tests; move vi.restoreAllMocks() into afterEach
alongside vi.clearAllMocks() so each test run restores spy implementations
before the next test, leaving afterAll to only perform global cleanup like
server.close(), and keep server.resetHandlers() in afterEach as-is to reset
handlers between tests.
---
Nitpick comments:
In `@test/node/rest-api/response/generator.test.ts`:
- Around line 160-167: Add a call to server.restoreHandlers() between the first
fetchJson assertion that resolves to 'Yield' and the subsequent fetchJson that
resolves to 'Stable' to verify that regular generator handlers are not reset
while the generator is mid-flight; update the test inside the server.boundary
block (which contains fetchJson('http://localhost/resource') and
server.restoreHandlers()) so that server.restoreHandlers() is invoked
immediately after the expect(...).resolves.toBe('Yield') assertion and before
the expect(...).resolves.toBe('Stable') assertion, keeping the remaining
assertions the same.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 3f517c06-0cfe-431a-9a53-23316c7197c8
📒 Files selected for processing (2)
src/core/handlers/RequestHandler.tstest/node/rest-api/response/generator.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/core/handlers/RequestHandler.ts
f47742b to
a4023a7
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/core/handlers/RequestHandler.ts (1)
180-187:⚠️ Potential issue | 🟠 MajorHandle cleanup failures from
iterator.return().Line 186 still lets synchronous generator cleanup throw out of
resetHandlers(), and async-generator cleanup rejections become unhandled because the promise is discarded. Wrap thereturn()call and attach a rejection handler so reset/restore cleanup remains safe.Proposed defensive cleanup handling
protected reset(): void { const iterator = this.resolverIterator this.resolverIterator = undefined this.resolverIteratorResult = undefined if (typeof iterator?.return === 'function') { - void Promise.resolve(iterator.return()) + try { + void Promise.resolve(iterator.return()).catch(() => undefined) + } catch { + // Ignore cleanup errors during reset. + } } }Verification script:
#!/bin/bash # Demonstrates why Promise.resolve(iterator.return()) does not make cleanup safe. node - <<'NODE' process.on('unhandledRejection', (reason) => { console.log('async cleanup rejection was unhandled:', reason.message) }) function* syncCleanupThrows() { try { yield 'value' } finally { throw new Error('sync cleanup') } } const syncIterator = syncCleanupThrows() syncIterator.next() try { void Promise.resolve(syncIterator.return()) } catch (error) { console.log('sync cleanup threw synchronously:', error.message) } async function* asyncCleanupRejects() { try { yield 'value' } finally { throw new Error('async cleanup') } } void (async () => { const asyncIterator = asyncCleanupRejects() await asyncIterator.next() void Promise.resolve(asyncIterator.return()) await new Promise((resolve) => setImmediate(resolve)) })() NODE🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/core/handlers/RequestHandler.ts` around lines 180 - 187, In reset(), guard the call to this.resolverIterator.return() so synchronous throws are caught and async rejections are handled: wrap the call in a try/catch to swallow or log synchronous exceptions from iterator.return(), and assign Promise.resolve(iterator.return()).catch(...) to handle any async rejection (or await it if appropriate); update the protected reset() method (the resolverIterator/return invocation) to use this defensive pattern so reset/restore cleanup cannot throw or produce unhandled rejections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 180-187: In reset(), guard the call to
this.resolverIterator.return() so synchronous throws are caught and async
rejections are handled: wrap the call in a try/catch to swallow or log
synchronous exceptions from iterator.return(), and assign
Promise.resolve(iterator.return()).catch(...) to handle any async rejection (or
await it if appropriate); update the protected reset() method (the
resolverIterator/return invocation) to use this defensive pattern so
reset/restore cleanup cannot throw or produce unhandled rejections.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 4a230e45-4c9f-468d-a8a1-b878da2cb843
📒 Files selected for processing (2)
src/core/handlers/RequestHandler.tstest/node/rest-api/response/generator.test.ts
Released: v2.13.5 🎉This has been released in v2.13.5. Get these changes by running the following command: Predictable release automation by Release. |
Problem
Calling
.resetHandlers()/.restoreHandlers()does not reset the generator state if the response resolver is a generator function. That renders exhausted generators effectively useless. User expectation is that either of those methods will transition generator resolvers into a "fresh" state (.resetHandlers()because it's a full reset;.restoreHandlers()becauseyieldis a substitute for one-time responses).Todos
.restoreHandlers()must ONLY reset the generator state if the handler is a one-time handler. Otherwise, it has no effect on the generator state.