thefrontside / thefrontside/effectionx

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

Open
#229 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
12
Forks
4
Avg merge
3h 17m
Merged PRs (30d)
1

Description

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:

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Inspect worker/worker.ts and the ensure() teardown path, then run the supplied CPU-bound reproduction first. Trace cancellation and outcome settlement; done means teardown regains control and reaches the final log even when the Worker cannot process the close message.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.