@tishlang/tish-lint 2.37.3 → 2.38.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.
- package/bin/tish-lint +0 -0
- package/crates/tish_compile/src/codegen.rs +10 -1
- package/crates/tish_compile/src/resolve.rs +3 -1
- package/crates/tish_compile/src/types.rs +6 -2
- package/crates/tish_compile/tests/regr_serve_closure.rs +22 -0
- package/crates/tish_lint/Cargo.toml +1 -1
- package/crates/tish_native/src/build.rs +1 -0
- package/crates/tish_runtime/Cargo.toml +5 -0
- package/crates/tish_runtime/src/lib.rs +5 -0
- package/crates/tish_runtime/src/pty.rs +451 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-lint +0 -0
- package/platform/darwin-x64/tish-lint +0 -0
- package/platform/linux-arm64/tish-lint +0 -0
- package/platform/linux-x64/tish-lint +0 -0
- package/platform/win32-x64/tish-lint.exe +0 -0
package/bin/tish-lint
CHANGED
|
Binary file
|
|
@@ -1714,6 +1714,15 @@ impl Codegen {
|
|
|
1714
1714
|
"readLine" => Some("Value::native(|args: &[Value]| tishlang_runtime::tty_read_line(args))"),
|
|
1715
1715
|
_ => None,
|
|
1716
1716
|
},
|
|
1717
|
+
"tish:pty" if self.has_feature("pty") => match export_name {
|
|
1718
|
+
"spawn" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_spawn(args))"),
|
|
1719
|
+
"read" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_read(args))"),
|
|
1720
|
+
"write" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_write(args))"),
|
|
1721
|
+
"resize" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_resize(args))"),
|
|
1722
|
+
"kill" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_kill(args))"),
|
|
1723
|
+
"pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_pid(args))"),
|
|
1724
|
+
_ => None,
|
|
1725
|
+
},
|
|
1717
1726
|
_ => return None,
|
|
1718
1727
|
};
|
|
1719
1728
|
init.map(String::from)
|
|
@@ -1723,7 +1732,7 @@ impl Codegen {
|
|
|
1723
1732
|
if self.features.contains("full") {
|
|
1724
1733
|
matches!(
|
|
1725
1734
|
name,
|
|
1726
|
-
"http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty"
|
|
1735
|
+
"http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty"
|
|
1727
1736
|
)
|
|
1728
1737
|
} else {
|
|
1729
1738
|
self.features.contains(name)
|
|
@@ -59,6 +59,7 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
|
|
|
59
59
|
("process", "tish:process"),
|
|
60
60
|
("ws", "tish:ws"),
|
|
61
61
|
("tty", "tish:tty"),
|
|
62
|
+
("pty", "tish:pty"),
|
|
62
63
|
];
|
|
63
64
|
|
|
64
65
|
/// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
|
|
@@ -87,9 +88,10 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
|
|
|
87
88
|
| "tish:process"
|
|
88
89
|
| "tish:ws"
|
|
89
90
|
| "tish:tty"
|
|
91
|
+
| "tish:pty"
|
|
90
92
|
) || matches!(
|
|
91
93
|
spec,
|
|
92
|
-
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
|
|
94
|
+
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
|
|
93
95
|
)
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -453,9 +453,13 @@ impl RustType {
|
|
|
453
453
|
)
|
|
454
454
|
}
|
|
455
455
|
RustType::Option(inner) => {
|
|
456
|
-
|
|
456
|
+
// Box by REFERENCE so the source Option is NOT moved — it may be a local captured by
|
|
457
|
+
// an FnMut closure (a `serve()` handler, called per request) or used again after this
|
|
458
|
+
// conversion. Mirrors the Vec arm's `.iter()` (which likewise avoids moving). `(*v)`
|
|
459
|
+
// derefs the `&inner` the ref-match binds, so the inner's own clone/copy still applies.
|
|
460
|
+
let inner_to_value = inner.to_value_expr("(*v)");
|
|
457
461
|
format!(
|
|
458
|
-
"match {} {{ Some(v) => {}, None => Value::Null }}",
|
|
462
|
+
"match &({}) {{ Some(v) => {}, None => Value::Null }}",
|
|
459
463
|
native_expr, inner_to_value
|
|
460
464
|
)
|
|
461
465
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//! tishlang/tish — a typed Option (`string?`) captured by a long-lived FnMut closure (a `serve()`
|
|
2
|
+
//! handler, called per request) must not be MOVED when boxed to a Value for a `=== null` comparison.
|
|
3
|
+
//! `to_value_expr(Option)` must match by REFERENCE (`match &(…)`), like the Vec arm's `.iter()`.
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use tishlang_compile::compile_project_full;
|
|
6
|
+
fn compile(rel: &str) -> String {
|
|
7
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
8
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
9
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
10
|
+
}
|
|
11
|
+
#[test]
|
|
12
|
+
fn option_boxing_borrows_not_moves() {
|
|
13
|
+
let rust = compile("tests/regression/serve_closure_captures_typed.tish");
|
|
14
|
+
assert!(
|
|
15
|
+
rust.contains("match &(sToken)"),
|
|
16
|
+
"Option→Value boxing must match by reference so the source isn't moved"
|
|
17
|
+
);
|
|
18
|
+
assert!(
|
|
19
|
+
!rust.contains("match sToken { Some(v)"),
|
|
20
|
+
"Option→Value boxing must not match by value (moves the closure-captured Option)"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -58,6 +58,9 @@ fs = ["promise", "dep:rand"]
|
|
|
58
58
|
process = []
|
|
59
59
|
# Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
|
|
60
60
|
tty = ["dep:crossterm"]
|
|
61
|
+
# Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
|
|
62
|
+
# so an interactive terminal emulator on the other end of a socket behaves like a real TTY.
|
|
63
|
+
pty = ["dep:portable-pty", "dep:lazy_static"]
|
|
61
64
|
regex = ["tishlang_core/regex"]
|
|
62
65
|
# WebSocket (JS-like API). Modular: import { WebSocket, Server } from 'tish:ws'.
|
|
63
66
|
# Requires `send-values` because the WS server keeps an `Arc<dyn Fn + Send +
|
|
@@ -90,6 +93,8 @@ libc = { version = "0.2", optional = true }
|
|
|
90
93
|
arc-swap = { version = "1", optional = true }
|
|
91
94
|
# Interactive terminal I/O (feature = tty): cross-platform raw mode, key/resize events, size.
|
|
92
95
|
crossterm = { version = "0.28", optional = true }
|
|
96
|
+
# Pseudoterminal hosting (feature = pty): cross-platform openpty + shell spawn (same crate Dune uses).
|
|
97
|
+
portable-pty = { version = "0.9", optional = true }
|
|
93
98
|
# hyper backend (feature = http-hyper)
|
|
94
99
|
hyper = { version = "1", default-features = false, features = ["server", "http1", "http2"], optional = true }
|
|
95
100
|
hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
|
|
@@ -1727,6 +1727,11 @@ pub use tty::{
|
|
|
1727
1727
|
tty_enter_alt_screen, tty_is_tty, tty_leave_alt_screen, tty_read, tty_read_line, tty_set_raw_mode, tty_size,
|
|
1728
1728
|
};
|
|
1729
1729
|
|
|
1730
|
+
#[cfg(feature = "pty")]
|
|
1731
|
+
pub mod pty;
|
|
1732
|
+
#[cfg(feature = "pty")]
|
|
1733
|
+
pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
|
|
1734
|
+
|
|
1730
1735
|
#[cfg(feature = "ws")]
|
|
1731
1736
|
pub use ws::{
|
|
1732
1737
|
web_socket_client, web_socket_server_accept, web_socket_server_construct,
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
//! Pseudoterminal (PTY) module for Tish (`tish:pty`), behind the `pty` feature.
|
|
2
|
+
//!
|
|
3
|
+
//! Spawns a real OS pseudoterminal with a live shell/program attached — so an interactive
|
|
4
|
+
//! terminal emulator (xterm.js) on the other end of a socket behaves like a real TTY:
|
|
5
|
+
//! `isatty()` passes, so line-editing and curses apps (vim, top, ssh, tab-completion, and
|
|
6
|
+
//! `SIGWINCH` resize) all work. This is what `tish:process` (run-to-completion capture) and
|
|
7
|
+
//! `tish:tty` (this process's OWN controlling terminal) cannot do. Imported as
|
|
8
|
+
//! `import { spawn, read, write, resize, kill } from 'tish:pty'`.
|
|
9
|
+
//!
|
|
10
|
+
//! Polling model (transport-agnostic — pairs with a `tish:ws` pump or an HTTP long-poll):
|
|
11
|
+
//! - `spawn({ program?, cwd?, cols?, rows?, env? }) -> id | null`
|
|
12
|
+
//! - `read(id, timeoutMs?) -> string | null` (`""` = live but no output yet; `null` = EOF/unknown)
|
|
13
|
+
//! - `write(id, data) -> bool`
|
|
14
|
+
//! - `resize(id, cols, rows) -> bool`
|
|
15
|
+
//! - `kill(id) -> bool`
|
|
16
|
+
//! - `pid(id) -> number | null`
|
|
17
|
+
//!
|
|
18
|
+
//! A per-session reader thread fills a byte buffer that `read` drains at a UTF-8 boundary
|
|
19
|
+
//! (incomplete trailing multibyte sequences are held for the next read — no mojibake). A global
|
|
20
|
+
//! `Mutex<HashMap<id, PtySession>>` registry mirrors `ws.rs`'s `CONNS`. Errors surface as
|
|
21
|
+
//! `null`/`false` rather than panicking.
|
|
22
|
+
|
|
23
|
+
use std::collections::HashMap;
|
|
24
|
+
use std::io::{Read, Write};
|
|
25
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
26
|
+
use std::sync::{Arc, Condvar, Mutex};
|
|
27
|
+
use std::time::Duration;
|
|
28
|
+
|
|
29
|
+
use lazy_static::lazy_static;
|
|
30
|
+
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
31
|
+
use tishlang_core::Value;
|
|
32
|
+
|
|
33
|
+
/// Output accumulated by the reader thread, drained by `read`.
|
|
34
|
+
struct PtyBuf {
|
|
35
|
+
data: Vec<u8>,
|
|
36
|
+
eof: bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
struct PtySession {
|
|
40
|
+
child: Mutex<Box<dyn Child + Send + Sync>>,
|
|
41
|
+
master: Mutex<Box<dyn MasterPty + Send>>,
|
|
42
|
+
writer: Mutex<Box<dyn Write + Send>>,
|
|
43
|
+
buf: Arc<(Mutex<PtyBuf>, Condvar)>,
|
|
44
|
+
pid: Option<u32>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lazy_static! {
|
|
48
|
+
static ref SESSIONS: Mutex<HashMap<u64, PtySession>> = Mutex::new(HashMap::new());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
|
52
|
+
|
|
53
|
+
// --- Value helpers (mirror the ws.rs idioms) ---
|
|
54
|
+
|
|
55
|
+
fn arg_u64(args: &[Value], i: usize) -> Option<u64> {
|
|
56
|
+
match args.get(i) {
|
|
57
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
|
|
58
|
+
_ => None,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn arg_u16(args: &[Value], i: usize) -> Option<u16> {
|
|
63
|
+
match args.get(i) {
|
|
64
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u16),
|
|
65
|
+
_ => None,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Clone a field out of an options object (`{ key: value }`), or `None` if the arg isn't an object.
|
|
70
|
+
fn obj_field(o: &Value, key: &str) -> Option<Value> {
|
|
71
|
+
if let Value::Object(m) = o {
|
|
72
|
+
return m.borrow().strings.get(key).cloned();
|
|
73
|
+
}
|
|
74
|
+
None
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn obj_str(o: &Value, key: &str) -> Option<String> {
|
|
78
|
+
match obj_field(o, key) {
|
|
79
|
+
Some(Value::String(s)) => Some(s.to_string()),
|
|
80
|
+
Some(Value::Null) | None => None,
|
|
81
|
+
Some(v) => Some(v.to_display_string()),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn obj_num(o: &Value, key: &str) -> Option<f64> {
|
|
86
|
+
match obj_field(o, key) {
|
|
87
|
+
Some(Value::Number(n)) if n.is_finite() => Some(n),
|
|
88
|
+
_ => None,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// `spawn({ program?, cwd?, cols?, rows?, env? })` → a stable id for read/write/resize/kill,
|
|
93
|
+
/// or `null` on failure.
|
|
94
|
+
pub fn pty_spawn(args: &[Value]) -> Value {
|
|
95
|
+
let null = Value::Null;
|
|
96
|
+
let opts = args.first().unwrap_or(&null);
|
|
97
|
+
|
|
98
|
+
let cols = obj_num(opts, "cols").filter(|n| *n > 0.0).unwrap_or(80.0) as u16;
|
|
99
|
+
let rows = obj_num(opts, "rows").filter(|n| *n > 0.0).unwrap_or(24.0) as u16;
|
|
100
|
+
|
|
101
|
+
let pty_system = native_pty_system();
|
|
102
|
+
let pair = match pty_system.openpty(PtySize {
|
|
103
|
+
rows,
|
|
104
|
+
cols,
|
|
105
|
+
pixel_width: 0,
|
|
106
|
+
pixel_height: 0,
|
|
107
|
+
}) {
|
|
108
|
+
Ok(p) => p,
|
|
109
|
+
Err(_) => return Value::Null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let mut cmd = if let Some(prog) = obj_str(opts, "program") {
|
|
113
|
+
CommandBuilder::new(prog)
|
|
114
|
+
} else if cfg!(windows) {
|
|
115
|
+
CommandBuilder::new("powershell.exe")
|
|
116
|
+
} else {
|
|
117
|
+
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
|
118
|
+
CommandBuilder::new(shell)
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if let Some(cwd) = obj_str(opts, "cwd") {
|
|
122
|
+
cmd.cwd(cwd);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Caller-supplied env overrides inherit the parent environment (portable_pty default); we
|
|
126
|
+
// only add. Ensure TERM is set so curses apps negotiate correctly unless overridden.
|
|
127
|
+
let mut has_term = false;
|
|
128
|
+
if let Some(Value::Object(em)) = obj_field(opts, "env") {
|
|
129
|
+
for (k, v) in em.borrow().strings.iter() {
|
|
130
|
+
if k.as_ref() == "TERM" {
|
|
131
|
+
has_term = true;
|
|
132
|
+
}
|
|
133
|
+
cmd.env(k.as_ref(), v.to_display_string());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if !has_term {
|
|
137
|
+
cmd.env("TERM", "xterm-256color");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let child = match pair.slave.spawn_command(cmd) {
|
|
141
|
+
Ok(c) => c,
|
|
142
|
+
Err(_) => return Value::Null,
|
|
143
|
+
};
|
|
144
|
+
let reader = match pair.master.try_clone_reader() {
|
|
145
|
+
Ok(r) => r,
|
|
146
|
+
Err(_) => return Value::Null,
|
|
147
|
+
};
|
|
148
|
+
let writer = match pair.master.take_writer() {
|
|
149
|
+
Ok(w) => w,
|
|
150
|
+
Err(_) => return Value::Null,
|
|
151
|
+
};
|
|
152
|
+
let pid = child.process_id();
|
|
153
|
+
|
|
154
|
+
let buf = Arc::new((
|
|
155
|
+
Mutex::new(PtyBuf {
|
|
156
|
+
data: Vec::new(),
|
|
157
|
+
eof: false,
|
|
158
|
+
}),
|
|
159
|
+
Condvar::new(),
|
|
160
|
+
));
|
|
161
|
+
|
|
162
|
+
// Reader thread: block on the master, append bytes, wake any waiting `read`. On EOF/error,
|
|
163
|
+
// mark eof so `read` can return null once the buffer drains.
|
|
164
|
+
{
|
|
165
|
+
let buf = buf.clone();
|
|
166
|
+
let mut reader = reader;
|
|
167
|
+
std::thread::spawn(move || {
|
|
168
|
+
let mut tmp = [0u8; 8192];
|
|
169
|
+
loop {
|
|
170
|
+
match reader.read(&mut tmp) {
|
|
171
|
+
Ok(0) => {
|
|
172
|
+
let (lock, cv) = &*buf;
|
|
173
|
+
if let Ok(mut b) = lock.lock() {
|
|
174
|
+
b.eof = true;
|
|
175
|
+
}
|
|
176
|
+
cv.notify_all();
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
Ok(n) => {
|
|
180
|
+
let (lock, cv) = &*buf;
|
|
181
|
+
if let Ok(mut b) = lock.lock() {
|
|
182
|
+
b.data.extend_from_slice(&tmp[..n]);
|
|
183
|
+
}
|
|
184
|
+
cv.notify_all();
|
|
185
|
+
}
|
|
186
|
+
Err(_) => {
|
|
187
|
+
let (lock, cv) = &*buf;
|
|
188
|
+
if let Ok(mut b) = lock.lock() {
|
|
189
|
+
b.eof = true;
|
|
190
|
+
}
|
|
191
|
+
cv.notify_all();
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
|
|
200
|
+
let session = PtySession {
|
|
201
|
+
child: Mutex::new(child),
|
|
202
|
+
master: Mutex::new(pair.master),
|
|
203
|
+
writer: Mutex::new(writer),
|
|
204
|
+
buf,
|
|
205
|
+
pid,
|
|
206
|
+
};
|
|
207
|
+
match SESSIONS.lock() {
|
|
208
|
+
Ok(mut g) => {
|
|
209
|
+
g.insert(id, session);
|
|
210
|
+
}
|
|
211
|
+
Err(_) => return Value::Null,
|
|
212
|
+
}
|
|
213
|
+
Value::Number(id as f64)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// `read(id, timeoutMs?)` → available output as a string (possibly `""` if none arrived within the
|
|
217
|
+
/// timeout), or `null` at EOF / for an unknown id. Drains only a valid UTF-8 prefix, holding any
|
|
218
|
+
/// incomplete trailing multibyte bytes for the next call.
|
|
219
|
+
pub fn pty_read(args: &[Value]) -> Value {
|
|
220
|
+
let id = match arg_u64(args, 0) {
|
|
221
|
+
Some(x) => x,
|
|
222
|
+
None => return Value::Null,
|
|
223
|
+
};
|
|
224
|
+
let timeout_ms = match args.get(1) {
|
|
225
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => *n as u64,
|
|
226
|
+
_ => 0,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let buf = {
|
|
230
|
+
let g = match SESSIONS.lock() {
|
|
231
|
+
Ok(g) => g,
|
|
232
|
+
Err(_) => return Value::Null,
|
|
233
|
+
};
|
|
234
|
+
match g.get(&id) {
|
|
235
|
+
Some(s) => s.buf.clone(),
|
|
236
|
+
None => return Value::Null,
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let (lock, cv) = &*buf;
|
|
241
|
+
let mut b = match lock.lock() {
|
|
242
|
+
Ok(b) => b,
|
|
243
|
+
Err(_) => return Value::Null,
|
|
244
|
+
};
|
|
245
|
+
if b.data.is_empty() && !b.eof && timeout_ms > 0 {
|
|
246
|
+
let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
|
|
247
|
+
b.data.is_empty() && !b.eof
|
|
248
|
+
});
|
|
249
|
+
b = match res {
|
|
250
|
+
Ok((g, _)) => g,
|
|
251
|
+
Err(e) => e.into_inner().0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if b.data.is_empty() {
|
|
256
|
+
if b.eof {
|
|
257
|
+
return Value::Null;
|
|
258
|
+
}
|
|
259
|
+
return Value::String("".into());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Drain up to the last complete UTF-8 code point; keep the incomplete tail for next time.
|
|
263
|
+
let valid = match std::str::from_utf8(&b.data) {
|
|
264
|
+
Ok(_) => b.data.len(),
|
|
265
|
+
Err(e) => e.valid_up_to(),
|
|
266
|
+
};
|
|
267
|
+
if valid == 0 {
|
|
268
|
+
// A leading incomplete multibyte sequence. If the stream is done, flush it lossily so
|
|
269
|
+
// nothing is stranded; otherwise wait for the rest.
|
|
270
|
+
if b.eof {
|
|
271
|
+
let s = String::from_utf8_lossy(&b.data).into_owned();
|
|
272
|
+
b.data.clear();
|
|
273
|
+
return Value::String(s.into());
|
|
274
|
+
}
|
|
275
|
+
return Value::String("".into());
|
|
276
|
+
}
|
|
277
|
+
let out: Vec<u8> = b.data.drain(..valid).collect();
|
|
278
|
+
let s = String::from_utf8(out).unwrap_or_default();
|
|
279
|
+
Value::String(s.into())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// `write(id, data)` → feed input bytes to the PTY. Returns whether the write succeeded.
|
|
283
|
+
pub fn pty_write(args: &[Value]) -> Value {
|
|
284
|
+
let id = match arg_u64(args, 0) {
|
|
285
|
+
Some(x) => x,
|
|
286
|
+
None => return Value::Bool(false),
|
|
287
|
+
};
|
|
288
|
+
let data = match args.get(1) {
|
|
289
|
+
Some(Value::String(s)) => s.to_string(),
|
|
290
|
+
Some(v) => v.to_display_string(),
|
|
291
|
+
None => return Value::Bool(false),
|
|
292
|
+
};
|
|
293
|
+
let g = match SESSIONS.lock() {
|
|
294
|
+
Ok(g) => g,
|
|
295
|
+
Err(_) => return Value::Bool(false),
|
|
296
|
+
};
|
|
297
|
+
let s = match g.get(&id) {
|
|
298
|
+
Some(s) => s,
|
|
299
|
+
None => return Value::Bool(false),
|
|
300
|
+
};
|
|
301
|
+
let mut w = match s.writer.lock() {
|
|
302
|
+
Ok(w) => w,
|
|
303
|
+
Err(_) => return Value::Bool(false),
|
|
304
|
+
};
|
|
305
|
+
match w.write_all(data.as_bytes()).and_then(|_| w.flush()) {
|
|
306
|
+
Ok(_) => Value::Bool(true),
|
|
307
|
+
Err(_) => Value::Bool(false),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// `resize(id, cols, rows)` → tell the PTY its new window size (fires `SIGWINCH` in the child).
|
|
312
|
+
pub fn pty_resize(args: &[Value]) -> Value {
|
|
313
|
+
let id = match arg_u64(args, 0) {
|
|
314
|
+
Some(x) => x,
|
|
315
|
+
None => return Value::Bool(false),
|
|
316
|
+
};
|
|
317
|
+
let cols = match arg_u16(args, 1) {
|
|
318
|
+
Some(c) if c > 0 => c,
|
|
319
|
+
_ => return Value::Bool(false),
|
|
320
|
+
};
|
|
321
|
+
let rows = match arg_u16(args, 2) {
|
|
322
|
+
Some(r) if r > 0 => r,
|
|
323
|
+
_ => return Value::Bool(false),
|
|
324
|
+
};
|
|
325
|
+
let g = match SESSIONS.lock() {
|
|
326
|
+
Ok(g) => g,
|
|
327
|
+
Err(_) => return Value::Bool(false),
|
|
328
|
+
};
|
|
329
|
+
let s = match g.get(&id) {
|
|
330
|
+
Some(s) => s,
|
|
331
|
+
None => return Value::Bool(false),
|
|
332
|
+
};
|
|
333
|
+
let m = match s.master.lock() {
|
|
334
|
+
Ok(m) => m,
|
|
335
|
+
Err(_) => return Value::Bool(false),
|
|
336
|
+
};
|
|
337
|
+
match m.resize(PtySize {
|
|
338
|
+
rows,
|
|
339
|
+
cols,
|
|
340
|
+
pixel_width: 0,
|
|
341
|
+
pixel_height: 0,
|
|
342
|
+
}) {
|
|
343
|
+
Ok(_) => Value::Bool(true),
|
|
344
|
+
Err(_) => Value::Bool(false),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// `kill(id)` → terminate the child and drop the session. Returns whether the id was live.
|
|
349
|
+
pub fn pty_kill(args: &[Value]) -> Value {
|
|
350
|
+
let id = match arg_u64(args, 0) {
|
|
351
|
+
Some(x) => x,
|
|
352
|
+
None => return Value::Bool(false),
|
|
353
|
+
};
|
|
354
|
+
let sess = {
|
|
355
|
+
let mut g = match SESSIONS.lock() {
|
|
356
|
+
Ok(g) => g,
|
|
357
|
+
Err(_) => return Value::Bool(false),
|
|
358
|
+
};
|
|
359
|
+
g.remove(&id)
|
|
360
|
+
};
|
|
361
|
+
match sess {
|
|
362
|
+
Some(s) => {
|
|
363
|
+
if let Ok(mut c) = s.child.lock() {
|
|
364
|
+
let _ = c.kill();
|
|
365
|
+
let _ = c.wait();
|
|
366
|
+
}
|
|
367
|
+
Value::Bool(true)
|
|
368
|
+
}
|
|
369
|
+
None => Value::Bool(false),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// `pid(id)` → the child process id, or `null` for an unknown id / no pid.
|
|
374
|
+
pub fn pty_pid(args: &[Value]) -> Value {
|
|
375
|
+
let id = match arg_u64(args, 0) {
|
|
376
|
+
Some(x) => x,
|
|
377
|
+
None => return Value::Null,
|
|
378
|
+
};
|
|
379
|
+
let g = match SESSIONS.lock() {
|
|
380
|
+
Ok(g) => g,
|
|
381
|
+
Err(_) => return Value::Null,
|
|
382
|
+
};
|
|
383
|
+
match g.get(&id).and_then(|s| s.pid) {
|
|
384
|
+
Some(p) => Value::Number(p as f64),
|
|
385
|
+
None => Value::Null,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[cfg(all(test, unix))]
|
|
390
|
+
mod tests {
|
|
391
|
+
use super::*;
|
|
392
|
+
|
|
393
|
+
/// Proves a LIVE interactive PTY (persistent shell on a pseudoterminal), not run-to-completion:
|
|
394
|
+
/// spawn a shell, write a command, and read its executed output back.
|
|
395
|
+
#[test]
|
|
396
|
+
fn spawn_write_read_roundtrip() {
|
|
397
|
+
let id = match pty_spawn(&[]) {
|
|
398
|
+
Value::Number(n) => n,
|
|
399
|
+
other => panic!("spawn failed: {:?}", other),
|
|
400
|
+
};
|
|
401
|
+
// Drain the shell's startup banner/prompt.
|
|
402
|
+
let _ = pty_read(&[Value::Number(id), Value::Number(300.0)]);
|
|
403
|
+
assert!(matches!(
|
|
404
|
+
pty_write(&[Value::Number(id), Value::String("echo pty_ok_123\n".into())]),
|
|
405
|
+
Value::Bool(true)
|
|
406
|
+
));
|
|
407
|
+
let mut acc = String::new();
|
|
408
|
+
for _ in 0..100 {
|
|
409
|
+
match pty_read(&[Value::Number(id), Value::Number(50.0)]) {
|
|
410
|
+
Value::String(s) => acc.push_str(&s),
|
|
411
|
+
Value::Null => break,
|
|
412
|
+
_ => {}
|
|
413
|
+
}
|
|
414
|
+
if acc.contains("pty_ok_123") {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
let _ = pty_kill(&[Value::Number(id)]);
|
|
419
|
+
assert!(acc.contains("pty_ok_123"), "pty output missing echo: {:?}", acc);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn resize_live_session_ok() {
|
|
424
|
+
let id = match pty_spawn(&[]) {
|
|
425
|
+
Value::Number(n) => n,
|
|
426
|
+
other => panic!("spawn failed: {:?}", other),
|
|
427
|
+
};
|
|
428
|
+
assert!(matches!(
|
|
429
|
+
pty_resize(&[Value::Number(id), Value::Number(120.0), Value::Number(40.0)]),
|
|
430
|
+
Value::Bool(true)
|
|
431
|
+
));
|
|
432
|
+
assert!(matches!(pty_pid(&[Value::Number(id)]), Value::Number(_)));
|
|
433
|
+
assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(true)));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
fn unknown_id_surfaces_null_or_false() {
|
|
438
|
+
let bad = 9_999_999.0;
|
|
439
|
+
assert!(matches!(pty_read(&[Value::Number(bad)]), Value::Null));
|
|
440
|
+
assert!(matches!(
|
|
441
|
+
pty_write(&[Value::Number(bad), Value::String("x".into())]),
|
|
442
|
+
Value::Bool(false)
|
|
443
|
+
));
|
|
444
|
+
assert!(matches!(
|
|
445
|
+
pty_resize(&[Value::Number(bad), Value::Number(80.0), Value::Number(24.0)]),
|
|
446
|
+
Value::Bool(false)
|
|
447
|
+
));
|
|
448
|
+
assert!(matches!(pty_kill(&[Value::Number(bad)]), Value::Bool(false)));
|
|
449
|
+
assert!(matches!(pty_pid(&[Value::Number(bad)]), Value::Null));
|
|
450
|
+
}
|
|
451
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|