@deepseek-ai/dsh-subprocess-local 0.1.6-alpha.2 → 0.1.7-alpha.2

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
5
- README.md: 24480565de2f324a451dfdb40101eb50703b30b6
6
- README.zh.md: c86a7eb930834d960fa25dd7b5d175af63d0b31a
5
+ README.md: 0e5423aecd46a18cde79437ca5707be5ee58d59b
6
+ README.zh.md: 32a1b937e5768ad87d72914fb6c8a90a33c5899f
package/README.md CHANGED
@@ -44,7 +44,7 @@ Windows ordinary subprocesses start the private Job runner with `windowsHide` an
44
44
 
45
45
  ### Collecting output
46
46
 
47
- Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
47
+ Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Spilling is best-effort: when the spill file cannot be opened or appended (the per-process directory removed by a temporary-file cleaner, `EACCES`, `EMFILE`, `ENOSPC`), the collector discards the spill, logs one `error` through the plugin logger, and keeps collecting the in-memory tail, so the result is truncated with no spill path. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
48
48
 
49
49
  The `./output` export shares this collector and retained-spill storage with process adapters. `snapshot()` returns the retained raw bytes and total byte count, allowing remote adapters to preserve offsets without forwarding the complete stream.
50
50
 
@@ -109,7 +109,7 @@ A spawn synchronously validates the final argv, cwd, and environment, selects co
109
109
 
110
110
  ### Safety invariants
111
111
 
112
- Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
112
+ Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed open, append, or final close withholds the spill path and never interrupts collection, because collection runs inside the stream's `'data'` listener where a thrown error would kill the host process. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
113
113
 
114
114
  </details>
115
115
 
@@ -155,6 +155,7 @@ These limits define when the provider is a poor fit or needs special operational
155
155
  - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
156
156
  - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
157
157
  - **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file.
158
+ - **A removed spill directory is not recreated** — the private per-process directory is created once; after an external cleaner removes it, every later spill in that process degrades to the in-memory tail with an `error` log until the host restarts. Recreating a fresh random directory on `ENOENT` is deferred work.
158
159
 
159
160
  <a id="dev-note"></a>
160
161
  ### Dev Note
package/README.zh.md CHANGED
@@ -44,7 +44,7 @@ Windows 普通子进程通过 `windowsHide` 启动私有 Job runner,并为原
44
44
 
45
45
  ### 收集输出
46
46
 
47
- 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
47
+ 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。spill 是尽力而为:当 spill 文件无法打开或追加(每进程目录被临时文件清理工具删除、`EACCES`、`EMFILE`、`ENOSPC`)时,收集器丢弃该 spill,通过插件 logger 记录一条 `error`,并继续收集内存尾部,因此结果带截断标记且没有 spill 路径。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
48
48
 
49
49
  `./output` 导出向进程适配器共享该收集器与保留 spill 的存储。`snapshot()` 返回保留的原始字节及总字节数,使远程适配器能够保留偏移量,而无需转发完整的流。
50
50
 
@@ -109,7 +109,7 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取
109
109
 
110
110
  ### 安全不变式
111
111
 
112
- spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
112
+ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;打开、追加或最终关闭失败时不公布 spill 路径,且绝不中断收集,因为收集运行在流的 `'data'` 监听器内,抛出的错误会杀死宿主进程。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
113
113
 
114
114
  </details>
115
115
 
@@ -155,6 +155,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
155
155
  - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS owner 负责。
156
156
  - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
157
157
  - **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。
158
+ - **被删除的 spill 目录不会重建**——每进程私有目录只创建一次;被外部清理工具删除后,该进程内之后的每次 spill 都降级为内存尾部并记录一条 `error`,直到宿主重启。在 `ENOENT` 时重新创建一个新的随机目录是待办工作。
158
159
 
159
160
  <a id="dev-note"></a>
