sshmd 0.1.0__py3-none-any.whl
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.
- sshm/__init__.py +3 -0
- sshm/__main__.py +4 -0
- sshm/autostart.py +163 -0
- sshm/cli.py +740 -0
- sshm/completions/sshm.fish +137 -0
- sshm/config.py +344 -0
- sshm/daemon.py +289 -0
- sshm/ipc.py +249 -0
- sshm/process.py +545 -0
- sshm/procutil.py +60 -0
- sshm/protocol.py +53 -0
- sshm/py.typed +0 -0
- sshm/state.py +76 -0
- sshm/terminal.py +199 -0
- sshmd-0.1.0.dist-info/METADATA +309 -0
- sshmd-0.1.0.dist-info/RECORD +19 -0
- sshmd-0.1.0.dist-info/WHEEL +4 -0
- sshmd-0.1.0.dist-info/entry_points.txt +4 -0
- sshmd-0.1.0.dist-info/licenses/LICENSE +21 -0
sshm/process.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""SSH subprocess management: sessions, scrollback, health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import socket
|
|
9
|
+
import struct
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .procutil import no_window_popen_flags, pid_alive
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("sshm.process")
|
|
20
|
+
|
|
21
|
+
RECONNECT_MIN = 1.0
|
|
22
|
+
RECONNECT_MAX = 60.0
|
|
23
|
+
STABLE_THRESHOLD = 30.0
|
|
24
|
+
SCROLLBACK_MAX = 16 * 1024 # scrollback buffer replayed to a newly attached client
|
|
25
|
+
READ_CHUNK = 4096
|
|
26
|
+
|
|
27
|
+
# On POSIX we run ssh under a real PTY so it can negotiate the remote terminal
|
|
28
|
+
# size and react to SIGWINCH. Windows has no pty module — there we keep the
|
|
29
|
+
# pipe-based model (the remote PTY stays at its default size). fcntl/termios are
|
|
30
|
+
# imported at module load (not inside the post-fork child) so preexec_fn can't
|
|
31
|
+
# deadlock on the import lock.
|
|
32
|
+
_HAS_PTY = sys.platform != "win32"
|
|
33
|
+
if _HAS_PTY:
|
|
34
|
+
import fcntl
|
|
35
|
+
import termios
|
|
36
|
+
|
|
37
|
+
# Pre-resolve everything the post-fork preexec_fn touches to plain globals, so
|
|
38
|
+
# the child (forked from a multithreaded daemon) does no module attribute
|
|
39
|
+
# lookups — only bound calls and bare syscalls — before exec.
|
|
40
|
+
_setsid = os.setsid
|
|
41
|
+
_ioctl = fcntl.ioctl
|
|
42
|
+
_TIOCSCTTY = termios.TIOCSCTTY
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_ssh() -> str:
|
|
46
|
+
ssh = shutil.which("ssh")
|
|
47
|
+
if not ssh:
|
|
48
|
+
raise RuntimeError("ssh not found in PATH")
|
|
49
|
+
return ssh
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_controlling_tty() -> None:
|
|
53
|
+
"""preexec_fn (child side): own a new session with the slave PTY as our tty.
|
|
54
|
+
|
|
55
|
+
setsid() makes the child a session leader; TIOCSCTTY then makes its stdin
|
|
56
|
+
(the slave PTY) the controlling terminal, which is what lets the kernel
|
|
57
|
+
deliver SIGWINCH to ssh when we resize the master.
|
|
58
|
+
"""
|
|
59
|
+
_setsid()
|
|
60
|
+
_ioctl(0, _TIOCSCTTY, 0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _set_winsize(fd: int, cols: int, rows: int) -> None:
|
|
64
|
+
# struct winsize is { ws_row, ws_col, ws_xpixel, ws_ypixel }
|
|
65
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _spawn_with_pty(cmd: list[str]) -> tuple[subprocess.Popen, int]:
|
|
69
|
+
"""Spawn under a PTY; return (process, master_fd). POSIX only."""
|
|
70
|
+
import pty
|
|
71
|
+
|
|
72
|
+
master_fd, slave_fd = pty.openpty()
|
|
73
|
+
try:
|
|
74
|
+
proc = subprocess.Popen(
|
|
75
|
+
cmd,
|
|
76
|
+
stdin=slave_fd,
|
|
77
|
+
stdout=slave_fd,
|
|
78
|
+
stderr=slave_fd,
|
|
79
|
+
preexec_fn=_make_controlling_tty,
|
|
80
|
+
close_fds=True,
|
|
81
|
+
)
|
|
82
|
+
except BaseException:
|
|
83
|
+
os.close(master_fd)
|
|
84
|
+
os.close(slave_fd)
|
|
85
|
+
raise
|
|
86
|
+
os.close(slave_fd) # the child holds its own copy; the parent keeps the master
|
|
87
|
+
return proc, master_fd
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class SshSession:
|
|
92
|
+
alias: str
|
|
93
|
+
name: str
|
|
94
|
+
process: subprocess.Popen | None = None
|
|
95
|
+
pid: int | None = None
|
|
96
|
+
started_at: float = 0.0
|
|
97
|
+
reconnect: bool = True
|
|
98
|
+
attached: bool = False
|
|
99
|
+
attached_pid: int | None = None
|
|
100
|
+
# PTY master fd that ssh's stdin/stdout/stderr are wired to (POSIX). None on
|
|
101
|
+
# Windows, where we fall back to the Popen pipes.
|
|
102
|
+
master_fd: int | None = None
|
|
103
|
+
# Last terminal size a client reported (cols, rows), re-applied across
|
|
104
|
+
# reconnects so the remote shell keeps its size.
|
|
105
|
+
last_winsize: tuple[int, int] | None = None
|
|
106
|
+
|
|
107
|
+
scrollback: bytearray = field(default_factory=bytearray, repr=False)
|
|
108
|
+
_active_socket: socket.socket | None = field(default=None, repr=False)
|
|
109
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
110
|
+
_reader_thread: threading.Thread | None = field(default=None, repr=False)
|
|
111
|
+
# Bumped on every (re)connect under _lock. A reader stamps the generation it
|
|
112
|
+
# was started for and stops appending once it no longer matches, so a dying
|
|
113
|
+
# reader can't pour stale output into the next process's scrollback.
|
|
114
|
+
_io_gen: int = 0
|
|
115
|
+
_backoff: float = RECONNECT_MIN
|
|
116
|
+
_last_attempt: float = 0.0
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def alive(self) -> bool:
|
|
120
|
+
return self.process is not None and self.process.poll() is None
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
return {
|
|
124
|
+
"alias": self.alias,
|
|
125
|
+
"name": self.name,
|
|
126
|
+
"pid": self.pid,
|
|
127
|
+
"started_at": self.started_at,
|
|
128
|
+
"uptime": time.time() - self.started_at if self.started_at else 0,
|
|
129
|
+
"alive": self.alive,
|
|
130
|
+
"attached": self.attached,
|
|
131
|
+
"port_forwards": [],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def adopt_process(self, proc: subprocess.Popen, master_fd: int | None = None) -> None:
|
|
135
|
+
"""Take ownership of a freshly spawned SSH process and start reading it."""
|
|
136
|
+
with self._lock:
|
|
137
|
+
# Swap in the new process/master and bump the generation atomically, so
|
|
138
|
+
# a concurrent writer/resize sees a consistent (and open) master_fd and
|
|
139
|
+
# the previous reader stops appending into the fresh scrollback.
|
|
140
|
+
old_fd = self.master_fd
|
|
141
|
+
self.process = proc
|
|
142
|
+
self.master_fd = master_fd
|
|
143
|
+
self.pid = proc.pid
|
|
144
|
+
self.started_at = time.time()
|
|
145
|
+
self.scrollback = bytearray()
|
|
146
|
+
self._io_gen += 1
|
|
147
|
+
gen = self._io_gen
|
|
148
|
+
winsize = self.last_winsize
|
|
149
|
+
|
|
150
|
+
# Close the old master outside the lock: nothing reads self.master_fd as
|
|
151
|
+
# `old_fd` anymore, and the old reader ends when its captured fd goes away.
|
|
152
|
+
if old_fd is not None and old_fd != master_fd:
|
|
153
|
+
try:
|
|
154
|
+
os.close(old_fd)
|
|
155
|
+
except OSError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
if master_fd is not None and winsize is not None:
|
|
159
|
+
self.set_winsize(*winsize) # re-apply the remembered size to the new PTY
|
|
160
|
+
|
|
161
|
+
self._start_reader(gen)
|
|
162
|
+
|
|
163
|
+
def set_winsize(self, cols: int, rows: int) -> None:
|
|
164
|
+
"""Resize the remote terminal (ssh gets SIGWINCH from the master)."""
|
|
165
|
+
# struct winsize fields are unsigned 16-bit; clamp so a bogus size can't
|
|
166
|
+
# raise struct.error out of the ioctl pack.
|
|
167
|
+
cols = max(0, min(cols, 0xFFFF))
|
|
168
|
+
rows = max(0, min(rows, 0xFFFF))
|
|
169
|
+
with self._lock:
|
|
170
|
+
self.last_winsize = (cols, rows)
|
|
171
|
+
fd = self.master_fd
|
|
172
|
+
if fd is not None:
|
|
173
|
+
try:
|
|
174
|
+
_set_winsize(fd, cols, rows) # ioctl doesn't block — safe under the lock
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _write_input(self, data: bytes) -> bool:
|
|
179
|
+
"""Forward client bytes to the SSH process. Returns False on failure."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
fd = self.master_fd
|
|
182
|
+
if fd is not None:
|
|
183
|
+
# dup under the lock so a concurrent _kill/reconnect closing
|
|
184
|
+
# master_fd can't make us write onto a reused fd; the private dup
|
|
185
|
+
# keeps the same PTY master alive for the duration of the write,
|
|
186
|
+
# which happens outside the lock (os.write can block on backpressure
|
|
187
|
+
# and must not stall the reader or deadlock a killer).
|
|
188
|
+
try:
|
|
189
|
+
wfd = os.dup(fd)
|
|
190
|
+
except OSError:
|
|
191
|
+
return False
|
|
192
|
+
stdin = None
|
|
193
|
+
else:
|
|
194
|
+
wfd = None
|
|
195
|
+
stdin = self.process.stdin if self.process else None
|
|
196
|
+
|
|
197
|
+
if wfd is not None:
|
|
198
|
+
try:
|
|
199
|
+
os.write(wfd, data)
|
|
200
|
+
return True
|
|
201
|
+
except (OSError, ValueError):
|
|
202
|
+
return False
|
|
203
|
+
finally:
|
|
204
|
+
try:
|
|
205
|
+
os.close(wfd)
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
if not stdin:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
stdin.write(data)
|
|
213
|
+
stdin.flush()
|
|
214
|
+
return True
|
|
215
|
+
except (OSError, ValueError):
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
def _start_reader(self, gen: int) -> None:
|
|
219
|
+
"""Background thread: SSH output → scrollback + attached socket, if any."""
|
|
220
|
+
process = self.process
|
|
221
|
+
if process is None:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Read from the PTY master where we have one, else the stdout pipe.
|
|
225
|
+
if self.master_fd is not None:
|
|
226
|
+
fd = self.master_fd
|
|
227
|
+
elif process.stdout is not None:
|
|
228
|
+
fd = process.stdout.fileno()
|
|
229
|
+
else:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
# Bind the Popen object to the thread for its lifetime (as a default arg,
|
|
233
|
+
# which the function object holds): a reconnect rebinds self.process, and
|
|
234
|
+
# without this reference the pipe fd could be closed (and reused) while we
|
|
235
|
+
# still read from it. The PTY master is owned by the session and closed
|
|
236
|
+
# explicitly on reconnect/kill, so the keepalive is only load-bearing for
|
|
237
|
+
# the Windows pipe path.
|
|
238
|
+
def _read(_keepalive: subprocess.Popen = process) -> None:
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
data = os.read(fd, READ_CHUNK) # unbuffered — returns as soon as data is available
|
|
242
|
+
if not data:
|
|
243
|
+
break
|
|
244
|
+
except (OSError, ValueError):
|
|
245
|
+
break
|
|
246
|
+
|
|
247
|
+
with self._lock:
|
|
248
|
+
# A reconnect (new generation) reset scrollback for the new
|
|
249
|
+
# process; stop so we don't append this dying process's tail.
|
|
250
|
+
if self._io_gen != gen:
|
|
251
|
+
break
|
|
252
|
+
self.scrollback.extend(data)
|
|
253
|
+
if len(self.scrollback) > SCROLLBACK_MAX:
|
|
254
|
+
del self.scrollback[:-SCROLLBACK_MAX]
|
|
255
|
+
sock = self._active_socket
|
|
256
|
+
|
|
257
|
+
# Send to the client OUTSIDE the lock. A stalled or flow-controlled
|
|
258
|
+
# client (Ctrl-S, a suspended `sshm attach`, a full TCP window) makes
|
|
259
|
+
# this blocking send hang; holding _lock here would wedge detach,
|
|
260
|
+
# _kill, set_winsize and _write_input (all need the lock) — and detach
|
|
261
|
+
# closing the socket, the very thing that would unblock us, couldn't
|
|
262
|
+
# run. Detaching/closing the socket from elsewhere unblocks the send.
|
|
263
|
+
if sock is not None:
|
|
264
|
+
try:
|
|
265
|
+
sock.sendall(data)
|
|
266
|
+
except OSError:
|
|
267
|
+
with self._lock:
|
|
268
|
+
if self._active_socket is sock:
|
|
269
|
+
self._active_socket = None
|
|
270
|
+
|
|
271
|
+
log.info("Reader thread ended for %s/%s", self.alias, self.name)
|
|
272
|
+
|
|
273
|
+
self._reader_thread = threading.Thread(target=_read, daemon=True, name=f"reader-{self.name}")
|
|
274
|
+
self._reader_thread.start()
|
|
275
|
+
|
|
276
|
+
def detach(self) -> None:
|
|
277
|
+
"""Drop the attached client, closing its socket if present."""
|
|
278
|
+
with self._lock:
|
|
279
|
+
if self._active_socket:
|
|
280
|
+
try:
|
|
281
|
+
self._active_socket.close()
|
|
282
|
+
except OSError:
|
|
283
|
+
pass
|
|
284
|
+
self._active_socket = None
|
|
285
|
+
self.attached = False
|
|
286
|
+
self.attached_pid = None
|
|
287
|
+
|
|
288
|
+
def bridge(self, conn: socket.socket, cli_pid: int | None = None) -> None:
|
|
289
|
+
"""Bridge a TCP socket to this session's SSH I/O. Blocks until detach."""
|
|
290
|
+
with self._lock:
|
|
291
|
+
self.attached = True
|
|
292
|
+
self.attached_pid = cli_pid
|
|
293
|
+
# Snapshot scrollback to replay. Leave _active_socket unset for now so
|
|
294
|
+
# the reader doesn't stream live output until the replay has been sent.
|
|
295
|
+
replay = bytes(self.scrollback)
|
|
296
|
+
|
|
297
|
+
# Replay outside the lock — like the reader's send, a blocking sendall to a
|
|
298
|
+
# stalled client must not hold the session lock. Output produced during the
|
|
299
|
+
# replay stays in scrollback (it just isn't shown live to this attach).
|
|
300
|
+
if replay:
|
|
301
|
+
try:
|
|
302
|
+
conn.sendall(replay)
|
|
303
|
+
except OSError:
|
|
304
|
+
self.detach()
|
|
305
|
+
return
|
|
306
|
+
with self._lock:
|
|
307
|
+
self._active_socket = conn
|
|
308
|
+
|
|
309
|
+
# Socket → SSH input (PTY master, or stdin pipe on Windows)
|
|
310
|
+
try:
|
|
311
|
+
while True:
|
|
312
|
+
data = conn.recv(READ_CHUNK)
|
|
313
|
+
if not data:
|
|
314
|
+
break
|
|
315
|
+
if not self._write_input(data):
|
|
316
|
+
break
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
finally:
|
|
320
|
+
self.detach()
|
|
321
|
+
log.info("Bridge ended for %s/%s", self.alias, self.name)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ProcessManager:
|
|
325
|
+
def __init__(self) -> None:
|
|
326
|
+
self.sessions: dict[str, SshSession] = {}
|
|
327
|
+
self._ssh = _find_ssh()
|
|
328
|
+
# Guards every access to self.sessions. Reentrant because the public
|
|
329
|
+
# methods call one another (attach -> connect, rebuild -> _kill/connect).
|
|
330
|
+
# Lock order is always ProcessManager._lock -> SshSession._lock, never the
|
|
331
|
+
# reverse: bridge()/the reader thread take only the session lock, so they
|
|
332
|
+
# can run while a handler holds this one (e.g. during _kill's wait).
|
|
333
|
+
self._lock = threading.RLock()
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _key(alias: str, name: str) -> str:
|
|
337
|
+
return f"{alias}/{name}"
|
|
338
|
+
|
|
339
|
+
def _next_name(self, alias: str) -> str:
|
|
340
|
+
existing = {s.name for s in self.sessions.values() if s.alias == alias}
|
|
341
|
+
n = 1
|
|
342
|
+
while f"{alias}-{n}" in existing:
|
|
343
|
+
n += 1
|
|
344
|
+
return f"{alias}-{n}"
|
|
345
|
+
|
|
346
|
+
def _spawn(self, alias: str) -> tuple[subprocess.Popen, int | None]:
|
|
347
|
+
cmd = [
|
|
348
|
+
self._ssh, alias,
|
|
349
|
+
"-tt", # force remote PTY
|
|
350
|
+
"-o", "ServerAliveInterval=10",
|
|
351
|
+
"-o", "ServerAliveCountMax=3",
|
|
352
|
+
]
|
|
353
|
+
log.info("Spawning: %s", " ".join(cmd))
|
|
354
|
+
if _HAS_PTY:
|
|
355
|
+
return _spawn_with_pty(cmd)
|
|
356
|
+
proc = subprocess.Popen(
|
|
357
|
+
cmd,
|
|
358
|
+
stdout=subprocess.PIPE,
|
|
359
|
+
stderr=subprocess.STDOUT,
|
|
360
|
+
stdin=subprocess.PIPE,
|
|
361
|
+
**no_window_popen_flags(),
|
|
362
|
+
)
|
|
363
|
+
return proc, None
|
|
364
|
+
|
|
365
|
+
def connect(self, alias: str, name: str | None = None) -> SshSession:
|
|
366
|
+
with self._lock:
|
|
367
|
+
if name is None:
|
|
368
|
+
name = self._next_name(alias)
|
|
369
|
+
|
|
370
|
+
key = self._key(alias, name)
|
|
371
|
+
existing = self.sessions.get(key)
|
|
372
|
+
if existing and existing.alive:
|
|
373
|
+
return existing
|
|
374
|
+
|
|
375
|
+
session = SshSession(alias=alias, name=name)
|
|
376
|
+
proc, master_fd = self._spawn(alias)
|
|
377
|
+
session.adopt_process(proc, master_fd)
|
|
378
|
+
self.sessions[key] = session
|
|
379
|
+
log.info("Connected %s (PID %s)", key, session.pid)
|
|
380
|
+
return session
|
|
381
|
+
|
|
382
|
+
def attach(
|
|
383
|
+
self, alias: str, name: str | None = None, cli_pid: int | None = None
|
|
384
|
+
) -> SshSession | None:
|
|
385
|
+
"""Reserve the first unattached live session (or create one) for a client.
|
|
386
|
+
|
|
387
|
+
The session is marked attached here, atomically, so two concurrent attach
|
|
388
|
+
requests can never hand the same shell to two clients. bridge() later just
|
|
389
|
+
confirms the flag; check_orphaned_attaches reclaims it if the client dies
|
|
390
|
+
before bridging.
|
|
391
|
+
"""
|
|
392
|
+
with self._lock:
|
|
393
|
+
if name:
|
|
394
|
+
session = self.sessions.get(self._key(alias, name))
|
|
395
|
+
if session and not session.attached and session.alive:
|
|
396
|
+
session.attached = True
|
|
397
|
+
session.attached_pid = cli_pid
|
|
398
|
+
return session
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
for s in self.sessions.values():
|
|
402
|
+
if s.alias == alias and not s.attached and s.alive:
|
|
403
|
+
s.attached = True
|
|
404
|
+
s.attached_pid = cli_pid
|
|
405
|
+
return s
|
|
406
|
+
|
|
407
|
+
session = self.connect(alias)
|
|
408
|
+
session.attached = True
|
|
409
|
+
session.attached_pid = cli_pid
|
|
410
|
+
return session
|
|
411
|
+
|
|
412
|
+
# The disconnect paths pop the session(s) out of the dict under the lock, then
|
|
413
|
+
# _kill them OUTSIDE it: killing blocks on process.wait(), and holding the PM
|
|
414
|
+
# lock across that would serialize every other session operation for seconds.
|
|
415
|
+
def disconnect(self, alias: str, name: str) -> bool:
|
|
416
|
+
with self._lock:
|
|
417
|
+
session = self.sessions.pop(self._key(alias, name), None)
|
|
418
|
+
if not session:
|
|
419
|
+
return False
|
|
420
|
+
session.reconnect = False
|
|
421
|
+
self._kill(session)
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
def disconnect_alias(self, alias: str) -> int:
|
|
425
|
+
with self._lock:
|
|
426
|
+
keys = [k for k, s in self.sessions.items() if s.alias == alias]
|
|
427
|
+
sessions = [self.sessions.pop(k) for k in keys]
|
|
428
|
+
for session in sessions:
|
|
429
|
+
session.reconnect = False
|
|
430
|
+
self._kill(session)
|
|
431
|
+
return len(sessions)
|
|
432
|
+
|
|
433
|
+
def disconnect_all(self) -> None:
|
|
434
|
+
with self._lock:
|
|
435
|
+
sessions = list(self.sessions.values())
|
|
436
|
+
self.sessions.clear()
|
|
437
|
+
for session in sessions:
|
|
438
|
+
session.reconnect = False
|
|
439
|
+
self._kill(session)
|
|
440
|
+
|
|
441
|
+
def _kill(self, session: SshSession) -> None:
|
|
442
|
+
"""Terminate a session. Must be called on a session already removed from
|
|
443
|
+
the dict, and NOT while holding self._lock (it blocks on process.wait)."""
|
|
444
|
+
session.detach() # kick the attached client, if any
|
|
445
|
+
|
|
446
|
+
if session.alive:
|
|
447
|
+
try:
|
|
448
|
+
session.process.terminate()
|
|
449
|
+
session.process.wait(timeout=5)
|
|
450
|
+
except Exception:
|
|
451
|
+
try:
|
|
452
|
+
session.process.kill()
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
log.info("Killed %s/%s (PID %s)", session.alias, session.name, session.pid)
|
|
456
|
+
|
|
457
|
+
# Null the fd under the session lock (so a racing writer sees None and
|
|
458
|
+
# won't dup it), then close it outside the lock.
|
|
459
|
+
with session._lock:
|
|
460
|
+
fd = session.master_fd
|
|
461
|
+
session.master_fd = None
|
|
462
|
+
if fd is not None:
|
|
463
|
+
try:
|
|
464
|
+
os.close(fd)
|
|
465
|
+
except OSError:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
def get_sessions(self, alias: str | None = None) -> list[SshSession]:
|
|
469
|
+
with self._lock:
|
|
470
|
+
if alias:
|
|
471
|
+
return [s for s in self.sessions.values() if s.alias == alias]
|
|
472
|
+
return list(self.sessions.values())
|
|
473
|
+
|
|
474
|
+
def count_unattached(self, alias: str) -> int:
|
|
475
|
+
with self._lock:
|
|
476
|
+
return sum(
|
|
477
|
+
1 for s in self.sessions.values()
|
|
478
|
+
if s.alias == alias and not s.attached and s.alive
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def ensure_unattached(self, alias: str) -> None:
|
|
482
|
+
with self._lock:
|
|
483
|
+
if self.count_unattached(alias) == 0:
|
|
484
|
+
log.info("Spawning new unattached session for %s", alias)
|
|
485
|
+
self.connect(alias)
|
|
486
|
+
|
|
487
|
+
def check_health(self) -> None:
|
|
488
|
+
with self._lock:
|
|
489
|
+
to_reconnect: list[SshSession] = []
|
|
490
|
+
|
|
491
|
+
for key, session in list(self.sessions.items()):
|
|
492
|
+
if not session.process:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
retcode = session.process.poll()
|
|
496
|
+
if retcode is None:
|
|
497
|
+
if time.time() - session.started_at > STABLE_THRESHOLD:
|
|
498
|
+
session._backoff = RECONNECT_MIN
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
log.warning("Session %s exited (code %s)", key, retcode)
|
|
502
|
+
session.detach()
|
|
503
|
+
|
|
504
|
+
# Clean exit (exit/logout) = remove session, don't reconnect.
|
|
505
|
+
# Non-zero (e.g. 255 = connection lost) = reconnect if enabled.
|
|
506
|
+
if retcode == 0 or not session.reconnect:
|
|
507
|
+
self.sessions.pop(key, None)
|
|
508
|
+
else:
|
|
509
|
+
now = time.time()
|
|
510
|
+
if now - session._last_attempt >= session._backoff:
|
|
511
|
+
to_reconnect.append(session)
|
|
512
|
+
session._last_attempt = now
|
|
513
|
+
|
|
514
|
+
for session in to_reconnect:
|
|
515
|
+
log.info(
|
|
516
|
+
"Reconnecting %s/%s (backoff %.1fs)",
|
|
517
|
+
session.alias, session.name, session._backoff,
|
|
518
|
+
)
|
|
519
|
+
try:
|
|
520
|
+
proc, master_fd = self._spawn(session.alias)
|
|
521
|
+
session.adopt_process(proc, master_fd)
|
|
522
|
+
log.info("Reconnected %s/%s (PID %s)", session.alias, session.name, session.pid)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
log.error("Failed to reconnect %s/%s: %s", session.alias, session.name, e)
|
|
525
|
+
finally:
|
|
526
|
+
session._backoff = min(session._backoff * 2, RECONNECT_MAX)
|
|
527
|
+
|
|
528
|
+
def check_orphaned_attaches(self) -> None:
|
|
529
|
+
with self._lock:
|
|
530
|
+
for s in self.sessions.values():
|
|
531
|
+
if s.attached and s.attached_pid and not pid_alive(s.attached_pid):
|
|
532
|
+
log.warning(
|
|
533
|
+
"CLI pid %s gone, auto-detaching %s/%s",
|
|
534
|
+
s.attached_pid, s.alias, s.name,
|
|
535
|
+
)
|
|
536
|
+
s.detach()
|
|
537
|
+
|
|
538
|
+
def rebuild_session(self, alias: str, name: str) -> SshSession | None:
|
|
539
|
+
"""Kill and restart a session (e.g. after a config change)."""
|
|
540
|
+
with self._lock:
|
|
541
|
+
session = self.sessions.pop(self._key(alias, name), None)
|
|
542
|
+
if not session:
|
|
543
|
+
return None
|
|
544
|
+
self._kill(session) # outside the lock — blocks on process.wait
|
|
545
|
+
return self.connect(alias, name)
|
sshm/procutil.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cross-platform process helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pid_alive(pid: int) -> bool:
|
|
12
|
+
"""Check whether a process with the given PID exists."""
|
|
13
|
+
try:
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
import ctypes
|
|
16
|
+
|
|
17
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
18
|
+
kernel32 = ctypes.windll.kernel32
|
|
19
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
20
|
+
if not handle:
|
|
21
|
+
return False
|
|
22
|
+
kernel32.CloseHandle(handle)
|
|
23
|
+
return True
|
|
24
|
+
os.kill(pid, 0)
|
|
25
|
+
return True
|
|
26
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def daemon_interpreter() -> str:
|
|
31
|
+
"""Interpreter for spawning the background daemon.
|
|
32
|
+
|
|
33
|
+
On Windows prefers pythonw.exe (GUI subsystem — can never open a console
|
|
34
|
+
window), falling back to the current interpreter.
|
|
35
|
+
"""
|
|
36
|
+
if sys.platform == "win32":
|
|
37
|
+
pythonw = Path(sys.executable).with_name("pythonw.exe")
|
|
38
|
+
if pythonw.exists():
|
|
39
|
+
return str(pythonw)
|
|
40
|
+
return sys.executable
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detached_popen_flags() -> dict:
|
|
44
|
+
"""Popen kwargs that detach the child from the current console/session."""
|
|
45
|
+
if sys.platform == "win32":
|
|
46
|
+
# CREATE_NO_WINDOW (hidden console) instead of DETACHED_PROCESS (no
|
|
47
|
+
# console): launcher/trampoline exes (uv venv python, script shims)
|
|
48
|
+
# re-spawn the real interpreter as a child, which would allocate a
|
|
49
|
+
# fresh *visible* console when the parent has none. A hidden console
|
|
50
|
+
# is inherited by such children.
|
|
51
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
52
|
+
return {"creationflags": CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
|
|
53
|
+
return {"start_new_session": True}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def no_window_popen_flags() -> dict:
|
|
57
|
+
"""Popen kwargs that suppress a console window on Windows."""
|
|
58
|
+
if sys.platform == "win32":
|
|
59
|
+
return {"creationflags": subprocess.CREATE_NO_WINDOW}
|
|
60
|
+
return {}
|
sshm/protocol.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""IPC message schemas and encoding for sshm daemon communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# --- Request commands ---
|
|
10
|
+
|
|
11
|
+
CMD_LIST = "list"
|
|
12
|
+
CMD_CONNECT = "connect"
|
|
13
|
+
CMD_DISCONNECT = "disconnect"
|
|
14
|
+
CMD_ATTACH = "attach"
|
|
15
|
+
CMD_DETACH = "detach"
|
|
16
|
+
CMD_RESIZE = "resize"
|
|
17
|
+
CMD_PORT_ADD = "port_add"
|
|
18
|
+
CMD_PORT_REMOVE = "port_remove"
|
|
19
|
+
CMD_ENABLE = "enable"
|
|
20
|
+
CMD_DISABLE = "disable"
|
|
21
|
+
CMD_REMOVE = "remove"
|
|
22
|
+
CMD_RENAME = "rename"
|
|
23
|
+
CMD_STATUS = "status"
|
|
24
|
+
CMD_SHUTDOWN = "shutdown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_request(cmd: str, token: str, **kwargs) -> dict[str, Any]:
|
|
28
|
+
return {"cmd": cmd, "token": token, **kwargs}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_response(ok: bool, data: Any = None, error: str | None = None) -> dict[str, Any]:
|
|
32
|
+
resp: dict[str, Any] = {"ok": ok}
|
|
33
|
+
if data is not None:
|
|
34
|
+
resp["data"] = data
|
|
35
|
+
if error is not None:
|
|
36
|
+
resp["error"] = error
|
|
37
|
+
return resp
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ok(data: Any = None) -> dict[str, Any]:
|
|
41
|
+
return make_response(True, data=data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def err(message: str) -> dict[str, Any]:
|
|
45
|
+
return make_response(False, error=message)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def encode(msg: dict[str, Any]) -> bytes:
|
|
49
|
+
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode(data: bytes) -> dict[str, Any]:
|
|
53
|
+
return json.loads(data.decode("utf-8").strip())
|
sshm/py.typed
ADDED
|
File without changes
|