Skip to content

Commit dca7626

Browse files
DavertMikclaude
andcommitted
fix(core): restore sessions and propagate errors on session/within/retryTo error paths
Fixes four of the latent error-path divergences characterized in #5622, by making the error paths symmetric with the success paths. The characterization assertions are updated to the corrected behavior in the same commit. - within() async error (lib/effects.js): the catch now calls finishHelpers() (so helpers' _withinEnd runs on error, not just success) and returns recorder.promise() (so the error propagates through within() instead of being detached onto a trailing task that a caller awaiting within() never sees). - session() async error (lib/session.js): schedules a recorder task that runs recorder.session.restore so the recorder session is restored on error (it was only restored on success, leaking the session id). The existing restoreVars/listener cleanup is kept as-is — switching to finalize()'s real restoreVars() closes the browser context under BROWSER_RESTART=session. - session() sync error (lib/session.js): the finally recorder.catch now calls recorder.session.restore before re-throwing (the only place that runs on a rejected chain). - retryTo() (lib/effects.js): a thrown callback no longer reject()s the outer promise prematurely — it routes through recorder.throw so the retry logic owns the outcome (retry, or reject once maxTries is exhausted). A callback that throws then succeeds on a later attempt now resolves instead of rejecting. tries now starts at 1 on the first attempt (was 2); the retry count is preserved (tries < maxTries). Verified: unit 748/0, runner 273/0 (incl. all retryFailedStep/rerun tests), acceptance within/session/els green under both BROWSER_RESTART=browser and =session. Not changed here (would be unsafe or out of scope, see PR): recorder.retries clearing (retryFailedStep depends on it), the stopped-recorder no-op contract, and nested cross-level restore ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5f51d3e commit dca7626

3 files changed

Lines changed: 44 additions & 29 deletions

File tree

lib/effects.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ function within(context, fn) {
5050
return recorder.promise().then(() => res)
5151
})
5252
.catch(e => {
53+
finishHelpers()
5354
finalize()
5455
recorder.throw(e)
56+
return recorder.promise()
5557
})
5658
}
5759

@@ -203,7 +205,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
203205
const sessionName = 'retryTo'
204206