160
161
  ### 开发备注
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as bindManagedProcess, D as createProcessInspector, E as validateSubprocessSpec, O as controlPipe, S as loadLinuxExecve, T as spawnSubprocess, _ as parseWindowsRunnerResult, c as runnerStdio, d as cleanupLinuxLaunchFiles, l as spawnRunnerInvocation, m as deserializeRunnerError, n as WINDOWS_RUNNER_SELECTION, o as runnerEnvironment, p as createLinuxLaunchFiles, s as runnerInvocationAvailable, u as targetEnvironment, w as childEnv, y as readLinuxStartupError } from "./runner-launch-DGV26RBf.js";
2
- import { prepareManagedProcessBinding } from "./output.js";
1
+ import { C as bindManagedProcess, D as createProcessInspector, E as validateSubprocessSpec, O as controlPipe, S as loadLinuxExecve, T as spawnSubprocess, _ as parseWindowsRunnerResult, c as runnerStdio, d as cleanupLinuxLaunchFiles, l as spawnRunnerInvocation, m as deserializeRunnerError, n as WINDOWS_RUNNER_SELECTION, o as runnerEnvironment, p as createLinuxLaunchFiles, s as runnerInvocationAvailable, u as targetEnvironment, w as childEnv, y as readLinuxStartupError } from "./runner-launch-B2zsQ1Dz.js";
2
+ import { logSpillFailure, prepareManagedProcessBinding } from "./output.js";
3
3
  import { closeSync, constants, existsSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { access, stat } from "node:fs/promises";
5
5
  import { constants as constants$1, devNull, tmpdir, userInfo } from "node:os";
@@ -1294,6 +1294,8 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
1294
1294
  };
1295
1295
  }, "local subprocess teardown");
1296
1296
  }
