Skip to main content

lattice_terminal/
spawner.rs

1//! `spawn` — fork a child process under a fresh pseudo-tty.
2//! Returns the [`PtyHandle`] (writer + resize) and the
3//! published `Arc<ArcSwap<TerminalSnapshot>>` cell the
4//! renderer reads from.
5//!
6//! T1 (2026-05-22): the reader task is a "drain bytes, build
7//! a naive snapshot" stub (no full VT/xterm parsing yet).
8//! T2 swaps in `alacritty_terminal::Term`; the crate's
9//! published interface is unchanged.
10
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use arc_swap::ArcSwap;
15use portable_pty::{CommandBuilder, PtySize, native_pty_system};
16use thiserror::Error;
17
18use crate::handle::PtyHandle;
19use crate::reader::{SharedTerm, spawn_reader};
20use crate::snapshot::TerminalSnapshot;
21
22/// Inputs to [`spawn`].
23#[derive(Debug, Clone)]
24pub struct SpawnConfig {
25    /// Path of the program to exec (e.g. `/usr/bin/zsh`,
26    /// `/bin/sh`, `cargo`).
27    pub program: String,
28    /// Arguments for the program (excluding argv[0]).
29    pub args: Vec<String>,
30    /// Spawn working directory. `None` = inherit parent's cwd.
31    pub cwd: Option<PathBuf>,
32    /// I5: extra environment variables to inject into the child, in addition to
33    /// the inherited parent environment. Applied as `CommandBuilder::env(k, v)`
34    /// per pair. Empty for an ordinary `:terminal`; the Claude Code IDE launch
35    /// (`:claude`) injects `CLAUDE_CODE_SSE_PORT` + `ENABLE_IDE_INTEGRATION` so
36    /// the spawned agent connects back to this editor's IDE server.
37    pub env: Vec<(String, String)>,
38    /// Initial PTY size (rows, cols).
39    pub rows: u16,
40    pub cols: u16,
41    /// T3 (2026-05-25): scrollback ring capacity in lines. `0`
42    /// disables scrollback. Caller resolves the
43    /// `terminal.scrollback-lines` typed option and passes the
44    /// result here.
45    pub scrollback_lines: u32,
46    /// Optional repaint notifier — fired by the reader task
47    /// after every published snapshot. Event-driven renderers
48    /// (GPUI) need this wake to know terminal output has
49    /// arrived; per-tick renderers (TUI) observe the publish
50    /// on their next tick and don't strictly need it. Wired by
51    /// the host from `Editor::paint_request` so terminal output
52    /// drives the same bridge the highlights worker uses.
53    pub paint_request: Option<Arc<tokio::sync::Notify>>,
54}
55
56#[derive(Debug, Error)]
57pub enum SpawnError {
58    #[error("pty open failed: {0}")]
59    OpenPty(String),
60    #[error("child spawn failed: {0}")]
61    SpawnChild(String),
62    #[error("take writer failed: {0}")]
63    TakeWriter(String),
64    #[error("clone reader failed: {0}")]
65    CloneReader(String),
66}
67
68/// Successful spawn handles.
69pub struct SpawnHandles {
70    pub pty: PtyHandle,
71    pub snapshot: Arc<ArcSwap<TerminalSnapshot>>,
72    /// T3 (2026-05-25): shared handle to the alacritty `Term`
73    /// the reader task drives. Dispatch-side actions (scroll,
74    /// resize) lock the inner Mutex; the reader holds an Arc to
75    /// the same `Term`. Cheap to clone.
76    pub term: SharedTerm,
77    /// 2026-05-25: handle to the spawned child the
78    /// `TerminalBuffer::Drop` uses to force the shell to exit
79    /// on buffer teardown. Without this, the master-side
80    /// reader fd (cloned via `try_clone_reader`) keeps the
81    /// PTY open even after `PtyHandle` drops — the child
82    /// never sees SIGHUP, the reader's blocking `read()`
83    /// never returns, and the editor freezes on `:q`. Calling
84    /// `killer.kill()` sends SIGKILL to the child; once it
85    /// exits, the reader's `read()` returns 0 (EOF), the
86    /// spawn_blocking task exits cleanly.
87    pub child_killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
88    /// 2026-05-25: child PID captured at spawn so Drop can
89    /// follow the portable_pty SIGHUP with a SIGKILL fallback
90    /// (`libc::kill(pid, SIGKILL)` on Unix). Some shells /
91    /// environments don't exit on SIGHUP reliably; SIGKILL is
92    /// the guaranteed wake for the reader's blocking read().
93    /// `None` when the platform doesn't expose the PID (Windows
94    /// ConPTY).
95    pub child_pid: Option<u32>,
96}
97
98/// Spawn a child under a PTY. Returns:
99/// - `PtyHandle` for writing keystrokes + resizing.
100/// - `Arc<ArcSwap<TerminalSnapshot>>` for the renderer to
101///   `.load()` each frame.
102/// - Reader task handle (aborted when the caller drops it).
103pub fn spawn(config: SpawnConfig) -> Result<SpawnHandles, SpawnError> {
104    let SpawnConfig {
105        program,
106        args,
107        cwd,
108        env,
109        rows,
110        cols,
111        scrollback_lines,
112        paint_request,
113    } = config;
114
115    let pty_system = native_pty_system();
116    let pair = pty_system
117        .openpty(PtySize {
118            rows,
119            cols,
120            pixel_width: 0,
121            pixel_height: 0,
122        })
123        .map_err(|e| SpawnError::OpenPty(e.to_string()))?;
124
125    let mut cmd = CommandBuilder::new(&program);
126    for arg in &args {
127        cmd.arg(arg);
128    }
129    if let Some(cwd_path) = cwd {
130        cmd.cwd(cwd_path);
131    }
132    // I5: inject extra environment on top of the inherited parent env.
133    for (key, value) in &env {
134        cmd.env(key, value);
135    }
136
137    // Spawn the child on the slave side of the pair.
138    let child = pair
139        .slave
140        .spawn_command(cmd)
141        .map_err(|e| SpawnError::SpawnChild(e.to_string()))?;
142    // 2026-05-25: capture the killer + PID BEFORE dropping the
143    // Child handle so TerminalBuffer::Drop can force the shell
144    // to exit on `:q` / `:bd!`. The reader-side fd (cloned via
145    // `try_clone_reader` below) keeps the master PTY open
146    // even after `PtyHandle` drops, so closing the master is
147    // not enough to wake the reader's blocking `read()` — the
148    // child has to actually exit. portable-pty's `kill()`
149    // sends SIGHUP on Unix, which most shells respect, but
150    // not all environments deliver it reliably (WSL2 quirks,
151    // captured stty, child has SIGHUP trap). Drop sends both:
152    // the portable_pty SIGHUP via the cloned killer AND a
153    // libc::kill(pid, SIGKILL) as the guaranteed fallback.
154    let child_killer = child.clone_killer();
155    let child_pid = child.process_id();
156    drop(child);
157    // Drop the slave on the parent side immediately after
158    // spawn — the child inherited its own copy via dup2.
159    drop(pair.slave);
160
161    // Pull the writer + reader sides out of the master.
162    let writer = pair
163        .master
164        .take_writer()
165        .map_err(|e| SpawnError::TakeWriter(e.to_string()))?;
166    let reader = pair
167        .master
168        .try_clone_reader()
169        .map_err(|e| SpawnError::CloneReader(e.to_string()))?;
170
171    // Share the master writer between the input path (`PtyHandle::write`) and
172    // the reader thread's VT-query responder, which writes DSR / DA / DECRQM /
173    // colour replies back so query-driven TUIs (opentui / opencode) render
174    // instead of blocking on a blank screen. One `Mutex` serialises the two.
175    let writer: crate::handle::SharedPtyWriter =
176        std::sync::Arc::new(parking_lot::Mutex::new(writer));
177    let handle = PtyHandle::new(pair.master, writer.clone(), rows, cols);
178    // `empty_sized` (not `empty`): the placeholder shown before the reader
179    // thread processes the child's first output must match the REAL spawn
180    // geometry — see its doc comment for the clipping regression a
181    // hardcoded 24×80 placeholder caused.
182    let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty_sized(
183        rows, cols,
184    )));
185    let term = spawn_reader(
186        reader,
187        Arc::clone(&snapshot),
188        rows,
189        cols,
190        scrollback_lines,
191        paint_request,
192        writer,
193    );
194
195    Ok(SpawnHandles {
196        pty: handle,
197        snapshot,
198        term,
199        child_killer,
200        child_pid,
201    })
202}