Skip to content

@effectionx/worker: teardown hangs when Worker cannot process graceful close #229

Description

@taras

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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions