@tishlang/tish-format 3.10.4 → 3.10.11

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 (44) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/tish/src/cargo_native_registry.rs +4 -0
  3. package/crates/tish/src/main.rs +65 -25
  4. package/crates/tish/tests/integration_test.rs +3 -0
  5. package/crates/tish_builtins/src/globals.rs +3 -3
  6. package/crates/tish_builtins/src/symbol.rs +86 -4
  7. package/crates/tish_bytecode/src/compiler.rs +65 -25
  8. package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
  9. package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
  10. package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
  11. package/crates/tish_core/src/shape.rs +55 -2
  12. package/crates/tish_core/src/value.rs +229 -13
  13. package/crates/tish_eval/src/timers.rs +66 -2
  14. package/crates/tish_ffi/src/lib.rs +74 -12
  15. package/crates/tish_ffi/tests/loader.rs +35 -0
  16. package/crates/tish_fmt/Cargo.toml +1 -1
  17. package/crates/tish_lsp/src/main.rs +91 -2
  18. package/crates/tish_pg/src/lib.rs +59 -8
  19. package/crates/tish_pg/src/statement_registry.rs +250 -0
  20. package/crates/tish_runtime/src/http.rs +19 -0
  21. package/crates/tish_runtime/src/http_fetch.rs +159 -65
  22. package/crates/tish_runtime/src/http_hyper.rs +18 -2
  23. package/crates/tish_runtime/src/lib.rs +4 -0
  24. package/crates/tish_runtime/src/net.rs +125 -97
  25. package/crates/tish_runtime/src/process_spawn.rs +326 -113
  26. package/crates/tish_runtime/src/promise.rs +521 -90
  27. package/crates/tish_runtime/src/promise_io.rs +29 -6
  28. package/crates/tish_runtime/src/pty.rs +161 -107
  29. package/crates/tish_runtime/src/stream_buf.rs +419 -0
  30. package/crates/tish_runtime/src/ws.rs +431 -52
  31. package/crates/tish_vm/src/jit.rs +473 -67
  32. package/crates/tish_vm/src/lib.rs +6 -0
  33. package/crates/tish_vm/src/vm.rs +8 -5
  34. package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
  35. package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
  36. package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
  37. package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
  38. package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
  39. package/package.json +1 -1
  40. package/platform/darwin-arm64/tish-fmt +0 -0
  41. package/platform/darwin-x64/tish-fmt +0 -0
  42. package/platform/linux-arm64/tish-fmt +0 -0
  43. package/platform/linux-x64/tish-fmt +0 -0
  44. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -20,27 +20,31 @@
20
20
  //! - `kill(id) -> bool`
21
21
  //! - `pid(id) -> number | null`
22
22
  //!
23
- //! Two per-session reader threads (stdout, stderr) fill byte buffers that the read fns drain at
24
- //! a UTF-8 boundary — an incomplete trailing multibyte sequence is held for the next read (no
25
- //! mojibake). A global `OnceLock<Mutex<HashMap<id, ProcSession>>>` registry mirrors `pty.rs`;
23
+ //! Two per-session reader threads (stdout, stderr) fill bounded byte buffers that the read fns
24
+ //! drain at a UTF-8 boundary (see `stream_buf.rs` for the cap/backpressure and invalid-byte
25
+ //! semantics). A global `OnceLock<Mutex<HashMap<id, ProcSession>>>` registry mirrors `pty.rs`;
26
26
  //! errors surface as `null` / `false` rather than panicking.
27
-
28
- use std::collections::HashMap;
29
- use std::io::{Read, Write};
27
+ //!
28
+ //! Session lifecycle: an entry lives until `kill(id)` — or, since the documented
29
+ //! `spawn -> wait -> forget` flow never kills, until it is *reaped*: once the child has exited
30
+ //! and both stream buffers are fully drained, an opportunistic sweep on each `spawn` (plus a
31
+ //! check when a read returns `null` at EOF) removes the entry. The exit code and pid live on in
32
+ //! a small bounded tombstone map so a late `wait(id)` / `pid(id)` still answers, and `kill(id)`
33
+ //! on a reaped id still reports `true` once. The child's stdin write-end is dropped as soon as
34
+ //! the exit is observed so its fd frees immediately.
35
+
36
+ use std::collections::{BTreeMap, HashMap};
37
+ use std::io::Write;
30
38
  use std::process::{Child, ChildStdin, Command, Stdio};
