@neoline/hostbridge 2.0.4 → 2.0.5

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 (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. package/src/server_control_mcp/transports/pty.py +0 -515
@@ -4,20 +4,31 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import hashlib
7
- import threading
7
+ import math
8
8
  import uuid
9
9
  from collections import deque
10
- from collections.abc import AsyncGenerator, Awaitable, Iterable, Iterator, Sequence
10
+ from collections.abc import AsyncGenerator, Sequence
11
11
  from contextlib import suppress
12
12
  from dataclasses import dataclass
13
- from pathlib import Path
14
- from typing import TypeVar
15
13
 
16
14
  import asyncssh
17
15
 
18
- from .base import ExecResult, ShellReadResult, TransferResult, TransportCapabilities, TransportError
19
-
20
- T = TypeVar("T")
16
+ from ..async_lifecycle import (
17
+ finish_task_before_cancellation,
18
+ finish_task_despite_cancellation,
19
+ stop_input_after_peer_eof,
20
+ )
21
+ from .base import (
22
+ ByteStream,
23
+ ExecResult,
24
+ ShellAttachment,
25
+ ShellReadResult,
26
+ ShellTerminalResult,
27
+ TransferResult,
28
+ TransportCapabilities,
29
+ TransportError,
30
+ iterate_byte_stream,
31
+ )
21
32
 
22
33
 
23
34
  class _CombinedOutputBuffer:
@@ -62,18 +73,36 @@ class SshConnectionConfig:
62
73
  password: str | None = None
63
74
 
64
75
 
65
- class _AsyncSshTransport:
76
+ DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS = 5.0
77
+
78
+
79
+ class AsyncSshConnection:
66
80
  capabilities = TransportCapabilities(
67
81
  separate_stderr=True,
68
82
  native_binary_transfer=True,
69
83
  parallel_channels=8,
70
84
  )
71
85
 
72
- def __init__(self, connection: asyncssh.SSHClientConnection) -> None:
86
+ def __init__(
87
+ self,
88
+ connection: asyncssh.SSHClientConnection,
89
+ *,
90
+ sftp_cleanup_timeout: float = DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS,
91
+ ) -> None:
92
+ if (
93
+ not isinstance(sftp_cleanup_timeout, (int, float))
94
+ or isinstance(sftp_cleanup_timeout, bool)
95
+ or not math.isfinite(sftp_cleanup_timeout)
96
+ or sftp_cleanup_timeout <= 0
97
+ ):
98
+ raise ValueError("sftp_cleanup_timeout must be a finite positive number")
73
99
  self._connection = connection
100
+ self._sftp_cleanup_timeout = float(sftp_cleanup_timeout)
101
+ self._shells: dict[str, asyncssh.SSHClientProcess[bytes]] = {}
102
+ self._shell_creation_lock = asyncio.Lock()
74
103
 
75
104
  @classmethod
76
- async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> _AsyncSshTransport:
105
+ async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> AsyncSshConnection:
77
106
  if not config.host.strip():
78
107
  raise ValueError("SSH host must not be empty")
79
108
  if not 1 <= config.port <= 65535:
@@ -95,7 +124,7 @@ class _AsyncSshTransport:
95
124
  self,
96
125
  command: str,
97
126
  *,
98
- stdin: Iterable[bytes] | None,
127
+ stdin: ByteStream | None,
99
128
  timeout: float,
100
129
  output_limit: int,
101
130
  ) -> ExecResult:
@@ -109,15 +138,8 @@ class _AsyncSshTransport:
109
138
  self._connection.create_process(command, encoding=None)
110
139
  )
111
140
  try:
112
- process = await asyncio.shield(open_task)
141
+ process = await open_task
113
142
  except asyncio.CancelledError:
114
- self._connection.abort()
115
- close_task = asyncio.create_task(self._connection.wait_closed())
116
- while not close_task.done():
117
- try:
118
- await asyncio.shield(close_task)
119
- except asyncio.CancelledError:
120
- continue
121
143
  if not open_task.done():
