Repository: nrwl/nx
Pull request #30987: fix(core): send signals when killing child process on unix
Author: login: AgentEnder
Created: 2025-05-01T23:14:50Z | Merged: 2025-05-02T00:48:52Z

<!-- Please make sure you have read the submission guidelines before posting an PR -->
<!-- https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr -->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you can request a dedicated Nx release for this pull request branch. Mention someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they will confirm if the PR warrants its own release for testing purposes, and generate it for you if appropriate. -->

## Current Behavior
rspack and next do not get killed when using the tui. This appears to be caused by them not responding correctly to SIGTERM, so we need a way to pass signals with our `kill` call.

## Expected Behavior
We properly shutdown rspack and next servers

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is merged. -->

Fixes #



Diff:
diff --git a/Cargo.lock b/Cargo.lock
index e7bde2ed30433..ff33eafee30c7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2032,6 +2032,18 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "nix"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537bc3c4a347b87fd52ac6c03a02ab1302962cfd93373c5d7a112cdc337854cc"
+dependencies = [
+ "bitflags 2.9.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
 [[package]]
 name = "nom"
 version = "7.1.3"
@@ -2170,6 +2182,7 @@ dependencies = [
  "napi",
  "napi-build",
  "napi-derive",
+ "nix 0.30.0",
  "nom",
  "once_cell",
  "parking_lot",
diff --git a/packages/nx/Cargo.toml b/packages/nx/Cargo.toml
index 804e406968c0b..f161d628bf39b 100644
--- a/packages/nx/Cargo.toml
+++ b/packages/nx/Cargo.toml
@@ -59,10 +59,11 @@ xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] }
 vt100-ctt = { git = "https://github.com/JamesHenry/vt100-rust", rev = "1de895505fe9f697aadac585e4075b8fb45c880d" }
 
 [target.'cfg(windows)'.dependencies]
-winapi = { version = "0.3", features = ["fileapi"] }
+winapi = { version = "0.3", features = ["fileapi", "psapi", "shellapi"] }
 
 [target.'cfg(all(not(windows), not(target_family = "wasm")))'.dependencies]
 mio = "0.8"
+nix = { version = "0.30.0", features = ["process", "signal"] }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 arboard = "3.4.1"
diff --git a/packages/nx/src/executors/run-commands/running-tasks.ts b/packages/nx/src/executors/run-commands/running-tasks.ts
index 716ea67ccf572..06493c755baf3 100644
--- a/packages/nx/src/executors/run-commands/running-tasks.ts
+++ b/packages/nx/src/executors/run-commands/running-tasks.ts
@@ -71,7 +71,7 @@ export class ParallelRunningTasks implements RunningTask {
     }
   }
 
