zsh/ported/modules/zpty.rs
1//! Pseudo-terminal module - port of Modules/zpty.c
2//!
3//! Provides zpty builtin for running sub-processes with pseudo terminals.
4
5use crate::ported::zsh_h::{features, module, OPT_ARG, OPT_ISSET};
6use std::collections::HashMap;
7use std::ffi::CString;
8use std::io::{self, Read, Write};
9use std::os::unix::io::{IntoRawFd, RawFd};
10use std::process::Command;
11use std::sync::{Mutex, OnceLock};
12
13/// Port of `READ_MAX` from `Src/Modules/zpty.c:44`. Maximum bytes
14/// to read at once from a pty's master end (1 MB).
15pub const READ_MAX: usize = 1024 * 1024; // c:44
16
17/// A pseudo-terminal command session.
18/// Port of `struct ptycmd` from Src/Modules/zpty.c — the C
19/// source threads it through `getptycmd()` (line 153),
20/// `newptycmd()` (line 310), `deleteptycmd()` (line 490) etc.
21/// Same fields (name, args, master fd, pid, echo, nonblock).
22#[derive(Debug)]
23pub struct ptycmd {
24 /// `name` field (C: `char *name`).
25 pub name: String,
26 /// `args` field (C: `char **args`).
27 pub args: Vec<String>,
28 /// `master_fd` field (C: `int fd` — master end of the pty).
29 pub master_fd: RawFd,
30 /// `pid` field (C: `pid_t pid` — spawned child PID).
31 pub pid: i32,
32 /// `echo` field (C: `int echo`).
33 pub echo: bool,
34 /// `nonblock` field (C: `int nblock`).
35 pub nonblock: bool,
36 /// `finished` field (C: `int fin`).
37 pub finished: bool,
38 /// `read_buf` field — C: `int read;` initialised to -1; the
39 /// single byte stashed by `checkptycmd` (c:543-544) for the next
40 /// `ptyread` to consume at c:583-588. `None` ⇔ C's -1 sentinel.
41 pub read_buf: Option<u8>,
42 /// `old` field — C: `char *old; int olen;` populated when a
43 /// pattern-bearing `ptyread` exits early with EWOULDBLOCK/EAGAIN
44 /// (c:682-694) so the partial-match prefix carries to the next
45 /// invocation (c:572-578).
46 pub old: Option<Vec<u8>>,
47}
48
49impl ptycmd {
50 /// `new` — see implementation.
51 pub fn new(
52 name: &str,
53 args: Vec<String>,
54 master_fd: RawFd,
55 pid: i32,
56 echo: bool,
57 nonblock: bool,
58 ) -> Self {
59 Self {
60 name: name.to_string(),
61 args,
62 master_fd,
63 pid,
64 echo,
65 nonblock,
66 finished: false,
67 // c:455-458 — `cmd->read = -1; cmd->old = NULL; cmd->olen = 0;`
68 read_buf: None,
69 old: None,
70 }
71 }
72}
73
74// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
75// ─── RUST-ONLY ACCESSORS ───
76//
77// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
78// RwLock<T>>` globals declared above. C zsh uses direct global
79// access; Rust needs these wrappers because `OnceLock::get_or_init`
80// is the only way to lazily construct shared state. These ported sit
81// here so the body of this file reads in C source order without
82// the accessor wrappers interleaved between real port ported.
83// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84
85// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
86// ─── RUST-ONLY ACCESSORS ───
87//
88// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
89// RwLock<T>>` globals declared above. C zsh uses direct global
90// access; Rust needs these wrappers because `OnceLock::get_or_init`
91// is the only way to lazily construct shared state. These ported sit
92// here so the body of this file reads in C source order without
93// the accessor wrappers interleaved between real port ported.
94// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
95
96/// WARNING: NOT IN ZPTY.C — OnceLock<Mutex> accessor for ptycmd registry; C uses static linked list `ptycmds`
97/// (equivalent C logic at Src/Modules/zpty.c:48).
98fn ptycmds() -> &'static Mutex<HashMap<String, ptycmd>> {
99 PTYCMDS.get_or_init(|| Mutex::new(HashMap::<String, ptycmd>::new()))
100}
101
102/// Set non-blocking mode on a file descriptor.
103/// Port of `ptynonblock(int fd)` from Src/Modules/zpty.c:65 — wraps
104/// `fcntl(F_GETFL)` + `fcntl(F_SETFL, |O_NONBLOCK)`.
105#[cfg(unix)]
106pub fn ptynonblock(fd: RawFd) -> io::Result<()> {
107 // c:65
108 unsafe {
109 let flags = libc::fcntl(fd, libc::F_GETFL);
110 if flags < 0 {
111 return Err(io::Error::last_os_error());
112 }
113
114 if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
115 return Err(io::Error::last_os_error());
116 }
117 }
118 Ok(())
119}
120
121/// Port of `ptygettyinfo(int fd, struct ttyinfo *ti)` from `Src/Modules/zpty.c:97`. Calls
122/// `tcgetattr(fd, &ti->tio)` to capture the pty's termios state.
123/// Returns 0 on success, 1 on failure or when fd == -1.
124///
125/// C signature: `static int ptygettyinfo(int fd, struct ttyinfo *ti)`.
126/// Rust port takes a mutable `&mut libc::termios` directly since
127/// `struct ttyinfo` (zsh.h) wraps termios + a few legacy fields.
128pub fn ptygettyinfo(fd: i32, ti: &mut libc::termios) -> i32 {
129 // c:97
130 if fd == -1 {
131 // c:97 inverted
132 return 1; // c:118
133 }
134 // c:101 — `tcgetattr(fd, &ti->tio)`.
135 let r = unsafe { libc::tcgetattr(fd, ti as *mut libc::termios) };
136 if r == -1 {
137 // c:103
138 return 1; // c:103
139 }
140 0 // c:124
141}
142
143/// Port of `ptysettyinfo(int fd, struct ttyinfo *ti)` from `Src/Modules/zpty.c:124`. Calls
144/// `tcsetattr(fd, TCSADRAIN, &ti->tio)` to install the captured
145/// termios state on the pty.
146///
147/// C signature: `static void ptysettyinfo(int fd, struct ttyinfo *ti)`.
148pub fn ptysettyinfo(fd: i32, ti: &libc::termios) {
149 // c:124
150 if fd == -1 {
151 // c:124 inverted
152 return;
153 }
154 // c:128-132 — `tcsetattr(fd, TCSADRAIN, &ti->tio);`
155 unsafe {
156 libc::tcsetattr(fd, libc::TCSADRAIN, ti as *const libc::termios);
157 }
158}
159
160// === auto-generated stubs ===
161/// Port of `getptycmd(char *name)` from `Src/Modules/zpty.c:153`. Linear
162/// scan over the `ptycmds` linked list looking for one matching
163/// `name`.
164///
165/// C signature: `static Ptycmd getptycmd(char *name)`. Returns
166/// the Ptycmd or NULL.
167/// WARNING: param names don't match C — Rust=(cmds, name) vs C=(name)
168pub fn getptycmd<'a>(cmds: &'a HashMap<String, ptycmd>, name: &str) -> Option<&'a ptycmd> {
169 // c:153
170 cmds.get(name) // c:153-160 strcmp loop
171}
172
173// ===========================================================
174// Methods moved verbatim from src/ported/vm_helper because their
175// C counterpart's source file maps 1:1 to this Rust module.
176// Phase: module-shims
177// ===========================================================
178
179// BEGIN moved-from-exec-rs
180// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
181
182// END moved-from-exec-rs
183
184// ─── moved from src/ported/vm_helper (drift extraction) ───
185
186// Note: dead `ZptyState` aggregate deleted per PORT_PLAN Phase 2.
187// It was a duplicate of `ptycmd` (zpty.rs:19), which is the correct
188// faithful port of C `struct ptycmd` (Src/Modules/zpty.c:48). The
189// dead `ZptyState` was wired into ShellExecutor as
190// `pub zptys: HashMap<String, ZptyState>` but never inserted or
191// read. Use `ptycmd` + `HashMap<String, ptycmd>` (the port of the file-static
192// `static Ptycmd ptycmds;` linked list at zpty.c:62) for any
193// real wiring.
194
195// =====================================================================
196// static struct features module_features c:884 (zpty.c)
197// =====================================================================
198
199/// Open a pseudo-terminal master/slave pair.
200/// Port of `get_pty(int master, int *retfd)` from Src/Modules/zpty.c:191 (or :255 for
201/// the fallback path on systems without `posix_openpt`). Wraps
202/// `posix_openpt` + `grantpt` + `unlockpt` + `ptsname` + `open`.
203#[cfg(unix)]
204/// WARNING: param names don't match C — Rust=() vs C=(master, retfd)
205pub fn get_pty() -> io::Result<(RawFd, RawFd)> {
206 // c:191
207 let master_fd = unsafe {
208 let fd = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY);
209 if fd < 0 {
210 return Err(io::Error::last_os_error());
211 }
212 fd
213 };
214
215 unsafe {
216 if libc::grantpt(master_fd) < 0 {
217 libc::close(master_fd);
218 return Err(io::Error::last_os_error());
219 }
220
221 if libc::unlockpt(master_fd) < 0 {
222 libc::close(master_fd);
223 return Err(io::Error::last_os_error());
224 }
225
226 let slave_name = libc::ptsname(master_fd);
227 if slave_name.is_null() {
228 libc::close(master_fd);
229 return Err(io::Error::other("ptsname failed"));
230 }
231
232 let slave_fd = libc::open(slave_name, libc::O_RDWR | libc::O_NOCTTY);
233 if slave_fd < 0 {
234 libc::close(master_fd);
235 return Err(io::Error::last_os_error());
236 }
237
238 Ok((master_fd, slave_fd))
239 }
240}
241
242/// Port of `newptycmd(char *nam, char *pname, char **args, int echo, int nblock)` from `Src/Modules/zpty.c:310`. Forks a
243/// new pty session, exec'ing `args` in the child. Allocates a
244/// fresh `Ptycmd` record, configures the master fd, sets up the
245/// echo / nonblock flags, and links it into `ptycmds`.
246///
247/// C signature: `static int newptycmd(char *nam, char *pname,
248/// char **args, int echo, int nblock)`.
249///
250/// **Approximation:** the full pty-allocation path uses
251/// `posix_openpt`/`grantpt`/`unlockpt`/`ptsname` (zpty.c:191-309)
252/// and forks via `zfork()` with an extensive child-side reset
253/// sequence. zshrs's port wires through `std::process::Command`
254/// + a libc pty-spawn helper which doesn't preserve the full C
255/// child-init contract. Returns 0 on success, 1 on failure.
256pub fn newptycmd(
257 cmds: &mut HashMap<String, ptycmd>,
258 _nam: &str,
259 pname: &str, // c:310
260 args: &[String],
261 echo: bool,
262 nblock: bool,
263) -> i32 {
264 // c:310
265 if args.is_empty() {
266 return 1;
267 }
268
269 // c:438 — `get_pty(1, &master, &slave)` — allocate master/slave fds.
270 // Approximation: full C path runs `parse_string(zjoin(args, ' ', 1))`
271 // then `execode(prog, 1, 0, "zpty")` in the child, so the args are
272 // interpreted as a shell command line (globs, redirs, builtins).
273 // This port still uses execvp(3), matching the prior inline shape
274 // — true execode integration is a separate iteration.
275 #[cfg(unix)]
276 {
277 let (master, slave) = match get_pty() {
278 Ok(p) => p,
279 Err(_) => return 1, // c:438-440
280 };
281 let pid = unsafe { libc::fork() };
282 match pid {
283 -1 => {
284 // c:441-446 — `if (pid == -1) { close(...); return 1; }`
285 unsafe {
286 libc::close(master);
287 libc::close(slave);
288 }
289 1
290 }
291 0 => {
292 // c:380-411 — child-side reset.
293 unsafe {
294 libc::close(master);
295 libc::setsid();
296 }
297 // c:381-396 — `if (!echo) { struct ttyinfo info; if (!ptygettyinfo(slave,
298 // &info)) { info.tio.c_lflag &= ~ECHO; ptysettyinfo(slave, &info); } }`
299 //
300 // Echo handling runs on the SLAVE fd BEFORE the dup2 so the
301 // termios state lives on the pty-slave handle independent
302 // of fd-table churn. Prior port flipped the order — set
303 // echo on fd 0 AFTER dup2 — which works because dup2 shares
304 // the underlying file description, but reads as a different
305 // operation when matched against the C source.
306 if !echo {
307 let mut info: libc::termios = unsafe { std::mem::zeroed() };
308 if ptygettyinfo(slave, &mut info) == 0 {
309 // c:386 (HAVE_TERMIOS_H branch) — `info.tio.c_lflag &= ~ECHO;`
310 info.c_lflag &= !libc::ECHO;
311 // c:394 — `ptysettyinfo(slave, &info);`
312 ptysettyinfo(slave, &info);
313 }
314 }
315 // c:398-400 — `ioctl(slave, TIOCSCTTY, 0);` — assign the
316 // slave as the child's controlling terminal so that
317 // ioctl(0, TIOC*) and tcgetpgrp/tcsetpgrp address it.
318 // Without TIOCSCTTY, signals like SIGINT from the master
319 // side via `kill -INT $(pgid)` would not reach the child.
320 unsafe {
321 libc::ioctl(slave, libc::TIOCSCTTY.into(), 0);
322 }
323 // c:402-414 — close stdio, dup slave to 0/1/2, close
324 // all leftover internal fds (so the child doesn't
325 // inherit the parent shell's pipe/coproc/module fds).
326 // close(0); close(1); close(2);
327 // dup2(slave, 0..2);
328 // closem(FDT_UNUSED, 0);
329 // close(slave);
330 // close(master); ← already done earlier (line 294)
331 // close(coprocin); close(coprocout);
332 unsafe {
333 libc::close(0);
334 libc::close(1);
335 libc::close(2);
336 libc::dup2(slave, 0);
337 libc::dup2(slave, 1);
338 libc::dup2(slave, 2);
339 }
340 // c:410 — closem(FDT_UNUSED, 0). Without this, the
341 // child inherits every fdtable-tracked internal fd the
342 // parent had open (coproc pipes, autoload module fds,
343 // opened-via-exec fds, ZFTP_CTRL, etc.) — same fd-
344 // leak the clone module's bin_clone port plugged at
345 // clone.rs:117-133. Prior zpty port skipped this step
346 // so a long-running `zpty -b name cmd` would slowly
347 // accumulate dead fd references on every spawn.
348 crate::ported::exec::closem(crate::ported::zsh_h::FDT_UNUSED, 0);
349 // c:411 — close(slave) AFTER dup2 so the underlying
350 // open file description retains the right refcount.
351 if slave > 2 {
352 unsafe {
353 libc::close(slave);
354 }
355 }
356 // c:413-414 — close(coprocin); close(coprocout). The
357 // parent's coproc pipe is irrelevant to the spawned
358 // pty-isolated child; same shape as clone.rs:135-136.
359 use std::sync::atomic::Ordering;
360 unsafe {
361 libc::close(crate::ported::modules::clone::coprocin.load(Ordering::Relaxed));
362 libc::close(crate::ported::modules::clone::coprocout.load(Ordering::Relaxed));
363 }
364 // c:420-431 — child-side sync handshake. Write a single
365 // NUL byte to fd 1 (now the slave) so the parent, which
366 // blocks on read(master) at c:472-482, can confirm the
367 // child finished its tty setup before zpty returns
368 // control to the caller. Without this, tests that send
369 // input immediately after `zpty -b name cmd` can race
370 // the child's session/pgrp setup and the early input
371 // is lost to the still-being-configured pty.
372 //
373 // EWOULDBLOCK/EAGAIN/EINTR retry mirrors C's loop —
374 // the slave fd is nonblocking on some platforms after
375 // pty allocation.
376 let sync_byte: u8 = 0;
377 loop {
378 let r = unsafe { libc::write(1, &sync_byte as *const u8 as *const _, 1) };
379 if r == 1 {
380 break;
381 }
382 let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
383 if eno != libc::EWOULDBLOCK && eno != libc::EAGAIN && eno != libc::EINTR {
384 break;
385 }
386 }
387 let cmd = match CString::new(args[0].as_str()) {
388 Ok(c) => c,
389 Err(_) => unsafe { libc::_exit(1) },
390 };
391 let c_args: Vec<CString> = args
392 .iter()
393 .filter_map(|s| CString::new(s.as_str()).ok())
394 .collect();
395 let c_args_ptrs: Vec<*const libc::c_char> = c_args
396 .iter()
397 .map(|s| s.as_ptr())
398 .chain(std::iter::once(std::ptr::null()))
399 .collect();
400 unsafe {
401 libc::execvp(cmd.as_ptr(), c_args_ptrs.as_ptr());
402 libc::_exit(1); // c:436 — `zexit(lastval, ZEXIT_NORMAL)` on failure.
403 }
404 }
405 pid => {
406 unsafe {
407 libc::close(slave); // c:451
408 }
409 // c:438-445 — `master = movefd(master);` (non-cygwin path).
410 // movefd relocates the master fd to a high number (>= 10)
411 // and registers it in fdtable as FDT_INTERNAL (utils.rs
412 // :2243). Prior port stored the raw master fd directly,
413 // which:
414 // 1. Could collide with shell-side fd 3-9 use the same
415 // way the random.rs urandom fd did (fixed at
416 // b3107b5a46).
417 // 2. Left the fd unregistered in fdtable, so closem
418 // (FDT_UNUSED, 0) could close it from another
419 // builtin's child fork.
420 let master = crate::ported::utils::movefd(master);
421 if master == -1 {
422 // c:441 — `zerrnam(nam, "cannot duplicate fd %d: ...", master, errno);`
423 crate::ported::utils::zerrnam(
424 _nam,
425 &format!("cannot duplicate fd: {}", std::io::Error::last_os_error()),
426 );
427 return 1; // c:444
428 }
429 // c:466-467 — `if (nblock) ptynonblock(master);`
430 if nblock {
431 let _ = ptynonblock(master);
432 }
433 // c:472-482 — parent-side sync handshake. Read the
434 // single byte the child writes from its end of the pty
435 // before returning, so the caller can't start sending
436 // data to a still-being-configured pty. EWOULDBLOCK /
437 // EAGAIN / EINTR retry mirror C; on a clean read the
438 // child has confirmed its tty setup completed.
439 let mut sync_buf: u8 = 0;
440 loop {
441 let r = unsafe { libc::read(master, &mut sync_buf as *mut u8 as *mut _, 1) };
442 if r == 1 {
443 break;
444 }
445 let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
446 if eno != libc::EWOULDBLOCK && eno != libc::EAGAIN && eno != libc::EINTR {
447 break;
448 }
449 }
450 // c:484 — `setiparam_no_convert("REPLY", (zlong)master);`.
451 // The user-facing `$REPLY` carries the master fd so
452 // shell-level reads/writes can address the pty
453 // alongside the zpty-name registry.
454 let _ = crate::ported::params::setiparam_no_convert("REPLY", master as i64);
455 // c:455-458 — `p = (Ptycmd) zalloc(...); p->name = ztrdup(pname);
456 // p->args = args; p->fd = master; p->pid = pid;
457 // p->echo = echo; p->nblock = nblock;`
458 let new = ptycmd::new(pname, args.to_vec(), master, pid, echo, nblock);
459 cmds.insert(new.name.clone(), new); // c:474 list-insert
460 0
461 }
462 }
463 }
464
465 #[cfg(not(unix))]
466 {
467 let _ = (echo, nblock, pname, args, cmds);
468 1
469 }
470}
471
472/// Port of `deleteptycmd(Ptycmd cmd)` from `Src/Modules/zpty.c:490`. Removes
473/// `cmd` from the `ptycmds` linked list, frees its name + args,
474/// closes the master fd, and kills the process group via
475/// `kill(-pid, SIGHUP)`.
476///
477/// C signature: `static void deleteptycmd(Ptycmd cmd)`.
478/// WARNING: param names don't match C — Rust=(cmds, name) vs C=(cmd)
479pub fn deleteptycmd(cmds: &mut HashMap<String, ptycmd>, name: &str) {
480 // c:490
481 if let Some(cmd) = cmds.remove(name) {
482 // c:490-503 list-unlink
483 // c:505 — `zsfree(p->name)` + c:506 `freearray(p->args)` —
484 // Rust drops String/Vec automatically on `cmd` going out
485 // of scope.
486 // c:507 — `zclose(cmd->fd);` — fdtable-aware close. The master
487 // fd was registered as FDT_INTERNAL by movefd in newptycmd
488 // (utils.rs:2243 sets the entry after the dup-to-high-fd). Raw
489 // libc::close skips the fdtable_set(fd, FDT_UNUSED) clear that
490 // zclose performs, leaving the FDT_INTERNAL marker stale on
491 // the freed fd. Same leak shape as the tcp_close fix
492 // (9b4dae375a) and the random.rs finish_ fix (b3107b5a46).
493 crate::ported::utils::zclose(cmd.master_fd);
494 // c:511 — `kill(-(p->pid), SIGHUP);` — kill the process group.
495 unsafe {
496 libc::kill(-cmd.pid, libc::SIGHUP);
497 }
498 // c:517 — `zfree(p, sizeof(*p))` — Rust drop handles.
499 }
500}
501
502/// Port of `deleteallptycmds()` from `Src/Modules/zpty.c:517`.
503/// Walks the `ptycmds` list and deletes every entry.
504///
505/// C signature: `static void deleteallptycmds(void)`.
506/// WARNING: param names don't match C — Rust=(cmds) vs C=()
507pub fn deleteallptycmds(cmds: &mut HashMap<String, ptycmd>) {
508 // c:517
509 let names: Vec<String> = cmds.keys().cloned().collect();
510 for n in names {
511 // c:530-525
512 deleteptycmd(cmds, &n); // c:530
513 }
514}
515
516/// Port of `checkptycmd(Ptycmd cmd)` from `Src/Modules/zpty.c:530`. Polls
517/// the master fd with a 1-byte non-blocking read; if read fails
518/// AND `kill(pid, 0)` confirms the process is gone, marks the
519/// command as finished and closes the fd.
520///
521/// C signature: `static void checkptycmd(Ptycmd cmd)`.
522pub fn checkptycmd(cmd: &mut ptycmd) {
523 // c:530
524 // c:535 — `if (cmd->read != -1 || cmd->fin) return;`
525 //
526 // C bails when EITHER (a) a byte is already stashed in cmd->read
527 // (a prior checkptycmd call buffered it), OR (b) the cmd has
528 // already finished. Prior Rust port only checked finished,
529 // missing the "already buffered" guard — back-to-back
530 // checkptycmd calls would re-issue read(2) and overwrite the
531 // stashed byte, dropping it before ptyread had a chance to
532 // consume it at c:583-588.
533 //
534 // `read_buf: Option<u8>` mirrors C's `read: int` where -1 = unset.
535 // Some(_) ↔ cmd->read != -1.
536 if cmd.read_buf.is_some() || cmd.finished {
537 // c:535
538 return;
539 }
540 let mut c: u8 = 0;
541 let r = unsafe { libc::read(cmd.master_fd, &mut c as *mut u8 as *mut _, 1) };
542 if r <= 0 {
543 // c:537
544 // c:538 — `if (kill(cmd->pid, 0) < 0)` — process gone.
545 if unsafe { libc::kill(cmd.pid, 0) } < 0 {
546 cmd.finished = true; // c:539 cmd->fin = 1
547 // c:540 — `zclose(cmd->fd);`. Was raw libc::close; same
548 // FDT_INTERNAL stale-marker leak as the deleteptycmd fix
549 // immediately above (both teardown paths must use zclose).
550 crate::ported::utils::zclose(cmd.master_fd);
551 }
552 return;
553 }
554 // c:543-544 — `cmd->read = (int) c;` — stash the probed byte
555 // for the next `ptyread` call to consume at c:583-588.
556 cmd.read_buf = Some(c);
557}
558
559/// Port of `ptyread(char *nam, Ptycmd cmd, char **args, int noblock, int mustmatch)`
560/// from Src/Modules/zpty.c:548-710. Reads 1 byte at a time from the
561/// pty master, metafies imeta bytes (c:642-646), pauses on each
562/// iteration to honor `errflag/breaks/retflag/contflag` (c:678),
563/// flushes to stdout in chunks when no setparam target is given
564/// (c:649-653), and bails as soon as `pattern` matches via
565/// `pattry` (c:680). Final disposition: setsparam(args[0], buf)
566/// if args[0] is set; otherwise write the accumulated buffer to
567/// stdout (c:696-701). Returns `0` on success-with-data,
568/// `cmd->fin + 1` (1/2) otherwise (c:704).
569///
570/// Deferred (separate port iteration): the `cmd->old` / `cmd->read`
571/// pre-buffer machinery at c:572-588, c:683-694 — these fields
572/// don't exist on the Rust `ptycmd` struct yet; without them the
573/// nblock-EWOULDBLOCK-stash path at c:682-694 falls through to
574/// the standard exit (still correct return value, just slower
575/// next-call because the unmatched prefix isn't carried).
576pub fn ptyread(nam: &str, cmd: &mut ptycmd, args: &[&str], noblock: bool, mustmatch: bool) -> i32 {
577 // c:550
578 let mut used: usize = 0;
579 let mut seen: bool = false;
580 let mut matchok: bool = false;
581 let mut ret: isize = 0;
582 let mut prog: Option<crate::ported::pattern::Patprog> = None; // c:552
583
584 // c:554-568 — pattern compile from args[1] if present.
585 if !args.is_empty() && args.len() >= 2 {
586 if args.len() > 2 {
587 // c:557-559
588 eprintln!("{}: too many arguments", nam);
589 return 1;
590 }
591 let p = args[1];
592 // c:565 — `patcompile(p, PAT_ZDUP, NULL)` — PAT_ZDUP = 0x100 per zsh.h.
593 match crate::ported::pattern::patcompile(
594 &{
595 let mut __pat_tok = (p).to_string();
596 crate::ported::glob::tokenize(&mut __pat_tok);
597 __pat_tok
598 },
599 0x100,
600 None,
601 ) {
602 Some(pp) => prog = Some(pp),
603 None => {
604 // c:566
605 eprintln!("{}: bad pattern: {}", nam, p);
606 return 1;
607 }
608 }
609 } else {
610 // c:569-570 — `fflush(stdout)` before any unbuffered emit.
611 use std::io::Write;
612 let _ = std::io::stdout().flush();
613 }
614
615 // c:572-582 — `if (cmd->old) { used=olen; buf=zhalloc(256+used+1); memcpy(buf, cmd->old, olen); ... } else { buf=zhalloc(256+1); }`
616 let mut buf: Vec<u8> = if let Some(old) = cmd.old.take() {
617 used = old.len();
618 let mut v = Vec::with_capacity(256 + used + 1);
619 v.extend_from_slice(&old);
620 v
621 } else {
622 Vec::with_capacity(257)
623 };
624 // c:583-588 — `if (cmd->read != -1) { buf[used] = cmd->read; seen = used = 1; cmd->read = -1; }`
625 if let Some(c) = cmd.read_buf.take() {
626 buf.push(c);
627 seen = true;
628 used = 1;
629 }
630
631 // c:589 — `do { ... } while ();` loop.
632 use std::sync::atomic::Ordering::Relaxed;
633 let setparam_target = args.first().copied(); // None ⇔ C's `!*args` (stdout).
634
635 loop {
636 // c:590 — `if (noblock && cmd->read == -1)` — poll only when
637 // we don't have a stashed read byte to consume; if read_buf
638 // is set, fall through to the read-or-stash path below.
639 if noblock && cmd.read_buf.is_none() {
640 let mut pfd = libc::pollfd {
641 fd: cmd.master_fd,
642 events: libc::POLLIN,
643 revents: 0,
644 };
645 let pollret = unsafe { libc::poll(&mut pfd, 1, 0) };
646 // c:626 — `if (pollret == 0) break;`
647 if pollret == 0 {
648 break;
649 }
650 // c:612-625 — when pollret < 0, C tries setblock_fd + 1-byte
651 // read and stashes the byte to cmd->read. Skip: setblock_fd
652 // isn't ported (mode-save/restore on fd 0); next iteration's
653 // checkptycmd does the equivalent stash anyway via c:543-544.
654 }
655 // c:629-633 — refresh fin-state on every loop iteration when
656 // we haven't read anything yet this round.
657 if ret == 0 {
658 checkptycmd(cmd);
659 if cmd.finished {
660 break; // c:632
661 }
662 }
663 // c:634 — `if (cmd->read != -1 || (ret = read(cmd->fd, buf+used, 1)) == 1)`:
664 // prefer the byte that checkptycmd's mid-loop pass at c:629-630
665 // just stashed in `cmd.read_buf`; only fall through to a fresh
666 // 1-byte read when none was stashed.
667 let mut byte: u8 = 0;
668 if let Some(c) = cmd.read_buf.take() {
669 byte = c;
670 ret = 1; // c:637
671 } else {
672 ret = unsafe { libc::read(cmd.master_fd, &mut byte as *mut u8 as *mut _, 1) } as isize;
673 }
674 if ret == 1 {
675 // c:642-646 — `if (imeta(c)) { buf[used++] = Meta; buf[used++] = c ^ 32; } else buf[used++] = c;`
676 if crate::ported::ztype_h::imeta(byte) {
677 buf.push(crate::ported::zsh_h::Meta);
678 buf.push(byte ^ 32);
679 used += 2;
680 } else {
681 buf.push(byte);
682 used += 1;
683 }
684 seen = true; // c:647
685 // c:648-658 — when buf grows and no setparam target, flush
686 // to stdout and reset; else grow the buffer.
687 if used >= 255 && setparam_target.is_none() {
688 let mut flush = std::mem::take(&mut buf);
689 let len = crate::ported::utils::unmetafy(&mut flush);
690 flush.truncate(len);
691 use std::io::Write;
692 let _ = std::io::stdout().write_all(&flush);
693 used = 0;
694 }
695 }
696 // c:662-677 — without a pattern: break on EOF or trailing-newline
697 // when args[0] is set; with a pattern: break only on hard read error.
698 if prog.is_none() {
699 if ret <= 0 {
700 break; // c:663 — EOF or hard error
701 }
702 // c:663-664 — `*args && buf[used-1] == '\n' && (used < 2 || buf[used-2] != Meta)`
703 if setparam_target.is_some()
704 && used > 0
705 && buf[used - 1] == b'\n'
706 && (used < 2 || buf[used - 2] != crate::ported::zsh_h::Meta)
707 {
708 break;
709 }
710 } else if ret < 0 {
711 // c:667-676 — with pattern: break on read error UNLESS EWOULDBLOCK/EAGAIN.
712 let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
713 if eno != libc::EWOULDBLOCK && eno != libc::EAGAIN {
714 break;
715 }
716 }
717 // c:678 — `while (!(errflag || breaks || retflag || contflag) && used < READ_MAX && ...)`
718 if crate::ported::utils::errflag.load(Relaxed) != 0
719 || crate::ported::builtin::BREAKS.load(Relaxed) != 0
720 || crate::ported::exec::retflag.load(Relaxed) != 0
721 || crate::ported::builtin::CONTFLAG.load(Relaxed) != 0
722 {
723 break;
724 }
725 if used >= READ_MAX {
726 break;
727 }
728 // c:680 — `prog && ret && (matchok = pattry(prog, buf))` early-exit.
729 if let Some(ref pp) = prog {
730 if ret > 0 {
731 let s = String::from_utf8_lossy(&buf);
732 if crate::ported::pattern::pattry(pp, &s) {
733 matchok = true;
734 break;
735 }
736 }
737 }
738 }
739
740 // c:682-694 — pattern-pending + EWOULDBLOCK/EAGAIN partial-match
741 // stash: copy `buf` into `cmd->old` for the next ptyread call to
742 // pick up at c:572-578, then early-return 1 (alive, no match yet).
743 if prog.is_some() && ret < 0 {
744 let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
745 if eno == libc::EWOULDBLOCK || eno == libc::EAGAIN {
746 // c:691-692 — `cmd->old = zalloc(olen=used); memcpy(...)`.
747 cmd.old = Some(buf);
748 // c:694
749 return 1;
750 }
751 }
752
753 // c:696-701 — output disposition: setsparam(target, buf) or
754 // write the unwritten tail to stdout.
755 if let Some(target) = setparam_target {
756 let s = String::from_utf8_lossy(&buf).to_string();
757 let _ = crate::ported::params::setsparam(target, &s);
758 } else if used > 0 {
759 let mut flush = buf;
760 let len = crate::ported::utils::unmetafy(&mut flush);
761 flush.truncate(len);
762 use std::io::Write;
763 let _ = std::io::stdout().write_all(&flush);
764 }
765
766 // c:703-709 — final return.
767 // int r = cmd->fin + 1;
768 // if (seen && (!prog || matchok || !mustmatch)) r = 0;
769 // return r;
770 let mut r: i32 = if cmd.finished { 2 } else { 1 };
771 if seen && (prog.is_none() || matchok || !mustmatch) {
772 r = 0;
773 }
774 r
775}
776
777/// Write a string to a pty's master end.
778/// Port of `ptywritestr(Ptycmd cmd, char *s, int len)` from Src/Modules/zpty.c:713-740.
779/// Walks the buffer with a control-flow-aware retry loop that bails
780/// on `errflag`, `breaks`, `retflag`, `contflag`; in `cmd->nblock`
781/// mode an `EWOULDBLOCK`/`EAGAIN` short-write returns `!all` so
782/// the caller can stash the unwritten tail (mirrors C c:720-729).
783/// On a non-nonblock write error, `checkptycmd` updates `cmd->fin`
784/// (the pty died); if still alive, the byte count is set to 0 and
785/// the loop continues (c:730-735). Returns `0` if any bytes
786/// landed; otherwise `cmd->fin + 1` (c:739) — `1` for "child
787/// alive, nothing written", `2` for "child dead".
788pub fn ptywritestr(cmd: &mut ptycmd, s: &[u8]) -> i32 {
789 // c:716
790 use std::sync::atomic::Ordering::Relaxed;
791 let mut all: usize = 0;
792 let mut off: usize = 0;
793 let mut len: usize = s.len();
794 // c:718 — `for (; !errflag && !breaks && !retflag && !contflag && len; ...)`
795 while crate::ported::utils::errflag.load(Relaxed) == 0
796 && crate::ported::builtin::BREAKS.load(Relaxed) == 0
797 && crate::ported::exec::retflag.load(Relaxed) == 0
798 && crate::ported::builtin::CONTFLAG.load(Relaxed) == 0
799 && len > 0
800 {
801 // c:720 — `written = write(cmd->fd, s, len)`.
802 let written = unsafe {
803 libc::write(
804 cmd.master_fd,
805 s.as_ptr().add(off) as *const libc::c_void,
806 len,
807 )
808 };
809 if written < 0 && cmd.nonblock {
810 // c:720-729 — nblock + (EWOULDBLOCK || EAGAIN) → return `!all`.
811 let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
812 if eno == libc::EWOULDBLOCK || eno == libc::EAGAIN {
813 return if all == 0 { 1 } else { 0 }; // c:729 — `return !all`
814 }
815 }
816 let written = if written < 0 {
817 // c:730-735 — `checkptycmd(cmd); if (cmd->fin) break; written = 0;`
818 checkptycmd(cmd);
819 if cmd.finished {
820 break;
821 }
822 0
823 } else {
824 written as usize
825 };
826 if written > 0 {
827 // c:736-737 — `all += written;`
828 all += written;
829 }
830 // c:719 — `len -= written, s += written`
831 len = len.saturating_sub(written);
832 off += written;
833 }
834 // c:739 — `return (all ? 0 : cmd->fin + 1);`
835 if all > 0 {
836 0
837 } else if cmd.finished {
838 2
839 } else {
840 1
841 }
842}
843
844/// Port of `ptywrite(Ptycmd cmd, char **args, int nonl)` from
845/// Src/Modules/zpty.c:742-769. Routes every write through
846/// `ptywritestr` so partial-write retry + control-flow bail
847/// (errflag/breaks/retflag/contflag) + nblock EWOULDBLOCK
848/// stash all apply uniformly. Prior implementation used raw
849/// `libc::write` four times inline and threw away all of those
850/// guarantees.
851///
852/// C signature: `static int ptywrite(Ptycmd cmd, char **args, int nonl)`.
853pub fn ptywrite(cmd: &mut ptycmd, args: &[&str], nonl: i32) -> i32 {
854 // c:744
855 if !args.is_empty() {
856 // c:745
857 // c:746 — `char sp = ' ', *tmp;` + `int len;`
858 let sp = b' '; // c:746
859 // c:749 — `while (*args)` — iterate argv with peek-ahead for
860 // the inter-arg space write at c:751.
861 for (i, a) in args.iter().enumerate() {
862 // c:750 — `unmetafy((tmp = dupstring(*args)), &len);`
863 let tmp = crate::ported::utils::unmeta(a);
864 let bytes = tmp.as_bytes();
865 // c:751-752 — `if (ptywritestr(cmd, tmp, len) ||
866 // (*++args && ptywritestr(cmd, &sp, 1)))
867 // return 1;`
868 if ptywritestr(cmd, bytes) != 0 {
869 return 1;
870 }
871 if i + 1 < args.len() && ptywritestr(cmd, &[sp]) != 0 {
872 return 1;
873 }
874 }
875 // c:755 — `if (!nonl) { sp = '\n'; if (ptywritestr(cmd, &sp, 1)) return 1; }`
876 if nonl == 0 {
877 let nl = b'\n'; // c:756
878 if ptywritestr(cmd, &[nl]) != 0 {
879 return 1; // c:758
880 }
881 }
882 } else {
883 // c:760-767 — `while ((n = read(0, buf, BUFSIZ)) > 0) if (ptywritestr(...)) return 1;`
884 let mut buf = [0u8; 4096];
885 loop {
886 let n = unsafe { libc::read(0, buf.as_mut_ptr() as *mut _, buf.len()) };
887 if n <= 0 {
888 break; // c:764
889 }
890 // c:765 — `if (ptywritestr(cmd, buf, n)) return 1;`
891 if ptywritestr(cmd, &buf[..n as usize]) != 0 {
892 return 1;
893 }
894 }
895 }
896 // c:768 — `return 0;`
897 0
898}
899
900/// Port of `bin_zpty(char *nam, char **args, Options ops, UNUSED(int func))` from `Src/Modules/zpty.c:773`.
901/// `zpty` builtin entry point — C-faithful signature matching
902/// `static int bin_zpty(char *nam, char **args, Options ops, int func)`
903/// from Src/Modules/zpty.c:773. Reads `-d/-L/-w/-r/-t/-b/-e/-T/-m`
904/// flags via OPT_ISSET/OPT_ARG, dispatches by mode, emits output to
905/// stdout/stderr based on status, returns the i32 status.
906#[allow(non_snake_case)]
907/// WARNING: param names don't match C — Rust=(_nam, args, _func) vs C=(nam, args, ops, func)
908pub fn bin_zpty(
909 _nam: &str,
910 args: &[String], // c:773
911 ops: &crate::ported::zsh_h::options,
912 _func: i32,
913) -> i32 {
914 // Per C: branches dispatch on OPT_ISSET(ops, 'X') directly. No
915 // aggregator struct — Rule D forbids `*Options` bags.
916 let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
917 let args = &argv[..];
918 let mut cmds_guard = ptycmds().lock().unwrap_or_else(|e| e.into_inner());
919 let cmds: &mut HashMap<String, ptycmd> = &mut *cmds_guard;
920 let (status, output): (i32, String) = (|| {
921 let mut output = String::new();
922
923 // c:775-790 — illegal-option-combination gate. C rejects:
924 // -r AND -w (can't read and write simultaneously)
925 // (-r OR -w) AND (-d|-e|-b|-L) (rw is for an EXISTING pty; create-only flags don't apply)
926 // -w AND (-t OR -m) (-t/-m are read-side only)
927 // -n AND (-b|-e|-r|-t|-d|-L|-m) (-n is the write-side suppress-newline; combined with read/list/create makes no sense)
928 // -d AND (-b|-e|-L|-t|-m) (-d only deletes; the others belong to other modes)
929 // -L AND (-b|-e|-m) (-L lists; -b/-e/-m are create-time / read-side)
930 //
931 // Prior Rust dispatch fell through to the first matching arm
932 // for any combo, so `zpty -r -w foo` would silently take the
933 // -w path and `zpty -d -e foo` would just delete. C rejects
934 // both with "illegal option combination".
935 let r = OPT_ISSET(ops, b'r');
936 let w = OPT_ISSET(ops, b'w');
937 let d = OPT_ISSET(ops, b'd');
938 let e = OPT_ISSET(ops, b'e');
939 let b = OPT_ISSET(ops, b'b');
940 let l = OPT_ISSET(ops, b'L');
941 let t = OPT_ISSET(ops, b't');
942 let n = OPT_ISSET(ops, b'n');
943 let m = OPT_ISSET(ops, b'm');
944 if (r && w)
945 || ((r || w) && (d || e || b || l))
946 || (w && (t || m))
947 || (n && (b || e || r || t || d || l || m))
948 || (d && (b || e || l || t || m))
949 || (l && (b || e || m))
950 {
951 return (1, "zpty: illegal option combination\n".to_string()); // c:789-790
952 }
953
954 if OPT_ISSET(ops, b'd') {
955 // c:809-824 — `-d` arm:
956 // Ptycmd p; int ret = 0;
957 // if (*args) {
958 // while (*args)
959 // if ((p = getptycmd(*args++))) deleteptycmd(p);
960 // else { zwarnnam(nam, "no such pty command: %s", args[-1]); ret = 1; }
961 // } else deleteallptycmds();
962 // return ret;
963 let mut ret: i32 = 0; // c:811
964 if !args.is_empty() {
965 // c:813 — iterate; missing entries log + ret=1 but loop continues.
966 for name in args {
967 if cmds.contains_key(*name) {
968 // c:815 — `deleteptycmd(p)` (SIGHUP to pgrp, not SIGTERM to leader).
969 deleteptycmd(cmds, name);
970 } else {
971 // c:818 — `zwarnnam(nam, "no such pty command: %s", args[-1])`.
972 output.push_str(&format!("zpty: no such pty command: {}\n", name));
973 ret = 1; // c:819
974 }
975 }
976 } else {
977 // c:822 — `deleteallptycmds()`.
978 deleteallptycmds(cmds);
979 }
980 return (ret, output); // c:824
981 }
982
983 if OPT_ISSET(ops, b'w') {
984 // c:795 — `if (!*args) { zwarnnam(nam, "missing pty command name"); return 1; }`
985 if args.is_empty() {
986 return (1, "zpty: missing pty command name\n".to_string());
987 }
988 let name = args[0];
989 // c:798 — `else if (!(p = getptycmd(*args))) { zwarnnam(...); return 1; }`
990 let cmd = match cmds.get_mut(name) {
991 Some(c) => c,
992 None => return (1, format!("zpty: no such pty command: {}\n", name)),
993 };
994 // c:802-803 — `if (p->fin) return 2;`
995 if cmd.finished {
996 return (2, output);
997 }
998 // c:808 — `ptywrite(p, args + 1, OPT_ISSET(ops,'n'))`
999 let tail: Vec<&str> = args[1..].to_vec();
1000 let nonl = if OPT_ISSET(ops, b'n') { 1 } else { 0 };
1001 let r = ptywrite(cmd, &tail, nonl);
1002 (r, output)
1003 } else if OPT_ISSET(ops, b'r') {
1004 // c:792-808 — symmetric `-r` arm: same prelude as `-w`,
1005 // then dispatch:
1006 // ptyread(nam, p, args+1, OPT_ISSET(ops,'t'), OPT_ISSET(ops,'m'))
1007 // c:795
1008 if args.is_empty() {
1009 return (1, "zpty: missing pty command name\n".to_string());
1010 }
1011 let name = args[0];
1012 // c:798
1013 let cmd = match cmds.get_mut(name) {
1014 Some(c) => c,
1015 None => return (1, format!("zpty: no such pty command: {}\n", name)),
1016 };
1017 // c:802-803 — `if (p->fin) return 2;`
1018 if cmd.finished {
1019 return (2, output);
1020 }
1021 // c:805-807
1022 let tail: Vec<&str> = args[1..].to_vec();
1023 let noblock = OPT_ISSET(ops, b't');
1024 let mustmatch = OPT_ISSET(ops, b'm');
1025 let r = ptyread(_nam, cmd, &tail, noblock, mustmatch);
1026 (r, output)
1027 } else if OPT_ISSET(ops, b't') {
1028 // c:825-836 — `-t` arm: "is the child still running?"
1029 // if (!*args) { zwarnnam(nam, "missing pty command name"); return 1; }
1030 // else if (!(p = getptycmd(*args))) { zwarnnam(...); return 1; }
1031 // checkptycmd(p);
1032 // return p->fin;
1033 // Prior port used `poll(fd, POLLIN, 0)` to test fd-readability —
1034 // a different question (pending output) from the C one
1035 // (child-process liveness). The poll-based answer returned
1036 // 0 when output was queued and 1 otherwise, so `zpty -t` on
1037 // an alive-but-idle pty incorrectly reported "finished".
1038 // c:828
1039 if args.is_empty() {
1040 return (1, "zpty: missing pty command name\n".to_string());
1041 }
1042 let name = args[0];
1043 // c:831
1044 let cmd = match cmds.get_mut(name) {
1045 Some(c) => c,
1046 None => return (1, format!("zpty: no such pty command: {}\n", name)),
1047 };
1048 // c:835 — `checkptycmd(p)` (1-byte non-blocking probe + kill(pid, 0)).
1049 checkptycmd(cmd);
1050 // c:836 — `return p->fin;` — 1 if finished, 0 if alive.
1051 // `zpty -t name` is true iff child still running, so a
1052 // finished pty exits 1.
1053 let r = if cmd.finished { 1 } else { 0 };
1054 (r, output)
1055 } else if !args.is_empty() {
1056 // c:837-847 — positional arm: create a new pty command.
1057 // if (!args[1]) { zwarnnam(nam, "missing command"); return 1; }
1058 // if (getptycmd(*args)) { zwarnnam(...); return 1; }
1059 // return newptycmd(nam, *args, args+1, OPT_ISSET(ops,'e'),
1060 // OPT_ISSET(ops,'b'));
1061 // c:838
1062 if args.len() < 2 {
1063 return (1, "zpty: missing command\n".to_string());
1064 }
1065 let name = args[0];
1066 // c:842
1067 if cmds.contains_key(name) {
1068 return (
1069 1,
1070 format!("zpty: pty command name already used: {}\n", name),
1071 );
1072 }
1073 // c:846 — single canonical entry point.
1074 let cmd_args: Vec<String> = args[1..].iter().map(|s| s.to_string()).collect();
1075 let r = newptycmd(
1076 cmds,
1077 _nam,
1078 name,
1079 &cmd_args,
1080 OPT_ISSET(ops, b'e'),
1081 OPT_ISSET(ops, b'b'),
1082 );
1083 (r, output)
1084 } else {
1085 // c:848-868 — no-flag (or -L) catch-all: list every live pty,
1086 // refreshing fin-state via checkptycmd. -L uses the
1087 // "nam [-e] [-b] name args..." format suitable for re-execution;
1088 // the default uses "(finished) name: args..." or
1089 // "(pid) name: args...".
1090 // for (p = ptycmds; p; p = p->next) {
1091 // checkptycmd(p);
1092 // if (OPT_ISSET(ops,'L')) printf("%s %s%s%s ", nam, ...);
1093 // else if (p->fin) printf("(finished) %s: ", p->name);
1094 // else printf("(%d) %s: ", p->pid, p->name);
1095 // for (a = p->args; *a; ) {
1096 // quotedzputs(*a++, stdout);
1097 // if (*a) putchar(' ');
1098 // }
1099 // putchar('\n');
1100 // }
1101 // return 0;
1102 // c:852 — iterate over snapshot of keys so checkptycmd can
1103 // mutate each ptycmd (and so we don't hold a borrow on cmds
1104 // while mutating).
1105 let names: Vec<String> = cmds.keys().cloned().collect();
1106 for n in &names {
1107 let cmd = match cmds.get_mut(n) {
1108 Some(c) => c,
1109 None => continue,
1110 };
1111 // c:853
1112 checkptycmd(cmd);
1113 if OPT_ISSET(ops, b'L') {
1114 // c:854-856 — `printf("%s %s%s%s ", nam, (echo?-e:""), (nblock?-b:""), name)`
1115 output.push_str(&format!(
1116 "{} {}{}{} ",
1117 _nam,
1118 if cmd.echo { "-e " } else { "" },
1119 if cmd.nonblock { "-b " } else { "" },
1120 cmd.name
1121 ));
1122 } else if cmd.finished {
1123 // c:857-858
1124 output.push_str(&format!("(finished) {}: ", cmd.name));
1125 } else {
1126 // c:859-860
1127 output.push_str(&format!("({}) {}: ", cmd.pid, cmd.name));
1128 }
1129 // c:861-865 — `for (a = p->args; *a; ) { quotedzputs(*a++, stdout);
1130 // if (*a) putchar(' '); }`
1131 let n_args = cmd.args.len();
1132 for (i, a) in cmd.args.iter().enumerate() {
1133 output.push_str(&crate::ported::utils::quotedzputs(a));
1134 if i + 1 < n_args {
1135 output.push(' ');
1136 }
1137 }
1138 output.push('\n'); // c:866
1139 }
1140 (0, output) // c:868
1141 }
1142 })();
1143 drop(cmds_guard);
1144 if !output.is_empty() {
1145 if status == 0 {
1146 print!("{}", output);
1147 } else {
1148 eprint!("{}", output);
1149 }
1150 }
1151 status
1152}
1153
1154/// Port of `ptyhook(UNUSED(Hookdef d), UNUSED(void *dummy))` from
1155/// `Src/Modules/zpty.c:874-878`. Registered against the "exit" hook
1156/// by `boot_` so that all live pty sessions get torn down (master
1157/// fd closed, child pgrp SIGHUP'd) when the shell exits.
1158///
1159/// C signature: `static int ptyhook(Hookdef d, void *dummy)`.
1160pub fn ptyhook(_d: *mut crate::ported::zsh_h::hookdef, _dummy: *mut std::ffi::c_void) -> i32 {
1161 // c:874
1162 // c:876 — `deleteallptycmds();`
1163 let mut cmds = ptycmds().lock().unwrap_or_else(|e| e.into_inner());
1164 deleteallptycmds(&mut cmds);
1165 0 // c:877
1166}
1167
1168// `bintab` — port of `static struct builtin bintab[]` (zpty.c).
1169
1170// `module_features` — port of `static struct features module_features`
1171// from zpty.c:884.
1172
1173/// Port of `setup_(UNUSED(Module m))` from `Src/Modules/zpty.c:896`.
1174#[allow(unused_variables)]
1175pub fn setup_(m: *const module) -> i32 {
1176 // c:896
1177 // C body c:898-899 — `return 0`. Faithful empty-body port.
1178 0
1179}
1180
1181/// Port of `features_(UNUSED(Module m), UNUSED(char ***features))` from `Src/Modules/zpty.c:903`.
1182pub fn features_(m: *const module, features: &mut Vec<String>) -> i32 {
1183 // c:903
1184 *features = featuresarray(m, module_features());
1185 0
1186}
1187
1188/// Port of `enables_(UNUSED(Module m), UNUSED(int **enables))` from `Src/Modules/zpty.c:911`.
1189pub fn enables_(m: *const module, enables: &mut Option<Vec<i32>>) -> i32 {
1190 // c:911
1191 handlefeatures(m, module_features(), enables)
1192}
1193
1194/// Port of `boot_(UNUSED(Module m))` from `Src/Modules/zpty.c:918-924`.
1195#[allow(unused_variables)]
1196pub fn boot_(m: *const module) -> i32 {
1197 // c:918
1198 // c:920 — `ptycmds = NULL;` (zero the global registry).
1199 *ptycmds().lock().unwrap_or_else(|e| e.into_inner()) = HashMap::<String, ptycmd>::new();
1200 // c:922 — `addhookfunc("exit", ptyhook);` — register the
1201 // shell-exit teardown callback so deleteallptycmds runs even
1202 // when the user `exit`s without a prior `zpty -d`.
1203 let _ = crate::ported::module::addhookfunc("exit", ptyhook);
1204 0 // c:923
1205}
1206
1207/// Port of `cleanup_(Module m)` from `Src/Modules/zpty.c:928-933`.
1208pub fn cleanup_(m: *const module) -> i32 {
1209 // c:928
1210 // c:930 — `deletehookfunc("exit", ptyhook);` — unregister before
1211 // the module is unloaded so the exit hook doesn't fire into a
1212 // freed module image.
1213 let _ = crate::ported::module::deletehookfunc("exit", ptyhook);
1214 // c:931 — `deleteallptycmds();`
1215 deleteallptycmds(&mut ptycmds().lock().unwrap_or_else(|e| e.into_inner()));
1216 // c:932 — `return setfeatureenables(m, &module_features, NULL);`
1217 setfeatureenables(m, module_features(), None)
1218}
1219
1220/// Port of `finish_(UNUSED(Module m))` from `Src/Modules/zpty.c:937`.
1221#[allow(unused_variables)]
1222pub fn finish_(m: *const module) -> i32 {
1223 // c:937
1224 // C body c:939-940 — `return 0`. Faithful empty-body port; the
1225 // pty session teardown happens in cleanup_.
1226 0
1227}
1228
1229/// Global `ptycmds` linked-list from `Src/Modules/zpty.c:36`.
1230/// C declares `static Ptycmd ptycmds;` and mutates it through the
1231/// whole module. Rust uses OnceLock<Mutex<>> for thread-safe access.
1232pub static PTYCMDS: std::sync::OnceLock<Mutex<HashMap<String, ptycmd>>> =
1233 std::sync::OnceLock::new();
1234
1235static MODULE_FEATURES: OnceLock<Mutex<features>> = OnceLock::new();
1236
1237// Local stubs for the per-module entry points. C uses generic
1238// `featuresarray`/`handlefeatures`/`setfeatureenables` (module.c:
1239// 3275/3370/3445) but those take `Builtin` + `Features` pointer
1240// fields the Rust port doesn't carry. The hardcoded descriptor
1241// list mirrors the C bintab/conddefs/mathfuncs/paramdefs.
1242// WARNING: NOT IN ZPTY.C — Rust-only module-framework shim.
1243// C uses generic featuresarray/handlefeatures/setfeatureenables from
1244// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
1245// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
1246fn featuresarray(_m: *const module, _f: &Mutex<features>) -> Vec<String> {
1247 vec!["b:zpty".to_string()]
1248}
1249
1250// WARNING: NOT IN ZPTY.C — Rust-only module-framework shim.
1251// C uses generic featuresarray/handlefeatures/setfeatureenables from
1252// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
1253// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
1254fn handlefeatures(_m: *const module, _f: &Mutex<features>, enables: &mut Option<Vec<i32>>) -> i32 {
1255 if enables.is_none() {
1256 *enables = Some(vec![1; 1]);
1257 }
1258 0
1259}
1260
1261// WARNING: NOT IN ZPTY.C — Rust-only module-framework shim.
1262// C uses generic featuresarray/handlefeatures/setfeatureenables from
1263// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
1264// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
1265fn setfeatureenables(_m: *const module, _f: &Mutex<features>, _e: Option<&[i32]>) -> i32 {
1266 0
1267}
1268
1269// WARNING: NOT IN ZPTY.C — Rust-only module-framework shim.
1270// C uses generic featuresarray/handlefeatures/setfeatureenables from
1271// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
1272// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
1273fn module_features() -> &'static Mutex<features> {
1274 MODULE_FEATURES.get_or_init(|| {
1275 Mutex::new(features {
1276 bn_list: None,
1277 bn_size: 1,
1278 cd_list: None,
1279 cd_size: 0,
1280 mf_list: None,
1281 mf_size: 0,
1282 pd_list: None,
1283 pd_size: 0,
1284 n_abstract: 0,
1285 })
1286 })
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use crate::zsh_h::{options, MAX_OPS};
1293
1294 #[test]
1295 fn test_pty_cmds_manager() {
1296 let _g = crate::test_util::global_state_lock();
1297 let mut cmds = HashMap::<String, ptycmd>::new();
1298 assert!(cmds.is_empty());
1299
1300 let cmd = ptycmd::new("test", vec!["echo".to_string()], 5, 1234, true, false);
1301 cmds.insert(cmd.name.clone(), cmd);
1302
1303 assert_eq!(cmds.len(), 1);
1304 assert!(cmds.get("test").is_some());
1305 assert!(cmds.get("nonexistent").is_none());
1306
1307 let names: Vec<String> = cmds.keys().cloned().collect();
1308 assert!(names.contains(&"test".to_string()));
1309
1310 cmds.remove("test");
1311 assert!(cmds.is_empty());
1312 }
1313
1314 #[test]
1315 fn test_pty_cmd_fields() {
1316 let _g = crate::test_util::global_state_lock();
1317 let cmd = ptycmd::new(
1318 "mypty",
1319 vec!["bash".to_string(), "-c".to_string()],
1320 10,
1321 5678,
1322 false,
1323 true,
1324 );
1325
1326 assert_eq!(cmd.name, "mypty");
1327 assert_eq!(cmd.args, vec!["bash", "-c"]);
1328 assert_eq!(cmd.master_fd, 10);
1329 assert_eq!(cmd.pid, 5678);
1330 assert!(!cmd.echo);
1331 assert!(cmd.nonblock);
1332 assert!(!cmd.finished);
1333 }
1334
1335 fn ops_with_flag(c: u8) -> options {
1336 let mut o = options {
1337 ind: [0u8; MAX_OPS],
1338 args: Vec::new(),
1339 argscount: 0,
1340 argsalloc: 0,
1341 };
1342 o.ind[c as usize] = 1;
1343 o
1344 }
1345
1346 /// Verifies `-L` (list) on an empty pty table returns 0.
1347 /// Mirrors Src/Modules/zpty.c:773 -L arm.
1348 #[test]
1349 fn test_builtin_zpty_list_empty() {
1350 let _g = crate::test_util::global_state_lock();
1351 // Reset global PTYCMDS for test isolation.
1352 *ptycmds().lock().unwrap() = HashMap::<String, ptycmd>::new();
1353 let status = bin_zpty("zpty", &[], &ops_with_flag(b'L'), 0);
1354 assert_eq!(status, 0);
1355 }
1356
1357 /// Verifies `-d` with no positional args clears all sessions.
1358 /// Mirrors Src/Modules/zpty.c:773 -d arm.
1359 #[test]
1360 fn test_builtin_zpty_delete_all() {
1361 let _g = crate::test_util::global_state_lock();
1362 *ptycmds().lock().unwrap() = HashMap::<String, ptycmd>::new();
1363 let status = bin_zpty("zpty", &[], &ops_with_flag(b'd'), 0);
1364 assert_eq!(status, 0);
1365 }
1366
1367 /// Verifies `-w` with no positional args returns 1 (needs name + data).
1368 #[test]
1369 fn test_builtin_zpty_write_no_args() {
1370 let _g = crate::test_util::global_state_lock();
1371 *ptycmds().lock().unwrap() = HashMap::<String, ptycmd>::new();
1372 let status = bin_zpty("zpty", &[], &ops_with_flag(b'w'), 0);
1373 assert_eq!(status, 1);
1374 }
1375
1376 /// Verifies `-t` with no positional args returns 1 (needs name).
1377 #[test]
1378 fn test_builtin_zpty_test_no_args() {
1379 let _g = crate::test_util::global_state_lock();
1380 *ptycmds().lock().unwrap() = HashMap::<String, ptycmd>::new();
1381 let status = bin_zpty("zpty", &[], &ops_with_flag(b't'), 0);
1382 assert_eq!(status, 1);
1383 }
1384
1385 /// c:153 — `getptycmd` returns None for an unknown name. Pin
1386 /// the negative case so a regen that always returns Some (with
1387 /// a stale entry) gets caught.
1388 #[test]
1389 fn getptycmd_unknown_name_returns_none() {
1390 let _g = crate::test_util::global_state_lock();
1391 let cmds: HashMap<String, ptycmd> = HashMap::new();
1392 assert!(getptycmd(&cmds, "never-created").is_none());
1393 }
1394
1395 /// c:153 — `getptycmd` returns Some for a name in the map.
1396 #[test]
1397 fn getptycmd_returns_inserted_entry() {
1398 let _g = crate::test_util::global_state_lock();
1399 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1400 let cmd = ptycmd::new("foo", vec!["x".to_string()], 3, 4, true, false);
1401 cmds.insert("foo".to_string(), cmd);
1402 let r = getptycmd(&cmds, "foo");
1403 assert!(r.is_some());
1404 assert_eq!(r.unwrap().name, "foo");
1405 }
1406
1407 /// c:490 — `deleteptycmd` on a missing name is a safe no-op.
1408 #[test]
1409 fn deleteptycmd_missing_name_is_safe() {
1410 let _g = crate::test_util::global_state_lock();
1411 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1412 deleteptycmd(&mut cmds, "absent");
1413 assert!(cmds.is_empty());
1414 }
1415
1416 /// c:490 — `deleteptycmd` on a present name removes exactly
1417 /// that entry, leaving siblings intact.
1418 ///
1419 /// IMPORTANT: deleteptycmd calls `libc::kill(-cmd.pid, SIGHUP)`
1420 /// at c:517 (kills the process group). Small pids would target
1421 /// real pgids (`kill(-1, ...)` = "all processes you can signal"
1422 /// — catastrophic in tests). Use pids well beyond any real
1423 /// pgid so the kill becomes ESRCH/EPERM no-op.
1424 #[test]
1425 fn deleteptycmd_removes_only_named_entry() {
1426 let _g = crate::test_util::global_state_lock();
1427 const SAFE_PID: i32 = i32::MAX - 1; // pgid that cannot exist
1428 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1429 cmds.insert(
1430 "a".into(),
1431 ptycmd::new("a", vec![], -1, SAFE_PID, true, false),
1432 );
1433 cmds.insert(
1434 "b".into(),
1435 ptycmd::new("b", vec![], -1, SAFE_PID, true, false),
1436 );
1437 cmds.insert(
1438 "c".into(),
1439 ptycmd::new("c", vec![], -1, SAFE_PID, true, false),
1440 );
1441 deleteptycmd(&mut cmds, "b");
1442 assert!(cmds.contains_key("a"));
1443 assert!(!cmds.contains_key("b"));
1444 assert!(cmds.contains_key("c"));
1445 }
1446
1447 /// c:517 — `deleteallptycmds` empties the map regardless of
1448 /// prior content. Pin the unconditional-clear contract.
1449 ///
1450 /// Uses SAFE_PID = i32::MAX-1 for the same `kill(-pid, SIGHUP)`
1451 /// safety reason as `deleteptycmd_removes_only_named_entry`.
1452 #[test]
1453 fn deleteallptycmds_clears_all() {
1454 let _g = crate::test_util::global_state_lock();
1455 const SAFE_PID: i32 = i32::MAX - 1;
1456 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1457 for n in ["a", "b", "c", "d"] {
1458 cmds.insert(n.into(), ptycmd::new(n, vec![], -1, SAFE_PID, true, false));
1459 }
1460 assert_eq!(cmds.len(), 4);
1461 deleteallptycmds(&mut cmds);
1462 assert!(cmds.is_empty());
1463 }
1464
1465 /// c:65 — `ptynonblock` on a closed/invalid fd must surface an
1466 /// error (not panic) because fcntl(F_GETFL) returns -1 on EBADF.
1467 #[test]
1468 fn ptynonblock_on_bad_fd_returns_error() {
1469 let _g = crate::test_util::global_state_lock();
1470 let r = ptynonblock(99999);
1471 assert!(r.is_err(), "ptynonblock on bad fd should be Err");
1472 }
1473
1474 /// c:97 — `ptygettyinfo` on a bad fd returns 1 (error sentinel,
1475 /// per the c:103 `return 1` arm when `tcgetattr` fails). Pin the
1476 /// non-zero return so a regression that returns 0 (success
1477 /// sentinel) silently passes garbage termios up the call chain.
1478 #[test]
1479 fn ptygettyinfo_on_bad_fd_returns_error_sentinel() {
1480 let _g = crate::test_util::global_state_lock();
1481 let mut ti: libc::termios = unsafe { std::mem::zeroed() };
1482 let r = ptygettyinfo(99999, &mut ti);
1483 assert_ne!(r, 0, "ptygettyinfo on bad fd must NOT report success");
1484 assert_eq!(r, 1, "c:103 error path returns 1");
1485 }
1486
1487 /// c:773 — `bin_zpty -r missing-name` (read from unknown
1488 /// session) returns nonzero. Pin missing-session lookup.
1489 #[test]
1490 fn bin_zpty_r_unknown_session_returns_nonzero() {
1491 let _g = crate::test_util::global_state_lock();
1492 *ptycmds().lock().unwrap() = HashMap::<String, ptycmd>::new();
1493 let r = bin_zpty(
1494 "zpty",
1495 &["unknown-pty".to_string()],
1496 &ops_with_flag(b'r'),
1497 0,
1498 );
1499 assert_ne!(r, 0, "read from unknown pty must fail");
1500 }
1501
1502 // ─── zsh-corpus pins for getptycmd / deleteptycmd ──────────────
1503
1504 /// `getptycmd` on empty table returns None.
1505 #[test]
1506 fn zpty_corpus_getptycmd_empty_returns_none() {
1507 let _g = crate::test_util::global_state_lock();
1508 let cmds = HashMap::<String, ptycmd>::new();
1509 assert!(getptycmd(&cmds, "anything").is_none());
1510 }
1511
1512 /// `getptycmd` finds existing entry.
1513 #[test]
1514 fn zpty_corpus_getptycmd_finds_existing() {
1515 let _g = crate::test_util::global_state_lock();
1516 let mut cmds = HashMap::<String, ptycmd>::new();
1517 let p = ptycmd::new("stub", Vec::new(), -1, 0, false, false);
1518 cmds.insert("my_session".to_string(), p);
1519 assert!(getptycmd(&cmds, "my_session").is_some());
1520 }
1521
1522 /// `deleteptycmd` on missing is a no-op (avoid touching real fds).
1523 #[test]
1524 fn zpty_corpus_deleteptycmd_missing_no_op() {
1525 let _g = crate::test_util::global_state_lock();
1526 let mut cmds = HashMap::<String, ptycmd>::new();
1527 deleteptycmd(&mut cmds, "never_was");
1528 assert!(cmds.is_empty(), "still empty");
1529 }
1530
1531 // Note: deleteptycmd/deleteallptycmds with real entries can attempt
1532 // to close fd -1 (or kill pid 0), which blocks under the test harness.
1533 // Pin only the empty/no-op paths above.
1534
1535 // ═══════════════════════════════════════════════════════════════════
1536 // Additional C-parity tests for Src/Modules/zpty.c — fd-error paths.
1537 // ═══════════════════════════════════════════════════════════════════
1538
1539 /// c:159 — `getptycmd` on empty HashMap returns None.
1540 #[test]
1541 fn getptycmd_empty_table_returns_none() {
1542 let _g = crate::test_util::global_state_lock();
1543 let cmds = HashMap::<String, ptycmd>::new();
1544 assert!(getptycmd(&cmds, "any").is_none());
1545 }
1546
1547 /// c:159 — `getptycmd` for absent name returns None even when
1548 /// table has other entries.
1549 #[test]
1550 fn getptycmd_absent_in_populated_table_returns_none() {
1551 let _g = crate::test_util::global_state_lock();
1552 let mut cmds = HashMap::<String, ptycmd>::new();
1553 cmds.insert(
1554 "session_a".to_string(),
1555 ptycmd::new("stub", Vec::new(), -1, 0, false, false),
1556 );
1557 assert!(getptycmd(&cmds, "session_b").is_none());
1558 }
1559
1560 /// c:314 — `deleteallptycmds` on empty map is a safe no-op.
1561 #[test]
1562 fn deleteallptycmds_empty_is_noop() {
1563 let _g = crate::test_util::global_state_lock();
1564 let mut cmds = HashMap::<String, ptycmd>::new();
1565 deleteallptycmds(&mut cmds);
1566 assert!(cmds.is_empty());
1567 }
1568
1569 /// c:97 — `ptynonblock(-1)` returns Err (invalid fd).
1570 #[test]
1571 fn ptynonblock_invalid_fd_returns_err() {
1572 let _g = crate::test_util::global_state_lock();
1573 let r = ptynonblock(-1);
1574 assert!(r.is_err(), "invalid fd → Err");
1575 }
1576
1577 /// c:119 — `ptygettyinfo(-1, ...)` returns nonzero (libc error).
1578 #[test]
1579 fn ptygettyinfo_invalid_fd_returns_nonzero() {
1580 let _g = crate::test_util::global_state_lock();
1581 let mut ti: libc::termios = unsafe { std::mem::zeroed() };
1582 let r = ptygettyinfo(-1, &mut ti);
1583 assert_ne!(r, 0, "invalid fd → nonzero error");
1584 }
1585
1586 /// c:713 — `ptywritestr(cmd, "x", 1)` with closed master_fd → write(2)
1587 /// returns -1 with EBADF; checkptycmd's `kill(pid, 0)` fails (pid=0
1588 /// is invalid signal target on most platforms), so `cmd->fin` flips
1589 /// true and the loop breaks. Final return: `all == 0 && fin == 1`
1590 /// → `cmd->fin + 1 == 2` per c:739.
1591 #[test]
1592 fn ptywritestr_invalid_fd_returns_nonzero() {
1593 let _g = crate::test_util::global_state_lock();
1594 let mut cmd = ptycmd::new("dummy", vec![], -1, 0, false, false);
1595 let r = ptywritestr(&mut cmd, b"data");
1596 assert_ne!(r, 0, "closed fd → nonzero per c:739");
1597 }
1598
1599 /// c:548 — `ptyread(nam, cmd, [], noblock=true, mustmatch=false)`
1600 /// on a closed master_fd: noblock poll returns 0 (no readable fd
1601 /// → no data ready) so the loop breaks immediately with `seen=0`.
1602 /// Final return: `cmd->fin + 1` (1 if checkptycmd hasn't flagged
1603 /// the child yet, 2 if it has) per c:704. Pin nonzero + no panic.
1604 #[test]
1605 fn ptyread_invalid_fd_no_panic() {
1606 let _g = crate::test_util::global_state_lock();
1607 let mut cmd = ptycmd::new("dummy", vec![], -1, 0, false, false);
1608 let r = ptyread("zpty", &mut cmd, &[], true, false);
1609 assert_ne!(r, 0, "no data + no pattern + noblock → nonzero per c:704");
1610 }
1611
1612 /// c:761-782 — module setup_ / boot_ return 0.
1613 #[test]
1614 fn zpty_setup_boot_return_zero() {
1615 let _g = crate::test_util::global_state_lock();
1616 assert_eq!(setup_(std::ptr::null()), 0);
1617 assert_eq!(boot_(std::ptr::null()), 0);
1618 }
1619
1620 /// c:791-803 — cleanup_ / finish_ return 0.
1621 #[test]
1622 fn zpty_cleanup_finish_return_zero() {
1623 let _g = crate::test_util::global_state_lock();
1624 assert_eq!(cleanup_(std::ptr::null()), 0);
1625 assert_eq!(finish_(std::ptr::null()), 0);
1626 }
1627
1628 // ═══════════════════════════════════════════════════════════════════
1629 // Additional C-parity tests for Src/Modules/zpty.c
1630 // c:97 ptynonblock / c:119 ptygettyinfo / c:159 getptycmd /
1631 // c:290 deleteptycmd / c:314 deleteallptycmds / c:748 ptyhook
1632 // ═══════════════════════════════════════════════════════════════════
1633
1634 /// c:97 — `ptynonblock(-1)` returns Err (invalid fd).
1635 #[test]
1636 fn ptynonblock_negative_fd_returns_err() {
1637 let _g = crate::test_util::global_state_lock();
1638 assert!(ptynonblock(-1).is_err(), "negative fd must error");
1639 }
1640
1641 /// c:97 — `ptynonblock(99999)` returns Err (way-out-of-range fd).
1642 #[test]
1643 fn ptynonblock_far_fd_returns_err() {
1644 let _g = crate::test_util::global_state_lock();
1645 assert!(ptynonblock(99999).is_err(), "out-of-range fd must error");
1646 }
1647
1648 /// c:159 — `getptycmd` is pure (multiple calls same result).
1649 #[test]
1650 fn getptycmd_is_pure_function() {
1651 let _g = crate::test_util::global_state_lock();
1652 let cmds = HashMap::new();
1653 let first = getptycmd(&cmds, "nothing").is_none();
1654 for _ in 0..5 {
1655 assert_eq!(getptycmd(&cmds, "nothing").is_none(), first);
1656 }
1657 }
1658
1659 /// c:290 — `deleteptycmd` on empty table doesn't panic.
1660 #[test]
1661 fn deleteptycmd_on_empty_table_no_panic() {
1662 let _g = crate::test_util::global_state_lock();
1663 let mut cmds = HashMap::new();
1664 deleteptycmd(&mut cmds, "anything");
1665 deleteptycmd(&mut cmds, "");
1666 }
1667
1668 /// c:314 — `deleteallptycmds` on already-empty table is no-op.
1669 #[test]
1670 fn deleteallptycmds_on_empty_table_is_safe() {
1671 let _g = crate::test_util::global_state_lock();
1672 let mut cmds = HashMap::new();
1673 deleteallptycmds(&mut cmds);
1674 assert!(cmds.is_empty(), "still empty");
1675 }
1676
1677 /// c:874-877 — `ptyhook` on an empty registry returns 0.
1678 #[test]
1679 fn ptyhook_empty_returns_zero() {
1680 let _g = crate::test_util::global_state_lock();
1681 *ptycmds().lock().unwrap_or_else(|e| e.into_inner()) = HashMap::new();
1682 assert_eq!(
1683 ptyhook(std::ptr::null_mut(), std::ptr::null_mut()),
1684 0,
1685 "empty registry → 0"
1686 );
1687 }
1688
1689 /// c:761-803 — full lifecycle setup→features→enables→boot→cleanup→finish.
1690 #[test]
1691 fn zpty_full_lifecycle_returns_zero_for_all() {
1692 let _g = crate::test_util::global_state_lock();
1693 let null = std::ptr::null();
1694 assert_eq!(setup_(null), 0);
1695 let mut feats = Vec::new();
1696 let _ = features_(null, &mut feats);
1697 let mut enables: Option<Vec<i32>> = None;
1698 let _ = enables_(null, &mut enables);
1699 assert_eq!(boot_(null), 0);
1700 assert_eq!(cleanup_(null), 0);
1701 assert_eq!(finish_(null), 0);
1702 }
1703
1704 /// c:761 — setup_ idempotent.
1705 #[test]
1706 fn zpty_setup_idempotent() {
1707 let _g = crate::test_util::global_state_lock();
1708 for _ in 0..10 {
1709 assert_eq!(setup_(std::ptr::null()), 0);
1710 }
1711 }
1712
1713 /// c:803 — finish_ idempotent.
1714 #[test]
1715 fn zpty_finish_idempotent() {
1716 let _g = crate::test_util::global_state_lock();
1717 for _ in 0..10 {
1718 assert_eq!(finish_(std::ptr::null()), 0);
1719 }
1720 }
1721
1722 /// c:159 — `getptycmd` empty name lookup returns None.
1723 #[test]
1724 fn getptycmd_empty_name_returns_none() {
1725 let _g = crate::test_util::global_state_lock();
1726 let cmds = HashMap::new();
1727 assert!(getptycmd(&cmds, "").is_none());
1728 }
1729
1730 /// c:119 — `ptygettyinfo(-1)` returns nonzero (error on bad fd).
1731 #[test]
1732 fn ptygettyinfo_negative_fd_returns_nonzero() {
1733 let _g = crate::test_util::global_state_lock();
1734 let mut ti: libc::termios = unsafe { std::mem::zeroed() };
1735 assert_ne!(ptygettyinfo(-1, &mut ti), 0, "negative fd → nonzero");
1736 }
1737
1738 // ═══════════════════════════════════════════════════════════════════
1739 // Additional C-parity tests for Src/Modules/zpty.c
1740 // c:97 ptynonblock / c:119 ptygettyinfo / c:159 getptycmd /
1741 // c:748 ptyhook / c:761-803 lifecycle — type pins + edge cases
1742 // ═══════════════════════════════════════════════════════════════════
1743
1744 /// c:97 — `ptynonblock` returns io::Result<()> (compile-time type pin).
1745 #[test]
1746 fn ptynonblock_returns_io_result_type() {
1747 let _g = crate::test_util::global_state_lock();
1748 let _: io::Result<()> = ptynonblock(-1);
1749 }
1750
1751 /// c:119 — `ptygettyinfo` returns i32 (compile-time type pin).
1752 #[test]
1753 fn ptygettyinfo_returns_i32_type() {
1754 let _g = crate::test_util::global_state_lock();
1755 let mut ti: libc::termios = unsafe { std::mem::zeroed() };
1756 let _: i32 = ptygettyinfo(-1, &mut ti);
1757 }
1758
1759 /// c:159 — `getptycmd` returns Option<&ptycmd> (compile-time type pin).
1760 #[test]
1761 fn getptycmd_returns_option_type() {
1762 let _g = crate::test_util::global_state_lock();
1763 let cmds = HashMap::new();
1764 let _: Option<&ptycmd> = getptycmd(&cmds, "anything");
1765 }
1766
1767 /// c:874-877 — `ptyhook` returns i32 (compile-time type pin).
1768 #[test]
1769 fn ptyhook_returns_i32_type() {
1770 let _g = crate::test_util::global_state_lock();
1771 let _: i32 = ptyhook(std::ptr::null_mut(), std::ptr::null_mut());
1772 }
1773
1774 /// c:761 — `setup_` returns i32 (compile-time type pin).
1775 #[test]
1776 fn zpty_setup_returns_i32_type() {
1777 let _g = crate::test_util::global_state_lock();
1778 let _: i32 = setup_(std::ptr::null());
1779 }
1780
1781 /// c:97 — `ptynonblock` is deterministic for bad fd.
1782 #[test]
1783 fn ptynonblock_negative_fd_is_deterministic() {
1784 let _g = crate::test_util::global_state_lock();
1785 let first = ptynonblock(-1).is_err();
1786 for _ in 0..3 {
1787 assert_eq!(
1788 ptynonblock(-1).is_err(),
1789 first,
1790 "ptynonblock(-1) must be deterministic"
1791 );
1792 }
1793 }
1794
1795 /// c:159 — `getptycmd` is deterministic.
1796 #[test]
1797 fn getptycmd_is_deterministic() {
1798 let _g = crate::test_util::global_state_lock();
1799 let cmds = HashMap::new();
1800 for name in ["", "x", "any", "definitely_missing_xyz"] {
1801 let first = getptycmd(&cmds, name).is_none();
1802 for _ in 0..3 {
1803 assert_eq!(
1804 getptycmd(&cmds, name).is_none(),
1805 first,
1806 "getptycmd({:?}) must be deterministic",
1807 name
1808 );
1809 }
1810 }
1811 }
1812
1813 /// c:768 — features list non-empty.
1814 #[test]
1815 fn zpty_features_nonempty() {
1816 let _g = crate::test_util::global_state_lock();
1817 let mut feats = Vec::new();
1818 features_(std::ptr::null(), &mut feats);
1819 assert!(!feats.is_empty(), "zpty must advertise ≥1 feature");
1820 }
1821
1822 /// c:768 — every feature uses b:/p: prefix per zsh module spec.
1823 #[test]
1824 fn zpty_features_use_canonical_prefix() {
1825 let _g = crate::test_util::global_state_lock();
1826 let mut feats = Vec::new();
1827 features_(std::ptr::null(), &mut feats);
1828 for f in &feats {
1829 assert!(
1830 f.starts_with("b:") || f.starts_with("p:"),
1831 "feature {:?} must use b:/p: prefix",
1832 f
1833 );
1834 }
1835 }
1836
1837 /// c:791 — `cleanup_` idempotent.
1838 #[test]
1839 fn zpty_cleanup_idempotent() {
1840 let _g = crate::test_util::global_state_lock();
1841 for _ in 0..10 {
1842 assert_eq!(cleanup_(std::ptr::null()), 0);
1843 }
1844 }
1845
1846 /// c:782 — `boot_` idempotent.
1847 #[test]
1848 fn zpty_boot_idempotent() {
1849 let _g = crate::test_util::global_state_lock();
1850 for _ in 0..10 {
1851 assert_eq!(boot_(std::ptr::null()), 0);
1852 }
1853 }
1854
1855 /// c:874-877 — `ptyhook` is deterministic on empty registry.
1856 #[test]
1857 fn ptyhook_empty_cmds_is_deterministic() {
1858 let _g = crate::test_util::global_state_lock();
1859 *ptycmds().lock().unwrap_or_else(|e| e.into_inner()) = HashMap::new();
1860 let first = ptyhook(std::ptr::null_mut(), std::ptr::null_mut());
1861 for _ in 0..3 {
1862 assert_eq!(
1863 ptyhook(std::ptr::null_mut(), std::ptr::null_mut()),
1864 first,
1865 "ptyhook on empty registry must be deterministic"
1866 );
1867 }
1868 }
1869
1870 // ═══════════════════════════════════════════════════════════════════
1871 // Additional C-parity pins for Src/Modules/zpty.c
1872 // c:159 getptycmd / c:290 deleteptycmd / c:314 deleteallptycmds /
1873 // c:501 bin_zpty / c:748 ptyhook / c:761-803 lifecycle hooks
1874 // ═══════════════════════════════════════════════════════════════════
1875
1876 /// c:761 — `setup_` is idempotent.
1877 #[test]
1878 fn zpty_setup_idempotent_alt_pin() {
1879 let _g = crate::test_util::global_state_lock();
1880 for _ in 0..10 {
1881 assert_eq!(setup_(std::ptr::null()), 0);
1882 }
1883 }
1884
1885 /// c:803 — `finish_` is idempotent.
1886 #[test]
1887 fn zpty_finish_idempotent_alt_pin() {
1888 let _g = crate::test_util::global_state_lock();
1889 for _ in 0..10 {
1890 assert_eq!(finish_(std::ptr::null()), 0);
1891 }
1892 }
1893
1894 /// c:761 — `setup_` return type i32 (compile-time pin, alt).
1895 #[test]
1896 fn zpty_setup_returns_i32_type_alt_pin() {
1897 let _g = crate::test_util::global_state_lock();
1898 let _: i32 = setup_(std::ptr::null());
1899 }
1900
1901 /// c:791 — `cleanup_` return type i32 (compile-time pin).
1902 #[test]
1903 fn zpty_cleanup_returns_i32_type() {
1904 let _g = crate::test_util::global_state_lock();
1905 let _: i32 = cleanup_(std::ptr::null());
1906 }
1907
1908 /// c:803 — `finish_` return type i32 (compile-time pin).
1909 #[test]
1910 fn zpty_finish_returns_i32_type() {
1911 let _g = crate::test_util::global_state_lock();
1912 let _: i32 = finish_(std::ptr::null());
1913 }
1914
1915 /// c:159 — `getptycmd` on empty map returns None.
1916 #[test]
1917 fn getptycmd_empty_map_returns_none() {
1918 let cmds: HashMap<String, ptycmd> = HashMap::new();
1919 assert!(
1920 getptycmd(&cmds, "anything").is_none(),
1921 "empty map must return None"
1922 );
1923 }
1924
1925 /// c:159 — `getptycmd` with empty name returns None on empty map (alt).
1926 #[test]
1927 fn getptycmd_empty_name_returns_none_alt() {
1928 let cmds: HashMap<String, ptycmd> = HashMap::new();
1929 assert!(
1930 getptycmd(&cmds, "").is_none(),
1931 "empty name on empty map must return None"
1932 );
1933 }
1934
1935 /// c:290 — `deleteptycmd` on empty map is safe (no panic).
1936 #[test]
1937 fn deleteptycmd_empty_map_no_panic() {
1938 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1939 deleteptycmd(&mut cmds, "anything");
1940 deleteptycmd(&mut cmds, "");
1941 }
1942
1943 /// c:314 — `deleteallptycmds` on empty map is safe and idempotent.
1944 #[test]
1945 fn deleteallptycmds_empty_map_idempotent_safe() {
1946 let mut cmds: HashMap<String, ptycmd> = HashMap::new();
1947 for _ in 0..10 {
1948 deleteallptycmds(&mut cmds);
1949 assert!(cmds.is_empty(), "must remain empty");
1950 }
1951 }
1952
1953 /// c:501 — `bin_zpty` empty args non-negative.
1954 #[test]
1955 fn bin_zpty_empty_args_non_negative() {
1956 let _g = crate::test_util::global_state_lock();
1957 let ops = crate::ported::zsh_h::options {
1958 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1959 args: Vec::new(),
1960 argscount: 0,
1961 argsalloc: 0,
1962 };
1963 let r = bin_zpty("zpty", &[], &ops, 0);
1964 assert!(r >= 0, "bin_zpty empty must be ≥ 0, got {}", r);
1965 }
1966
1967 /// c:501 — `bin_zpty` various func values don't panic.
1968 #[test]
1969 fn bin_zpty_various_func_values_no_panic() {
1970 let _g = crate::test_util::global_state_lock();
1971 let ops = crate::ported::zsh_h::options {
1972 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1973 args: Vec::new(),
1974 argscount: 0,
1975 argsalloc: 0,
1976 };
1977 for func in [-1, 0, 1, 100, i32::MAX] {
1978 let _ = bin_zpty("zpty", &[], &ops, func);
1979 }
1980 }
1981
1982 /// c:874-877 — `ptyhook` returns i32 (compile-time pin, alt).
1983 #[test]
1984 fn ptyhook_returns_i32_type_alt() {
1985 let _: i32 = ptyhook(std::ptr::null_mut(), std::ptr::null_mut());
1986 }
1987
1988 /// c:768 — `features_` is deterministic.
1989 #[test]
1990 fn zpty_features_deterministic_alt_pin() {
1991 let _g = crate::test_util::global_state_lock();
1992 let mut v1: Vec<String> = Vec::new();
1993 let mut v2: Vec<String> = Vec::new();
1994 let _ = features_(std::ptr::null(), &mut v1);
1995 let _ = features_(std::ptr::null(), &mut v2);
1996 assert_eq!(v1, v2, "features_ must be deterministic");
1997 }
1998}