122
144
  open_task.cancel()
123
145
  await asyncio.gather(open_task, return_exceptions=True)
@@ -134,17 +156,13 @@ class _AsyncSshTransport:
134
156
  stdout, stderr = output.outputs()
135
157
  return ExecResult(process.exit_status, stdout, stderr)
136
158
  except TimeoutError:
137
- await self._abort_process(process, tasks)
159
+ cleanup = asyncio.create_task(self._abort_process(process, tasks))
160
+ await finish_task_before_cancellation(cleanup)
138
161
  stdout, stderr = output.outputs()
139
162
  return ExecResult(None, stdout, stderr, timed_out=True)
140
163
  except BaseException:
141
164
  cleanup = asyncio.create_task(self._abort_process(process, tasks))
142
- while not cleanup.done():
143
- try:
144
- await asyncio.shield(cleanup)
145
- except asyncio.CancelledError:
146
- continue
147
- await cleanup
165
+ await finish_task_despite_cancellation(cleanup)
148
166
  raise
149
167
 
150
168
  @staticmethod
@@ -157,10 +175,10 @@ class _AsyncSshTransport:
157
175
  wait_task = tasks[-1]
158
176
  try:
159
177
  await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
160
- except BaseException:
178
+ except Exception:
161
179
  process.kill()
162
180
  process.close()
163
- with suppress(BaseException):
181
+ with suppress(Exception):
164
182
  await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
165
183
  for task in tasks:
166
184
  if not task.done():
@@ -168,15 +186,9 @@ class _AsyncSshTransport:
168
186
  await asyncio.gather(*tasks, return_exceptions=True)
169
187
 
170
188
  @staticmethod
171
- async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: Iterable[bytes] | None) -> None:
189
+ async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: ByteStream | None) -> None:
172
190
  if stdin is not None:
173
- iterator = iter(stdin)
174
- while True:
175
- present, chunk = await asyncio.to_thread(_next_chunk, iterator)
176
- if not present:
177
- break
178
- if not isinstance(chunk, bytes):
179
- raise TypeError("stdin chunks must be bytes")
191
+ async for chunk in iterate_byte_stream(stdin, label="stdin"):
180
192
  process.stdin.write(chunk)
181
193
  await process.stdin.drain()
182
194
  process.stdin.write_eof()
@@ -189,81 +201,100 @@ class _AsyncSshTransport:
189
201
  break
190
202
  output.append(bytes(chunk), stderr=stderr)
191
203
 
192
- async def upload_file(
193
- self,
194
- local_path: Path | str,
195
- remote_path: str,
196
- *,
197
- chunk_size: int = 256 * 1024,
198
- mode: int = 0o644,
199
- ) -> TransferResult:
200
- source = Path(local_path)
201
- self._validate_chunk_size(chunk_size)
202
- self._validate_remote_path(remote_path)
203
- temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
204
- digest = hashlib.sha256()
205
- transferred = 0
206
- sftp = await self._connection.start_sftp_client()
207
- try:
208
- local_file = source.open("rb")
209
- try:
210
- async with sftp.open(temporary, "wb") as remote_file:
211
- while True:
212
- chunk = await asyncio.to_thread(local_file.read, chunk_size)
213
- if not chunk:
214
- break
215
- await remote_file.write(chunk)
216
- digest.update(chunk)
217
- transferred += len(chunk)
218
- finally:
219
- local_file.close()
220
- await sftp.chmod(temporary, mode)
221
- await sftp.rename(temporary, remote_path)
222
- except Exception:
223
- with suppress(Exception):
224
- await sftp.remove(temporary)
225
- raise
226
- finally:
227
- sftp.exit()
228
- return TransferResult(transferred, digest.hexdigest())
229
-
230
204
  async def upload(
231
205
  self,
232
- chunks: Iterable[bytes],
206
+ chunks: ByteStream,
233
207
  remote_path: str,
234
208
  *,
235
209
  mode: int,
236
210
  expected_size: int | None,
211
+ expected_sha256: str | None,
237
212
  ) -> TransferResult:
