tape-six-proc 1.2.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,11 @@ in its own subprocess instead, providing full process isolation. This prevents s
14
14
  leaks and is useful when tests need a clean environment. TypeScript test files (`.ts`)
15
15
  run natively on modern Node, Deno, and Bun — no transpilation needed.
16
16
 
17
+ A control channel lets the runner stop workers mid-run: `failOnce` (flag `O`) drains
18
+ the test files still in flight, and `TAPE6_WORKER_TIMEOUT` sets a per-worker time limit.
19
+ Both cancel cooperatively (cleanup hooks run) with a force-kill backstop after
20
+ `TAPE6_GRACE_TIMEOUT`. See [Stopping tests early](https://github.com/uhop/tape-six-proc/wiki/Utility-%E2%80%90-tape6-proc#stopping-tests-early).
21
+
17
22
  ## Install
18
23
 
19
24
  ```bash
@@ -88,6 +93,8 @@ LLM-friendly documentation is available:
88
93
 
89
94
  The most recent releases:
90
95
 
96
+ - 1.3.1 _Updated dependencies (includes a workaround for Bun's stream bug)._
97
+ - 1.3.0 _Implemented the terminate protocol. Updated dependencies._
91
98
  - 1.2.9 _Added `js-check`, Bun + Deno CI, tuned dependabot. Updated dependencies._
92
99
  - 1.2.8 _Fixed Bun stdout flush bug. Added GitHub Actions CI. Updated dependencies._
93
100
  - 1.2.7 _Added `--help` and `--version` flags. Updated dependencies._
package/llms-full.txt CHANGED
@@ -9,6 +9,7 @@
9
9
  - Same configuration format as `tape-six`
10
10
  - Parallel execution with configurable concurrency
11
11
  - TAP, TTY (colored), JSONL, and minimal output formats
12
+ - Worker control channel: `failOnce` stops in-flight workers, plus an optional per-worker time limit
12
13
 
13
14
  ## Install
14
15
 
@@ -115,7 +116,7 @@ Flags are a string of characters. Uppercase = enabled, lowercase = disabled.
115
116
  - `T` — show Time for each test.
116
117
  - `B` — show Banner with summary.
117
118
  - `D` — show Data of failed tests.
118
- - `O` — fail Once: stop at first failure.
119
+ - `O` — fail Once: stop at first failure. Via the control channel this also drains the workers still in flight (cleanup hooks run), not just the file queue.
119
120
  - `N` — show assert Number.
120
121
  - `M` — Monochrome: no colors.
121
122
  - `C` — don't Capture console output.
@@ -150,9 +151,12 @@ Environment-specific subsections (`node`, `deno`, `bun`) are supported. The runn
150
151
  - `TAPE6_TAP` — force TAP reporter (any non-empty value).
151
152
  - `TAPE6_JSONL` — force JSONL reporter (any non-empty value).
152
153
  - `TAPE6_MIN` — force minimal reporter (any non-empty value).
154
+ - `TAPE6_GRACE_TIMEOUT` — milliseconds a worker is given to drain (run cleanup hooks, flush) after a `terminate` before it is force-killed. Default 5000. Shared with `tape-six`.
155
+ - `TAPE6_WORKER_TIMEOUT` — per-worker wall-clock deadline in milliseconds; on expiry the worker is terminated (drain → kill). Default 0 (disabled). Shared with `tape-six`.
153
156
  - `TAPE6_TEST` — set by the runner: unique ID for each spawned test process.
154
157
  - `TAPE6_TEST_FILE_NAME` — set by the runner: file name of the current test.
155
158
  - `TAPE6_JSONL_PREFIX` — set by the runner: UUID prefix for JSONL lines in stdout.
159
+ - `TAPE6_CONTROL` — set by the runner: marks a spawned child as controlled, so the tape-six runtime opens the stdin control channel and stays alive after `end` until told to exit.
156
160
 
157
161
  ## Architecture
158
162
 
@@ -178,12 +182,12 @@ Delegates argument parsing, reporter setup, and file resolution to `tape-six/uti
178
182
  Extends `EventServer` from `tape-six`. Manages parallel subprocess execution:
179
183
 
180
184
  - **`constructor(reporter, numberOfTasks, options)`** — initializes with a reporter, concurrency limit, and options. Generates a UUID prefix for JSONL parsing.
181
- - **`makeTask(fileName)`** — spawns a child process for a test file using `dollar-shell`. Sets up stream pipelines for stdout and stderr. Returns a task ID.
182
- - **`destroyTask(id)`** — cleans up a completed task.
185
+ - **`makeTask(fileName)`** — spawns a child process for a test file using `dollar-shell` (`stdin: 'pipe'` so it can be controlled). Sets up stream pipelines for stdout and stderr, marks completion when the child's top-level `end` is read, and reports premature exits. Returns a task ID.
186
+ - **`destroyTask(id, reason)`** — the control-plane terminate. Writes a line-delimited `terminate` command to the child's stdin and EOFs it, then arms a `TAPE6_GRACE_TIMEOUT` deadline after which the child is force-killed (`worker.kill()`, SIGTERM). The base `EventServer` calls it with `reason` `'done'` (the worker finished — read its `end`), `'failOnce'` (a sibling tripped failOnce / bail), or `'timeout'` (the per-worker deadline). Idempotent per task.
183
187
 
184
188
  ### Stream pipeline
185
189
 
186
- Each spawned process has two stream pipelines:
190
+ Each spawned process has two data-plane stream pipelines (worker → reporter) plus a stdin control plane (reporter → worker, carrying `terminate`):
187
191
 
188
192
  **stdout pipeline:**
189
193
  ```
@@ -202,15 +206,16 @@ process.stderr → TextDecoderStream → lines → wrap-lines → report
202
206
 
203
207
  ### Process lifecycle
204
208
 
205
- 1. Process is spawned with environment variables: `TAPE6_FLAGS`, `TAPE6_TEST`, `TAPE6_TEST_FILE_NAME`, `TAPE6_JSONL=Y`, `TAPE6_JSONL_PREFIX`.
206
- 2. The test file runs and outputs JSONL-prefixed lines to stdout.
207
- 3. After the process exits, `TestWorker` checks exit code and signal.
208
- 4. Non-zero exit codes are reported as errors with a `{type: 'terminated'}` event.
209
- 5. The task is closed and the next queued test file starts.
209
+ 1. Process is spawned with environment variables: `TAPE6_FLAGS`, `TAPE6_TEST`, `TAPE6_TEST_FILE_NAME`, `TAPE6_JSONL=Y`, `TAPE6_JSONL_PREFIX`, `TAPE6_CONTROL=Y`, `TAPE6_GRACE_TIMEOUT`.
210
+ 2. The test file runs and outputs JSONL-prefixed lines to stdout. Marked by `TAPE6_CONTROL`, the tape-six runtime opens a stdin control channel and stays alive after emitting its top-level `end` — it no longer self-exits.
211
+ 3. **Normal completion** is keyed off the parent *reading* that top-level `end` (not racing the child's exit). The parent then issues `terminate` (drain + EOF stdin); the child exits and the next queued file starts. Because the child exits only after `end` has been consumed, the Bun stdout-flush bug (a dropped tail on self-exit) cannot occur.
212
+ 4. **Abort** (`failOnce` / bail, or a `TAPE6_WORKER_TIMEOUT` deadline) issues `terminate` to every in-flight worker. Each drains its running test through `reporter.terminate()` `t.signal` fires, `finally` / `afterEach` / `afterAll` run — then exits, emitting its `end` as usual.
213
+ 5. **Force-kill backstop.** A worker that does not exit within `TAPE6_GRACE_TIMEOUT` of a `terminate` (a test hung in a non-signal-aware `await`) is killed with `worker.kill()` (SIGTERM).
214
+ 6. A premature exit — the child closes stdout without a top-level `end` (crash, bad import, force-kill) — is reported as an error with a `{type: 'terminated'}` event, including the exit code / signal, unless a failure was already reported.
210
215
 
211
216
  ## Dependencies
212
217
 
213
- - **`tape-six`** — the core test library. Imports: `utils/config.js` (`getOptions`, `initFiles`, `initReporter`, `showInfo`, `printFlagOptions`), `test.js` (`getReporter`, `setReporter`), `utils/timer.js`, `State.js`, `utils/EventServer.js`, `utils/makeDeferred.js`.
218
+ - **`tape-six`** — the core test library. Imports: `utils/config.js` (`getOptions`, `initFiles`, `initReporter`, `showInfo`, `printFlagOptions`), `test.js` (`getReporter`, `setReporter`), `utils/timer.js`, `State.js`, `utils/EventServer.js`, `utils/makeDeferred.js`. The worker control channel additionally depends on tape-six's child-side listener (`src/utils/control-channel.js`, opened via `TAPE6_CONTROL`) and the `getGraceTimeout` / `getWorkerTimeout` config it injects through `getOptions`, so it requires a tape-six version that ships them.
214
219
  - **`dollar-shell`** — cross-runtime process spawning. Imports: `spawn`, `currentExecPath`, `runFileArgs`.
215
220
 
216
221
  ## Writing tests
package/llms.txt CHANGED
@@ -42,6 +42,7 @@ test('my test', async t => {
42
42
  - **Cross-runtime** — works with Node, Deno, and Bun using the same test files.
43
43
  - **TypeScript without transpilation** — runs `.ts` test files natively on modern Node, Deno, and Bun.
44
44
  - **Drop-in replacement** — uses the same configuration and test format as `tape6` (worker-thread runner).
45
+ - **Early termination** — a control channel lets `failOnce` (flag `O`) stop in-flight workers and supports a per-worker time limit (`TAPE6_WORKER_TIMEOUT`); both drain cleanly (cleanup hooks run) with a force-kill backstop after `TAPE6_GRACE_TIMEOUT`.
45
46
 
46
47
  ## CLI: tape6-proc
47
48
 
@@ -102,6 +103,8 @@ Environment-specific subsections: `node`, `deno`, `bun`.
102
103
  - `TAPE6_TAP` — force TAP reporter.
103
104
  - `TAPE6_JSONL` — force JSONL reporter.
104
105
  - `TAPE6_MIN` — force minimal reporter.
106
+ - `TAPE6_GRACE_TIMEOUT` — ms a worker may drain (cleanup, flush) after a `terminate` before it is force-killed. Default 5000.
107
+ - `TAPE6_WORKER_TIMEOUT` — per-worker wall-clock deadline in ms; on expiry the worker is terminated. Default 0 (off).
105
108
  - `TAPE6_TEST_FILE_NAME` — set by the runner: file name of the current test.
106
109
 
107
110
  ## Links
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tape-six-proc",
3
- "version": "1.2.9",
3
+ "version": "1.3.1",
4
4
  "description": "Process-isolated test runner for tape-six. Runs each test file in its own subprocess. Works with Node, Deno, and Bun. Supports TypeScript without transpilation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,8 +57,8 @@
57
57
  "llms-full.txt"
58
58
  ],
59
59
  "dependencies": {
60
- "dollar-shell": "^1.1.14",
61
- "tape-six": "^1.9.0"
60
+ "dollar-shell": "^1.2.1",
61
+ "tape-six": "^1.10.1"
62
62
  },
63
63
  "tape6": {
64
64
  "tests": [
@@ -66,7 +66,7 @@
66
66
  ]
67
67
  },
68
68
  "devDependencies": {
69
- "@types/node": "^25.6.0",
69
+ "@types/node": "^25.9.1",
70
70
  "typescript": "^6.0.3"
71
71
  }
72
72
  }
package/src/TestWorker.js CHANGED
@@ -15,6 +15,8 @@ import wrap from './streams/wrap-lines.js';
15
15
 
16
16
  const baseName = pathToFileURL(process.cwd() + sep);
17
17
 
18
+ const encoder = new TextEncoder();
19
+
18
20
  export default class TestWorker extends EventServer {
19
21
  constructor(reporter, numberOfTasks, options) {
20
22
  super(reporter, numberOfTasks, options);
@@ -29,7 +31,10 @@ export default class TestWorker extends EventServer {
29
31
  worker = spawn(
30
32
  [currentExecPath(), ...runFileArgs, ...self.options.runFileArgs, fileURLToPath(testName)],
31
33
  {
32
- stdin: 'ignore',
34
+ // stdin is the control channel: the parent writes a line-delimited
35
+ // `terminate` and ends the stream to drive the child's exit. See
36
+ // dev-docs/worker-control-channel.md (tape-six).
37
+ stdin: 'pipe',
33
38
  stdout: 'pipe',
34
39
  stderr: 'pipe',
35
40
  env: {
@@ -39,13 +44,19 @@ export default class TestWorker extends EventServer {
39
44
  TAPE6_TEST_FILE_NAME: fileName,
40
45
  TAPE6_JSONL: 'Y',
41
46
  TAPE6_JSONL_PREFIX: self.prefix,
47
+ TAPE6_CONTROL: 'Y',
48
+ TAPE6_GRACE_TIMEOUT: String(self.graceTimeout),
42
49
  TAPE6_MIN: '',
43
50
  TAPE6_TAP: '',
44
51
  TAPE6_TTY: ''
45
52
  }
46
53
  }
47
54
  );
48
- self.idToWorker[id] = worker;
55
+ // Per-task control-plane state. `endSeen` keys completion off *reading* the
56
+ // child's top-level `end` (not racing the child's own exit); `terminating`
57
+ // makes destroyTask idempotent; `graceTimer` is the force-kill backstop.
58
+ const task = {worker, endSeen: false, terminating: false, graceTimer: null};
59
+ self.idToWorker[id] = task;
49
60
  const stdoutDeferred = makeDeferred();
50
61
  worker.stdout
51
62
  .pipeThrough(new TextDecoderStream())
@@ -62,6 +73,13 @@ export default class TestWorker extends EventServer {
62
73
  throw error;
63
74
  }
64
75
  }
76
+ // Normal completion: the parent has consumed the top-level `end`.
77
+ // Tell the child to exit (drain + EOF its control channel) — this is
78
+ // the parent-driven exit that closes the Bun flush race.
79
+ if (msg && msg.type === 'end' && msg.test === 0) {
80
+ task.endSeen = true;
81
+ self.destroyTask(id, 'done');
82
+ }
65
83
  },
66
84
  close() {
67
85
  stdoutDeferred.resolve();
@@ -84,7 +102,13 @@ export default class TestWorker extends EventServer {
84
102
  })
85
103
  );
86
104
  Promise.allSettled([worker.exited, stdoutDeferred.promise, stderrDeferred.promise]).then(() => {
87
- if (!self.reporter.state || !self.reporter.state.failed) {
105
+ if (task.graceTimer) {
106
+ clearTimeout(task.graceTimer);
107
+ task.graceTimer = null;
108
+ }
109
+ // A premature exit (no top-level `end` read) is a crash or a force-killed
110
+ // hung child — surface it, unless a failure was already reported.
111
+ if (!task.endSeen && (!self.reporter.state || !self.reporter.state.failed)) {
88
112
  const reason = [];
89
113
  if (worker.exitCode) {
90
114
  reason.push(`exit code: ${worker.exitCode}`);
@@ -103,15 +127,41 @@ export default class TestWorker extends EventServer {
103
127
  self.report(id, {type: 'terminated', test: 0, name: 'FILE: /' + fileName});
104
128
  }
105
129
  }
130
+ delete self.idToWorker[id];
106
131
  self.close(id);
107
132
  });
108
133
  return id;
109
134
  }
110
- destroyTask(id) {
111
- const worker = this.idToWorker[id];
112
- if (worker) {
113
- // worker.kill();
114
- delete this.idToWorker[id];
135
+ // Deliver `terminate` to one child: write the line-delimited command, then
136
+ // EOF the control channel. The child drains a running test (reporter
137
+ // .terminate() — cleanup hooks run) and exits on its own; the graceTimeout
138
+ // backstop force-kills a test that won't drain. Idempotent per task.
139
+ destroyTask(id, reason = 'done') {
140
+ const task = this.idToWorker[id];
141
+ if (!task || task.terminating) return;
142
+ task.terminating = true;
143
+ this.#sendTerminate(task.worker, reason);
144
+ task.graceTimer = setTimeout(() => this.#kill(id), /** @type {*} */ (this).graceTimeout);
145
+ }
146
+ async #sendTerminate(worker, reason) {
147
+ const stdin = worker.stdin;
148
+ if (!stdin) return;
149
+ try {
150
+ const writer = stdin.getWriter();
151
+ await writer.write(encoder.encode(JSON.stringify({cmd: 'terminate', reason}) + '\n'));
152
+ await writer.close();
153
+ } catch (e) {
154
+ void e; // child already gone / pipe closed
155
+ }
156
+ }
157
+ #kill(id) {
158
+ const task = this.idToWorker[id];
159
+ if (!task) return;
160
+ task.graceTimer = null;
161
+ try {
162
+ task.worker.kill();
163
+ } catch (e) {
164
+ void e;
115
165
  }
116
166
  }
117
167
  }