Skip to content

Commit 5b0f1eb

Browse files
committed
Change race() methods to return PromiseLike instead of Promise
1 parent b014c61 commit 5b0f1eb

File tree

9 files changed

+322
-12
lines changed

9 files changed

+322
-12
lines changed

src/__tests__/all-settled.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ describe("allSettled", () => {
7373
expect(result.b).toEqual({ status: "fulfilled", value: 15 })
7474
})
7575

76+
it("passes a non-aborted task signal when no external signal is configured", async () => {
77+
let taskSignalAborted: boolean | undefined
78+
79+
const result = await try$.allSettled({
80+
a() {
81+
taskSignalAborted = this.$signal.aborted
82+
return 1
83+
},
84+
})
85+
86+
expect(result.a).toEqual({ status: "fulfilled", value: 1 })
87+
expect(taskSignalAborted).toBe(false)
88+
})
89+
7690
it("rejects dependent task when referenced task fails", async () => {
7791
const error = new Error("a failed")
7892

@@ -180,6 +194,25 @@ describe("allSettled", () => {
180194
expect(result.b).toEqual({ status: "fulfilled", value: "b done" })
181195
})
182196

197+
it("keeps sibling task signals usable after another task fails", async () => {
198+
const signalStates: boolean[] = []
199+
200+
const result = await try$.allSettled({
201+
a() {
202+
throw new Error("a failed")
203+
},
204+
async b() {
205+
signalStates.push(this.$signal.aborted)
206+
await sleep(10)
207+
signalStates.push(this.$signal.aborted)
208+
return "b done"
209+
},
210+
})
211+
212+
expect(signalStates).toEqual([false, false])
213+
expect(result.b).toEqual({ status: "fulfilled", value: "b done" })
214+
})
215+
183216
it("applies wrap middleware around allSettled execution", async () => {
184217
let wrapCalls = 0
185218

@@ -256,4 +289,29 @@ describe("allSettled", () => {
256289

257290
expect(cleaned).toBe(true)
258291
})
292+
293+
it("runs disposer cleanup for both fulfilled and rejected tasks without external signals", async () => {
294+
let cleanedA = false
295+
let cleanedB = false
296+
297+
const result = await try$.allSettled({
298+
a() {
299+
this.$disposer.defer(() => {
300+
cleanedA = true
301+
})
302+
return 1
303+
},
304+
b() {
305+
this.$disposer.defer(() => {
306+
cleanedB = true
307+
})
308+
throw new Error("boom")
309+
},
310+
})
311+
312+
expect(result.a).toEqual({ status: "fulfilled", value: 1 })
313+
expect(result.b.status).toBe("rejected")
314+
expect(cleanedA).toBe(true)
315+
expect(cleanedB).toBe(true)
316+
})
259317
})

src/__tests__/all.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ describe("all", () => {
4444
expect(result).toEqual({ a: 10, b: 15 })
4545
})
4646

47+
it("passes a non-aborted task signal when no external signal is configured", async () => {
48+
let taskSignalAborted: boolean | undefined
49+
50+
const result = await try$.all({
51+
a() {
52+
taskSignalAborted = this.$signal.aborted
53+
return 1
54+
},
55+
})
56+
57+
expect(result).toEqual({ a: 1 })
58+
expect(taskSignalAborted).toBe(false)
59+
})
60+
4761
it("rejects on first task failure", async () => {
4862
try {
4963
await try$.all({
@@ -119,6 +133,32 @@ describe("all", () => {
119133
expect(signalAborted).toBe(true)
120134
})
121135

136+
it("aborts dependency waiters when a sibling task fails", async () => {
137+
let signalAbortedWhileWaiting = false
138+
139+
try {
140+
await try$.all({
141+
a() {
142+
throw new Error("boom")
143+
},
144+
async b() {
145+
try {
146+
await this.$result.a
147+
return 2
148+
} catch (error) {
149+
signalAbortedWhileWaiting = this.$signal.aborted
150+
throw error
151+
}
152+
},
153+
})
154+
expect.unreachable("should have thrown")
155+
} catch (error) {
156+
expect((error as Error).message).toBe("boom")
157+
}
158+
159+
expect(signalAbortedWhileWaiting).toBe(true)
160+
})
161+
122162
it("rejects when a task accesses its own result", async () => {
123163
try {
124164
await try$.all({
@@ -249,6 +289,27 @@ describe("all", () => {
249289
})
250290
})
251291

292+
it("keeps catch-context signal usable after failure", async () => {
293+
const result = await try$.all(
294+
{
295+
a() {
296+
throw new Error("boom")
297+
},
298+
},
299+
{
300+
catch: (_error, ctx) => ({
301+
aborted: ctx.signal.aborted,
302+
hasSignal: ctx.signal instanceof AbortSignal,
303+
}),
304+
}
305+
)
306+
307+
expect(result).toEqual({
308+
aborted: true,
309+
hasSignal: true,
310+
})
311+
})
312+
252313
it("throws Panic when all catch throws", async () => {
253314
try {
254315
await try$.all(
@@ -380,6 +441,22 @@ describe("all", () => {
380441
expect(cleaned).toBe(true)
381442
})
382443

444+
it("allows disposer usage in a successful task without external cancellation", async () => {
445+
let cleaned = false
446+
447+
const result = await try$.all({
448+
a() {
449+
this.$disposer.defer(() => {
450+
cleaned = true
451+
})
452+
return 1
453+
},
454+
})
455+
456+
expect(result).toEqual({ a: 1 })
457+
expect(cleaned).toBe(true)
458+
})
459+
383460
it("runs disposer cleanup even on failure", async () => {
384461
let cleaned = false
385462

src/__tests__/flow.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,20 @@ describe("flow", () => {
148148
expect(result).toBe("api-value-transformed")
149149
})
150150

151+
it("passes a non-aborted task signal when no external signal is configured", async () => {
152+
let taskSignalAborted: boolean | undefined
153+
154+
const result = await try$.flow({
155+
a() {
156+
taskSignalAborted = this.$signal.aborted
157+
return this.$exit("done" as const)
158+
},
159+
})
160+
161+
expect(result).toBe("done")
162+
expect(taskSignalAborted).toBe(false)
163+
})
164+
151165
it("returns early when a dependent task reads a task that already exited", async () => {
152166
const result = await try$.flow({
153167
a() {
@@ -174,6 +188,28 @@ describe("flow", () => {
174188
expect(result).toBe("done")
175189
})
176190

191+
it("aborts dependent waiters after early exit", async () => {
192+
let dependencySawAbort = false
193+
194+
const result = await try$.flow({
195+
a() {
196+
return this.$exit("done" as const)
197+
},
198+
async b() {
199+
try {
200+
await this.$result.a
201+
} catch {
202+
dependencySawAbort = this.$signal.aborted
203+
}
204+
205+
return null
206+
},
207+
})
208+
209+
expect(result).toBe("done")
210+
expect(dependencySawAbort).toBe(true)
211+
})
212+
177213
it("applies wrap middleware in chained flow execution", async () => {
178214
let wrapCalls = 0
179215

@@ -193,6 +229,27 @@ describe("flow", () => {
193229
expect(wrapCalls).toBe(1)
194230
})
195231

232+
it("runs cleanup after early exit even when a task starts through normal dependency flow", async () => {
233+
let cleaned = false
234+
235+
const result = await try$.flow({
236+
a() {
237+
this.$disposer.defer(() => {
238+
cleaned = true
239+
})
240+
241+
return 1
242+
},
243+
async b() {
244+
const value = await this.$result.a
245+
return this.$exit(value + 1)
246+
},
247+
})
248+
249+
expect(result).toBe(2)
250+
expect(cleaned).toBe(true)
251+
})
252+
196253
it("retries leaf work inside flow tasks via nested run()", async () => {
197254
let attempts = 0
198255

@@ -253,6 +310,58 @@ describe("flow", () => {
253310
}
254311
})
255312

313+
it("propagates external cancellation while tasks use disposer and dependency results", async () => {
314+
const controller = new AbortController()
315+
let cleaned = false
316+
let dependencySawAbort = false
317+
318+
const pending = try$.signal(controller.signal).flow({
319+
async a() {
320+
this.$disposer.defer(() => {
321+
cleaned = true
322+
})
323+
324+
if (!this.$signal.aborted) {
325+
await new Promise<void>((resolve) => {
326+
this.$signal.addEventListener(
327+
"abort",
328+
() => {
329+
resolve()
330+
},
331+
{ once: true }
332+
)
333+
})
334+
}
335+
336+
throw this.$signal.reason
337+
},
338+
async b() {
339+
try {
340+
await this.$result.a
341+
} catch {
342+
dependencySawAbort = this.$signal.aborted
343+
throw this.$signal.reason
344+
}
345+
346+
return this.$exit("late")
347+
},
348+
})
349+
350+
setTimeout(() => {
351+
controller.abort(new Error("stop"))
352+
}, 5)
353+
354+
try {
355+
await pending
356+
expect.unreachable("should have thrown")
357+
} catch (error) {
358+
expect(error).toBeInstanceOf(CancellationError)
359+
}
360+
361+
expect(cleaned).toBe(true)
362+
expect(dependencySawAbort).toBe(true)
363+
})
364+
256365
it("rejects when a flow task awaits its own result", async () => {
257366
try {
258367
await try$.flow({

src/lib/__tests__/coverage-exceptions.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,64 @@ describe("coverage exceptions", () => {
135135

136136
expect(result).toBe("aborted")
137137
})
138+
139+
it("returns the original promise value when the promise settles before abort", async () => {
140+
const controller = new AbortController()
141+
let resolvePromise!: (value: string) => void
142+
let abortFactoryCalls = 0
143+
144+
const pending = new Promise<string>((resolve) => {
145+
resolvePromise = resolve
146+
})
147+
148+
const resultPromise = resolveWithAbort(controller.signal, pending, () => {
149+
abortFactoryCalls += 1
150+
return "aborted" as const
151+
})
152+
153+
resolvePromise("done")
154+
155+
const result = await resultPromise
156+
controller.abort(new Error("too late"))
157+
158+
expect(result).toBe("done")
159+
expect(abortFactoryCalls).toBe(0)
160+
})
161+
162+
it("returns the abort result when abort happens after registration and before settlement", async () => {
163+
const controller = new AbortController()
164+
let resolvePromise!: (value: string) => void
165+
166+
const pending = new Promise<string>((resolve) => {
167+
resolvePromise = resolve
168+
})
169+
170+
const resultPromise = resolveWithAbort(controller.signal, pending, () => "aborted" as const)
171+
172+
controller.abort(new Error("stop"))
173+
resolvePromise("done")
174+
175+
expect(await resultPromise).toBe("aborted")
176+
})
177+
178+
it("calls createAbortResult exactly once when abort wins", async () => {
179+
const controller = new AbortController()
180+
let abortFactoryCalls = 0
181+
182+
const pending = new Promise<string>((resolve) => {
183+
void resolve
184+
})
185+
186+
const resultPromise = resolveWithAbort(controller.signal, pending, () => {
187+
abortFactoryCalls += 1
188+
return "aborted" as const
189+
})
190+
191+
controller.abort(new Error("stop"))
192+
193+
expect(await resultPromise).toBe("aborted")
194+
expect(abortFactoryCalls).toBe(1)
195+
})
138196
})
139197

140198
describe("guard branches", () => {

0 commit comments

Comments
 (0)