@tishlang/tish-lint 2.12.0 → 2.35.0

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 (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-lint +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/Cargo.toml +1 -1
  63. package/crates/tish_lint/src/lib.rs +126 -0
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-lint +0 -0
  85. package/platform/darwin-x64/tish-lint +0 -0
  86. package/platform/linux-arm64/tish-lint +0 -0
  87. package/platform/linux-x64/tish-lint +0 -0
  88. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -201,7 +201,7 @@ impl HttpBody {
201
201
  };
202
202
  Box::pin(async move {
203
203
  match resp {
204
- Ok(r) => r.text().await.map_err(|e| e.to_string()),
204
+ Ok(r) => read_text_capped(r, fetch_max_body()).await,
205
205
  Err(e) => Err(e),
206
206
  }
207
207
  })
@@ -373,12 +373,116 @@ fn fetch_client() -> &'static reqwest::Client {
373
373
  })
374
374
  }
375
375
 
376
+ /// Maximum outbound-`fetch` response body we will buffer, in bytes. `reqwest::Response::text()`
377
+ /// reads the whole body with no bound, so a compromised or hostile upstream (or a
378
+ /// compression-amplified response) could OOM the worker (#383). Override with `TISH_FETCH_MAX_BODY`;
379
+ /// default 100 MiB — generous enough for normal API responses and moderate downloads, low enough to
380
+ /// stop a pathological body. Set it higher for legitimately large downloads.
381
+ fn fetch_max_body() -> usize {
382
+ use std::sync::OnceLock;
383
+ static MAX: OnceLock<usize> = OnceLock::new();
384
+ *MAX.get_or_init(|| parse_max_body(std::env::var("TISH_FETCH_MAX_BODY").ok()))
385
+ }
386
+
387
+ /// Pure `TISH_FETCH_MAX_BODY` parse: a valid byte count, else the 100 MiB default. Split out so it is
388
+ /// testable without touching process-global env / the `OnceLock` cache.
389
+ fn parse_max_body(env: Option<String>) -> usize {
390
+ env.and_then(|v| v.parse().ok()).unwrap_or(100 * 1024 * 1024)
391
+ }
392
+
393
+ /// Read a response body to a `String`, streaming with a byte budget so it can never buffer more than
394
+ /// `max` bytes (#383). Rejects early on a `Content-Length` that already exceeds the cap, and again mid
395
+ /// stream for chunked/lying responses. Decodes UTF-8 lossily (matching `reqwest::text()` for the
396
+ /// UTF-8 responses that dominate `fetch`).
397
+ async fn read_text_capped(mut resp: reqwest::Response, max: usize) -> Result<String, String> {
398
+ if let Some(len) = resp.content_length() {
399
+ if len as usize > max {
400
+ return Err(format!(
401
+ "fetch: response body ({len} bytes, Content-Length) exceeds the {max}-byte cap (set TISH_FETCH_MAX_BODY to raise it)"
402
+ ));
403
+ }
404
+ }
405
+ let mut buf: Vec<u8> = Vec::new();
406
+ while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
407
+ if buf.len() + chunk.len() > max {
408
+ return Err(format!(
409
+ "fetch: response body exceeds the {max}-byte cap (set TISH_FETCH_MAX_BODY to raise it)"
410
+ ));
411
+ }
412
+ buf.extend_from_slice(&chunk);
413
+ }
414
+ Ok(String::from_utf8_lossy(&buf).into_owned())
415
+ }
416
+
417
+ /// Opt-in SSRF guard (#383): when `TISH_FETCH_BLOCK_PRIVATE_IPS=1`, `fetch()` refuses to connect to a
418
+ /// loopback / private / link-local / unique-local address. Off by default so localhost development is
419
+ /// unaffected; enable it when the request URL can derive from untrusted input (metadata-endpoint /
420
+ /// internal-service pivots). NOTE: this is a pre-flight resolution check, so a DNS-rebinding attacker
421
+ /// could in principle change the record between this check and reqwest's own resolution — a follow-up
422
+ /// could move the filter into a custom `reqwest` resolver to close that window.
423
+ fn fetch_block_private() -> bool {
424
+ use std::sync::OnceLock;
425
+ static ON: OnceLock<bool> = OnceLock::new();
426
+ *ON.get_or_init(|| std::env::var("TISH_FETCH_BLOCK_PRIVATE_IPS").as_deref() == Ok("1"))
427
+ }
428
+
429
+ /// True for addresses a hardened `fetch()` must not reach: loopback, RFC1918 private, link-local
430
+ /// (incl. the `169.254.169.254` cloud-metadata endpoint), unspecified/broadcast, `0.0.0.0/8`, IPv6
431
+ /// unique-local (`fc00::/7`) and link-local (`fe80::/10`), and IPv4-mapped forms of all the above.
432
+ fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
433
+ use std::net::IpAddr;
434
+ match ip {
435
+ IpAddr::V4(v4) => {
436
+ v4.is_loopback()
437
+ || v4.is_private()
438
+ || v4.is_link_local()
439
+ || v4.is_broadcast()
440
+ || v4.is_unspecified()
441
+ || v4.octets()[0] == 0 // 0.0.0.0/8 "this network"
442
+ }
443
+ IpAddr::V6(v6) => {
444
+ v6.is_loopback()
445
+ || v6.is_unspecified()
446
+ || (v6.segments()[0] & 0xfe00) == 0xfc00 // unique-local fc00::/7
447
+ || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
448
+ || v6
449
+ .to_ipv4_mapped()
450
+ .is_some_and(|m| is_blocked_ip(IpAddr::V4(m)))
451
+ }
452
+ }
453
+ }
454
+
455
+ /// Reject the request if the URL's host resolves to a blocked address (only when the opt-in policy is
456
+ /// enabled). `tokio::net::lookup_host` handles both literal IPs and hostnames.
457
+ async fn ssrf_preflight(url: &str) -> Result<(), String> {
458
+ let parsed = reqwest::Url::parse(url).map_err(|e| format!("fetch: invalid URL: {e}"))?;
459
+ let host = parsed
460
+ .host_str()
461
+ .ok_or_else(|| "fetch: URL has no host".to_string())?;
462
+ let port = parsed.port_or_known_default().unwrap_or(80);
463
+ let addrs = tokio::net::lookup_host((host, port))
464
+ .await
465
+ .map_err(|e| format!("fetch: cannot resolve {host}: {e}"))?;
466
+ for addr in addrs {
467
+ if is_blocked_ip(addr.ip()) {
468
+ return Err(format!(
469
+ "fetch: blocked by SSRF policy — host {host} resolves to internal address {}",
470
+ addr.ip()
471
+ ));
472
+ }
473
+ }
474
+ Ok(())
475
+ }
476
+
376
477
  async fn send_request_parts(
377
478
  url: String,
378
479
  method: String,
379
480
  headers: Vec<(String, String)>,
380
481
  body: Option<String>,
381
482
  ) -> Result<reqwest::Response, String> {
483
+ if fetch_block_private() {
484
+ ssrf_preflight(&url).await?;
485
+ }
382
486
  let client = fetch_client();
383
487
  let mut req = match method.as_str() {
384
488
  "GET" => client.get(&url),
@@ -490,3 +594,48 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
490
594
  rx: Mutex::new(Some(rx)),
491
595
  }))
