Skip to content

Commit cd00964

Browse files
authored
🐛 fix resource destruction order (#1091)
There was a regression introduced with #1081 such that when a task was halted, its resources were being shut down _before_ the task body was finished. This resulted in violation of the structured concurrency guarantees that required everything in scope to remain alive until after the body of the task is fully executed. This is because the body of the operation may use resources that are in scope throughout the _entirety_ of the operation. For example, in the following code, the finally block of the main operation requires the `child` task to be fully complete. ```ts let task = run(function* () { let synchronize = withResolvers<void>(); let child = yield* spawn(() => synchronize.operation); try { yield* suspend(); } finally { synchronize.resolve(); yield* child; } }); await task.halt() // deadlock! ``` Because the main task dependes on `child`, child needs to be active and only halted _after_ the main task is complete, _not_ before. (in this case the antecedent resource is a simple spawned `Task`, but it could be anything; a database connection, file handle, whatever...). Only after all references to the resource have become inactive can the resource be destroyed. When destroying a scope then, we need to first destroy oneself by interrupting effect iteration, and only once that is complete, destroy child scopes. This is accomplished by not reversing scope destructors. It turns out this regression was actually being surfaced by the an existing testcase which we commented out 😬. Another test has been added to ensure the proper shutdown order is preserved when explicitly halting. > Note: in the future we will simplify this process even further by removing the scope "destructor" mechanism altogether and replacing it with middleware. This will replace fiddling with the order of registration and execution with explicit declaration of sequence of operations as "around" or "within" other operations.
1 parent 253ce61 commit cd00964

4 files changed

Lines changed: 63 additions & 5 deletions

File tree

lib/scope-internal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function createScopeInternal(
7474
unbind();
7575
let outcome = Ok();
7676
try {
77-
for (let destructor of [...destructors].reverse()) {
77+
for (let destructor of destructors) {
7878
try {
7979
destructors.delete(destructor);
8080
yield* destructor();

test/resource.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,37 @@ describe("resource", () => {
107107

108108
expect(sequence).toEqual([1, 2, 3, 4]);
109109
});
110+
111+
it("is released in the reverse order from which it was acquired", async () => {
112+
let sequence: string[] = [];
113+
114+
await run(function* () {
115+
yield* resource<void>(function* (provide) {
116+
try {
117+
yield* provide();
118+
} finally {
119+
sequence.push("first start");
120+
yield* sleep(5);
121+
sequence.push("first done");
122+
}
123+
});
124+
yield* resource<void>(function* (provide) {
125+
try {
126+
yield* provide();
127+
} finally {
128+
sequence.push("second start");
129+
yield* sleep(10);
130+
sequence.push("second done");
131+
}
132+
});
133+
});
134+
expect(sequence).toEqual([
135+
"second start",
136+
"second done",
137+
"first start",
138+
"first done",
139+
]);
140+
});
110141
});
111142

112143
function createResource(container: State): Operation<State> {

test/run.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,7 @@ describe("run()", () => {
211211
await expect(task).rejects.toMatchObject({ message: "halted" });
212212
});
213213

214-
// TODO: this test is of dubious value. Deadlock might be the right thing here
215-
// we can revisit and try to either detect this and deal with it, or maybe raise
216-
// an error
217-
it.skip("can halt itself between yield points", async () => {
214+
it("can halt itself between yield points", async () => {
218215
let task: Task<void> = run(function* root() {
219216
yield* sleep(0);
220217

test/spawn.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,34 @@ describe("spawn", () => {
225225

226226
expect(order).toEqual(["zero", "one", "two"]);
227227
});
228+
229+
it("preserves all child tasks until after the parent operation passes out of scope", async () => {
230+
let sequence: string[] = [];
231+
232+
let task = run(function* () {
233+
yield* spawn(function* () {
234+
try {
235+
yield* suspend();
236+
} finally {
237+
sequence.push("first");
238+
}
239+
});
240+
yield* spawn(function* () {
241+
try {
242+
yield* suspend();
243+
} finally {
244+
sequence.push("second");
245+
}
246+
});
247+
try {
248+
yield* suspend();
249+
} finally {
250+
sequence.push("parent");
251+
}
252+
});
253+
254+
await task.halt();
255+
256+
expect(sequence).toEqual(["parent", "second", "first"]);
257+
});
228258
});

0 commit comments

Comments
 (0)