-  async kill(signal?: NodeJS.Signals | number) {
+  async kill(signal?: NodeJS.Signals) {
     await Promise.all(
       this.childProcesses.map(async (p) => {
         try {
@@ -215,7 +215,7 @@ export class SeriallyRunningTasks implements RunningTask {
     throw new Error('Not implemented');
   }
 
-  kill(signal?: NodeJS.Signals | number) {
+  kill(signal?: NodeJS.Signals) {
     return this.currentProcess.kill(signal);
   }
 
@@ -357,7 +357,7 @@ class RunningNodeProcess implements RunningTask {
     this.childProcess.send(message);
   }
 
-  kill(signal?: NodeJS.Signals | number): Promise<void> {
+  kill(signal?: NodeJS.Signals): Promise<void> {
     return new Promise<void>((res, rej) => {
       treeKill(this.childProcess.pid, signal, (err) => {
         if (err) {
diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts
index 4318653faa519..bb50d6cf6e9f8 100644
--- a/packages/nx/src/native/index.d.ts
+++ b/packages/nx/src/native/index.d.ts
@@ -26,7 +26,7 @@ export declare class AppLifeCycle {
 
 export declare class ChildProcess {
   getParserAndWriter(): ExternalObject<[ParserArc, WriterArc]>
-  kill(): void
+  kill(signal?: NodeJS.Signals): void
   onExit(callback: (message: string) => void): void
   onOutput(callback: (message: string) => void): void
   cleanup(): void
diff --git a/packages/nx/src/native/pseudo_terminal/child_process.rs b/packages/nx/src/native/pseudo_terminal/child_process.rs
index 06a2c186c54ee..39d875d92e051 100644
--- a/packages/nx/src/native/pseudo_terminal/child_process.rs
+++ b/packages/nx/src/native/pseudo_terminal/child_process.rs
@@ -1,3 +1,4 @@
+use super::process_killer::ProcessKiller;
 use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc};
 use crossbeam_channel::Sender;
 use crossbeam_channel::{bounded, Receiver};
@@ -8,7 +9,6 @@ use napi::{
     },
     Env, JsFunction,
 };
-use portable_pty::ChildKiller;
 use std::io::Write;
 use std::sync::{Arc, Mutex, RwLock};
 use tracing::warn;
@@ -21,7 +21,7 @@ pub enum ChildProcessMessage {
 #[napi]
 pub struct ChildProcess {
     parser: Arc<RwLock<Parser>>,
-    process_killer: Box<dyn ChildKiller + Sync + Send>,
+    process_killer: ProcessKiller,
     message_receiver: Receiver<String>,
     pub(crate) wait_receiver: Receiver<String>,
     thread_handles: Vec<Sender<()>>,
@@ -32,7 +32,7 @@ impl ChildProcess {
     pub fn new(
         parser: Arc<RwLock<Parser>>,
         writer_arc: Arc<Mutex<Box<dyn Write + Send>>>,
-        process_killer: Box<dyn ChildKiller + Sync + Send>,
+        process_killer: ProcessKiller,
         message_receiver: Receiver<String>,
         exit_receiver: Receiver<String>,
     ) -> Self {
@@ -51,9 +51,9 @@ impl ChildProcess {
         External::new((self.parser.clone(), self.writer_arc.clone()))
     }
 
-    #[napi]
-    pub fn kill(&mut self) -> anyhow::Result<()> {
-        self.process_killer.kill().map_err(anyhow::Error::from)
+    #[napi(ts_args_type = "signal?: NodeJS.Signals")]
+    pub fn kill(&mut self, signal: Option<&str>) -> anyhow::Result<()> {
+        self.process_killer.kill(signal)
     }
 
     #[napi]
diff --git a/packages/nx/src/native/pseudo_terminal/mod.rs b/packages/nx/src/native/pseudo_terminal/mod.rs
index 8c6dbd7f86b0c..170cbb8611e8c 100644
--- a/packages/nx/src/native/pseudo_terminal/mod.rs
+++ b/packages/nx/src/native/pseudo_terminal/mod.rs
@@ -10,3 +10,7 @@ pub mod child_process;
 #[cfg_attr(target_os = "macos", path = "mac.rs")]
 #[cfg_attr(not(target_os = "macos"), path = "non_mac.rs")]
 pub mod rust_pseudo_terminal;
+
+#[cfg_attr(windows, path = "process_killer/windows.rs")]
+#[cfg_attr(not(windows), path = "process_killer/unix.rs")]
+mod process_killer;
diff --git a/packages/nx/src/native/pseudo_terminal/process_killer/unix.rs b/packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
new file mode 100644
index 0000000000000..f6a31ff71c19e
--- /dev/null
+++ b/packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
@@ -0,0 +1,59 @@
+use nix::{
+    sys::signal::{kill, Signal as NixSignal},
+    unistd::Pid,
+};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<&str>) -> anyhow::Result<()> {
+        let pid = Pid::from_raw(self.pid);
+        match kill(
+            pid,
+            NixSignal::from(
+                Signal::try_from(signal.unwrap_or("SIGINT")).map_err(|e| anyhow::anyhow!(e))?,
+            ),
+        ) {
+            Ok(_) => Ok(()),
+            Err(e) => Err(anyhow::anyhow!("Failed to kill process: {}", e)),
+        }
+    }
+}
+
+enum Signal {
+    SIGTERM,
+    SIGINT,
+    SIGKILL,
+    SIGHUP,
+}
+
+impl TryFrom<&str> for Signal {
+    type Error = String;
+
+    fn try_from(value: &str) -> Result<Self, Self::Error> {
+        match value {
+            "SIGHUP" => Ok(Signal::SIGHUP),
+            "SIGINT" => Ok(Signal::SIGINT),
+            "SIGKILL" => Ok(Signal::SIGKILL),
+            "SIGTERM" => Ok(Signal::SIGTERM),
+            _ => Err(format!("Invalid signal: {}", value)),
+        }
+    }
+}
+
+impl From<Signal> for NixSignal {
+    fn from(signal: Signal) -> Self {
+        match signal {
+            Signal::SIGTERM => NixSignal::SIGTERM,
+            Signal::SIGINT => NixSignal::SIGINT,
+            Signal::SIGKILL => NixSignal::SIGKILL,
+            Signal::SIGHUP => NixSignal::SIGHUP,
+        }
+    }
+}
diff --git a/packages/nx/src/native/pseudo_terminal/process_killer/windows.rs b/packages/nx/src/native/pseudo_terminal/process_killer/windows.rs
new file mode 100644
index 0000000000000..580f54f2b673a
--- /dev/null
+++ b/packages/nx/src/native/pseudo_terminal/process_killer/windows.rs
@@ -0,0 +1,47 @@
+use std::ptr::null_mut;
+
+use winapi::shared::minwindef::DWORD;
+use winapi::shared::ntdef::HANDLE;
+use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
+use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    // windows doesn't have different signals to kill with
+    pub fn kill(&self, _: Option<String>) -> anyhow::Result<()> {
+        let pc = WinProcess::open(self.pid as DWORD).expect("!open");
+        pc.kill()
+            .map_err(|e| anyhow::anyhow!("Failed to kill process {}: {}", self.pid, e))?;
+        Ok(())
+    }
+}
+
+struct WinProcess(HANDLE);
+impl WinProcess {
+    fn open(pid: DWORD) -> anyhow::Result<WinProcess> {
+        // https://msdn.microsoft.com/en-us/library/windows/desktop/ms684320%28v=vs.85%29.aspx
+        let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) };
+        if pc == null_mut() {
+            anyhow::bail!("Failed to open process with pid {}", pid)
+        } else {
+            Ok(WinProcess(pc))
+        }
+    }
+
+    fn kill(self) -> Result<(), String> {
+        unsafe { TerminateProcess(self.0, 1) };
+        Ok(())
+    }
+}
+impl Drop for WinProcess {
+    fn drop(&mut self) {
+        unsafe { winapi::um::handleapi::CloseHandle(self.0) };
+    }
+}
diff --git a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs
index 636f4e074b979..457b75330cdb4 100644
--- a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs
+++ b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs
@@ -23,7 +23,7 @@ use tracing::log::trace;
 use vt100_ctt::Parser;
 
 use super::os;
-use crate::native::pseudo_terminal::child_process::ChildProcess;
+use crate::native::pseudo_terminal::{child_process::ChildProcess, process_killer::ProcessKiller};
 
 pub struct PseudoTerminal {
     pub pty_pair: PtyPair,
@@ -225,7 +225,11 @@ impl PseudoTerminal {
             trace!("Enabling raw mode");
             enable_raw_mode().expect("Failed to enter raw terminal mode");
         }
-        let process_killer = child.clone_killer();
+        let process_killer = ProcessKiller::new(
+            child
+                .process_id()
+                .expect("unable to determine child process id") as i32,
+        );
 
         trace!("Getting running clone");
         let running_clone = self.running.clone();
@@ -317,7 +321,7 @@ mod tests {
         while i < 10 {
             println!("Running {}", i);
             let cp1 = pseudo_terminal
-                .run_command(String::from("whoami"), None, None, None)
+                .run_command(String::from("whoami"), None, None, None, None, None)
                 .unwrap();
             cp1.wait_receiver.recv().unwrap();
             i += 1;
diff --git a/packages/nx/src/tasks-runner/life-cycle.ts b/packages/nx/src/tasks-runner/life-cycle.ts
index 7fc1f16452384..25309da528839 100644
--- a/packages/nx/src/tasks-runner/life-cycle.ts
+++ b/packages/nx/src/tasks-runner/life-cycle.ts
@@ -27,7 +27,7 @@ interface RustRunningTask extends RunningTask {
 
   onExit(cb: (code: number, terminalOutput: string) => void): void;
 
-  kill(signal?: NodeJS.Signals | number): Promise<void> | void;
+  kill(signal?: NodeJS.Signals): Promise<void> | void;
 }
 
 export interface LifeCycle {
diff --git a/packages/nx/src/tasks-runner/pseudo-terminal.ts b/packages/nx/src/tasks-runner/pseudo-terminal.ts
index ae2c4312a0444..ae3f9b1194b26 100644
--- a/packages/nx/src/tasks-runner/pseudo-terminal.ts
+++ b/packages/nx/src/tasks-runner/pseudo-terminal.ts
@@ -6,15 +6,15 @@ import { PseudoIPCServer } from './pseudo-ipc';
 import { RunningTask } from './running-tasks/running-task';
 
 // Register single event listeners for all pseudo-terminal instances
-const pseudoTerminalShutdownCallbacks: Array<() => void> = [];
+const pseudoTerminalShutdownCallbacks: Array<(s?: NodeJS.Signals) => void> = [];
 process.on('SIGINT', () => {
-  pseudoTerminalShutdownCallbacks.forEach((cb) => cb());
+  pseudoTerminalShutdownCallbacks.forEach((cb) => cb('SIGINT'));
 });
 process.on('SIGTERM', () => {
-  pseudoTerminalShutdownCallbacks.forEach((cb) => cb());
+  pseudoTerminalShutdownCallbacks.forEach((cb) => cb('SIGTERM'));
 });
 process.on('SIGHUP', () => {
-  pseudoTerminalShutdownCallbacks.forEach((cb) => cb());
+  pseudoTerminalShutdownCallbacks.forEach((cb) => cb('SIGHUP'));
 });
 process.on('exit', () => {
   pseudoTerminalShutdownCallbacks.forEach((cb) => cb());
@@ -56,10 +56,10 @@ export class PseudoTerminal {
     this.initialized = true;
   }
 
-  shutdown() {
+  shutdown(s?: NodeJS.Signals) {
     for (const cp of this.childProcesses) {
       try {
-        cp.kill();
+        cp.kill(s);
       } catch {}
     }
     if (this.initialized) {
@@ -189,10 +189,10 @@ export class PseudoTtyProcess implements RunningTask {
     this.outputCallbacks.push(callback);
   }
 
-  kill(): void {
+  kill(s?: NodeJS.Signals): void {
     if (this.isAlive) {
       try {
-        this.childProcess.kill();
+        this.childProcess.kill(s);
       } catch {
         // when the child process completes before we explicitly call kill, this will throw
         // do nothing
diff --git a/packages/nx/src/tasks-runner/running-tasks/batch-process.ts b/packages/nx/src/tasks-runner/running-tasks/batch-process.ts
index 22899eb2665fa..2c878133bf5a3 100644
--- a/packages/nx/src/tasks-runner/running-tasks/batch-process.ts
+++ b/packages/nx/src/tasks-runner/running-tasks/batch-process.ts
@@ -76,7 +76,7 @@ export class BatchProcess {
     }
   }
 
-  kill(signal?: NodeJS.Signals | number): void {
+  kill(signal?: NodeJS.Signals): void {
     if (this.childProcess.connected) {
       this.childProcess.kill(signal);
     }
diff --git a/packages/nx/src/tasks-runner/running-tasks/node-child-process.ts b/packages/nx/src/tasks-runner/running-tasks/node-child-process.ts
index 843da3146598e..84112079791ff 100644
--- a/packages/nx/src/tasks-runner/running-tasks/node-child-process.ts
+++ b/packages/nx/src/tasks-runner/running-tasks/node-child-process.ts
@@ -86,7 +86,7 @@ export class NodeChildProcessWithNonDirectOutput implements RunningTask {
     }
   }
 
-  public kill(signal?: NodeJS.Signals | number) {
+  public kill(signal?: NodeJS.Signals) {
     if (this.childProcess.connected) {
       this.childProcess.kill(signal);
     }
@@ -209,7 +209,7 @@ export class NodeChildProcessWithDirectOutput implements RunningTask {
     return this.terminalOutput;
   }
 
-  kill(signal?: NodeJS.Signals | number): void {
+  kill(signal?: NodeJS.Signals): void {
     if (this.childProcess.connected) {
       this.childProcess.kill(signal);
     }
diff --git a/packages/nx/src/tasks-runner/running-tasks/running-task.ts b/packages/nx/src/tasks-runner/running-tasks/running-task.ts
index 062e6c0af9ffe..8e0e4dfe2bab1 100644
--- a/packages/nx/src/tasks-runner/running-tasks/running-task.ts
+++ b/packages/nx/src/tasks-runner/running-tasks/running-task.ts
@@ -5,7 +5,7 @@ export abstract class RunningTask {
 
   abstract onExit(cb: (code: number) => void): void;
 
-  abstract kill(signal?: NodeJS.Signals | number): Promise<void> | void;
+  abstract kill(signal?: NodeJS.Signals): Promise<void> | void;
 
   abstract onOutput?(cb: (output: string) => void): void;
 


Reviews:
user: login: FrozenPandaz
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T23:30:29Z
user: login: AgentEnder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T23:33:11Z
user: login: AgentEnder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T23:35:16Z
user: login: AgentEnder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T23:35:25Z
user: login: FrozenPandaz
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T00:24:12Z
user: login: AgentEnder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T00:26:19Z

Review comments:
diff_hunk: @@ -0,0 +1,42 @@
+use winapi::shared::minwindef::DWORD;
+use winapi::shared::ntdef::HANDLE;
+use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
+use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<String>) -> anyhow::Result<()> {
+        let pc = WinProcess::open(pid as DWORD).expect("!open");
+        pc.kill()
+            .map_err(|e| anyhow::anyhow!("Failed to kill process {}: {}", self.pid, e))?;
+    }
+}
+
+struct WinProcess(HANDLE);
+impl WinProcess {
+    fn open(pid: DWORD) -> Result<Process, String> {
+        // https://msdn.microsoft.com/en-us/library/windows/desktop/ms684320%28v=vs.85%29.aspx
+        let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) };
path: packages/nx/src/native/pseudo_terminal/process_killer/windows.rs
user: login: FrozenPandaz
body: Is this what portable_pty does under the hood? Why is this unsafe?
created_at: 2025-05-01T23:29:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+use nix::{
+    sys::signal::{kill, Signal as NixSignal},
+    unistd::Pid,
+};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<String>) -> anyhow::Result<()> {
+        let pid = Pid::from_raw(self.pid);
+        let signal: LocalSignal = if let Some(signal_string) = signal {
+            LocalSignal::try_from(signal_string).map_err(|e| {
+                anyhow::anyhow!("Failed to convert signal string to LocalSignal: {}", e)
+            })?
+        } else {
+            LocalSignal(NixSignal::SIGINT)
+        };
+        let nix_signal = signal.0;
+        match kill(pid, nix_signal) {
+            Ok(_) => Ok(()),
+            Err(e) => Err(anyhow::anyhow!("Failed to kill process: {}", e)),
+        }
+    }
+}
+
+struct LocalSignal(NixSignal);
path: packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
user: login: FrozenPandaz
body: Can this be our own enum?
created_at: 2025-05-01T23:30:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+use winapi::shared::minwindef::DWORD;
+use winapi::shared::ntdef::HANDLE;
+use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
+use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<String>) -> anyhow::Result<()> {
+        let pc = WinProcess::open(pid as DWORD).expect("!open");
+        pc.kill()
+            .map_err(|e| anyhow::anyhow!("Failed to kill process {}: {}", self.pid, e))?;
+    }
+}
+
+struct WinProcess(HANDLE);
+impl WinProcess {
+    fn open(pid: DWORD) -> Result<Process, String> {
+        // https://msdn.microsoft.com/en-us/library/windows/desktop/ms684320%28v=vs.85%29.aspx
+        let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) };
path: packages/nx/src/native/pseudo_terminal/process_killer/windows.rs
user: login: AgentEnder
body: `OpenProcess` is marked unsafe, so this has to be unsafe. Its just one of those `winapi` things unfortunately
created_at: 2025-05-01T23:33:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,42 @@
+use winapi::shared::minwindef::DWORD;
+use winapi::shared::ntdef::HANDLE;
+use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
+use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<String>) -> anyhow::Result<()> {
+        let pc = WinProcess::open(pid as DWORD).expect("!open");
+        pc.kill()
+            .map_err(|e| anyhow::anyhow!("Failed to kill process {}: {}", self.pid, e))?;
+    }
+}
+
+struct WinProcess(HANDLE);
+impl WinProcess {
+    fn open(pid: DWORD) -> Result<Process, String> {
+        // https://msdn.microsoft.com/en-us/library/windows/desktop/ms684320%28v=vs.85%29.aspx
+        let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) };
path: packages/nx/src/native/pseudo_terminal/process_killer/windows.rs
user: login: AgentEnder
body: I don't think portable pty has to use OpenProcess... because they are the one that spawned the command. But winapi has OpenProcess marked as `unsafe`, so calling it has to be `unsafe`.

We don't have the windows instance of the process and have to fetch it by pid, and this is the winapi provided way of doing so.
created_at: 2025-05-01T23:35:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,53 @@
+use nix::{
+    sys::signal::{kill, Signal as NixSignal},
+    unistd::Pid,
+};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<String>) -> anyhow::Result<()> {
+        let pid = Pid::from_raw(self.pid);
+        let signal: LocalSignal = if let Some(signal_string) = signal {
+            LocalSignal::try_from(signal_string).map_err(|e| {
+                anyhow::anyhow!("Failed to convert signal string to LocalSignal: {}", e)
+            })?
+        } else {
+            LocalSignal(NixSignal::SIGINT)
+        };
+        let nix_signal = signal.0;
+        match kill(pid, nix_signal) {
+            Ok(_) => Ok(()),
+            Err(e) => Err(anyhow::anyhow!("Failed to kill process: {}", e)),
+        }
+    }
+}
+
+struct LocalSignal(NixSignal);
path: packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
user: login: AgentEnder
body: Sure
created_at: 2025-05-01T23:35:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,59 @@
+use nix::{
+    sys::signal::{kill, Signal as NixSignal},
+    unistd::Pid,
+};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<&str>) -> anyhow::Result<()> {
+        let pid = Pid::from_raw(self.pid);
+        match kill(
+            pid,
+            NixSignal::from(
+                Signal::try_from(signal.unwrap_or("SIGINT")).map_err(|e| anyhow::anyhow!(e))?,
+            ),
+        ) {
+            Ok(_) => Ok(()),
+            Err(e) => Err(anyhow::anyhow!("Failed to kill process: {}", e)),
+        }
+    }
+}
+
+enum Signal {
+    SIGTERM,
+    SIGINT,
+    SIGKILL,
+    SIGHUP,
+}
+
+impl TryFrom<&str> for Signal {
+    type Error = String;
+
+    fn try_from(value: &str) -> Result<Self, Self::Error> {
+        match value {
+            "SIGHUP" => Ok(Signal::SIGHUP),
+            "SIGINT" => Ok(Signal::SIGINT),
+            "SIGKILL" => Ok(Signal::SIGKILL),
+            "SIGTERM" => Ok(Signal::SIGTERM),
+            _ => Err(format!("Invalid signal: {}", value)),
+        }
+    }
+}
+
+impl From<Signal> for NixSignal {
path: packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
user: login: FrozenPandaz
body: future: Is it possible to do `impl TryFrom<&str> for NixSignal` or in fact, is that already implemented?
created_at: 2025-05-02T00:24:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,59 @@
+use nix::{
+    sys::signal::{kill, Signal as NixSignal},
+    unistd::Pid,
+};
+
+pub struct ProcessKiller {
+    pid: i32,
+}
+
+impl ProcessKiller {
+    pub fn new(pid: i32) -> Self {
+        Self { pid }
+    }
+
+    pub fn kill(&self, signal: Option<&str>) -> anyhow::Result<()> {
+        let pid = Pid::from_raw(self.pid);
+        match kill(
+            pid,
+            NixSignal::from(
+                Signal::try_from(signal.unwrap_or("SIGINT")).map_err(|e| anyhow::anyhow!(e))?,
+            ),
+        ) {
+            Ok(_) => Ok(()),
+            Err(e) => Err(anyhow::anyhow!("Failed to kill process: {}", e)),
+        }
+    }
+}
+
+enum Signal {
+    SIGTERM,
+    SIGINT,
+    SIGKILL,
+    SIGHUP,
+}
+
+impl TryFrom<&str> for Signal {
+    type Error = String;
+
+    fn try_from(value: &str) -> Result<Self, Self::Error> {
+        match value {
+            "SIGHUP" => Ok(Signal::SIGHUP),
+            "SIGINT" => Ok(Signal::SIGINT),
+            "SIGKILL" => Ok(Signal::SIGKILL),
+            "SIGTERM" => Ok(Signal::SIGTERM),
+            _ => Err(format!("Invalid signal: {}", value)),
+        }
+    }
+}
+
+impl From<Signal> for NixSignal {
path: packages/nx/src/native/pseudo_terminal/process_killer/unix.rs
user: login: AgentEnder
body: I tried that first, rust won't let you implement from one type to another unless at least one of the types is defined in the file
created_at: 2025-05-02T00:26:19Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-01T23:14:54Z
body: [vc]: #QgfBnsK+C3J7/8EaaOP2t2jnFkeLFhpNyAQKGx0TBCs=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJueC1kZXYiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbnJ3bC9ueC1kZXYvRkZzMWtoVHNXTFk0aVA3RVh4WkZhdUpMalBTeSIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicm9vdERpcmVjdG9yeSI6bnVsbCwicHJldmlld1VybCI6Im54LWRldi1naXQtY2hpbGRraWxsZXJ2Mi1ucndsLnZlcmNlbC5hcHAifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **nx-dev** | ⬜️ Ignored ([Inspect](https://vercel.com/nrwl/nx-dev/FFs1khTsWLY4iP7EXxZFauJLjPSy)) | [Visit Preview](https://nx-dev-git-childkillerv2-nrwl.vercel.app) | May 1, 2025 11:53pm |
</details>


author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-05-01T23:15:22Z
body: 
View your [CI Pipeline Execution ↗](https://staging.nx.app/cipes/6814149bc4d5176168f416a2?utm_source=pull-request&utm_medium=comment) for commit ec1064e0d98cdb4805bdf9770f9aef0b1c1f2501.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected --targets=lint,test,build,e2e,e2e-c...` | ✅ Succeeded | 1m 7s | [View ↗](https://staging.nx.app/runs/9jvTtDJLh0?utm_source=pull-request&utm_medium=comment) |
| `nx run-many -t check-imports check-commit check...` | ✅ Succeeded | 18s | [View ↗](https://staging.nx.app/runs/5XJG3Yidsn?utm_source=pull-request&utm_medium=comment) |
| `nx-cloud record -- nx-cloud conformance:check` | ✅ Succeeded | 2s | [View ↗](https://staging.nx.app/runs/bPeVsdEh79?utm_source=pull-request&utm_medium=comment) |
| `nx-cloud record -- nx format:check` | ✅ Succeeded | 1s | [View ↗](https://staging.nx.app/runs/GOt4IWIKEf?utm_source=pull-request&utm_medium=comment) |
| `nx-cloud record -- nx sync:check` | ✅ Succeeded | <1s | [View ↗](https://staging.nx.app/runs/IjZAyNn8LP?utm_source=pull-request&utm_medium=comment) |
| `nx documentation` | ✅ Succeeded | <1s | [View ↗](https://staging.nx.app/runs/d9meh47vm5?utm_source=pull-request&utm_medium=comment) |

---

☁️ [Nx Cloud](https://staging.nx.app?utm_source=pull-request&utm_medium=comment) last updated this comment at `2025-05-02 00:46:44` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-08T00:12:04Z
body: This pull request has already been merged/closed. If you experience issues related to these changes, please open a new issue referencing this pull request.
author_association: CONTRIBUTOR