205207
return new Promise((done, reject) => {
206-
let tries = 1
208+
let tries = 0
207209

208210
function handleRetryException(err) {
209211
recorder.throw(err)
@@ -216,7 +218,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
216218
try {
217219
await callback(tries)
218220
} catch (err) {
219-
handleRetryException(err)
221+
recorder.throw(err)
220222
}
221223

222224
// Call done if no errors
@@ -228,7 +230,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
228230
// Catch errors and retry
229231
recorder.session.catch(err => {
230232
recorder.session.restore(`${sessionName} ${tries}`)
231-
if (tries <= maxTries) {
233+
if (tries < maxTries) {
232234
output.debug(`Error ${err}... Retrying`)
233235
recorder.add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
234236
} else {

lib/session.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ function session(sessionName, config, fn) {
109109
output.stepShift = 0
110110
session.restoreVars(sessionName)
111111
event.dispatcher.removeListener(event.step.after, addContextToStep)
112+
recorder.add('restore session on error', () => recorder.session.restore(`session:${sessionName}`))
112113
recorder.throw(e)
113114
return recorder.promise()
114115
})
@@ -124,6 +125,7 @@ function session(sessionName, config, fn) {
124125
session.restoreVars(sessionName)
125126
output.stepShift = 0
126127
event.dispatcher.removeListener(event.step.after, addContextToStep)
128+
recorder.session.restore(`session:${sessionName}`)
127129
throw e
128130
})
129131
}

test/unit/session_composition_test.js

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,28 @@ describe('promise-core composition (characterization)', () => {
9494
expect(recorder.getCurrentSessionId()).to.equal(null)
9595
})
9696

97-
it('FINDING: async callback error leaks the session id and never calls recorder.session.restore', async () => {
97+
it('async callback error surfaces the error and restores the session id', async () => {
9898
session('asyncerr', async () => {
9999
throw new Error('boom')
100100
})
101-
let rejected
102-
await settles(recorder.promise()).catch(e => (rejected = e))
103-
// characterized behavior, see plans/001-findings.md
104-
expect(rejected, 'outer chain rejects').to.be.instanceof(Error)
105-
expect(rejected.message).to.equal('boom')
106-
expect(recorder.getCurrentSessionId(), 'session id leaks').to.equal('session:asyncerr')
101+
const err = await drain()
102+
expect(err, 'outer chain rejects').to.be.instanceof(Error)
103+
expect(err.message).to.equal('boom')
104+
expect(helper.calls.restoreVars, 'restoreVars called on error').to.be.greaterThan(0)
105+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
107106
})
108107

109-
it('FINDING: sync callback whose queued task throws restores vars but leaks the session id', async () => {
108+
it('sync callback whose queued task throws surfaces the error and restores the session id', async () => {
110109
session('syncerr', () => {
111110
recorder.add(() => {
112111
throw new Error('boomsync')
113112
})
114113
})
115114
const err = await drain()
116-
// The finally recorder.catch re-throws before finalize() runs
117-
// recorder.session.restore, so the session id leaks. See plans/001-findings.md
118115
expect(err, 'error surfaces after draining').to.be.instanceof(Error)
119116
expect(err.message).to.equal('boomsync')
120-
expect(helper.calls.restoreVars, 'restoreVars called by the finally handler').to.equal(1)
121-
expect(recorder.getCurrentSessionId(), 'session id leaks').to.equal('session:syncerr')
117+
expect(helper.calls.restoreVars, 'restoreVars called by the finally handler').to.be.greaterThan(0)
118+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
122119
})
123120
})
124121

@@ -134,22 +131,40 @@ describe('promise-core composition (characterization)', () => {
134131
expect(inside).to.equal(true)
135132
})
136133

137-
it('FINDING: async callback error skips _withinEnd and detaches the error onto a trailing task', async () => {
134+
it('async callback error runs _withinEnd and propagates the error', async () => {
138135
within('ctx', async () => {
139136
throw new Error('boomwithin')
140137
})
141138
const err = await drain()
142-
// The error is only visible after draining trailing tasks because within()'s
143-
// async catch omits `return recorder.promise()`. See plans/001-findings.md
144-
expect(err, 'error surfaces after draining').to.be.instanceof(Error)
139+
expect(err, 'error surfaces').to.be.instanceof(Error)
145140
expect(err.message).to.equal('boomwithin')
146141
expect(helper.calls.withinBegin, '_withinBegin ran').to.be.greaterThan(0)
147-
expect(helper.calls.withinEnd, '_withinEnd skipped on error').to.equal(0)
142+
expect(helper.calls.withinEnd, '_withinEnd runs on error').to.be.greaterThan(0)
148143
})
149144
})
150145

151146
describe('retryTo()', () => {
152-
it('FINDING: a synchronously-throwing callback rejects but keeps retrying past the rejection', async () => {
147+
it('retries a throwing callback and resolves when a later attempt succeeds', async () => {
148+
let firstTries
149+
let calls = 0
150+
let rejected = null
151+
await settles(
152+
retryTo(
153+
tries => {
154+
if (firstTries === undefined) firstTries = tries
155+
calls++
156+
if (tries < 3) throw new Error('not yet')
157+
},
158+
5,
159+
20,
160+
),
161+
).catch(e => (rejected = e))
162+
expect(rejected, 'resolves once an attempt succeeds (no premature reject)').to.equal(null)
163+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
164+
expect(calls, 'ran until the succeeding attempt').to.equal(3)
165+
})
166+
167+
it('a callback that always throws rejects only after exhausting maxTries', async () => {
153168
let firstTries
154169
let calls = 0
155170
let rejected
@@ -165,15 +180,11 @@ describe('promise-core composition (characterization)', () => {
165180
),
166181
3000,
167182
).catch(e => (rejected = e))
168-
await new Promise(r => setTimeout(r, 300))
169-
const finalCalls = calls
170-
await new Promise(r => setTimeout(r, 300))
171-
// characterized behavior, see plans/001-findings.md
172-
expect(rejected, 'retryTo rejects with the real error').to.be.instanceof(Error)
183+
await new Promise(r => setTimeout(r, 200))
184+
expect(rejected, 'rejects with the real error').to.be.instanceof(Error)
173185
expect(rejected.message).to.equal('always')
174-
expect(firstTries, 'first attempt receives tries === 2 (tries starts at 1, incremented before callback)').to.equal(2)
175-
expect(calls, 'retrying continues past the first rejection').to.be.greaterThan(1)
176-
expect(calls, 'retries stop once drained (no perpetual loop)').to.equal(finalCalls)
186+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
187+
expect(calls, 'retried exactly maxTries times').to.equal(3)
177188
})
178189

179190
it('retries via recorder failures then resolves', async () => {

0 commit comments

Comments
 (0)