31
39
  use std::sync::atomic::{AtomicU64, Ordering};
32
- use std::sync::{Arc, Condvar, Mutex, OnceLock};
40
+ use std::sync::{Mutex, OnceLock};
33
41
  use std::time::{Duration, Instant};
34
42
 
35
43
  use tishlang_core::Value;
36
44
 
37
- /// Output accumulated by a reader thread, drained by `readStdout` / `readStderr`.
38
- struct StreamBuf {
39
- data: Vec<u8>,
40
- eof: bool,
41
- }
42
-
43
- type SharedBuf = Arc<(Mutex<StreamBuf>, Condvar)>;
45
+ use crate::stream_buf::{
46
+ drain_stream, is_drained, new_buf, retire_buf, spawn_reader, spawn_side_reader, SharedBuf,
47
+ };
44
48
 
45
49
  struct ProcSession {
46
50
  child: Mutex<Child>,
@@ -55,6 +59,95 @@ fn sessions() -> &'static Mutex<HashMap<u64, ProcSession>> {
55
59
  S.get_or_init(|| Mutex::new(HashMap::new()))
56
60
  }
57
61
 
62
+ /// Exit record kept after a session is reaped, so late `wait`/`pid`/`kill` calls still answer.
63
+ struct Tombstone {
64
+ code: i32,
65
+ pid: Option<u32>,
66
+ }
67
+
68
+ /// Bounded: ids are monotonic, so evicting the smallest key evicts the oldest record.
69
+ const MAX_TOMBSTONES: usize = 1024;
70
+
71
+ fn tombstones() -> &'static Mutex<BTreeMap<u64, Tombstone>> {
72
+ static T: OnceLock<Mutex<BTreeMap<u64, Tombstone>>> = OnceLock::new();
73
+ T.get_or_init(|| Mutex::new(BTreeMap::new()))
74
+ }
75
+
76
+ fn remember_tombstone(id: u64, t: Tombstone) {
77
+ if let Ok(mut m) = tombstones().lock() {
78
+ m.insert(id, t);
79
+ while m.len() > MAX_TOMBSTONES {
80
+ m.pop_first();
81
+ }
82
+ }
83
+ }
84
+
85
+ /// Reap every session whose child has exited AND whose streams are fully drained; drop the
86
+ /// stdin write-end of any exited child so its fd frees even when the buffers still hold data.
87
+ fn sweep_sessions() {
88
+ let mut reaped: Vec<(u64, Tombstone)> = Vec::new();
89
+ {
90
+ let mut g = match sessions().lock() {
91
+ Ok(g) => g,
92
+ Err(_) => return,
93
+ };
94
+ g.retain(|id, s| {
95
+ let status = match s.child.lock() {
96
+ Ok(mut c) => c.try_wait().ok().flatten(),
97
+ Err(_) => None,
98
+ };
99
+ let Some(st) = status else { return true };
100
+ if let Ok(mut w) = s.stdin.lock() {
101
+ *w = None;
102
+ }
103
+ if is_drained(&s.out) && is_drained(&s.err) {
104
+ reaped.push((
105
+ *id,
106
+ Tombstone {
107
+ code: st.code().unwrap_or(-1),
108
+ pid: s.pid,
109
+ },
110
+ ));
111
+ false
112
+ } else {
113
+ true
114
+ }
115
+ });
116
+ }
117
+ for (id, t) in reaped {
118
+ remember_tombstone(id, t);
119
+ }
120
+ }
121
+
122
+ /// Reap one session if (and only if) its child has exited and both streams are drained.
123
+ fn try_reap(id: u64) {
124
+ let tomb = {
125
+ let mut g = match sessions().lock() {
126
+ Ok(g) => g,
127
+ Err(_) => return,
128
+ };
129
+ let Some(s) = g.get(&id) else { return };
130
+ let status = match s.child.lock() {
131
+ Ok(mut c) => c.try_wait().ok().flatten(),
132
+ Err(_) => None,
133
+ };
134
+ let Some(st) = status else { return };
135
+ if let Ok(mut w) = s.stdin.lock() {
136
+ *w = None;
137
+ }
138
+ if !(is_drained(&s.out) && is_drained(&s.err)) {
139
+ return;
140
+ }
141
+ let tomb = Tombstone {
142
+ code: st.code().unwrap_or(-1),
143
+ pid: s.pid,
144
+ };
145
+ g.remove(&id);
146
+ tomb
147
+ };
148
+ remember_tombstone(id, tomb);
149
+ }
150
+
58
151
  static NEXT_ID: AtomicU64 = AtomicU64::new(1);
