Summary
useWorker() teardown can hang indefinitely when the Worker is CPU-bound or otherwise unable to cooperatively process its { type: "close" } control message.
This is reproducible with @effectionx/worker@0.5.4, and the same teardown path is present on main.
Reproduction
spin-worker.ts:
import { workerMain } from "@effectionx/worker";
await workerMain(function* () {
while (true) {
// Deliberately does not yield to the Worker event loop.
}
});
main.ts:
import { run, sleep } from "effection";
import { useWorker } from "@effectionx/worker";
await run(function* () {
yield* useWorker(new URL("./spin-worker.ts", import.meta.url), {
type: "module",
});
yield* sleep(100);
// Returning tears down the resource.
});
console.log("teardown completed");
Run the host under an external three-second timeout. The final log is never reached and the process must be terminated externally.
Observed environment:
@effectionx/worker 0.5.4
deno 2.9.1 (aarch64-apple-darwin)
macOS 15 / Darwin 25.5.0 arm64
Actual behavior
The ensure() block in worker/worker.ts posts { type: "close" }, then waits until it receives the Worker's close result:
worker.postMessage({ type: "close" });
while (!outcomeSettled) {
const event = yield* once(worker, "message");
// waits for msg.type === "close"
}
The Worker-side handler can only halt its task after the event loop receives that control message. CPU-bound code never services the message, so the resource's teardown never settles. Effection cancellation therefore cannot regain control of the host scope.
Expected behavior
Tearing down or cancelling a useWorker() resource eventually terminates the underlying Worker even when Worker code is non-cooperative. A graceful close may be attempted, but resource cleanup needs a hard-termination path (for example Worker.terminate() immediately on cancellation, or a bounded graceful-to-hard escalation).
The hard-termination path should also settle/reject the internal outcome so no cleanup operation remains waiting for a close message that cannot arrive.
Impact
This prevents a Worker boundary from providing preemptible cancellation for CPU-bound interpreters. In a transactional host, it also prevents control from returning to rollback mutations and publish a failed result.
An executable transaction POC confirmed the behavior:
- Unpatched
@effectionx/worker@0.5.4: a 500 ms application timeout fired, but teardown was still hung after 3 seconds and required external process termination.
- With a version-checked POC delta calling
Worker.terminate() during resource shutdown: the host regained control, rolled back the partial mutation, and recorded the timeout result.
Evidence:
Summary
useWorker()teardown can hang indefinitely when the Worker is CPU-bound or otherwise unable to cooperatively process its{ type: "close" }control message.This is reproducible with
@effectionx/worker@0.5.4, and the same teardown path is present onmain.Reproduction
spin-worker.ts:main.ts:Run the host under an external three-second timeout. The final log is never reached and the process must be terminated externally.
Observed environment:
Actual behavior
The
ensure()block inworker/worker.tsposts{ type: "close" }, then waits until it receives the Worker's close result:The Worker-side handler can only halt its task after the event loop receives that control message. CPU-bound code never services the message, so the resource's teardown never settles. Effection cancellation therefore cannot regain control of the host scope.
Expected behavior
Tearing down or cancelling a
useWorker()resource eventually terminates the underlying Worker even when Worker code is non-cooperative. A graceful close may be attempted, but resource cleanup needs a hard-termination path (for exampleWorker.terminate()immediately on cancellation, or a bounded graceful-to-hard escalation).The hard-termination path should also settle/reject the internal outcome so no cleanup operation remains waiting for a close message that cannot arrive.
Impact
This prevents a Worker boundary from providing preemptible cancellation for CPU-bound interpreters. In a transactional host, it also prevents control from returning to rollback mutations and publish a failed result.
An executable transaction POC confirmed the behavior:
@effectionx/worker@0.5.4: a 500 ms application timeout fired, but teardown was still hung after 3 seconds and required external process termination.Worker.terminate()during resource shutdown: the host regained control, rolled back the partial mutation, and recorded the timeout result.Evidence: