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.
- {teams_cli-0.2.2 → teams_cli-0.2.3}/CHANGELOG.md +7 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/Cargo.lock +1 -1
- {teams_cli-0.2.2 → teams_cli-0.2.3}/Cargo.toml +1 -1
- {teams_cli-0.2.2 → teams_cli-0.2.3}/PKG-INFO +17 -2
- {teams_cli-0.2.2 → teams_cli-0.2.3}/README.md +16 -1
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/cli.rs +6 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser.rs +197 -12
- teams_cli-0.2.3/src/desktop/browser_children.rs +455 -0
- teams_cli-0.2.3/src/desktop/browser_lifetime.rs +465 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop.rs +35 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/main.rs +12 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/schema.rs +18 -2
- teams_cli-0.2.3/tests/browser_lifetime.rs +993 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/ci.yml +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/dependencies.yml +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/.github/workflows/release.yml +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/.gitignore +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/.mise.toml +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/LICENSE +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/Makefile +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/build.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/docs/releases.md +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/examples/validate_schema.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/pyproject.toml +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/scripts/smoke-release.py +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/pass.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/process.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/wsl.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth/wsl_credentials.ps1 +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/auth.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/backend.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/config.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/delivery.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/avatars.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_core.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_install.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/browser_setup.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/cdp.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/connection.ps1 +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/connection.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/helper.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/install.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/macos.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/owner.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/session.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop/windows.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop.js +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_cache.js +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_core.js +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_people.js +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/desktop_send.js +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/error.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/graph.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/lib.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/output.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/render.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/setup/capabilities.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/setup.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/teams_cli_alias.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/src/tui.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/cli.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/connection.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/connection.test.ps1 +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/credentials.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop_scripts.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/desktop_scripts.test.cjs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/fixtures/clispec-v0.3.json +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/js/people.test.mjs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/package_alias.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/pass_credentials.sh +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/release_smoke.rs +0 -0
- {teams_cli-0.2.2 → teams_cli-0.2.3}/tests/wsl_credentials.test.ps1 +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: teams-cli
|
|
3
|
-
Version: 0.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
|
|
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
|
|
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
|
-
//!
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
//
|
|
752
|
-
//
|
|
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
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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());
|