59
152
 
60
153
  // --- Value helpers (mirror pty.rs idioms) ---
@@ -88,95 +181,14 @@ fn obj_str(o: &Value, key: &str) -> Option<String> {
88
181
  }
89
182
  }
90
183
 
91
- fn new_buf() -> SharedBuf {
92
- Arc::new((
93
- Mutex::new(StreamBuf {
94
- data: Vec::new(),
95
- eof: false,
96
- }),
97
- Condvar::new(),
98
- ))
99
- }
100
-
101
- /// Reader thread: block on a pipe, append bytes, wake any waiting reader; mark EOF on 0/err.
102
- fn spawn_reader<R: Read + Send + 'static>(mut r: R, buf: SharedBuf) {
103
- std::thread::spawn(move || {
104
- let mut tmp = [0u8; 8192];
105
- loop {
106
- match r.read(&mut tmp) {
107
- Ok(0) => {
108
- let (lock, cv) = &*buf;
109
- if let Ok(mut b) = lock.lock() {
110
- b.eof = true;
111
- }
112
- cv.notify_all();
113
- break;
114
- }
115
- Ok(n) => {
116
- let (lock, cv) = &*buf;
117
- if let Ok(mut b) = lock.lock() {
118
- b.data.extend_from_slice(&tmp[..n]);
119
- }
120
- cv.notify_all();
121
- }
122
- Err(_) => {
123
- let (lock, cv) = &*buf;
124
- if let Ok(mut b) = lock.lock() {
125
- b.eof = true;
126
- }
127
- cv.notify_all();
128
- break;
129
- }
130
- }
131
- }
132
- });
133
- }
134
-
135
- /// Drain a stream buffer as a string (possibly `""` within the timeout), or `null` at EOF.
136
- /// Drains only a valid UTF-8 prefix, holding any incomplete trailing multibyte for next time
137
- /// (identical semantics to `pty::pty_read`).
138
- fn drain_stream(buf: &SharedBuf, timeout_ms: u64) -> Value {
139
- let (lock, cv) = &**buf;
140
- let mut b = match lock.lock() {
141
- Ok(b) => b,
142
- Err(_) => return Value::Null,
143
- };
144
- if b.data.is_empty() && !b.eof && timeout_ms > 0 {
145
- let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
146
- b.data.is_empty() && !b.eof
147
- });
148
- b = match res {
149
- Ok((g, _)) => g,
150
- Err(e) => e.into_inner().0,
151
- };
152
- }
153
- if b.data.is_empty() {
154
- if b.eof {
155
- return Value::Null;
156
- }
157
- return Value::String("".into());
158
- }
159
- let valid = match std::str::from_utf8(&b.data) {
160
- Ok(_) => b.data.len(),
161
- Err(e) => e.valid_up_to(),
162
- };
163
- if valid == 0 {
164
- if b.eof {
165
- let s = String::from_utf8_lossy(&b.data).into_owned();
166
- b.data.clear();
167
- return Value::String(s.into());
168
- }
169
- return Value::String("".into());
170
- }
171
- let out: Vec<u8> = b.data.drain(..valid).collect();
172
- let s = String::from_utf8(out).unwrap_or_default();
173
- Value::String(s.into())
174
- }
175
-
176
184
  /// `spawn({ program, args?, cwd?, env? })` → a stable id for read/write/wait/kill, or `null`
177
185
  /// on failure. `program` is required; `args` is an array of strings; `env` entries are ADDED to
178
186
  /// the inherited environment.
