Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion process/deno.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@effectionx/process",
"exports": "./mod.ts",
"version": "0.5.0",
"version": "0.6.0",
"license": "MIT",
"imports": {
"@types/cross-spawn": "npm:@types/[email protected]",
Expand Down
6 changes: 6 additions & 0 deletions process/src/exec/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface Process extends StdIO {
* not complete successfully, it will raise an ExecError.
*/
expect(): Operation<ExitStatus>;

/**
* Kill the child process
*/
kill(): Operation<void>;
}

export interface ExecOptions {
Expand Down Expand Up @@ -77,6 +82,7 @@ export interface ProcessResult extends ExitStatus {
stdout: string;
stderr: string;
}

export interface CreateOSProcess {
(command: string, options: ExecOptions): Operation<Process>;
}
16 changes: 10 additions & 6 deletions process/src/exec/posix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,21 @@ export const createPosixProcess: CreateOSProcess = function* createPosixProcess(
processResult.resolve(Ok(value));
} finally {
try {
if (typeof childProcess.pid === "undefined") {
// deno-lint-ignore no-unsafe-finally
throw new Error("no pid for childProcess");
}
process.kill(-childProcess.pid, "SIGTERM");
yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
yield* kill();
} catch (_e) {
// do nothing, process is probably already dead
}
}
});

function* kill() {
if (typeof childProcess.pid === "undefined") {
throw new Error("no pid for childProcess");
}
process.kill(-childProcess.pid, "SIGTERM");
yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
}

function* join() {
let result = yield* processResult.operation;
if (result.ok) {
Expand Down Expand Up @@ -129,5 +132,6 @@ export const createPosixProcess: CreateOSProcess = function* createPosixProcess(
stderr,
join,
expect,
kill,
};
};
117 changes: 58 additions & 59 deletions process/src/exec/win32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import {
createSignal,
Err,
Ok,
race,
type Result,
sleep,
spawn,
withResolvers,
} from "effection";
Expand Down Expand Up @@ -110,74 +108,74 @@ export const createWin32Process: CreateOSProcess = function* createWin32Process(
processResult.resolve(Ok(value));
} finally {
try {
// Only try to kill the process if it hasn't exited yet
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
if (typeof childProcess.pid === "undefined") {
// deno-lint-ignore no-unsafe-finally
throw new Error("no pid for childProcess");
}
yield* kill();
} catch (_e) {
// do nothing, process is probably already dead
}
}
});

let stdinStream = childProcess.stdin;
function* kill() {
// Only try to kill the process if it hasn't exited yet
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
if (typeof childProcess.pid === "undefined") {
throw new Error("no pid for childProcess");
}

// Try graceful shutdown with ctrlc
try {
ctrlc(childProcess.pid);
if (stdinStream.writable) {
try {
// Terminate batch process (Y/N)
stdinStream.write("Y\n");
} catch (_err) {
// not much we can do here
}
}
} catch (_err) {
// ctrlc might fail
}
let stdinStream = childProcess.stdin;

// Close stdin to allow process to exit cleanly
// Try graceful shutdown with ctrlc
try {
ctrlc(childProcess.pid);
if (stdinStream.writable) {
try {
stdinStream.end();
// Terminate batch process (Y/N)
stdinStream.write("Y\n");
} catch (_err) {
// stdin might already be closed
// not much we can do here
}
}
} catch (_err) {
// ctrlc might fail
}

// Wait for graceful exit with a timeout
yield* race([processResult.operation, sleep(300)]);

// If process still hasn't exited, escalate
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
// Try regular kill first
try {
childProcess.kill();
} catch (_err) {
// process might already be dead
}

// If still alive after kill, force-kill entire process tree
// This is necessary for bash on Windows where ctrlc doesn't work
// and child.kill() only kills the shell, leaving grandchildren alive
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
yield* killTree(childProcess.pid);
}
}
// Close stdin to allow process to exit cleanly
try {
stdinStream.end();
} catch (_err) {
// stdin might already be closed
}

// Wait for streams to finish
yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
// If process still hasn't exited, escalate
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
// Try regular kill first
try {
childProcess.kill();
} catch (_err) {
// process might already be dead
}

// If still alive after kill, force-kill entire process tree
// This is necessary for bash on Windows where ctrlc doesn't work
// and child.kill() only kills the shell, leaving grandchildren alive
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
yield* killTree(childProcess.pid);
}
} catch (_e) {
// do nothing, process is probably already dead
}

// Wait for streams to finish
yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
}
});
}

function* join() {
let result = yield* processResult.operation;
Expand Down Expand Up @@ -206,6 +204,7 @@ export const createWin32Process: CreateOSProcess = function* createWin32Process(
stderr,
join,
expect,
kill,
};
};

Expand Down
30 changes: 30 additions & 0 deletions process/test/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,33 @@ describe("handles env vars", () => {

// Close the main "handles env vars" describe block
});

describe("kill", () => {
it("can explicitly kill a running process", function* () {
let proc: Process = yield* exec("deno run -A './fixtures/echo-server.ts'", {
env: {
PORT: "29001",
PATH: process.env.PATH as string,
...(SystemRoot ? { SystemRoot } : {}),
},
cwd: import.meta.dirname,
});

// Wait for the server to start
yield* expectMatch(/listening/, lines()(proc.stdout));

// Kill the process
yield* proc.kill();

// Join should complete after kill
let status = yield* proc.join();

// On POSIX systems, a killed process should have a signal set
if (process.platform !== "win32") {
expect(status.signal).toBeDefined();
} else {
// On Windows, it might be an exit code instead
expect(status.code !== 0 || status.signal).toBeTruthy();
}
});
});
35 changes: 35 additions & 0 deletions v4.importmap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"imports": {
"@deno/dnt": "jsr:@deno/[email protected]",
"@effectionx/bdd": "./bdd/mod.ts",
"@effectionx/process": "./process/mod.ts",
"@effectionx/signals": "./signals/mod.ts",
"@effectionx/stream-helpers": "./stream-helpers/mod.ts",
"@effectionx/test-adapter": "./test-adapter/mod.ts",
"@effectionx/timebox": "./timebox/mod.ts",
"@essentials/raf": "npm:@essentials/raf@^1.2.0",
"@std/assert": "jsr:@std/assert@^1",
"@std/expect": "jsr:@std/expect@^1",
"@std/fs": "jsr:@std/fs@^1",
"@std/json": "jsr:@std/json@^1",
"@std/path": "jsr:@std/path@^1",
"@std/streams": "jsr:@std/streams@^1",
"@std/testing": "jsr:@std/testing@^1",
"@std/testing/bdd": "jsr:@std/testing@^1/bdd",
"@std/testing/mock": "jsr:@std/testing@^1/mock",
"@std/testing/time": "jsr:@std/testing@^1/time",
"@types/cross-spawn": "npm:@types/[email protected]",
"chokidar": "npm:chokidar@^4.0.3",
"cross-spawn": "npm:[email protected]",
"ctrlc-windows": "npm:[email protected]",
"effection": "npm:effection@^4.0.0-0",
"ignore": "npm:ignore@^7.0.3",
"immutable": "npm:immutable@^5",
"remeda": "npm:remeda@^2",
"shellwords": "npm:shellwords@^1.1.1",
"tinyexec": "npm:[email protected]",
"ws": "npm:ws@^8",
"zod": "npm:zod@^3.20.2",
"zod-opts": "npm:[email protected]"
}
}