Skip to content

Commit 1e09c90

Browse files
Merge pull request #1549 from corbitsdev/cl-10975-stop-a-finished-session-operation-from-rejecting-the-next
fix(tui): stop a finished session op from rejecting the next one
2 parents f32b43a + 1a38a8d commit 1e09c90

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

‎src/tui/delivery-queue.ts‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -461,27 +461,55 @@ export function createSessionOperationQueue(): SessionOperationQueue {
461461
// the await.
462462
let nextId = 0;
463463
let activeId = 0;
464+
// Body promise of the op in `activeId`. Unset during that op's first
465+
// synchronous turn, before `run` returns.
466+
let activeResult: Promise<void> | undefined;
464467

465468
// True only when this continuation is the op that is running right now.
466469
// A detached continuation still carries its parent's id; that must not
467470
// match a later op's activeId.
468471
const inThisOp = (): boolean => runningSessionOp.getStore() === activeId;
469472

473+
// The body promise can be fulfilled while `activeId` is still set: `finally`
474+
// clears the id on a later microtask. A continuation in that gap is not the
475+
// op waiting on itself.
476+
const bodyStillRunning = (): boolean => {
477+
if (activeResult === undefined) return true;
478+
try {
479+
return Bun.peek(activeResult) === activeResult;
480+
} catch {
481+
return false;
482+
}
483+
};
484+
470485
const execute = (op: () => Promise<void>): Promise<void> => {
471486
const id = ++nextId;
472487
activeId = id;
473488
let result: Promise<void>;
474489
try {
475490
result = runningSessionOp.run(id, op);
476491
} catch (err) {
477-
if (activeId === id) activeId = 0;
492+
if (activeId === id) {
493+
activeId = 0;
494+
activeResult = undefined;
495+
}
478496
throw err;
479497
}
498+
activeResult = result;
480499
return Promise.resolve(result).finally(() => {
481-
if (activeId === id) activeId = 0;
500+
if (activeId === id) {
501+
activeId = 0;
502+
activeResult = undefined;
503+
}
482504
});
483505
};
484506

507+
// `observed === activeResult` is the adoption of a thenable returned from
508+
// the op. That `.then` runs after AsyncLocalStorage.run returns.
509+
const isSelfWait = (during: number, observed: object): boolean =>
510+
activeId === during &&
511+
(observed === activeResult || (inThisOp() && bodyStillRunning()));
512+
485513
const enqueue = (op: () => Promise<void>): Promise<void> => {
486514
// `tail.then(op, op)` — a failed op must not wedge the chain. The
487515
// returned promise is not that tail: a self-wait rejection has to
@@ -493,7 +521,9 @@ export function createSessionOperationQueue(): SessionOperationQueue {
493521
tail = scheduled;
494522
if (!inThisOp()) return scheduled;
495523
const enqueuedDuring = activeId;
496-
return observeUnlessSelfWait(scheduled, () => activeId === enqueuedDuring);
524+
return observeUnlessSelfWait(scheduled, (observed) =>
525+
isSelfWait(enqueuedDuring, observed),
526+
);
497527
};
498528

499529
const awaitTail = (): Promise<void> => {
@@ -503,7 +533,9 @@ export function createSessionOperationQueue(): SessionOperationQueue {
503533
// deadlocks the same way as awaiting enqueue.
504534
if (!inThisOp()) return drained;
505535
const awaitedDuring = activeId;
506-
return observeUnlessSelfWait(drained, () => activeId === awaitedDuring);
536+
return observeUnlessSelfWait(drained, (observed) =>
537+
isSelfWait(awaitedDuring, observed),
538+
);
507539
};
508540

509541
return { enqueue, awaitTail };
@@ -517,7 +549,7 @@ export function createSessionOperationQueue(): SessionOperationQueue {
517549
*/
518550
function observeUnlessSelfWait(
519551
forward: Promise<void>,
520-
stillRunning: () => boolean,
552+
stillRunning: (observed: object) => boolean,
521553
): Promise<void> {
522554
let reported: SessionOperationSelfWaitError | undefined;
523555
let guarded = false;
@@ -555,7 +587,7 @@ function observeUnlessSelfWait(
555587
): Promise<undefined>;
556588
} = {
557589
then(onFulfilled, onRejected) {
558-
if (stillRunning()) {
590+
if (stillRunning(thenable)) {
559591
guardUnobserved();
560592
return reject().then(onFulfilled, onRejected);
561593
}

‎src/tui/session-operation-queue.test.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,128 @@ test("a self-wait does not leave the queued op's rejection unhandled", async ()
437437
process.off("unhandledRejection", onRejection);
438438
}
439439
});
440+
441+
test("a microtask detached from a finished op body is not a self-wait", async () => {
442+
const log: string[] = [];
443+
const { enqueue, awaitTail } = createSessionOperationQueue();
444+
let detached: Promise<void> = Promise.resolve();
445+
446+
const outer = enqueue(async () => {
447+
detached = (async () => {
448+
await Promise.resolve();
449+
await enqueue(async () => {
450+
log.push("inner");
451+
});
452+
log.push("detached:ok");
453+
})();
454+
log.push("outer:end");
455+
});
456+
457+
await outer;
458+
await detached;
459+
await awaitTail();
460+
expect(log).toEqual(["outer:end", "inner", "detached:ok"]);
461+
});
462+
463+
test("queueMicrotask after the op body returns is not a self-wait", async () => {
464+
const log: string[] = [];
465+
const { enqueue, awaitTail } = createSessionOperationQueue();
466+
let done: Promise<void> = Promise.resolve();
467+
468+
const outer = enqueue(async () => {
469+
done = new Promise((resolve, reject) => {
470+
queueMicrotask(() => {
471+
void enqueue(async () => {
472+
log.push("inner");
473+
}).then(resolve, reject);
474+
});
475+
});
476+
log.push("outer:end");
477+
});
478+
479+
await outer;
480+
await done;
481+
await awaitTail();
482+
expect(log).toEqual(["outer:end", "inner"]);
483+
});
484+
485+
test("an outside waiter is not rejected while the enqueuing op is blocked", async () => {
486+
const log: string[] = [];
487+
const { enqueue, awaitTail } = createSessionOperationQueue();
488+
let release: () => void = () => undefined;
489+
const gate = new Promise<void>((resolve) => {
490+
release = resolve;
491+
});
492+
let inner: Promise<void> | undefined;
493+
494+
const outer = enqueue(async () => {
495+
inner = enqueue(async () => {
496+
log.push("inner");
497+
});
498+
await gate;
499+
log.push("outer:end");
500+
});
501+
502+
await Promise.resolve();
503+
await Promise.resolve();
504+
if (inner === undefined) throw new Error("inner missing");
505+
let observed = "pending";
506+
const waiting = inner.then(
507+
() => {
508+
observed = "resolved";
509+
},
510+
(err: unknown) => {
511+
observed =
512+
err instanceof SessionOperationSelfWaitError ? "self-wait" : "error";
513+
},
514+
);
515+
await Promise.resolve();
516+
expect(observed).toBe("pending");
517+
release();
518+
await outer;
519+
await waiting;
520+
await awaitTail();
521+
expect(observed).toBe("resolved");
522+
expect(log).toEqual(["outer:end", "inner"]);
523+
});
524+
525+
async function settleOrTimeout(promise: Promise<unknown>): Promise<string> {
526+
return Promise.race([
527+
promise.then(
528+
() => "resolved",
529+
(err: unknown) =>
530+
err instanceof SessionOperationSelfWaitError ? "self-wait" : "error",
531+
),
532+
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 200)),
533+
]);
534+
}
535+
536+
test("a non-async callback that returns enqueue rejects and the queue drains", async () => {
537+
const log: string[] = [];
538+
const { enqueue, awaitTail } = createSessionOperationQueue();
539+
540+
const outer = enqueue(() => {
541+
log.push("outer");
542+
return enqueue(async () => {
543+
log.push("inner");
544+
});
545+
});
546+
547+
expect(await settleOrTimeout(outer)).toBe("self-wait");
548+
expect(await settleOrTimeout(awaitTail())).toBe("resolved");
549+
expect(log).toEqual(["outer", "inner"]);
550+
});
551+
552+
test("a non-async callback that returns awaitTail rejects and the queue drains", async () => {
553+
const log: string[] = [];
554+
const { enqueue, awaitTail } = createSessionOperationQueue();
555+
556+
const outer = enqueue(() => {
557+
log.push("outer");
558+
return awaitTail();
559+
});
560+
561+
expect(await settleOrTimeout(outer)).toBe("self-wait");
562+
expect(await settleOrTimeout(awaitTail())).toBe("resolved");
563+
expect(log).toEqual(["outer"]);
564+
});

0 commit comments

Comments
 (0)