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.
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/PKG-INFO +1 -1
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/pyproject.toml +1 -1
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/client.py +68 -7
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/rpc_server.py +12 -1
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/services_daemon.py +78 -28
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ssh.py +7 -2
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/types.py +5 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/facade.py +209 -74
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/base.py +13 -1
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/linux.py +5 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/mac.py +5 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/app.py +10 -3
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/__init__.py +11 -3
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/connections.py +17 -7
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/dashboard.py +15 -7
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/PKG-INFO +1 -1
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_services_daemon.py +20 -12
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/LICENSE +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/README.md +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/setup.cfg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/__init__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icon.png +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/error.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/running.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/stopped.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/__init__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/browsers.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/config.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/log_style.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/pac.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ports.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/process.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/rpc_protocol.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/share.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/socat.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/ssh_config.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/core/status.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tray/__init__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/__init__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/__main__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/app.tcss +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/cli.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/screens/shares.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/widgets/__init__.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/tui/widgets/connection_card.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops/version.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/SOURCES.txt +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/dependency_links.txt +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/entry_points.txt +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/requires.txt +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/src/susops.egg-info/top_level.txt +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_bw_totals.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_cli.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_client.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_config.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_conftest_smoke.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_facade.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_openapi.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_pac.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_packaging.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_process.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_rpc_protocol.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_rpc_server.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_scripts.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_share.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_share_aiohttp.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_socat.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_ssh.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_status.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_update_homebrew_sha.py +0 -0
- {susops-3.0.0rc3.dev8 → susops-3.0.0rc4.dev2}/tests/test_version.py +0 -0
|
@@ -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-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
|
-
|
|
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.
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
157
|
+
pass
|
|
97
158
|
msg = (
|
|
98
159
|
f"Daemon exited during startup (rc={rc})"
|
|
99
|
-
+ (f":\n{
|
|
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
|
-
|
|
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.
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
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
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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=
|
|
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)
|