Skip to main content

teams_cli/desktop/
browser_lifetime.rs

1//! Application leases are backed by a supervised stdin pipe, not the application's PID.
2//! EOF survives application crashes and avoids PID-reuse and extension-host sharing bugs.
3//! All per-browser changes use the same lock as connect/disconnect; cleanup never acts
4//! on a snapshot taken before acquiring that lock.
5use super::*;
6use std::collections::HashMap;
7use std::io::IsTerminal;
8use tokio::io::AsyncReadExt;
9
10pub const CLIENT_ENV: &str = "TEAMS_DESKTOP_BROWSER_CLIENT";
11
12#[derive(Debug, Default, Serialize, Deserialize)]
13pub(super) struct Lifetime {
14    pub auto_close: bool,
15    #[serde(default)]
16    pub clients: Vec<String>,
17    #[serde(default)]
18    pub users: Vec<Identity>,
19    #[serde(default)]
20    pub idle_since: Option<u64>,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub(super) struct Client {
25    token: String,
26    owner: Identity,
27    keep_alive: bool,
28}
29
30pub(super) fn live(identity: &Identity) -> Result<bool, AppError> {
31    live_with(identity, system::lease_process)
32}
33
34fn live_with(
35    identity: &Identity,
36    inspect: impl FnOnce(u32) -> Result<(Identity, Vec<OsString>), AppError>,
37) -> Result<bool, AppError> {
38    match inspect(identity.pid) {
39        // A lease follows the process across exec or an executable upgrade.
40        // The browser itself still requires full executable/profile verification.
41        Ok((current, _)) => Ok(current.pid == identity.pid && current.started == identity.started),
42        Err(error) => {
43            // Inspection can fail for a live process (permissions, transient OS
44            // errors). Only ESRCH proves it is gone; uncertainty must preserve its
45            // lease, rather than closing a browser still in use.
46            let pid = i32::try_from(identity.pid)
47                .ok()
48                .filter(|pid| *pid > 0)
49                .ok_or_else(|| failed("invalid browser lease process ID"))?;
50            // SAFETY: signal zero only checks existence; it never signals a process.
51            let exists = unsafe { libc::kill(pid, 0) };
52            if exists == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
53                Ok(false)
54            } else if exists == 0 && system::zombie(pid as u32)? {
55                // Zombies still answer kill(pid, 0), but cannot use a browser.
56                Ok(false)
57            } else {
58                Err(error)
59            }
60        }
61    }
62}
63
64fn clients_directory() -> Result<PathBuf, AppError> {
65    Ok(data_directory()?.join("clients"))
66}
67
68fn valid_token(token: &str) -> bool {
69    token.len() == 32 && token.bytes().all(|b| b.is_ascii_hexdigit())
70}
71
72fn client(token: &str) -> Result<Option<Client>, AppError> {
73    if !valid_token(token) {
74        return Err(failed("invalid browser supervisor token"));
75    }
76    let dir = clients_directory()?;
77    if !dir.try_exists()? {
78        return Ok(None);
79    }
80    validate_directory(&dir)?;
81    let path = dir.join(token);
82    let bytes = match std::fs::read(&path) {
83        Ok(bytes) => bytes,
84        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
85        Err(error) => return Err(error.into()),
86    };
87    let value: Client = serde_json::from_slice(&bytes)
88        .map_err(|_| failed("invalid browser supervisor record; preserved for recovery"))?;
89    Ok((value.token == token && live(&value.owner)?).then_some(value))
90}
91
92pub(super) fn current_client() -> Result<Option<Client>, AppError> {
93    match std::env::var(CLIENT_ENV) {
94        Ok(token) => client(&token)?.map(Some).ok_or_else(|| {
95            failed("the browser supervisor has closed; reopen Teams Companion before retrying")
96        }),
97        Err(std::env::VarError::NotPresent) => Ok(None),
98        Err(_) => Err(failed("invalid browser supervisor token")),
99    }
100}
101
102impl Lifetime {
103    pub(super) fn for_launch() -> Result<Self, AppError> {
104        let client = current_client()?;
105        let keep_alive = match std::env::var("TEAMS_DESKTOP_BROWSER_KEEP_ALIVE") {
106            Ok(value) if value == "1" => true,
107            Ok(value) if value == "0" => false,
108            Err(std::env::VarError::NotPresent) => client.as_ref().is_some_and(|c| c.keep_alive),
109            _ => return Err(failed("invalid browser keep-alive policy")),
110        };
111        let mut lifetime = Self {
112            auto_close: client.is_some() && !keep_alive,
113            ..Self::default()
114        };
115        lifetime.touch(client.as_ref())?;
116        Ok(lifetime)
117    }
118
119    pub(super) fn prune(&mut self) -> Result<(), AppError> {
120        let mut active = Vec::new();
121        for token in &self.clients {
122            // An unreadable/damaged peer lease must protect the session without
123            // blocking healthy clients from using it. Explicit recovery can clear it.
124            if !matches!(client(token), Ok(None)) {
125                active.push(token.clone());
126            }
127        }
128        let mut users = Vec::new();
129        for user in &self.users {
130            if live(user).unwrap_or(true) {
131                users.push(user.clone());
132            }
133        }
134        self.clients = active;
135        self.users = users;
136        Ok(())
137    }
138
139    pub(super) fn touch(&mut self, client: Option<&Client>) -> Result<(), AppError> {
140        if !self.auto_close {
141            return Ok(());
142        }
143        self.prune()?;
144        self.idle_since = None;
145        if let Some(client) = client
146            && !self.clients.contains(&client.token)
147        {
148            self.clients.push(client.token.clone());
149        }
150        // Also protect in-flight commands after their application's pipe closes,
151        // and standalone CLI users sharing this browser (including long-lived TUIs).
152        let (identity, _) = system::lease_process(std::process::id())?;
153        if !self.users.contains(&identity) {
154            self.users.push(identity);
155        }
156        Ok(())
157    }
158}
159
160/// Register a reader before it can connect a CDP socket. Persistent sessions need no lease.
161pub fn attach(port: u16) -> Result<bool, AppError> {
162    let dir = directory(port)?;
163    if !dir.try_exists()? {
164        return Ok(false);
165    }
166    validate_directory(&dir)?;
167    match load(&dir, port)? {
168        None => return Ok(false),
169        Some(state) if !state.lifetime.auto_close => return Ok(true),
170        _ => {}
171    }
172    let lock = OpenOptions::new()
173        .create(true)
174        .truncate(false)
175        .read(true)
176        .write(true)
177        .mode(0o600)
178        .open(dir.join("connection.lock"))?;
179    let deadline = std::time::Instant::now() + Duration::from_secs(25);
180    loop {
181        match lock.try_lock() {
182            Ok(()) => break,
183            Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => {
184                std::thread::sleep(Duration::from_millis(20))
185            }
186            Err(std::fs::TryLockError::WouldBlock) => {
187                return Err(failed(
188                    "another browser connection command is running; retry shortly",
189                ));
190            }
191            Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
192        }
193    }
194    let _guard = Guard(lock);
195    if let Some(mut state) = load(&dir, port)?
196        && state.lifetime.auto_close
197    {
198        let client = current_client()?;
199        state.lifetime.touch(client.as_ref())?;
200        save(&dir, &state)?;
201    }
202    Ok(load(&dir, port)?.is_some())
203}
204
205async fn sweep_session(path: &Path, port: u16) -> Result<(bool, bool), AppError> {
206    // Do not follow arbitrary directories while enumerating saved sessions.
207    validate_directory(path)?;
208    let Some(state) = load(path, port)? else {
209        return Ok((false, false));
210    };
211    if !state.lifetime.auto_close {
212        return Ok((false, false));
213    }
214    let result = manage_inner(
215        port,
216        DesktopAccount::Auto,
217        Action::Recover,
218        false,
219        state.core,
220        true,
221    )
222    .await?;
223    Ok((result["pending"] == true, result["grace"] == true))
224}
225
226#[derive(Default)]
227struct Sweeper {
228    retries: HashMap<PathBuf, (std::time::Instant, u64, u8)>,
229}
230impl Sweeper {
231    async fn sweep(&mut self, closing: bool) -> Result<(bool, bool, bool), AppError> {
232        let base = data_directory()?;
233        let mut pending = false;
234        let mut failed = false;
235        let mut grace = false;
236        for entry in std::fs::read_dir(&base)? {
237            let entry = match entry {
238                Ok(entry) => entry,
239                Err(_) => {
240                    failed = true;
241                    continue;
242                }
243            };
244            let Some(port) = entry
245                .file_name()
246                .to_str()
247                .and_then(|s| s.parse::<u16>().ok())
248            else {
249                continue;
250            };
251            let path = entry.path();
252            if let Some((until, _, attempts)) = self.retries.get(&path)
253                && ((closing && *attempts >= 3) || *until > std::time::Instant::now())
254            {
255                failed = true;
256                pending |= closing && *attempts < 3;
257                continue;
258            }
259            match sweep_session(&path, port).await {
260                Ok((waiting, in_grace)) => {
261                    pending |= waiting;
262                    grace |= in_grace;
263                    self.retries.remove(&path);
264                }
265                Err(_) => {
266                    failed = true;
267                    let delay = self
268                        .retries
269                        .get(&path)
270                        .map_or(2, |(_, delay, _)| (delay * 2).min(60));
271                    let attempts = self
272                        .retries
273                        .get(&path)
274                        .map_or(1, |(_, _, attempts)| attempts.saturating_add(1));
275                    pending |= closing && attempts < 3;
276                    self.retries.insert(
277                        path,
278                        (
279                            std::time::Instant::now() + Duration::from_secs(delay),
280                            delay,
281                            attempts,
282                        ),
283                    );
284                }
285            }
286        }
287        // One damaged record must never prevent cleanup of independent browser sessions.
288        // Registrations are immutable and unguessable. Only remove a record whose
289        // owner is positively gone; never let a damaged entry block other sessions.
290        if let Ok(entries) = std::fs::read_dir(clients_directory()?) {
291            for entry in entries.flatten() {
292                if let Some(token) = entry.file_name().to_str().filter(|s| valid_token(s))
293                    && matches!(client(token), Ok(None))
294                {
295                    let _ = std::fs::remove_file(entry.path());
296                }
297            }
298        }
299        Ok((pending, failed, grace))
300    }
301}
302
303/// Protocol 1: a single JSON ready line, then stdin stays open for the client's life.
304/// The caller must give this process its own process group and must not inherit its
305/// stdin into other children. Closing stdin requests cleanup; stdout EOF confirms it.
306pub async fn supervise(keep_alive: bool) -> Result<(), AppError> {
307    if std::io::stdin().is_terminal() {
308        return Err(crate::error::AppError::InvalidInput(
309            "desktop supervise requires a piped stdin owned by the calling application".into(),
310        ));
311    }
312    let base = data_directory()?;
313    prepare_directory(&base)?;
314    let dir = clients_directory()?;
315    prepare_directory(&dir)?;
316    let token = format!("{:032x}", rand::random::<u128>());
317    let record = Client {
318        token: token.clone(),
319        owner: system::lease_process(std::process::id())?.0,
320        keep_alive,
321    };
322    let path = dir.join(&token);
323    let mut file = OpenOptions::new()
324        .write(true)
325        .create_new(true)
326        .mode(0o600)
327        .open(&path)?;
328    file.write_all(
329        &serde_json::to_vec(&record).map_err(|_| failed("invalid browser supervisor record"))?,
330    )?;
331    file.sync_all()?;
332    // Drop removes the registration even when stdout is closed during startup.
333    struct Registration(PathBuf);
334    impl Drop for Registration {
335        fn drop(&mut self) {
336            let _ = std::fs::remove_file(&self.0);
337        }
338    }
339    let registration = Registration(path);
340    writeln!(
341        std::io::stdout(),
342        "{}",
343        json!({"protocol": 1, "client": token})
344    )?;
345    std::io::stdout().flush()?;
346    let mut sweeper = Sweeper::default();
347    let mut input = tokio::io::stdin();
348    let mut byte = [0; 1];
349    let mut timer = tokio::time::interval(Duration::from_secs(2));
350    timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
351    loop {
352        tokio::select! {
353            read = input.read(&mut byte) => {
354                if read? == 0 { break; }
355            }
356            _ = timer.tick() => {
357                // Recover abandoned leases left by a killed supervisor as well.
358                // Fail closed on verification errors; do not repeatedly emit diagnostics.
359                let _ = sweeper.sweep(false).await;
360            }
361        }
362    }
363    drop(registration);
364    // Start a bounded retry budget afresh; background errors may have recovered.
365    sweeper.retries.clear();
366    loop {
367        let (pending, failed_cleanup, grace) = sweeper.sweep(true).await?;
368        if !pending {
369            if failed_cleanup {
370                return Err(failed(
371                    "Some browser sessions could not be closed safely. Their records and sign-in were preserved; use `teams desktop status` and `teams desktop recover`.",
372                ));
373            }
374            break;
375        }
376        tokio::time::sleep(if grace {
377            Duration::from_millis(250)
378        } else {
379            Duration::from_secs(2)
380        })
381        .await;
382    }
383    Ok(())
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn inspection_failure_does_not_expire_a_live_lease() {
392        let identity = Identity {
393            pid: std::process::id(),
394            started: "test".into(),
395            executable: PathBuf::from("/test"),
396        };
397        assert!(live_with(&identity, |_| Err(failed("inspection unavailable"))).is_err());
398        assert!(live_with(&identity, |_| Ok((identity.clone(), vec![]))).unwrap());
399        let upgraded = Identity {
400            executable: PathBuf::from("/test (deleted)"),
401            ..identity.clone()
402        };
403        assert!(live_with(&identity, |_| Ok((upgraded, vec![]))).unwrap());
404        let changed = Identity {
405            started: "other".into(),
406            ..identity.clone()
407        };
408        assert!(!live_with(&identity, |_| Ok((changed, vec![]))).unwrap());
409    }
410
411    #[test]
412    fn a_reused_pid_is_expired_even_when_its_new_owner_has_another_uid() {
413        // PID 1 belongs to the system on ordinary hosts. If procfs hides it,
414        // uncertainty remains conservative instead of blocking other readers.
415        let old = Identity {
416            pid: 1,
417            started: "not the current process start".into(),
418            executable: PathBuf::new(),
419        };
420        if system::lease_process(1).is_ok() {
421            assert!(!live(&old).unwrap());
422        }
423    }
424
425    #[test]
426    fn a_real_zombie_expires_without_reaping_it() {
427        let mut child = Command::new("/bin/sh")
428            .args(["-c", "read line"])
429            .stdin(Stdio::piped())
430            .spawn()
431            .unwrap();
432        let identity = system::lease_process(child.id()).unwrap().0;
433        drop(child.stdin.take());
434        let deadline = std::time::Instant::now() + Duration::from_secs(5);
435        let expired = loop {
436            if matches!(live(&identity), Ok(false)) {
437                break true;
438            }
439            if std::time::Instant::now() >= deadline {
440                break false;
441            }
442            std::thread::sleep(Duration::from_millis(10));
443        };
444        child.wait().unwrap();
445        assert!(
446            expired,
447            "an unreaped process must not retain a browser lease"
448        );
449    }
450
451    #[test]
452    fn inspection_failure_expires_a_process_confirmed_gone() {
453        let mut child = Command::new("/bin/sh")
454            .args(["-c", "exit 0"])
455            .spawn()
456            .unwrap();
457        let identity = Identity {
458            pid: child.id(),
459            started: "test".into(),
460            executable: PathBuf::from("/bin/sh"),
461        };
462        child.wait().unwrap();
463        assert!(!live_with(&identity, |_| Err(failed("process gone"))).unwrap());
464    }
465}