agentmux_srv/backend/blockcontroller/shell/lifecycle.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The `impl Controller for ShellController` block: start/stop/send_input/
5//! get_runtime_status. Kept whole in one file (a trait impl cannot be split
6//! across modules); it orchestrates the helpers extracted into the sibling
7//! `pty`, `file_ops`, and `translation` modules.
8
9use std::io::Read as _;
10use std::sync::atomic::Ordering;
11use std::sync::Arc;
12use std::time::Instant;
13
14#[cfg(unix)]
15use libc;
16
17use portable_pty::{native_pty_system, CommandBuilder, PtySize};
18use tokio::sync::mpsc;
19
20use super::super::{
21 BlockControllerRuntimeStatus, BlockInputUnion, Controller, META_KEY_CMD_ENV, STATUS_DONE,
22 STATUS_RUNNING,
23};
24#[cfg(unix)]
25use super::controller::KILL_GRACE_SECS;
26use super::controller::{ShellController, SHELL_INPUT_CH_SIZE};
27use super::file_ops::handle_append_block_file;
28use super::pty::{
29 FLUSHER_BARRIER_TIMEOUT, FLUSHER_EOF_GRACE,
30 PTY_CHANNEL_CAPACITY, PTY_COALESCE_MAX_BYTES, PTY_READ_BUF_SIZE,
31};
32use super::translation::accumulate_and_translate;
33use crate::backend::obj::{self, MetaMapType};
34use crate::backend::shellexec::ShellProc;
35use crate::backend::mps;
36
37/// One PTY read's worth of already-OSC-cleaned output, on its way from the
38/// blocking read loop to the coalescing flusher (`start()`, below). See
39/// docs/reports/REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md.
40struct PtyReadChunk {
41 data: Vec<u8>,
42 osc_events: Vec<crate::backend::osc_extractor::OscEvent>,
43 /// Flush barrier. When set, the flusher signals it AFTER committing the
44 /// batch this chunk lands in — so a sender that enqueues a barrier and
45 /// awaits it knows everything queued before it has reached the FileStore
46 /// and the broker.
47 ///
48 /// Exists because "the flusher task ended" is NOT a usable completion
49 /// signal on Windows: ConPTY's console host outlives the direct child, so
50 /// the read loop never sees EOF, never drops its sender, and the channel
51 /// never closes. Waiting on the task therefore always burned the full
52 /// `FLUSHER_DRAIN_TIMEOUT` (see its doc comment) even though the flusher
53 /// itself had been idle for most of that second. A barrier asks the
54 /// question actually being asked — "is prior output committed yet?" —
55 /// and is answered as soon as the batch it lands in commits.
56 ack: Option<tokio::sync::oneshot::Sender<()>>,
57}
58
59/// Do the actual per-batch work the read loop used to do per-read: write
60/// through to the FileStore, broadcast the raw bytes, publish any OSC
61/// activity, and (for agent panes) feed the JSON-stream translator. Called
62/// once per coalesced batch instead of once per (up to 4KB) PTY read.
63#[allow(clippy::too_many_arguments)]
64fn flush_pty_batch(
65 broker: &mps::Broker,
66 block_id: &str,
67 data: &[u8],
68 osc_events: &[crate::backend::osc_extractor::OscEvent],
69 filestore: Option<&Arc<crate::backend::storage::filestore::FileStore>>,
70 line_buf: &mut Vec<u8>,
71 translator: Option<&mut crate::agents::translator::claude::ClaudeTranslator>,
72) {
73 if data.is_empty() && osc_events.is_empty() {
74 return;
75 }
76
77 handle_append_block_file(
78 broker,
79 block_id,
80 "term",
81 data,
82 // Write-through so scrollback survives a reconnect
83 // (SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md
84 // §2.1) — was `None` (raw PTY bytes discarded after
85 // the live broadcast), which meant every `view:"term"`
86 // pane (standalone Terminal panes and the agent-shell
87 // drawer alike) lost all output on remount.
88 filestore,
89 None, // not an agent output stream; no global mirror
90 );
91
92 for ev in osc_events {
93 mps::publish_block_activity(broker, block_id, &ev.payload);
94 }
95
96 if let Some(t) = translator {
97 accumulate_and_translate(broker, block_id, line_buf, data, t);
98 }
99}
100
101/// Drain `rx`, coalescing into batches whatever is already queued at the
102/// moment each batch is assembled — never waiting on output that has not
103/// arrived, and bounded by `PTY_COALESCE_MAX_BYTES` — then flushing each
104/// batch through [`flush_pty_batch`]. Runs until `rx` closes
105/// (the PTY read loop's sender dropped, i.e. the PTY hit EOF or an error),
106/// flushing any final partial batch before returning.
107///
108/// A standalone fn rather than inlined into its `tokio::spawn` call site so
109/// it can be driven directly in a test with a synthetic channel and no real
110/// PTY — see `pty_output_flusher_tests` below.
111async fn run_pty_output_flusher(
112 mut rx: mpsc::Receiver<PtyReadChunk>,
113 broker: Option<Arc<mps::Broker>>,
114 block_id: String,
115 filestore: Option<Arc<crate::backend::storage::filestore::FileStore>>,
116 is_agent: bool,
117) {
118 let Some(broker) = broker else {
119 // No broker means the read loop's sender never had anything worth
120 // sending (see its own `broker_read.is_some()` gate) — nothing to
121 // drain or flush.
122 return;
123 };
124 // Phase 1.5 PR 1 (additive): if this is an agent pane, also try to
125 // interpret stdout as Claude Code stream-json line-by-line, feeding
126 // successful parses through ClaudeTranslator and emitting AgentEvents
127 // on a new MPS scope `agent_event:<block_id>`. The existing raw-chunk
128 // path stays byte-equal — interactive panes (which don't emit JSON) see
129 // no behavior change because every line fails the JSON parse and is
130 // silently dropped. The future stream-json-mode pane and the drone
131 // inspector (issue #830 / Phase 1.5 PR 3) will be the first real
132 // consumers.
133 let mut translator: Option<crate::agents::translator::claude::ClaudeTranslator> = if is_agent
134 {
135 Some(crate::agents::translator::claude::ClaudeTranslator::new())
136 } else {
137 None
138 };
139 // Per-block line-buffer (raw bytes — see `extract_agent_events`). Capped
140 // to AGENT_LINE_BUFFER_CAP so a producer that never emits a newline
141 // can't grow the buffer unboundedly.
142 let mut line_buf: Vec<u8> = Vec::new();
143
144 loop {
145 let Some(first) = rx.recv().await else {
146 break; // channel closed, nothing pending
147 };
148 let mut batch = first.data;
149 let mut batch_osc = first.osc_events;
150 // Barriers collected into THIS batch. A barrier ends accumulation as
151 // soon as it is seen (below): everything already in `batch` is about
152 // to be flushed, and holding the batch open for the rest of the
153 // coalescing window would delay the ack for no benefit.
154 let mut batch_acks: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
155 if let Some(ack) = first.ack {
156 batch_acks.push(ack);
157 }
158 // Drain what is ALREADY queued into this batch, and nothing more —
159 // never wait for output that has not arrived yet.
160 //
161 // This previously waited up to a fixed 20ms window for more chunks.
162 // That is application-layer Nagle, and it sat directly on the
163 // keystroke echo path: typing one character into an otherwise idle
164 // pane produces exactly one chunk, so the window could never be
165 // satisfied early and the echo paid the full 20ms — longer than a
166 // 60Hz frame — before it was ever broadcast. It is the same mistake
167 // SPEC_TERM_DOUBLE_RAF_TEAROUT_2026_05_30 removed from the frontend
168 // (a second frame gate on the echo path, symptom: "not silky"
169 // hiccups); that spec's own prescription for a backend version was a
170 // ~2-4ms merge of consecutive reads, not a 20ms wait.
171 //
172 // Dropping the wait costs no batching, because batching here is
173 // self-tuning without it: a flush takes real time (SQLite write +
174 // broadcast, on spawn_blocking), and everything the producer writes
175 // while that flush is in flight queues up behind it. So the busier
176 // the pane the bigger the next batch — which is exactly the burst
177 // case REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md
178 // measured — while an idle pane, where there is nothing to coalesce
179 // in the first place, now pays nothing to find that out.
180 let closed_mid_batch = loop {
181 if batch.len() >= PTY_COALESCE_MAX_BYTES {
182 break false;
183 }
184 match rx.try_recv() {
185 Ok(next) => {
186 batch.extend_from_slice(&next.data);
187 batch_osc.extend(next.osc_events);
188 if let Some(ack) = next.ack {
189 batch_acks.push(ack);
190 break false; // flush now; someone is waiting on it
191 }
192 }
193 // Nothing more queued — flush immediately rather than
194 // waiting to see whether something shows up.
195 Err(mpsc::error::TryRecvError::Empty) => break false,
196 Err(mpsc::error::TryRecvError::Disconnected) => break true,
197 }
198 };
199
200 // spawn_blocking, not block_in_place (reagentx P2 on PR #3206,
201 // correcting the round before it): flush_pty_batch does blocking
202 // SQLite I/O and takes std::sync::Mutex locks, so it must not run
203 // on a Tokio async worker directly — this closure is inside a
204 // plain `tokio::spawn`, not `spawn_blocking`. The first fix for
205 // that reached for `block_in_place` specifically to avoid moving
206 // `&mut line_buf`/`translator.as_mut()` across a 'static+Send
207 // boundary — but `block_in_place` gets its own replacement worker
208 // via THE SAME shared `spawn_blocking` pool internally (verified
209 // directly against the vendored tokio 1.52.3 source,
210 // `runtime/scheduler/multi_thread/worker.rs`'s `block_in_place`: (comment-hygiene: allow)
211 // `runtime::spawn_blocking(move || run(worker))`), so per flush it
212 // draws on that shared pool TWICE — once for the replacement
213 // worker, once implicitly for the original thread's own blocking
214 // — for work `spawn_blocking` alone would cover with one. Tokio's
215 // own docs single out `block_in_place`'s one real advantage
216 // (protecting OTHER concurrent work in the SAME task, e.g. across
217 // a `join!`) as the reason to prefer it over `spawn_blocking` —
218 // this task does nothing else concurrently, so that advantage
219 // never applies here, and `spawn_blocking` is strictly the better
220 // fit. The ownership problem is solved properly instead: `batch`/
221 // `batch_osc` are freshly built each iteration (cheap to move),
222 // and `line_buf`/`translator` — the only state the flush actually
223 // MUTATES — are moved into the closure and handed back out via the
224 // `JoinHandle`'s return value, rather than trying to keep them
225 // borrowed across the call.
226 let broker_owned = broker.clone();
227 let block_id_owned = block_id.clone();
228 let filestore_owned = filestore.clone();
229 let (new_line_buf, new_translator) = tokio::task::spawn_blocking(move || {
230 flush_pty_batch(
231 &broker_owned,
232 &block_id_owned,
233 &batch,
234 &batch_osc,
235 filestore_owned.as_ref(),
236 &mut line_buf,
237 translator.as_mut(),
238 );
239 (line_buf, translator)
240 })
241 .await
242 .expect("PTY output flush task panicked");
243
244 // Everything enqueued up to and including the barrier is now
245 // committed. A dropped receiver (the waiter timed out and moved on)
246 // is fine — that is the bounded-wait path doing its job.
247 for ack in batch_acks {
248 let _ = ack.send(());
249 }
250 line_buf = new_line_buf;
251 translator = new_translator;
252
253 if closed_mid_batch {
254 break;
255 }
256 }
257}
258
259/// Resolve the effective AGENTMUX_AGENT_ID for jekt auto-registration from a
260/// block's own spawn metadata — both the canonical key and the legacy
261/// WAVEMUX_AGENT_ID alias, checked in the SAME block-scoped `cmd:env` map.
262/// Deliberately does NOT fall back to `std::env::var` for either key: that
263/// reads THIS SRV PROCESS's own environment, shared by every block/pane
264/// this instance spawns — reintroducing exactly the same collision as the
265/// global-settings fallback below (#2694). Nor does it
266/// fall back to the global settings' cmd_env.AGENTMUX_AGENT_ID: that value
267/// is shared across every pane in the instance/channel, so a pane spawned
268/// without its own explicit per-block ID would silently inherit whatever
269/// another pane happened to configure there — and register_agent_with_nonce
270/// unconditionally evicts whoever previously held that agent_key, so the
271/// second such pane to register silently steals the first's jekt identity
272/// (same-host cross-instance jekt misdelivery). See `cmd_env_overrides` below
273/// (its `settings.cmd_env` loop) for the matching fix that keeps
274/// this global value out of the pane's OWN environment too — otherwise a
275/// pane could still pick it up via OSC 16162 and re-register through the
276/// frontend's `/agentmux/reactive/register` path instead, even with THIS
277/// function never reading it directly. A pane with
278/// no block-scoped identity is simply not jekt-registered, matching
279/// persistent/mod.rs's muxbus_agent_id_from_env, which never had either fallback.
280fn resolve_agent_id_for_jekt(block_meta: &MetaMapType) -> Option<String> {
281 let cmd_env = block_meta.get(META_KEY_CMD_ENV).and_then(|m| m.as_object());
282 crate::backend::blockcontroller::agent_id_from_env(|key| {
283 cmd_env.and_then(|obj| obj.get(key)).and_then(|v| v.as_str())
284 })
285}
286
287/// Whether `key` may be injected into a pane's actual OS environment from
288/// the global settings' `cmd_env` defaults (`cmd_env_overrides`, lowest
289/// priority — the block's own `cmd:env` override, highest
290/// priority, is never filtered by this and can always set either key
291/// explicitly). Identity keys are excluded (reagentx P1, round 2 on #2694):
292/// without this, a global cmd_env.AGENTMUX_AGENT_ID setting would still land
293/// in every pane's real environment even though `resolve_agent_id_for_jekt`
294/// never reads it for registration — shell integration echoes an injected
295/// AGENTMUX_AGENT_ID back via OSC 16162, and the frontend's own
296/// `POST /agentmux/reactive/register` path (triggered by that OSC) would
297/// pick it up and register the pane under the shared value anyway, silently
298/// reopening the same-host identity collision this PR removes from the
299/// backend auto-register path specifically.
300fn is_global_cmd_env_injectable(key: &str) -> bool {
301 !matches!(key, "AGENTMUX_AGENT_ID" | "WAVEMUX_AGENT_ID")
302}
303
304/// The `cmd:env` part of an interactive shell's environment, computed from
305/// the global settings and the block's own metadata. See
306/// [`cmd_env_overrides`].
307#[derive(Debug)]
308struct CmdEnvOverrides {
309 /// `(key, value)` pairs in application order: global settings first,
310 /// block metadata last. Applied one by one, so a later entry for the same
311 /// key wins. Kept as a list rather than merged into a map because
312 /// `CommandBuilder` matches env keys case-insensitively on Windows: a
313 /// block's `PATH` must still override a global `Path`.
314 vars: Vec<(String, String)>,
315 /// Whether the block's own `cmd:env` sets `AGENTMUX_AGENT_ID`. When it
316 /// does not, the inherited agent-identity vars are stripped.
317 block_sets_agent_id: bool,
318}
319
320impl CmdEnvOverrides {
321 fn apply_to(&self, c: &mut CommandBuilder) {
322 for (k, v) in &self.vars {
323 c.env(k, v);
324 }
325 // Strip host-inherited agent identity unless the block's own cmd:env
326 // sets it. (Global settings never can: `is_global_cmd_env_injectable`.)
327 // This also supersedes the old WAVEMUX backward-compat bridge — both
328 // AGENTMUX_* and WAVEMUX_* vars are removed so new panes start as
329 // plain "Terminal".
330 if !self.block_sets_agent_id {
331 c.env_remove("AGENTMUX_AGENT_ID");
332 c.env_remove("WAVEMUX_AGENT_ID");
333 c.env_remove("WAVEMUX_AGENT_COLOR");
334 }
335 }
336}
337
338/// Compute an interactive shell's `cmd:env` overrides.
339///
340/// Precedence, lowest to highest:
341/// 1. `settings.cmd_env`, the global `cmd:env` from settings.json, minus the
342/// identity keys `is_global_cmd_env_injectable` excludes.
343/// 2. The block's own `cmd:env` metadata.
344///
345/// Values get `~` expanded. `settings` must come from the live config
346/// ([`ShellController::live_settings`]). The spawn once read a freshly built
347/// default config instead, whose `cmd_env` is always empty, so the global
348/// setting never reached any shell.
349fn cmd_env_overrides(
350 settings: &crate::backend::wconfig::SettingsType,
351 block_meta: &MetaMapType,
352) -> CmdEnvOverrides {
353 let mut vars = Vec::new();
354 let mut block_sets_agent_id = false;
355
356 // Global defaults.
357 for (k, v) in &settings.cmd_env {
358 if !is_global_cmd_env_injectable(k) {
359 continue;
360 }
361 let expanded = crate::backend::base::expand_home_dir_safe(v);
362 vars.push((k.clone(), expanded.to_string_lossy().into_owned()));
363 }
364
365 // Block metadata (per-block overrides).
366 // Sorted, matching the key order serde_json's map gave before.
367 let mut block_env: Vec<(String, String)> = super::super::cmd_env_of(block_meta).into_iter().collect();
368 block_env.sort();
369 for (k, val) in block_env {
370 if k == "AGENTMUX_AGENT_ID" {
371 block_sets_agent_id = true;
372 }
373 let expanded = crate::backend::base::expand_home_dir_safe(&val);
374 vars.push((k, expanded.to_string_lossy().into_owned()));
375 }
376
377 CmdEnvOverrides { vars, block_sets_agent_id }
378}
379
380impl ShellController {
381 /// Apply `cmd:env` to an interactive shell's command: the live global
382 /// settings, then the block's own metadata, then the identity strip.
383 fn apply_cmd_env(&self, c: &mut CommandBuilder, block_meta: &MetaMapType) {
384 cmd_env_overrides(&self.live_settings(), block_meta).apply_to(c);
385 }
386
387 /// The process a `cmd` pane runs: `cmd` itself when it has `cmd:args` or
388 /// `cmd:interactive`, otherwise `cmd` through the platform shell. Either
389 /// way with the pane's `cmd:env`, as an interactive shell gets it: a pane
390 /// running a provider CLI's login needs the account's config directory.
391 ///
392 /// Spawned directly, an npm `.cmd` shim on Windows is resolved to what it
393 /// runs: the shim itself hangs under ConPTY (`resolve_cli_spawn_target`).
394 /// One that can't be read is refused, rather than started into a pane
395 /// that would never respond.
396 fn command_process(
397 &self,
398 cmd_str: &str,
399 cmd_args: &[String],
400 interactive: bool,
401 block_meta: &MetaMapType,
402 ) -> Result<CommandBuilder, String> {
403 let mut c = if !cmd_args.is_empty() || interactive {
404 // Spawned directly (no shell wrapper), so args pass through intact.
405 let (program, prefix) = agentmux_common::resolve_cli_spawn_target(cmd_str).ok_or_else(|| {
406 format!(
407 "can't run {cmd_str}: it's a command wrapper AgentMux can't read, and run as it is it would hang; \
408 reinstall the tool, or run its own executable"
409 )
410 })?;
411 let mut c = CommandBuilder::new(&program);
412 c.args(&prefix);
413 c.args(cmd_args);
414 c
415 } else if cfg!(windows) {
416 let mut c = CommandBuilder::new("cmd.exe");
417 c.args(["/C", cmd_str]);
418 c
419 } else {
420 let mut c = CommandBuilder::new("/bin/sh");
421 c.args(["-c", cmd_str]);
422 c
423 };
424 self.apply_cmd_env(&mut c, block_meta);
425 Ok(c)
426 }
427
428 /// Ends a start that was refused before anything ran: the pane is done,
429 /// with no exit code, and the controller can be started again.
430 fn refuse_start(&self, message: String) -> Result<(), String> {
431 let mut inner = self.inner.lock().unwrap();
432 Self::set_status(&mut inner, STATUS_DONE);
433 inner.proc_exit_code = -1;
434 inner.input_tx = None;
435 drop(inner);
436 self.unlock_run();
437 Err(message)
438 }
439}
440
441impl Controller for ShellController {
442 fn start(
443 &self,
444 block_meta: MetaMapType,
445 rt_opts: Option<serde_json::Value>,
446 force: bool,
447 ) -> Result<(), String> {
448 let cmd_str_preview = Self::get_cmd_str(&block_meta);
449 let interactive_preview = Self::is_interactive(&block_meta);
450 tracing::info!(
451 block_id = %self.block_id,
452 controller = %self.controller_type,
453 cmd = %cmd_str_preview,
454 interactive = interactive_preview,
455 force = force,
456 "block start requested"
457 );
458
459 // Check if we should run
460 if !force && !Self::should_run_on_start(&block_meta) {
461 tracing::info!(block_id = %self.block_id, "skipping start: run_on_start is false");
462 return Ok(());
463 }
464
465 // A pane on a connection AgentMux cannot run must not quietly get a
466 // local shell under a remote name: it gets no shell, and its connection
467 // overlay says why (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
468 // WSL runs on Windows (P1); SSH wherever the system `ssh` is (P2). The
469 // test-only mock factory keeps its own path.
470 if self.conn_factory.lock().unwrap().is_none() {
471 let conn = Self::get_conn_name(&block_meta);
472 match crate::backend::remote::ConnTarget::parse(&conn) {
473 Ok(target) if target.is_local() => {}
474 #[cfg(windows)]
475 Ok(crate::backend::remote::ConnTarget::Wsl(_)) => {}
476 Ok(crate::backend::remote::ConnTarget::Ssh(_))
477 if crate::backend::remote::ssh::binary().is_some() => {}
478 Ok(crate::backend::remote::ConnTarget::Ssh(_)) => {
479 return Err(crate::backend::remote::ssh::missing_binary_message());
480 }
481 Ok(target) => {
482 return Err(format!(
483 "connection {} is not available yet: this version of AgentMux runs local terminals only",
484 target.name()
485 ));
486 }
487 Err(e) => return Err(format!("connection {conn:?}: {e}")),
488 }
489 }
490
491 // Try to acquire run lock
492 if !self.try_lock_run() {
493 return Err("controller is already running".to_string());
494 }
495
496 // Get connection info
497 let conn_name = Self::get_conn_name(&block_meta);
498
499 // Update status to running.
500 {
501 let mut inner = self.inner.lock().unwrap();
502 Self::set_status(&mut inner, STATUS_RUNNING);
503 inner.conn_name = conn_name.clone();
504 }
505
506 // Create input channel. Unbounded: input must never be silently
507 // dropped on burst (the paste-truncation bug). See SHELL_INPUT_CH_SIZE.
508 let (input_tx, input_rx) = mpsc::unbounded_channel();
509 {
510 let mut inner = self.inner.lock().unwrap();
511 inner.input_tx = Some(input_tx);
512 inner.input_seq_next = 0;
513 inner.input_seq_buf.clear();
514 }
515
516 // Publish "running" only AFTER `input_tx` exists, so the event is a
517 // truthful readiness signal: a frontend that resizes the PTY the instant
518 // it sees "running" will not hit `send_input`'s "controller is not
519 // running" guard (which fires while `input_tx` is None). The channel is
520 // unbounded, so a resize enqueued before the input task spawns is
521 // buffered, not lost.
522 // See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
523 self.publish_status();
524
525 // Check if we have a conn_factory (test/mock path)
526 let has_factory = self.conn_factory.lock().unwrap().is_some();
527
528 if has_factory {
529 // Mock path: use ConnInterface factory (synchronous, for tests)
530 let conn_result = {
531 let factory = self.conn_factory.lock().unwrap();
532 factory.as_ref().unwrap()(&conn_name, &block_meta)
533 };
534
535 let mut conn = match conn_result {
536 Ok(c) => c,
537 Err(e) => {
538 let mut inner = self.inner.lock().unwrap();
539 Self::set_status(&mut inner, STATUS_DONE);
540 inner.proc_exit_code = -1;
541 inner.input_tx = None;
542 self.unlock_run();
543 return Err(format!("failed to create connection: {e}"));
544 }
545 };
546
547 if let Err(e) = conn.start() {
548 let mut inner = self.inner.lock().unwrap();
549 Self::set_status(&mut inner, STATUS_DONE);
550 inner.proc_exit_code = -1;
551 inner.input_tx = None;
552 self.unlock_run();
553 return Err(format!("failed to start process: {e}"));
554 }
555
556 let mut shell_proc = ShellProc::new(conn_name, conn);
557 let _done_rx = shell_proc.take_done_rx();
558 let exit_code = shell_proc.wait_and_signal();
559
560 {
561 let mut inner = self.inner.lock().unwrap();
562 inner.proc_exit_code = exit_code;
563 Self::set_status(&mut inner, STATUS_DONE);
564 inner.input_tx = None;
565 }
566 self.publish_status();
567 self.unlock_run();
568 return Ok(());
569 }
570
571 // Real PTY path.
572 //
573 // Open the PTY at the size the frontend computed from the pane (passed
574 // as `rtopts.termsize` on the resync), so the agent CLI and its tools
575 // wrap correctly from the very first byte. The earlier code always
576 // opened at a fixed 200 cols and relied on a post-spawn resize RPC to
577 // correct it — but that RPC races controller startup and could fail
578 // outright, leaving output wrapped at 200 all session. Seeding the size
579 // here removes that race; `pty_size_from_rt_opts` falls back to the
580 // historical 25x200 when no size was supplied (e.g. programmatic
581 // spawns). See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
582 let pty_system = native_pty_system();
583 let pty_size = self.spawn_pty_size(&rt_opts);
584
585 let pair = pty_system.openpty(pty_size).map_err(|e| {
586 tracing::error!(block_id = %self.block_id, error = %e, "failed to open PTY");
587 let mut inner = self.inner.lock().unwrap();
588 Self::set_status(&mut inner, STATUS_DONE);
589 inner.proc_exit_code = -1;
590 inner.input_tx = None;
591 self.unlock_run();
592 format!("failed to open PTY: {e}")
593 })?;
594 tracing::info!(block_id = %self.block_id, rows = pty_size.rows, cols = pty_size.cols, "PTY opened");
595
596 // Determine shell command
597 let cmd_str = Self::get_cmd_str(&block_meta);
598 let cmd_args = Self::get_cmd_args(&block_meta);
599 let interactive = Self::is_interactive(&block_meta);
600
601 // Resolve effective AGENTMUX_AGENT_ID for jekt auto-registration.
602 // See resolve_agent_id_for_jekt's doc comment for why this
603 // deliberately does not fall back to global settings.
604 let agent_id_for_jekt: Option<String> = resolve_agent_id_for_jekt(&block_meta);
605
606 // Close-on-exit (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10-11):
607 // resolved from `block_meta` now, while it's still in scope, for
608 // the wait/cleanup task below to act on once the process actually
609 // exits. See `effective_close_on_exit`'s own doc comment for the
610 // per-controller-type default. (`should_close_on_exit_force` is
611 // deliberately NOT read here — see its own doc comment for why no
612 // call site uses it.)
613 //
614 // Forced off for a SUB-block — a block whose `parentoref` names
615 // another BLOCK (`block:<id>`): an agent's composer-drawer shell
616 // (`AgentShellSubblock.tsx`, via `createsubblock`) or a
617 // `PtyShellCreate`-spawned headless shell. Those are not
618 // independent panes, and closing one via
619 // `sagas::delete_block::run` leaves the parent's own
620 // `term:shellsubblockid` pointer dangling (a field that saga knows
621 // nothing about), after which the drawer's own "attach or create"
622 // logic silently recreates a fresh shell — reproducing the
623 // original respawn loop by a different route (§11).
624 //
625 // A TOP-LEVEL pane's `parentoref` names its owning TAB
626 // (`tab:<id>`, set by `wcore::block`/`persist_subscriber`) — NOT
627 // empty. Checking `!parentoref.is_empty()` here (as the first cut
628 // of §11 did) therefore disabled close-on-exit for *every* pane,
629 // including the plain `Terminal` widget this feature exists for —
630 // the regression the reporter hit as "still hangs" (§12). Match
631 // on the `block:` prefix specifically, the same discriminator
632 // `websocket.rs`'s `strip_prefix("block:")` already uses.
633 let is_sub_block = self
634 .mstore
635 .as_ref()
636 .and_then(|store| store.get::<obj::Block>(&self.block_id).ok().flatten())
637 .map(|b| b.parentoref.starts_with("block:"))
638 .unwrap_or(false);
639 let close_on_exit = Self::effective_close_on_exit(&block_meta, &self.controller_type) && !is_sub_block;
640 let close_on_exit_delay_ms = Self::close_on_exit_delay_ms(&block_meta);
641
642 // Detect agent pane: cmd contains a known agent CLI or has AGENTMUX_AGENT_ID set.
643 // Computed here (before spawn) rather than after so the commit-aware admission
644 // gate below can use it; also reused post-spawn to set `inner.is_agent_pane`.
645 let is_agent = agent_id_for_jekt.is_some()
646 || cmd_str.to_lowercase().contains("claude")
647 || cmd_str.to_lowercase().contains("codex")
648 || cmd_str.to_lowercase().contains("gemini")
649 || cmd_str.to_lowercase().contains("qwen");
650
651 // Pillar 3 — commit-aware admission control, extended to the interactive
652 // agent pane. The drone Agent block's one-shot spawn (`agents::runner::
653 // run_agent`) already refuses to start another `claude.exe` when system
654 // commit headroom is below the reserve, to avoid tipping the box into a
655 // Chromium OOM abort (0xE0000008). That gate covered only the drone path;
656 // this interactive PTY spawn is the OTHER (likely more common) place a
657 // fresh agent CLI process gets started, and previously had no protection
658 // at all. Reuse the same pure decision + reserve lookup here, gated on
659 // `is_agent` so plain shell/cmd panes are unaffected — only a new
660 // claude/codex/gemini/qwen process is refused under commit pressure.
661 // See SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29.md, PR #1853.
662 if is_agent {
663 if let Err(e) = crate::agents::runner::admit_spawn(
664 crate::backend::sysinfo::available_commit_gb(),
665 crate::agents::runner::agent_commit_reserve_gb(),
666 ) {
667 tracing::warn!(
668 block_id = %self.block_id,
669 cmd = %cmd_str,
670 error = %e,
671 "admission gate: refusing interactive agent spawn under commit pressure"
672 );
673 return self.refuse_start(format!(
674 "memory full — not enough memory to start a new agent right now, free up memory and try again ({e})"
675 ));
676 }
677 }
678
679 // A WSL pane runs `wsl.exe -d <distro>` (Windows only; the guard above
680 // refuses it elsewhere). Its cwd is a path inside the distro, passed as
681 // `--cd`, never set as the Windows working directory below.
682 let wsl_distro = match crate::backend::remote::ConnTarget::parse(&conn_name) {
683 Ok(crate::backend::remote::ConnTarget::Wsl(d)) if cfg!(windows) => Some(d),
684 _ => None,
685 };
686 // An SSH pane runs the system `ssh` (remote::ssh). Its cwd is a path on
687 // the remote host, handed to the remote shell, never set here. An
688 // agent's (its PtyShell, marked below) needs one that forces askpass.
689 let ssh_plan = match crate::backend::remote::ConnTarget::parse(&conn_name) {
690 Ok(crate::backend::remote::ConnTarget::Ssh(dest)) => {
691 let agents = !obj::meta_get_string(
692 &block_meta,
693 crate::backend::remote::askpass::META_KEY_AGENT_BLOCK,
694 "",
695 )
696 .is_empty();
697 if agents {
698 crate::backend::remote::ssh::binary()
699 } else {
700 crate::backend::remote::ssh::binary_for_user()
701 }
702 .map(|path| (path, dest))
703 }
704 _ => None,
705 };
706 // An agent's SSH PtyShell (`http_pty_shell.rs` marks it): a secret for
707 // its ssh's prompts, revoked when this shell's wait task ends.
708 let askpass_secret = ssh_plan.as_ref().and_then(|_| {
709 let agent_block = obj::meta_get_string(&block_meta, crate::backend::remote::askpass::META_KEY_AGENT_BLOCK, "");
710 (!agent_block.is_empty()).then(|| {
711 crate::backend::remote::askpass::grant(crate::backend::remote::askpass::AskpassGrant {
712 agent_block_id: agent_block,
713 agent: obj::meta_get_string(&block_meta, crate::backend::remote::askpass::META_KEY_AGENT, ""),
714 connection: conn_name.clone(),
715 user_pane: false,
716 })
717 })
718 });
719 let askpass_guard = askpass_secret.clone().map(crate::backend::remote::askpass::Revoke);
720 let mut cmd = if let Some(distro) = &wsl_distro {
721 let cwd = obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, "");
722 let plan = crate::backend::remote::wsl::launch(distro, &cmd_str, &cmd_args, &cwd);
723 tracing::info!(block_id = %self.block_id, distro = %distro, args = ?plan.args, "wsl spawn path");
724 let mut c = CommandBuilder::new("wsl.exe");
725 c.args(plan.args.iter().map(String::as_str));
726 c.env("TERM", "xterm-256color");
727 c.env("COLORTERM", "truecolor");
728 c.env("TERM_PROGRAM", "agentmux");
729 c.env("AGENTMUX_BLOCKID", &self.block_id);
730 c.env("AGENTMUX_TABID", &self.tab_id);
731 c.env("AGENTMUX_VERSION", env!("CARGO_PKG_VERSION"));
732 // Configured cmd:env (global, then the block's), as a local
733 // interactive shell gets it, and listed in WSLENV so it crosses.
734 let overrides = cmd_env_overrides(&self.live_settings(), &block_meta);
735 overrides.apply_to(&mut c);
736 c.env(
737 "WSLENV",
738 crate::backend::remote::wsl::wslenv_with(
739 &std::env::var("WSLENV").unwrap_or_default(),
740 overrides.vars.iter().map(|(k, _)| k.as_str()),
741 ),
742 );
743 c
744 } else if let Some((ssh_path, dest)) = &ssh_plan {
745 let cwd = obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, "");
746 tracing::info!(block_id = %self.block_id, "ssh spawn");
747 let what = (cmd_str.as_str(), cmd_args.as_slice(), cwd.as_str());
748 super::ssh_pane::command(ssh_path, dest, what, askpass_secret.as_deref(), &self.auth_key, &self.block_id)
749 } else if !cmd_str.is_empty() && (!cmd_args.is_empty() || interactive) {
750 // Direct spawn: cmd:args provided or cmd:interactive set.
751 // Spawn the CLI directly (no sh -c wrapper) so args are passed correctly.
752 tracing::info!(block_id = %self.block_id, cmd = %cmd_str, args = ?cmd_args, "direct spawn path");
753 match self.command_process(&cmd_str, &cmd_args, interactive, &block_meta) {
754 Ok(c) => c,
755 Err(e) => return self.refuse_start(e),
756 }
757 } else if !cmd_str.is_empty() {
758 // "cmd" controller: run a specific command string via shell wrapper
759 tracing::info!(block_id = %self.block_id, cmd = %cmd_str, "shell-wrapped spawn path");
760 match self.command_process(&cmd_str, &cmd_args, interactive, &block_meta) {
761 Ok(c) => c,
762 Err(e) => return self.refuse_start(e),
763 }
764 } else {
765 // "shell" controller: the block's or the default shell, with AgentMux integration (backend::shells).
766 let (shell_path, shell_opts) = crate::backend::shells::for_block(&self.block_id, &block_meta, &self.live_settings());
767 let shell_path = shell_path.display().to_string();
768
769 let shell_type = crate::backend::shellintegration::detect_shell_type(&shell_path);
770
771 // Deploy shell integration scripts under the user's home rather than
772 // AGENTMUX_DATA_HOME. MSIX packages virtualise writes to %LocalAppData%,
773 // so files written by the packaged backend aren't visible to child
774 // processes (pwsh, bash, etc.) spawned via ConPTY. The home dir is never
775 // virtualised, so the scripts are always reachable at their literal path.
776 //
777 // The path is keyed per instance: this runs on EVERY shell spawn, so an
778 // unkeyed home-global dir let two concurrently-running versions rewrite
779 // each other's scripts indefinitely (I6). See `integration_base`.
780 let shell_home = crate::backend::shellintegration::integration_base();
781 crate::backend::shellintegration::deploy_scripts(&shell_home);
782
783 tracing::info!(block_id = %self.block_id, shell = %shell_path, shell_type = ?shell_type, "interactive shell path");
784
785 let mut c = CommandBuilder::new(&shell_path);
786
787
788 // Apply shell-specific startup args (--rcfile, -File, etc.)
789 let startup = crate::backend::shellintegration::get_shell_startup(shell_type, &shell_home);
790 let integration = startup.as_ref().map(|s| s.extra_args.as_slice()).unwrap_or_default();
791 c.args(crate::backend::shells::with_user_opts(integration, &shell_opts));
792 for (k, v) in startup.iter().flat_map(|s| &s.env_vars) {
793 c.env(k, v);
794 }
795
796 // Inject terminal capability env vars into the PTY environment.
797 // ConPTY on Windows fully supports VT/ANSI sequences, so set TERM
798 // on all platforms. Without this, CLI tools (e.g. Claude Code) use
799 // different Unicode width tables, causing ANSI color offset on Windows.
800 c.env("TERM", "xterm-256color");
801 c.env("COLORTERM", "truecolor");
802 c.env("TERM_PROGRAM", "agentmux");
803 c.env("AGENTMUX_BLOCKID", &self.block_id);
804 c.env("AGENTMUX_TABID", &self.tab_id);
805 c.env("AGENTMUX_VERSION", env!("CARGO_PKG_VERSION"));
806
807 // Inject log directory so agents can find logs without guessing.
808 // Always ~/.agentmux/logs/ — matches both host and sidecar.
809 let log_dir = dirs::home_dir()
810 .unwrap_or_default()
811 .join(".agentmux")
812 .join("logs");
813 c.env("AGENTMUX_LOG_DIR", log_dir.to_string_lossy().as_ref());
814
815 // AGENTMUX_LOCAL_URL / AGENTMUX_AUTH_KEY: injected below, applied
816 // to ALL THREE branches (not just this one) — see that comment
817 // for why.
818
819 // Wire AgentMux-managed tool dirs into the agent's PATH.
820 //
821 // Two stores, two precedence rules:
822 //
823 // • Bundled store (`<exe_dir>/tools/bin`) ships the app's OWN
824 // version-locked binaries — notably `agentmux-bashwrap`, the
825 // streaming hook that MUST match the running build. It is
826 // PREPENDED so it wins over any stale copy elsewhere on the
827 // system PATH. A stale system-PATH `agentmux-bashwrap` (from a
828 // leftover portable) silently shadowed the fixed one and
829 // reintroduced the exit-130 bug — see
830 // docs/retro/RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md. The
831 // bundled jq/rg winning too is intended: agents get the app's
832 // curated, deterministic tool versions regardless of host.
833 //
834 // • User-managed store (`~/.agentmux/tools/bin`) holds tools the
835 // user installed via /tools. APPENDED so the user's own system
836 // PATH still wins for those.
837 {
838 let current_path = std::env::var("PATH").unwrap_or_default();
839 let mut bundled: Option<std::path::PathBuf> = None;
840
841 // Bundled store — prepended (app-owned, version-locked).
842 if let Some(bundled_bin) = crate::backend::tool_store::bundled_tools_dir() {
843 if bundled_bin.exists() {
844 // Guardrail: log which agentmux-bashwrap the agent will
845 // actually run. A stale system-PATH copy silently
846 // shadowing the bundled one is exactly the exit-130
847 // trap (RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md); this
848 // one line makes "which binary?" answerable at a glance
849 // (cross-check the version with `agentmux-bashwrap
850 // --version`).
851 let bashwrap_exe = if cfg!(windows) {
852 "agentmux-bashwrap.exe"
853 } else {
854 "agentmux-bashwrap"
855 };
856 let bw = bundled_bin.join(bashwrap_exe);
857 if bw.exists() {
858 tracing::info!(
859 target: "agent-tools",
860 path = %bw.display(),
861 "agent bashwrap: bundled (version-locked, prepended to PATH)"
862 );
863 } else {
864 tracing::warn!(
865 target: "agent-tools",
866 dir = %bundled_bin.display(),
867 "agent bashwrap: bundled store present but agentmux-bashwrap MISSING — agent will resolve via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
868 );
869 }
870 bundled = Some(bundled_bin.clone());
871 } else {
872 tracing::warn!(
873 target: "agent-tools",
874 "agent bashwrap: no bundled tools dir — agent will resolve agentmux-bashwrap via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
875 );
876 }
877 }
878
879 // User-managed store — after the system PATH, which still wins.
880 let user = crate::backend::tool_store::user_tools_dir().filter(|d| d.exists());
881 if let Some(path) = crate::backend::tool_store::compose_tools_path(
882 ¤t_path,
883 bundled.as_deref(),
884 user.as_deref(),
885 crate::backend::tool_store::UserToolsPrecedence::AfterExisting,
886 ) {
887 c.env("PATH", path);
888 }
889 }
890
891 // cmd:env: global settings (lowest priority), then the block's
892 // own metadata (highest), then the agent-identity strip. The
893 // settings come from the live config — see `cmd_env_overrides`.
894 self.apply_cmd_env(&mut c, &block_meta);
895
896 c
897 };
898
899 // Propagate this instance's local API endpoint + auth key to ALL
900 // THREE branches above, not just the interactive shell — same "don't
901 // half-fix it" lesson as the sanitize_pty_command call below (muxreview
902 // P0 on PR #3326). `muxsh`/`muxspect`/`muxopen`/`muxlog` — meant for
903 // a human to run from inside a plain Terminal-widget pane, the same
904 // role Wave Terminal's `wsh` played — need both to authenticate
905 // against this instance's REST API; without them they fail with
906 // "AGENTMUX_LOCAL_URL / AGENTMUX_AUTH_KEY not set" even when run from
907 // a pane AgentMux itself opened, which is exactly the case they're
908 // documented to support (SPEC_MUXSH_FULL_COLLECTION_2026_09_16.md).
909 //
910 // AGENTMUX_LOCAL_URL is set by main.rs after binding and never
911 // scrubbed from this process's own env, so it can be read straight
912 // back (absent in test/mock contexts — graceful no-op). AUTH_KEY
913 // cannot be read the same way: config.rs deliberately removes it
914 // from this process's own environment right after startup (PR #801),
915 // so it has to be threaded in explicitly via `self.auth_key` instead
916 // — the same value agent panes already receive via their own
917 // `PersistentSpawnConfig`/`SubprocessSpawnConfig` env_vars.
918 if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
919 cmd.env("AGENTMUX_LOCAL_URL", &local_url);
920 }
921 if !self.auth_key.is_empty() {
922 cmd.env("AGENTMUX_AUTH_KEY", &self.auth_key);
923 }
924
925 // Applied to ALL THREE branches above, not just the interactive shell.
926 // The direct-spawn branch launches agent CLIs and the shell-wrapped `cmd`
927 // branch can launch anything; both inherited the full instance identity
928 // while this lived inside the third branch (ReAgent P0 on PR #3326).
929 crate::backend::pane_env::sanitize_pty_command(&mut cmd);
930 // An agent's PtyShell carries the agent's plain-`gh` guard (set by
931 // `http_pty_shell.rs`): the agent types into this shell, so `gh` here
932 // must not act as the user. Every branch, after the sanitizer; a WSL
933 // shell's WSLENV already lists GH_CONFIG_DIR as a distro path.
934 let gh_guard = obj::meta_get_string(
935 &block_meta,
936 crate::backend::gh_guard::META_KEY_PTYSHELL_GH_CONFIG_DIR,
937 "",
938 );
939 if !gh_guard.is_empty() {
940 cmd.env(crate::backend::gh_guard::GH_CONFIG_DIR, &gh_guard);
941 }
942 // Its publish-guard hooks too (`backend::publish_guard`). A host path,
943 // so a WSL shell, whose WSLENV doesn't list these, is unaffected.
944 let hooks = obj::meta_get_string(
945 &block_meta,
946 crate::backend::publish_guard::META_KEY_PTYSHELL_GIT_HOOKS_PATH,
947 "",
948 );
949 if !hooks.is_empty() {
950 // On top of the GIT_CONFIG_* entries this shell already has (the
951 // process env plus cmd:env), so none of them is overwritten.
952 let entries = crate::backend::publish_guard::git_config_entries(
953 |k| cmd.get_env(k).and_then(|v| v.to_str()).map(str::to_string),
954 "core.hooksPath",
955 &hooks,
956 );
957 for (key, value) in entries {
958 cmd.env(key, value);
959 }
960 }
961 // Identity M4a: a terminal pane never carries a token. No counter —
962 // most panes are not agents — but an agent CLI that registers from
963 // this block shows in the live gauge.
964 crate::backend::identity_spawn::record_spawn(&self.block_id, false, None);
965
966 // Working directory: the pane's own, else (a local shell) the user's
967 // home, not srv's own working directory.
968 let remote = wsl_distro.is_some() || ssh_plan.is_some();
969 let cwd = super::spawn_cwd::spawn_cwd(&obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, ""), remote);
970 if !cwd.is_empty() && !remote {
971 cmd.cwd(&cwd);
972 }
973
974 // Program name for the UI, captured before `cmd` is consumed by the
975 // spawn. The process tracker can't supply it: it deliberately omits
976 // each block's root process (#3430), and for this sub-block the root
977 // IS the shell.
978 let prog_name = cmd
979 .get_argv()
980 .first()
981 .map(|p| {
982 std::path::Path::new(p)
983 .file_stem()
984 .map(|s| s.to_string_lossy().into_owned())
985 .unwrap_or_else(|| p.to_string_lossy().into_owned())
986 })
987 .unwrap_or_default();
988
989 if is_agent {
990 crate::backend::process_tracker::registry::place_pty_spawn(&self.block_id, &mut cmd);
991 }
992 let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
993 tracing::error!(block_id = %self.block_id, error = %e, cmd = %cmd_str, "spawn failed");
994 let mut inner = self.inner.lock().unwrap();
995 Self::set_status(&mut inner, STATUS_DONE);
996 inner.proc_exit_code = -1;
997 inner.input_tx = None;
998 self.unlock_run();
999 format!("failed to spawn command: {e}")
1000 })?;
1001 tracing::info!(block_id = %self.block_id, "process spawned successfully");
1002
1003 // Register PID and record spawn metadata.
1004 let spawn_ts_ms = agentmux_common::time::now_ms();
1005 {
1006 let mut inner = self.inner.lock().unwrap();
1007 if let Some(pid) = child.process_id() {
1008 super::super::pidregistry::register(&self.block_id, pid);
1009 if is_agent {
1010 crate::backend::process_tracker::registry::track_spawned_agent(&self.block_id, pid);
1011 } else {
1012 crate::backend::process_tracker::registry::track_spawned(&self.block_id, pid);
1013 }
1014 inner.child_pid = Some(pid);
1015 }
1016 inner.spawn_ts_ms = Some(spawn_ts_ms);
1017 inner.shell_name = prog_name.clone();
1018 inner.is_agent_pane = is_agent;
1019 inner.agent_id = agent_id_for_jekt.clone();
1020 }
1021
1022 // Re-publish now that the spawn metadata exists. The `running` status
1023 // above goes out BEFORE the PTY child is created, so it carries no
1024 // pid, program name or spawn time, and nothing else publishes until
1025 // the process exits — a subscriber would show a running shell as an
1026 // anonymous one for its whole life (Codex P1 on #3436). `persist: 1`
1027 // means this snapshot is also what a later subscriber gets replayed.
1028 self.publish_status();
1029
1030 // Auto-register with jekt if AGENTMUX_AGENT_ID was set in the block env.
1031 // This maps agent_id → block_id in the ReactiveHandler so jekt can deliver
1032 // messages directly to this PTY without a separate /agentmux/reactive/register call.
1033 if let Some(ref agent_id) = agent_id_for_jekt {
1034 // Identity M2: a PTY pane has no `db_agents` row, so no UID —
1035 // registered by name only, counted (spec §4.4.1 row 4).
1036 match crate::backend::reactive::get_global_handler()
1037 .register_agent_full(
1038 agent_id,
1039 &self.block_id,
1040 Some(&self.tab_id),
1041 0,
1042 None,
1043 None,
1044 "registration.no_uid.shell_pane",
1045 )
1046 {
1047 Ok(()) => {
1048 tracing::info!(
1049 block_id = %self.block_id,
1050 agent_id = %agent_id,
1051 "jekt: auto-registered"
1052 );
1053 // Also write to cross-instance file registry, and its
1054 // host-global sibling (Tier 2b, issue #1916) — this
1055 // auto-register path bypasses the HTTP register handler
1056 // entirely, so it needs its own mirror call too.
1057 if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
1058 let data_dir = crate::backend::base::get_mux_data_dir();
1059 crate::backend::reactive::registry::write(
1060 &data_dir,
1061 agent_id,
1062 &local_url,
1063 &self.block_id,
1064 );
1065 crate::backend::reactive::registry::write_shared_from_env(
1066 agent_id,
1067 &local_url,
1068 &self.block_id,
1069 );
1070 }
1071 }
1072 Err(e) => tracing::warn!(
1073 block_id = %self.block_id,
1074 agent_id = %agent_id,
1075 error = %e,
1076 "jekt: auto-register failed"
1077 ),
1078 }
1079 }
1080 tracing::info!(
1081 block_id = %self.block_id,
1082 mstore_present = self.mstore.is_some(),
1083 event_bus_present = self.event_bus.is_some(),
1084 "[dnd-debug] pre-seed state after spawn"
1085 );
1086
1087 // Seed cmd:cwd in block meta immediately after spawn so drag-and-drop works
1088 // before the shell emits its first OSC 7 (or for shells without integration).
1089 // Not for an SSH or WSL pane: its cwd is a path on the other side, and
1090 // this machine's directory would be sent there on the next launch.
1091 let remote_pane = ssh_plan.is_some() || wsl_distro.is_some();
1092 if let Some(store) = self.mstore.as_ref().filter(|_| !remote_pane) {
1093 let effective_cwd = cwd.clone(); // spawn_cwd: never empty for a local shell
1094 tracing::debug!(block_id = %self.block_id, cwd = %effective_cwd, "seeding cmd:cwd");
1095 if !effective_cwd.is_empty() {
1096 let oref_str = format!("block:{}", self.block_id);
1097 let mut meta_update = MetaMapType::new();
1098 meta_update.insert(
1099 super::super::META_KEY_CMD_CWD.to_string(),
1100 serde_json::Value::String(effective_cwd),
1101 );
1102 // Only set if not already populated — don't clobber a restored session CWD
1103 match store.must_get::<crate::backend::obj::Block>(&self.block_id) {
1104 Ok(block) if obj::meta_get_string(&block.meta, super::super::META_KEY_CMD_CWD, "").is_empty() => {
1105 match crate::server::service::update_object_meta(store, &oref_str, &meta_update) {
1106 Ok(()) => {
1107 // Re-read updated block and broadcast obj:update so the
1108 // frontend Jotai atom refreshes (update_object_meta only writes
1109 // to SQLite — it does NOT send a WebSocket event on its own).
1110 if let Ok(updated_block) = store.must_get::<crate::backend::obj::Block>(&self.block_id) {
1111 if let Some(ref event_bus) = self.event_bus {
1112 let update = obj::MuxObjUpdate {
1113 updatetype: "update".into(),
1114 otype: "block".into(),
1115 oid: self.block_id.clone(),
1116 obj: Some(obj::mux_obj_to_value(&updated_block)),
1117 };
1118 event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, oref_str.clone()));
1119 tracing::info!(block_id = %self.block_id, "cmd:cwd seeded and broadcast to frontend");
1120 } else {
1121 tracing::warn!(block_id = %self.block_id, "cmd:cwd written to store but no event_bus to broadcast — frontend won't update");
1122 }
1123 }
1124 }
1125 Err(e) => {
1126 tracing::warn!(block_id = %self.block_id, error = %e, "failed to seed cmd:cwd in store");
1127 }
1128 }
1129 }
1130 Ok(_) => {
1131 tracing::debug!(block_id = %self.block_id, "cmd:cwd already set, skipping seed");
1132 }
1133 Err(e) => {
1134 tracing::warn!(block_id = %self.block_id, error = %e, "failed to read block for cmd:cwd seed");
1135 }
1136 }
1137 }
1138 }
1139
1140 // Get reader/writer from master
1141 let reader = pair.master.try_clone_reader().map_err(|e| {
1142 let _ = child.kill();
1143 let mut inner = self.inner.lock().unwrap();
1144 Self::set_status(&mut inner, STATUS_DONE);
1145 inner.proc_exit_code = -1;
1146 inner.input_tx = None;
1147 self.unlock_run();
1148 format!("failed to clone PTY reader: {e}")
1149 })?;
1150
1151 let writer = pair.master.take_writer().map_err(|e| {
1152 let _ = child.kill();
1153 let mut inner = self.inner.lock().unwrap();
1154 Self::set_status(&mut inner, STATUS_DONE);
1155 inner.proc_exit_code = -1;
1156 inner.input_tx = None;
1157 self.unlock_run();
1158 format!("failed to take PTY writer: {e}")
1159 })?;
1160
1161 // Spawn PTY read task (blocking I/O → spawn_blocking) + a companion
1162 // coalescing flusher (async task). Split in two, connected by an
1163 // mpsc channel, so the actual OS-level `read()` loop's blocking
1164 // semantics are untouched (this file supports both Unix and
1165 // Windows PTYs and that boundary is not something to risk getting
1166 // subtly wrong per-platform) while the expensive per-chunk work —
1167 // file write, WebSocket broadcast, OSC/translation — is batched.
1168 // See docs/reports/REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md.
1169 let block_id_read = self.block_id.clone();
1170 let block_id_flush = self.block_id.clone();
1171 let broker_read = self.broker.clone();
1172 let broker_flush = self.broker.clone();
1173 let inner_read = self.inner.clone();
1174 let filestore_flush = self.filestore.clone();
1175 let is_agent_read = is_agent;
1176 let is_agent_flush = is_agent;
1177 // Bounded (reagentx P1 on PR #3206) — see PTY_CHANNEL_CAPACITY's own
1178 // doc comment for why: an unbounded channel here would let a
1179 // sustained fast producer grow queued memory without limit if the
1180 // flusher ever falls behind the read rate.
1181 let (pty_tx, pty_rx) = mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
1182 // Kept for the post-exit flush barrier (see the wait/cleanup task).
1183 //
1184 // A WEAK sender, emphatically not a clone. ReAgent P1 (round 3) on PR
1185 // #3261: an mpsc channel closes only once EVERY `Sender` is dropped, so
1186 // a strong clone held by the wait task keeps the channel open for as
1187 // long as that task lives — which is precisely across the window where
1188 // the task waits for the flusher to end. `flusher_handle` could then
1189 // never resolve, on any platform, making the "prefer true EOF" fast
1190 // path dead code and silently applying the fallback's residual race to
1191 // every exit everywhere. An earlier revision of this very comment
1192 // claimed a clone "does NOT keep the channel open"; that was simply
1193 // wrong.
1194 //
1195 // `WeakSender` does not count towards keeping the channel alive, and
1196 // `upgrade()` returning `None` is itself the answer the barrier wants:
1197 // the channel is closed, so everything in it was drained.
1198 let barrier_tx: tokio::sync::mpsc::WeakSender<PtyReadChunk> = pty_tx.downgrade();
1199
1200 tokio::task::spawn_blocking(move || {
1201 let mut reader = reader;
1202 let mut buf = [0u8; PTY_READ_BUF_SIZE];
1203
1204 // OSC extractor: only for agent panes. Terminal panes forward
1205 // raw bytes to xterm.js which handles OSC natively — stripping
1206 // there would suppress the native window-title update. Agent
1207 // panes don't use xterm.js; OSC bytes in FileStore corrupt the
1208 // document renderer, so we extract and strip them here. Kept
1209 // in THIS (per-read) loop rather than the flusher: it is
1210 // cheap, and its own internal state must see every read in
1211 // order exactly as they arrive — moving it to the batched side
1212 // would change nothing about correctness but would mean
1213 // holding it across an .await, so it stays here for simplicity.
1214 let mut osc_extractor: Option<crate::backend::osc_extractor::OscExtractor> =
1215 if is_agent_read {
1216 Some(crate::backend::osc_extractor::OscExtractor::new())
1217 } else {
1218 None
1219 };
1220
1221 loop {
1222 match reader.read(&mut buf) {
1223 Ok(0) => break, // EOF
1224 Ok(n) => {
1225 inner_read.lock().unwrap().last_pty_output = Some(Instant::now());
1226 if broker_read.is_some() {
1227 let raw = &buf[..n];
1228
1229 // Extract OSC sequences from agent-pane output.
1230 // `cleaned` has OSC bytes removed; `osc_events` carries
1231 // any normalised Claude Code title strings found in this chunk.
1232 let mut cleaned_storage: Vec<u8> = Vec::new();
1233 let mut osc_events: Vec<crate::backend::osc_extractor::OscEvent> = Vec::new();
1234 if let Some(ref mut ext) = osc_extractor {
1235 let (cleaned, evs) = ext.feed(raw);
1236 cleaned_storage = cleaned;
1237 osc_events = evs;
1238 }
1239 let chunk: &[u8] = if osc_extractor.is_some() {
1240 &cleaned_storage
1241 } else {
1242 raw
1243 };
1244
1245 // `blocking_send`, not `send` — this closure runs
1246 // on a `spawn_blocking` thread, not async, and a
1247 // bounded channel's plain `send` is itself async.
1248 // Blocks THIS (already-dedicated-to-blocking)
1249 // thread once the channel is full, which is
1250 // exactly the backpressure PTY_CHANNEL_CAPACITY's
1251 // doc comment describes — never the async runtime.
1252 //
1253 // Best-effort beyond that: if the flusher task is
1254 // gone (block torn down mid-read), there is
1255 // nothing useful to do with this chunk — the PTY
1256 // read loop itself must keep draining so the
1257 // child process's stdout never backs up.
1258 let _ = pty_tx.blocking_send(PtyReadChunk {
1259 ack: None,
1260 data: chunk.to_vec(),
1261 osc_events,
1262 });
1263 }
1264 }
1265 Err(e) => {
1266 tracing::debug!("PTY read error for {}: {}", block_id_read, e);
1267 break;
1268 }
1269 }
1270 }
1271 // `pty_tx` drops here, closing the channel — the flusher below
1272 // sees `recv()` return `None` once it has drained everything
1273 // already sent, flushes any final partial batch, and exits.
1274 // (Still true with the barrier handle in play: that one is a
1275 // `WeakSender`, which does not keep the channel open. It must stay
1276 // weak — see its own comment above.)
1277 });
1278
1279 // Coalescing flusher: batches whatever chunks the read loop above
1280 // has already queued (bounded by PTY_COALESCE_MAX_BYTES, and never
1281 // waiting for more to arrive) before doing the
1282 // actual file write + broadcast + translation — once per batch
1283 // instead of once per (up to 4KB) PTY read. A standalone fn (not
1284 // inlined into the spawn) so it can be driven directly in tests
1285 // with a synthetic channel, no real PTY required.
1286 //
1287 // Handle kept (reagentx P1 on PR #3206, third round): the wait
1288 // task below must await this before publishing STATUS_DONE — see
1289 // its own comment for why.
1290 let flusher_handle = tokio::spawn(run_pty_output_flusher(
1291 pty_rx,
1292 broker_flush,
1293 block_id_flush,
1294 filestore_flush,
1295 is_agent_flush,
1296 ));
1297
1298 // Spawn input task (routes input channel → PTY writer + resize + signals)
1299 // Owns writer and master — dropping them closes the PTY, causing child to exit.
1300 let master = pair.master;
1301 tokio::spawn(async move {
1302 let mut writer = writer;
1303 let mut input_rx = input_rx;
1304 while let Some(input) = input_rx.recv().await {
1305 if let Some(data) = input.input_data {
1306 use std::io::Write;
1307 if let Err(e) = writer.write_all(&data) {
1308 tracing::debug!("PTY write error: {}", e);
1309 break;
1310 }
1311 }
1312 if let Some(ref size) = input.term_size {
1313 let pty_size = PtySize {
1314 rows: size.rows as u16,
1315 cols: size.cols as u16,
1316 pixel_width: 0,
1317 pixel_height: 0,
1318 };
1319 if let Err(e) = master.resize(pty_size) {
1320 tracing::debug!("PTY resize error: {}", e);
1321 }
1322 }
1323 if input.sig_name.is_some() {
1324 // Drop writer + master to close PTY, which terminates the child
1325 break;
1326 }
1327 }
1328 // writer and master drop here → PTY closes → child gets EOF/terminates
1329 });
1330
1331 // Spawn wait task (monitors process exit)
1332 let inner_wait = Arc::clone(&self.inner);
1333 let block_id_wait = self.block_id.clone();
1334 let tab_id_wait = self.tab_id.clone();
1335 let agent_id_wait = agent_id_for_jekt.clone();
1336 let broker_wait = self.broker.clone();
1337 // An SSH pane holds its connection open from here to the wait task's
1338 // pane_ended (ssh_pane). Counted only now, after every step that can
1339 // still fail and return, so each start has exactly one end.
1340 if ssh_plan.is_some() {
1341 crate::backend::remote::link::pane_started(&conn_name, false);
1342 }
1343 let filestore_note = self.filestore.clone();
1344 let ssh_wait = ssh_plan
1345 .as_ref()
1346 .map(|(_, dest)| (conn_name.clone(), dest.destination.clone()));
1347 // Moved into the wait task: the secret lives exactly as long as the shell.
1348 let askpass_guard_wait = askpass_guard;
1349 // For the agent-lease release below: clearing the `term:agentlockuntil`
1350 // meta copy (not just the in-memory registry) needs both, since that
1351 // is what the frontend's own gate reads.
1352 let mstore_wait = self.mstore.clone();
1353 let event_bus_wait = self.event_bus.clone();
1354 let run_lock = Arc::clone(&self.run_lock);
1355 // Async outer task, not a bare spawn_blocking (reagentx P1 on PR
1356 // #3206, third round): before this fix, the read loop flushed
1357 // every chunk SYNCHRONOUSLY inline, so by the time it observed PTY
1358 // EOF (closely tracking child exit) all output was already
1359 // committed to FileStore and broadcast — no explicit join needed,
1360 // the ordering fell out of the code being synchronous. Now the
1361 // read loop only drops the channel sender on EOF; the flusher (its
1362 // `JoinHandle` above) still has to notice the closed channel,
1363 // drain its accumulation loop, and complete an async
1364 // `spawn_blocking` flush — plus
1365 // queueing behind other panes' flushes — with nothing previously
1366 // synchronizing that completion against this task. A fast-exiting
1367 // command's trailing output could be missing from FileStore / not
1368 // yet broadcast at the moment clients observe `STATUS_DONE`,
1369 // widening a race that used to be negligible into a real one.
1370 //
1371 // codex P1 on PR #3206 (same round, caught on the fix above before
1372 // merge): reaping the child must NOT be gated behind awaiting the
1373 // flusher. If a background descendant inherits the PTY slave fd and
1374 // keeps it open after the direct child (the shell) exits — e.g. it
1375 // ignores SIGHUP — the read loop's `read()` may never observe EOF,
1376 // `pty_tx` is never dropped, the flusher's accumulation loop never
1377 // sees its channel close, and `flusher_handle` never resolves.
1378 // Awaiting it before `child.wait()` would then hang cleanup,
1379 // `STATUS_DONE` publication, and `run_lock` release for that
1380 // descendant's entire remaining lifetime — the pane reports running
1381 // forever and can never restart. `persistent/process_waiter.rs`'s stdout/stderr
1382 // reader cleanup hits the identical descendant-held-descriptor case
1383 // and already establishes the fix: reap the child first
1384 // (unconditional, not gated on any reader/flusher), then bound the
1385 // reader/flusher wait with a timeout rather than waiting forever —
1386 // see the timeout's own comment below for why expiry does NOT
1387 // `abort()` here, unlike `persistent/process_waiter.rs`'s version of this bound.
1388 tokio::spawn(async move {
1389 // Reap the child (blocking OS call) on its own — depends only
1390 // on the direct child exiting, never on PTY EOF. Clones
1391 // `block_id_wait` in (rather than moving the outer binding)
1392 // since the rest of this task still needs it afterward.
1393 let block_id_reap = block_id_wait.clone();
1394 let (exit_code, raw_exit_code) = tokio::task::spawn_blocking(move || {
1395 let mut child = child;
1396 match child.wait() {
1397 // The pane keeps its 0 / 1 / -1; the raw code is for an
1398 // SSH pane, where 255 means ssh could not connect.
1399 Ok(status) => (if status.success() { 0 } else { 1 }, Some(status.exit_code())),
1400 Err(e) => {
1401 tracing::warn!("wait error for block {}: {}", block_id_reap, e);
1402 (-1, None)
1403 }
1404 }
1405 })
1406 .await
1407 .expect("PTY child wait task panicked");
1408 drop(askpass_guard_wait);
1409 // An SSH pane that lost its connection stays, saying so (ssh_pane).
1410 let lost_note = ssh_wait.as_ref().and_then(|(conn, dest)| {
1411 let stopped_by_srv = inner_wait.lock().unwrap().proc_status != STATUS_RUNNING;
1412 super::ssh_pane::ended(conn, dest, raw_exit_code, stopped_by_srv)
1413 });
1414 let close_on_exit = close_on_exit && lost_note.is_none();
1415
1416 tracing::info!(block_id = %block_id_wait, exit_code = exit_code, "process exited");
1417
1418 // Bounded wait for the flusher to drain trailing output — see
1419 // `FLUSHER_DRAIN_TIMEOUT`'s own doc comment for why this must be
1420 // bounded. Deliberately does NOT `abort()` on timeout (reagentx
1421 // P1, same round, on the timeout+abort version of this fix):
1422 // the flusher (consumer) and the PTY read loop (producer,
1423 // `spawn_blocking` doing a raw, un-cancellable OS-level
1424 // `reader.read()`) are separate tasks, unlike persistent/stdout_reader.rs's
1425 // single combined reader task. Aborting only the flusher would
1426 // stop it draining `pty_tx` — but the read loop, still blocked
1427 // in real OS I/O, cannot be interrupted by tokio's cooperative
1428 // cancellation and keeps running regardless, so the leaked
1429 // thread is NOT reclaimed either way. Aborting would only add a
1430 // regression on top: every later `blocking_send` finds the
1431 // receiver gone and is silently discarded, permanently losing
1432 // any further output from a still-live descendant with no log
1433 // at the point of loss. Simply letting `flusher_handle` drop
1434 // here (no `abort()`) detaches it — the flusher and its read
1435 // loop keep running independently in the background, still
1436 // persisting and broadcasting whatever the descendant produces
1437 // next, and exit naturally once the PTY genuinely reaches EOF.
1438 //
1439 // Two signals, strongest first (ReAgent P1 on PR #3261).
1440 //
1441 // The flusher task ENDING is the strong one: it only ends when the
1442 // channel closes, which only happens once the read loop observed
1443 // true EOF and dropped its sender — so every byte the child ever
1444 // produced is both enqueued and committed. Wherever EOF actually
1445 // arrives (Linux, macOS) this resolves in milliseconds and the
1446 // guarantee is exactly what it was before the barrier existed.
1447 //
1448 // The barrier is the fallback, for where EOF never comes at all:
1449 // on Windows ConPTY's console host outlives the direct child, so
1450 // the read loop stays blocked in a read that never returns and the
1451 // task can never end. Waiting on it there always burned the full
1452 // ceiling (§12) — the second a human sees after typing `exit`.
1453 //
1454 // Residual, stated plainly: the barrier cannot order against a
1455 // chunk the read loop has read but not yet enqueued, so on the
1456 // fallback path a final chunk racing it can land after
1457 // STATUS_DONE. Pre-PR behaviour on that same path was also
1458 // best-effort (it timed out and detached), so this trades grace
1459 // for responsiveness rather than trading away a guarantee that was
1460 // ever actually held there. Where the guarantee WAS held — EOF
1461 // platforms — it still is.
1462 //
1463 // Total worst case stays FLUSHER_DRAIN_TIMEOUT: the two waits
1464 // split that budget rather than each taking it (ReAgent P2).
1465 if close_on_exit {
1466 drop(flusher_handle);
1467 } else {
1468 match tokio::time::timeout(FLUSHER_EOF_GRACE, flusher_handle).await {
1469 Ok(Ok(())) => {}
1470 Ok(Err(e)) => {
1471 tracing::warn!(
1472 block_id = %block_id_wait,
1473 error = %e,
1474 "PTY output flusher task panicked or was cancelled before completing; \
1475 proceeding with pane teardown anyway — some trailing output may be missing"
1476 );
1477 }
1478 Err(_) => {
1479 // No EOF within the grace — assume it is never coming
1480 // (ConPTY) and settle for the bounded barrier.
1481 // `None` means every sender is gone — the channel
1482 // closed and the flusher drained it, which is the
1483 // guarantee the barrier exists to obtain.
1484 let barrier = match barrier_tx.upgrade() {
1485 Some(tx) => flush_barrier(&tx, FLUSHER_BARRIER_TIMEOUT).await,
1486 None => Ok(()),
1487 };
1488 if barrier.is_err() {
1489 tracing::warn!(
1490 block_id = %block_id_wait,
1491 timeout = ?FLUSHER_BARRIER_TIMEOUT,
1492 "PTY output flush barrier went unanswered after process exit (a wedged flusher, or a background descendant still holding the PTY open?) — proceeding with pane teardown; the flusher and its read loop stay detached and running, so later output is still not lost, but some trailing output may land after STATUS_DONE"
1493 );
1494 }
1495 }
1496 }
1497 }
1498
1499 // After the drain above, so it comes after ssh's own last words.
1500 if let (Some(text), Some(broker)) = (lost_note, broker_wait.clone()) {
1501 super::ssh_pane::write_note(broker, filestore_note, block_id_wait.clone(), text).await;
1502 }
1503
1504 // Cloned before the inner `spawn_blocking` below moves
1505 // `block_id_wait` — still needed after it completes, for the
1506 // close-on-exit trigger.
1507 let block_id_for_close = block_id_wait.clone();
1508 let tab_id_for_close = tab_id_wait.clone();
1509 // Identity of THIS controller instance, for the
1510 // still-the-current-controller check at close time — see the
1511 // trigger below.
1512 let inner_for_close = Arc::clone(&inner_wait);
1513
1514 // Entire body below is UNCHANGED from before this fix, still
1515 // inside its own spawn_blocking — registry::remove (among
1516 // others here) does its own synchronous file I/O, so it
1517 // doesn't belong on an async worker either.
1518 tokio::task::spawn_blocking(move || {
1519 // Unregister PID from per-pane metrics
1520 super::super::pidregistry::unregister(&block_id_wait);
1521
1522 // Deregister from jekt — removes the agent_id → block_id mapping so
1523 // subsequent jekt attempts fall back to MessageBus rather than a dead PTY.
1524 crate::backend::reactive::get_global_handler().unregister_block(&block_id_wait);
1525
1526 // Also remove from cross-instance file registry and cloud subscriber.
1527 if let Some(ref agent_id) = agent_id_wait {
1528 let data_dir = crate::backend::base::get_mux_data_dir();
1529 crate::backend::reactive::registry::remove(&data_dir, agent_id);
1530 crate::backend::reactive::registry::remove_shared_from_env(agent_id);
1531 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1532 sub.remove_agent(agent_id);
1533 }
1534 }
1535
1536 // Release any PtyShell agent lease this block still holds.
1537 //
1538 // The lease (`blockcontroller::agent_lock`) locks a human out
1539 // of their own keyboard while an agent drives the pane's
1540 // shell, and it is deliberately time-bounded so it always
1541 // lapses on its own. But "the shell this lease belongs to has
1542 // exited" is a stronger signal than the clock: there is
1543 // nothing left to collide with, and holding the lock for the
1544 // remainder of its window would drop keystrokes aimed at
1545 // whatever comes next in this pane. Matters most for the
1546 // exact case that motivated close-on-exit
1547 // (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10): a human
1548 // typing `exit` in a shell an agent was using must not have
1549 // their following keystrokes swallowed by a lease for a PTY
1550 // that is already gone.
1551 //
1552 // Guarded on this task still belonging to the REGISTERED
1553 // controller, the same `Arc::ptr_eq` identity check the
1554 // close-on-exit trigger below already uses, and for the same
1555 // reason (Codex P1 / ReAgent P1 on PR #3249): a force-restart
1556 // (`resync_controller` → `stop_for_replace`) kills this child
1557 // and registers a REPLACEMENT controller for the same
1558 // block_id. This wait task belongs to the OLD one. Without
1559 // the guard it would fire afterwards and clear a lease the
1560 // agent had legitimately taken on the NEW shell, letting
1561 // human input race the agent's writes on a pane that never
1562 // stopped being driven. Leases are keyed by block_id, so a
1563 // stale task cannot tell its own lease from its successor's —
1564 // controller identity is what distinguishes them.
1565 //
1566 // Check-then-act, like its precedent: a replacement
1567 // registered in the window between this check and the remove
1568 // would still lose its lease. Same residual race the
1569 // close-on-exit guard accepts, and self-correcting here — the
1570 // agent's next write re-takes the lease, since
1571 // `lock_shell_for_agent` runs before every one.
1572 release_lease_if_current(
1573 &block_id_wait,
1574 &inner_wait,
1575 &mstore_wait,
1576 &event_bus_wait,
1577 );
1578
1579 // Update inner state
1580 {
1581 let mut inner = inner_wait.lock().unwrap();
1582 inner.proc_exit_code = exit_code;
1583 ShellController::set_status(&mut inner, STATUS_DONE);
1584 inner.input_tx = None;
1585 }
1586
1587 // Publish done status
1588 if let Some(ref broker) = broker_wait {
1589 let status = {
1590 let inner = inner_wait.lock().unwrap();
1591 BlockControllerRuntimeStatus {
1592 durable: false,
1593 blockid: block_id_wait.clone(),
1594 version: inner.status_version,
1595 shellprocstatus: inner.proc_status.clone(),
1596 shellprocconnname: inner.conn_name.clone(),
1597 shellprocexitcode: inner.proc_exit_code,
1598 // Kept on the exit status too, so the drawer's
1599 // info line can still say which PID exited.
1600 shellprocpid: inner.child_pid,
1601 shellprocname: inner.shell_name.clone(),
1602 spawn_ts_ms: inner.spawn_ts_ms,
1603 is_agent_pane: inner.is_agent_pane,
1604 turn_active: false,
1605 }
1606 };
1607 super::super::publish_controller_status(broker, &status);
1608 }
1609
1610 // Release run lock
1611 run_lock.store(false, Ordering::SeqCst);
1612 })
1613 .await
1614 .expect("PTY wait/cleanup task panicked");
1615
1616 // Close-on-exit (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md
1617 // §10): fire-and-forget, deliberately AFTER STATUS_DONE is
1618 // published and `run_lock` is released above — closing the
1619 // pane doesn't need to block or gate either of those.
1620 //
1621 // Reached promptly after the child is reaped: for a CLOSING
1622 // pane the flusher-drain wait above is skipped outright (§13),
1623 // so none of `FLUSHER_DRAIN_TIMEOUT` is paid here, and
1624 // `close_on_exit_delay_ms` defaults to 0. A pane that is NOT
1625 // closing still takes the full ordered drain wait — now
1626 // bounded at 1s rather than the original 10s (§12), because
1627 // that ceiling turned out to be reached on essentially every
1628 // ordinary exit on Windows (ConPTY's console host outlives
1629 // the direct child, so the read loop never sees EOF) and it
1630 // gates `STATUS_DONE` publication, i.e. every
1631 // STATUS_DONE-driven affordance — a pane's "done" icon,
1632 // `PtyShellStatus`'s `running: false` — not just this one.
1633 //
1634 // NOT gated on the flusher-drain outcome just logged above.
1635 // An earlier version of this code skipped closing whenever the
1636 // flusher timed out, reasoning that a still-live background
1637 // descendant might have more output coming. Live-tested on
1638 // Windows (this PR) and found to backfire completely: the
1639 // 10s `FLUSHER_DRAIN_TIMEOUT` was hit on essentially EVERY
1640 // plain `cmd.exe` exit, not as a rare edge case — ConPTY's own
1641 // console-host process routinely outlives the direct child by
1642 // design, so gating on this made close-on-exit silently never
1643 // fire on the one platform this was tested on. Simply closing
1644 // regardless matches what actually happens next either way:
1645 // the flusher and its read loop are ALREADY left running
1646 // detached in the background (see the comment above), so any
1647 // genuinely-still-arriving trailing output keeps being
1648 // persisted to the block's file exactly as it would if the
1649 // pane stayed open — closing the pane just stops anyone from
1650 // looking at it, the same as closing any other terminal app's
1651 // window while a background job still has an open handle.
1652 if close_on_exit {
1653 tokio::spawn(async move {
1654 tokio::time::sleep(std::time::Duration::from_millis(close_on_exit_delay_ms)).await;
1655 // Only close if THIS controller is still the one
1656 // registered for the block (Codex P1 on PR #3230).
1657 //
1658 // A process exit is not by itself evidence that the
1659 // pane should close — the child also dies when the
1660 // controller is deliberately replaced. `resync_controller`'s
1661 // force-restart path (the terminal's own refresh
1662 // action, `forcerestart: true`) calls
1663 // `stop_for_replace`, which kills this child, then
1664 // registers a REPLACEMENT controller for the same
1665 // block. This wait task belongs to the OLD controller
1666 // and still carries `close_on_exit == true`, so
1667 // without this check reaping that killed child would
1668 // delete the pane — and with it the freshly
1669 // registered replacement — instead of completing the
1670 // restart. The delay above widens the same window for
1671 // an explicitly-configured `cmd:closeonexitdelay`:
1672 // restarting an exited shell during it must cancel the
1673 // close, which re-checking AFTER the sleep is what
1674 // makes true.
1675 //
1676 // Registry identity is the discriminator, and it
1677 // covers the whole replace sequence: during it the old
1678 // entry is removed (`remove_controller_entry_only`)
1679 // and a new one registered, so this lookup finds
1680 // either nothing or a DIFFERENT controller — both
1681 // correctly skip. A natural exit leaves this
1682 // controller registered and untouched, so it matches
1683 // and the close proceeds.
1684 let still_current = super::super::get_controller(&block_id_for_close)
1685 .and_then(|ctrl| {
1686 ctrl.as_any()
1687 .downcast_ref::<ShellController>()
1688 .map(|sc| Arc::ptr_eq(&sc.inner, &inner_for_close))
1689 })
1690 .unwrap_or(false);
1691 if !still_current {
1692 tracing::info!(
1693 block_id = %block_id_for_close,
1694 "close-on-exit: controller was replaced or removed since this \
1695 process exited (restart in flight?) — skipping pane close"
1696 );
1697 return;
1698 }
1699 super::super::close_on_exit(tab_id_for_close, block_id_for_close);
1700 });
1701 }
1702 });
1703
1704 // Return immediately — PTY tasks run in background
1705 Ok(())
1706 }
1707
1708 fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
1709 // Extract what we need from the lock, release it before any async work.
1710 #[allow(unused_variables)] // used under #[cfg(unix)] only
1711 let pid_to_kill = {
1712 let mut inner = self.inner.lock().unwrap();
1713 if inner.proc_status == new_status {
1714 return Ok(());
1715 }
1716 let pid = inner.child_pid;
1717 // Drop the input channel — closes PTY writer → delivers EOF/SIGHUP as
1718 // belt-and-suspenders in case signal delivery fails on the platform.
1719 inner.input_tx = None;
1720 Self::set_status(&mut inner, new_status);
1721 pid
1722 };
1723
1724 // Send SIGTERM to the process group so that child processes spawned by
1725 // the shell (e.g. `claude --dangerously-skip-permissions` and its subtree)
1726 // are also signalled. Negative pid targets the whole process group.
1727 // Schedule SIGKILL after KILL_GRACE_SECS as a backstop for processes
1728 // that ignore or delay on SIGTERM.
1729 #[cfg(unix)]
1730 if let Some(pid) = pid_to_kill {
1731 // SAFETY: kill() is a well-defined POSIX syscall.
1732 unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
1733 tokio::spawn(async move {
1734 tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1735 unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
1736 });
1737 }
1738
1739 // A declared-background descendant (bashwrap, and transitively
1740 // whatever it PTY-spawned, e.g. `task dev`) deliberately detaches
1741 // into its OWN session at startup (`setsid()` in bash_wrap.rs's
1742 // `detach_declared_background_session`), specifically so a
1743 // session-RESTART's narrower kill (`stop_for_replace`, above)
1744 // can't reach it via PTY-hangup/session-leader-death SIGHUP. But
1745 // that same detachment also removes it from THIS process's group
1746 // — so the group-wide kill just above (intended to reach it on a
1747 // genuine STOP, e.g. pane close) no longer can either. The agent's
1748 // tracker reaches it (a cgroup on Linux, the best-effort
1749 // `ScanTracker` on macOS and Linux without delegation, by its env
1750 // tag), but only when the block's tracker is released; a terminal
1751 // block has the no-op stub. This step doesn't wait for either.
1752 // Without it, `stop()` (the real,
1753 // unmodified deletion path — used by `delete_tab`/`delete_block`/
1754 // `wcore::tab`) would leave it running past the stop on Unix (reagentx
1755 // finding, PR #2683). Kill each `Running`, known-pid declared-
1756 // background task's own (detached) process group explicitly, by
1757 // pid from the durable registry — the one piece of state that
1758 // still knows about it once it's escaped this controller's own
1759 // process tree. Windows is unaffected: Job Object membership
1760 // isn't process-group-based, so `delete_controller`'s existing
1761 // whole-job close already reaches it there, unchanged.
1762 #[cfg(unix)]
1763 if let Some(store) = &self.mstore {
1764 match store.background_task_list_for_block(&self.block_id) {
1765 Ok(tasks) => {
1766 for task in tasks {
1767 if task.status != crate::backend::storage::background_tasks::BackgroundTaskStatus::Running {
1768 continue;
1769 }
1770 let Some(pid) = task.pid else { continue };
1771 let pid = pid as libc::pid_t;
1772 // SAFETY: kill() is a well-defined POSIX syscall.
1773 unsafe { libc::kill(-pid, libc::SIGTERM) };
1774 tokio::spawn(async move {
1775 tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1776 unsafe { libc::kill(-pid, libc::SIGKILL) };
1777 });
1778 }
1779 }
1780 Err(e) => {
1781 tracing::warn!(
1782 block_id = %self.block_id,
1783 error = %e,
1784 "stop(): failed to list declared-background tasks for cleanup",
1785 );
1786 }
1787 }
1788 }
1789
1790 Ok(())
1791 }
1792
1793 fn stop_for_replace(&self, new_status: &str) -> Result<(), String> {
1794 // Same extraction as stop() — but the kill below targets ONLY this
1795 // one pid, never the process group/job, so a declared-background
1796 // descendant (e.g. `task dev`) survives. See
1797 // docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md.
1798 #[allow(unused_variables)] // used under #[cfg(unix)] only
1799 let pid_to_kill = {
1800 let mut inner = self.inner.lock().unwrap();
1801 if inner.proc_status == new_status {
1802 return Ok(());
1803 }
1804 let pid = inner.child_pid;
1805 inner.input_tx = None;
1806 Self::set_status(&mut inner, new_status);
1807 pid
1808 };
1809
1810 let Some(pid) = pid_to_kill else { return Ok(()) };
1811
1812 // Prefer the tracked kill (Windows: OpenProcess+TerminateProcess,
1813 // with a membership check against the block's job first — see
1814 // `process_tracker::registry::kill_pid`). This is expected to
1815 // succeed in every real production case: `track_spawned` already
1816 // ran for this exact pid right after this controller's own spawn.
1817 if let Some(registry) = crate::backend::process_tracker::registry::global() {
1818 if registry.kill_pid(&self.block_id, pid) {
1819 return Ok(());
1820 }
1821 }
1822
1823 // Fallback — reached when the registry global isn't set (tests;
1824 // same "silently skip tracker registration" convention
1825 // `track_spawned`'s own doc comment describes), OR `kill_pid`
1826 // itself returned `false` because this pid isn't (yet, or ever)
1827 // a recognized member of the block's tracker — e.g.
1828 // `track_spawned`'s own `assign_process` call failed at spawn
1829 // time (a real, already-logged non-fatal warning path in
1830 // `registry::track_spawned`). Unlike `stop()`, where a failed
1831 // direct kill is backstopped by `delete_controller`'s whole-job
1832 // teardown, `stop_for_replace` deliberately never touches the
1833 // job — so without a fallback here, that failure mode would leak
1834 // the old CLI process forever once resync_controller replaces it
1835 // (reagentx P1, PR #2683). A direct, single-PID (not group,
1836 // not job) kill on both platforms, so this can't reach a
1837 // declared-background descendant the way stop()'s `-(pid)` /
1838 // a whole-job close would.
1839 //
1840 // The primary path for a terminal block on Unix, whose tracker is the
1841 // no-op stub (`kill_pid` returns `false`). An agent block's tracker
1842 // (cgroup, or `ScanTracker` on macOS and Linux without delegation)
1843 // SIGKILLs the pid itself and returns `true`.
1844 #[cfg(unix)]
1845 {
1846 // SAFETY: kill() is a well-defined POSIX syscall.
1847 unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
1848 tokio::spawn(async move {
1849 tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1850 unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
1851 });
1852 }
1853 #[cfg(windows)]
1854 {
1855 // Mirrors process_tracker::windows::JobObjectTracker::kill_pid's
1856 // own kill step exactly, minus its job-membership pre-check
1857 // (which is precisely what already failed to get us here) —
1858 // a plain OpenProcess(PROCESS_TERMINATE) + TerminateProcess on
1859 // the raw pid, best-effort (the process may have already
1860 // exited on its own).
1861 use windows_sys::Win32::Foundation::CloseHandle;
1862 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
1863 unsafe {
1864 let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
1865 if !h.is_null() {
1866 let ok = TerminateProcess(h, 1);
1867 CloseHandle(h);
1868 if ok == 0 {
1869 tracing::warn!(
1870 block_id = %self.block_id,
1871 pid,
1872 error = %std::io::Error::last_os_error(),
1873 "stop_for_replace: fallback TerminateProcess failed"
1874 );
1875 }
1876 } else {
1877 tracing::warn!(
1878 block_id = %self.block_id,
1879 pid,
1880 error = %std::io::Error::last_os_error(),
1881 "stop_for_replace: fallback OpenProcess failed — old CLI process may be leaked"
1882 );
1883 }
1884 }
1885 }
1886
1887 Ok(())
1888 }
1889
1890 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
1891 self.get_status_snapshot()
1892 }
1893
1894 fn send_input(&self, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String> {
1895 let mut inner = self.inner.lock().unwrap();
1896 let tx = match &inner.input_tx {
1897 Some(tx) => tx.clone(),
1898 None => return Err("controller is not running".to_string()),
1899 };
1900 match seq {
1901 None => tx.send(input).map_err(|e| format!("send_input: {e}")),
1902 Some(s) => {
1903 // Detect session reset: seq==0 means the TermViewModel
1904 // restarted and its per-block counter is back at zero.
1905 // (A gap-threshold heuristic was tried and removed — a
1906 // stale/duplicate packet far behind could falsely trigger
1907 // a reset and replay old input.)
1908 if s == 0 && inner.input_seq_next > 0 {
1909 tracing::info!(
1910 block_id = %self.block_id,
1911 prev_next = inner.input_seq_next,
1912 new_seq = s,
1913 "input seq reset (session reset detected)"
1914 );
1915 inner.input_seq_next = s;
1916 inner.input_seq_buf.clear();
1917 }
1918
1919 let next = inner.input_seq_next;
1920 if s == next {
1921 // Advance before sending — a send failure must not leave the
1922 // backend stuck waiting for a seq the frontend will never resend.
1923 inner.input_seq_next += 1;
1924 if let Err(e) = tx.send(input) {
1925 // Unbounded send only fails if the receiver is gone
1926 // (controller stopped) — nothing to do but drop quietly.
1927 tracing::warn!(
1928 block_id = %self.block_id,
1929 seq = s,
1930 "send_input: input channel closed, discarding packet: {e}"
1931 );
1932 return Ok(());
1933 }
1934 // Drain any buffered out-of-order packets now in order.
1935 loop {
1936 let expected = inner.input_seq_next;
1937 match inner.input_seq_buf.remove(&expected) {
1938 Some(buffered) => {
1939 inner.input_seq_next += 1;
1940 if let Err(e) = tx.send(buffered) {
1941 tracing::warn!(
1942 block_id = %self.block_id,
1943 seq = expected,
1944 "send_input drain: input channel closed, discarding buffered packet: {e}"
1945 );
1946 }
1947 }
1948 None => break,
1949 }
1950 }
1951 Ok(())
1952 } else if s > next {
1953 if inner.input_seq_buf.len() < SHELL_INPUT_CH_SIZE {
1954 inner.input_seq_buf.insert(s, input);
1955 } else {
1956 tracing::warn!(block_id = %self.block_id, seq = s, "input reorder buffer full, dropping");
1957 }
1958 Ok(())
1959 } else {
1960 tracing::warn!(block_id = %self.block_id, seq = s, next, "duplicate input seq, discarding");
1961 Ok(())
1962 }
1963 }
1964 }
1965 }
1966
1967 fn controller_type(&self) -> &str {
1968 &self.controller_type
1969 }
1970
1971 fn block_id(&self) -> &str {
1972 &self.block_id
1973 }
1974
1975 fn agent_id(&self) -> Option<String> {
1976 self.inner.lock().unwrap().agent_id.clone()
1977 }
1978
1979 fn set_agent_id(&self, id: Option<String>) {
1980 self.inner.lock().unwrap().agent_id = id;
1981 }
1982
1983 fn as_any(&self) -> &dyn std::any::Any {
1984 self
1985 }
1986}
1987
1988#[cfg(test)]
1989mod agent_id_for_jekt_tests {
1990 use super::{is_global_cmd_env_injectable, resolve_agent_id_for_jekt};
1991 use crate::backend::blockcontroller::META_KEY_CMD_ENV;
1992 use crate::backend::obj::MetaMapType;
1993
1994 #[test]
1995 fn identity_keys_are_never_globally_injectable() {
1996 // reagentx P1, round 2 on #2694: these must never come from global
1997 // settings.cmd_env — only from a block's own explicit override.
1998 assert!(!is_global_cmd_env_injectable("AGENTMUX_AGENT_ID"));
1999 assert!(!is_global_cmd_env_injectable("WAVEMUX_AGENT_ID"));
2000 }
2001
2002 #[test]
2003 fn other_global_cmd_env_keys_remain_injectable() {
2004 // Unrelated global defaults (arbitrary user cmd_env entries) are
2005 // unaffected — only the two identity keys are excluded. Was
2006 // AGENTMUX_AGENT_COLOR before that var's decommission (2026-09-20,
2007 // SPEC_AGENT_HEADER_COLOR_UNIFICATION_2026_09_20.md) — swapped to a
2008 // generic name so this test doesn't reference a var with no live
2009 // meaning left in the codebase.
2010 assert!(is_global_cmd_env_injectable("SOME_COSMETIC_VAR"));
2011 assert!(is_global_cmd_env_injectable("SOME_OTHER_VAR"));
2012 }
2013
2014 fn meta_with_env(pairs: &[(&str, &str)]) -> MetaMapType {
2015 let mut obj = serde_json::Map::new();
2016 for (k, v) in pairs {
2017 obj.insert(k.to_string(), serde_json::json!(v));
2018 }
2019 let mut meta = MetaMapType::new();
2020 meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::Value::Object(obj));
2021 meta
2022 }
2023
2024 #[test]
2025 fn no_block_scoped_identity_resolves_to_none() {
2026 // This is the collision-fix regression case: a pane with neither
2027 // AGENTMUX_AGENT_ID nor WAVEMUX_AGENT_ID set in its OWN cmd:env is
2028 // simply not jekt-registered — no fallback to global settings, and
2029 // (reagentx P1, round 2) no fallback to this srv PROCESS's own
2030 // std::env::var either, since that's process-global, not per-pane.
2031 let empty_meta = MetaMapType::new();
2032 assert_eq!(resolve_agent_id_for_jekt(&empty_meta), None);
2033 }
2034
2035 #[test]
2036 fn resolves_block_scoped_agentmux_agent_id() {
2037 let meta = meta_with_env(&[("AGENTMUX_AGENT_ID", "agentx")]);
2038 assert_eq!(resolve_agent_id_for_jekt(&meta), Some("agentx".to_string()));
2039 }
2040
2041 #[test]
2042 fn resolves_block_scoped_legacy_wavemux_agent_id() {
2043 // The legacy alias is read from the SAME block-scoped cmd:env map,
2044 // not process env (reagentx P1, round 2) — genuinely per-pane, so
2045 // it can't reintroduce the same-host collision.
2046 let meta = meta_with_env(&[("WAVEMUX_AGENT_ID", "legacy-agent")]);
2047 assert_eq!(
2048 resolve_agent_id_for_jekt(&meta),
2049 Some("legacy-agent".to_string())
2050 );
2051 }
2052
2053 #[test]
2054 fn agentmux_agent_id_wins_over_legacy_wavemux_when_both_set_on_the_same_block() {
2055 let meta = meta_with_env(&[
2056 ("AGENTMUX_AGENT_ID", "agentx"),
2057 ("WAVEMUX_AGENT_ID", "legacy-agent"),
2058 ]);
2059 assert_eq!(resolve_agent_id_for_jekt(&meta), Some("agentx".to_string()));
2060 }
2061
2062 #[test]
2063 fn blank_value_is_treated_as_absent() {
2064 // Matches persistent/mod.rs's muxbus_agent_id_from_env: a present-but-
2065 // whitespace-only value doesn't count as a real identity.
2066 let meta = meta_with_env(&[("AGENTMUX_AGENT_ID", " ")]);
2067 assert_eq!(resolve_agent_id_for_jekt(&meta), None);
2068 }
2069
2070 #[test]
2071 fn a_different_blocks_env_never_leaks_in() {
2072 // resolve_agent_id_for_jekt takes ONE block's own metadata — there
2073 // is no code path here that could read another block's or the
2074 // process's own env, unlike the bug this fn was rewritten to fix.
2075 // This test exists to make that structurally obvious: the fn's
2076 // only input is the map passed in.
2077 let other_blocks_meta = meta_with_env(&[("AGENTMUX_AGENT_ID", "agenty")]);
2078 let this_blocks_meta = MetaMapType::new();
2079 assert_eq!(resolve_agent_id_for_jekt(&other_blocks_meta), Some("agenty".to_string()));
2080 assert_eq!(resolve_agent_id_for_jekt(&this_blocks_meta), None);
2081 }
2082}
2083
2084/// Global `cmd:env` (settings.json) reaching an interactive shell's env.
2085///
2086/// Settings enter a `ConfigState` the way they do in production: a
2087/// settings.json parsed by `read_config_file`, then `update_settings`, which
2088/// is what `config_watcher_fs` does at startup and on every save. The env is
2089/// read off a real `CommandBuilder` after `apply_cmd_env`, the call `start()`
2090/// makes.
2091#[cfg(test)]
2092mod global_cmd_env_tests {
2093 use std::sync::Arc;
2094
2095 use portable_pty::CommandBuilder;
2096
2097 use super::super::controller::ShellController;
2098 use super::cmd_env_overrides;
2099 use crate::backend::blockcontroller::META_KEY_CMD_ENV;
2100 use crate::backend::obj::MetaMapType;
2101 use crate::backend::wconfig::{self, ConfigState};
2102
2103 /// Load `settings_json` into `config` as the settings watcher does.
2104 fn load_settings(config: &ConfigState, settings_json: &str) {
2105 let dir = tempfile::tempdir().unwrap();
2106 let path = dir.path().join(wconfig::SETTINGS_FILE);
2107 std::fs::write(&path, settings_json).unwrap();
2108 let (settings, errors): (wconfig::SettingsType, _) = wconfig::read_config_file(&path);
2109 assert!(errors.is_empty(), "test settings.json must parse: {errors:?}");
2110 config.update_settings(settings);
2111 }
2112
2113 /// A config built like `bootstrap/background.rs`'s, with `settings_json` loaded.
2114 fn live_config(settings_json: &str) -> Arc<ConfigState> {
2115 let config = Arc::new(ConfigState::with_config(wconfig::build_default_config()));
2116 load_settings(&config, settings_json);
2117 config
2118 }
2119
2120 fn controller(config: Option<Arc<ConfigState>>) -> ShellController {
2121 ShellController::new(
2122 "shell".to_string(),
2123 "tab-cmd-env".to_string(),
2124 "block-cmd-env".to_string(),
2125 None,
2126 None,
2127 None,
2128 None,
2129 String::new(),
2130 )
2131 .with_config(config)
2132 }
2133
2134 fn block_env(pairs: &[(&str, &str)]) -> MetaMapType {
2135 let mut obj = serde_json::Map::new();
2136 for (k, v) in pairs {
2137 obj.insert(k.to_string(), serde_json::json!(v));
2138 }
2139 let mut meta = MetaMapType::new();
2140 meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::Value::Object(obj));
2141 meta
2142 }
2143
2144 /// The env a spawn from `ctrl` gets for `block_meta`. Starts from an empty
2145 /// env so the test process's own variables can't leak into assertions.
2146 fn spawn_env(ctrl: &ShellController, block_meta: &MetaMapType) -> CommandBuilder {
2147 let mut c = CommandBuilder::new("shell-under-test");
2148 c.env_clear();
2149 ctrl.apply_cmd_env(&mut c, block_meta);
2150 c
2151 }
2152
2153 fn var(c: &CommandBuilder, key: &str) -> Option<String> {
2154 c.get_env(key).map(|v| v.to_string_lossy().into_owned())
2155 }
2156
2157 #[test]
2158 fn a_command_pane_gets_its_cmd_env_direct_or_through_the_shell() {
2159 // A login pane: the CLI run directly, with the account's config dir.
2160 let ctrl = controller(None);
2161 let meta = block_env(&[("CLAUDE_CONFIG_DIR", "/accounts/a")]);
2162 let args = vec!["auth".to_string(), "login".to_string()];
2163 let direct = ctrl.command_process("claude", &args, true, &meta).unwrap();
2164 assert_eq!(var(&direct, "CLAUDE_CONFIG_DIR").as_deref(), Some("/accounts/a"));
2165 let wrapped = ctrl.command_process("claude auth login", &[], false, &meta).unwrap();
2166 assert_eq!(var(&wrapped, "CLAUDE_CONFIG_DIR").as_deref(), Some("/accounts/a"));
2167 }
2168
2169 /// On Windows a direct-spawn pane runs what an npm `.cmd` shim points
2170 /// at, not the shim (which hangs under ConPTY), with the pane's args after.
2171 #[cfg(windows)]
2172 #[test]
2173 fn a_command_pane_runs_what_an_npm_shim_points_at() {
2174 let dir = tempfile::tempdir().unwrap();
2175 let bin = dir.path().join("node_modules").join(".bin");
2176 std::fs::create_dir_all(&bin).unwrap();
2177 let shim = bin.join("openclaw.cmd");
2178 std::fs::write(
2179 &shim,
2180 concat!(
2181 "@ECHO off
2182GOTO start
2183:find_dp0
2184SET dp0=%~dp0
2185EXIT /b
2186:start
2187SETLOCAL
2188",
2189 r#""%_prog%" "%dp0%\..\openclaw\cli.js" %*"#,
2190 "
2191"
2192 ),
2193 )
2194 .unwrap();
2195 let args = vec!["login".to_string()];
2196 let c = controller(None).command_process(&shim.to_string_lossy(), &args, true, &MetaMapType::new()).unwrap();
2197 let argv: Vec<String> = c.get_argv().iter().map(|a| a.to_string_lossy().into_owned()).collect();
2198 assert_eq!(argv[0], "node");
2199 assert!(argv[1].ends_with("cli.js"), "the shim's script, got {argv:?}");
2200 assert_eq!(argv[2], "login");
2201 }
2202
2203 /// A wrapper that can't be read is refused rather than started into a
2204 /// pane that would hang.
2205 #[cfg(windows)]
2206 #[test]
2207 fn a_command_pane_refuses_a_wrapper_it_cannot_read() {
2208 let dir = tempfile::tempdir().unwrap();
2209 let shim = dir.path().join("odd.cmd");
2210 std::fs::write(&shim, "@echo off\r\nsomething-else %*\r\n").unwrap();
2211 let err = controller(None)
2212 .command_process(&shim.to_string_lossy(), &[], true, &MetaMapType::new())
2213 .err()
2214 .expect("an unreadable wrapper is refused");
2215 assert!(err.contains("can't run"), "{err}");
2216 }
2217
2218 #[test]
2219 fn defaults_carry_no_cmd_env() {
2220 // Precondition for every test below: a value they observe can only
2221 // have come from the loaded settings, never from the defaults.
2222 assert!(wconfig::build_default_config().settings.cmd_env.is_empty());
2223 }
2224
2225 #[test]
2226 fn global_cmd_env_from_the_live_config_is_applied() {
2227 let ctrl = controller(Some(live_config(
2228 r#"{ "cmd:env": { "AMUX_TEST_GLOBAL": "from-settings" } }"#,
2229 )));
2230 let env = spawn_env(&ctrl, &MetaMapType::new());
2231 assert_eq!(var(&env, "AMUX_TEST_GLOBAL").as_deref(), Some("from-settings"));
2232 }
2233
2234 #[test]
2235 fn block_cmd_env_overrides_the_global_value() {
2236 let ctrl = controller(Some(live_config(
2237 r#"{ "cmd:env": { "AMUX_TEST_SHARED": "global", "AMUX_TEST_GLOBAL_ONLY": "g" } }"#,
2238 )));
2239 let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_SHARED", "block")]));
2240 assert_eq!(var(&env, "AMUX_TEST_SHARED").as_deref(), Some("block"));
2241 assert_eq!(
2242 var(&env, "AMUX_TEST_GLOBAL_ONLY").as_deref(),
2243 Some("g"),
2244 "a global key the block doesn't set still applies"
2245 );
2246 }
2247
2248 /// Windows env names are case-insensitive, and `CommandBuilder` matches
2249 /// them that way there. The block must still win when its key differs
2250 /// from the global one only in case.
2251 #[cfg(windows)]
2252 #[test]
2253 fn block_cmd_env_overrides_a_global_key_differing_only_in_case() {
2254 let ctrl = controller(Some(live_config(
2255 r#"{ "cmd:env": { "Amux_Test_Case": "global" } }"#,
2256 )));
2257 let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_CASE", "block")]));
2258 assert_eq!(var(&env, "AMUX_TEST_CASE").as_deref(), Some("block"));
2259 }
2260
2261 #[test]
2262 fn global_agent_identity_keys_are_still_not_injected() {
2263 let ctrl = controller(Some(live_config(
2264 r#"{ "cmd:env": {
2265 "AGENTMUX_AGENT_ID": "shared-id",
2266 "WAVEMUX_AGENT_ID": "shared-legacy-id",
2267 "AMUX_TEST_NEIGHBOUR": "applied"
2268 } }"#,
2269 )));
2270
2271 // Excluded at injection (#2694), not merely removed by the strip.
2272 let overrides = cmd_env_overrides(&ctrl.live_settings(), &MetaMapType::new());
2273 let keys: Vec<&str> = overrides.vars.iter().map(|(k, _)| k.as_str()).collect();
2274 assert_eq!(keys, vec!["AMUX_TEST_NEIGHBOUR"]);
2275
2276 let plain = spawn_env(&ctrl, &MetaMapType::new());
2277 assert_eq!(var(&plain, "AMUX_TEST_NEIGHBOUR").as_deref(), Some("applied"));
2278 assert_eq!(var(&plain, "AGENTMUX_AGENT_ID"), None);
2279 assert_eq!(var(&plain, "WAVEMUX_AGENT_ID"), None);
2280
2281 // A block with its own identity skips the strip, so only the global
2282 // exclusion keeps the shared legacy id out here.
2283 let agent = spawn_env(&ctrl, &block_env(&[("AGENTMUX_AGENT_ID", "block-id")]));
2284 assert_eq!(var(&agent, "AGENTMUX_AGENT_ID").as_deref(), Some("block-id"));
2285 assert_eq!(var(&agent, "WAVEMUX_AGENT_ID"), None);
2286 }
2287
2288 #[test]
2289 fn a_settings_change_reaches_the_next_spawn_of_the_same_controller() {
2290 let config = live_config(r#"{ "cmd:env": { "AMUX_TEST_LIVE": "v1" } }"#);
2291 let ctrl = controller(Some(Arc::clone(&config)));
2292 assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v1"));
2293
2294 // A later settings.json save, reloaded as the watcher does.
2295 load_settings(&config, r#"{ "cmd:env": { "AMUX_TEST_LIVE": "v2" } }"#);
2296 assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v2"));
2297
2298 // A Settings-pane save: the `setconfig` handler merges into memory.
2299 let mut keys = serde_json::Map::new();
2300 keys.insert("cmd:env".to_string(), serde_json::json!({ "AMUX_TEST_LIVE": "v3" }));
2301 let merged = crate::backend::config_watcher_fs::merge_settings_into_current(&config, keys);
2302 config.update_settings(merged);
2303 assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v3"));
2304
2305 // Dropping the key from settings.json drops it from the next spawn.
2306 load_settings(&config, "{}");
2307 assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE"), None);
2308 }
2309
2310 #[test]
2311 fn no_attached_config_applies_only_the_block_env() {
2312 let ctrl = controller(None);
2313 let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_BLOCK", "b")]));
2314 assert_eq!(var(&env, "AMUX_TEST_BLOCK").as_deref(), Some("b"));
2315 }
2316
2317 /// The production entry point: `resync_controller` must hand the config
2318 /// it is given to the shell controller it creates.
2319 #[test]
2320 fn resync_controller_gives_a_new_shell_controller_the_live_config() {
2321 use crate::backend::blockcontroller as bc;
2322
2323 let block_id = "cmd-env-resync-wiring";
2324 let config = live_config(r#"{ "cmd:env": { "AMUX_TEST_WIRED": "yes" } }"#);
2325 let mut meta = MetaMapType::new();
2326 meta.insert(bc::META_KEY_CONTROLLER.to_string(), serde_json::json!(bc::BLOCK_CONTROLLER_SHELL));
2327 // Register the controller without spawning a real shell.
2328 meta.insert(bc::META_KEY_CMD_RUN_ON_START.to_string(), serde_json::json!(false));
2329 let block = crate::backend::obj::Block {
2330 oid: block_id.to_string(),
2331 meta,
2332 ..Default::default()
2333 };
2334
2335 bc::resync_controller(
2336 &block, "tab-cmd-env", None, false, true,
2337 None, None, None, None, None, None, None,
2338 Arc::from("test-boot"), "test-key",
2339 Some(Arc::clone(&config)),
2340 )
2341 .expect("resync");
2342 let registered = bc::get_controller(block_id).expect("controller registered");
2343 let shell = registered
2344 .as_any()
2345 .downcast_ref::<ShellController>()
2346 .expect("a shell controller");
2347 let seen = var(&spawn_env(shell, &MetaMapType::new()), "AMUX_TEST_WIRED");
2348 bc::delete_controller(block_id);
2349
2350 assert_eq!(seen.as_deref(), Some("yes"));
2351 }
2352}
2353
2354/// Ask the output flusher to confirm that everything enqueued so far has been
2355/// committed, bounded by `timeout`.
2356///
2357/// This is the ordering guarantee the post-exit wait actually needs: clients
2358/// must not observe `STATUS_DONE` before the output that preceded it is
2359/// readable. `PtyShellStatus` → `PtyShellRead` is the sharpest case — an agent
2360/// polls until `running: false` and then reads the file, so a `STATUS_DONE`
2361/// that outran the final flush hands it truncated command output (Codex P1 on
2362/// PR #3261).
2363///
2364/// Waiting on the flusher TASK was the previous way to get that, and it is a
2365/// much blunter instrument: the task only ends when the channel closes, which
2366/// on Windows never happens (ConPTY's console host outlives the child, so the
2367/// read loop stays blocked in a read that never returns and never drops its
2368/// sender). Every ordinary exit therefore paid the full
2369/// `FLUSHER_DRAIN_TIMEOUT` while the flusher sat idle — a visible second of
2370/// dead UI, and the reason `exit` in a shell felt stuck.
2371///
2372/// `Err(())` means the barrier could not be confirmed within `timeout` (or the
2373/// flusher is already gone). Callers fall back to the old bounded wait.
2374async fn flush_barrier(
2375 tx: &mpsc::Sender<PtyReadChunk>,
2376 timeout: std::time::Duration,
2377) -> Result<(), ()> {
2378 let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
2379 // The SEND is inside the timeout too, not just the ack wait (ReAgent P1 on
2380 // PR #3261). `tx` is a bounded channel: if it is full and the flusher is
2381 // stalled — a wedged FileStore write, a jammed broker — an unbounded
2382 // `send().await` blocks the whole wait/cleanup task forever, and with it
2383 // STATUS_DONE, the jekt lease release, and the close-on-exit trigger. The
2384 // entire point of this path is to be bounded; a bound with an unbounded
2385 // step inside it is not one.
2386 let deadline = tokio::time::Instant::now() + timeout;
2387 let send_fut = tx.send(PtyReadChunk {
2388 data: Vec::new(),
2389 osc_events: Vec::new(),
2390 ack: Some(ack_tx),
2391 });
2392 match tokio::time::timeout_at(deadline, send_fut).await {
2393 // Receiver gone — the flusher has already exited, which means the
2394 // channel closed, which means everything in it was drained.
2395 Ok(Err(_)) => return Ok(()),
2396 Ok(Ok(())) => {}
2397 Err(_) => return Err(()),
2398 }
2399 match tokio::time::timeout_at(deadline, ack_rx).await {
2400 Ok(Ok(())) => Ok(()),
2401 _ => Err(()),
2402 }
2403}
2404
2405/// Release `block_id`'s PtyShell agent lease — both copies — but ONLY if
2406/// `inner` still identifies the controller currently registered for that
2407/// block. Returns whether it released.
2408///
2409/// The guard is the same `Arc::ptr_eq` controller-identity check the
2410/// close-on-exit trigger uses, for the same reason (Codex P1 / ReAgent P1 on
2411/// PR #3249): a force-restart (`resync_controller` → `stop_for_replace`)
2412/// kills this child and registers a REPLACEMENT controller under the same
2413/// block_id. The killed child's wait/cleanup task belongs to the OLD
2414/// controller; without this check it fires afterwards and clears a lease the
2415/// agent legitimately took on the NEW shell, letting human input race the
2416/// agent's writes on a pane that never stopped being driven. Leases are keyed
2417/// by block_id alone, so a stale task cannot tell its own lease from its
2418/// successor's — controller identity is what distinguishes them.
2419///
2420/// Check-then-act, like its precedent: a replacement registered in the window
2421/// between the check and the remove would still lose its lease. Same residual
2422/// race the close-on-exit guard accepts, and self-correcting here, since
2423/// `lock_shell_for_agent` re-takes the lease before every agent write.
2424pub(super) fn release_lease_if_current(
2425 block_id: &str,
2426 inner: &Arc<std::sync::Mutex<super::controller::ShellControllerInner>>,
2427 mstore: &Option<Arc<crate::backend::storage::store::Store>>,
2428 event_bus: &Option<Arc<crate::backend::eventbus::EventBus>>,
2429) -> bool {
2430 let is_current = super::super::get_controller(block_id)
2431 .and_then(|ctrl| {
2432 ctrl.as_any()
2433 .downcast_ref::<ShellController>()
2434 .map(|sc| Arc::ptr_eq(&sc.inner, inner))
2435 })
2436 .unwrap_or(false);
2437 if !is_current {
2438 tracing::info!(
2439 block_id = %block_id,
2440 "agent lease: controller was replaced since this process exited \
2441 (restart in flight?) — leaving the successor's lease alone"
2442 );
2443 return false;
2444 }
2445 super::super::agent_lock::release_and_clear_meta(block_id, mstore, event_bus);
2446 true
2447}
2448
2449#[cfg(test)]
2450mod flush_barrier_tests {
2451 use super::{flush_barrier, run_pty_output_flusher, PtyReadChunk, PTY_CHANNEL_CAPACITY};
2452
2453 /// The guarantee Codex's P1 on PR #3261 is about: the barrier must not
2454 /// resolve until output enqueued BEFORE it has actually been flushed.
2455 /// `PtyShellStatus` -> `PtyShellRead` depends on this — an agent polls for
2456 /// `running: false` and then reads the file, so a `STATUS_DONE` that
2457 /// outran the final flush hands it truncated output.
2458 ///
2459 /// Uses the real flusher with no broker, which makes it return
2460 /// immediately and close the channel — so this asserts the OTHER half:
2461 /// that a gone flusher resolves rather than hanging (see below). The
2462 /// ordering itself is asserted by
2463 /// `barrier_does_not_resolve_until_prior_output_is_committed`.
2464 #[tokio::test]
2465 async fn a_gone_flusher_resolves_the_barrier_instead_of_hanging() {
2466 let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2467 // No broker → the flusher returns at once, dropping the receiver.
2468 let flusher = tokio::spawn(run_pty_output_flusher(rx, None, "blk".into(), None, false));
2469 flusher.await.expect("flusher task");
2470
2471 let res = flush_barrier(&tx, std::time::Duration::from_secs(5)).await;
2472 assert_eq!(res, Ok(()), "a closed channel means nothing is left unflushed");
2473 }
2474
2475 /// THE load-bearing property: the ack must not fire until the output
2476 /// enqueued before it is actually COMMITTED — written to the FileStore,
2477 /// not merely dequeued.
2478 ///
2479 /// Runs the real flusher against a real in-memory FileStore and reads the
2480 /// file at the moment the barrier resolves. An earlier version of this
2481 /// test drained the channel by hand instead, which only proved the
2482 /// channel is FIFO — it passed unchanged when the ack was moved to BEFORE
2483 /// the flush, i.e. it could not see the bug it existed to catch.
2484 #[tokio::test]
2485 async fn barrier_does_not_resolve_until_prior_output_is_committed() {
2486 use super::super::super::super::storage::filestore::FileStore;
2487 use std::sync::Arc;
2488
2489 let (broker, _events) = super::pty_output_flusher_tests::broker_recording_block_file_events();
2490 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2491 let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2492
2493 tx.send(PtyReadChunk { data: b"committed output".to_vec(), osc_events: Vec::new(), ack: None })
2494 .await
2495 .expect("send data");
2496
2497 let flusher = tokio::spawn(run_pty_output_flusher(
2498 rx,
2499 Some(Arc::new(broker)),
2500 "block-1".to_string(),
2501 Some(fs.clone()),
2502 false,
2503 ));
2504
2505 flush_barrier(&tx, std::time::Duration::from_secs(5))
2506 .await
2507 .expect("barrier must resolve");
2508
2509 // The barrier has resolved — the output it was queued behind must be
2510 // readable RIGHT NOW, with no further waiting.
2511 let data = fs
2512 .read_file("block-1", "term")
2513 .expect("read_file ok")
2514 .expect("output must be committed by the time the barrier resolves");
2515 assert_eq!(
2516 data,
2517 b"committed output".to_vec(),
2518 "barrier resolved before the preceding output reached the FileStore"
2519 );
2520
2521 drop(tx);
2522 let _ = flusher.await;
2523 }
2524
2525 /// The property that makes the "prefer true EOF" fast path reachable at
2526 /// all, and the exact thing ReAgent's round-3 P1 found broken: the wait
2527 /// task's barrier handle must NOT keep the channel open.
2528 ///
2529 /// An mpsc channel closes only once EVERY `Sender` is dropped. A strong
2530 /// clone held across the EOF wait means `recv()` never yields `None`, the
2531 /// flusher never ends, `flusher_handle` never resolves — and the fast path
2532 /// is dead code on every platform, silently applying the fallback's
2533 /// residual race everywhere.
2534 ///
2535 /// The production site is additionally type-annotated as `WeakSender`, so
2536 /// swapping it back to `clone()` is a compile error rather than a silent
2537 /// behaviour change.
2538 #[tokio::test]
2539 async fn a_weak_barrier_handle_does_not_keep_the_flusher_alive() {
2540 use super::super::super::super::storage::filestore::FileStore;
2541 use std::sync::Arc;
2542
2543 let (broker, _events) = super::pty_output_flusher_tests::broker_recording_block_file_events();
2544 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2545 let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2546
2547 let weak: tokio::sync::mpsc::WeakSender<PtyReadChunk> = tx.downgrade();
2548 let flusher = tokio::spawn(run_pty_output_flusher(
2549 rx,
2550 Some(Arc::new(broker)),
2551 "block-1".to_string(),
2552 Some(fs),
2553 false,
2554 ));
2555
2556 // The read loop's sender going away is the ONLY strong sender.
2557 drop(tx);
2558
2559 tokio::time::timeout(std::time::Duration::from_secs(5), flusher)
2560 .await
2561 .expect("the flusher must end once the last STRONG sender drops — a weak handle must not hold it open")
2562 .expect("flusher task");
2563
2564 assert!(
2565 weak.upgrade().is_none(),
2566 "a closed channel must not be upgradeable — the barrier reads this as 'already drained'"
2567 );
2568 }
2569
2570 /// ReAgent P1 on PR #3261: the SEND has to be inside the timeout too.
2571 /// `tx` is bounded, so a full channel plus a stalled flusher makes an
2572 /// unbounded `send().await` block the whole wait/cleanup task forever —
2573 /// taking STATUS_DONE, the lease release and close-on-exit with it. A
2574 /// bound with an unbounded step inside it is not a bound.
2575 ///
2576 /// Fills the channel to capacity with a receiver that never reads, so the
2577 /// send genuinely cannot complete.
2578 #[tokio::test]
2579 async fn a_full_channel_expires_instead_of_blocking_forever() {
2580 let (tx, _rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(1);
2581 tx.send(PtyReadChunk { data: b"wedged".to_vec(), osc_events: Vec::new(), ack: None })
2582 .await
2583 .expect("first send fills the channel");
2584
2585 let started = std::time::Instant::now();
2586 let res = flush_barrier(&tx, std::time::Duration::from_millis(80)).await;
2587 assert_eq!(res, Err(()), "a send that cannot complete must expire");
2588 assert!(
2589 started.elapsed() < std::time::Duration::from_secs(5),
2590 "must expire on the timeout, not block: took {:?}",
2591 started.elapsed()
2592 );
2593 }
2594
2595 /// The bounded-wait contract: if nothing ever acks (a wedged flusher), the
2596 /// barrier gives up rather than hanging the exit path forever. This is the
2597 /// case the old code always hit, and it is now the fallback rather than
2598 /// the norm.
2599 #[tokio::test]
2600 async fn an_unanswered_barrier_times_out() {
2601 let (tx, _rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2602 // `_rx` is held open but never read, so the ack can never fire.
2603 let res = flush_barrier(&tx, std::time::Duration::from_millis(50)).await;
2604 assert_eq!(res, Err(()), "an unanswered barrier must expire, not block forever");
2605 }
2606}
2607
2608#[cfg(test)]
2609mod agent_lease_release_tests {
2610 use super::*;
2611 use crate::backend::blockcontroller::agent_lock;
2612
2613 fn controller_for(block_id: &str) -> Arc<ShellController> {
2614 Arc::new(ShellController::new(
2615 "shell".to_string(),
2616 "tab-lease".to_string(),
2617 block_id.to_string(),
2618 None,
2619 None,
2620 None,
2621 None,
2622 String::new(),
2623 ))
2624 }
2625
2626 /// The natural case: this task's own controller is still the registered
2627 /// one (a human typed `exit`), so the lease goes.
2628 #[test]
2629 fn releases_when_this_task_still_owns_the_controller() {
2630 let block_id = "lease-guard-current";
2631 let ctrl = controller_for(block_id);
2632 let inner = Arc::clone(&ctrl.inner);
2633 super::super::super::register_controller(block_id, ctrl);
2634
2635 agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2636 assert!(release_lease_if_current(block_id, &inner, &None, &None));
2637 assert!(!agent_lock::is_locked(block_id), "an exited shell must drop its lease");
2638
2639 super::super::super::delete_controller(block_id);
2640 agent_lock::release(block_id);
2641 }
2642
2643 /// Force-restart: a REPLACEMENT controller is registered under the same
2644 /// block_id and the agent takes a fresh lease on it. This stale task —
2645 /// from the killed old process — must not clear that live lease, or human
2646 /// keystrokes race the agent's writes on a shell still being driven.
2647 #[test]
2648 fn leaves_a_successors_lease_alone_after_a_force_restart() {
2649 let block_id = "lease-guard-replaced";
2650 let old = controller_for(block_id);
2651 let old_inner = Arc::clone(&old.inner);
2652 let replacement = controller_for(block_id);
2653
2654 // The replacement is what's registered now — exactly what
2655 // `stop_for_replace` + re-register leaves behind.
2656 super::super::super::register_controller(block_id, replacement);
2657 agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2658
2659 assert!(
2660 !release_lease_if_current(block_id, &old_inner, &None, &None),
2661 "a stale task must not release"
2662 );
2663 assert!(
2664 agent_lock::is_locked(block_id),
2665 "the successor's lease must survive the old controller's cleanup"
2666 );
2667
2668 super::super::super::delete_controller(block_id);
2669 agent_lock::release(block_id);
2670 }
2671
2672 /// No controller registered at all (block already torn down) — nothing to
2673 /// own the lease, so this task must not assume the lease is its own.
2674 #[test]
2675 fn does_not_release_when_no_controller_is_registered() {
2676 let block_id = "lease-guard-unregistered";
2677 let orphan = controller_for(block_id);
2678 let inner = Arc::clone(&orphan.inner);
2679
2680 agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2681 assert!(!release_lease_if_current(block_id, &inner, &None, &None));
2682 assert!(agent_lock::is_locked(block_id));
2683
2684 agent_lock::release(block_id);
2685 }
2686}
2687
2688#[cfg(test)]
2689mod controller_agent_id_tests {
2690 use super::super::controller::ShellController;
2691 use super::super::super::Controller;
2692
2693 fn controller() -> ShellController {
2694 ShellController::new(
2695 "shell".to_string(),
2696 "tab1".to_string(),
2697 "block1".to_string(),
2698 None,
2699 None,
2700 None,
2701 None,
2702 String::new(),
2703 )
2704 }
2705
2706 /// reagentx P1 on #2697: `agent_id()` was captured once at spawn and
2707 /// never refreshed on re-registration, so a legitimately renamed or
2708 /// reconfigured agent's own messages could be falsely rejected as an
2709 /// identity mismatch by `inject_message_inner`'s recipient-identity
2710 /// check (#2695). `set_agent_id` (called from `handle_reactive_register`
2711 /// on every re-registration) must actually overwrite the captured value,
2712 /// not just the initial spawn-time one.
2713 #[test]
2714 fn set_agent_id_overwrites_a_stale_captured_value() {
2715 let ctrl = controller();
2716 assert_eq!(ctrl.agent_id(), None);
2717
2718 ctrl.set_agent_id(Some("agentx".to_string()));
2719 assert_eq!(ctrl.agent_id(), Some("agentx".to_string()));
2720
2721 // A later re-registration under a different name (rename,
2722 // reconfigured cmd:env) must be reflected, not stuck at "agentx".
2723 ctrl.set_agent_id(Some("agenty".to_string()));
2724 assert_eq!(ctrl.agent_id(), Some("agenty".to_string()));
2725
2726 ctrl.set_agent_id(None);
2727 assert_eq!(ctrl.agent_id(), None);
2728 }
2729}
2730
2731#[cfg(test)]
2732pub(super) mod pty_output_flusher_tests {
2733 use super::{flush_pty_batch, run_pty_output_flusher, PtyReadChunk, PTY_CHANNEL_CAPACITY};
2734 use crate::backend::storage::filestore::FileStore;
2735 use crate::backend::mps;
2736 use std::sync::{Arc, Mutex};
2737
2738 /// Records every event delivered to it — lets a test count broadcasts
2739 /// rather than only inspect final file content, which is what actually
2740 /// distinguishes "coalesced into one broadcast" from "one broadcast per
2741 /// PTY read" (both produce identical final content; only the broadcast
2742 /// COUNT tells them apart). Holds an `Arc` (not an owned `Vec`) because
2743 /// `Broker::set_client` takes ownership of the client, so the test needs
2744 /// its own handle to read events back out afterward.
2745 struct RecordingClient {
2746 events: Arc<Mutex<Vec<mps::MuxEvent>>>,
2747 }
2748
2749 impl mps::WpsClient for RecordingClient {
2750 fn send_event(&self, _route_id: &str, event: mps::MuxEvent) {
2751 self.events.lock().unwrap().push(event);
2752 }
2753 }
2754
2755 /// A broker wired to a `RecordingClient`, subscribed (all-scopes, so
2756 /// this doesn't need to know the exact `block:<id>` scope string) to
2757 /// `EVENT_BLOCK_FILE` — the event `handle_append_block_file` publishes.
2758 pub(super) fn broker_recording_block_file_events() -> (mps::Broker, Arc<Mutex<Vec<mps::MuxEvent>>>) {
2759 let broker = mps::Broker::new();
2760 let events = Arc::new(Mutex::new(Vec::new()));
2761 broker.set_client(Box::new(RecordingClient {
2762 events: events.clone(),
2763 }));
2764 broker.subscribe(
2765 "test-route",
2766 mps::SubscriptionRequest {
2767 event: mps::EVENT_BLOCK_FILE.to_string(),
2768 scopes: vec![],
2769 allscopes: true,
2770 },
2771 );
2772 (broker, events)
2773 }
2774
2775 /// Decode the base64 `data64` payload of a `term`-file `EVENT_BLOCK_FILE`
2776 /// broadcast back to raw bytes, for asserting on content.
2777 fn decode_event_data(event: &mps::MuxEvent) -> Vec<u8> {
2778 use base64::Engine;
2779 let data: mps::WSFileEventData =
2780 serde_json::from_value(event.data.clone().expect("event has data")).expect("valid WSFileEventData");
2781 base64::engine::general_purpose::STANDARD
2782 .decode(&data.data64)
2783 .expect("valid base64")
2784 }
2785
2786 #[test]
2787 fn flush_pty_batch_writes_and_broadcasts_one_call() {
2788 let (broker, events) = broker_recording_block_file_events();
2789 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2790 let mut line_buf = Vec::new();
2791
2792 flush_pty_batch(&broker, "block-1", b"hello world", &[], Some(&fs), &mut line_buf, None);
2793
2794 let data = fs
2795 .read_file("block-1", "term")
2796 .expect("read_file ok")
2797 .expect("data present");
2798 assert_eq!(data, b"hello world");
2799
2800 let evs = events.lock().unwrap();
2801 assert_eq!(evs.len(), 1, "one flush call must produce exactly one broadcast");
2802 assert_eq!(decode_event_data(&evs[0]), b"hello world");
2803 }
2804
2805 #[test]
2806 fn flush_pty_batch_is_a_no_op_for_empty_input() {
2807 // Guards against a spurious empty broadcast on a batch that ended
2808 // up with nothing in it (shouldn't happen given how the flusher
2809 // calls this, but a no-op guard here is cheap and cheaper than
2810 // debugging a phantom empty append if that assumption ever breaks).
2811 let (broker, events) = broker_recording_block_file_events();
2812 let mut line_buf = Vec::new();
2813 flush_pty_batch(&broker, "block-1", b"", &[], None, &mut line_buf, None);
2814 assert!(events.lock().unwrap().is_empty());
2815 }
2816
2817 /// The actual property this whole change exists to deliver: N chunks
2818 /// that arrive back-to-back (well inside the coalescing window and the
2819 /// byte cap) collapse into far fewer broadcasts than N — ideally one —
2820 /// with no data lost, duplicated, or reordered.
2821 ///
2822 /// Deterministic, not timing-dependent: every chunk is sent to the
2823 /// channel BEFORE the flusher is ever polled, so its first "opportunistic
2824 /// drain" pass finds them all already queued and drains them without
2825 /// actually waiting out any real wall-clock delay — it's exercising the
2826 /// "channel closed mid-accumulation" exit path (see
2827 /// `run_pty_output_flusher`'s `closed_mid_batch`). The drain never waits
2828 /// on unarrived output at all now, so there is no window to race.
2829 #[tokio::test]
2830 async fn rapid_chunks_coalesce_into_far_fewer_broadcasts_with_no_data_loss() {
2831 let (broker, events) = broker_recording_block_file_events();
2832 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2833 // Bounded, matching production (PTY_CHANNEL_CAPACITY) — comfortably
2834 // above the 50 messages below, so every send below still completes
2835 // without actually waiting for capacity; the determinism this test's
2836 // own doc comment relies on is unaffected by the channel being bounded.
2837 let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
2838
2839 let mut expected = Vec::new();
2840 for i in 0..50 {
2841 let piece = format!("line {i}\n");
2842 expected.extend_from_slice(piece.as_bytes());
2843 tx.send(PtyReadChunk {
2844 ack: None,
2845 data: piece.into_bytes(),
2846 osc_events: Vec::new(),
2847 })
2848 .await
2849 .unwrap();
2850 }
2851 drop(tx); // closes the channel once these 50 are drained
2852
2853 run_pty_output_flusher(rx, Some(Arc::new(broker)), "block-1".to_string(), Some(fs.clone()), false)
2854 .await;
2855
2856 let data = fs.read_file("block-1", "term").expect("read_file ok").expect("data present");
2857 assert_eq!(data, expected, "coalescing must not lose, duplicate, or reorder bytes");
2858
2859 // Deterministic exact count, not a loose bound: every chunk was
2860 // already queued before the flusher was ever polled (see the test's
2861 // own doc comment), so its very first opportunistic-drain pass finds
2862 // all 50 sitting in the channel and drains them without actually
2863 // waiting out any real time — one batch, one broadcast. Verified
2864 // stable across repeated local runs before asserting on it exactly;
2865 // a looser "< 50" bound would also pass on a much weaker result
2866 // (say, 40 broadcasts) that wouldn't actually demonstrate the fix.
2867 let broadcast_count = events.lock().unwrap().len();
2868 assert_eq!(
2869 broadcast_count, 1,
2870 "50 chunks queued before the flusher ever polled should collapse into \
2871 exactly one broadcast; got {broadcast_count}"
2872 );
2873 // Concatenating every broadcast's own payload, in delivery order,
2874 // must also reconstruct the exact original stream — proves the
2875 // coalescing boundaries themselves never split/reordered content
2876 // even if more than one broadcast happened.
2877 let mut reconstructed = Vec::new();
2878 for ev in events.lock().unwrap().iter() {
2879 reconstructed.extend(decode_event_data(ev));
2880 }
2881 assert_eq!(reconstructed, expected);
2882 }
2883
2884 /// The byte cap must still force a flush even if chunks keep arriving
2885 /// faster than the coalescing window would otherwise close — otherwise
2886 /// a sustained fast producer (`yes` piped into a shell pane) could grow
2887 /// one "batch" unboundedly.
2888 #[tokio::test]
2889 async fn byte_cap_forces_a_flush_before_the_channel_closes() {
2890 let (broker, events) = broker_recording_block_file_events();
2891 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2892 // Bounded, matching production — 80 messages below is comfortably
2893 // under PTY_CHANNEL_CAPACITY, so this test still exercises the byte
2894 // cap (not channel backpressure) as the thing forcing the split.
2895 let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
2896
2897 // Comfortably over PTY_COALESCE_MAX_BYTES (64 * 4096 = 256KiB) in
2898 // total, sent as many small chunks so the cap — not the channel
2899 // closing — is what has to trigger the split.
2900 let piece = vec![b'x'; 4096];
2901 let mut expected = Vec::new();
2902 for _ in 0..80 {
2903 expected.extend_from_slice(&piece);
2904 tx.send(PtyReadChunk {
2905 ack: None,
2906 data: piece.clone(),
2907 osc_events: Vec::new(),
2908 })
2909 .await
2910 .unwrap();
2911 }
2912 drop(tx);
2913
2914 run_pty_output_flusher(rx, Some(Arc::new(broker)), "block-1".to_string(), Some(fs.clone()), false)
2915 .await;
2916
2917 let data = fs.read_file("block-1", "term").expect("read_file ok").expect("data present");
2918 assert_eq!(data, expected);
2919
2920 let broadcast_count = events.lock().unwrap().len();
2921 assert!(
2922 broadcast_count >= 2,
2923 "80 * 4096 bytes exceeds PTY_COALESCE_MAX_BYTES; expected at least 2 broadcasts \
2924 (the cap forcing an early flush), got {broadcast_count}"
2925 );
2926 }
2927
2928 /// The actual property reagentx's P1 asked for: a full channel makes
2929 /// `blocking_send` — what the real PTY read loop calls, from a real
2930 /// blocking (`spawn_blocking`) thread, not an async one — block until
2931 /// the receiver drains it, rather than growing queued memory without
2932 /// limit. A tiny capacity (not `PTY_CHANNEL_CAPACITY`) so the test is
2933 /// fast and the blocking is unambiguous with only 2 messages.
2934 #[test]
2935 fn blocking_send_backpressures_once_the_channel_is_full() {
2936 let (tx, mut rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(1);
2937
2938 // Fills the one slot; must return immediately (room available).
2939 tx.blocking_send(PtyReadChunk { data: vec![1], osc_events: Vec::new(), ack: None })
2940 .expect("first send has room");
2941
2942 // A real background thread — mirrors the production shape exactly:
2943 // blocking_send is called from a spawn_blocking thread, never from
2944 // an async task.
2945 let sent_second = Arc::new(std::sync::atomic::AtomicBool::new(false));
2946 let sent_second_writer = sent_second.clone();
2947 let handle = std::thread::spawn(move || {
2948 tx.blocking_send(PtyReadChunk { data: vec![2], osc_events: Vec::new(), ack: None })
2949 .expect("second send eventually succeeds once drained");
2950 sent_second_writer.store(true, std::sync::atomic::Ordering::SeqCst);
2951 });
2952
2953 // The channel is full and nobody has drained it yet — the second
2954 // send must still be blocked. A short, generous sleep rather than
2955 // asserting instantaneously: proving a negative ("hasn't returned
2956 // yet") is inherently a bit timing-sensitive, but 50ms is enormous
2957 // next to what an unblocked send would take (microseconds), so a
2958 // false pass here would need a wildly unlikely scheduling delay.
2959 std::thread::sleep(std::time::Duration::from_millis(50));
2960 assert!(
2961 !sent_second.load(std::sync::atomic::Ordering::SeqCst),
2962 "second blocking_send must still be blocked while the channel is full"
2963 );
2964
2965 // Drain the one queued message — this is the "flusher catches up"
2966 // moment in production. The blocked send must now complete.
2967 let first = rx.try_recv().expect("first message still queued");
2968 assert_eq!(first.data, vec![1]);
2969
2970 handle.join().expect("background thread should complete once drained");
2971 assert!(sent_second.load(std::sync::atomic::Ordering::SeqCst));
2972
2973 let second = rx.try_recv().expect("second message now queued");
2974 assert_eq!(second.data, vec![2]);
2975 }
2976
2977 #[tokio::test]
2978 async fn no_broker_is_a_clean_no_op() {
2979 // Mirrors the read loop's own `broker_read.is_some()` gate — the
2980 // flusher must not panic or hang when there is nothing to flush to.
2981 let (_tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2982 run_pty_output_flusher(rx, None, "block-1".to_string(), None, false).await;
2983 }
2984
2985 /// Pins the exact ordering guarantee the round-3 fix (reagentx P1:
2986 /// STATUS_DONE could publish before the flusher's final batch was
2987 /// broadcast) relies on: awaiting the `JoinHandle` returned by
2988 /// `tokio::spawn(run_pty_output_flusher(...))` — exactly what the wait
2989 /// task now does, before its own cleanup/status-publish logic — only
2990 /// returns once every batch, including one forced out by the channel
2991 /// closing, has actually been broadcast. The wait task itself spawns a
2992 /// real PTY + child process and isn't practical to drive from a unit
2993 /// test; this pins the one guarantee that fix leans on, against this
2994 /// crate's own flusher function.
2995 #[tokio::test]
2996 async fn awaiting_the_flusher_join_handle_observes_every_broadcast_first() {
2997 let (broker, events) = broker_recording_block_file_events();
2998 let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2999 let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
3000
3001 let flusher_handle = tokio::spawn(run_pty_output_flusher(
3002 rx,
3003 Some(Arc::new(broker)),
3004 "block-1".to_string(),
3005 Some(fs.clone()),
3006 false,
3007 ));
3008
3009 // Sent on a delay from a separate task, standing in for the real PTY
3010 // read loop's own separate blocking thread — proves the ordering
3011 // holds even when the flusher genuinely has to wait on the channel,
3012 // not just the pre-queued-before-first-poll shortcut the coalescing
3013 // tests above deliberately use for their own determinism.
3014 let sender = tokio::spawn(async move {
3015 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3016 tx.send(PtyReadChunk {
3017 ack: None,
3018 data: b"trailing output".to_vec(),
3019 osc_events: Vec::new(),
3020 })
3021 .await
3022 .unwrap();
3023 // Dropping `tx` here closes the channel — the same signal the
3024 // real read loop sends by dropping `pty_tx` on PTY EOF — which
3025 // is what lets the flusher's final accumulation loop unstick
3026 // and return.
3027 });
3028 sender.await.expect("sender task should not panic");
3029
3030 flusher_handle.await.expect("flusher task should not panic");
3031
3032 // The property under test: by the time the join handle above has
3033 // returned, the trailing batch must already be visible — both in
3034 // FileStore and as a broadcast — with no extra yield/sleep needed to
3035 // "catch up" to it. This is the exact sequencing the wait task now
3036 // depends on before it publishes STATUS_DONE.
3037 assert_eq!(
3038 events.lock().unwrap().len(),
3039 1,
3040 "flusher_handle.await must not return until the final batch was broadcast"
3041 );
3042 let data = fs
3043 .read_file("block-1", "term")
3044 .expect("read_file ok")
3045 .expect("data present");
3046 assert_eq!(data, b"trailing output");
3047 }
3048}