@neoline/hostbridge 2.0.2 → 2.0.4

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.
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import queue
5
+ import sys
6
+ import threading
7
+ from contextlib import suppress
8
+ from functools import partial
9
+ from typing import cast
10
+
11
+ import asyncssh
12
+
13
+ from .client import HostBridgeClient, HostBridgeError
14
+ from .mock_ssh_session import HostBridgeSessionFactory
15
+
16
+
17
+ def _to_bytes(chunk: str | bytes) -> bytes:
18
+ if isinstance(chunk, bytes):
19
+ return chunk
20
+ return chunk.encode("utf-8")
21
+
22
+
23
+ class HostBridgeSSHProcess:
24
+ def __init__(
25
+ self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int
26
+ ) -> None:
27
+ self._client = client
28
+ self._session_factory = session_factory
29
+ self._timeout = timeout
30
+ self._output_limit = output_limit
31
+
32
+ async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
33
+ open_task = asyncio.create_task(asyncio.to_thread(self._session_factory.open))
34
+ try:
35
+ session_id = await asyncio.shield(open_task)
36
+ except asyncio.CancelledError:
37
+ session_id = None
38
+ with suppress(BaseException):
39
+ session_id = await open_task
40
+ if session_id is not None:
41
+ await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
42
+ self._set_exit_status(process, 255)
43
+ return
44
+ assert session_id is not None
45
+ reusable = False
46
+ try:
47
+ reusable = await self._run(process, session_id)
48
+ except asyncio.CancelledError:
49
+ self._set_exit_status(process, 255)
50
+ return
51
+ finally:
52
+ await asyncio.to_thread(self._session_factory.close, session_id, reusable=reusable)
53
+
54
+ async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
55
+ command = process.command
56
+ if not command:
57
+ await self._run_shell(process, session_id)
58
+ return False
59
+
60
+ try:
61
+ result = await self._exec_stream(process, session_id, command)
62
+ if result.stdout:
63
+ process.stdout.write(result.stdout)
64
+ if result.stderr:
65
+ process.stderr.write(result.stderr)
66
+ if result.timed_out:
67
+ self._set_exit_status(process, 124)
68
+ return False
69
+ if result.exit_code is None:
70
+ self._set_exit_status(process, 255)
71
+ return False
72
+ self._set_exit_status(process, int(result.exit_code))
73
+ return True
74
+ except Exception as exc:
75
+ print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
76
+ with suppress(Exception):
77
+ process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
78
+ status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
79
+ self._set_exit_status(process, status)
80
+ return False
81
+
82
+ async def _exec_stream(
83
+ self,
84
+ process: asyncssh.SSHServerProcess[bytes],
85
+ session_id: str,
86
+ command: str,
87
+ ):
88
+ loop = asyncio.get_running_loop()
89
+ items: queue.Queue[bytes | BaseException | object] = queue.Queue(maxsize=8)
90
+ end = object()
91
+ cancelled = threading.Event()
92
+
93
+ def chunks():
94
+ while True:
95
+ item = items.get()
96
+ if item is end:
97
+ return
98
+ if isinstance(item, BaseException):
99
+ raise item
100
+ yield cast(bytes, item)
101
+
102
+ consumer = loop.run_in_executor(
103
+ None,
104
+ lambda: self._client.exec_stream(
105
+ session_id,
106
+ command,
107
+ chunks(),
108
+ timeout=self._timeout,
109
+ output_limit=self._output_limit,
110
+ owner="mock-ssh",
111
+ cancel_event=cancelled,
112
+ ),
113
+ )
114
+ staging_deadline = loop.time() + self._timeout
115
+
116
+ async def put(item: bytes | BaseException | object) -> None:
117
+ while True:
118
+ try:
119
+ items.put_nowait(item)
120
+ return
121
+ except queue.Full:
122
+ if consumer.done():
123
+ await consumer
124
+ await asyncio.sleep(0.005)
125
+
126
+ try:
127
+ while not process.stdin.at_eof():
128
+ remaining = staging_deadline - loop.time()
129
+ if remaining <= 0:
130
+ await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin"))
131
+ break
132
+ try:
133
+ data = await asyncio.wait_for(process.stdin.read(64 * 1024), timeout=remaining)
134
+ except TimeoutError:
135
+ await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin"))
136
+ break
137
+ except BaseException as exc:
138
+ await put(exc)
139
+ break
140
+ if not data:
141
+ break
142
+ await put(_to_bytes(data))
143
+ await put(end)
144
+ return await consumer
145
+ except asyncio.CancelledError:
146
+ cancelled.set()
147
+ await put(end)
148
+ with suppress(BaseException):
149
+ await asyncio.shield(consumer)
150
+ raise
151
+
152
+ @staticmethod
153
+ def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
154
+ with suppress(Exception):
155
+ process.exit(status)
156
+
157
+ async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
158
+ loop = asyncio.get_running_loop()
159
+ stop = asyncio.Event()
160
+ input_done = asyncio.Event()
161
+
162
+ async def pump_input() -> None:
163
+ try:
164
+ while not process.stdin.at_eof():
165
+ data = await process.stdin.read(4096)
166
+ if not data:
167
+ break
168
+ await loop.run_in_executor(
169
+ None,
170
+ partial(self._client.shell_write, session_id, _to_bytes(data), owner="mock-ssh"),
171
+ )
172
+ finally:
173
+ input_done.set()
174
+
175
+ async def pump_output() -> None:
176
+ try:
177
+ idle_after_input = 0
178
+ while not stop.is_set():
179
+ result = await loop.run_in_executor(
180
+ None,
181
+ lambda: self._client.shell_read(
182
+ session_id,
183
+ timeout=0.2,
184
+ max_bytes=4096,
185
+ owner="mock-ssh",
186
+ ),
187
+ )
188
+ if result.data:
189
+ idle_after_input = 0
190
+ process.stdout.write(result.data)
191
+ elif input_done.is_set():
192
+ idle_after_input += 1
193
+ if idle_after_input >= 5:
194
+ stop.set()
195
+ break
196
+ if not result.alive:
197
+ stop.set()
198
+ break
199
+ except Exception as exc:
200
+ print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
201
+ with suppress(Exception):
202
+ process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
203
+ stop.set()
204
+
205
+ tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
206
+ await stop.wait()
207
+ for task in tasks:
208
+ task.cancel()
209
+ await asyncio.gather(*tasks, return_exceptions=True)
210
+ self._set_exit_status(process, 0)
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ from .client import HostBridgeClient, HostBridgeError
6
+
7
+
8
+ class HostBridgeSessionFactory:
9
+ def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
10
+ self._client = client
11
+ self._host_id = host_id
12
+ self._connect_timeout = connect_timeout
13
+ self._lock = threading.Lock()
14
+ self._leased: set[str] = set()
15
+ self._shutdown = False
16
+
17
+ def open(self) -> str:
18
+ with self._lock:
19
+ if self._shutdown:
20
+ raise RuntimeError("mock-ssh session factory is shut down")
21
+ session = self._client.open_session(
22
+ self._host_id,
23
+ owner="mock-ssh",
24
+ connect_timeout=self._connect_timeout,
25
+ )
26
+ session_id = session.session_id
27
+ with self._lock:
28
+ if not self._shutdown:
29
+ self._leased.add(session_id)
30
+ return session_id
31
+ self._close_backend(session_id)
32
+ raise RuntimeError("mock-ssh session factory is shut down")
33
+
34
+ def close(self, session_id: str, *, reusable: bool = True) -> None:
35
+ with self._lock:
36
+ if session_id not in self._leased:
37
+ return
38
+ self._close_backend(session_id)
39
+ with self._lock:
40
+ self._leased.discard(session_id)
41
+
42
+ def shutdown(self) -> None:
43
+ with self._lock:
44
+ self._shutdown = True
45
+ session_ids = list(self._leased)
46
+ last_error: Exception | None = None
47
+ for session_id in session_ids:
48
+ try:
49
+ self.close(session_id, reusable=False)
50
+ except Exception as exc:
51
+ last_error = exc
52
+ if last_error is not None:
53
+ raise last_error
54
+
55
+ def _close_backend(self, session_id: str) -> None:
56
+ last_error: Exception | None = None
57
+ for _ in range(2):
58
+ try:
59
+ self._client.close_session(session_id, owner="mock-ssh")
60
+ return
61
+ except HostBridgeError as exc:
62
+ if exc.code == "not_found":
63
+ return
64
+ last_error = exc
65
+ except Exception as exc:
66
+ last_error = exc
67
+ assert last_error is not None
68
+ raise last_error