running-process 3.4.0__tar.gz → 3.4.1__tar.gz

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.
Files changed (107) hide show
  1. {running_process-3.4.0 → running_process-3.4.1}/Cargo.lock +7 -7
  2. {running_process-3.4.0 → running_process-3.4.1}/Cargo.toml +1 -1
  3. {running_process-3.4.0 → running_process-3.4.1}/PKG-INFO +1 -1
  4. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/Cargo.toml +1 -1
  5. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/Cargo.toml +1 -1
  6. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/lib.rs +181 -6
  7. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/public_symbols.rs +11 -0
  8. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/process_core_test.rs +143 -0
  9. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/Cargo.toml +3 -3
  10. {running_process-3.4.0 → running_process-3.4.1}/pyproject.toml +1 -1
  11. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/__init__.py +1 -1
  12. {running_process-3.4.0 → running_process-3.4.1}/LICENSE +0 -0
  13. {running_process-3.4.0 → running_process-3.4.1}/README.md +0 -0
  14. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/src/client.rs +0 -0
  15. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/src/lib.rs +0 -0
  16. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/src/paths.rs +0 -0
  17. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/src/pipe_session.rs +0 -0
  18. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-client/src/pty_session.rs +0 -0
  19. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/console_detect.rs +0 -0
  20. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/containment.rs +0 -0
  21. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/originator.rs +0 -0
  22. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/pty/mod.rs +0 -0
  23. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/pty/native_pty_process.rs +0 -0
  24. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/pty/pty_posix.rs +0 -0
  25. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/pty/pty_windows.rs +0 -0
  26. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/pty/terminal_input.rs +0 -0
  27. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/rust_debug.rs +0 -0
  28. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/spawn.rs +0 -0
  29. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/spawn_imp_unix.rs +0 -0
  30. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/spawn_imp_windows.rs +0 -0
  31. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/src/tests.rs +0 -0
  32. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/containment_test.rs +0 -0
  33. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/fs_adversarial_test.rs +0 -0
  34. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/originator_test.rs +0 -0
  35. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/pty_conhost_job_test.rs +0 -0
  36. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-core/tests/spawn_test.rs +0 -0
  37. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-proto/Cargo.toml +0 -0
  38. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-proto/build.rs +0 -0
  39. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-proto/proto/daemon.proto +0 -0
  40. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-proto/src/lib.rs +0 -0
  41. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/containment.rs +0 -0
  42. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/daemon_client.rs +0 -0
  43. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/debug_traces.rs +0 -0
  44. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/helpers.rs +0 -0
  45. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/idle_detector.rs +0 -0
  46. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/lib.rs +0 -0
  47. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/metrics.rs +0 -0
  48. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/originator.rs +0 -0
  49. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/pid_tracking.rs +0 -0
  50. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/priority.rs +0 -0
  51. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/process.rs +0 -0
  52. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/process_tree.rs +0 -0
  53. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/pty_buffer.rs +0 -0
  54. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/pty_process.rs +0 -0
  55. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/public_symbols.rs +0 -0
  56. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/py_native_process.rs +0 -0
  57. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/registry.rs +0 -0
  58. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/signal_bool.rs +0 -0
  59. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/terminal_input.rs +0 -0
  60. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/control_churn.rs +0 -0
  61. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/expect_match.rs +0 -0
  62. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/idle_detector.rs +0 -0
  63. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/mod.rs +0 -0
  64. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/parse_command.rs +0 -0
  65. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/process_tree.rs +0 -0
  66. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/pty_buffer.rs +0 -0
  67. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/pty_process.rs +0 -0
  68. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/registry.rs +0 -0
  69. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/signal_bool.rs +0 -0
  70. {running_process-3.4.0 → running_process-3.4.1}/crates/running-process-py/src/tests/terminal_input.rs +0 -0
  71. {running_process-3.4.0 → running_process-3.4.1}/crates/test-watchdog/Cargo.toml +0 -0
  72. {running_process-3.4.0 → running_process-3.4.1}/crates/test-watchdog/src/lib.rs +0 -0
  73. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/assets/example.txt +0 -0
  74. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/cli.py +0 -0
  75. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/command_render.py +0 -0
  76. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/compat.py +0 -0
  77. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/console_encoding.py +0 -0
  78. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/daemon.py +0 -0
  79. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/dashboard.py +0 -0
  80. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/exit_status.py +0 -0
  81. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/expect.py +0 -0
  82. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/interrupt_handler.py +0 -0
  83. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/launch.py +0 -0
  84. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/line_iterator.py +0 -0
  85. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/output_formatter.py +0 -0
  86. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/priority.py +0 -0
  87. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/process_utils.py +0 -0
  88. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/processor_cli.py +0 -0
  89. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/__init__.py +0 -0
  90. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_command.py +0 -0
  91. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_console_io.py +0 -0
  92. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_errors.py +0 -0
  93. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_idle_helpers.py +0 -0
  94. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_idle_state.py +0 -0
  95. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_interactive.py +0 -0
  96. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_process_helpers.py +0 -0
  97. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_pseudo_terminal.py +0 -0
  98. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_terminal_strip.py +0 -0
  99. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_types.py +0 -0
  100. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/pty/_wait_input.py +0 -0
  101. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/__init__.py +0 -0
  102. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/_core.py +0 -0
  103. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/_helpers.py +0 -0
  104. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/_iter.py +0 -0
  105. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/_subprocess.py +0 -0
  106. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process/_types.py +0 -0
  107. {running_process-3.4.0 → running_process-3.4.1}/src/running_process/running_process_manager.py +0 -0
