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.
Files changed (96) hide show
  1. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/PKG-INFO +18 -6
  2. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/README.md +17 -5
  3. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/pyproject.toml +1 -1
  4. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/client.py +74 -9
  5. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/config.py +10 -18
  6. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/pac.py +12 -2
  7. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/rpc_server.py +10 -1
  8. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/services_daemon.py +86 -32
  9. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ssh.py +42 -2
  10. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/types.py +5 -0
  11. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/facade.py +394 -228
  12. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/base.py +4 -0
  13. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/linux.py +5 -0
  14. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/mac.py +5 -0
  15. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/__init__.py +11 -3
  16. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/connections.py +17 -7
  17. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/dashboard.py +33 -9
  18. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/PKG-INFO +18 -6
  19. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_client.py +26 -6
  20. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_config.py +37 -0
  21. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_facade.py +150 -0
  22. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_pac.py +24 -0
  23. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_services_daemon.py +70 -12
  24. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/LICENSE +0 -0
  25. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/setup.cfg +0 -0
  26. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/__init__.py +0 -0
  27. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icon.png +0 -0
  28. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  29. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  30. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  31. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  32. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  33. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  34. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  35. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  36. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  37. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  38. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  39. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  40. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  41. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  42. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  43. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  44. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  45. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  46. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  47. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  48. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/error.svg +0 -0
  49. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/running.svg +0 -0
  50. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  51. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  52. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/error.svg +0 -0
  53. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/running.svg +0 -0
  54. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/stopped.svg +0 -0
  55. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  56. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/__init__.py +0 -0
  57. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/browsers.py +0 -0
  58. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/log_style.py +0 -0
  59. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ports.py +0 -0
  60. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/process.py +0 -0
  61. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/rpc_protocol.py +0 -0
  62. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/share.py +0 -0
  63. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/socat.py +0 -0
  64. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/ssh_config.py +0 -0
  65. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/core/status.py +0 -0
  66. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tray/__init__.py +0 -0
  67. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/__init__.py +0 -0
  68. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/__main__.py +0 -0
  69. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/app.py +0 -0
  70. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/app.tcss +0 -0
  71. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/cli.py +0 -0
  72. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/screens/shares.py +0 -0
  73. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/widgets/__init__.py +0 -0
  74. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/tui/widgets/connection_card.py +0 -0
  75. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops/version.py +0 -0
  76. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/SOURCES.txt +0 -0
  77. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/dependency_links.txt +0 -0
  78. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/entry_points.txt +0 -0
  79. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/requires.txt +0 -0
  80. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/src/susops.egg-info/top_level.txt +0 -0
  81. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_bw_totals.py +0 -0
  82. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_cli.py +0 -0
  83. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_conftest_smoke.py +0 -0
  84. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_openapi.py +0 -0
  85. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_packaging.py +0 -0
  86. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_process.py +0 -0
  87. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_rpc_protocol.py +0 -0
  88. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_rpc_server.py +0 -0
  89. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_scripts.py +0 -0
  90. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_share.py +0 -0
  91. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_share_aiohttp.py +0 -0
  92. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_socat.py +0 -0
  93. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_ssh.py +0 -0
  94. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_status.py +0 -0
  95. {susops-3.0.0rc3.dev9 → susops-3.0.0rc5.dev1}/tests/test_update_homebrew_sha.py +0 -0
  96. {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.0rc3.dev9
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 with all dependencies. The cask at `packaging/homebrew/Casks/susops.rb` is for a future `.dmg` distribution of `SusOps.app`.
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 with all dependencies. The cask at `packaging/homebrew/Casks/susops.rb` is for a future `.dmg` distribution of `SusOps.app`.
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-rc3.dev9"
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
- _DAEMON_SPAWN_TIMEOUT = 5.0
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.PIPE,
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
- _, err = proc.communicate(timeout=0.5)
112
- stderr = err.decode(errors="replace").strip() if err else ""
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
- stderr = ""
179
+ pass
115
180
  msg = (
116
181
  f"Daemon exited during startup (rc={rc})"
117
- + (f":\n{stderr}" if stderr else "")
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
- # Temp file in the same directory so the rename stays on one filesystem.
214
- # Open with O_EXCL so we never overwrite a leftover temp file from a
215
- # crashed earlier save (would otherwise inherit its mode).
216
- tmp_path = path.with_name(path.name + ".tmp")
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
- Raises RuntimeError if already running or if port is in use.
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
- raise RuntimeError("PAC server is already running")
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
- result = method(*req.args, **req.kwargs)
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
- # Stale PID file. Remove + retry the atomic claim once.
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.basicConfig(level=logging.INFO,
157
- format="%(asctime)s [services] %(message)s")
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
- mgr = SusOpsManager(workspace=workspace, _enable_background_threads=True)
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 (no SSH masters, no shares, no PAC, no watched
215
- # connections). Startup gets a small grace so a frontend that calls
216
- # ensure_daemon_running() has time to make its first SSE connection
217
- # before the periodic check fires.
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
- # Fast path react immediately when the last SSE client drops.
233
- # The periodic check below handles the "no SSE was ever opened"
234
- # case (e.g. `susops ps` fires one RPC and exits).
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
- return
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
- msg = (f"Idle for {_IDLE_CHECK_INTERVAL_S:.0f}s with "
257
- f"no clients shutting down")
258
- log.info(msg)
259
- try:
260
- mgr._log(msg)
261
- except Exception:
262
- pass
263
- stop_event.set()
264
- return
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=3,
334
+ timeout=1,
295
335
  )
296
336
  return result.returncode == 0
297
337
  except (subprocess.TimeoutExpired, FileNotFoundError):