Skip to content

✨ Add @effectionx/forceable and implement it on Worker and Process - #242

Open
taras wants to merge 12 commits into
mainfrom
agent/worker-force-symbol
Open

✨ Add @effectionx/forceable and implement it on Worker and Process#242
taras wants to merge 12 commits into
mainfrom
agent/worker-force-symbol

Conversation

@taras

@taras taras commented Aug 11, 2026

Copy link
Copy Markdown
Member

Motivation

Closes #229 and #228. Supersedes #235 and #230.

Cooperative shutdown assumes the other side is listening, and sometimes it
isn't.

A Worker spinning in a tight loop never reads its control channel, so
useWorker() waits forever for a close it will never get (#229). A process that
traps SIGTERM, or a descendant holding inherited stdout open, never satisfies
exec()'s wait for the process and its stdio to close (#228). In both cases the
owning scope stays open indefinitely, and no amount of patience fixes it.

#235 and #230 each solved this in place, with a shutdown option carrying a
"graceful" | "forced" union. Review feedback on #235 was that this puts the
decision in the wrong place: a resource should try to shut down gracefully, and
only conditions the application can observe make extraordinary measures
reasonable. The same objection applies to #230, and solving it twice would mean
two policy vocabularies for one idea.

Approach

Add @effectionx/forceable, and implement it on both resources.

  • withForce(op, policy) puts a deadline on a resource's graceful teardown
    without changing how the resource tears itself down.
  • The resource still runs its own graceful teardown, unmodified. The policy runs
    alongside it and may cut it short. Whichever finishes first wins, and a
    resource that closes in time cancels the policy where it stands.
  • A policy is an operation, so it can wait on application state rather than a
    clock. Neither package infers what makes a resource unhealthy.
  • Resources opt in by implementing one symbol, so a single policy shape covers
    Workers, processes, and anything else holding a handle the runtime will not
    reclaim on its own.
import { sleep } from "effection";
import { withForce } from "@effectionx/forceable";

let worker = yield* withForce(
  useWorker("./transcode.ts", { type: "module" }),
  function* (force) {
    yield* sleep(10_000);
    force("worker did not close within 10s");
  },
);

let server = yield* withForce(daemon("node server.js"), function* (force) {
  yield* sleep(10_000);
  force("server did not exit within 10s of SIGTERM");
});

Each resource implements the symbol with the kill it already had:

Resource Graceful [force]
Worker post close message, await teardown Worker.terminate()
exec/daemon, POSIX SIGTERM to the group, await stdio close SIGKILL to the group
exec/daemon, Windows Ctrl-C plus stdin close, await stdio close taskkill /T /F

SIGKILL and taskkill /T /F both address the tree, which is what makes them
effective against the inherited-stdio case in #228.

Windows needed one accommodation: taskkill is an operation but [force] is
synchronous, so the kill runs in a task owned by the resource. Cancelling
whatever policy asked for the kill therefore cannot cancel the kill itself —
independently reproducing a fix #230 had to arrange deliberately.

Impact

useWorker(), exec(), and daemon() are unchanged for existing callers. The
shutdown options, the unions, and the policy plumbing from #235 and #230 are
all gone. worker.ts shrinks by 54 lines and its teardown returns to what it
was before #235; the process changes are +14 in posix.ts and +12 in win32.ts
against #230's +66 and +87.

Forcing skips the resource's cleanup — that is the point, and it is not free.
Whatever the graceful teardown was responsible for (flushing a buffer, removing
a lock file, acknowledging in-flight work) has not happened. Durable cleanup for
a resource that might be forced has to be host-owned.

Forcing is quiet: force(reason) returns, teardown finishes, and the enclosing
halt is undisturbed. The policy decided to force, so the policy is the natural
place to log or count it.

Open question: should forcing be loud?

Raising from inside a policy is not a reliable way to make it loud. Whether
the error escapes races against how many turns the resource's teardown needs
after being forced:

teardown settles 0ms after force -> throw SWALLOWED
teardown settles 1ms after force -> throw LANDED
teardown settles 5ms after force -> throw LANDED

So this is not something an application can arrange for itself. If forcing
should raise, it belongs in withForce() where it can be deterministic, and it
carries a real cost: effection 4.1 replaces an in-flight error rather than
aggregating, so a raising teardown buries whatever was actually bringing the
scope down. Left out pending a decision.

One thing #230 had that this does not: an eager { exit } operation giving
process policies the direct command's ExitStatus. A policy can close over the
Process and call join() itself, but I have not verified that covers the
descendant-stdio case, so it is worth a look.

Validation

  • pnpm test — 396 passed, 6 skipped
  • pnpm test:matrix — all ten rows pass (effection 3.0.0 and 4.1.0, effect 3.0.0
    and 3.22.1, vitest 3.0.0 and 4.1.10)
  • Worker and forceable suites against effection 3.6.1 directly — 64 passed
  • pnpm build, pnpm check, pnpm lint, pnpm fmt:check, pnpm sync — clean
  • The forced-process test asserts the tree is actually reaped via
    kill(pid, 0); the same fixture without withForce hangs teardown forever,
    confirmed by timeout

Summary by CodeRabbit

  • New Features
    • Added configurable resource teardown with graceful shutdown, deadlines, cancellation, and forced termination.
    • Added forceful termination support for workers and processes, including optional reasons and process-tree cleanup.
    • Added the @effectionx/forceable package for reusable shutdown policies.
  • Bug Fixes
    • Improved handling of workers and processes that ignore cooperative shutdown requests.
  • Documentation
    • Documented forced cleanup, timeout policies, worker shutdown behavior, and process termination limitations.
  • Tests
    • Added coverage for graceful shutdown, forced termination, cancellation, and resistant workers and processes.

taras and others added 7 commits August 11, 2026 18:09
Graceful teardown assumes the other side is listening. A Worker spinning
in a tight loop never reads its control channel, so waiting on it holds
the enclosing scope open forever, and no amount of patience fixes it.

withForce() puts a deadline on that wait without changing how a resource
tears itself down. The resource still runs its own graceful teardown; the
policy runs alongside it and may cut it short. Whichever finishes first
wins, and a resource that closes in time cancels the policy where it
stands.

Resources opt in by implementing a single symbol, so one policy shape
works across Workers, processes, and anything else holding a handle the
runtime will not reclaim on its own. This replaces the shutdown option,
its "graceful" | "forced" union, and the policy plumbing inside
useWorker, which shrinks by 54 lines.

Forcing is quiet: the policy decided to force, so the policy is the
natural place to log or count it. Raising from inside a policy is not a
reliable alternative — whether the error escapes races against how many
turns the resource's teardown needs after being forced.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@taras, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8b44a296-841b-415c-9b9e-9eec93dd59b8

📥 Commits

Reviewing files that changed from the base of the PR and between 8c1921a and f52d333.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • forceable/forceable.test.ts
  • process/package.json
  • process/src/exec/posix.ts
  • process/src/exec/win32.ts
  • process/test/fixtures/cooperative.ts
  • process/test/force.test.ts
  • process/tsconfig.json
📝 Walkthrough

Walkthrough

Adds the @effectionx/forceable package with deadline-based resource teardown. Integrates forced termination with Worker and Process resources. Adds tests, documentation, package metadata, and workspace references.

Changes

Forceable resource teardown

Layer / File(s) Summary
Forceable API and lifecycle
forceable/forceable.ts, forceable/forceable.test.ts, forceable/README.md, forceable/mod.ts, forceable/package.json
Defines force, ForcedTerminationError, Forceable, ForcePolicy, and withForce. Tests cover graceful teardown, forced teardown, policy cancellation, and halted tasks.
Process force integration
process/src/exec/types.ts, process/src/exec/posix.ts, process/src/exec/win32.ts, process/test/force.test.ts, process/test/fixtures/shutdown-resistant.ts, process/README.md, process/package.json, process/tsconfig.json
Adds platform-specific forced process-tree termination and documents configurable process shutdown policies.
Worker force integration
worker/worker.ts, worker/package.json, worker/tsconfig.json, worker/README.md
Adds forced Worker termination, separates initialization data from construction options, and updates graceful cleanup settlement.
Worker force behavior validation
worker/worker.test.ts, worker/worker-force.test.ts, worker/test-assets/cpu-bound-worker.ts
Tests graceful and forced shutdown, CPU-bound Workers, policy cancellation, health-triggered forcing, error propagation, and force reasons.
Workspace and project wiring
pnpm-workspace.yaml, tsconfig.json, forceable/tsconfig.json
Adds the package to workspace and TypeScript project references.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HostTask
  participant withForce
  participant Resource
  participant ForcePolicy
  HostTask->>withForce: acquire Resource
  HostTask->>withForce: start cleanup
  withForce->>Resource: request graceful teardown
  withForce->>ForcePolicy: run policy concurrently
  ForcePolicy->>Resource: invoke force(reason)
  Resource-->>withForce: settle forced teardown
  withForce-->>HostTask: complete cleanup
Loading

Possibly related PRs

Suggested reviewers: cowboyd


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Policy Compliance ❌ Error The PR adds sleep(500), sleep(100), and sleep(300) in process/test/force.test.ts to wait for startup, reaping, and shutdown; this violates the Recommended No-Sleep Test Synchronization policy. Replace these waits with deterministic callback or state synchronization, such as withResolvers() or when().
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the new forceable package and its implementation for Worker and Process.
Description check ✅ Passed The description includes the required Motivation and Approach sections and provides detailed scope, behavior, and validation information.
Linked Issues check ✅ Passed The PR provides application-defined force policies and hard termination for non-cooperative Workers and process trees, with supporting tests and documentation [#229, #230].
Out of Scope Changes check ✅ Passed The changes are limited to the forceable package, Worker and Process integrations, related tests, documentation, and project configuration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/worker-force-symbol

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/thefrontside/effectionx/@effectionx/forceable@242
npm i https://pkg.pr.new/thefrontside/effectionx/@effectionx/process@242
npm i https://pkg.pr.new/thefrontside/effectionx/@effectionx/worker@242

commit: f52d333

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@forceable/forceable.test.ts`:
- Around line 43-45: Replace the non-zero sleep-based synchronization in the
affected tests around the ensure/spawn lifecycle with deterministic signals
using withResolvers() or a channel. Signal graceful-close start, force
invocation, and teardown completion explicitly, then await those signals at each
synchronization point so the quiet-halt test confirms forcing occurred. Remove
the corresponding test sleeps while preserving the existing lifecycle
assertions.
- Line 1: Migrate forceable/forceable.test.ts (anchor, lines 1-1) from
`@effectionx/vitest` to `@effectionx/bdd`, replace sleep(50) with deterministic
synchronization, and exclude the suite from Vitest. Update
forceable/package.json (sibling, lines 30-32), the TypeScript reference,
lockfile, and test commands to use the Node.js test runner and the new BDD
dependency.

In `@forceable/forceable.ts`:
- Around line 21-43: Define and apply one consistent public contract for force
behavior: decide whether an independently awaited resource rejects with
ForcedTerminationError after force or remains quiet, then update the Forceable
and ForcedTerminationError documentation in forceable/forceable.ts (lines
21-43), the rejectOutcome example in forceable/README.md (lines 62-70), and the
quiet-forcing statement in forceable/README.md (lines 84-86) to describe that
same behavior.

In `@forceable/package.json`:
- Around line 2-4: Update the package manifest description associated with
`@effectionx/forceable` to exactly “Put a deadline on a resource's graceful
teardown,” matching the README text before its `---` separator; leave the
package name and version unchanged.
- Around line 2-4: Add a files field to the package metadata for
`@effectionx/forceable`, explicitly including dist, mod.ts, and the package source
files so the published artifact is not determined by npm defaults.

In `@forceable/README.md`:
- Around line 88-93: Update the generator example so `reason` is initialized
before the `logger.warn` call and the subsequent `force(reason)` invocation,
ensuring both use the same defined value and the policy does not throw due to an
undefined variable.

In `@worker/worker-force.test.ts`:
- Around line 9-29: Replace the timing-based sleep in the withForce test with
deterministic shared-state synchronization: update spinning() to return the
allocated state alongside the worker task, then use when() from
`@effectionx/converge` to wait until Atomics.load(state, 0) indicates the
CPU-bound worker has entered its spin loop before continuing the test.

In `@worker/worker.ts`:
- Line 125: Update the UseWorkerOptions documentation near the worker
constructor to remove the reference to shutdown options and state that forcing
is opt-in through withForce().
- Around line 157-164: Update workerMain() graceful-shutdown handling to release
all remaining Node.js handles before returning after parentPort closes. Use
withForce for workers that cannot close their handles, and preserve terminate()
only for forced/error paths; do not unconditionally call worker.terminate()
during graceful cleanup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0f73d076-88b5-440d-ad24-cca592b9b718

📥 Commits

Reviewing files that changed from the base of the PR and between 64dbc21 and 92d8645.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • forceable/README.md
  • forceable/forceable.test.ts
  • forceable/forceable.ts
  • forceable/mod.ts
  • forceable/package.json
  • forceable/tsconfig.json
  • pnpm-workspace.yaml
  • tsconfig.json
  • worker/README.md
  • worker/package.json
  • worker/test-assets/cpu-bound-worker.ts
  • worker/tsconfig.json
  • worker/worker-force.test.ts
  • worker/worker.test.ts
  • worker/worker.ts

Comment thread forceable/forceable.test.ts
Comment thread forceable/forceable.test.ts Outdated
Comment thread forceable/forceable.ts
Comment thread forceable/package.json
Comment on lines +2 to +4
"name": "@effectionx/forceable",
"description": "Put a deadline on a resource's graceful teardown and tear it down forcibly when it expires",
"version": "0.1.0",

@coderabbitai coderabbitai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Match the manifest description to the README description.

The manifest description differs from the package README text before the --- separator. Set it to Put a deadline on a resource's graceful teardown.

As per coding guidelines, “Package README text before the --- separator is used as the package description.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forceable/package.json` around lines 2 - 4, Update the package manifest
description associated with `@effectionx/forceable` to exactly “Put a deadline on
a resource's graceful teardown,” matching the README text before its `---`
separator; leave the package name and version unchanged.

Source: Coding guidelines


📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Declare the published package files.

Add a files field that includes dist, mod.ts, and the package source files. Do not rely on npm defaults for the published artifact.

Proposed fix
   "version": "0.1.0",
+  "files": ["dist", "mod.ts", "forceable.ts"],
   "keywords": ["concurrency"],

As per coding guidelines, “The files field must include dist, mod.ts, and source files.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"name": "@effectionx/forceable",
"description": "Put a deadline on a resource's graceful teardown and tear it down forcibly when it expires",
"version": "0.1.0",
"name": "`@effectionx/forceable`",
"description": "Put a deadline on a resource's graceful teardown and tear it down forcibly when it expires",
"version": "0.1.0",
"files": ["dist", "mod.ts", "forceable.ts"],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forceable/package.json` around lines 2 - 4, Add a files field to the package
metadata for `@effectionx/forceable`, explicitly including dist, mod.ts, and the
package source files so the published artifact is not determined by npm
defaults.

Source: Coding guidelines

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two findings here, handled differently.

files — applied in 8c1921a, thank you. The field was genuinely missing and the package would have published its tests and source. It is now files: ["dist"] rather than the suggested dist + mod.ts + source: #216 landed a few hours before this PR and rewrote that guideline. AGENTS.md now reads "Include only published artifacts (dist)", with bdd and inline as the sole exceptions.

Description — not applied. No package in the repo follows the "README text before ---" convention today:

package package.json README
timebox Constrain operations to complete within a time limit Constrain any operation to complete within a certain time.
converge Poll and wait for conditions to be met with automatic retry and timeout Recognize a desired state and synchronize on when that state has been achieved.
task-buffer Limit concurrent task execution with automatic queuing Manages concurrent task execution by enforcing a maximum limit…

The current description is 88 characters, verb-first, no Markdown, no trailing period — compliant with .policies/package-json-metadata.md, which is the strict policy .coderabbit.yaml points at for **/package.json.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- forceable/package.json ---'
cat forceable/package.json

printf '%s\n' '--- forceable/README.md (opening section) ---'
sed -n '1,35p' forceable/README.md

printf '%s\n' '--- relevant repository guidance ---'
fd -a -t f 'AGENTS.md|package-json-metadata\.md|index\.md|coderabbit\.ya?ml' . \
  | while IFS= read -r file; do
      printf '\n--- %s ---\n' "$file"
      rg -n -i -C2 'published artifacts|files|readme.*description|description' "$file" || true
    done

printf '%s\n' '--- commit 8c1921a manifest change ---'
git show --format=fuller --stat --oneline 8c1921a -- forceable/package.json 2>/dev/null || true
git show --format= -- forceable/package.json 8c1921a 2>/dev/null || true

Length of output: 5290


@taras, files: ["dist"] is correct. The current AGENTS.md rule requires only published artifacts in files, and the forceable package has no listed exception. The files finding is addressed.

The manifest description meets the strict metadata-format policy. However, the current AGENTS.md also states that the README text before --- is the package description. forceable/README.md and forceable/package.json still differ. Existing package differences do not define an exception to that rule.


🧠 Learnings used
Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/package.json : package.json must include a `description` field: single sentence, under 120 chars, no Markdown, no trailing period (see Package.json Metadata policy)

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T19:16:27.135Z
Learning: Applies to **/package.json : Package descriptions are required, single-sentence, under 120 characters, Markdown-free, and must not end with a period.

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-25T17:56:28.931Z
Learning: Applies to **/package.json : For `package.json` files, enforce `.policies/package-json-metadata.md`: ensure non-empty `description` field with required format.

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T19:16:27.135Z
Learning: Applies to **/README.md : Package README text before the `---` separator is used as the package description.

Learnt from: taras
Repo: thefrontside/effectionx PR: 216
File: bdd/package.json:29-29
Timestamp: 2026-04-26T13:41:09.571Z
Learning: In this repo, the publish workflow runs `.internal/strip-dev-exports.ts` before `pnpm publish` to strip the `development` export condition from every workspace package’s `package.json`, ensuring published tarballs don’t reference source `.ts` entry points via `development` conditions. Therefore, when reviewing any workspace `package.json`, you generally should not add TypeScript source entry points (e.g., `mod.ts`, `mod.node.ts`, or other `.ts` files) to the `files` field solely to support `development` exports; the publish step should ensure those `development` exports are removed from the published artifacts.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

Comment thread forceable/README.md
Comment thread worker/worker-force.test.ts Outdated
Comment thread worker/worker.ts Outdated
Comment thread worker/worker.ts
@taras

taras commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

On removing the inline close-message loop

f7bfa52 (the first commit here) dropped the loop that read the close message
directly inside ensure:

while (!outcomeSettled) {
  const event = yield* once(worker, "message");
  // ...resolveOutcome / rejectOutcome
}

Worth spelling out why that is safe, since teardown now depends on the spawned
message loop instead, and that dependency is easy to miss.

It was redundant, not a second line of defense. once(worker, "message")
and the spawned loop's on(worker, "message") are separate listeners on the
same EventTarget, so both received every close message. The outcomeSettled
guards made the duplication harmless rather than useful.

The inline version was the weaker one. It settles the outcome but never
calls requests.close(undefined), which is what lets forEach terminate. It
only ever worked because the spawned loop was running alongside it.

The ordering it relies on is not new in effection 4.1. Since 4.1 fixed a
number of teardown bugs, the concern that this code was working around them is
reasonable — but ensure handlers running before a resource's spawned children
are halted holds on 3.x as well:

Check Result
ensure observing spawned-task work on 3.6.1 same ordering as 4.1.0
worker + forceable suites on 3.6.1 64 passed, 4 skipped
pnpm test:matrix (effection 3.0.0 and 4.1.0) 25 packages, 355 tests
APIs both packages use, present in 3.6.1 all, including scoped

test:matrix runs in CI, so the ^3 || ^4 peer range stays honest as this
changes.

worker.ts now carries a comment recording this, so the next teardown change
does not have to rediscover it.


Unrelated, but found while checking: fx/parallel.test.ts > returns an immediate channel with results as they are completed fails intermittently under
test:matrix on effection@3.0.0 — it asserts a fixed result order and got
["second", "first"]. It passed in CI here and this branch touches no files in
fx/, so it is pre-existing. Probably wants its own issue, in the same family
as #203.

Graceful teardown used to read the close message inline, duplicating the
spawned message loop that was already handling it. Removing the duplicate
leaves teardown depending on that loop still being alive, which is true
but invisible.

Say so, and say which effection versions it was checked against, so the
next person weighing a teardown change knows what is holding it up.
@taras
taras force-pushed the agent/worker-force-symbol branch from 0d0edc6 to bfeef62 Compare August 11, 2026 22:54
taras added 2 commits August 11, 2026 19:03
A process that traps SIGTERM, or a descendant holding the inherited
stdio open, never lets teardown finish: exec() signals the group and
then waits on stdout and stderr closing, and that wait has no bound.

Implement the force symbol so withForce() can put one there. POSIX
sends SIGKILL to the process group, which cannot be trapped and reaches
descendants holding the stdio open. Windows runs taskkill /T /F from a
task owned by the resource, so cancelling whatever policy asked for the
kill cannot cancel the kill itself.

daemon() returns the same Process, so it gains this too.
Explain what cooperative shutdown waits for, the two ordinary cases that
never satisfy that wait, and how withForce() bounds it. Record the force
symbol alongside join() and expect() in the Process interface.

Minor bump: exec() and daemon() gain a capability without changing any
existing behaviour.
@taras taras changed the title ✨ Add @effectionx/forceable and implement it on Worker ✨ Add @effectionx/forceable and implement it on Worker and Process Aug 11, 2026
The withForce tests waited on the clock. Worse, the worker one allocated
the shared spin flag and never read it, so on a loaded machine the halt
could run against a worker that was still cooperative and the test would
pass without forcing anything. Wait on the flag with when(), and assert
that forcing actually happened rather than only that the halt was quiet.

Give the stubborn fixture a trigger instead of a timer so every case is
caused rather than timed. The suite drops from half a second to 8ms.

The force contract also claimed ForcedTerminationError is reported to
whoever awaits the resource. Nothing usually is: forcing happens during
teardown, so on halt the awaiter is cancelled first and on normal exit
the body has already returned. Say that, and point at the policy as the
thing that knows.

Also declare files for the new package, define the reason the README
example logs, and drop the reference to a shutdown option this branch
removes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@forceable/forceable.test.ts`:
- Around line 41-43: Update the useStubborn test fixture and quiet-halt test to
settle with a ForcedTerminationError for the forced path instead of resolving
settled identically for graceful and forced paths. Assert the resource outcome
separately from the enclosing task.halt() result, covering both the forced
failure and successful halt behavior while preserving the existing graceful-path
assertions.
- Around line 79-90: Update the test around the scoped withForce policy so it
signals when execution reaches yield* suspend() before triggering graceful
teardown. Await or yield until that policy-ready signal is set, then perform the
cancellation and lifecycle assertions, ensuring policyResumed remains a
meaningful cancellation invariant rather than a timing-dependent result.

In `@process/src/exec/posix.ts`:
- Around line 143-153: Update the force handler around the visible [force]
method to track whether processResult has settled at every resolution site,
including normal completion and forced termination. Return immediately when
settled, before checking the PID or calling process.kill(); retain the
processResult.resolve(Err(new ForcedTerminationError(reason))) path only for
unsettled resources, without relying solely on the direct child exit state.

In `@process/test/force.test.ts`:
- Line 22: Replace every positive-duration sleep in the force tests with
deterministic synchronization: consume the fixture’s readiness signal before
sending signals, use explicit test-owned triggers to exercise the force policy,
and await an observable process-completion event before assertions. Update the
affected scenarios around the fixture setup and force/termination checks while
preserving their intended outcomes; do not use sleep-based timing, including the
sleep calls near lines 30–31, 40, 59, and 67.
- Line 3: Update process/test/force.test.ts to import test helpers from
`@effectionx/bdd`, add that dependency, and include the file in the Node.js test
command. Replace the wait-only sleep calls in the force tests around the
affected lifecycle assertions with deterministic process-state or lifecycle
synchronization, while preserving the delayed policy-action sleeps.

In `@worker/worker-force.test.ts`:
- Line 3: Update the worker test setup by adding `@effectionx/bdd` to
worker/package.json, replacing the describe/it import in worker-force.test.ts
with the package’s BDD import, and updating the Node.js test command to include
the worker tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: be1f774c-93c6-4495-ab05-c6b97505efba

📥 Commits

Reviewing files that changed from the base of the PR and between 92d8645 and 8c1921a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • forceable/README.md
  • forceable/forceable.test.ts
  • forceable/forceable.ts
  • forceable/package.json
  • process/README.md
  • process/package.json
  • process/src/exec/posix.ts
  • process/src/exec/types.ts
  • process/src/exec/win32.ts
  • process/test/fixtures/shutdown-resistant.ts
  • process/test/force.test.ts
  • process/tsconfig.json
  • worker/worker-force.test.ts
  • worker/worker.ts

Comment thread forceable/forceable.test.ts
Comment thread forceable/forceable.test.ts
Comment thread process/src/exec/posix.ts Outdated
Comment thread process/test/force.test.ts
Comment thread process/test/force.test.ts Outdated
Comment thread worker/worker-force.test.ts
exec()'s force implementation signalled before checking whether the
process had settled, so a late call could send SIGKILL to a process
group id the operating system was free to have reused. Track settlement
at every resolution site and return early, which is what the Forceable
contract already promised.

Also tighten the tests that were passing for the wrong reasons. The
cancellation test closed the resource gracefully without waiting for the
policy to suspend, so it could pass without cancelling anything. The
process tests slept to decide when the child was ready, which let
SIGTERM arrive before the fixture installed its handlers, and then the
child died of graceful shutdown rather than of forcing. Wait on the
fixture's own readiness line and on the process actually being reaped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant