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}