Skip to main content

agent_float_term/tmux/
lifecycle.rs

1//! Invocation ownership and detached supervision. No terminal contents are inspected.
2
3use super::*;
4use std::os::unix::process::CommandExt;
5
6const METADATA_INTERVAL: Duration = Duration::from_millis(250);
7const POLL_INTERVAL: Duration = Duration::from_millis(100);
8
9pub(super) fn parent_lock(generation: &str, pane: &str) -> Result<PathBuf> {
10    ensure!(
11        valid_token(generation) && valid_pane(pane),
12        "invalid float owner"
13    );
14    Ok(runtime_dir()?.join(format!("aft-{}-{}.lock", &generation[..12], &pane[1..])))
15}
16
17fn watcher_lock(instance: &str) -> Result<PathBuf> {
18    ensure!(valid_token(instance), "invalid float instance");
19    Ok(runtime_dir()?.join(format!("watch-{instance}.lock")))
20}
21
22struct WatchLock {
23    path: PathBuf,
24    file: File,
25}
26
27impl WatchLock {
28    fn acquire(path: &Path) -> Result<Option<Self>> {
29        loop {
30            let Some(file) = lock_for(path, Duration::ZERO)? else {
31                return Ok(None);
32            };
33            let guard = Self {
34                path: path.into(),
35                file,
36            };
37            // A contender may have opened the previous watcher's inode just
38            // before it was unlinked. Never supervise while locking that inode.
39            if guard.current() {
40                return Ok(Some(guard));
41            }
42        }
43    }
44
45    fn current(&self) -> bool {
46        self.file
47            .metadata()
48            .ok()
49            .zip(fs::symlink_metadata(&self.path).ok())
50            .is_some_and(|(held, named)| held.dev() == named.dev() && held.ino() == named.ino())
51    }
52}
53
54impl Drop for WatchLock {
55    fn drop(&mut self) {
56        // Unlink before unlocking; acquire rechecks the inode after flock.
57        // In particular, a late drop must not unlink a replacement watcher's lock.
58        if self.current() {
59            let _ = fs::remove_file(&self.path);
60        }
61    }
62}
63
64fn valid_session(session: &str) -> bool {
65    session
66        .strip_prefix('$')
67        .is_some_and(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()))
68}
69
70fn all(conditions: impl IntoIterator<Item = String>) -> String {
71    conditions
72        .into_iter()
73        .reduce(|a, b| format!("#{{&&:{a},{b}}}"))
74        .unwrap_or_else(|| "0".into())
75}
76
77// tmux's format parser treats commas and closing braces specially, even inside
78// shell quotes. The literal modifier prevents JSON from becoming format syntax.
79fn format_literal(value: &str) -> String {
80    format!(
81        "#{{l:{}}}",
82        value
83            .replace('#', "##")
84            .replace(',', "#,")
85            .replace('}', "#}")
86    )
87}
88
89pub(super) fn ownership_condition(
90    generation: &str,
91    owner: &str,
92    instance: &str,
93    invocation: Invocation,
94) -> Result<String> {
95    Ok(all([
96        format!("#{{==:#{{@aft_float_generation}},{generation}}}"),
97        format!("#{{==:#{{@aft_generation}},{generation}}}"),
98        format!("#{{==:#{{@aft_owner}},{owner}}}"),
99        format!("#{{==:#{{@aft_instance}},{instance}}}"),
100        format!(
101            "#{{==:#{{@aft_invocation}},{}}}",
102            format_literal(&serde_json::to_string(&invocation)?)
103        ),
104    ]))
105}
106
107pub(super) fn exclusive_windows(tmux: &Tmux, session: &str) -> Result<bool> {
108    let windows = tmux.output(&[
109        "list-windows",
110        "-t",
111        session,
112        "-F",
113        "#{session_grouped}|#{window_linked}",
114    ])?;
115    Ok(!windows.is_empty() && windows.lines().all(|line| line == "0|0"))
116}
117
118/// Caller holds the parent lock. A server-side guard rechecks every window at
119/// the kill boundary, including noncurrent windows added since the snapshot.
120pub(super) fn kill_owned(
121    tmux: &Tmux,
122    session: &str,
123    generation: &str,
124    owner: &str,
125    instance: &str,
126    invocation: Invocation,
127) -> Result<()> {
128    ensure!(
129        valid_session(session) && valid_token(instance),
130        "invalid float identity"
131    );
132    if invocation.liveness() != Liveness::Exited || tmux.global(GENERATION)? != generation {
133        return Ok(());
134    }
135    let condition = all([
136        ownership_condition(generation, owner, instance, invocation)?,
137        "#{==:#{session_grouped},0}".into(),
138        "#{==:#{m:*1*,#{W:#{window_linked}}},0}".into(),
139    ]);
140    tmux.output(&[
141        "if-shell",
142        "-F",
143        "-t",
144        session,
145        &condition,
146        &format!("kill-session -t {}", tmux_quote(session)),
147    ])?;
148    // Also reclaim a lock left by a crashed watcher during invocation rollover.
149    drop(WatchLock::acquire(&watcher_lock(instance)?)?);
150    cleanup_routing(tmux, session, instance, false);
151    Ok(())
152}
153
154fn cleanup_routing(tmux: &Tmux, session: &str, instance: &str, unbound: bool) {
155    // A failed metadata command does not prove a session disappeared. A live
156    // linked/grouped float keeps its tables even when integration is unbound.
157    let Ok(sessions) = tmux.output(&["list-sessions", "-F", "#{session_id}"]) else {
158        return;
159    };
160    let gone = !sessions.lines().any(|id| id == session);
161    if gone
162        || (unbound
163            && exclusive_windows(tmux, session).unwrap_or(false)
164            && tmux
165                .output(&["show-options", "-qv", "-t", session, "@aft_instance"])
166                .is_ok_and(|current| current == instance))
167    {
168        let _ = routing::cleanup(tmux, instance);
169    }
170}
171
172fn helper(tmux: &Tmux) -> Result<Command> {
173    let mut command = Command::new(helper_path()?);
174    // Inherit HOME/XDG roots, and pin the approved per-server executable even
175    // when the caller selected it from a binding record rather than its env.
176    command
177        .env("AFT_TMUX_BINARY", &tmux.binary)
178        .env_remove("TMUX")
179        .env_remove("AFT_RESTORE_INSTANCE")
180        .stdin(Stdio::null())
181        .stdout(Stdio::null())
182        .stderr(Stdio::null());
183    // SAFETY: setsid is async-signal-safe and touches no Rust state in the child.
184    unsafe {
185        command.pre_exec(|| {
186            if libc::setsid() < 0 {
187                return Err(std::io::Error::last_os_error());
188            }
189            Ok(())
190        });
191    }
192    Ok(command)
193}
194
195pub(super) fn start_watcher(tmux: &Tmux, session: &str, instance: &str) -> Result<()> {
196    // Never take the parent lock in watch before publishing readiness: popup
197    // holds it while waiting here. The separate lifetime lock deduplicates watch.
198    let path = watcher_lock(instance)?;
199    let ready = || -> Result<bool> {
200        let metadata = tmux.output(&[
201            "display-message",
202            "-p",
203            "-t",
204            session,
205            "#{@aft_instance}|#{@aft_watch_ready}|#{@aft_watcher}",
206        ])?;
207        let fields: Vec<_> = metadata.split('|').collect();
208        Ok(fields.len() == 3
209            && fields[0] == instance
210            && fields[1] == instance
211            && alive(fields[2])
212            && WatchLock::acquire(&path)?.is_none())
213    };
214    if ready()? {
215        return Ok(());
216    }
217    let mut child = helper(tmux)?
218        .args(["watch", "--socket"])
219        .arg(&tmux.socket)
220        .args(["--session", session, "--instance", instance])
221        .spawn()?;
222    let deadline = Instant::now() + COMMAND_TIMEOUT;
223    loop {
224        if ready()? {
225            // Reap if this was a duplicate helper. Otherwise retain no wait on
226            // the watcher; the OS adopts it after this popup worker exits.
227            let _ = child.try_wait();
228            return Ok(());
229        }
230        if child.try_wait()?.is_some() || Instant::now() >= deadline {
231            let _ = child.kill();
232            let _ = child.wait();
233            bail!("lifecycle watcher did not become ready; shell was preserved");
234        }
235        thread::sleep(Duration::from_millis(20));
236    }
237}
238
239struct State {
240    generation: String,
241    owner: String,
242    instance: String,
243    invocation: Invocation,
244    attached: bool,
245    worker: String,
246    visible: bool,
247    routing: bool,
248    origin_pid: String,
249    origin_name: String,
250    origin_session: String,
251    restore_table: String,
252    clients: Vec<Vec<String>>,
253}
254
255impl State {
256    fn read(tmux: &Tmux, session: &str) -> Result<Self> {
257        let value = tmux.output(&[
258            "show-options", "-gqv", GENERATION, ";",
259            "display-message", "-p", "-t", session,
260            "#{session_id}|#{@aft_float_generation}|#{@aft_owner}|#{@aft_instance}|#{@aft_invocation}|#{session_attached}|#{@aft_worker}|#{@aft_visible}|#{@aft_routing}|#{@aft_origin_client}|#{@aft_origin_name}|#{@aft_origin_session}|#{@aft_restore_table}",
261            ";", "list-clients", "-F",
262            "#{client_pid}|#{client_name}|#{session_id}|#{pane_id}|#{pane_in_mode}|#{client_key_table}|#{client_flags}",
263        ])?;
264        Self::parse(&value, session)
265    }
266
267    fn parse(value: &str, session: &str) -> Result<Self> {
268        let mut lines = value.lines();
269        let generation = lines.next().context("missing generation")?;
270        let fields: Vec<_> = lines
271            .next()
272            .context("float disappeared")?
273            .split('|')
274            .collect();
275        ensure!(
276            fields.len() == 13
277                && fields[0] == session
278                && fields[1] == generation
279                && valid_token(generation)
280                && valid_pane(fields[2])
281                && valid_token(fields[3]),
282            "float ownership changed"
283        );
284        Ok(Self {
285            generation: generation.into(),
286            owner: fields[2].into(),
287            instance: fields[3].into(),
288            invocation: serde_json::from_str(fields[4])?,
289            attached: fields[5] != "0",
290            worker: fields[6].into(),
291            visible: fields[7] == "1",
292            routing: fields[8] == "1",
293            origin_pid: fields[9].into(),
294            origin_name: fields[10].into(),
295            origin_session: fields[11].into(),
296            restore_table: fields[12].into(),
297            clients: lines
298                .map(|line| line.split('|').map(str::to_owned).collect())
299                .collect(),
300        })
301    }
302
303    fn matches(&self, original: &Self) -> bool {
304        self.generation == original.generation
305            && self.owner == original.owner
306            && self.instance == original.instance
307            && self.invocation == original.invocation
308    }
309
310    fn origin(&self) -> Option<&[String]> {
311        self.clients
312            .iter()
313            .find(|fields| {
314                fields.len() == 7 && fields[0] == self.origin_pid && fields[1] == self.origin_name
315            })
316            .map(Vec::as_slice)
317    }
318
319    fn can_restore(&self) -> bool {
320        self.visible
321            && !self.routing
322            && !self.attached
323            && !alive(&self.worker)
324            && self.origin().is_some_and(|client| {
325                client[2] == self.origin_session
326                    && client[3] == self.owner
327                    && idle_client(client, &self.restore_table)
328            })
329    }
330}
331
332fn idle_client(client: &[String], table: &str) -> bool {
333    // This only selects a candidate. routing::restore must enter through native
334    // client input handling so prompts/overlays get first refusal, not dispatch.
335    client.len() == 7
336        && client[4] == "0"
337        && !table.is_empty()
338        && client[5] == table
339        && client[6].split(',').any(|flag| flag == "attached")
340        && !client[6].split(',').any(|flag| {
341            matches!(
342                flag,
343                "suspended" | "read-only" | "control-mode" | "overlay" | "prompt"
344            )
345        })
346}
347
348pub(super) fn restore_allowed(
349    tmux: &Tmux,
350    session: &str,
351    instance: &str,
352    invocation: Invocation,
353    client: &Client,
354) -> Result<bool> {
355    let state = State::read(tmux, session)?;
356    Ok(state.instance == instance
357        && state.invocation == invocation
358        && state.can_restore()
359        && state.origin_pid == client.pid.to_string()
360        && state.origin_name == client.name
361        && state.origin_session == client.session
362        && state.owner == client.pane)
363}
364
365pub(super) fn viewer_command(
366    tmux: &Tmux,
367    session: &str,
368    instance: &str,
369    worker: &str,
370) -> Result<String> {
371    let binary = shell_quote(text(&tmux.binary)?);
372    let socket = shell_quote(text(&tmux.socket)?);
373    let condition = all([
374        format!("#{{==:#{{@aft_instance}},{instance}}}"),
375        format!("#{{==:#{{@aft_worker}},{worker}}}"),
376    ]);
377    // The shell's PID survives exec into attach-session. Publish it only under
378    // this viewer claim, and do not attach at all if the claim was superseded.
379    let claim = shell_quote(&format!(
380        "set-option -t {} @aft_viewer ",
381        tmux_quote(session)
382    ));
383    Ok(format!(
384        "if [ \"$({binary} -S {socket} if-shell -F -t {target} {condition} {claim}\"$$\"'; display-message -p AFT_VIEWER')\" = AFT_VIEWER ]; then exec {binary} -T RGB -S {socket} attach-session -E -t {target}; fi",
385        target = shell_quote(session), condition = shell_quote(&condition),
386    ))
387}
388
389/// Hidden helper entry point. Ownership loss is a normal, non-destructive exit.
390pub fn watch(socket: PathBuf, session: String, instance: String) -> Result<()> {
391    ensure!(
392        valid_session(&session) && valid_token(&instance),
393        "invalid watcher target"
394    );
395    let tmux = Tmux::new(socket)?;
396    let _watch_guard = match WatchLock::acquire(&watcher_lock(&instance)?)? {
397        Some(guard) => guard,
398        None => return Ok(()),
399    };
400    let original = match State::read(&tmux, &session) {
401        Ok(state) if state.instance == instance => state,
402        _ => return Ok(()),
403    };
404    let record_path = record_path(&tmux.socket)?;
405    let bound = || -> Result<bool> {
406        Ok(read_record(&record_path)?.is_some_and(|record| {
407            record.socket == tmux.socket && record.generation == original.generation
408        }))
409    };
410    if !bound()? {
411        cleanup_routing(&tmux, &session, &instance, true);
412        return Ok(());
413    }
414    let pid = std::process::id().to_string();
415    let condition = ownership_condition(
416        &original.generation,
417        &original.owner,
418        &instance,
419        original.invocation,
420    )?;
421    tmux.output(&[
422        "if-shell",
423        "-F",
424        "-t",
425        &session,
426        &condition,
427        &format!(
428            "set-option -t {} @aft_watcher {pid} ; set-option -t {} @aft_watch_ready {instance}",
429            tmux_quote(&session),
430            tmux_quote(&session)
431        ),
432    ])?;
433    let mut metadata_at = Instant::now();
434    let mut restore_at = Instant::now();
435    loop {
436        let started = Instant::now();
437        let liveness = original.invocation.liveness();
438        if liveness == Liveness::Exited {
439            // Retry lock contention: a creator may still be completing its
440            // readiness handshake, and must be allowed to release this lock.
441            if let Some(_guard) = lock_for(
442                &parent_lock(&original.generation, &original.owner)?,
443                Duration::ZERO,
444            )? {
445                if !bound()? {
446                    cleanup_routing(&tmux, &session, &instance, true);
447                    return Ok(());
448                }
449                if let Ok(current) = State::read(&tmux, &session) {
450                    if current.matches(&original) {
451                        kill_owned(
452                            &tmux,
453                            &session,
454                            &original.generation,
455                            &original.owner,
456                            &instance,
457                            original.invocation,
458                        )?;
459                    }
460                }
461                cleanup_routing(&tmux, &session, &instance, false);
462                return Ok(());
463            }
464        }
465        if started >= metadata_at {
466            metadata_at = started + METADATA_INTERVAL;
467            if !bound()? {
468                cleanup_routing(&tmux, &session, &instance, true);
469                return Ok(());
470            }
471            let current = match State::read(&tmux, &session) {
472                Ok(state) if state.matches(&original) => state,
473                _ => {
474                    cleanup_routing(&tmux, &session, &instance, false);
475                    return Ok(());
476                }
477            };
478            if current.attached && alive(&current.worker) && !current.routing {
479                if let Some(client) = current.origin() {
480                    if client[2] != current.origin_session || client[3] != current.owner {
481                        close_viewer(&tmux, &session, &current)?;
482                    }
483                }
484            }
485            if liveness == Liveness::Alive && started >= restore_at && current.can_restore() {
486                if let Ok(pid) = current.origin_pid.parse() {
487                    let client = Client {
488                        pid,
489                        name: current.origin_name.clone(),
490                        pane: current.owner.clone(),
491                        session: current.origin_session.clone(),
492                    };
493                    let _ = routing::restore(&tmux, &instance, &client);
494                }
495                restore_at = started + Duration::from_secs(1);
496            }
497        }
498        thread::sleep(POLL_INTERVAL.saturating_sub(started.elapsed()));
499    }
500}
501
502fn close_viewer(tmux: &Tmux, session: &str, state: &State) -> Result<()> {
503    // Detach only the nested client whose PID was leased by this popup worker.
504    // Unlike display-popup -C this cannot close a replacement, foreign overlay.
505    let viewer = tmux.output(&["display-message", "-p", "-t", session, "#{@aft_viewer}"])?;
506    if !alive(&viewer) {
507        return Ok(());
508    }
509    let Some(client) = state
510        .clients
511        .iter()
512        .find(|client| client.len() == 7 && client[0] == viewer && client[2] == session)
513    else {
514        return Ok(());
515    };
516    let moved_origin = all([
517        format!(
518            "#{{==:#{{client_pid}},{}}}",
519            format_literal(&state.origin_pid)
520        ),
521        format!(
522            "#{{==:#{{client_name}},{}}}",
523            format_literal(&state.origin_name)
524        ),
525        format!(
526            "#{{||:#{{!=:#{{session_id}},{}}},#{{!=:#{{pane_id}},{}}}}}",
527            format_literal(&state.origin_session),
528            state.owner
529        ),
530    ]);
531    let leased_viewer = all([
532        format!("#{{==:#{{client_pid}},{viewer}}}"),
533        format!("#{{==:#{{client_name}},{}}}", format_literal(&client[1])),
534        format!("#{{==:#{{session_id}},{session}}}"),
535    ]);
536    let condition = all([
537        ownership_condition(
538            &state.generation,
539            &state.owner,
540            &state.instance,
541            state.invocation,
542        )?,
543        format!("#{{==:#{{@aft_worker}},{}}}", format_literal(&state.worker)),
544        format!("#{{==:#{{@aft_viewer}},{viewer}}}"),
545        "#{!=:#{@aft_routing},1}".into(),
546        format!("#{{m:*1*,#{{L:{moved_origin}}}}}"),
547        format!("#{{m:*1*,#{{L:{leased_viewer}}}}}"),
548    ]);
549    tmux.output(&[
550        "if-shell",
551        "-F",
552        "-t",
553        session,
554        &condition,
555        &format!("detach-client -t {}", tmux_quote(&client[1])),
556    ])?;
557    Ok(())
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn restoration_candidates_require_the_original_table_and_normal_client() {
566        let mut client: Vec<String> =
567            ["1", "/dev/pts/1", "$1", "%1", "0", "root", "attached,UTF-8"]
568                .map(str::to_owned)
569                .into();
570        assert!(idle_client(&client, "root"));
571        assert!(!idle_client(&client, ""));
572        assert!(!idle_client(&client, "other"));
573        client[5] = "custom-default".into();
574        assert!(idle_client(&client, "custom-default"));
575        client[5] = "root".into();
576        for index in [4, 5, 6] {
577            let saved = client[index].clone();
578            client[index] = String::new();
579            assert!(!idle_client(&client, "root"));
580            client[index] = saved;
581        }
582        for flag in [
583            "overlay",
584            "prompt",
585            "suspended",
586            "read-only",
587            "control-mode",
588        ] {
589            client[6] = format!("attached,{flag}");
590            assert!(!idle_client(&client, "root"));
591        }
592    }
593
594    #[test]
595    fn identities_and_format_literals_are_not_commands() {
596        assert!(valid_session("$12"));
597        for session in ["", "$", "$1;kill-server", "main", "$1\n"] {
598            assert!(!valid_session(session));
599        }
600        assert_eq!(
601            format_literal("{\"a\":1,\"b\":2}"),
602            "#{l:{\"a\":1#,\"b\":2#}}"
603        );
604        assert_eq!(format_literal("#{pane_id}"), "#{l:##{pane_id#}}");
605    }
606
607    #[test]
608    fn ownership_and_restore_require_exact_identity_and_intent() {
609        let generation = "a".repeat(32);
610        let instance = "b".repeat(32);
611        let invocation = r#"{"frontend":{"pid":42,"started":[123,456]},"wrapper":null}"#;
612        let transcript = format!("{generation}\n$2|{generation}|%1|{instance}|{invocation}|0||1|0|1|/dev/pts/1|$1|root\n1|/dev/pts/1|$1|%1|0|root|attached,UTF-8");
613        let original = State::parse(&transcript, "$2").unwrap();
614        let mut current = State::parse(&transcript, "$2").unwrap();
615        assert!(current.matches(&original));
616        assert!(current.can_restore());
617        current.visible = false;
618        assert!(!current.can_restore());
619        current.visible = true;
620        current.routing = true;
621        assert!(!current.can_restore());
622        current.routing = false;
623        current.attached = true;
624        assert!(!current.can_restore());
625        current.attached = false;
626        current.worker = std::process::id().to_string();
627        assert!(!current.can_restore());
628        current.worker.clear();
629        for index in 0..4 {
630            let saved = current.clients[0][index].clone();
631            current.clients[0][index].push('9');
632            assert!(!current.can_restore());
633            current.clients[0][index] = saved;
634        }
635        current.instance = "c".repeat(32);
636        assert!(!current.matches(&original));
637        current.instance = original.instance.clone();
638        current.invocation = serde_json::from_str(&invocation.replace("123", "124")).unwrap();
639        assert!(!current.matches(&original));
640        assert!(State::parse(&transcript, "$3").is_err());
641        assert!(State::parse(&transcript.replacen(&generation, &"c".repeat(32), 1), "$2").is_err());
642        assert!(State::parse(&transcript.replace(invocation, ""), "$2").is_err());
643        let marker = serde_json::to_string(&original.invocation).unwrap();
644        assert!(!marker.contains('|'));
645        let condition =
646            ownership_condition(&generation, "%1", &instance, original.invocation).unwrap();
647        for option in [
648            "@aft_generation",
649            "@aft_float_generation",
650            "@aft_owner",
651            "@aft_instance",
652            "@aft_invocation",
653        ] {
654            assert!(condition.contains(option));
655        }
656    }
657
658    #[test]
659    fn viewer_shell_claim_is_valid_with_quoted_paths() {
660        let tmux = Tmux {
661            binary: "/not installed/tmux'client".into(),
662            socket: "/not installed/socket'path".into(),
663        };
664        let command = viewer_command(&tmux, "$2", &"a".repeat(32), "42").unwrap();
665        assert!(command.contains("@aft_instance"));
666        assert!(command.contains("@aft_worker"));
667        assert!(command.contains("@aft_viewer"));
668        // Syntax check only: no tmux executable or socket is used by this test.
669        assert!(Command::new("/bin/sh")
670            .args(["-n", "-c", &command])
671            .status()
672            .unwrap()
673            .success());
674    }
675
676    #[test]
677    fn watcher_lifetime_lock_does_not_take_parent_lock() {
678        let directory = tempfile::tempdir().unwrap();
679        let parent = directory.path().join("parent.lock");
680        let watcher = directory.path().join("watch.lock");
681        let _parent = lock(&parent).unwrap();
682        let watcher_guard = WatchLock::acquire(&watcher).unwrap().unwrap();
683        assert!(WatchLock::acquire(&watcher).unwrap().is_none());
684        assert!(lock_for(&parent, Duration::ZERO).unwrap().is_none());
685        drop(watcher_guard);
686        assert!(!watcher.exists());
687        let stale = WatchLock::acquire(&watcher).unwrap().unwrap();
688        fs::remove_file(&watcher).unwrap();
689        let replacement = WatchLock::acquire(&watcher).unwrap().unwrap();
690        drop(stale);
691        assert!(watcher.exists());
692        assert!(WatchLock::acquire(&watcher).unwrap().is_none());
693        drop(replacement);
694        assert!(!watcher.exists());
695    }
696}