From 50dbd875cf17aec70c38b6ac3a2d36e6895c6459 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:19:48 +0800 Subject: [PATCH] fix(process): preserve owning operation cancellation --- src/process/SharedStartupAttempt.test.ts | 32 ++++++++++++++++++++++++ src/process/SharedStartupAttempt.ts | 15 ++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/process/SharedStartupAttempt.test.ts b/src/process/SharedStartupAttempt.test.ts index 1ed9e3f2dd..05bed1443c 100644 --- a/src/process/SharedStartupAttempt.test.ts +++ b/src/process/SharedStartupAttempt.test.ts @@ -70,3 +70,35 @@ it("does not lose an unexpected producer rejection after its final caller cancel reject?.(cause); await expect(waiting).rejects.toBe(cause); }); + +it.each(["caller", "owner"])( + "preserves %s interruption when owning cancellation and closure occur together", + async (first) => { + const completed = createDeferred(); + const controller = new AbortController(); + const reason = { caller: "operation owner" }; + const attempt = new SharedStartupAttempt( + () => completed.promise, + controller.signal, + ); + const waiting = attempt.wait(); + if (first === "caller") { + controller.abort(reason); + attempt.close(); + } else { + attempt.close(); + expect(attempt.signal.aborted).toBe(true); + expect(attempt.signal.reason).toBe(STARTUP_OWNER_CLOSED); + controller.abort(reason); + } + expect(attempt.signal.reason).toBe( + first === "caller" ? reason : STARTUP_OWNER_CLOSED, + ); + expect(attempt.settled).toBe(false); + completed.resolve("finalized"); + await expect(waiting).resolves.toEqual({ + status: "completed", + value: "finalized", + }); + }, +); diff --git a/src/process/SharedStartupAttempt.ts b/src/process/SharedStartupAttempt.ts index 16bf3b8d8b..7dde69a262 100644 --- a/src/process/SharedStartupAttempt.ts +++ b/src/process/SharedStartupAttempt.ts @@ -18,11 +18,20 @@ type StartupSettlement = export class SharedStartupAttempt { readonly #controller = new AbortController(); readonly promise: Promise; + readonly #signal: AbortSignal; #waiters = 0; #settlement: StartupSettlement = { status: "pending" }; - constructor(start: (signal: AbortSignal) => Promise) { - this.promise = start(this.#controller.signal); + constructor( + start: (signal: AbortSignal) => Promise, + /** An owning operation's cancellation is independent of individual waiters. */ + ownerSignal?: AbortSignal, + ) { + this.#signal = + ownerSignal === undefined + ? this.#controller.signal + : AbortSignal.any([ownerSignal, this.#controller.signal]); + this.promise = start(this.#signal); void this.promise.then( (value) => { this.#settlement = { status: "completed", value }; @@ -35,7 +44,7 @@ export class SharedStartupAttempt { /** Signal consumed by the startup producer, never by an individual waiter. */ get signal(): AbortSignal { - return this.#controller.signal; + return this.#signal; } /** Completion includes the producer's final cleanup, not just its abort. */