492
596
  }
597
+
598
+ #[cfg(test)]
599
+ mod fetch_cap_tests_383 {
600
+ use super::{is_blocked_ip, parse_max_body};
601
+ use std::net::IpAddr;
602
+
603
+ #[test]
604
+ fn default_cap_is_100_mib() {
605
+ assert_eq!(parse_max_body(None), 100 * 1024 * 1024);
606
+ assert_eq!(parse_max_body(Some("not-a-number".into())), 100 * 1024 * 1024);
607
+ }
608
+
609
+ #[test]
610
+ fn env_override_parses() {
611
+ assert_eq!(parse_max_body(Some("1048576".into())), 1024 * 1024);
612
+ assert_eq!(parse_max_body(Some("0".into())), 0);
613
+ }
614
+
615
+ #[test]
616
+ fn ssrf_blocks_internal_addresses() {
617
+ for s in [
618
+ "127.0.0.1", // loopback
619
+ "10.0.0.5", // RFC1918
620
+ "172.16.9.9", // RFC1918
621
+ "192.168.1.1", // RFC1918
622
+ "169.254.169.254", // link-local / cloud metadata
623
+ "0.0.0.0", // unspecified
624
+ "::1", // IPv6 loopback
625
+ "fc00::1", // IPv6 unique-local
626
+ "fe80::1", // IPv6 link-local
627
+ "::ffff:127.0.0.1", // IPv4-mapped loopback
628
+ ] {
629
+ let ip: IpAddr = s.parse().unwrap();
630
+ assert!(is_blocked_ip(ip), "{s} must be blocked");
631
+ }
632
+ }
633
+
634
+ #[test]
635
+ fn ssrf_allows_public_addresses() {
636
+ for s in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1::"] {
637
+ let ip: IpAddr = s.parse().unwrap();
638
+ assert!(!is_blocked_ip(ip), "{s} must be allowed");
639
+ }
640
+ }
641
+ }
@@ -10,7 +10,7 @@
10
10
  //! N OS threads (one per CPU, pinned via core_affinity)