@@ -210,7 +210,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
210
210
 
211
211
  [[package]]
212
212
  name = "daemon-trampoline"
213
- version = "3.4.0"
213
+ version = "3.4.1"
214
214
  dependencies = [
215
215
  "libc",
216
216
  "serde",
@@ -1036,7 +1036,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1036
1036
 
1037
1037
  [[package]]
1038
1038
  name = "running-process-client"
1039
- version = "3.4.0"
1039
+ version = "3.4.1"
1040
1040
  dependencies = [
1041
1041
  "dirs",
1042
1042
  "interprocess",
@@ -1047,7 +1047,7 @@ dependencies = [
1047
1047
 
1048
1048
  [[package]]
1049
1049
  name = "running-process-core"
1050
- version = "3.4.0"
1050
+ version = "3.4.1"
1051
1051
  dependencies = [
1052
1052
  "libc",
1053
1053
  "portable-pty",
@@ -1061,7 +1061,7 @@ dependencies = [
1061
1061
 
1062
1062
  [[package]]
1063
1063
  name = "running-process-daemon"
1064
- version = "3.4.0"
1064
+ version = "3.4.1"
1065
1065
  dependencies = [
1066
1066
  "bytes",
1067
1067
  "clap",
@@ -1088,7 +1088,7 @@ dependencies = [
1088
1088
 
1089
1089
  [[package]]
1090
1090
  name = "running-process-proto"
1091
- version = "3.4.0"
1091
+ version = "3.4.1"
1092
1092
  dependencies = [
1093
1093
  "prost",
1094
1094
  "prost-build",
@@ -1098,7 +1098,7 @@ dependencies = [
1098
1098
 
1099
1099
  [[package]]
1100
1100
  name = "running-process-py"
1101
- version = "3.4.0"
1101
+ version = "3.4.1"
1102
1102
  dependencies = [
1103
1103
  "interprocess",
1104
1104
  "libc",
@@ -1114,7 +1114,7 @@ dependencies = [
1114
1114
 
1115
1115
  [[package]]
1116
1116
  name = "runpm-cli"
1117
- version = "3.4.0"
1117
+ version = "3.4.1"
1118
1118
  dependencies = [
1119
1119
  "anyhow",
1120
1120
  "clap",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/running-process-core", "crates/running-process-proto", "crates/running-process-client", "crates/running-process-py", "crates/test-watchdog"]
4
4
 
5
5
  [workspace.package]
6
- version = "3.4.0"
6
+ version = "3.4.1"
7
7
  edition = "2021"
8
8
  rust-version = "1.85"
9
9
  license = "BSD-3-Clause"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: running_process
3
- Version: 3.4.0
3
+ Version: 3.4.1
4
4
  License-File: LICENSE
5
5
  Summary: A Rust-backed subprocess wrapper with split stdout/stderr streaming
6
6
  Home-Page: https://github.com/zackees/running-process
@@ -9,7 +9,7 @@ homepage.workspace = true
9
9
  description = "Lightweight synchronous IPC client for the running-process daemon"
10
10
 
11
11
  [dependencies]
12
- running-process-proto = { path = "../running-process-proto", version = "3.4.0" }
12
+ running-process-proto = { path = "../running-process-proto", version = "3.4.1" }
13
13
  prost = "0.14"
14
14
  interprocess = "2"
15
15
  dirs = "6"
@@ -17,7 +17,7 @@ libc = "0.2"
17
17
  portable-pty = "0.9"
18
18
  sysinfo = "0.30"
19
19
  thiserror = { workspace = true }
20
- winapi = { version = "0.3", features = ["errhandlingapi", "fileapi", "handleapi", "jobapi2", "namedpipeapi", "processthreadsapi", "winnt", "minwindef", "windef", "winuser", "consoleapi", "processenv", "synchapi", "winbase", "wincon", "tlhelp32"] }
20
+ winapi = { version = "0.3", features = ["errhandlingapi", "fileapi", "handleapi", "ioapiset", "jobapi2", "namedpipeapi", "processthreadsapi", "winnt", "minwindef", "windef", "winuser", "consoleapi", "processenv", "synchapi", "winbase", "wincon", "tlhelp32"] }
21
21
 
22
22
  [dev-dependencies]
23
23
  serde_json = "1"
@@ -40,6 +40,25 @@ macro_rules! rp_rust_debug_scope {
40
40
 
41
41
  const CHILD_PID_LOG_PATH_ENV: &str = "RUNNING_PROCESS_CHILD_PID_LOG_PATH";
42
42
 
43
+ /// Hard cap on how long `kill_impl()` will block on
44
+ /// `wait_for_capture_completion` after the direct child has been
45
+ /// reaped. Override via the `RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS`
46
+ /// env var (milliseconds). The default of 2 s gives normal children
47
+ /// time to flush their pipe buffers while preventing indefinite hangs
48
+ /// when a grandchild inherits the pipe and outlives the parent (FastLED
49
+ /// Bug B).
50
+ const DEFAULT_KILL_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
51
+ const KILL_DRAIN_TIMEOUT_ENV: &str = "RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS";
52
+
53
+ fn kill_drain_deadline() -> Instant {
54
+ let timeout = std::env::var(KILL_DRAIN_TIMEOUT_ENV)
55
+ .ok()
56
+ .and_then(|raw| raw.trim().parse::<u64>().ok())
57
+ .map(Duration::from_millis)
58
+ .unwrap_or(DEFAULT_KILL_DRAIN_TIMEOUT);
59
+ Instant::now() + timeout
60
+ }
61
+
43
62
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
63
  pub enum StreamKind {
45
64
  Stdout,
@@ -168,6 +187,21 @@ struct ChildState {
168
187
  _job: WindowsJobHandle,
169
188
  }
170
189
 
190
+ /// Parent-side handles for the captured stdout/stderr pipes, kept so
191
+ /// that `kill_impl` can call `CancelIoEx` to interrupt a reader thread
192
+ /// blocked in `read()`. Stored as `usize` because `RawHandle` (a raw
193
+ /// pointer) is not `Send` and we share this via `Arc<Mutex<...>>`.
194
+ ///
195
+ /// The reader thread clears its slot (under the mutex) immediately
196
+ /// before dropping its `ChildStd*`, so `kill_impl` never calls
197
+ /// `CancelIoEx` on a closed (and potentially reused) handle.
198
+ #[cfg(windows)]
199
+ #[derive(Default)]
200
+ struct CapturePipeHandles {
201
+ stdout: Option<usize>,
202
+ stderr: Option<usize>,
203
+ }
204
+
171
205
  impl SharedState {
172
206
  fn new(capture: bool) -> Self {
173
207
  let queues = QueueState {
@@ -187,6 +221,8 @@ pub struct NativeProcess {
187
221
  config: ProcessConfig,
188
222
  child: Arc<Mutex<Option<ChildState>>>,
189
223
  shared: Arc<SharedState>,
224
+ #[cfg(windows)]
225
+ capture_pipe_handles: Arc<Mutex<CapturePipeHandles>>,
190
226
  }
191
227
 
192
228
  impl NativeProcess {
@@ -195,6 +231,8 @@ impl NativeProcess {
195
231
  shared: Arc::new(SharedState::new(config.capture)),
196
232
  child: Arc::new(Mutex::new(None)),
197
233
  config,
234
+ #[cfg(windows)]
235
+ capture_pipe_handles: Arc::new(Mutex::new(CapturePipeHandles::default())),
198
236
  }
199
237
  }
200
238
 
@@ -234,7 +272,22 @@ impl NativeProcess {
234
272
  if self.config.capture {
235
273
  let stdout = child.stdout.take().expect("stdout pipe missing");
236
274
  let stderr = child.stderr.take().expect("stderr pipe missing");
237
- self.spawn_reader(stdout, StreamKind::Stdout, StreamKind::Stdout);
275
+ #[cfg(windows)]
276
+ {
277
+ use std::os::windows::io::AsRawHandle;
278
+ let mut handles = self
279
+ .capture_pipe_handles
280
+ .lock()
281
+ .expect("capture pipe handles mutex poisoned");
282
+ handles.stdout = Some(stdout.as_raw_handle() as usize);
283
+ handles.stderr = Some(stderr.as_raw_handle() as usize);
284
+ }
285
+ self.spawn_reader(
286
+ stdout,
287
+ StreamKind::Stdout,
288
+ StreamKind::Stdout,
289
+ self.pipe_done_callback(StreamKind::Stdout),
290
+ );
238
291
  self.spawn_reader(
239
292
  stderr,
240
293
  StreamKind::Stderr,
@@ -242,6 +295,7 @@ impl NativeProcess {
242
295
  StderrMode::Stdout => StreamKind::Stdout,
243
296
  StderrMode::Pipe => StreamKind::Stderr,
244
297
  },
298
+ self.pipe_done_callback(StreamKind::Stderr),
245
299
  );
246
300
  }
247
301
  *guard = Some(ChildState {
@@ -405,6 +459,15 @@ impl NativeProcess {
405
459
  let status = child.wait().map_err(ProcessError::Io)?;
406
460
  self.set_returncode(exit_code(status));
407
461
  }
462
+ // On Windows, interrupt any pending blocking `read()` in the
463
+ // per-stream reader threads so they fall out of their loops
464
+ // immediately. This is what makes the grandchild-pipe-orphan
465
+ // case (FastLED Bug B: uv.exe spawns a python.exe grandchild
466
+ // that inherits the pipe and outlives uv) wake up in
467
+ // microseconds instead of waiting for the bounded-drain
468
+ // safety-net deadline below.
469
+ #[cfg(windows)]
470
+ self.cancel_capture_io();
408
471
  // Synchronize with the per-stream reader threads so that by the
409
472
  // time kill() returns, the capture queues have flipped from
410
473
  // "blocked on read" to "closed" and downstream pollers (e.g.
@@ -412,7 +475,15 @@ impl NativeProcess {
412
475
  // this, callers that hit a wait()-timeout path see Python code
413
476
  // raise TimeoutError, kill the child, then race the reader
414
477
  // threads — a 10ms poll loop can miss the EOS flip entirely.
415
- public_symbols::rp_native_process_wait_for_capture_completion_public(self);
478
+ //
479
+ // The deadline is the safety-net: on Windows `CancelIoEx`
480
+ // above almost always wakes the readers first; on POSIX, and
481
+ // in any pathological Windows case where `CancelIoEx` doesn't
482
+ // fire, the deadline guarantees `kill()` still returns.
483
+ public_symbols::rp_native_process_wait_for_capture_completion_with_deadline_public(
484
+ self,
485
+ kill_drain_deadline(),
486
+ );
416
487
  Ok(())
417
488
  }
418
489
 
@@ -785,8 +856,9 @@ impl NativeProcess {
785
856
  } else {
786
857
  0
787
858
  };
788
- let flags =
789
- self.config.creationflags.unwrap_or(0) | extra | windows_priority_flags(self.config.nice);
859
+ let flags = self.config.creationflags.unwrap_or(0)
860
+ | extra
861
+ | windows_priority_flags(self.config.nice);
790
862
  if flags != 0 {
791
863
  command.creation_flags(flags);
792
864
  }
@@ -818,8 +890,13 @@ impl NativeProcess {
818
890
  command
819
891
  }
820
892
 
821
- fn spawn_reader<R>(&self, pipe: R, source_stream: StreamKind, visible_stream: StreamKind)
822
- where
893
+ fn spawn_reader<R>(
894
+ &self,
895
+ pipe: R,
896
+ source_stream: StreamKind,
897
+ visible_stream: StreamKind,
898
+ on_pipe_done: Box<dyn FnOnce() + Send>,
899
+ ) where
823
900
  R: Read + Send + 'static,
824
901
  {
825
902
  let shared = Arc::clone(&self.shared);
@@ -843,6 +920,13 @@ impl NativeProcess {
843
920
  emit_lines(&shared, visible_stream, vec![std::mem::take(&mut pending)]);
844
921
  }
845
922
 
923
+ // Clear the parent-side pipe-handle slot under its mutex
924
+ // before dropping the reader. After this returns,
925
+ // `kill_impl` can no longer try to `CancelIoEx` on us, so
926
+ // it's safe for `reader`'s drop to close the HANDLE.
927
+ on_pipe_done();
928
+ drop(reader);
929
+
846
930
  let mut guard = shared.queues.lock().expect("queue mutex poisoned");
847
931
  match source_stream {
848
932
  StreamKind::Stdout => guard.stdout_closed = true,
@@ -852,6 +936,57 @@ impl NativeProcess {
852
936
  });
853
937
  }
854
938
 
939
+ #[cfg(windows)]
940
+ fn pipe_done_callback(&self, stream: StreamKind) -> Box<dyn FnOnce() + Send> {
941
+ let handles = Arc::clone(&self.capture_pipe_handles);
942
+ Box::new(move || {
943
+ let mut guard = handles
944
+ .lock()
945
+ .expect("capture pipe handles mutex poisoned");
946
+ match stream {
947
+ StreamKind::Stdout => guard.stdout = None,
948
+ StreamKind::Stderr => guard.stderr = None,
949
+ }
950
+ })
951
+ }
952
+
953
+ #[cfg(not(windows))]
954
+ fn pipe_done_callback(&self, _stream: StreamKind) -> Box<dyn FnOnce() + Send> {
955
+ Box::new(|| {})
956
+ }
957
+
958
+ /// Cancel any pending blocking `read()` on the parent-side capture
959
+ /// pipes so the reader threads' `read()` calls return
960
+ /// `ERROR_OPERATION_ABORTED` immediately. Used by `kill_impl` to
961
+ /// break the grandchild-orphan deadlock without waiting on
962
+ /// `wait_for_capture_completion_with_deadline`'s safety-net.
963
+ #[cfg(windows)]
964
+ fn cancel_capture_io(&self) {
965
+ crate::rp_rust_debug_scope!("running_process_core::NativeProcess::cancel_capture_io");
966
+ use winapi::shared::ntdef::HANDLE;
967
+ use winapi::um::ioapiset::CancelIoEx;
968
+ let guard = self
969
+ .capture_pipe_handles
970
+ .lock()
971
+ .expect("capture pipe handles mutex poisoned");
972
+ if let Some(h) = guard.stdout {
973
+ // SAFETY: the slot is `Some` only while the owning reader
974
+ // thread still holds the `ChildStdout`, so the HANDLE is
975
+ // valid for the duration of this call. The reader is
976
+ // blocked in `lock()` on the same mutex if it's racing us
977
+ // toward exit, so it cannot drop the pipe and close the
978
+ // HANDLE until we return.
979
+ unsafe {
980
+ CancelIoEx(h as HANDLE, std::ptr::null_mut());
981
+ }
982
+ }
983
+ if let Some(h) = guard.stderr {
984
+ unsafe {
985
+ CancelIoEx(h as HANDLE, std::ptr::null_mut());
986
+ }
987
+ }
988
+ }
989
+
855
990
  fn set_returncode(&self, code: i32) {
856
991
  self.shared.returncode.store(code as i64, Ordering::Release);
857
992
  self.shared.condvar.notify_all();
@@ -874,6 +1009,46 @@ impl NativeProcess {
874
1009
  .expect("queue mutex poisoned");
875
1010
  }
876
1011
  }
1012
+
1013
+ /// Like `wait_for_capture_completion_impl` but bounded by `deadline`.
1014
+ /// Returns `true` if the reader threads flipped both closed flags on
1015
+ /// their own, `false` if the deadline elapsed first. On timeout the
1016
+ /// closed flags are force-set (and waiters notified) so downstream
1017
+ /// pollers stop seeing `Timeout` and start seeing `Eof`. A reader
1018
+ /// thread that eventually unblocks after the OS releases the pipe
1019
+ /// will assign `closed = true` again, which is a harmless no-op.
1020
+ fn wait_for_capture_completion_with_deadline_impl(&self, deadline: Instant) -> bool {
1021
+ crate::rp_rust_debug_scope!(
1022
+ "running_process_core::NativeProcess::wait_for_capture_completion_with_deadline"
1023
+ );
1024
+ if !self.config.capture {
1025
+ return true;
1026
+ }
1027
+
1028
+ let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1029
+ while !(guard.stdout_closed && guard.stderr_closed) {
1030
+ let now = Instant::now();
1031
+ if now >= deadline {
1032
+ guard.stdout_closed = true;
1033
+ guard.stderr_closed = true;
1034
+ self.shared.condvar.notify_all();
1035
+ return false;
1036
+ }
1037
+ let (next_guard, result) = self
1038
+ .shared
1039
+ .condvar
1040
+ .wait_timeout(guard, deadline - now)
1041
+ .expect("queue mutex poisoned");
1042
+ guard = next_guard;
1043
+ if result.timed_out() && !(guard.stdout_closed && guard.stderr_closed) {
1044
+ guard.stdout_closed = true;
1045
+ guard.stderr_closed = true;
1046
+ self.shared.condvar.notify_all();
1047
+ return false;
1048
+ }
1049
+ }
1050
+ true
1051
+ }
877
1052
  }
878
1053
 
879
1054
  #[cfg(unix)]
@@ -1,5 +1,7 @@
1
1
  #![allow(improper_ctypes_definitions)]
2
2
 
3
+ use std::time::Instant;
4
+
3
5
  use super::*;
4
6
 
5
7
  #[unsafe(no_mangle)]
@@ -50,6 +52,15 @@ pub extern "C" fn rp_native_process_wait_for_capture_completion_public(process:
50
52
  process.wait_for_capture_completion_impl();
51
53
  }
52
54
 
55
+ #[unsafe(no_mangle)]
56
+ #[inline(never)]
57
+ pub extern "C" fn rp_native_process_wait_for_capture_completion_with_deadline_public(
58
+ process: &NativeProcess,
59
+ deadline: Instant,
60
+ ) -> bool {
61
+ process.wait_for_capture_completion_with_deadline_impl(deadline)
62
+ }
63
+
53
64
  #[cfg(windows)]
54
65
  #[unsafe(no_mangle)]
55
66
  #[inline(never)]
@@ -648,6 +648,149 @@ fn terminate_kills_running_process() {
648
648
  process.terminate().unwrap();
649
649
  }
650
650
 
651
+ /// FastLED Bug B follow-up: on Windows, `kill()` must wake the
652
+ /// reader threads via `CancelIoEx` *immediately* even when a
653
+ /// grandchild keeps the captured pipe open. This is what the
654
+ /// `cancel_capture_io()` call in `kill_impl` provides — without it,
655
+ /// kill() would wait for the full `RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS`
656
+ /// safety-net deadline before returning. The test sets that deadline
657
+ /// to 5000 ms and asserts kill() returns in under 1 s, proving the
658
+ /// CancelIoEx fast path is wired up.
659
+ #[cfg(windows)]
660
+ #[test]
661
+ fn kill_cancels_capture_io_when_grandchild_orphans_pipe() {
662
+ let script = "\
663
+ import os, subprocess, sys, time;\
664
+ print('PARENT_PID=' + str(os.getpid()), flush=True);\
665
+ gc = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']);\
666
+ print('GRANDCHILD_PID=' + str(gc.pid), flush=True);\
667
+ time.sleep(60)";
668
+
669
+ let process = NativeProcess::new(config(
670
+ CommandSpec::Argv(vec!["python".into(), "-c".into(), script.into()]),
671
+ true,
672
+ StdinMode::Inherit,
673
+ None,
674
+ ));
675
+
676
+ process.start().unwrap();
677
+
678
+ let mut grandchild_pid: Option<u32> = None;
679
+ let deadline = Instant::now() + Duration::from_secs(10);
680
+ while Instant::now() < deadline {
681
+ match process.read_combined(Some(Duration::from_millis(200))) {
682
+ ReadStatus::Line(event) => {
683
+ let line = String::from_utf8_lossy(&event.line).into_owned();
684
+ if let Some(rest) = line.strip_prefix("GRANDCHILD_PID=") {
685
+ grandchild_pid = rest.trim().parse::<u32>().ok();
686
+ break;
687
+ }
688
+ }
689
+ ReadStatus::Timeout => continue,
690
+ ReadStatus::Eof => panic!("parent exited before announcing grandchild"),
691
+ }
692
+ }
693
+ let grandchild_pid = grandchild_pid.expect("did not observe GRANDCHILD_PID line");
694
+
695
+ // Crank the safety-net drain deadline way up so the only way
696
+ // kill() can return fast is via the CancelIoEx fast path.
697
+ let prior = env::var_os("RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS");
698
+ env::set_var("RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS", "5000");
699
+
700
+ let kill_start = Instant::now();
701
+ let kill_result = process.kill();
702
+ let kill_elapsed = kill_start.elapsed();
703
+
704
+ match prior {
705
+ Some(v) => env::set_var("RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS", v),
706
+ None => env::remove_var("RUNNING_PROCESS_KILL_DRAIN_TIMEOUT_MS"),
707
+ }
708
+
709
+ kill_result.expect("kill() returned an error");
710
+ assert!(
711
+ kill_elapsed < Duration::from_secs(1),
712
+ "kill() took {kill_elapsed:?} with 5 s safety-net deadline; \
713
+ CancelIoEx fast path is not interrupting the reader thread",
714
+ );
715
+
716
+ let _ = Command::new("taskkill")
717
+ .args(["/F", "/T", "/PID", &grandchild_pid.to_string()])
718
+ .stdout(Stdio::null())
719
+ .stderr(Stdio::null())
720
+ .status();
721
+ }
722
+
723
+ /// FastLED Bug B regression: when a grandchild inherits the captured
724
+ /// stdout pipe and outlives the direct child, `kill()` must still
725
+ /// return promptly instead of blocking forever in
726
+ /// `wait_for_capture_completion`. Mirrors the `uv run python ...`
727
+ /// shape, where uv exits while a python grandchild keeps the pipe open.
728
+ #[test]
729
+ fn kill_returns_when_grandchild_inherits_stdout_pipe() {
730
+ // Parent: print its own PID, spawn a grandchild python that sleeps
731
+ // 60 s with inherited stdout, then itself sleep 60 s. We kill the
732
+ // parent before either sleep elapses; the grandchild stays alive
733
+ // (and thus the pipe stays open) for the duration of the test.
734
+ let script = "\
735
+ import os, subprocess, sys, time;\
736
+ print('PARENT_PID=' + str(os.getpid()), flush=True);\
737
+ gc = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']);\
738
+ print('GRANDCHILD_PID=' + str(gc.pid), flush=True);\
739
+ time.sleep(60)";
740
+
741
+ let process = NativeProcess::new(config(
742
+ CommandSpec::Argv(vec!["python".into(), "-c".into(), script.into()]),
743
+ true,
744
+ StdinMode::Inherit,
745
+ None,
746
+ ));
747
+
748
+ process.start().unwrap();
749
+
750
+ // Wait for the parent to spawn the grandchild and announce both PIDs.
751
+ let mut grandchild_pid: Option<u32> = None;
752
+ let deadline = Instant::now() + Duration::from_secs(10);
753
+ while Instant::now() < deadline {
754
+ match process.read_combined(Some(Duration::from_millis(200))) {
755
+ ReadStatus::Line(event) => {
756
+ let line = String::from_utf8_lossy(&event.line).into_owned();
757
+ if let Some(rest) = line.strip_prefix("GRANDCHILD_PID=") {
758
+ grandchild_pid = rest.trim().parse::<u32>().ok();
759
+ break;
760
+ }
761
+ }
762
+ ReadStatus::Timeout => continue,
763
+ ReadStatus::Eof => panic!("parent exited before announcing grandchild"),
764
+ }
765
+ }
766
+ let grandchild_pid = grandchild_pid.expect("did not observe GRANDCHILD_PID line");
767
+
768
+ // The grandchild now holds the stdout pipe open. kill() on the
769
+ // parent reaps the parent but the reader thread is still blocked
770
+ // on read(); without the bounded wait this hangs forever.
771
+ let kill_start = Instant::now();
772
+ process.kill().expect("kill() returned an error");
773
+ let kill_elapsed = kill_start.elapsed();
774
+ assert!(
775
+ kill_elapsed < Duration::from_secs(5),
776
+ "kill() blocked for {kill_elapsed:?}; expected bounded return after grandchild orphan",
777
+ );
778
+
779
+ // Cleanup: terminate the lingering grandchild so it doesn't leak.
780
+ #[cfg(windows)]
781
+ {
782
+ let _ = Command::new("taskkill")
783
+ .args(["/F", "/T", "/PID", &grandchild_pid.to_string()])
784
+ .stdout(Stdio::null())
785
+ .stderr(Stdio::null())
786
+ .status();
787
+ }
788
+ #[cfg(not(windows))]
789
+ unsafe {
790
+ libc::kill(grandchild_pid as i32, libc::SIGKILL);
791
+ }
792
+ }
793
+
651
794
  // ── pid() ──
652
795
 
653
796
  #[test]
@@ -16,10 +16,10 @@ crate-type = ["cdylib", "lib"]
16
16
  libc = "0.2"
17
17
  pyo3 = { workspace = true }
18
18
  regex = "1"
19
- running-process-core = { path = "../running-process-core", version = "3.4.0", features = ["originator-scan"] }
19
+ running-process-core = { path = "../running-process-core", version = "3.4.1", features = ["originator-scan"] }
20
20
  sysinfo = "0.30"
21
21
  winapi = { version = "0.3", features = ["consoleapi", "handleapi", "jobapi2", "processenv", "processthreadsapi", "synchapi", "winbase", "wincon", "winnt", "winuser"] }
22
- running-process-proto = { path = "../running-process-proto", version = "3.4.0" }
23
- running-process-client = { path = "../running-process-client", version = "3.4.0" }
22
+ running-process-proto = { path = "../running-process-proto", version = "3.4.1" }
23
+ running-process-client = { path = "../running-process-client", version = "3.4.1" }
24
24
  prost = "0.14"
25
25
  interprocess = "2"
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "running_process"
7
- version = "3.4.0"
7
+ version = "3.4.1"
8
8
  description = "A Rust-backed subprocess wrapper with split stdout/stderr streaming"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "3.4.0"
3
+ __version__ = "3.4.1"
4
4
 
5
5
  from running_process._native import (
6
6
  ContainedProcessGroup,
File without changes