import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Layer from "effect/Layer";import * as Scope from "effect/Scope";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export type ChildProcessShutdownSignal = "SIGTERM" | "SIGKILL"; export interface BoundedChildProcessSpawnerOptions { readonly termGraceMs?: number; readonly killGraceMs?: number; readonly pollIntervalMs?: number;} const DEFAULT_TERM_GRACE_MS = 2_000;const DEFAULT_KILL_GRACE_MS = 1_000;const DEFAULT_POLL_INTERVAL_MS = 25; const normalizedDelay = (value: number | undefined, fallback: number) => value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, value); const isStillRunning = (handle: ChildProcessSpawner.ChildProcessHandle) => handle.isRunning.pipe(Effect.orElseSucceed(() => true)); const waitUntilStopped = Effect.fn("BoundedChildProcessSpawner.waitUntilStopped")(function* ( handle: ChildProcessSpawner.ChildProcessHandle, timeoutMs: number, pollIntervalMs: number,) { let remainingMs = timeoutMs; while (yield* isStillRunning(handle)) { if (remainingMs <= 0) return false; const delayMs = Math.min(pollIntervalMs, remainingMs); yield* Effect.sleep(delayMs); remainingMs -= delayMs; } return true;}); const closeScopeDetached = Effect.fn("BoundedChildProcessSpawner.closeScopeDetached")(function* ( scope: Scope.Closeable,) { yield* Scope.close(scope, Exit.void).pipe( Effect.ignoreCause({ log: true }), Effect.forkDetach({ startImmediately: true }), );}); export const make = ( delegate: ChildProcessSpawner.ChildProcessSpawner["Service"], options: BoundedChildProcessSpawnerOptions = {},) => { const termGraceMs = normalizedDelay(options.termGraceMs, DEFAULT_TERM_GRACE_MS); const killGraceMs = normalizedDelay(options.killGraceMs, DEFAULT_KILL_GRACE_MS); const pollIntervalMs = Math.max( 1, normalizedDelay(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS), ); const sendSignal = Effect.fn("BoundedChildProcessSpawner.sendSignal")(function* ( handle: ChildProcessSpawner.ChildProcessHandle, signal: ChildProcess.Signal, ) { yield* handle .kill({ killSignal: signal }) .pipe(Effect.ignore, Effect.forkDetach({ startImmediately: true })); }); const kill = Effect.fn("BoundedChildProcessSpawner.kill")(function* ( handle: ChildProcessSpawner.ChildProcessHandle, killOptions?: ChildProcess.KillOptions, ) { if (!(yield* isStillRunning(handle))) return; const initialSignal = killOptions?.killSignal ?? "SIGTERM"; const initialGraceMs = initialSignal === "SIGKILL" ? killGraceMs : Math.min( termGraceMs, normalizedDelay( killOptions?.forceKillAfter === undefined ? undefined : Duration.toMillis(killOptions.forceKillAfter), termGraceMs, ), ); yield* sendSignal(handle, initialSignal); const stoppedAfterInitialSignal = yield* waitUntilStopped( handle, initialGraceMs, pollIntervalMs, ); if (stoppedAfterInitialSignal) return; if (initialSignal !== "SIGKILL") { yield* sendSignal(handle, "SIGKILL"); if (yield* waitUntilStopped(handle, killGraceMs, pollIntervalMs)) return; } yield* Effect.logWarning("Child process did not stop after SIGKILL grace period", { pid: Number(handle.pid), killGraceMs, }); }); const shutdown = Effect.fn("BoundedChildProcessSpawner.shutdown")(function* ( handle: ChildProcessSpawner.ChildProcessHandle, childScope: Scope.Closeable, isReferenced: () => boolean, ) { if (isReferenced()) { yield* kill(handle); } // The platform spawner's own finalizer can wait forever for an exit event. // Run it detached so it can finish normally without blocking the caller's // scope when the operating system does not report an exit in time. yield* closeScopeDetached(childScope); }); const spawn = Effect.fn("BoundedChildProcessSpawner.spawn")(function* ( command: ChildProcess.Command, ) { const childScope = yield* Scope.make("sequential"); const spawned = yield* delegate .spawn(command) .pipe(Effect.provideService(Scope.Scope, childScope), Effect.exit); if (Exit.isFailure(spawned)) { yield* closeScopeDetached(childScope); return yield* Effect.failCause(spawned.cause); } const delegateHandle = spawned.value; let referenced = true; const handle = ChildProcessSpawner.makeHandle({ pid: delegateHandle.pid, exitCode: delegateHandle.exitCode, isRunning: delegateHandle.isRunning, kill: (killOptions) => kill(delegateHandle, killOptions), stdin: delegateHandle.stdin, stdout: delegateHandle.stdout, stderr: delegateHandle.stderr, all: delegateHandle.all, getInputFd: delegateHandle.getInputFd, getOutputFd: delegateHandle.getOutputFd, unref: delegateHandle.unref.pipe( Effect.tap(() => Effect.sync(() => { referenced = false; }), ), Effect.map((reref) => reref.pipe( Effect.tap(() => Effect.sync(() => { referenced = true; }), ), ), ), ), }); yield* Effect.addFinalizer(() => shutdown(delegateHandle, childScope, () => referenced)); yield* handle.exitCode.pipe( Effect.exit, Effect.andThen(Scope.close(childScope, Exit.void)), Effect.ignoreCause({ log: true }), Effect.forkDetach({ startImmediately: true }), ); return handle; }, Effect.uninterruptible); return ChildProcessSpawner.make(spawn);}; export const layer = (options?: BoundedChildProcessSpawnerOptions) => Layer.effect( ChildProcessSpawner.ChildProcessSpawner, Effect.map(ChildProcessSpawner.ChildProcessSpawner, (delegate) => make(delegate, options)), );
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Scope from "effect/Scope";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
export type ChildProcessShutdownSignal = "SIGTERM" | "SIGKILL";
export interface BoundedChildProcessSpawnerOptions {
readonly termGraceMs?: number;
readonly killGraceMs?: number;
readonly pollIntervalMs?: number;
}
const DEFAULT_TERM_GRACE_MS = 2_000;
const DEFAULT_KILL_GRACE_MS = 1_000;
const DEFAULT_POLL_INTERVAL_MS = 25;
const normalizedDelay = (value: number | undefined, fallback: number) =>
value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, value);
const isStillRunning = (handle: ChildProcessSpawner.ChildProcessHandle) =>
handle.isRunning.pipe(Effect.orElseSucceed(() => true));
const waitUntilStopped = Effect.fn("BoundedChildProcessSpawner.waitUntilStopped")(function* (
handle: ChildProcessSpawner.ChildProcessHandle,
timeoutMs: number,
pollIntervalMs: number,
) {
let remainingMs = timeoutMs;
while (yield* isStillRunning(handle)) {
if (remainingMs <= 0) return false;
const delayMs = Math.min(pollIntervalMs, remainingMs);
yield* Effect.sleep(delayMs);
remainingMs -= delayMs;
}
return true;
});
const closeScopeDetached = Effect.fn("BoundedChildProcessSpawner.closeScopeDetached")(function* (
scope: Scope.Closeable,
) {
yield* Scope.close(scope, Exit.void).pipe(
Effect.ignoreCause({ log: true }),
Effect.forkDetach({ startImmediately: true }),
);
});
export const make = (
delegate: ChildProcessSpawner.ChildProcessSpawner["Service"],
options: BoundedChildProcessSpawnerOptions = {},
) => {
const termGraceMs = normalizedDelay(options.termGraceMs, DEFAULT_TERM_GRACE_MS);
const killGraceMs = normalizedDelay(options.killGraceMs, DEFAULT_KILL_GRACE_MS);
const pollIntervalMs = Math.max(
1,
normalizedDelay(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS),
);
const sendSignal = Effect.fn("BoundedChildProcessSpawner.sendSignal")(function* (
handle: ChildProcessSpawner.ChildProcessHandle,
signal: ChildProcess.Signal,
) {
yield* handle
.kill({ killSignal: signal })
.pipe(Effect.ignore, Effect.forkDetach({ startImmediately: true }));
});
const kill = Effect.fn("BoundedChildProcessSpawner.kill")(function* (
handle: ChildProcessSpawner.ChildProcessHandle,
killOptions?: ChildProcess.KillOptions,
) {
if (!(yield* isStillRunning(handle))) return;
const initialSignal = killOptions?.killSignal ?? "SIGTERM";
const initialGraceMs =
initialSignal === "SIGKILL"
? killGraceMs
: Math.min(
termGraceMs,
normalizedDelay(
killOptions?.forceKillAfter === undefined
? undefined
: Duration.toMillis(killOptions.forceKillAfter),
termGraceMs,
),
);
yield* sendSignal(handle, initialSignal);
const stoppedAfterInitialSignal = yield* waitUntilStopped(
handle,
initialGraceMs,
pollIntervalMs,
);
if (stoppedAfterInitialSignal) return;
if (initialSignal !== "SIGKILL") {
yield* sendSignal(handle, "SIGKILL");
if (yield* waitUntilStopped(handle, killGraceMs, pollIntervalMs)) return;
}
yield* Effect.logWarning("Child process did not stop after SIGKILL grace period", {
pid: Number(handle.pid),
killGraceMs,
});
});
const shutdown = Effect.fn("BoundedChildProcessSpawner.shutdown")(function* (
handle: ChildProcessSpawner.ChildProcessHandle,
childScope: Scope.Closeable,
isReferenced: () => boolean,
) {
if (isReferenced()) {
yield* kill(handle);
}
// The platform spawner's own finalizer can wait forever for an exit event.
// Run it detached so it can finish normally without blocking the caller's
// scope when the operating system does not report an exit in time.
yield* closeScopeDetached(childScope);
});
const spawn = Effect.fn("BoundedChildProcessSpawner.spawn")(function* (
command: ChildProcess.Command,
) {
const childScope = yield* Scope.make("sequential");
const spawned = yield* delegate
.spawn(command)
.pipe(Effect.provideService(Scope.Scope, childScope), Effect.exit);
if (Exit.isFailure(spawned)) {
yield* closeScopeDetached(childScope);
return yield* Effect.failCause(spawned.cause);
}
const delegateHandle = spawned.value;
let referenced = true;
const handle = ChildProcessSpawner.makeHandle({
pid: delegateHandle.pid,
exitCode: delegateHandle.exitCode,
isRunning: delegateHandle.isRunning,
kill: (killOptions) => kill(delegateHandle, killOptions),
stdin: delegateHandle.stdin,
stdout: delegateHandle.stdout,
stderr: delegateHandle.stderr,
all: delegateHandle.all,
getInputFd: delegateHandle.getInputFd,
getOutputFd: delegateHandle.getOutputFd,
unref: delegateHandle.unref.pipe(
Effect.tap(() =>
Effect.sync(() => {
referenced = false;
}),
),
Effect.map((reref) =>
reref.pipe(
Effect.tap(() =>
Effect.sync(() => {
referenced = true;
}),
),
),
),
),
});
yield* Effect.addFinalizer(() => shutdown(delegateHandle, childScope, () => referenced));
yield* handle.exitCode.pipe(
Effect.exit,
Effect.andThen(Scope.close(childScope, Exit.void)),
Effect.ignoreCause({ log: true }),
Effect.forkDetach({ startImmediately: true }),
);
return handle;
}, Effect.uninterruptible);
return ChildProcessSpawner.make(spawn);
};
export const layer = (options?: BoundedChildProcessSpawnerOptions) =>
Layer.effect(
ChildProcessSpawner.ChildProcessSpawner,
Effect.map(ChildProcessSpawner.ChildProcessSpawner, (delegate) => make(delegate, options)),
);