Skip to content

Commit 49fe66f

Browse files
authored
🐛 propagate unwind out of scoped()'s async teardown (#1186)
Fixes #1185. If a routine was halted while suspended inside scoped(), code after yield* scoped(...) would still run. iter.return() yielded the destroy() effects in scoped's finally, but the resume came back as iter.next() — which takes the frame out of return-mode. trap() re-arms via trap.exit(); scoped() had no equivalent. Pre-d3266784 the Delimiter handled this with a sticky "cancelling" state; the unify refactor lost it. Fix: inline the Trap, run destroy() in critical() so cleanup completes, then yield trap.exit() to re-arm unwind based on the inner trap's outcome.
1 parent 846b97e commit 49fe66f

3 files changed

Lines changed: 29 additions & 5 deletions

File tree

lib/scoped.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { Operation } from "./types.ts";
2-
import { trap } from "./trap.ts";
3-
import { useCoroutine } from "./coroutine.ts";
2+
import { Trap, trap } from "./trap.ts";
3+
import { critical, useCoroutine } from "./coroutine.ts";
44
import { createScopeInternal } from "./scope-internal.ts";
5+
import { Just } from "./maybe.ts";
6+
import { Err, Ok } from "./result.ts";
57

68
/**
79
* Encapsulate an operation so that no effects will persist outside of
@@ -32,12 +34,17 @@ export function scoped<T>(operation: () => Operation<T>): Operation<T> {
3234
let routine = yield* useCoroutine();
3335
let original = routine.scope;
3436
let [scope, destroy] = createScopeInternal(original);
37+
let t = new Trap<T>(routine);
3538
try {
3639
routine.scope = scope;
37-
return yield* trap(operation);
40+
t.outcome = Just(Ok(yield* trap(operation)));
41+
} catch (error) {
42+
t.outcome = Just(Err(error));
3843
} finally {
3944
routine.scope = original;
40-
yield* destroy();
45+
yield* critical(destroy);
46+
// deno-lint-ignore no-unsafe-finally
47+
return (yield t.exit()) as T;
4148
}
4249
},
4350
};

lib/trap.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function* trap<T>(operation: () => Operation<T>): Operation<T> {
2727
}
2828
}
2929

30-
class Trap<T> implements ErrorBoundary {
30+
export class Trap<T> implements ErrorBoundary {
3131
outcome = Nothing<Result<T>>();
3232

3333
constructor(public routine: Coroutine<unknown>) {}

test/scoped.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,21 @@ describe("scoped", () => {
178178

179179
await expect(task).rejects.toMatchObject({ message: "boom!" });
180180
});
181+
182+
it("does not execute code after scoped() when halted during async teardown", async () => {
183+
let leaked = false;
184+
let task = run(function* () {
185+
yield* scoped(function* () {
186+
yield* spawn(function* () {
187+
yield* sleep(5000);
188+
});
189+
yield* suspend();
190+
});
191+
leaked = true;
192+
});
193+
194+
await task.halt();
195+
196+
expect(leaked).toBe(false);
197+
});
181198
});

0 commit comments

Comments
 (0)