238
213
  self._validate_remote_path(remote_path)
239
214
  temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
240
215
  digest = hashlib.sha256()
241
216
  transferred = 0
242
- iterator = iter(chunks)
243
217
  sftp = await self._connection.start_sftp_client()
244
218
  try:
245
219
  async with sftp.open(temporary, "wb", encoding=None) as remote_file:
246
- while True:
247
- present, chunk = await asyncio.to_thread(_next_chunk, iterator)
248
- if not present:
249
- break
250
- if not isinstance(chunk, bytes):
251
- raise TypeError("upload chunks must be bytes")
220
+ async for chunk in iterate_byte_stream(chunks, label="upload"):
252
221
  await remote_file.write(chunk)
253
222
  digest.update(chunk)
254
223
  transferred += len(chunk)
255
224
  if expected_size is not None and transferred != expected_size:
256
225
  raise TransportError(f"upload size mismatch: expected {expected_size}, received {transferred}")
226
+ actual_sha256 = digest.hexdigest()
227
+ if expected_sha256 is not None and actual_sha256.lower() != expected_sha256.lower():
228
+ raise TransportError(
229
+ f"upload SHA-256 mismatch: expected {expected_sha256.lower()}, received {actual_sha256}"
230
+ )
257
231
  await sftp.chmod(temporary, mode)
258
232
  await sftp.rename(temporary, remote_path)
259
- except BaseException:
260
- with suppress(Exception):
261
- await sftp.remove(temporary)
233
+ except BaseException as primary_error:
234
+ cleanup = asyncio.create_task(self._finish_sftp(sftp, temporary=temporary))
235
+ try:
236
+ await finish_task_despite_cancellation(cleanup)
237
+ except BaseException as cleanup_error:
238
+ primary_error.add_note(f"SFTP temporary cleanup failed: {cleanup_error}")
262
239
  raise
263
- finally:
264
- sftp.exit()
240
+ cleanup = asyncio.create_task(self._finish_sftp(sftp))
241
+ await finish_task_before_cancellation(cleanup)
265
242
  return TransferResult(transferred, digest.hexdigest())
266
243
 
244
+ async def _finish_sftp(self, sftp: asyncssh.SFTPClient, *, temporary: str | None = None) -> None:
245
+ removed = await self._close_sftp_channel(sftp, temporary=temporary)
246
+ if temporary is None or removed:
247
+ return
248
+
249
+ replacement: asyncssh.SFTPClient | None = None
250
+ try:
251
+ async with asyncio.timeout(self._sftp_cleanup_timeout):
252
+ replacement = await self._connection.start_sftp_client()
253
+ except Exception as exc:
254
+ self._abort_connection()
255
+ raise TransportError(f"could not reopen SFTP to remove temporary file {temporary!r}") from exc
256
+
257
+ if await self._close_sftp_channel(replacement, temporary=temporary):
258
+ return
259
+ self._abort_connection()
260
+ raise TransportError(f"could not remove SFTP temporary file {temporary!r}")
261
+
262
+ async def _close_sftp_channel(self, sftp: asyncssh.SFTPClient, *, temporary: str | None) -> bool:
263
+ exit_sent = False
264
+ removed = temporary is None
265
+ try:
266
+ async with asyncio.timeout(self._sftp_cleanup_timeout):
267
+ if temporary is not None:
268
+ try:
269
+ await sftp.remove(temporary)
270
+ except asyncssh.SFTPNoSuchFile:
271
+ removed = True
272
+ else:
273
+ removed = True
274
+ sftp.exit()
275
+ exit_sent = True
276
+ with suppress(Exception):
277
+ await sftp.wait_closed()
278
+ except Exception:
279
+ pass
280
+ finally:
281
+ if not exit_sent:
282
+ with suppress(Exception):
283
+ sftp.exit()
284
+ return removed
285
+
286
+ def _abort_connection(self) -> None:
287
+ try:
288
+ self._connection.abort()
289
+ except Exception as exc:
290
+ try:
291
+ self._connection.close()
292
+ except Exception as fallback_error:
293
+ fallback_error.add_note(f"SSH connection abort failed: {exc}")
294
+ raise TransportError(
295
+ "could not terminate SSH connection after SFTP cleanup failure"
296
+ ) from fallback_error
297
+
267
298
  async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
