@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.
- package/bin/tish-format +0 -0
- package/crates/tish/src/cargo_native_registry.rs +4 -0
- package/crates/tish/src/main.rs +65 -25
- package/crates/tish/tests/integration_test.rs +3 -0
- package/crates/tish_builtins/src/globals.rs +3 -3
- package/crates/tish_builtins/src/symbol.rs +86 -4
- package/crates/tish_bytecode/src/compiler.rs +65 -25
- package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +229 -13
- package/crates/tish_eval/src/timers.rs +66 -2
- package/crates/tish_ffi/src/lib.rs +74 -12
- package/crates/tish_ffi/tests/loader.rs +35 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_lsp/src/main.rs +91 -2
- package/crates/tish_pg/src/lib.rs +59 -8
- package/crates/tish_pg/src/statement_registry.rs +250 -0
- package/crates/tish_runtime/src/http.rs +19 -0
- package/crates/tish_runtime/src/http_fetch.rs +159 -65
- package/crates/tish_runtime/src/http_hyper.rs +18 -2
- package/crates/tish_runtime/src/lib.rs +4 -0
- package/crates/tish_runtime/src/net.rs +125 -97
- package/crates/tish_runtime/src/process_spawn.rs +326 -113
- package/crates/tish_runtime/src/promise.rs +521 -90
- package/crates/tish_runtime/src/promise_io.rs +29 -6
- package/crates/tish_runtime/src/pty.rs +161 -107
- package/crates/tish_runtime/src/stream_buf.rs +419 -0
- package/crates/tish_runtime/src/ws.rs +431 -52
- package/crates/tish_vm/src/jit.rs +473 -67
- package/crates/tish_vm/src/lib.rs +6 -0
- package/crates/tish_vm/src/vm.rs +8 -5
- package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
- package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
- package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
- package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- 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
|
|
24
|
-
//! a UTF-8 boundary
|
|
25
|
-
//!
|
|
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
|
-
|
|
29
|
-
|
|
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::{
|
|
40
|
+
use std::sync::{Mutex, OnceLock};
|
|
33
41
|
use std::time::{Duration, Instant};
|
|
34
42
|
|
|
35
43
|
use tishlang_core::Value;
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
Some(
|
|
429
|
-
|
|
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
|
}
|