Skip to main content

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}