@opencode-ai/util 0.0.0-beta-18707 → 0.0.0-beta-18721

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.
@@ -192,19 +192,23 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
192
192
  return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink);
193
193
  return Effect.succeed(sink);
194
194
  });
195
- const setupOutput = (command, proc, out, err) => {
196
- let stdout = proc.stdout
197
- ? NodeStream.fromReadable({
198
- evaluate: () => proc.stdout,
199
- onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
200
- })
201
- : Stream.empty;
202
- let stderr = proc.stderr
203
- ? NodeStream.fromReadable({
204
- evaluate: () => proc.stderr,
205
- onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
206
- })
207
- : Stream.empty;
195
+ const setupOutput = (command, proc, out, err, stopOutput) => {
196
+ const capture = (readable, name) => {
197
+ if (!readable)
198
+ return Stream.empty;
199
+ return NodeStream.fromReadable({
200
+ evaluate: () => readable,
201
+ onError: (cause) => toPlatformError(`fromReadable(${name})`, toError(cause), command),
202
+ closeOnDone: false,
203
+ }).pipe(Stream.interruptWhen(Deferred.await(stopOutput)), Stream.ensuring(Effect.gen(function* () {
204
+ // Only the capture deadline transfers the reader back to the process scope.
205
+ if (yield* Deferred.isDone(stopOutput))
206
+ return;
207
+ readable.destroy();
208
+ })));
209
+ };
210
+ let stdout = capture(proc.stdout, "stdout");
211
+ let stderr = capture(proc.stderr, "stderr");
208
212
  if (Sink.isSink(out.stream))
209
213
  stdout = Stream.transduce(stdout, out.stream);
210
214
  if (Sink.isSink(err.stream))
@@ -212,7 +216,8 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
212
216
  return { stdout, stderr, all: Stream.merge(stdout, stderr) };
213
217
  };
214
218
  const launchProcess = (command, opts) => Effect.callback((resume) => {
215
- const signal = Deferred.makeUnsafe();
219
+ const closed = Deferred.makeUnsafe();
220
+ const exited = Deferred.makeUnsafe();
216
221
  const proc = launch(command.command, command.args, opts);
217
222
  let end = false;
218
223
  let exit;
@@ -221,15 +226,17 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
221
226
  });
222
227
  proc.on("exit", (...args) => {
223
228
  exit = args;
229
+ Deferred.doneUnsafe(exited, Exit.succeed(args));
224
230
  });
225
231
  proc.on("close", (...args) => {
226
232
  if (end)
227
233
  return;
228
234
  end = true;
229
- Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args));
235
+ Deferred.doneUnsafe(exited, Exit.succeed(exit ?? args));
236
+ Deferred.doneUnsafe(closed, Exit.succeed(exit ?? args));
230
237
  });
231
238
  proc.on("spawn", () => {
232
- resume(Effect.succeed([proc, signal]));
239
+ resume(Effect.succeed([proc, closed, exited]));
233
240
  });
234
241
  return Effect.sync(() => {
235
242
  proc.kill("SIGTERM");
@@ -237,8 +244,35 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
237
244
  });
238
245
  const spawn = Effect.fnUntraced(function* (command, opts) {
239
246
  yield* Effect.logInfo("spawning process", { command: command.command, args: command.args, cwd: opts.cwd });
240
- return yield* launchProcess(command, opts);
247
+ const [proc, closed, exited] = yield* launchProcess(command, opts);
248
+ const stopOutput = yield* Deferred.make();
249
+ // Register before process release so the deadline remains active during termination.
250
+ yield* Effect.forkScoped(Effect.gen(function* () {
251
+ yield* Deferred.await(exited);
252
+ // Particularly on Windows, some shell calls will cause detached descendants. Inherited stdio can hang the call.
253
+ // Almost every ecosystem has tried to fix this; there's no single "good" answer here.
254
+ // Calls that trigger this are e.g. dotnet build with warmed MSBuild processes, agent-browser, etc.
255
+ // One shared deadline covers stdout and stderr, then the output pump finishes its file.
256
+ if ((yield* Effect.timeoutOption(Deferred.await(closed), "1 second"))._tag === "Some")
257
+ return;
258
+ yield* Deferred.succeed(stopOutput, undefined);
259
+ discard(proc.stdout);
260
+ discard(proc.stderr);
261
+ }));
262
+ return [proc, closed, exited, stopOutput];
241
263
  });
264
+ const discard = (readable) => {
265
+ if (!readable || readable.destroyed)
266
+ return;
267
+ // read() also drains while a backpressured Effect adapter still has a readable listener.
268
+ const drain = () => {
269
+ while (readable.read() !== null) { }
270
+ };
271
+ readable.on("readable", drain);
272
+ readable.once("error", () => { });
273
+ readable.once("close", () => readable.off("readable", drain));
274
+ drain();
275
+ };
242
276
  const killGroup = (command, proc, signal) => {
243
277
  if (globalThis.process.platform === "win32") {
244
278
  return Effect.callback((resume) => {
@@ -261,23 +295,16 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
261
295
  return Effect.void;
262
296
  return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command));
263
297
  });