179
187
  pub fn process_spawn(args: &[Value]) -> Value {
188
+ // Opportunistic reap of exited-and-drained sessions, so the documented
189
+ // spawn -> wait -> forget lifecycle cannot grow the table (or leak fds) without bound.
190
+ sweep_sessions();
191
+
180
192
  let null = Value::Null;
181
193
  let opts = args.first().unwrap_or(&null);
182
194
 
@@ -242,7 +254,9 @@ pub fn process_spawn(args: &[Value]) -> Value {
242
254
  let out = new_buf();
243
255
  let err = new_buf();
244
256
  spawn_reader(stdout, out.clone());
245
- spawn_reader(stderr, err.clone());
257
+ // stderr is a SIDE channel programs may never read: DropOldest, not Park — parking would
258
+ // block the child's stderr write(2) at the cap and freeze its stdout with it.
259
+ spawn_side_reader(stderr, err.clone());
246
260
 
247
261
  let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
248
262
  let session = ProcSession {
@@ -283,7 +297,13 @@ fn read_from(args: &[Value], stderr: bool) -> Value {
283
297
  None => return Value::Null,
284
298
  }
285
299
  };
286
- drain_stream(&buf, timeout_ms)
300
+ let out = drain_stream(&buf, timeout_ms);
301
+ if matches!(out, Value::Null) {
302
+ // This stream hit EOF fully drained; if the sibling stream is done too and the child
303
+ // has exited, the session is fully consumed — reap it (tombstone keeps wait/pid live).
304
+ try_reap(id);
305
+ }
306
+ out
287
307
  }
288
308
 
289
309
  /// `readStdout(id, timeoutMs?)` → available stdout, `""` (live, none yet), or `null` at EOF/unknown.
@@ -352,6 +372,7 @@ pub fn process_close_stdin(args: &[Value]) -> Value {
352
372
 
353
373
  /// `wait(id, timeoutMs?)` → the child's exit code once it has exited, or `null` while it is still
354
374
  /// running (or for an unknown id). Polls up to `timeoutMs` (0 = a single non-blocking check).
375
+ /// Still answers after the session was reaped (the exit code is tombstoned).
355
376
  pub fn process_wait(args: &[Value]) -> Value {
356
377
  let id = match arg_u64(args, 0) {
357
378
  Some(x) => x,
@@ -369,13 +390,30 @@ pub fn process_wait(args: &[Value]) -> Value {
369
390
  };
370
391
  let s = match g.get(&id) {
371
392
  Some(s) => s,
372
- None => return Value::Null,
393
+ None => {
394
+ // Reaped after a natural exit: the tombstoned exit code still answers.
395
+ return match tombstones().lock() {
396
+ Ok(t) => match t.get(&id) {
397
+ Some(tomb) => Value::Number(tomb.code as f64),
398
+ None => Value::Null,
399
+ },
400
+ Err(_) => Value::Null,
401
+ };
402
+ }
373
403
  };
374
404
  let mut c = match s.child.lock() {
375
405
  Ok(c) => c,
376
406
  Err(_) => return Value::Null,
377
407
  };
378
- c.try_wait()
408
+ let status = c.try_wait();
409
+ if let Ok(Some(_)) = status {
410
+ // Child gone: release its stdin write-end fd immediately (the session itself
411
+ // stays until its buffers are drained).
412
+ if let Ok(mut w) = s.stdin.lock() {
413
+ *w = None;
414
+ }
415
+ }
416
+ status
379
417
  };
380
418
  match status {
381
419
  Ok(Some(st)) => return Value::Number(st.code().unwrap_or(-1) as f64),
@@ -389,7 +427,9 @@ pub fn process_wait(args: &[Value]) -> Value {
389
427
  }
390
428
  }
391
429
 
392
- /// `kill(id)` → terminate the child and drop the session. Returns whether the id was live.
430
+ /// `kill(id)` → terminate the child and drop the session. Returns whether the id was live
431
+ /// (including a reaped id, whose child already exited — reported `true` once, like before
432
+ /// reaping existed).
393
433
  pub fn process_kill(args: &[Value]) -> Value {
394
434
  let id = match arg_u64(args, 0) {
395
435
  Some(x) => x,
@@ -408,25 +448,47 @@ pub fn process_kill(args: &[Value]) -> Value {
408
448
  let _ = c.kill();
409
449
  let _ = c.wait();
410
450
  }
451
+ // Wake reader threads parked at the buffer cap so they exit (a read-blocked
452
+ // reader exits on its own once the killed child's pipe closes).
453
+ retire_buf(&s.out);
454
+ retire_buf(&s.err);
411
455
  Value::Bool(true)
412
456
  }
413
- None => Value::Bool(false),
457
+ None => {
458
+ // A reaped session was live from the caller's perspective; consume its tombstone.
459
+ match tombstones().lock() {
460
+ Ok(mut t) => Value::Bool(t.remove(&id).is_some()),
461
+ Err(_) => Value::Bool(false),
462
+ }
463
+ }
414
464
  }
415
465
  }
416
466
 
417
- /// `pid(id)` → the child process id, or `null` for an unknown id.
467
+ /// `pid(id)` → the child process id, or `null` for an unknown id. Still answers after the
468
+ /// session was reaped (the pid is tombstoned).
418
469
  pub fn process_pid(args: &[Value]) -> Value {
419
470
  let id = match arg_u64(args, 0) {
420
471
  Some(x) => x,
421
472
  None => return Value::Null,
422
473
  };
423
- let g = match sessions().lock() {
424
- Ok(g) => g,
425
- Err(_) => return Value::Null,
426
- };
427
- match g.get(&id).and_then(|s| s.pid) {
428
- Some(p) => Value::Number(p as f64),
429
- None => Value::Null,
474
+ {
475
+ let g = match sessions().lock() {
476
+ Ok(g) => g,
477
+ Err(_) => return Value::Null,
478
+ };
479
+ if let Some(s) = g.get(&id) {
480
+ return match s.pid {
481
+ Some(p) => Value::Number(p as f64),
482
+ None => Value::Null,
483
+ };
484
+ }
485
+ }
486
+ match tombstones().lock() {
487
+ Ok(t) => match t.get(&id).and_then(|tomb| tomb.pid) {
488
+ Some(p) => Value::Number(p as f64),
489
+ None => Value::Null,
490
+ },
491
+ Err(_) => Value::Null,
430
492
  }
431
493
  }
432
494
 
@@ -444,6 +506,43 @@ mod tests {
444
506
  }
445
507
  }
446
508
 
509
+ fn spawn_sh(cmd: &str) -> f64 {
510
+ let mut m = tishlang_core::ObjectMap::default();
511
+ m.insert(std::sync::Arc::from("program"), Value::String("sh".into()));
512
+ let args = vec![Value::String("-c".into()), Value::String(cmd.into())];
513
+ m.insert(
514
+ std::sync::Arc::from("args"),
515
+ Value::Array(tishlang_core::VmRef::new(args)),
516
+ );
517
+ match process_spawn(&[Value::object(m)]) {
518
+ Value::Number(n) => n,
519
+ other => panic!("spawn failed: {:?}", other),
520
+ }
521
+ }
522
+
523
+ fn wait_code(id: f64) -> f64 {
524
+ for _ in 0..200 {
525
+ if let Value::Number(n) = process_wait(&[Value::Number(id), Value::Number(50.0)]) {
526
+ return n;
527
+ }
528
+ }
529
+ panic!("child did not exit in time");
530
+ }
531
+
532
+ fn drain_to_eof(id: f64, stderr: bool) {
533
+ for _ in 0..200 {
534
+ let v = if stderr {
535
+ process_read_stderr(&[Value::Number(id), Value::Number(50.0)])
536
+ } else {
537
+ process_read_stdout(&[Value::Number(id), Value::Number(50.0)])
538
+ };
539
+ if matches!(v, Value::Null) {
540
+ return;
541
+ }
542
+ }
543
+ panic!("stream did not reach EOF in time");
544
+ }
545
+
447
546
  #[test]
448
547
  fn spawn_write_read_roundtrip() {
449
548
  let id = spawn_cat();
@@ -505,4 +604,118 @@ mod tests {
505
604
  assert!(matches!(process_kill(&[Value::Number(bad)]), Value::Bool(false)));
506
605
  assert!(matches!(process_pid(&[Value::Number(bad)]), Value::Null));
507
606
  }
607
+
608
+ #[test]
609
+ fn invalid_utf8_byte_does_not_wedge_stdout() {
610
+ // One invalid byte, then valid text (the issue-#709 wedge shape). Pre-fix, every read
611
+ // after the 0xFF returned "" forever while the buffer grew unbounded.
612
+ let id = spawn_sh("printf '\\377'; echo wedge_ok_9");
613
+ let mut acc = String::new();
614
+ for _ in 0..200 {
615
+ match process_read_stdout(&[Value::Number(id), Value::Number(50.0)]) {
616
+ Value::String(s) => acc.push_str(&s),
617
+ Value::Null => break,
618
+ _ => {}
619
+ }
620
+ if acc.contains("wedge_ok_9") {
621
+ break;
622
+ }
623
+ }
624
+ let _ = process_kill(&[Value::Number(id)]);
625
+ assert!(
626
+ acc.contains('\u{fffd}'),
627
+ "invalid byte not replaced: {:?}",
628
+ acc
629
+ );
630
+ assert!(
631
+ acc.contains("wedge_ok_9"),
632
+ "stream wedged after invalid byte: {:?}",
633
+ acc
634
+ );
635
+ }
636
+
637
+ #[test]
638
+ fn exited_and_drained_session_is_reaped_with_exit_code_tombstoned() {
639
+ let id = spawn_sh("exit 7");
640
+ assert_eq!(wait_code(id), 7.0);
641
+ // Observing the exit must have released the child's stdin write-end fd already,
642
+ // even while the session itself is still registered.
643
+ {
644
+ let g = sessions().lock().unwrap();
645
+ if let Some(s) = g.get(&(id as u64)) {
646
+ assert!(
647
+ s.stdin.lock().unwrap().is_none(),
648
+ "stdin fd not released on exit"
649
+ );
650
+ }
651
+ }
652
+ drain_to_eof(id, false);
653
+ drain_to_eof(id, true);
654
+ // Reading both streams to EOF reaped the session...
655
+ assert!(
656
+ !sessions().lock().unwrap().contains_key(&(id as u64)),
657
+ "session not reaped after exit + full drain"
658
+ );
659
+ // ...but wait/pid still answer from the tombstone, and kill still reports the id as
660
+ // having been live (once).
661
+ assert!(matches!(process_wait(&[Value::Number(id)]), Value::Number(n) if n == 7.0));
662
+ assert!(matches!(
663
+ process_pid(&[Value::Number(id)]),
664
+ Value::Number(_)
665
+ ));
666
+ assert!(matches!(
667
+ process_kill(&[Value::Number(id)]),
668
+ Value::Bool(true)
669
+ ));
670
+ assert!(matches!(
671
+ process_kill(&[Value::Number(id)]),
672
+ Value::Bool(false)
673
+ ));
674
+ }
675
+
676
+ #[test]
677
+ fn spawn_sweeps_prior_exited_sessions() {
678
+ let id = spawn_sh("exit 3");
679
+ assert_eq!(wait_code(id), 3.0);
680
+ // No reads issued — wait until the reader threads observe EOF so the sweep (the only
681
+ // eviction path exercised here) can fire on the next spawn.
682
+ let deadline = Instant::now() + Duration::from_secs(5);
683
+ loop {
684
+ {
685
+ let g = sessions().lock().unwrap();
686
+ match g.get(&(id as u64)) {
687
+ Some(s) if is_drained(&s.out) && is_drained(&s.err) => break,
688
+ Some(_) => {}
689
+ None => break, // a concurrent test's spawn already swept it
690
+ }
691
+ }
692
+ assert!(Instant::now() < deadline, "reader threads never hit EOF");
693
+ std::thread::sleep(Duration::from_millis(5));
694
+ }
695
+ let id2 = spawn_cat();
696
+ assert!(
697
+ !sessions().lock().unwrap().contains_key(&(id as u64)),
698
+ "sweep on spawn did not reap the exited session"
699
+ );
700
+ assert!(matches!(process_wait(&[Value::Number(id)]), Value::Number(n) if n == 3.0));
701
+ let _ = process_kill(&[Value::Number(id2)]);
702
+ }
703
+
704
+ #[test]
705
+ fn spawn_wait_drain_loop_does_not_accumulate_sessions() {
706
+ // The G4 harness shape (spawn + wait, no kill) measured exactly +1 pipe fd and +1
707
+ // table entry per iteration pre-fix. Post-fix every fully-consumed session is reaped.
708
+ let mut ids = Vec::new();
709
+ for _ in 0..25 {
710
+ let id = spawn_sh("exit 0");
711
+ assert_eq!(wait_code(id), 0.0);
712
+ drain_to_eof(id, false);
713
+ drain_to_eof(id, true);
714
+ ids.push(id);
715
+ }
716
+ let g = sessions().lock().unwrap();
717
+ for id in ids {
718
+ assert!(!g.contains_key(&(id as u64)), "session {} leaked", id);
719
+ }
720
+ }
508
721
  }