1use 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 Ok((current, _)) => Ok(current.pid == identity.pid && current.started == identity.started),
42 Err(error) => {
43 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 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 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 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 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
160pub 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 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 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
303pub 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 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 let _ = sweeper.sweep(false).await;
360 }
361 }
362 }
363 drop(registration);
364 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 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}