11
11
  //! ├─ single-threaded tokio runtime per thread
12
12
  //! ├─ SO_REUSEPORT-bound TcpListener per thread
13
- //! └─ hyper HTTP/1.1 + HTTP/2 server
13
+ //! └─ hyper HTTP/1.1 server (`http1::Builder`)
14
14
  //! │
15
15
  //! ├─ async per-connection state machine (no OS thread)
16
16
  //! ├─ static-route fast path (lock-free ArcSwap<HashMap> from
@@ -23,13 +23,17 @@
23
23
  //!
24
24
  //! * Removes tiny_http's thread-per-connection model (the bottleneck on
25
25
  //! every macOS/Linux Tish HTTP server at >50k concurrent connections).
26
- //! * Gives HTTP/2 + TLS-ready surface for free (hyper handles the state
27
- //! machine; we only have to convert our `RequestPrimitive` to/from
28
- //! `http::Request` / `http::Response`).
29
26
  //! * Shared async context with `reqwest`, `tokio-postgres`, `tokio` in
30
27
  //! general — the tokio runtime is reused for client fetch, db, and
31
28
  //! server accept.
32
29
  //!
30
+ //! ## Protocol support
31
+ //!
32
+ //! HTTP/1.1 only today (the accept loop uses `http1::Builder`). The `hyper` dependency enables the
33
+ //! `http2` feature, so upgrading to an `hyper_util::server::conn::auto` builder (h2c prior-knowledge,
34
+ //! and HTTP/2 over TLS once ALPN is wired) is a contained follow-up — tracked separately. Do NOT
35
+ //! advertise HTTP/2 until that lands.
36
+ //!
33
37
  //! ## Integration with the Tish VM
34
38
  //!
35
39
  //! The Tish handler returns a synchronous `Value`. We call it from inside
@@ -182,20 +186,37 @@ where
182
186
  }
183
187
  drop(tx);
184
188
 
185
- // VM-thread dispatcher loop: identical to the tiny_http path.
189
+ // VM-thread dispatcher loop. Mirrors the tiny_http path (#384): `recv_timeout` rather than a
190
+ // blocking `recv` so a handler's scheduled timers still FIRE when the server is idle, and
191
+ // `drain_timers()` runs after every handler call AND on each idle tick. Handler calls all run on
192
+ // this single VM thread, so the timer registry lives here — without the drain a `setTimeout`
193
+ // scheduled from a hyper handler would never fire and never be freed.
186
194
  let mut count = 0usize;