268
299
  self._validate_chunk_size(chunk_size)
269
300
  self._validate_remote_path(remote_path)
@@ -276,45 +307,8 @@ class _AsyncSshTransport:
276
307
  break
277
308
  yield raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
278
309
  finally:
279
- sftp.exit()
280
-
281
- async def download_file(
282
- self,
283
- remote_path: str,
284
- local_path: Path | str,
285
- *,
286
- chunk_size: int = 256 * 1024,
287
- ) -> TransferResult:
288
- destination = Path(local_path)
289
- self._validate_chunk_size(chunk_size)
290
- self._validate_remote_path(remote_path)
291
- temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
292
- digest = hashlib.sha256()
293
- transferred = 0
294
- sftp = await self._connection.start_sftp_client()
295
- try:
296
- destination.parent.mkdir(parents=True, exist_ok=True)
297
- local_file = temporary.open("wb")
298
- try:
299
- async with sftp.open(remote_path, "rb") as remote_file:
300
- while True:
301
- raw_chunk = await remote_file.read(chunk_size)
302
- if not raw_chunk:
303
- break
304
- chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
305
- await asyncio.to_thread(local_file.write, chunk)
306
- digest.update(chunk)
307
- transferred += len(chunk)
308
- finally:
309
- local_file.close()
310
- temporary.chmod(0o600)
311
- temporary.replace(destination)
312
- except Exception:
313
- temporary.unlink(missing_ok=True)
314
- raise
315
- finally:
316
- sftp.exit()
317
- return TransferResult(transferred, digest.hexdigest())
310
+ cleanup = asyncio.create_task(self._finish_sftp(sftp))
311
+ await finish_task_before_cancellation(cleanup)
318
312
 
319
313
  @staticmethod
320
314
  def _validate_chunk_size(chunk_size: int) -> None:
@@ -326,154 +320,107 @@ class _AsyncSshTransport:
326
320
  if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
327
321
  raise ValueError("remote_path must be a non-empty path without NUL bytes")
328
322
 
329
- async def shell_write(self, data: bytes) -> None:
323
+ async def shell_write(self, shell_id: str, data: bytes) -> None:
330
324
  if not isinstance(data, bytes):
331
325
  raise TypeError("shell data must be bytes")
332
- process = await self._ensure_shell()
326
+ process = await self._ensure_shell(shell_id)
333
327
  process.stdin.write(data)
334
328
  await process.stdin.drain()
335
329
 
336
- async def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
337
- if timeout < 0:
330
+ async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
331
+ if timeout is not None and timeout < 0:
338
332
  raise ValueError("timeout must be non-negative")
339
333
  if max_bytes <= 0:
340
334
  raise ValueError("max_bytes must be positive")
341
- process = await self._ensure_shell()
342
- try:
343
- data = await asyncio.wait_for(process.stdout.read(max_bytes), timeout=timeout)
344
- except TimeoutError:
345
- data = b""
346
- return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
347
-
348
- async def _ensure_shell(self) -> asyncssh.SSHClientProcess[bytes]:
349
- shell = getattr(self, "_shell", None)
350
- if shell is None or shell.exit_status is not None:
351
- shell = await self._connection.create_process(term_type="xterm", encoding=None)
352
- self._shell = shell
353
- return shell
354
-
355
- async def close(self) -> None:
356
- shell = getattr(self, "_shell", None)
357
- if shell is not None:
358
- shell.terminate()
359
- shell.close()
360
- with suppress(Exception):
361
- await asyncio.wait_for(shell.wait_closed(), timeout=1)
362
- self._connection.close()
363
- await self._connection.wait_closed()
364
-
365
-
366
- def _next_chunk(iterator: Iterator[bytes]) -> tuple[bool, bytes]:
367
- try:
368
- return True, next(iterator)
369
- except StopIteration:
370
- return False, b""
371
-
372
-
373
- class _EventLoopThread:
374
- def __init__(self) -> None:
375
- self.loop = asyncio.new_event_loop()
376
- self.thread = threading.Thread(target=self._run, name="hostbridge-ssh", daemon=True)
377
- self.started = threading.Event()
378
- self.thread.start()
379
- self.started.wait()
380
-
381
- def _run(self) -> None:
382
- asyncio.set_event_loop(self.loop)
383
- self.started.set()
384
- self.loop.run_forever()
385
- self.loop.run_until_complete(self.loop.shutdown_asyncgens())
386
- self.loop.close()
387
-
388
- def run(self, awaitable: Awaitable[T]) -> T:
389
- if not self.thread.is_alive():
390
- raise TransportError("SSH transport is closed")
391
- return asyncio.run_coroutine_threadsafe(_await_value(awaitable), self.loop).result()
392
-
393
- def stop(self) -> None:
394
- if self.thread.is_alive():
395
- self.loop.call_soon_threadsafe(self.loop.stop)
396
- self.thread.join(timeout=2)
397
-
398
-
399
- class SshTransport:
400
- capabilities = _AsyncSshTransport.capabilities
401
-
402
- def __init__(self, core: _AsyncSshTransport, runner: _EventLoopThread) -> None:
403
- self._core = core
404
- self._runner = runner
405
- self._close_lock = threading.Lock()
406
- self._closed = False
407
-
408
- @classmethod
409
- def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> SshTransport:
410
- runner = _EventLoopThread()
411
- try:
412
- core = runner.run(_AsyncSshTransport.connect(config, connect_timeout=connect_timeout))
413
- except BaseException:
414
- runner.stop()
415
- raise
416
- return cls(core, runner)
417
-
418
- def exec(
419
- self,
420
- command: str,
421
- *,
422
- stdin: Iterable[bytes] | None,
423
- timeout: float,
424
- output_limit: int,
425
- ) -> ExecResult:
426
- return self._run(self._core.exec(command, stdin=stdin, timeout=timeout, output_limit=output_limit))
427
-
428
- def upload(
429
- self,
430
- chunks: Iterable[bytes],
431
- remote_path: str,
432
- *,
433
- mode: int,
434
- expected_size: int | None,
435
- ) -> TransferResult:
436
- return self._run(self._core.upload(chunks, remote_path, mode=mode, expected_size=expected_size))
437
-
438
- def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
439
- stream = self._core.download(remote_path, chunk_size=chunk_size)
440
-
441
- def chunks() -> Iterator[bytes]:
335
+ process = await self._ensure_shell(shell_id)
336
+ if timeout is None:
337
+ data = await process.stdout.read(max_bytes)
338
+ else:
442
339
  try:
443
- while True:
444
- try:
445
- yield self._run(stream.__anext__())
446
- except StopAsyncIteration:
447
- return
448
- finally:
449
- self._run(stream.aclose())
450
-
451
- return chunks()
340
+ data = await asyncio.wait_for(process.stdout.read(max_bytes), timeout=timeout)
341
+ except TimeoutError:
342
+ data = b""
343
+ return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
452
344
 
453
- def shell_write(self, data: bytes) -> None:
454
- self._run(self._core.shell_write(data))
345
+ async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
346
+ process = await self._ensure_shell(shell_id)
347
+ terminal = asyncio.get_running_loop().create_future()
455
348
 
456
- def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
457
- return self._run(self._core.shell_read(timeout=timeout, max_bytes=max_bytes))
349
+ async def send_input() -> None:
350
+ async for chunk in iterate_byte_stream(stdin, label="shell input"):
351
+ if len(chunk) > max_bytes:
352
+ raise TransportError(f"shell input chunk exceeds {max_bytes} bytes")
353
+ process.stdin.write(chunk)
354
+ await process.stdin.drain()
355
+ process.stdin.write_eof()
458
356
 
459
- def close(self) -> None:
460
- with self._close_lock:
461
- if self._closed:
462
- return
463
- self._closed = True
357
+ async def output() -> AsyncGenerator[bytes, None]:
358
+ input_task = asyncio.create_task(send_input(), name=f"hostbridge-native-shell-input-{shell_id}")
464
359
  try:
465
- self._runner.run(self._core.close())
360
+ while data := await process.stdout.read(max_bytes):
361
+ yield bytes(data)
362
+ await stop_input_after_peer_eof(input_task)
363
+ await process.wait_closed()
364
+ terminal.set_result(_ssh_shell_terminal(process))
365
+ except BaseException as exc:
366
+ if not terminal.done():
367
+ terminal.set_exception(exc)
368
+ terminal.exception()
369
+ raise
466
370
  finally:
467
- self._runner.stop()
371
+ if not input_task.done():
372
+ input_task.cancel()
373
+ cleanup = asyncio.gather(input_task, return_exceptions=True)
374
+ await finish_task_before_cancellation(cleanup)
375
+
376
+ return ShellAttachment(output(), terminal)
377
+
378
+ async def _ensure_shell(self, shell_id: str) -> asyncssh.SSHClientProcess[bytes]:
379
+ if not shell_id:
380
+ raise ValueError("shell_id must not be empty")
381
+ async with self._shell_creation_lock:
382
+ shell = self._shells.get(shell_id)
383
+ if shell is None or shell.exit_status is not None:
384
+ shell = await self._connection.create_process(term_type="xterm", encoding=None)
385
+ self._shells[shell_id] = shell
386
+ return shell
387
+
388
+ async def shell_close(self, shell_id: str) -> None:
389
+ async with self._shell_creation_lock:
390
+ shell = self._shells.pop(shell_id, None)
391
+ if shell is None:
392
+ return
393
+ cleanup = asyncio.create_task(self._close_shell_process(shell))
394
+ await finish_task_before_cancellation(cleanup)
468
395
 
469
- def _run(self, awaitable: Awaitable[T]) -> T:
396
+ @staticmethod
397
+ async def _close_shell_process(shell: asyncssh.SSHClientProcess[bytes]) -> None:
398
+ shell.terminate()
399
+ shell.close()
470
400
  try:
471
- return self._runner.run(awaitable)
472
- except (StopAsyncIteration, TypeError, ValueError, TransportError):
473
- raise
474
- except Exception as exc:
475
- raise TransportError(str(exc) or exc.__class__.__name__) from exc
401
+ await asyncio.wait_for(shell.wait_closed(), timeout=1)
402
+ except TimeoutError:
403
+ shell.kill()
404
+ shell.close()
405
+ with suppress(Exception):
406
+ await asyncio.wait_for(shell.wait_closed(), timeout=1)
407
+ except Exception:
408
+ pass
409
+
410
+ async def close(self) -> None:
411
+ await asyncio.gather(*(self.shell_close(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True)
412
+ self._connection.close()
413
+ await self._connection.wait_closed()
476
414
 
477
415
 
478
- async def _await_value(awaitable: Awaitable[T]) -> T:
479
- return await awaitable
416
+ def _ssh_shell_terminal(process: asyncssh.SSHClientProcess[bytes]) -> ShellTerminalResult:
417
+ exit_status = process.exit_status
418
+ raw_signal = process.exit_signal
419
+ signal_name: str | None = None
420
+ if isinstance(raw_signal, tuple) and raw_signal and isinstance(raw_signal[0], str):
421
+ signal_name = raw_signal[0]
422
+ elif isinstance(raw_signal, str):
423
+ signal_name = raw_signal
424
+ if signal_name:
425
+ return ShellTerminalResult(None, signal_name)
426
+ return ShellTerminalResult(exit_status, None)