1use 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 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 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
77fn 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
118pub(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 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 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 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 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 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 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 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 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
389pub 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 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(¤t.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, ¤t)?;
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 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 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}