187
- while let Ok((req_prim, resp_tx)) = rx.recv() {
188
- let req_value = req_prim.into_value_pub();
189
- // `handler` is the generic `F: Fn(&[Value]) -> Value` — call it directly. (`.call(..)` here
190
- // would bind the *unstable* `Fn::call` trait method, which takes a tuple, not `&[Value]`.)
191
- let response_value = handler(&[req_value]);
192
- let resp_prim = ResponsePrimitive::from_value_pub(&response_value);
193
- let _ = resp_tx.send(resp_prim);
194
-
195
- count += 1;
196
- if max_requests.map(|m| count >= m).unwrap_or(false) {
197
- stop.store(true, Ordering::Relaxed);
198
- break;
195
+ loop {
196
+ match rx.recv_timeout(std::time::Duration::from_millis(250)) {
197
+ Ok((req_prim, resp_tx)) => {
198
+ let req_value = req_prim.into_value_pub();
199
+ // `handler` is the generic `F: Fn(&[Value]) -> Value` — call it directly. (`.call(..)`
200
+ // here would bind the *unstable* `Fn::call` trait method, which takes a tuple, not
201
+ // `&[Value]`.)
202
+ let response_value = handler(&[req_value]);
203
+ crate::timers::drain_timers();
204
+ let resp_prim = ResponsePrimitive::from_value_pub(&response_value);
205
+ let _ = resp_tx.send(resp_prim);
206
+
207
+ count += 1;
208
+ if max_requests.map(|m| count >= m).unwrap_or(false) {
209
+ stop.store(true, Ordering::Relaxed);
210
+ break;
211
+ }
212
+ }
213
+ Err(std_mpsc::RecvTimeoutError::Timeout) => {
214
+ crate::timers::drain_timers();
215
+ if stop.load(Ordering::Relaxed) {
216
+ break;
217
+ }
218
+ }
219
+ Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
199
220
  }
200
221
  }
201
222
 
@@ -295,7 +316,9 @@ async fn handle_request(
295
316
 
296
317
  // Slow path: cross the mpsc to the VM thread.
297
318
  let (method, url, path, query, headers, body_bytes) = extract_request(req).await;
298
- let body = String::from_utf8(body_bytes).unwrap_or_default();
319
+ // Lossy UTF-8 decode to match the tiny_http path (`http.rs`): a body with invalid UTF-8 becomes
320
+ // replacement chars, NOT an empty string — `req.body` is a String and both backends must agree.
321
+ let body = String::from_utf8_lossy(&body_bytes).into_owned();
299
322
  let prim = RequestPrimitive::new_pub(method, url, path, query, headers, body);
300
323
 
301
324
  let (resp_tx, resp_rx) = std_mpsc::sync_channel::<ResponsePrimitive>(1);
@@ -57,6 +57,8 @@
57
57
  use std::io;
58
58
  use std::process::{Child, Command};
59
59
  use std::sync::atomic::{AtomicBool, Ordering};
60
+ #[cfg(unix)]
61
+ use std::sync::atomic::{AtomicI32, AtomicUsize};
60
62
  use std::sync::Arc;
61
63
 
62
64
  /// Role of the current process in a prefork group.
@@ -142,28 +144,57 @@ pub fn install_parent_signal_handler(children: Vec<Child>) -> Arc<AtomicBool> {
142
144
  stop
143
145
  }
144
146
 
147
+ /// Managed child PIDs, in a fixed atomic array (not a `Vec`/`OnceLock`): the signal handler may only
148
+ /// do async-signal-safe work, so it reads plain atomics — never a lock or an allocation. Each
149
+ /// `serve()` APPENDS its group here (#384), so a second `serve()` in the same parent no longer orphans
150
+ /// its children on SIGINT/SIGTERM (the previous `OnceLock` silently dropped every group after the
151
+ /// first). Ample for any realistic worker count; extra children beyond the cap simply aren't managed.
152
+ #[cfg(unix)]
153
+ const MAX_MANAGED_PIDS: usize = 4096;
154
+ #[cfg(unix)]
155
+ static CHILD_PIDS: [AtomicI32; MAX_MANAGED_PIDS] =
156
+ [const { AtomicI32::new(0) }; MAX_MANAGED_PIDS];
157
+ #[cfg(unix)]
158
+ static CHILD_PID_COUNT: AtomicUsize = AtomicUsize::new(0);
159
+
160
+ /// Append a `serve()` group's child PIDs to the managed set. Stores land before the count is published
161
+ /// (the SeqCst count store is the release), and the signal handler skips non-positive slots — so it
162
+ /// never calls `kill(0, …)` (which would signal the whole process group). Installs are sequential (the
163
+ /// parent's own thread), so no compare-exchange is needed.
164
+ #[cfg(unix)]
165
+ fn register_managed_pids(pids: &[u32]) {
166
+ let start = CHILD_PID_COUNT.load(Ordering::SeqCst);
167
+ for (i, pid) in pids.iter().enumerate() {
168
+ if let Some(slot) = CHILD_PIDS.get(start + i) {
169
+ slot.store(*pid as i32, Ordering::SeqCst);
170
+ }
171
+ }
172
+ CHILD_PID_COUNT.store((start + pids.len()).min(MAX_MANAGED_PIDS), Ordering::SeqCst);
173
+ }
174
+
145
175
  #[cfg(unix)]
146
176
  fn install_shutdown_handler(stop: Arc<AtomicBool>, pids: Vec<u32>) {
147
- // Store state in process-global statics so an `extern "C"` fn can reach
148
- // them from inside a signal handler. This is the usual pattern for
149
- // libc::signal callbacks — setting a flag + waking up listeners is the
150
- // only async-signal-safe work we do here.
177
+ // A single process-global stop flag the handler sets. First `serve()` wins the slot; that is fine
178
+ // because the handler re-raises the signal with default disposition below, which terminates the
179
+ // parent regardless of which accept loop is polling — the flag only matters for the non-signal
180
+ // (max-requests) path, which each loop drives on its own returned `stop` handle.
151
181
  use std::sync::OnceLock;
152
182
  static STOP_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
153
- static CHILD_PIDS: OnceLock<Vec<u32>> = OnceLock::new();
154
-
155
183
  let _ = STOP_FLAG.set(stop);
156
- let _ = CHILD_PIDS.set(pids);
184
+
185
+ register_managed_pids(&pids);
157
186
 
158
187
  extern "C" fn on_signal(sig: libc::c_int) {
159
188
  if let Some(flag) = STOP_FLAG.get() {
160
189
  flag.store(true, Ordering::Relaxed);
161
190
  }
162
- if let Some(pids) = CHILD_PIDS.get() {
163
- for pid in pids {
191
+ let count = CHILD_PID_COUNT.load(Ordering::SeqCst).min(MAX_MANAGED_PIDS);
192
+ for slot in CHILD_PIDS.iter().take(count) {
193
+ let pid = slot.load(Ordering::SeqCst);
194
+ if pid > 0 {
164
195
  // codacy-disable-next-line
165
- unsafe {
166
- libc::kill(*pid as libc::pid_t, libc::SIGTERM);
196
+ unsafe {
197
+ libc::kill(pid as libc::pid_t, libc::SIGTERM);
167
198
  }
168
199
  }
169
200
  }
@@ -187,3 +218,33 @@ fn install_shutdown_handler(stop: Arc<AtomicBool>, pids: Vec<u32>) {
187
218
  fn install_shutdown_handler(_stop: Arc<AtomicBool>, _pids: Vec<u32>) {
188
219
  // TODO: SetConsoleCtrlHandler on Windows.
189
220
  }
221
+
222
+ #[cfg(all(test, unix))]
223
+ mod prefork_pid_tests_384 {
224
+ use super::*;
225
+
226
+ /// Read the managed PID set the way the signal handler does (skipping empty slots).
227
+ fn managed_snapshot() -> Vec<i32> {
228
+ let count = CHILD_PID_COUNT.load(Ordering::SeqCst).min(MAX_MANAGED_PIDS);
229
+ CHILD_PIDS
230
+ .iter()
231
+ .take(count)
232
+ .map(|s| s.load(Ordering::SeqCst))
233
+ .filter(|&p| p > 0)
234
+ .collect()
235
+ }
236
+
237
+ #[test]
238
+ fn repeat_serve_appends_child_pids() {
239
+ // Reset the process-global set (this is the only test that touches it).
240
+ CHILD_PID_COUNT.store(0, Ordering::SeqCst);
241
+ for s in CHILD_PIDS.iter() {
242
+ s.store(0, Ordering::SeqCst);
243
+ }
244
+ // First serve() group, then a SECOND — the old OnceLock silently dropped the second, orphaning
245
+ // its children on shutdown. Now both groups are managed.
246
+ register_managed_pids(&[101, 102]);
247
+ register_managed_pids(&[201, 202, 203]);
248
+ assert_eq!(managed_snapshot(), vec![101, 102, 201, 202, 203]);
249
+ }
250
+ }