teams-cli 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {teams_cli-0.2.2 → teams_cli-0.2.3}/CHANGELOG.md +7 -0
  2. {teams_cli-0.2.2 → teams_cli-0.2.3}/Cargo.lock +1 -1
  3. {teams_cli-0.2.2 → teams_cli-0.2.3}/Cargo.toml +1 -1
  4. {teams_cli-0.2.2 → teams_cli-0.2.3}/PKG-INFO +17 -2
  5. {teams_cli-0.2.2 → teams_cli-0.2.3}/README.md +16 -1
  6. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/cli.rs +6 -0
  7. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser.rs +197 -12
  8. teams_cli-0.2.3/src/desktop/browser_children.rs +455 -0
  9. teams_cli-0.2.3/src/desktop/browser_lifetime.rs +465 -0
  10. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop.rs +35 -0
  11. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/main.rs +12 -0
  12. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/schema.rs +18 -2
  13. teams_cli-0.2.3/tests/browser_lifetime.rs +993 -0
  14. {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/ci.yml +0 -0
  15. {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/dependencies.yml +0 -0
  16. {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/release.yml +0 -0
  17. {teams_cli-0.2.2 → teams_cli-0.2.3}/.gitignore +0 -0
  18. {teams_cli-0.2.2 → teams_cli-0.2.3}/.mise.toml +0 -0
  19. {teams_cli-0.2.2 → teams_cli-0.2.3}/LICENSE +0 -0
  20. {teams_cli-0.2.2 → teams_cli-0.2.3}/Makefile +0 -0
  21. {teams_cli-0.2.2 → teams_cli-0.2.3}/build.rs +0 -0
  22. {teams_cli-0.2.2 → teams_cli-0.2.3}/docs/releases.md +0 -0
  23. {teams_cli-0.2.2 → teams_cli-0.2.3}/examples/validate_schema.rs +0 -0
  24. {teams_cli-0.2.2 → teams_cli-0.2.3}/pyproject.toml +0 -0
  25. {teams_cli-0.2.2 → teams_cli-0.2.3}/scripts/smoke-release.py +0 -0
  26. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/pass.rs +0 -0
  27. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/process.rs +0 -0
  28. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/wsl.rs +0 -0
  29. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/wsl_credentials.ps1 +0 -0
  30. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth.rs +0 -0
  31. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/backend.rs +0 -0
  32. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/config.rs +0 -0
  33. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/delivery.rs +0 -0
  34. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/avatars.rs +0 -0
  35. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_core.rs +0 -0
  36. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_install.rs +0 -0
  37. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_setup.rs +0 -0
  38. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/cdp.rs +0 -0
  39. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/connection.ps1 +0 -0
  40. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/connection.rs +0 -0
  41. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/helper.rs +0 -0
  42. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/install.rs +0 -0
  43. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/macos.rs +0 -0
  44. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/owner.rs +0 -0
  45. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/session.rs +0 -0
  46. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/windows.rs +0 -0
  47. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop.js +0 -0
  48. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_cache.js +0 -0
  49. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_core.js +0 -0
  50. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_people.js +0 -0
  51. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_send.js +0 -0
  52. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/error.rs +0 -0
  53. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/graph.rs +0 -0
  54. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/lib.rs +0 -0
  55. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/output.rs +0 -0
  56. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/render.rs +0 -0
  57. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/setup/capabilities.rs +0 -0
  58. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/setup.rs +0 -0
  59. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/teams_cli_alias.rs +0 -0
  60. {teams_cli-0.2.2 → teams_cli-0.2.3}/src/tui.rs +0 -0
  61. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/cli.rs +0 -0
  62. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/connection.rs +0 -0
  63. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/connection.test.ps1 +0 -0
  64. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/credentials.rs +0 -0
  65. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop.rs +0 -0
  66. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop_scripts.rs +0 -0
  67. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop_scripts.test.cjs +0 -0
  68. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/fixtures/clispec-v0.3.json +0 -0
  69. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/js/people.test.mjs +0 -0
  70. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/package_alias.rs +0 -0
  71. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/pass_credentials.sh +0 -0
  72. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/release_smoke.rs +0 -0
  73. {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/wsl_credentials.test.ps1 +0 -0
  74. {teams_cli-0.2.2 → teams_cli-0.2.3}/vership.toml +0 -0
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.3](https://github.com/rvben/teams-cli/compare/v0.2.2...v0.2.3) - 2026-10-16
11
+
12
+ ### Fixed
13
+
14
+ - Close extension-managed browser sessions after the last client exits, including crashes and editor reloads, while retaining saved sign-in and browsers started independently.
15
+ - Track shared clients and active CLI reads before shutdown. Recover interrupted cleanup and stop individually verified browser helpers, including hung processes, without signalling unrelated applications.
16
+
10
17
  ## [0.2.2](https://github.com/rvben/teams-cli/compare/v0.2.1...v0.2.2) - 2026-10-15
11
18
 
12
19
  ### Added
@@ -3187,7 +3187,7 @@ dependencies = [
3187
3187
 
3188
3188
  [[package]]
3189
3189
  name = "teams-cli"
3190
- version = "0.2.2"
3190
+ version = "0.2.3"
3191
3191
  dependencies = [
3192
3192
  "assert_cmd",
3193
3193
  "base64 0.23.1",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -544,7 +544,22 @@ profile. Different ports use different browser profiles. Disconnect closes this
544
544
  browser and keeps its sign-in data. To switch between visible and headless operation,
545
545
  disconnect first. `desktop recover` closes a verified managed browser or clears a
546
546
  stale record when no listener remains. A verified browser that does not exit after
547
- closing its debugging port is stopped; unverifiable processes are left untouched.
547
+ closing its debugging port is stopped. Verified helpers are recorded before shutdown
548
+ and closed individually if they outlive it; unverifiable processes are left untouched.
549
+
550
+ Standalone CLI browser sessions persist until `desktop disconnect`. Applications can
551
+ instead use `desktop supervise`: keep its stdin pipe open, read its protocol-1 JSON
552
+ handshake, and pass the returned `client` token as `TEAMS_DESKTOP_BROWSER_CLIENT` to
553
+ CLI commands and sign-in terminals. Browsers those commands start close when their
554
+ last application user exits, including after a parent-process crash, with a 15-second
555
+ grace period so a reloaded application can reclaim its session. Existing
556
+ persistent sessions are left running, and in-flight CLI commands finish before
557
+ cleanup. The supervisor must run in its own process group, with its stdin pipe held
558
+ only by the application. Sign-in data is retained. `--keep-alive` opts newly started
559
+ sessions into persistent operation; `TEAMS_DESKTOP_BROWSER_KEEP_ALIVE=1` or `0` can
560
+ override that policy for subsequent launches without restarting the supervisor.
561
+ Explicit `desktop connect` outside supervision makes a shared session persistent.
562
+ Only macOS and Linux support this integration protocol.
548
563
 
549
564
  The CLI checks that every listener is loopback-only and belongs to the recorded
550
565
  browser process, running as you with the expected executable, debugging port and
@@ -528,7 +528,22 @@ profile. Different ports use different browser profiles. Disconnect closes this
528
528
  browser and keeps its sign-in data. To switch between visible and headless operation,
529
529
  disconnect first. `desktop recover` closes a verified managed browser or clears a
530
530
  stale record when no listener remains. A verified browser that does not exit after
531
- closing its debugging port is stopped; unverifiable processes are left untouched.
531
+ closing its debugging port is stopped. Verified helpers are recorded before shutdown
532
+ and closed individually if they outlive it; unverifiable processes are left untouched.
533
+
534
+ Standalone CLI browser sessions persist until `desktop disconnect`. Applications can
535
+ instead use `desktop supervise`: keep its stdin pipe open, read its protocol-1 JSON
536
+ handshake, and pass the returned `client` token as `TEAMS_DESKTOP_BROWSER_CLIENT` to
537
+ CLI commands and sign-in terminals. Browsers those commands start close when their
538
+ last application user exits, including after a parent-process crash, with a 15-second
539
+ grace period so a reloaded application can reclaim its session. Existing
540
+ persistent sessions are left running, and in-flight CLI commands finish before
541
+ cleanup. The supervisor must run in its own process group, with its stdin pipe held
542
+ only by the application. Sign-in data is retained. `--keep-alive` opts newly started
543
+ sessions into persistent operation; `TEAMS_DESKTOP_BROWSER_KEEP_ALIVE=1` or `0` can
544
+ override that policy for subsequent launches without restarting the supervisor.
545
+ Explicit `desktop connect` outside supervision makes a shared session persistent.
546
+ Only macOS and Linux support this integration protocol.
532
547
 
533
548
  The CLI checks that every listener is loopback-only and belongs to the recorded
534
549
  browser process, running as you with the expected executable, debugging port and
@@ -294,6 +294,12 @@ pub struct TuiArgs {
294
294
 
295
295
  #[derive(Debug, Subcommand)]
296
296
  pub enum DesktopCommand {
297
+ /// Supervise application-owned browsers until stdin closes (integration protocol)
298
+ Supervise {
299
+ /// Preserve newly started browsers after the application exits
300
+ #[arg(long)]
301
+ keep_alive: bool,
302
+ },
297
303
  /// Inspect endpoint metadata and managed-session recovery state
298
304
  Status(DesktopArgs),
299
305
  /// Open a debugging session (native Teams restart or dedicated browser launch)
@@ -1,6 +1,7 @@
1
1
  //! A dedicated Chromium profile. Verification precedes every page connection; closing
2
2
  //! uses the verified browser's DevTools socket, never a signal to an arbitrary PID. Only
3
- //! the verified browser is signalled, when it does not exit after closing its endpoint.
3
+ //! verified browser and its recorded helpers are signalled individually if they
4
+ //! outlive shutdown. Process groups and unrelated applications are never signalled.
4
5
  use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, connection::Action, session::Verifier};
5
6
  use crate::{config::DesktopAccount, error::AppError};
6
7
  use serde::{Deserialize, Serialize};
@@ -15,6 +16,11 @@ use std::{
15
16
  time::Duration,
16
17
  };
17
18
 
19
+ #[path = "browser_children.rs"]
20
+ mod children;
21
+ #[path = "browser_lifetime.rs"]
22
+ pub mod lifetime;
23
+
18
24
  pub struct BrowserSystem {
19
25
  pub core: bool,
20
26
  }
@@ -35,6 +41,11 @@ struct State {
35
41
  #[serde(default)]
36
42
  core: bool,
37
43
  phase: String,
44
+ // Missing fields preserve the persistent behavior of older records.
45
+ #[serde(default)]
46
+ lifetime: lifetime::Lifetime,
47
+ #[serde(default)]
48
+ children: Vec<children::Child>,
38
49
  }
39
50
 
40
51
  fn failed(message: &str) -> AppError {
@@ -713,11 +724,21 @@ pub async fn manage(
713
724
  action: Action,
714
725
  visible: bool,
715
726
  core: bool,
727
+ ) -> Result<Value, AppError> {
728
+ manage_inner(port, account, action, visible, core, false).await
729
+ }
730
+
731
+ async fn manage_inner(
732
+ port: u16,
733
+ account: DesktopAccount,
734
+ action: Action,
735
+ visible: bool,
736
+ core: bool,
737
+ cleanup: bool,
716
738
  ) -> Result<Value, AppError> {
717
739
  if core && matches!(action, Action::Connect) {
718
740
  super::browser_install::ensure().await?;
719
741
  }
720
- prerequisites_for(core)?;
721
742
  let dir = directory(port)?;
722
743
  prepare_directory(&dir)?;
723
744
  prepare_directory(&profile(&dir))?;
@@ -728,10 +749,69 @@ pub async fn manage(
728
749
  .write(true)
729
750
  .mode(0o600)
730
751
  .open(dir.join("connection.lock"))?;
731
- lock.try_lock()
732
- .map_err(|_| failed("another browser connection command is running"))?;
752
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(25);
753
+ loop {
754
+ match lock.try_lock() {
755
+ Ok(()) => break,
756
+ Err(std::fs::TryLockError::WouldBlock) if cleanup => {
757
+ return Ok(json!({"pending": true}));
758
+ }
759
+ Err(std::fs::TryLockError::WouldBlock) if tokio::time::Instant::now() < deadline => {
760
+ tokio::time::sleep(Duration::from_millis(20)).await
761
+ }
762
+ Err(std::fs::TryLockError::WouldBlock) => {
763
+ return Err(failed(
764
+ "another browser connection command is running; retry shortly",
765
+ ));
766
+ }
767
+ Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
768
+ }
769
+ }
733
770
  let _guard = Guard(lock);
734
771
  let mut state = load(&dir, port)?;
772
+ if cleanup {
773
+ let Some(current) = state.as_mut() else {
774
+ return Ok(json!({}));
775
+ };
776
+ current.lifetime.prune()?;
777
+ if !current.lifetime.auto_close || !current.lifetime.clients.is_empty() {
778
+ return Ok(json!({}));
779
+ }
780
+ if !current.lifetime.users.is_empty() {
781
+ return Ok(json!({"pending": true}));
782
+ }
783
+ // Reloads and extension updates replace the client process. Give the new
784
+ // host time to attach before closing the shared browser.
785
+ let now = std::time::SystemTime::now()
786
+ .duration_since(std::time::UNIX_EPOCH)
787
+ .map_err(|_| failed("could not establish the browser shutdown grace period"))?
788
+ .as_secs();
789
+ if current.lifetime.idle_since.is_none() {
790
+ current.lifetime.idle_since = Some(now);
791
+ save(&dir, current)?;
792
+ }
793
+ let idle = current.lifetime.idle_since.expect("saved idle time");
794
+ if now.saturating_sub(idle) < 15 {
795
+ return Ok(json!({"pending": true, "grace": true}));
796
+ }
797
+ } else if matches!(action, Action::Status | Action::Connect) {
798
+ let client = lifetime::current_client()?;
799
+ if let Some(current) = state.as_mut() {
800
+ let before =
801
+ serde_json::to_value(&*current).map_err(|_| failed("invalid browser state"))?;
802
+ current.lifetime.touch(client.as_ref())?;
803
+ // Explicitly connecting from a terminal requests a persistent session.
804
+ if matches!(action, Action::Connect) && client.is_none() {
805
+ current.lifetime.auto_close = false;
806
+ }
807
+ if serde_json::to_value(&*current).map_err(|_| failed("invalid browser state"))?
808
+ != before
809
+ {
810
+ save(&dir, current)?;
811
+ }
812
+ }
813
+ }
814
+ prerequisites_for(core)?;
735
815
  let system = BrowserSystem { core };
736
816
  let recover = format!(
737
817
  "teams desktop recover --desktop-client {} --port {port}",
@@ -748,13 +828,14 @@ pub async fn manage(
748
828
  if core {
749
829
  validate_core(account)?;
750
830
  }
751
- // A dead/reused owner with an empty port can be forgotten without
752
- // touching a process. Unrecorded startups and occupied ports still refuse.
831
+ // Finish saved helpers before forgetting a dead/reused owner.
832
+ // Unrecorded startups and occupied ports still refuse.
753
833
  if state
754
834
  .as_ref()
755
835
  .is_some_and(|s| s.owner.is_some() && !owned(s, &dir))
756
836
  && system::listeners(port)?.is_empty()
757
837
  {
838
+ children::finish(state.as_ref().expect("saved state")).await?;
758
839
  std::fs::remove_file(dir.join("connection.json"))?;
759
840
  state = None;
760
841
  }
@@ -783,6 +864,8 @@ pub async fn manage(
783
864
  visible,
784
865
  core,
785
866
  phase: "starting".into(),
867
+ lifetime: lifetime::Lifetime::for_launch()?,
868
+ children: vec![],
786
869
  };
787
870
  save(&dir, &next)?;
788
871
  let mut child = Command::new(session_executable(core, visible)?)
@@ -865,6 +948,19 @@ pub async fn manage(
865
948
  save(&dir, current)?;
866
949
  }
867
950
  }
951
+ if let Some(current) = state.as_mut()
952
+ && owned(current, &dir)
953
+ {
954
+ let captured = children::capture(current, &dir)?;
955
+ for child in captured {
956
+ if !current.children.contains(&child) {
957
+ current.children.push(child);
958
+ }
959
+ }
960
+ // Persist before contacting Chrome, so a killed cleanup process can
961
+ // safely finish individual helpers after the browser parent exits.
962
+ save(&dir, current)?;
963
+ }
868
964
  if let Some(current) = state.as_ref() {
869
965
  if owned(current, &dir) && system::listeners(port)?.is_empty() {
870
966
  // It already released its endpoint, as after a close request it never
@@ -872,20 +968,36 @@ pub async fn manage(
872
968
  stop(current, &dir).await?;
873
969
  } else if owned(current, &dir) {
874
970
  let (metadata, _) = system.verify(port).await?;
875
- if !metadata.endpoint_available() {
971
+ if metadata.teams_owned != Some(true) || metadata.loopback_only != Some(true) {
876
972
  return Err(failed(&format!(
877
973
  "browser ownership could not be verified; close the dedicated browser manually, then run `{recover}`"
878
974
  )));
879
975
  }
880
- let (version, _) = super::read_metadata(port).await?;
881
- let id = browser_id(&version, port)?;
882
- if !owned(current, &dir) {
883
- return Err(failed("the browser process changed; it was left untouched"));
976
+ // OS ownership remains authoritative when Chrome's HTTP/CDP
977
+ // event loop is hung. Never send an unsafe control URL, but give
978
+ // this exact verified process a bounded chance before PID fallback.
979
+ let close = async {
980
+ if !metadata.endpoint_available() {
981
+ return Err(failed("the verified browser endpoint is unresponsive"));
982
+ }
983
+ let (version, _) = super::read_metadata(port).await?;
984
+ let id = browser_id(&version, port)?;
985
+ if !owned(current, &dir) {
986
+ return Err(failed(
987
+ "the browser process changed; it was left untouched",
988
+ ));
989
+ }
990
+ Cdp::close_browser(port, &id).await
884
991
  }
885
- Cdp::close_browser(port, &id).await?;
992
+ .await;
886
993
  if !exited(current, &dir, Duration::from_secs(10)).await {
887
994
  stop(current, &dir).await?;
888
995
  }
996
+ if close.is_err() && !system::listeners(port)?.is_empty() {
997
+ return Err(failed(
998
+ "browser shutdown could not be confirmed; its recovery record was preserved",
999
+ ));
1000
+ }
889
1001
  } else if current.owner.is_some() {
890
1002
  // A reused or changed PID is never contacted or signalled. Recover
891
1003
  // clears stale state only when there is no listener left on its port.
@@ -901,6 +1013,7 @@ pub async fn manage(
901
1013
  "browser startup was interrupted before ownership was saved; close the dedicated browser manually, then recover",
902
1014
  ));
903
1015
  }
1016
+ children::finish(current).await?;
904
1017
  std::fs::remove_file(dir.join("connection.json"))?;
905
1018
  state = None;
906
1019
  } else if !system::listeners(port)?.is_empty() {
@@ -918,6 +1031,7 @@ pub async fn manage(
918
1031
  "management":{"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),
919
1032
  "phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),
920
1033
  "visible":state.as_ref().map(|s|s.visible),"core_only":core,"settings_pending":false,
1034
+ "auto_close":state.as_ref().is_some_and(|s|s.lifetime.auto_close),
921
1035
  "needs_recovery":state.as_ref().is_some_and(|s|!matches!(s.phase.as_str(), "active" | "core_ready") || !live)}}),
922
1036
  )
923
1037
  }
@@ -1031,6 +1145,34 @@ mod system {
1031
1145
  }
1032
1146
  Ok(sockets)
1033
1147
  }
1148
+ pub fn lease_process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1149
+ // Lease liveness requires only the kernel start time. Executable and uid
1150
+ // restrictions would hide PID reuse by another user's/non-dumpable process.
1151
+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
1152
+ if stat
1153
+ .rsplit_once(") ")
1154
+ .and_then(|(_, rest)| rest.split_whitespace().next())
1155
+ .is_some_and(|state| matches!(state, "Z" | "X"))
1156
+ {
1157
+ return Err(failed("lease owner has exited"));
1158
+ }
1159
+ Ok((
1160
+ Identity {
1161
+ pid,
1162
+ started: start_time(&stat)?,
1163
+ executable: PathBuf::new(),
1164
+ },
1165
+ vec![],
1166
+ ))
1167
+ }
1168
+ pub fn zombie(pid: u32) -> Result<bool, AppError> {
1169
+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
1170
+ let state = stat
1171
+ .rsplit_once(") ")
1172
+ .and_then(|(_, rest)| rest.split_whitespace().next())
1173
+ .ok_or_else(|| failed("invalid Linux process state"))?;
1174
+ Ok(matches!(state, "Z" | "X"))
1175
+ }
1034
1176
  pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1035
1177
  let dir = PathBuf::from(format!("/proc/{pid}"));
1036
1178
  let uid = std::fs::metadata("/proc/self")?.uid();
@@ -1160,6 +1302,45 @@ mod system {
1160
1302
  .collect()
1161
1303
  })
1162
1304
  }
1305
+ pub fn lease_process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1306
+ // Force a stable representation across terminal/VS Code locales and zones.
1307
+ // Browser ownership keeps its legacy representation for existing records.
1308
+ let output = Command::new("/bin/ps")
1309
+ .args(["-p", &pid.to_string(), "-o", "uid=,stat=,lstart="])
1310
+ .env("LC_ALL", "C")
1311
+ .env("TZ", "UTC")
1312
+ .stdin(Stdio::null())
1313
+ .output()?;
1314
+ if !output.status.success() {
1315
+ return Err(failed("macOS lease process inspection failed"));
1316
+ }
1317
+ let text = String::from_utf8(output.stdout)
1318
+ .map_err(|_| failed("invalid lease process identity"))?;
1319
+ let (_uid, rest) = text
1320
+ .trim()
1321
+ .split_once(char::is_whitespace)
1322
+ .ok_or_else(|| failed("invalid lease process identity"))?;
1323
+ let (state, started) = rest
1324
+ .trim_start()
1325
+ .split_once(char::is_whitespace)
1326
+ .ok_or_else(|| failed("invalid lease process state"))?;
1327
+ if state.starts_with('Z') {
1328
+ return Err(failed("lease owner has exited"));
1329
+ }
1330
+ Ok((
1331
+ Identity {
1332
+ pid,
1333
+ started: started.trim().into(),
1334
+ executable: PathBuf::new(),
1335
+ },
1336
+ vec![],
1337
+ ))
1338
+ }
1339
+ pub fn zombie(pid: u32) -> Result<bool, AppError> {
1340
+ Ok(run("/bin/ps", &["-p", &pid.to_string(), "-o", "stat="])?
1341
+ .trim_start()
1342
+ .starts_with('Z'))
1343
+ }
1163
1344
  pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1164
1345
  let snapshot = run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])?;
1165
1346
  let (uid, stamp) = snapshot
@@ -1560,6 +1741,8 @@ mod tests {
1560
1741
  visible: true,
1561
1742
  core: false,
1562
1743
  phase: "starting".into(),
1744
+ lifetime: lifetime::Lifetime::default(),
1745
+ children: vec![],
1563
1746
  };
1564
1747
  save(dir.path(), &state).unwrap();
1565
1748
  assert!(load(dir.path(), 9222).is_err());
@@ -1620,6 +1803,8 @@ mod tests {
1620
1803
  visible: false,
1621
1804
  core: false,
1622
1805
  phase: "active".into(),
1806
+ lifetime: lifetime::Lifetime::default(),
1807
+ children: vec![],
1623
1808
  };
1624
1809
  assert!(owned(&state, &dir));
1625
1810
  let reaper = std::thread::spawn(move || child.wait());