1297
+ /** Spill failures reach the plugin logger; the log line is the only trace of why a result has no spill path. */
1298
+ reportSpillFailure = logSpillFailure(this.ctx.logger, "subprocess-local");
1297
1299
  terminateForHostExit() {
1298
1300
  for (const handle of this.live) try {
1299
1301
  handle.terminateForHostExit();
@@ -1356,9 +1358,13 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
1356
1358
  const env = targetEnvironment(spec);
1357
1359
  const containmentMode = this.selectContainmentMode("ordinary");
1358
1360
  let handle;
1359
- if (containmentMode === "fallback") handle = spawnSubprocess(spec, this.internals);
1361
+ const internals = {
1362
+ ...this.internals,
1363
+ onSpillFailure: this.reportSpillFailure
1364
+ };
1365
+ if (containmentMode === "fallback") handle = spawnSubprocess(spec, internals);
1360
1366
  else {
1361
- const binding = prepareManagedProcessBinding(this.internals);
1367
+ const binding = prepareManagedProcessBinding(internals);
1362
1368
  handle = bindManagedProcess(spec, containmentMode === "linux-scope" ? launchLinuxScope(spec, env) : launchWindowsJob(spec, env), binding);
1363
1369
  }
1364
1370
  this.live.add(handle);
package/lib/output.js CHANGED
@@ -9,10 +9,13 @@ let defaultSpillDir;
9
9
  /**
10
10
  * The default spill location: a private (0700) per-process directory under
11
11
  * the OS tmpdir, created lazily. Predictable world-readable paths would let
12
- * other local users read command output or pre-create symlinks. At a
13
- * JavaScript-observable process exit the directory is removed only when it
14
- * holds no completed spill file (spill files are retained as full-output
15
- * recovery artifacts until an external cleanup).
12
+ * other local users read command output or pre-create symlinks. The directory
13
+ * is created once per process and never recreated: when an external
14
+ * temporary-file cleaner removes it while empty, the next spill open fails and
15
+ * the collector degrades to its in-memory tail. At a JavaScript-observable
16
+ * process exit the directory is removed only when it holds no completed spill
17
+ * file (spill files are retained as full-output recovery artifacts until an
18
+ * external cleanup).
16
19
  */
17
20
  function privateSpillDir() {
18
21
  defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
@@ -26,28 +29,62 @@ process.once("exit", () => {
26
29
  } catch {}
27
30
  });
28
31
  /**
32
+ * The stderr reporter used when no owner supplies one: a bare
33
+ * {@link prepareManagedProcessBinding} caller has no plugin logger, and the
34
+ * failure must still reach the process diagnostics.
35
+ * @param error - the spill failure.
36
+ * @param label - the failed stream label.
37
+ */
38
+ function reportSpillFailureToStderr(error, label) {
39
+ process.stderr.write(`dsh-subprocess-local: ${label} spill failed; only the in-memory tail is retained: ${String(error)}\n`);
40
+ }
41
+ /**
42
+ * Build the reporter an owner passes as {@link SpillOptions.onFailure}: one
43
+ * error-level log line naming the owner and stream, with the failure appended
44
+ * so its `code`, `syscall`, and `path` reach the log.
45
+ * @param logger - the owner's plugin logger.
46
+ * @param owner - the component named in the line.
47
+ * @returns the reporter.
48
+ */
49
+ function logSpillFailure(logger, owner) {
50
+ return (error, label) => {
51
+ const removedDirectory = error.code === "ENOENT";
52
+ logger.error(`${owner} could not write the complete ${label} stream to its spill file; the result keeps only the in-memory tail and reports no full-output path.` + (removedDirectory ? " The spill directory no longer exists; a temporary-file cleaner removing it while empty is the usual cause." : ""), error);
53
+ };
54
+ }
55
+ /**
29
56
  * Prepare fallible output storage before starting a managed native process.
30
- * @param internals - optional caller-owned spill directory.
57
+ * This is the explicit resolve step for spill inputs: the spill directory
58
+ * defaults to the private per-process directory, and the failure reporter
59
+ * defaults to a stderr line when the caller has no logger.
60
+ * @param internals - optional caller-owned spill directory and failure reporter.
31
61
  * @returns binding inputs whose spill directory is ready for use.
32
62
  */
33
63
  function prepareManagedProcessBinding(internals = {}) {
34
- return { spillDir: internals.spillDir ?? privateSpillDir() };
64
+ return {
65
+ spillDir: internals.spillDir ?? privateSpillDir(),
66
+ onSpillFailure: internals.onSpillFailure ?? reportSpillFailureToStderr
67
+ };
35
68
  }
36
69
  /**
37
- * Collects one stream with a bounded in-memory tail. With a spill cap, on
70
+ * Collects one stream with a bounded in-memory tail. With spill options, on
38
71
  * first overflow a spill file is created and every chunk (including those
39
72
  * already collected) is appended there while the full stream remains within
40
- * the cap; without one, only the in-memory tail is ever retained (the
73
+ * the cap; without them, only the in-memory tail is ever retained (the
41
74
  * diagnostic-tail shape — a language server's stderr).
42
75
  *
76
+ * Spilling is best-effort: a spill open or write failure discards the spill,
77
+ * reports once through {@link SpillOptions.onFailure}, and never interrupts
78
+ * in-memory collection, because `push()` runs inside the stream's `'data'`
79
+ * listener where a thrown error would become an uncaught exception.
80
+ *
43
81
  * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
44
82
  * end of command output; the spill file covers the head.
45
83
  */
46
84
  var OutputCollector = class {
47
85
  maxBytes;
48
- maxSpillBytes;
49
86
  label;
50
- spillDir;
87
+ spill;
51
88
  chunks = [];
52
89
  bytes = 0;
53
90
  dropped = false;
@@ -56,12 +93,16 @@ var OutputCollector = class {
56
93
  spillDisabled;
57
94
  /** Total bytes ever pushed (not just retained). */
58
95
  total = 0;
59
- constructor(maxBytes, maxSpillBytes, label, spillDir) {
96
+ /**
97
+ * @param maxBytes - in-memory tail cap in bytes.
98
+ * @param label - stream label used in spill file names and failure reports.
99
+ * @param spill - spill storage; omit for tail-only collection.
100
+ */
101
+ constructor(maxBytes, label, spill) {
60
102
  this.maxBytes = maxBytes;
61
- this.maxSpillBytes = maxSpillBytes;
62
103
  this.label = label;
63
- this.spillDir = spillDir;
64
- this.spillDisabled = maxSpillBytes === void 0;
104
+ this.spill = spill;
105
+ this.spillDisabled = spill === void 0;
65
106
  }
66
107
  /**
67
108
  * Ingest one stream chunk, counting it toward the whole-stream total. On
@@ -74,7 +115,8 @@ var OutputCollector = class {
74
115
  push(chunk) {
75
116
  this.total += chunk.length;
76
117
  const overflows = this.bytes + chunk.length > this.maxBytes;
77
- if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
118
+ const spill = this.spill;
119
+ if (spill !== void 0 && !this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(spill, chunk);
78
120
  this.chunks.push(chunk);
79
121
  this.bytes += chunk.length;
80
122
  while (this.bytes > this.maxBytes) {
@@ -90,18 +132,34 @@ var OutputCollector = class {
90
132
  this.dropped = true;
91
133
  }
92
134
  }
93
- /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
94
- spillAll(chunk) {
95
- if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
135
+ /**
136
+ * Open the spill file lazily and append `chunk` (and any prior chunks once).
137
+ * Runs inside the stream's `'data'` listener, so every filesystem failure is
138
+ * contained here: the spill is discarded, reported once, and collection
139
+ * continues with the in-memory tail alone.
140
+ */
141
+ spillAll(spill, chunk) {
142
+ if (this.total > spill.maxBytes) {
96
143
  this.discardSpill();
97
144
  return;
98
145
  }
99
- if (this.spillFd === void 0) {
100
- this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
101
- this.spillFd = openSync(this.spillFile, "wx", 384);
102
- for (const prior of this.chunks) writeSync(this.spillFd, prior);
146
+ try {
147
+ if (this.spillFd === void 0) {
148
+ const file = join(spill.dir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
149
+ const fd = openSync(file, "wx", 384);
150
+ this.spillFile = file;
151
+ this.spillFd = fd;
152
+ for (const prior of this.chunks) writeSync(fd, prior);
153
+ }
154
+ writeSync(this.spillFd, chunk);
155
+ } catch (error) {
156
+ this.discardSpill();
157
+ try {
158
+ spill.onFailure(error, this.label);
159
+ } catch (reporterFailure) {
160
+ process.stderr.write(`dsh-subprocess-local: spill failure reporter threw: ${String(reporterFailure)}\n`);
161
+ }
103
162
  }
104
- writeSync(this.spillFd, chunk);
105
163
  }
106
164
  /** Stop spilling and remove the file once it can no longer hold the complete stream. */
107
165
  discardSpill() {
@@ -178,4 +236,4 @@ var OutputCollector = class {
178
236
  }
179
237
  };
180
238
  //#endregion
181
- export { OutputCollector, prepareManagedProcessBinding };
239
+ export { OutputCollector, logSpillFailure, prepareManagedProcessBinding };
@@ -846,11 +846,11 @@ function fallbackOwner(platform, pid, child, taskkill, linuxGroupHasLiveMembers,
846
846
  * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle.
847
847
  * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
848
848
  * @param launch - platform streams, direct outcome, and managed-range owner.
849
- * @param internals - test-only spill-directory override.
849
+ * @param internals - spill-directory override and spill failure reporter.
850
850
  * @returns live subprocess handle.
851
851
  */
852
852
  function bindManagedProcess(spec, launch, internals = {}) {
853
- const { spillDir } = prepareManagedProcessBinding(internals);
853
+ const { spillDir, onSpillFailure } = prepareManagedProcessBinding(internals);
854
854
  const { stdin, stdout, stderr } = launch;
855
855
  const isCollect = (mode) => mode !== "pipe" && mode !== "inherit";
856
856
  const outMode = spec.stdio.stdout;
@@ -858,7 +858,11 @@ function bindManagedProcess(spec, launch, internals = {}) {
858
858
  const stdinMode = spec.stdio.stdin;
859
859
  const collectStream = (mode, stream, label) => {
860
860
  if (!isCollect(mode) || stream === null) return void 0;
861
- const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir);
861
+ const collector = new OutputCollector(mode.maxBytes, label, mode.spill === void 0 ? void 0 : {
862
+ maxBytes: mode.spill.maxBytes,
863
+ dir: spillDir,
864
+ onFailure: onSpillFailure
865
+ });
862
866
  stream.on("data", (chunk) => {
863
867
  collector.push(chunk);
864
868
  });
package/lib/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-DGV26RBf.js";
1
+ import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-B2zsQ1Dz.js";
2
2
  import { closeSync } from "node:fs";
3
3
  import { SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
4
4
  import { Win32Error, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, pollProcessExit, spawnCurrentTokenJobProcess, terminateJob } from "@deepseek-ai/dsh-win32-process";
@@ -35,6 +35,8 @@ export declare class LocalSubprocessRuntime extends SubprocessRuntime {
35
35
  /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
36
36
  terminalInspector: ProcessInspector | undefined;
37
37
  constructor(ctx: Context);
38
+ /** Spill failures reach the plugin logger; the log line is the only trace of why a result has no spill path. */
39
+ private readonly reportSpillFailure;
38
40
  private terminateForHostExit;
39
41
  private disposeManagedProcesses;
40
42
  resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string>;
@@ -1,29 +1,71 @@
1
1
  import type { CollectedOutput } from '@deepseek-ai/dsh-subprocess';
2
+ /**
3
+ * Receives one spill failure so the owner can log it through its own logger.
4
+ * Called at most once per collector, after the spill has been discarded and
5
+ * the in-memory tail has kept collecting. A reporter that throws is contained
6
+ * and its failure written to stderr.
7
+ * @param error - the `node:fs` failure from opening or appending the spill file.
8
+ * @param label - the stream label of the collector that failed.
9
+ */
10
+ export type SpillFailureReporter = (error: unknown, label: string) => void;
11
+ /** Spill storage for one collected stream; `undefined` means tail-only collection. */
12
+ export interface SpillOptions {
13
+ /** Whole-stream byte cap beyond which an incomplete spill is discarded. */
14
+ maxBytes: number;
15
+ /** Private directory receiving the spill file. */
16
+ dir: string;
17
+ /** Owner-side report of a spill open or write failure. */
18
+ onFailure: SpillFailureReporter;
19
+ }
20
+ /**
21
+ * Build the reporter an owner passes as {@link SpillOptions.onFailure}: one
22
+ * error-level log line naming the owner and stream, with the failure appended
23
+ * so its `code`, `syscall`, and `path` reach the log.
24
+ * @param logger - the owner's plugin logger.
25
+ * @param owner - the component named in the line.
26
+ * @returns the reporter.
27
+ */
28
+ export declare function logSpillFailure(logger: {
29
+ error(message: string, ...detail: unknown[]): void;
30
+ }, owner: string): SpillFailureReporter;
31
+ /** Inputs a managed native process needs before its output streams are bound. */
32
+ export interface ManagedProcessBinding {
33
+ /** Directory receiving spill files. */
34
+ spillDir: string;
35
+ /** Receives a spill open or write failure. */
36
+ onSpillFailure: SpillFailureReporter;
37
+ }
2
38
  /**
3
39
  * Prepare fallible output storage before starting a managed native process.
4
- * @param internals - optional caller-owned spill directory.
40
+ * This is the explicit resolve step for spill inputs: the spill directory
41
+ * defaults to the private per-process directory, and the failure reporter
42
+ * defaults to a stderr line when the caller has no logger.
43
+ * @param internals - optional caller-owned spill directory and failure reporter.
5
44
  * @returns binding inputs whose spill directory is ready for use.
6
45
  */
7
46
  export declare function prepareManagedProcessBinding(internals?: {
8
47
  spillDir?: string;
9
- }): {
10
- spillDir: string;
11
- };
48
+ onSpillFailure?: SpillFailureReporter;
49
+ }): ManagedProcessBinding;
12
50
  /**
13
- * Collects one stream with a bounded in-memory tail. With a spill cap, on
51
+ * Collects one stream with a bounded in-memory tail. With spill options, on
14
52
  * first overflow a spill file is created and every chunk (including those
15
53
  * already collected) is appended there while the full stream remains within
16
- * the cap; without one, only the in-memory tail is ever retained (the
54
+ * the cap; without them, only the in-memory tail is ever retained (the
17
55
  * diagnostic-tail shape — a language server's stderr).
18
56
  *
57
+ * Spilling is best-effort: a spill open or write failure discards the spill,
58
+ * reports once through {@link SpillOptions.onFailure}, and never interrupts
59
+ * in-memory collection, because `push()` runs inside the stream's `'data'`
60
+ * listener where a thrown error would become an uncaught exception.
61
+ *
19
62
  * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
20
63
  * end of command output; the spill file covers the head.
21
64
  */
22
65
  export declare class OutputCollector {
23
66
  private readonly maxBytes;
24
- private readonly maxSpillBytes;
25
67
  private readonly label;
26
- private readonly spillDir;
68
+ private readonly spill;
27
69
  private chunks;
28
70
  private bytes;
29
71
  private dropped;
@@ -32,7 +74,12 @@ export declare class OutputCollector {
32
74
  private spillDisabled;
33
75
  /** Total bytes ever pushed (not just retained). */
34
76
  private total;
35
- constructor(maxBytes: number, maxSpillBytes: number | undefined, label: string, spillDir: string);
77
+ /**
78
+ * @param maxBytes - in-memory tail cap in bytes.
79
+ * @param label - stream label used in spill file names and failure reports.
80
+ * @param spill - spill storage; omit for tail-only collection.
81
+ */
82
+ constructor(maxBytes: number, label: string, spill: SpillOptions | undefined);
36
83
  /**
37
84
  * Ingest one stream chunk, counting it toward the whole-stream total. On
38
85
  * first overflow of the in-memory cap a spill file is opened (when spilling
@@ -42,7 +89,12 @@ export declare class OutputCollector {
42
89
  * @param chunk - the raw bytes from one stream 'data' event.
43
90
  */
44
91
  push(chunk: Buffer): void;
45
- /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
92
+ /**
93
+ * Open the spill file lazily and append `chunk` (and any prior chunks once).
94
+ * Runs inside the stream's `'data'` listener, so every filesystem failure is
95
+ * contained here: the spill is discarded, reported once, and collection
96
+ * continues with the in-memory tail alone.
97
+ */
46
98
  private spillAll;
47
99
  /** Stop spilling and remove the file once it can no longer hold the complete stream. */
48
100
  private discardSpill;
@@ -10,6 +10,7 @@
10
10
  import { type ChildProcess, type SpawnOptions } from 'node:child_process';
11
11
  import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
12
12
  import type { ManagedProcessLaunch } from './managed-owner.ts';
13
+ import { type SpillFailureReporter } from './output.ts';
13
14
  type SpawnProcess = (program: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
14
15
  /**
15
16
  * Build a child environment: explicit caller entries override the scrubbed
@@ -26,6 +27,8 @@ export interface SpawnInternals {
26
27
  spawn?: SpawnProcess;
27
28
  /** Directory for spill files (defaults to the OS temp dir). */
28
29
  spillDir?: string;
30
+ /** Receives spill open/write failures; the runtime supplies its plugin logger, bare callers get a stderr line. */
31
+ onSpillFailure?: SpillFailureReporter;
29
32
  /** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
30
33
  taskkill?: (pid: number) => void;
31
34
  /** Host platform override for signalling decisions. */
@@ -68,10 +71,10 @@ export declare function validateSubprocessSpec(spec: SubprocessSpawnSpec): void;
68
71
  * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle.
69
72
  * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
70
73
  * @param launch - platform streams, direct outcome, and managed-range owner.
71
- * @param internals - test-only spill-directory override.
74
+ * @param internals - spill-directory override and spill failure reporter.
72
75
  * @returns live subprocess handle.
73
76
  */
74
- export declare function bindManagedProcess(spec: SubprocessSpawnSpec, launch: ManagedProcessLaunch, internals?: Pick<SpawnInternals, 'spillDir'>): LocalSubprocessHandle;
77
+ export declare function bindManagedProcess(spec: SubprocessSpawnSpec, launch: ManagedProcessLaunch, internals?: Pick<SpawnInternals, 'spillDir' | 'onSpillFailure'>): LocalSubprocessHandle;
75
78
  /**
76
79
  * Spawn one detached PGID/taskkill fallback and bind the common lifecycle.
77
80
  * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-subprocess-local",
3
3
  "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -39,21 +39,21 @@
39
39
  ],
40
40
  "license": "MIT",
41
41
  "peerDependencies": {
42
- "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.2",
43
- "@deepseek-ai/cordis": "^4.0.2",
44
- "@deepseek-ai/dsh-timeout": "^0.1.6-alpha.2"
42
+ "@deepseek-ai/dsh-timeout": "0.1.7-alpha.2",
43
+ "@deepseek-ai/cordis": "~4.0.4",
44
+ "@deepseek-ai/dsh-subprocess": "0.1.7-alpha.2"
45
45
  },
46
46
  "dependencies": {
47
47
  "koffi": "^3.1.0",
48
48
  "node-pty": "1.2.0-beta.15",
49
- "@deepseek-ai/dsh-lazy-require": "^0.1.6-alpha.2",
50
- "@deepseek-ai/dsh-win32-process": "^0.1.6-alpha.2"
49
+ "@deepseek-ai/dsh-win32-process": "0.1.7-alpha.2",
50
+ "@deepseek-ai/dsh-lazy-require": "0.1.7-alpha.2"
51
51
  },
52
52
  "devDependencies": {
53
- "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-loader-smoke": "^0.1.6-alpha.2",
55
- "@deepseek-ai/dsh-timeout": "^0.1.6-alpha.2",
56
- "@deepseek-ai/cordis": "^4.0.2"
53
+ "@deepseek-ai/dsh-loader-smoke": "0.1.7-alpha.2",
54
+ "@deepseek-ai/dsh-subprocess": "0.1.7-alpha.2",
55
+ "@deepseek-ai/dsh-timeout": "0.1.7-alpha.2",
56
+ "@deepseek-ai/cordis": "~4.0.4"
57
57
  },
58
58
  "scripts": {
59
59
  "postinstall": "node scripts/ensure-spawn-helper.mjs"