Skip to content

Commit f921147

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): the catch now calls finalize() — the same cleanup the success path uses (awaited restoreVars + recorder.session .restore) — instead of an ad-hoc cleanup that never restored the recorder session, leaking the session id after an error. - session() sync error (lib/session.js): the finally recorder.catch now calls recorder.session.restore before re-throwing. finalize()'s restore task is skipped on a rejected chain, so the catch handler is the only place that can restore the session id on the sync-throw path. - 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 all green. 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 f921147

3 files changed

Lines changed: 41 additions & 28 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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,7 @@ function session(sessionName, config, fn) {
106106
return res
107107
})
108108
.catch(e => {
109-
output.stepShift = 0
110-
session.restoreVars(sessionName)
111-
event.dispatcher.removeListener(event.step.after, addContextToStep)
109+
finalize()
112110
recorder.throw(e)
113111
return recorder.promise()
114112
})
@@ -124,6 +122,7 @@ function session(sessionName, config, fn) {
124122
session.restoreVars(sessionName)
125123
output.stepShift = 0
126124
event.dispatcher.removeListener(event.step.after, addContextToStep)
125+
recorder.session.restore(`session:${sessionName}`)
127126
throw e
128127
})
129128
}

test/unit/session_composition_test.js

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,29 @@ 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 rejects the chain and restores the session id', async () => {
9898
session('asyncerr', async () => {
9999
throw new Error('boom')
100100
})
101101
let rejected
102102
await settles(recorder.promise()).catch(e => (rejected = e))
103-
// characterized behavior, see plans/001-findings.md
104103
expect(rejected, 'outer chain rejects').to.be.instanceof(Error)
105104
expect(rejected.message).to.equal('boom')
106-
expect(recorder.getCurrentSessionId(), 'session id leaks').to.equal('session:asyncerr')
105+
expect(helper.calls.restoreVars, 'restoreVars called on error').to.be.greaterThan(0)
106+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
107107
})
108108

109-
it('FINDING: sync callback whose queued task throws restores vars but leaks the session id', async () => {
109+
it('sync callback whose queued task throws surfaces the error and restores the session id', async () => {
110110
session('syncerr', () => {
111111
recorder.add(() => {
112112
throw new Error('boomsync')
113113
})
114114
})
115115
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
118116
expect(err, 'error surfaces after draining').to.be.instanceof(Error)
119117
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')
118+
expect(helper.calls.restoreVars, 'restoreVars called by the finally handler').to.be.greaterThan(0)
119+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
122120
})
123121
})
124122

@@ -134,22 +132,40 @@ describe('promise-core composition (characterization)', () => {
134132
expect(inside).to.equal(true)
135133
})
136134

137-
it('FINDING: async callback error skips _withinEnd and detaches the error onto a trailing task', async () => {
135+
it('async callback error runs _withinEnd and propagates the error', async () => {
138136
within('ctx', async () => {
139137
throw new Error('boomwithin')
140138
})
141139
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)
140+
expect(err, 'error surfaces').to.be.instanceof(Error)
145141
expect(err.message).to.equal('boomwithin')
146142
expect(helper.calls.withinBegin, '_withinBegin ran').to.be.greaterThan(0)
147-
expect(helper.calls.withinEnd, '_withinEnd skipped on error').to.equal(0)
143+
expect(helper.calls.withinEnd, '_withinEnd runs on error').to.be.greaterThan(0)
148144
})
149145
})
150146

151147
describe('retryTo()', () => {
152-
it('FINDING: a synchronously-throwing callback rejects but keeps retrying past the rejection', async () => {
148+
it('retries a throwing callback and resolves when a later attempt succeeds', async () => {
149+
let firstTries
150+
let calls = 0
151+
let rejected = null
152+
await settles(
153+
retryTo(
154+
tries => {
155+
if (firstTries === undefined) firstTries = tries
156+
calls++
157+
if (tries < 3) throw new Error('not yet')
158+
},
159+
5,
160+
20,
161+
),
162+
).catch(e => (rejected = e))
163+
expect(rejected, 'resolves once an attempt succeeds (no premature reject)').to.equal(null)
164+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
165+
expect(calls, 'ran until the succeeding attempt').to.equal(3)
166+
})
167+
168+
it('a callback that always throws rejects only after exhausting maxTries', async () => {
153169
let firstTries
154170
let calls = 0
155171
let rejected
@@ -165,15 +181,11 @@ describe('promise-core composition (characterization)', () => {
165181
),
166182
3000,
167183
).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)
184+
await new Promise(r => setTimeout(r, 200))
185+
expect(rejected, 'rejects with the real error').to.be.instanceof(Error)
173186
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)
187+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
188+
expect(calls, 'retried exactly maxTries times').to.equal(3)
177189
})
178190

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

0 commit comments

Comments
 (0)