264
- const stop = (command, proc, exit, opts) => {
265
- const send = (signal) => Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal));
266
- const attempt = send(opts?.killSignal ?? "SIGTERM").pipe(Effect.andThen(Deferred.await(exit)), Effect.asVoid);
267
- if (!opts?.forceKillAfter)
298
+ const stop = (command, proc, closed, stopOutput, opts) => {
299
+ const terminate = (signal) => Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)).pipe(Effect.andThen(signal === "SIGKILL"
300
+ ? Effect.raceFirst(Deferred.await(closed), Deferred.await(stopOutput))
301
+ : Deferred.await(closed)), Effect.asVoid);
302
+ const attempt = terminate(opts?.killSignal ?? "SIGTERM");
303
+ if (opts?.forceKillAfter === undefined)
268
304
  return attempt;
269
305
  return Effect.timeoutOrElse(attempt, {
270
306
  duration: opts.forceKillAfter,
271
- orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(exit)), Effect.asVoid),
272
- });
273
- };
274
- const timeout = (proc, command, opts) => (f) => {
275
- const signal = opts?.killSignal ?? "SIGTERM";
276
- if (Predicate.isUndefined(opts?.forceKillAfter))
277
- return f(command, proc, signal);
278
- return Effect.timeoutOrElse(f(command, proc, signal), {
279
- duration: opts.forceKillAfter,
280
- orElse: () => f(command, proc, "SIGKILL"),
307
+ orElse: () => terminate("SIGKILL"),
281
308
  });
282
309
  };
283
310
  const source = (handle, from) => {
@@ -303,28 +330,34 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
303
330
  const serr = stdio(command.options, "stderr");
304
331
  const extra = fds(command.options);
305
332
  const dir = yield* cwd(command.options);
306
- const [proc, signal] = yield* Effect.acquireRelease(spawn(command, {
333
+ const [proc, closed, exited, stopOutput] = yield* Effect.acquireRelease(spawn(command, {
307
334
  cwd: dir,
308
335
  env: env(command.options),
309
336
  stdio: stdios(sin, sout, serr, extra),
310
337
  detached: command.options.detached ?? process.platform !== "win32",
311
338
  shell: command.options.shell,
312
339
  windowsHide: process.platform === "win32",
313
- }), Effect.fnUntraced(function* ([proc, signal]) {
314
- const done = yield* Deferred.isDone(signal);
315
- const kill = timeout(proc, command, command.options);
340
+ }), Effect.fnUntraced(function* ([proc, closed, exited, stopOutput]) {
341
+ const done = (yield* Deferred.isDone(closed)) || (yield* Deferred.isDone(stopOutput));
316
342
  if (done) {
317
- const [code] = yield* Deferred.await(signal);
343
+ const [code] = yield* Deferred.await(exited);
318
344
  if (process.platform === "win32")
319
- return yield* Effect.void;
320
- if (code !== 0 && Predicate.isNotNull(code))
321
- return yield* Effect.ignore(kill(killGroup));
322
- return yield* Effect.void;
345
+ return;
346
+ if (code === 0 || Predicate.isNull(code))
347
+ return;
348
+ if (command.options.forceKillAfter === undefined) {
349
+ return yield* Effect.ignore(killGroup(command, proc, command.options.killSignal ?? "SIGTERM"));
350
+ }
323
351
  }
324
- return yield* Effect.ignore(stop(command, proc, signal, command.options));
325
- }));
352
+ yield* Effect.ignore(stop(command, proc, closed, stopOutput, command.options));
353
+ }, (effect, [proc, , , stopOutput]) => effect.pipe(Effect.ensuring(Effect.gen(function* () {
354
+ yield* Deferred.succeed(stopOutput, undefined);
355
+ proc.stdout?.destroy();
356
+ proc.stderr?.destroy();
357
+ })))));
358
+ const completion = Effect.raceFirst(Deferred.await(closed), Deferred.await(stopOutput).pipe(Effect.andThen(Deferred.await(exited))));
326
359
  const fd = yield* setupFds(command, proc, extra);
327
- const out = setupOutput(command, proc, sout, serr);
360
+ const out = setupOutput(command, proc, sout, serr, stopOutput);
328
361
  let ref = true;
329
362
  return makeHandle({
330
363
  pid: ProcessId(proc.pid),
@@ -334,13 +367,15 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
334
367
  all: out.all,
335
368
  getInputFd: fd.getInputFd,
336
369
  getOutputFd: fd.getOutputFd,
337
- isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
338
- exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
370
+ isRunning: Effect.gen(function* () {
371
+ return !(yield* Deferred.isDone(closed)) && !(yield* Deferred.isDone(stopOutput));
372
+ }),
373
+ exitCode: Effect.flatMap(completion, ([code, signal]) => {
339
374
  if (Predicate.isNotNull(code))
340
375
  return Effect.succeed(ExitCode(code));
341
376
  return Effect.fail(toPlatformError("exitCode", new Error(`Process interrupted due to receipt of signal: '${signal}'`), command));
342
377
  }),
343
- kill: (opts) => stop(command, proc, signal, opts),
378
+ kill: (opts) => stop(command, proc, closed, stopOutput, opts),
344
379
  unref: Effect.sync(() => {
345
380
  if (ref) {
346
381
  proc.unref();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode-ai/util",
4
- "version": "0.0.0-beta-18707",
4
+ "version": "0.0.0-beta-18721",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {