susops 3.0.0rc3.dev8__tar.gz → 3.0.0rc4.dev2__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.dev8 → susops-3.0.0rc4.dev2}/PKG-INFO +1 -1
  2. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/pyproject.toml +1 -1
  3. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/client.py +68 -7
  4. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/rpc_server.py +12 -1
  5. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/services_daemon.py +78 -28
  6. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ssh.py +7 -2
  7. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/types.py +5 -0
  8. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/facade.py +209 -74
  9. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/base.py +13 -1
  10. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/linux.py +5 -0
  11. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/mac.py +5 -0
  12. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/app.py +10 -3
  13. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/__init__.py +11 -3
  14. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/connections.py +17 -7
  15. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/dashboard.py +15 -7
  16. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/PKG-INFO +1 -1
  17. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_services_daemon.py +20 -12
  18. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/LICENSE +0 -0
  19. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/README.md +0 -0
  20. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/setup.cfg +0 -0
  21. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/__init__.py +0 -0
  22. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icon.png +0 -0
  23. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  24. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  25. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  26. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  27. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  28. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  29. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  30. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  31. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  32. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  33. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  34. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  35. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  36. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  37. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  38. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  39. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  40. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  41. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  42. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  43. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/error.svg +0 -0
  44. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/running.svg +0 -0
  45. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  46. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  47. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/error.svg +0 -0
  48. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/running.svg +0 -0
  49. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/stopped.svg +0 -0
  50. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  51. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/__init__.py +0 -0
  52. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/browsers.py +0 -0
  53. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/config.py +0 -0
  54. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/log_style.py +0 -0
  55. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/pac.py +0 -0
  56. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ports.py +0 -0
  57. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/process.py +0 -0
  58. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/rpc_protocol.py +0 -0
  59. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/share.py +0 -0
  60. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/socat.py +0 -0
  61. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ssh_config.py +0 -0
  62. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/status.py +0 -0
  63. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/__init__.py +0 -0
  64. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/__init__.py +0 -0
  65. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/__main__.py +0 -0
  66. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/app.tcss +0 -0
  67. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/cli.py +0 -0
  68. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/shares.py +0 -0
  69. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/widgets/__init__.py +0 -0
  70. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/widgets/connection_card.py +0 -0
  71. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/version.py +0 -0
  72. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/SOURCES.txt +0 -0
  73. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/dependency_links.txt +0 -0
  74. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/entry_points.txt +0 -0
  75. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/requires.txt +0 -0
  76. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/top_level.txt +0 -0
  77. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_bw_totals.py +0 -0
  78. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_cli.py +0 -0
  79. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_client.py +0 -0
  80. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_config.py +0 -0
  81. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_conftest_smoke.py +0 -0
  82. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_facade.py +0 -0
  83. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_openapi.py +0 -0
  84. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_pac.py +0 -0
  85. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_packaging.py +0 -0
  86. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_process.py +0 -0
  87. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_rpc_protocol.py +0 -0
  88. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_rpc_server.py +0 -0
  89. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_scripts.py +0 -0
  90. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_share.py +0 -0
  91. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_share_aiohttp.py +0 -0
  92. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_socat.py +0 -0
  93. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_ssh.py +0 -0
  94. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_status.py +0 -0
  95. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_update_homebrew_sha.py +0 -0
  96. {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/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.dev8
3
+ Version: 3.0.0rc4.dev2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "susops"
7
- version = "3.0.0-rc3.dev8"
7
+ version = "3.0.0-rc4.dev2"
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):
@@ -65,17 +81,50 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
65
81
  (PAC port squatted, peer daemon alive, …) is surfaced to the caller
66
82
  instead of getting buried under a generic "didn't come up" message.
67
83
  """
84
+ global _last_fast_fail
68
85
  if _is_daemon_alive(workspace):
69
86
  port = _read_port(workspace)
70
87
  if port:
71
88
  return port
89
+ # Daemon claimed its PID file but hasn't published the port yet.
90
+ # Wait for it instead of spawning a competitor that would race the
91
+ # O_EXCL claim and exit rc=2 ("another daemon is already running").
92
+ deadline = time.monotonic() + _DAEMON_SPAWN_TIMEOUT
93
+ while time.monotonic() < deadline:
94
+ if not _is_daemon_alive(workspace):
95
+ break # died mid-startup, fall through to spawn a new one
96
+ port = _read_port(workspace)
97
+ if port:
98
+ return port
99
+ time.sleep(0.1)
100
+ else:
101
+ raise DaemonUnavailableError(
102
+ "An existing susops-services daemon is alive but never "
103
+ "published an RPC port. Try `kill " +
104
+ (_pid_path(workspace).read_text().strip() or "<pid>") +
105
+ "` and start again."
106
+ )
72
107
 
108
+ # If we recently fast-failed a spawn (daemon exited within
109
+ # _FAST_FAIL_WINDOW_S of starting), wait out the backoff window
110
+ # before respawning. Prevents tight respawn loops when something is
111
+ # structurally broken (corrupt config, PAC port squatted, etc.).
112
+ with _last_fast_fail_lock:
113
+ since_last = time.monotonic() - _last_fast_fail
114
+ if since_last < _BACKOFF_S:
115
+ time.sleep(_BACKOFF_S - since_last)
116
+
117
+ # DEVNULL (not PIPE) for stderr — the daemon writes its log to
118
+ # ~/.susops/logs/susops-services.log directly. Capturing as a pipe
119
+ # would back up once the daemon's log volume exceeded the kernel
120
+ # buffer (~64 KB on macOS) and freeze every subsequent log call.
121
+ spawn_start = time.monotonic()
73
122
  proc = subprocess.Popen(
74
123
  [sys.executable, "-m", "susops.core.services_daemon",
75
124
  "--workspace", str(workspace)],
76
125
  stdin=subprocess.DEVNULL,
77
126
  stdout=subprocess.DEVNULL,
78
- stderr=subprocess.PIPE,
127
+ stderr=subprocess.DEVNULL,
79
128
  start_new_session=True,
80
129
  )
81
130
 
@@ -86,17 +135,29 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
86
135
  if port:
87
136
  return port
88
137
  # If the daemon exited (e.g. preflight rejected it), don't keep
89
- # polling — surface the reason and bail out immediately.
138
+ # polling — surface the reason and bail out immediately. The
139
+ # daemon's log file is the new source of truth for failure
140
+ # reasons (stderr is no longer piped — see Popen above).
90
141
  rc = proc.poll()
91
142
  if rc is not None:
143
+ # Fast fail = structural problem. Record the timestamp so the
144
+ # next ensure_daemon_running call within _BACKOFF_S sleeps
145
+ # before retrying.
146
+ if time.monotonic() - spawn_start < _FAST_FAIL_WINDOW_S:
147
+ with _last_fast_fail_lock:
148
+ _last_fast_fail = time.monotonic()
149
+ log_path = workspace / "logs" / "susops-services.log"
150
+ tail = ""
92
151
  try:
93
- _, err = proc.communicate(timeout=0.5)
94
- stderr = err.decode(errors="replace").strip() if err else ""
152
+ if log_path.exists():
153
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
154
+ lines = f.readlines()[-15:]
155
+ tail = "".join(lines).strip()
95
156
  except Exception:
96
- stderr = ""
157
+ pass
97
158
  msg = (
98
159
  f"Daemon exited during startup (rc={rc})"
99
- + (f":\n{stderr}" if stderr else "")
160
+ + (f":\n{tail}" if tail else "")
100
161
  )
101
162
  raise DaemonUnavailableError(msg)
102
163
  time.sleep(0.1)
@@ -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
@@ -46,6 +47,8 @@ _ALLOWED_METHODS: set[str] = {
46
47
  # Bandwidth
47
48
  "get_bandwidth", "get_bandwidth_totals", "get_bandwidth_global",
48
49
  "get_bandwidth_history",
50
+ # Frontend coordination
51
+ "sse_client_count",
49
52
  # Reconnect introspection
50
53
  "reconnect_monitor_info",
51
54
  # Process introspection
@@ -85,7 +88,15 @@ async def _handle_rpc(request: web.Request) -> web.Response:
85
88
  return web.Response(text=resp.to_json(), status=404, content_type="application/json")
86
89
 
87
90
  try:
88
- 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
+ )
89
100
  resp = InvocationResponse(ok=True, result=result)
90
101
  return web.Response(text=resp.to_json(), content_type="application/json")
91
102
  except Exception as exc:
@@ -153,9 +153,28 @@ def main() -> int:
153
153
  args = parser.parse_args()
154
154
  workspace = Path(args.workspace)
155
155
 
156
- logging.basicConfig(level=logging.INFO,
157
- format="%(asctime)s [services] %(message)s")
156
+ # Route logging + raw stderr to a workspace-local file. With stderr
157
+ # piped from the spawner (client.py used stderr=PIPE) the kernel's
158
+ # 64 KB pipe buffer would fill within minutes of daemon logging,
159
+ # blocking every subsequent log call — including ones running on
160
+ # executor threads serving RPC requests. Result: freeze.
161
+ log_path = workspace / "logs" / "susops-services.log"
162
+ log_path.parent.mkdir(parents=True, exist_ok=True)
163
+ logging.basicConfig(
164
+ level=logging.INFO,
165
+ format="%(asctime)s [services] %(message)s",
166
+ handlers=[logging.FileHandler(log_path, mode="a", encoding="utf-8")],
167
+ force=True,
168
+ )
158
169
  log = logging.getLogger("susops.services")
170
+ # Anything that bypasses logging (raw `print`, tracebacks) also goes
171
+ # to the log file rather than the spawner's pipe.
172
+ try:
173
+ _stderr_redirect = open(log_path, "a", buffering=1, encoding="utf-8")
174
+ sys.stderr = _stderr_redirect
175
+ sys.stdout = _stderr_redirect
176
+ except Exception:
177
+ pass
159
178
 
160
179
  # Install signal handlers BEFORE preflight: SIGTERM arriving in the
161
180
  # window between claiming the PID file and entering the try block
@@ -177,7 +196,17 @@ def main() -> int:
177
196
  from susops.core.rpc_server import serve
178
197
  from susops.facade import SusOpsManager
179
198
 
180
- mgr = SusOpsManager(workspace=workspace, _enable_background_threads=True)
199
+ # Defer the SSH/PAC/share probes (_restore_*) until AFTER the RPC
200
+ # port is bound and the port file is written. Those probes can
201
+ # block on the ssh agent (e.g. 1Password approval prompt) for
202
+ # several seconds, and frontends only give up after _DAEMON_SPAWN_TIMEOUT.
203
+ # By publishing the port first, the daemon is "alive" to frontends
204
+ # immediately regardless of how slow the agent decides to be.
205
+ mgr = SusOpsManager(
206
+ workspace=workspace,
207
+ _enable_background_threads=True,
208
+ _skip_restore=True,
209
+ )
181
210
  # CLI `--port` wins. Fall back to the configured `rpc_server_port`,
182
211
  # then 0 (auto-allocate). When we auto-allocate, persist the bound
183
212
  # port back to config so subsequent spawns reuse it.
@@ -198,6 +227,24 @@ def main() -> int:
198
227
  # backoff for several seconds after a fresh daemon spawn.
199
228
  sse_port = mgr.ensure_sse_status_server()
200
229
 
230
+ # Now that the daemon is reachable (RPC + SSE), run the deferred
231
+ # restore work in a background thread so slow ssh-agent prompts
232
+ # don't keep the daemon process pinned during startup.
233
+ def _restore_in_background() -> None:
234
+ try:
235
+ mgr._restore_pac()
236
+ if mgr.config.susops_app.restore_shares_on_start:
237
+ mgr._restore_shares()
238
+ mgr._restore_reconnect_monitor()
239
+ except Exception:
240
+ log.exception("Background restore failed")
241
+
242
+ threading.Thread(
243
+ target=_restore_in_background,
244
+ daemon=True,
245
+ name="susops-restore",
246
+ ).start()
247
+
201
248
  # Surface daemon-startup details in the in-memory log buffer too so
202
249
  # they show up in the TUI Logs tab and the tray Logs window — the
203
250
  # `log.info` calls only land on the daemon process's stderr.
@@ -211,13 +258,16 @@ def main() -> int:
211
258
  pass
212
259
 
213
260
  # 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.
261
+ # no in-flight work AND that state holds for _IDLE_CONSECUTIVE_TICKS
262
+ # consecutive checks. Requiring multiple ticks defends against the
263
+ # "SSE flap during reconnect momentary client_count==0 daemon
264
+ # exits frontend respawns it" churn that bit us during heavy
265
+ # reconnect activity.
218
266
  _IDLE_STARTUP_GRACE_S = 3.0
219
267
  _IDLE_CHECK_INTERVAL_S = 5.0
268
+ _IDLE_CONSECUTIVE_TICKS = 3
220
269
  startup_time = _time.monotonic()
270
+ idle_streak = 0 # consecutive ticks where _should_exit() was True
221
271
 
222
272
  def _should_exit() -> bool:
223
273
  if _time.monotonic() - startup_time < _IDLE_STARTUP_GRACE_S:
@@ -229,19 +279,12 @@ def main() -> int:
229
279
  return mgr.is_idle()
230
280
 
231
281
  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).
282
+ # No fast-path exit here anymore the watcher's consecutive-tick
283
+ # requirement covers genuine quiescence and rejects flap events
284
+ # without us having to special-case them.
285
+ nonlocal idle_streak
235
286
  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()
287
+ idle_streak = 0
245
288
 
246
289
  mgr._status_server.on_clients_changed = _on_clients_changed
247
290
  # Surface SSE connect/disconnect in the in-memory log buffer so the
@@ -249,19 +292,26 @@ def main() -> int:
249
292
  mgr._status_server.on_log = mgr._log
250
293
 
251
294
  def _idle_watcher() -> None:
295
+ nonlocal idle_streak
252
296
  while not stop_event.is_set():
253
297
  if stop_event.wait(_IDLE_CHECK_INTERVAL_S):
254
298
  return
255
299
  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
300
+ idle_streak += 1
301
+ if idle_streak >= _IDLE_CONSECUTIVE_TICKS:
302
+ msg = (
303
+ f"Idle for {_IDLE_CHECK_INTERVAL_S * idle_streak:.0f}s "
304
+ f"({idle_streak} consecutive checks) — shutting down"
305
+ )
306
+ log.info(msg)
307
+ try:
308
+ mgr._log(msg)
309
+ except Exception:
310
+ pass
311
+ stop_event.set()
312
+ return
313
+ else:
314
+ idle_streak = 0
265
315
 
266
316
  threading.Thread(
267
317
  target=_idle_watcher, daemon=True, name="susops-idle-watcher",
@@ -282,7 +282,12 @@ def find_master_pid(tag: str, workspace: Path) -> int | None:
282
282
  def is_socket_alive(tag: str, workspace: Path) -> bool:
283
283
  """Return True if the ControlMaster socket is responsive.
284
284
 
285
- Uses `ssh -O check` to verify the master is healthy.
285
+ Uses `ssh -O check` to verify the master is healthy. The 1s timeout
286
+ is tight on purpose: a healthy local control socket replies in <100ms,
287
+ and anything longer means the master is stuck (e.g. waiting on agent),
288
+ in which case we should return False quickly so callers move on. The
289
+ previous 3s timeout could saturate the daemon's executor pool when
290
+ multiple callers stalled simultaneously.
286
291
  """
287
292
  sock = socket_path(tag, workspace)
288
293
  if not sock.exists():
@@ -291,7 +296,7 @@ def is_socket_alive(tag: str, workspace: Path) -> bool:
291
296
  result = subprocess.run(
292
297
  ["ssh", "-O", "check", "-o", f"ControlPath={sock}", "placeholder"],
293
298
  capture_output=True,
294
- timeout=3,
299
+ timeout=1,
295
300
  )
296
301
  return result.returncode == 0
297
302
  except (subprocess.TimeoutExpired, FileNotFoundError):
@@ -81,6 +81,11 @@ class ConnectionStatus:
81
81
  pid: Optional[int] = None
82
82
  socks_port: int = 0
83
83
  enabled: bool = True
84
+ # ssh master spawned but ControlMaster socket not yet alive — almost
85
+ # always means "waiting for the user to approve the agent prompt".
86
+ # Frontends should render this with a distinct glyph (e.g. yellow /
87
+ # spinner) so it's clear the connection isn't usable yet.
88
+ pending: bool = False
84
89
 
85
90
 
86
91
  @dataclasses.dataclass(frozen=True)