susops 3.0.0rc3.dev9__tar.gz → 3.0.0rc5.dev1__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.
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/PKG-INFO +18 -6
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/README.md +17 -5
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/pyproject.toml +1 -1
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/client.py +74 -9
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/config.py +10 -18
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/pac.py +12 -2
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/rpc_server.py +10 -1
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/services_daemon.py +86 -32
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ssh.py +42 -2
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/types.py +5 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/facade.py +394 -228
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/base.py +4 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/linux.py +5 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/mac.py +5 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/__init__.py +11 -3
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/connections.py +17 -7
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/dashboard.py +33 -9
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/PKG-INFO +18 -6
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_client.py +26 -6
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_config.py +37 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_facade.py +150 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_pac.py +24 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_services_daemon.py +70 -12
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/LICENSE +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/setup.cfg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/__init__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icon.png +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/error.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/running.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/stopped.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/__init__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/browsers.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/log_style.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ports.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/process.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/rpc_protocol.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/share.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/socat.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ssh_config.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/status.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/__init__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/__init__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/__main__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/app.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/app.tcss +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/cli.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/shares.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/widgets/__init__.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/widgets/connection_card.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/version.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/SOURCES.txt +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/dependency_links.txt +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/entry_points.txt +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/requires.txt +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/top_level.txt +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_bw_totals.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_cli.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_conftest_smoke.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_openapi.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_packaging.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_process.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_rpc_protocol.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_rpc_server.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_scripts.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_share.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_share_aiohttp.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_socat.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_ssh.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_status.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_update_homebrew_sha.py +0 -0
- {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: susops
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0rc5.dev1
|
|
4
4
|
Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -315,7 +315,7 @@ susops
|
|
|
315
315
|
|
|
316
316
|
### Screens
|
|
317
317
|
|
|
318
|
-
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
318
|
+
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
319
319
|
- **Stats** — CPU%, memory, active connections, PID for the selected connection
|
|
320
320
|
- **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
|
|
321
321
|
- **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
|
|
@@ -436,9 +436,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
|
|
|
436
436
|
|------|---------|
|
|
437
437
|
| `0` | Success / all running |
|
|
438
438
|
| `1` | Error |
|
|
439
|
-
| `2` | Partial (some services stopped) |
|
|
439
|
+
| `2` | Partial (some services stopped, or any connection pending) |
|
|
440
440
|
| `3` | All stopped |
|
|
441
441
|
|
|
442
|
+
A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
|
|
443
|
+
|
|
442
444
|
---
|
|
443
445
|
|
|
444
446
|
## System Tray
|
|
@@ -461,7 +463,7 @@ susops-tray
|
|
|
461
463
|
|
|
462
464
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
463
465
|
|
|
464
|
-
The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. Both tray implementations support the same feature set via native dialogs:
|
|
466
|
+
The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected. Both tray implementations support the same feature set via native dialogs:
|
|
465
467
|
|
|
466
468
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
467
469
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -552,7 +554,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
|
|
|
552
554
|
|
|
553
555
|
### Port assignment
|
|
554
556
|
|
|
555
|
-
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
|
|
557
|
+
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
|
|
558
|
+
|
|
559
|
+
### Tag format
|
|
560
|
+
|
|
561
|
+
Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
|
|
556
562
|
|
|
557
563
|
### Workspace
|
|
558
564
|
|
|
@@ -860,6 +866,12 @@ susops
|
|
|
860
866
|
# Run the CLI
|
|
861
867
|
susops ps
|
|
862
868
|
susops ls
|
|
869
|
+
|
|
870
|
+
# Build local pypi + brew artifacts (mirrors CI, no upload)
|
|
871
|
+
scripts/build-local.sh pypi # wheel + sdist
|
|
872
|
+
scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
|
|
873
|
+
scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
874
|
+
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
863
875
|
```
|
|
864
876
|
|
|
865
877
|
### Project layout
|
|
@@ -937,7 +949,7 @@ brew tap mashb1t/susops
|
|
|
937
949
|
brew install susops
|
|
938
950
|
```
|
|
939
951
|
|
|
940
|
-
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment
|
|
952
|
+
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
|
|
941
953
|
|
|
942
954
|
**Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
|
|
943
955
|
|
|
@@ -291,7 +291,7 @@ susops
|
|
|
291
291
|
|
|
292
292
|
### Screens
|
|
293
293
|
|
|
294
|
-
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
294
|
+
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
295
295
|
- **Stats** — CPU%, memory, active connections, PID for the selected connection
|
|
296
296
|
- **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
|
|
297
297
|
- **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
|
|
@@ -412,9 +412,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
|
|
|
412
412
|
|------|---------|
|
|
413
413
|
| `0` | Success / all running |
|
|
414
414
|
| `1` | Error |
|
|
415
|
-
| `2` | Partial (some services stopped) |
|
|
415
|
+
| `2` | Partial (some services stopped, or any connection pending) |
|
|
416
416
|
| `3` | All stopped |
|
|
417
417
|
|
|
418
|
+
A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
|
|
419
|
+
|
|
418
420
|
---
|
|
419
421
|
|
|
420
422
|
## System Tray
|
|
@@ -437,7 +439,7 @@ susops-tray
|
|
|
437
439
|
|
|
438
440
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
439
441
|
|
|
440
|
-
The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. Both tray implementations support the same feature set via native dialogs:
|
|
442
|
+
The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected. Both tray implementations support the same feature set via native dialogs:
|
|
441
443
|
|
|
442
444
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
443
445
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -528,7 +530,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
|
|
|
528
530
|
|
|
529
531
|
### Port assignment
|
|
530
532
|
|
|
531
|
-
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
|
|
533
|
+
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
|
|
534
|
+
|
|
535
|
+
### Tag format
|
|
536
|
+
|
|
537
|
+
Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
|
|
532
538
|
|
|
533
539
|
### Workspace
|
|
534
540
|
|
|
@@ -836,6 +842,12 @@ susops
|
|
|
836
842
|
# Run the CLI
|
|
837
843
|
susops ps
|
|
838
844
|
susops ls
|
|
845
|
+
|
|
846
|
+
# Build local pypi + brew artifacts (mirrors CI, no upload)
|
|
847
|
+
scripts/build-local.sh pypi # wheel + sdist
|
|
848
|
+
scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
|
|
849
|
+
scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
850
|
+
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
839
851
|
```
|
|
840
852
|
|
|
841
853
|
### Project layout
|
|
@@ -913,7 +925,7 @@ brew tap mashb1t/susops
|
|
|
913
925
|
brew install susops
|
|
914
926
|
```
|
|
915
927
|
|
|
916
|
-
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment
|
|
928
|
+
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
|
|
917
929
|
|
|
918
930
|
**Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
|
|
919
931
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "susops"
|
|
7
|
-
version = "3.0.0-
|
|
7
|
+
version = "3.0.0-rc5.dev1"
|
|
8
8
|
description = "SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import os
|
|
14
14
|
import subprocess
|
|
15
15
|
import sys
|
|
16
|
+
import threading
|
|
16
17
|
import time
|
|
17
18
|
import urllib.error
|
|
18
19
|
import urllib.request
|
|
@@ -22,7 +23,22 @@ from typing import Any
|
|
|
22
23
|
from susops.core.rpc_protocol import InvocationRequest, InvocationResponse
|
|
23
24
|
|
|
24
25
|
_WORKSPACE_DEFAULT = Path.home() / ".susops"
|
|
25
|
-
|
|
26
|
+
# 15s rather than 5s gives headroom for slow init paths (ssh agent
|
|
27
|
+
# prompts, network probes) on top of the daemon's "publish port first,
|
|
28
|
+
# restore async" startup ordering. Frontends will still detect a truly
|
|
29
|
+
# dead daemon quickly because they poll the port file every 100ms — the
|
|
30
|
+
# timeout only fires for genuine hangs.
|
|
31
|
+
_DAEMON_SPAWN_TIMEOUT = 15.0
|
|
32
|
+
|
|
33
|
+
# Backoff to avoid pegging CPU and filling logs when the daemon
|
|
34
|
+
# repeatedly exits during startup (e.g. corrupt config, PAC port
|
|
35
|
+
# squatted). On a fast-fail (daemon exits within FAST_FAIL_WINDOW_S of
|
|
36
|
+
# spawn), the next ensure_daemon_running call within BACKOFF_S sleeps
|
|
37
|
+
# until the window expires before retrying.
|
|
38
|
+
_FAST_FAIL_WINDOW_S = 2.0
|
|
39
|
+
_BACKOFF_S = 5.0
|
|
40
|
+
_last_fast_fail: float = 0.0
|
|
41
|
+
_last_fast_fail_lock = threading.Lock()
|
|
26
42
|
|
|
27
43
|
|
|
28
44
|
class DaemonUnavailableError(RuntimeError):
|
|
@@ -44,16 +60,38 @@ def _read_port(workspace: Path) -> int | None:
|
|
|
44
60
|
return None
|
|
45
61
|
|
|
46
62
|
|
|
63
|
+
def _pid_is_susops_daemon(pid: int) -> bool:
|
|
64
|
+
"""True only if PID belongs to a live process whose cmdline matches a
|
|
65
|
+
services_daemon invocation. Defends against PID reuse: SIGKILLing the
|
|
66
|
+
daemon under load can let an ssh fork (or any other short-lived child)
|
|
67
|
+
inherit the freed PID, and a bare `os.kill(pid, 0)` liveness probe
|
|
68
|
+
would then falsely report the daemon as up.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
import psutil
|
|
72
|
+
except ImportError:
|
|
73
|
+
try:
|
|
74
|
+
os.kill(pid, 0)
|
|
75
|
+
return True
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
proc = psutil.Process(pid)
|
|
80
|
+
cmdline = " ".join(proc.cmdline())
|
|
81
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
82
|
+
return False
|
|
83
|
+
return "susops.core.services_daemon" in cmdline or "susops-services" in cmdline
|
|
84
|
+
|
|
85
|
+
|
|
47
86
|
def _is_daemon_alive(workspace: Path) -> bool:
|
|
48
87
|
pid_file = _pid_path(workspace)
|
|
49
88
|
if not pid_file.exists():
|
|
50
89
|
return False
|
|
51
90
|
try:
|
|
52
91
|
pid = int(pid_file.read_text().strip())
|
|
53
|
-
os.kill(pid, 0) # signal 0 = liveness probe
|
|
54
|
-
return True
|
|
55
92
|
except (OSError, ValueError):
|
|
56
93
|
return False
|
|
94
|
+
return _pid_is_susops_daemon(pid)
|
|
57
95
|
|
|
58
96
|
|
|
59
97
|
def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
|
|
@@ -65,6 +103,7 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
|
|
|
65
103
|
(PAC port squatted, peer daemon alive, …) is surfaced to the caller
|
|
66
104
|
instead of getting buried under a generic "didn't come up" message.
|
|
67
105
|
"""
|
|
106
|
+
global _last_fast_fail
|
|
68
107
|
if _is_daemon_alive(workspace):
|
|
69
108
|
port = _read_port(workspace)
|
|
70
109
|
if port:
|
|
@@ -88,12 +127,26 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
|
|
|
88
127
|
"` and start again."
|
|
89
128
|
)
|
|
90
129
|
|
|
130
|
+
# If we recently fast-failed a spawn (daemon exited within
|
|
131
|
+
# _FAST_FAIL_WINDOW_S of starting), wait out the backoff window
|
|
132
|
+
# before respawning. Prevents tight respawn loops when something is
|
|
133
|
+
# structurally broken (corrupt config, PAC port squatted, etc.).
|
|
134
|
+
with _last_fast_fail_lock:
|
|
135
|
+
since_last = time.monotonic() - _last_fast_fail
|
|
136
|
+
if since_last < _BACKOFF_S:
|
|
137
|
+
time.sleep(_BACKOFF_S - since_last)
|
|
138
|
+
|
|
139
|
+
# DEVNULL (not PIPE) for stderr — the daemon writes its log to
|
|
140
|
+
# ~/.susops/logs/susops-services.log directly. Capturing as a pipe
|
|
141
|
+
# would back up once the daemon's log volume exceeded the kernel
|
|
142
|
+
# buffer (~64 KB on macOS) and freeze every subsequent log call.
|
|
143
|
+
spawn_start = time.monotonic()
|
|
91
144
|
proc = subprocess.Popen(
|
|
92
145
|
[sys.executable, "-m", "susops.core.services_daemon",
|
|
93
146
|
"--workspace", str(workspace)],
|
|
94
147
|
stdin=subprocess.DEVNULL,
|
|
95
148
|
stdout=subprocess.DEVNULL,
|
|
96
|
-
stderr=subprocess.
|
|
149
|
+
stderr=subprocess.DEVNULL,
|
|
97
150
|
start_new_session=True,
|
|
98
151
|
)
|
|
99
152
|
|
|
@@ -104,17 +157,29 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
|
|
|
104
157
|
if port:
|
|
105
158
|
return port
|
|
106
159
|
# If the daemon exited (e.g. preflight rejected it), don't keep
|
|
107
|
-
# polling — surface the reason and bail out immediately.
|
|
160
|
+
# polling — surface the reason and bail out immediately. The
|
|
161
|
+
# daemon's log file is the new source of truth for failure
|
|
162
|
+
# reasons (stderr is no longer piped — see Popen above).
|
|
108
163
|
rc = proc.poll()
|
|
109
164
|
if rc is not None:
|
|
165
|
+
# Fast fail = structural problem. Record the timestamp so the
|
|
166
|
+
# next ensure_daemon_running call within _BACKOFF_S sleeps
|
|
167
|
+
# before retrying.
|
|
168
|
+
if time.monotonic() - spawn_start < _FAST_FAIL_WINDOW_S:
|
|
169
|
+
with _last_fast_fail_lock:
|
|
170
|
+
_last_fast_fail = time.monotonic()
|
|
171
|
+
log_path = workspace / "logs" / "susops-services.log"
|
|
172
|
+
tail = ""
|
|
110
173
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
174
|
+
if log_path.exists():
|
|
175
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
176
|
+
lines = f.readlines()[-15:]
|
|
177
|
+
tail = "".join(lines).strip()
|
|
113
178
|
except Exception:
|
|
114
|
-
|
|
179
|
+
pass
|
|
115
180
|
msg = (
|
|
116
181
|
f"Daemon exited during startup (rc={rc})"
|
|
117
|
-
+ (f":\n{
|
|
182
|
+
+ (f":\n{tail}" if tail else "")
|
|
118
183
|
)
|
|
119
184
|
raise DaemonUnavailableError(msg)
|
|
120
185
|
time.sleep(0.1)
|
|
@@ -193,39 +193,32 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
|
|
|
193
193
|
clicks.
|
|
194
194
|
"""
|
|
195
195
|
import os
|
|
196
|
+
import tempfile
|
|
196
197
|
path = get_config_path(workspace)
|
|
197
198
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
199
|
yaml = YAML()
|
|
199
200
|
yaml.default_flow_style = False
|
|
200
201
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
201
|
-
# Convert to plain dict via model_dump, then save
|
|
202
202
|
data = config.model_dump(mode='python')
|
|
203
|
-
# Convert enums to their values for serialization
|
|
204
203
|
data['susops_app']['logo_style'] = config.susops_app.logo_style.value
|
|
205
|
-
# Compute the target mode BEFORE writing: preserve any user-set restrictive
|
|
206
|
-
# permissions (e.g. chmod 600 on a hardened install) since Path.replace()
|
|
207
|
-
# would otherwise clobber them with the temp file's umask-derived mode.
|
|
208
|
-
# New configs default to 0o600 — the file holds share passwords.
|
|
209
204
|
try:
|
|
210
205
|
target_mode = path.stat().st_mode & 0o777
|
|
211
206
|
except OSError:
|
|
212
207
|
target_mode = 0o600
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
|
|
208
|
+
# Unique tmp file per call so concurrent saves (rapid TUI start/stop runs
|
|
209
|
+
# each handler in its own executor thread) don't fight over the same name
|
|
210
|
+
# and crash on tmp.replace(path) after a peer already renamed it away.
|
|
211
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
212
|
+
prefix=path.name + ".",
|
|
213
|
+
suffix=".tmp",
|
|
214
|
+
dir=str(path.parent),
|
|
215
|
+
)
|
|
216
|
+
tmp_path = Path(tmp_name)
|
|
217
217
|
try:
|
|
218
|
-
fd = os.open(
|
|
219
|
-
str(tmp_path),
|
|
220
|
-
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
221
|
-
target_mode,
|
|
222
|
-
)
|
|
223
218
|
try:
|
|
224
219
|
with os.fdopen(fd, 'w') as f:
|
|
225
220
|
yaml.dump(data, f)
|
|
226
221
|
except Exception:
|
|
227
|
-
# fdopen owns fd on success; on failure we may need to close it
|
|
228
|
-
# if fdopen never took ownership. Best-effort.
|
|
229
222
|
try:
|
|
230
223
|
os.close(fd)
|
|
231
224
|
except OSError:
|
|
@@ -235,7 +228,6 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
|
|
|
235
228
|
os.chmod(tmp_path, target_mode)
|
|
236
229
|
tmp_path.replace(path)
|
|
237
230
|
except Exception:
|
|
238
|
-
# Best-effort cleanup of the temp file if writing failed.
|
|
239
231
|
try:
|
|
240
232
|
tmp_path.unlink(missing_ok=True)
|
|
241
233
|
except Exception:
|
|
@@ -134,10 +134,20 @@ class PacServer:
|
|
|
134
134
|
def start(self, port: int, pac_path: Path) -> None:
|
|
135
135
|
"""Start the PAC HTTP server on the given port.
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
No-op if already running on the same port (parallel start() callers
|
|
138
|
+
race here — facade's check-then-act on is_running() can let multiple
|
|
139
|
+
threads through). Raises RuntimeError if the port is in use by
|
|
140
|
+
something else.
|
|
138
141
|
"""
|
|
139
142
|
if self._server is not None:
|
|
140
|
-
|
|
143
|
+
if self._port == port:
|
|
144
|
+
# Same port + already up: caller's intent is already satisfied.
|
|
145
|
+
self._pac_path = pac_path
|
|
146
|
+
return
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"PAC server already running on port {self._port}, "
|
|
149
|
+
f"refusing to also bind {port}"
|
|
150
|
+
)
|
|
141
151
|
|
|
142
152
|
self._pac_path = pac_path
|
|
143
153
|
|
|
@@ -7,6 +7,7 @@ Anything outside the allowlist below is rejected too — deny-by-default.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import asyncio
|
|
10
11
|
import logging
|
|
11
12
|
|
|
12
13
|
from aiohttp import web
|
|
@@ -87,7 +88,15 @@ async def _handle_rpc(request: web.Request) -> web.Response:
|
|
|
87
88
|
return web.Response(text=resp.to_json(), status=404, content_type="application/json")
|
|
88
89
|
|
|
89
90
|
try:
|
|
90
|
-
|
|
91
|
+
# Method handlers are synchronous — many touch the filesystem or spawn
|
|
92
|
+
# `ssh -O check` subprocesses (up to 3s each). Running them directly
|
|
93
|
+
# in the aiohttp event loop blocks every other RPC behind the slow one
|
|
94
|
+
# and eventually leads to "Daemon RPC failed: timed out". Offload to
|
|
95
|
+
# the default thread executor so the loop stays responsive.
|
|
96
|
+
loop = asyncio.get_running_loop()
|
|
97
|
+
result = await loop.run_in_executor(
|
|
98
|
+
None, lambda: method(*req.args, **req.kwargs)
|
|
99
|
+
)
|
|
91
100
|
resp = InvocationResponse(ok=True, result=result)
|
|
92
101
|
return web.Response(text=resp.to_json(), content_type="application/json")
|
|
93
102
|
except Exception as exc:
|
|
@@ -86,17 +86,21 @@ def _preflight(workspace: Path, log: "logging.Logger") -> None:
|
|
|
86
86
|
# Won the race uncontested.
|
|
87
87
|
pass
|
|
88
88
|
else:
|
|
89
|
-
# File exists. Is the holder alive?
|
|
89
|
+
# File exists. Is the holder alive AND actually our daemon?
|
|
90
|
+
# PID reuse (an ssh fork inheriting the freed PID after we kill -9
|
|
91
|
+
# the daemon) would otherwise wedge us in a refusal loop until the
|
|
92
|
+
# impostor exits. Reuse the same identity check the client does.
|
|
90
93
|
pid_file = _pid_path(workspace)
|
|
91
94
|
try:
|
|
92
95
|
existing_pid = int(pid_file.read_text().strip())
|
|
93
|
-
os.kill(existing_pid, 0) # liveness probe
|
|
94
96
|
except (OSError, ValueError):
|
|
95
|
-
|
|
97
|
+
existing_pid = None
|
|
98
|
+
from susops.client import _pid_is_susops_daemon
|
|
99
|
+
if existing_pid is None or not _pid_is_susops_daemon(existing_pid):
|
|
100
|
+
# Stale (or impostor) PID file. Remove + retry the atomic claim.
|
|
96
101
|
pid_file.unlink(missing_ok=True)
|
|
97
102
|
_port_path(workspace).unlink(missing_ok=True)
|
|
98
103
|
if not _claim_pid_file(workspace):
|
|
99
|
-
# Some other daemon claimed it between our cleanup and retry.
|
|
100
104
|
log.error(
|
|
101
105
|
"another susops-services daemon raced us to start. "
|
|
102
106
|
"Try again — or run "
|
|
@@ -153,9 +157,28 @@ def main() -> int:
|
|
|
153
157
|
args = parser.parse_args()
|
|
154
158
|
workspace = Path(args.workspace)
|
|
155
159
|
|
|
156
|
-
logging.
|
|
157
|
-
|
|
160
|
+
# Route logging + raw stderr to a workspace-local file. With stderr
|
|
161
|
+
# piped from the spawner (client.py used stderr=PIPE) the kernel's
|
|
162
|
+
# 64 KB pipe buffer would fill within minutes of daemon logging,
|
|
163
|
+
# blocking every subsequent log call — including ones running on
|
|
164
|
+
# executor threads serving RPC requests. Result: freeze.
|
|
165
|
+
log_path = workspace / "logs" / "susops-services.log"
|
|
166
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
logging.basicConfig(
|
|
168
|
+
level=logging.INFO,
|
|
169
|
+
format="%(asctime)s [services] %(message)s",
|
|
170
|
+
handlers=[logging.FileHandler(log_path, mode="a", encoding="utf-8")],
|
|
171
|
+
force=True,
|
|
172
|
+
)
|
|
158
173
|
log = logging.getLogger("susops.services")
|
|
174
|
+
# Anything that bypasses logging (raw `print`, tracebacks) also goes
|
|
175
|
+
# to the log file rather than the spawner's pipe.
|
|
176
|
+
try:
|
|
177
|
+
_stderr_redirect = open(log_path, "a", buffering=1, encoding="utf-8")
|
|
178
|
+
sys.stderr = _stderr_redirect
|
|
179
|
+
sys.stdout = _stderr_redirect
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
159
182
|
|
|
160
183
|
# Install signal handlers BEFORE preflight: SIGTERM arriving in the
|
|
161
184
|
# window between claiming the PID file and entering the try block
|
|
@@ -177,7 +200,17 @@ def main() -> int:
|
|
|
177
200
|
from susops.core.rpc_server import serve
|
|
178
201
|
from susops.facade import SusOpsManager
|
|
179
202
|
|
|
180
|
-
|
|
203
|
+
# Defer the SSH/PAC/share probes (_restore_*) until AFTER the RPC
|
|
204
|
+
# port is bound and the port file is written. Those probes can
|
|
205
|
+
# block on the ssh agent (e.g. 1Password approval prompt) for
|
|
206
|
+
# several seconds, and frontends only give up after _DAEMON_SPAWN_TIMEOUT.
|
|
207
|
+
# By publishing the port first, the daemon is "alive" to frontends
|
|
208
|
+
# immediately regardless of how slow the agent decides to be.
|
|
209
|
+
mgr = SusOpsManager(
|
|
210
|
+
workspace=workspace,
|
|
211
|
+
_enable_background_threads=True,
|
|
212
|
+
_skip_restore=True,
|
|
213
|
+
)
|
|
181
214
|
# CLI `--port` wins. Fall back to the configured `rpc_server_port`,
|
|
182
215
|
# then 0 (auto-allocate). When we auto-allocate, persist the bound
|
|
183
216
|
# port back to config so subsequent spawns reuse it.
|
|
@@ -198,6 +231,24 @@ def main() -> int:
|
|
|
198
231
|
# backoff for several seconds after a fresh daemon spawn.
|
|
199
232
|
sse_port = mgr.ensure_sse_status_server()
|
|
200
233
|
|
|
234
|
+
# Now that the daemon is reachable (RPC + SSE), run the deferred
|
|
235
|
+
# restore work in a background thread so slow ssh-agent prompts
|
|
236
|
+
# don't keep the daemon process pinned during startup.
|
|
237
|
+
def _restore_in_background() -> None:
|
|
238
|
+
try:
|
|
239
|
+
mgr._restore_pac()
|
|
240
|
+
if mgr.config.susops_app.restore_shares_on_start:
|
|
241
|
+
mgr._restore_shares()
|
|
242
|
+
mgr._restore_reconnect_monitor()
|
|
243
|
+
except Exception:
|
|
244
|
+
log.exception("Background restore failed")
|
|
245
|
+
|
|
246
|
+
threading.Thread(
|
|
247
|
+
target=_restore_in_background,
|
|
248
|
+
daemon=True,
|
|
249
|
+
name="susops-restore",
|
|
250
|
+
).start()
|
|
251
|
+
|
|
201
252
|
# Surface daemon-startup details in the in-memory log buffer too so
|
|
202
253
|
# they show up in the TUI Logs tab and the tray Logs window — the
|
|
203
254
|
# `log.info` calls only land on the daemon process's stderr.
|
|
@@ -211,13 +262,16 @@ def main() -> int:
|
|
|
211
262
|
pass
|
|
212
263
|
|
|
213
264
|
# Idle-shutdown. Exit when the last SSE client disconnects AND there's
|
|
214
|
-
# no in-flight work
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
265
|
+
# no in-flight work AND that state holds for _IDLE_CONSECUTIVE_TICKS
|
|
266
|
+
# consecutive checks. Requiring multiple ticks defends against the
|
|
267
|
+
# "SSE flap during reconnect → momentary client_count==0 → daemon
|
|
268
|
+
# exits → frontend respawns it" churn that bit us during heavy
|
|
269
|
+
# reconnect activity.
|
|
218
270
|
_IDLE_STARTUP_GRACE_S = 3.0
|
|
219
271
|
_IDLE_CHECK_INTERVAL_S = 5.0
|
|
272
|
+
_IDLE_CONSECUTIVE_TICKS = 3
|
|
220
273
|
startup_time = _time.monotonic()
|
|
274
|
+
idle_streak = 0 # consecutive ticks where _should_exit() was True
|
|
221
275
|
|
|
222
276
|
def _should_exit() -> bool:
|
|
223
277
|
if _time.monotonic() - startup_time < _IDLE_STARTUP_GRACE_S:
|
|
@@ -229,19 +283,12 @@ def main() -> int:
|
|
|
229
283
|
return mgr.is_idle()
|
|
230
284
|
|
|
231
285
|
def _on_clients_changed(count: int) -> None:
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
286
|
+
# No fast-path exit here anymore — the watcher's consecutive-tick
|
|
287
|
+
# requirement covers genuine quiescence and rejects flap events
|
|
288
|
+
# without us having to special-case them.
|
|
289
|
+
nonlocal idle_streak
|
|
235
290
|
if count > 0:
|
|
236
|
-
|
|
237
|
-
if _should_exit():
|
|
238
|
-
msg = "Last client disconnected and no work pending — shutting down"
|
|
239
|
-
log.info(msg)
|
|
240
|
-
try:
|
|
241
|
-
mgr._log(msg)
|
|
242
|
-
except Exception:
|
|
243
|
-
pass
|
|
244
|
-
stop_event.set()
|
|
291
|
+
idle_streak = 0
|
|
245
292
|
|
|
246
293
|
mgr._status_server.on_clients_changed = _on_clients_changed
|
|
247
294
|
# Surface SSE connect/disconnect in the in-memory log buffer so the
|
|
@@ -249,19 +296,26 @@ def main() -> int:
|
|
|
249
296
|
mgr._status_server.on_log = mgr._log
|
|
250
297
|
|
|
251
298
|
def _idle_watcher() -> None:
|
|
299
|
+
nonlocal idle_streak
|
|
252
300
|
while not stop_event.is_set():
|
|
253
301
|
if stop_event.wait(_IDLE_CHECK_INTERVAL_S):
|
|
254
302
|
return
|
|
255
303
|
if _should_exit():
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
304
|
+
idle_streak += 1
|
|
305
|
+
if idle_streak >= _IDLE_CONSECUTIVE_TICKS:
|
|
306
|
+
msg = (
|
|
307
|
+
f"Idle for {_IDLE_CHECK_INTERVAL_S * idle_streak:.0f}s "
|
|
308
|
+
f"({idle_streak} consecutive checks) — shutting down"
|
|
309
|
+
)
|
|
310
|
+
log.info(msg)
|
|
311
|
+
try:
|
|
312
|
+
mgr._log(msg)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
stop_event.set()
|
|
316
|
+
return
|
|
317
|
+
else:
|
|
318
|
+
idle_streak = 0
|
|
265
319
|
|
|
266
320
|
threading.Thread(
|
|
267
321
|
target=_idle_watcher, daemon=True, name="susops-idle-watcher",
|
|
@@ -8,6 +8,7 @@ Architecture:
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import subprocess
|
|
12
13
|
import time
|
|
13
14
|
from pathlib import Path
|
|
@@ -249,9 +250,43 @@ def stop_tunnel(
|
|
|
249
250
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
250
251
|
pass
|
|
251
252
|
|
|
253
|
+
# Last-resort sweep for orphan masters bound to our socket path.
|
|
254
|
+
# Under parallel start/stop the PID file can race (start writes PID Y while
|
|
255
|
+
# stop reads stale PID X, or the reconnect monitor spawns M2 between the
|
|
256
|
+
# read and the kill), leaving an ssh -N -T -D ... ControlPath=...sock
|
|
257
|
+
# process alive until its own keepalive timeout fires ~60s later.
|
|
258
|
+
if workspace is not None:
|
|
259
|
+
_sweep_orphan_masters(tag, workspace)
|
|
260
|
+
|
|
252
261
|
return stopped
|
|
253
262
|
|
|
254
263
|
|
|
264
|
+
def _sweep_orphan_masters(tag: str, workspace: Path) -> None:
|
|
265
|
+
"""Kill any ssh process whose argv references our ControlPath socket but
|
|
266
|
+
that ProcessManager isn't tracking. No-op if psutil is unavailable.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
import psutil
|
|
270
|
+
except ImportError:
|
|
271
|
+
return
|
|
272
|
+
sock_str = str(socket_path(tag, workspace))
|
|
273
|
+
try:
|
|
274
|
+
own_pid = os.getpid()
|
|
275
|
+
except Exception:
|
|
276
|
+
own_pid = -1
|
|
277
|
+
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
|
278
|
+
try:
|
|
279
|
+
if proc.info["pid"] == own_pid:
|
|
280
|
+
continue
|
|
281
|
+
if proc.info.get("name") != "ssh":
|
|
282
|
+
continue
|
|
283
|
+
cmdline = proc.info.get("cmdline") or []
|
|
284
|
+
if any(sock_str in arg for arg in cmdline):
|
|
285
|
+
proc.kill()
|
|
286
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
|
|
255
290
|
def is_tunnel_running(tag: str, process_mgr: ProcessManager) -> bool:
|
|
256
291
|
"""Return True if the ControlMaster SSH process for tag is currently running."""
|
|
257
292
|
return process_mgr.is_running(_master_name(tag))
|
|
@@ -282,7 +317,12 @@ def find_master_pid(tag: str, workspace: Path) -> int | None:
|
|
|
282
317
|
def is_socket_alive(tag: str, workspace: Path) -> bool:
|
|
283
318
|
"""Return True if the ControlMaster socket is responsive.
|
|
284
319
|
|
|
285
|
-
Uses `ssh -O check` to verify the master is healthy.
|
|
320
|
+
Uses `ssh -O check` to verify the master is healthy. The 1s timeout
|
|
321
|
+
is tight on purpose: a healthy local control socket replies in <100ms,
|
|
322
|
+
and anything longer means the master is stuck (e.g. waiting on agent),
|
|
323
|
+
in which case we should return False quickly so callers move on. The
|
|
324
|
+
previous 3s timeout could saturate the daemon's executor pool when
|
|
325
|
+
multiple callers stalled simultaneously.
|
|
286
326
|
"""
|
|
287
327
|
sock = socket_path(tag, workspace)
|
|
288
328
|
if not sock.exists():
|
|
@@ -291,7 +331,7 @@ def is_socket_alive(tag: str, workspace: Path) -> bool:
|
|
|
291
331
|
result = subprocess.run(
|
|
292
332
|
["ssh", "-O", "check", "-o", f"ControlPath={sock}", "placeholder"],
|
|
293
333
|
capture_output=True,
|
|
294
|
-
timeout=
|
|
334
|
+
timeout=1,
|
|
295
335
|
)
|
|
296
336
|
return result.returncode == 0
|
|
297
337
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|