akernel-sdk 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.
- akernel_sdk/__init__.py +71 -0
- akernel_sdk/_addresses.py +166 -0
- akernel_sdk/_instance.py +416 -0
- akernel_sdk/_openyuanrong.py +320 -0
- akernel_sdk/_pty_transport.py +304 -0
- akernel_sdk/cli.py +549 -0
- akernel_sdk/commands.py +207 -0
- akernel_sdk/filesystem.py +291 -0
- akernel_sdk/pty.py +250 -0
- akernel_sdk/py.typed +0 -0
- akernel_sdk/sandbox.py +354 -0
- akernel_sdk/types.py +204 -0
- akernel_sdk-0.1.0.dist-info/METADATA +390 -0
- akernel_sdk-0.1.0.dist-info/RECORD +17 -0
- akernel_sdk-0.1.0.dist-info/WHEEL +5 -0
- akernel_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- akernel_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Copyright (c) 2026 Ant Group Corporation.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Internal openYuanrong adapter used by the public AKernel SDK."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import yr
|
|
28
|
+
from yr.runtime_holder import global_runtime
|
|
29
|
+
from yr.sandbox.sandbox import _sanitize_instance_id
|
|
30
|
+
|
|
31
|
+
from ._addresses import api_endpoint_from_env, gateway_endpoint_from_env
|
|
32
|
+
from ._instance import _SandboxInstance
|
|
33
|
+
from .types import HttpReverseTunnel, Mount, NodeInfo, S3Config
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_NAMESPACE = "akernel"
|
|
38
|
+
_initialized = False
|
|
39
|
+
_init_lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_initialized() -> None:
|
|
43
|
+
"""Initialize the openYuanrong client once for the current process."""
|
|
44
|
+
|
|
45
|
+
global _initialized
|
|
46
|
+
if _initialized:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
with _init_lock:
|
|
50
|
+
if _initialized:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
token = os.environ.get("AKERNEL_TOKEN", "").strip()
|
|
54
|
+
if not token:
|
|
55
|
+
raise RuntimeError("AKERNEL_TOKEN is not set")
|
|
56
|
+
|
|
57
|
+
endpoint = api_endpoint_from_env()
|
|
58
|
+
# Keep openYuanrong tuning inside its backend initialization boundary.
|
|
59
|
+
os.environ.setdefault("YR_HTTP_CONNECTION_NUM", "2")
|
|
60
|
+
os.environ.setdefault("YR_SERVER_ADDRESS", endpoint.authority())
|
|
61
|
+
gateway_override = os.environ.get("AKERNEL_GATEWAY_ADDRESS", "").strip()
|
|
62
|
+
if gateway_override and not os.environ.get("YR_GATEWAY_ADDRESS", "").strip():
|
|
63
|
+
os.environ["YR_GATEWAY_ADDRESS"] = gateway_override
|
|
64
|
+
|
|
65
|
+
yr.init(
|
|
66
|
+
yr.Config(
|
|
67
|
+
server_address=endpoint.authority(),
|
|
68
|
+
auth_token=token,
|
|
69
|
+
enable_tls=endpoint.use_tls,
|
|
70
|
+
server_name="akernel-sdk",
|
|
71
|
+
in_cluster=False,
|
|
72
|
+
http_ioc_threads_num=8,
|
|
73
|
+
bypass_datasystem=True,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
atexit.register(yr.finalize)
|
|
77
|
+
_initialized = True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_positive_int(name: str, value: int, *, allow_zero: bool = False) -> None:
|
|
81
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
82
|
+
raise TypeError(f"{name} must be an integer")
|
|
83
|
+
minimum = 0 if allow_zero else 1
|
|
84
|
+
if value < minimum:
|
|
85
|
+
comparator = "non-negative" if allow_zero else "greater than 0"
|
|
86
|
+
raise ValueError(f"{name} must be {comparator}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rootfs_json(
|
|
90
|
+
*, image: str | None, rootfs: S3Config | None, runtime: str
|
|
91
|
+
) -> str | None:
|
|
92
|
+
if image is not None:
|
|
93
|
+
return json.dumps(
|
|
94
|
+
{
|
|
95
|
+
"runtime": runtime,
|
|
96
|
+
"type": "image",
|
|
97
|
+
"readonly": False,
|
|
98
|
+
"imageurl": image,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
if rootfs is not None:
|
|
102
|
+
return json.dumps(
|
|
103
|
+
{
|
|
104
|
+
"runtime": runtime,
|
|
105
|
+
"type": "s3",
|
|
106
|
+
"readonly": False,
|
|
107
|
+
"s3_config": rootfs.to_dict(),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_options(
|
|
114
|
+
*,
|
|
115
|
+
image: str | None,
|
|
116
|
+
rootfs: S3Config | None,
|
|
117
|
+
runtime: str,
|
|
118
|
+
cpu: int,
|
|
119
|
+
memory: int,
|
|
120
|
+
cpu_limit: int,
|
|
121
|
+
mem_limit: int,
|
|
122
|
+
idle_timeout: int,
|
|
123
|
+
schedule_timeout: int,
|
|
124
|
+
env: Mapping[str, str] | None,
|
|
125
|
+
name: str | None,
|
|
126
|
+
port_forwardings: Sequence[int],
|
|
127
|
+
mounts: Sequence[Mount],
|
|
128
|
+
reverse_tunnel: HttpReverseTunnel | None,
|
|
129
|
+
detached: bool,
|
|
130
|
+
node_id: str | None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Translate the stable SDK configuration to openYuanrong options."""
|
|
133
|
+
|
|
134
|
+
_validate_positive_int("cpu", cpu)
|
|
135
|
+
_validate_positive_int("memory", memory)
|
|
136
|
+
_validate_positive_int("cpu_limit", cpu_limit, allow_zero=True)
|
|
137
|
+
_validate_positive_int("mem_limit", mem_limit, allow_zero=True)
|
|
138
|
+
_validate_positive_int("idle_timeout", idle_timeout, allow_zero=True)
|
|
139
|
+
if schedule_timeout != -1:
|
|
140
|
+
_validate_positive_int("schedule_timeout", schedule_timeout, allow_zero=True)
|
|
141
|
+
if cpu_limit and cpu_limit < cpu:
|
|
142
|
+
raise ValueError("cpu_limit must be 0 or greater than or equal to cpu")
|
|
143
|
+
if mem_limit and mem_limit < memory:
|
|
144
|
+
raise ValueError("mem_limit must be 0 or greater than or equal to memory")
|
|
145
|
+
|
|
146
|
+
options = yr.InvokeOptions()
|
|
147
|
+
# A Sandbox is driven by one sequential SDK client. Disabling ordered RPC
|
|
148
|
+
# execution prevents a missing sequence number from stalling later calls.
|
|
149
|
+
options.need_order = False
|
|
150
|
+
options.idle_timeout = idle_timeout
|
|
151
|
+
options.schedule_timeout_ms = (
|
|
152
|
+
-1 if schedule_timeout == -1 else schedule_timeout * 1000
|
|
153
|
+
)
|
|
154
|
+
options.cpu = cpu
|
|
155
|
+
options.memory = memory
|
|
156
|
+
options.cpu_limit = cpu_limit
|
|
157
|
+
options.mem_limit = mem_limit
|
|
158
|
+
options.namespace = _NAMESPACE
|
|
159
|
+
|
|
160
|
+
rootfs_json = _rootfs_json(image=image, rootfs=rootfs, runtime=runtime)
|
|
161
|
+
if rootfs_json is not None:
|
|
162
|
+
options.custom_extensions["rootfs"] = rootfs_json
|
|
163
|
+
if detached:
|
|
164
|
+
options.custom_extensions["lifecycle"] = "detached"
|
|
165
|
+
if env:
|
|
166
|
+
options.env_vars = dict(env)
|
|
167
|
+
if name:
|
|
168
|
+
options.name = name
|
|
169
|
+
if mounts:
|
|
170
|
+
options.custom_extensions["mounts"] = json.dumps(
|
|
171
|
+
[mount.to_dict() for mount in mounts]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
forwarded = list(port_forwardings)
|
|
175
|
+
if reverse_tunnel is not None:
|
|
176
|
+
forwarded.append(reverse_tunnel.reverse_port)
|
|
177
|
+
if forwarded:
|
|
178
|
+
from yr.config import PortForwarding
|
|
179
|
+
|
|
180
|
+
options.port_forwardings = [
|
|
181
|
+
PortForwarding(port=port, protocol="TCP") for port in forwarded
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
if node_id is not None:
|
|
185
|
+
if not isinstance(node_id, str) or not node_id.strip():
|
|
186
|
+
raise ValueError("node_id must be a non-empty string")
|
|
187
|
+
operator = yr.LabelOperator(
|
|
188
|
+
yr.OperatorType.LABEL_IN,
|
|
189
|
+
"NODE_ID",
|
|
190
|
+
[node_id.strip()],
|
|
191
|
+
)
|
|
192
|
+
options.schedule_affinities = [
|
|
193
|
+
yr.Affinity(
|
|
194
|
+
yr.AffinityKind.RESOURCE,
|
|
195
|
+
yr.AffinityType.REQUIRED,
|
|
196
|
+
[operator],
|
|
197
|
+
)
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
return options
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def create_instance(options: Any, *, cwd: str | None) -> Any:
|
|
204
|
+
instance_class: Any = _SandboxInstance
|
|
205
|
+
handle = instance_class.options(options).invoke(cwd=cwd)
|
|
206
|
+
yr.get(handle.ping.invoke())
|
|
207
|
+
return handle
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def real_instance_id(handle: Any) -> str:
|
|
211
|
+
return global_runtime.get_runtime().get_real_instance_id(handle.instance_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def terminate_instance(handle: Any) -> None:
|
|
215
|
+
handle.terminate()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def delete_named_instance(name: str) -> None:
|
|
219
|
+
ensure_initialized()
|
|
220
|
+
handle = yr.get_instance(name, namespace=_NAMESPACE)
|
|
221
|
+
handle.terminate(is_sync=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def ping_instance(handle: Any) -> bool:
|
|
225
|
+
try:
|
|
226
|
+
yr.get(handle.ping.invoke())
|
|
227
|
+
return True
|
|
228
|
+
except Exception:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def instance_state(handle: Any) -> str:
|
|
233
|
+
result = yr.get(handle.get_info.invoke())
|
|
234
|
+
return result.get("state", "unknown")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def start_reverse_tunnel(
|
|
238
|
+
handle: Any,
|
|
239
|
+
tunnel: HttpReverseTunnel,
|
|
240
|
+
*,
|
|
241
|
+
name: str | None,
|
|
242
|
+
) -> Any:
|
|
243
|
+
"""Start both ends of an HTTP reverse tunnel and return its client."""
|
|
244
|
+
|
|
245
|
+
yr.get(
|
|
246
|
+
handle.start_tunnel_server.invoke(
|
|
247
|
+
tunnel.reverse_port,
|
|
248
|
+
tunnel.listen_port,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
gateway = gateway_endpoint_from_env()
|
|
252
|
+
instance_id = real_instance_id(handle)
|
|
253
|
+
safe_id = _sanitize_instance_id(instance_id)
|
|
254
|
+
websocket_url = (
|
|
255
|
+
f"{gateway.websocket_scheme}://"
|
|
256
|
+
f"{gateway.authority(omit_default_port=True)}/"
|
|
257
|
+
f"{safe_id}/{tunnel.reverse_port}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
from yr.sandbox.tunnel_client import TunnelClient
|
|
261
|
+
|
|
262
|
+
client = TunnelClient(tunnel.target)
|
|
263
|
+
logger.info(
|
|
264
|
+
"Starting reverse tunnel: sandbox_id=%s name=%s url=%s target=%s",
|
|
265
|
+
safe_id,
|
|
266
|
+
name or "",
|
|
267
|
+
websocket_url,
|
|
268
|
+
tunnel.target,
|
|
269
|
+
)
|
|
270
|
+
if client.start(websocket_url, timeout=float(tunnel.connect_timeout)):
|
|
271
|
+
return client
|
|
272
|
+
|
|
273
|
+
client.stop()
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"reverse tunnel connection timed out after "
|
|
276
|
+
f"{float(tunnel.connect_timeout):.1f}s: sandbox_id={safe_id} "
|
|
277
|
+
f"name={name or ''} tunnel_url={websocket_url}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _to_float_mapping(value: Any) -> dict[str, float]:
|
|
282
|
+
if not isinstance(value, Mapping):
|
|
283
|
+
return {}
|
|
284
|
+
result: dict[str, float] = {}
|
|
285
|
+
for key, raw in value.items():
|
|
286
|
+
scalar = raw
|
|
287
|
+
if isinstance(raw, Mapping):
|
|
288
|
+
scalar = raw.get("value", raw.get("scalar", raw))
|
|
289
|
+
if isinstance(scalar, Mapping):
|
|
290
|
+
scalar = scalar.get("value", 0)
|
|
291
|
+
try:
|
|
292
|
+
result[str(key)] = float(scalar)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
continue
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _to_node_info(value: Any) -> NodeInfo:
|
|
299
|
+
if not isinstance(value, Mapping):
|
|
300
|
+
raise TypeError(f"unexpected openYuanrong resource entry: {value!r}")
|
|
301
|
+
labels = value.get("labels", value.get("nodeLabels", {}))
|
|
302
|
+
return NodeInfo(
|
|
303
|
+
id=str(value.get("id", "")),
|
|
304
|
+
status=int(value.get("status", 0)),
|
|
305
|
+
capacity=_to_float_mapping(value.get("capacity", {})),
|
|
306
|
+
allocatable=_to_float_mapping(value.get("allocatable", {})),
|
|
307
|
+
labels=dict(labels) if isinstance(labels, Mapping) else {},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def resources() -> list[NodeInfo]:
|
|
312
|
+
"""Return cluster resources through stable AKernel value types."""
|
|
313
|
+
|
|
314
|
+
ensure_initialized()
|
|
315
|
+
raw = yr.resources()
|
|
316
|
+
if raw is None:
|
|
317
|
+
return []
|
|
318
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
|
|
319
|
+
raise TypeError("openYuanrong resources() returned a non-list value")
|
|
320
|
+
return [_to_node_info(item) for item in raw]
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Copyright (c) 2026 Ant Group Corporation.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Thread-backed WebSocket transport for the synchronous PTY API."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from concurrent.futures import Future
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlencode
|
|
26
|
+
|
|
27
|
+
from ._addresses import Endpoint
|
|
28
|
+
|
|
29
|
+
_PROTOCOL = "sandbox.pty.v1"
|
|
30
|
+
_PROTOCOL_VERSION = 1
|
|
31
|
+
_WRITE_TIMEOUT = 10.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _PtyTransportError(RuntimeError):
|
|
35
|
+
"""Internal transport failure converted to PtyError by the public API."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_pty_uri(
|
|
39
|
+
endpoint: Endpoint,
|
|
40
|
+
*,
|
|
41
|
+
instance_id: str,
|
|
42
|
+
token: str,
|
|
43
|
+
command: Sequence[str],
|
|
44
|
+
rows: int,
|
|
45
|
+
cols: int,
|
|
46
|
+
) -> str:
|
|
47
|
+
parameters: list[tuple[str, str]] = [
|
|
48
|
+
("instance", instance_id),
|
|
49
|
+
("tenant_id", "default"),
|
|
50
|
+
("token", token),
|
|
51
|
+
("tty", "true"),
|
|
52
|
+
("rows", str(rows)),
|
|
53
|
+
("cols", str(cols)),
|
|
54
|
+
("protocol", _PROTOCOL),
|
|
55
|
+
]
|
|
56
|
+
parameters.extend(("command", argument) for argument in command)
|
|
57
|
+
return (
|
|
58
|
+
f"{endpoint.websocket_scheme}://"
|
|
59
|
+
f"{endpoint.authority(omit_default_port=True)}/terminal/ws?"
|
|
60
|
+
f"{urlencode(parameters)}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _PtyConnection:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
uri: str,
|
|
68
|
+
*,
|
|
69
|
+
ssl_context: Any,
|
|
70
|
+
rows: int,
|
|
71
|
+
cols: int,
|
|
72
|
+
on_data: Callable[[bytes], None] | None,
|
|
73
|
+
on_done: Callable[[], None],
|
|
74
|
+
) -> None:
|
|
75
|
+
self._uri = uri
|
|
76
|
+
self._ssl_context = ssl_context
|
|
77
|
+
self._rows = rows
|
|
78
|
+
self._cols = cols
|
|
79
|
+
self._on_data = on_data
|
|
80
|
+
self._on_done = on_done
|
|
81
|
+
|
|
82
|
+
self._ready = threading.Event()
|
|
83
|
+
self._done = threading.Event()
|
|
84
|
+
self._state_lock = threading.Lock()
|
|
85
|
+
self._thread = threading.Thread(
|
|
86
|
+
target=self._thread_main,
|
|
87
|
+
name="akernel-pty",
|
|
88
|
+
daemon=True,
|
|
89
|
+
)
|
|
90
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
91
|
+
self._run_task: asyncio.Task[None] | None = None
|
|
92
|
+
self._websocket: Any = None
|
|
93
|
+
self._session_id: str | None = None
|
|
94
|
+
self._exit_code: int | None = None
|
|
95
|
+
self._error: BaseException | None = None
|
|
96
|
+
self._closed_by_client = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def session_id(self) -> str:
|
|
100
|
+
if self._session_id is None:
|
|
101
|
+
raise _PtyTransportError("PTY session has not started")
|
|
102
|
+
return self._session_id
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def exit_code(self) -> int | None:
|
|
106
|
+
return self._exit_code
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def done(self) -> bool:
|
|
110
|
+
return self._done.is_set()
|
|
111
|
+
|
|
112
|
+
def start(self, timeout: float) -> None:
|
|
113
|
+
self._thread.start()
|
|
114
|
+
if not self._ready.wait(timeout):
|
|
115
|
+
self.close()
|
|
116
|
+
raise TimeoutError(f"PTY did not start within {timeout:g} seconds")
|
|
117
|
+
self._raise_error()
|
|
118
|
+
if self._session_id is None:
|
|
119
|
+
raise _PtyTransportError("PTY connection closed before it started")
|
|
120
|
+
|
|
121
|
+
def send_stdin(self, data: bytes) -> None:
|
|
122
|
+
self._send(data)
|
|
123
|
+
|
|
124
|
+
def close_stdin(self) -> None:
|
|
125
|
+
self._send("STDIN_EOF")
|
|
126
|
+
|
|
127
|
+
def resize(self, *, rows: int, cols: int) -> None:
|
|
128
|
+
self._send(f"RESIZE:{cols}:{rows}")
|
|
129
|
+
|
|
130
|
+
def wait(self, timeout: float | None) -> int:
|
|
131
|
+
if not self._done.wait(timeout):
|
|
132
|
+
rendered = (
|
|
133
|
+
"the requested timeout"
|
|
134
|
+
if timeout is None
|
|
135
|
+
else f"{timeout:g} seconds"
|
|
136
|
+
)
|
|
137
|
+
raise TimeoutError(f"PTY did not exit within {rendered}")
|
|
138
|
+
self._raise_error()
|
|
139
|
+
if self._exit_code is None:
|
|
140
|
+
if self._closed_by_client:
|
|
141
|
+
raise _PtyTransportError("PTY session was closed by the client")
|
|
142
|
+
raise _PtyTransportError("PTY connection closed without an exit status")
|
|
143
|
+
return self._exit_code
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
with self._state_lock:
|
|
147
|
+
if self._closed_by_client:
|
|
148
|
+
return
|
|
149
|
+
self._closed_by_client = True
|
|
150
|
+
loop = self._loop
|
|
151
|
+
run_task = self._run_task
|
|
152
|
+
websocket = self._websocket
|
|
153
|
+
|
|
154
|
+
if loop is not None and websocket is not None and not self._done.is_set():
|
|
155
|
+
future = asyncio.run_coroutine_threadsafe(websocket.close(), loop)
|
|
156
|
+
try:
|
|
157
|
+
future.result(timeout=_WRITE_TIMEOUT)
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
elif loop is not None and run_task is not None and not run_task.done():
|
|
161
|
+
loop.call_soon_threadsafe(run_task.cancel)
|
|
162
|
+
if self._thread.is_alive() and threading.current_thread() is not self._thread:
|
|
163
|
+
self._thread.join(timeout=_WRITE_TIMEOUT)
|
|
164
|
+
|
|
165
|
+
def _send(self, data: bytes | str) -> None:
|
|
166
|
+
if self._done.is_set():
|
|
167
|
+
self._raise_error()
|
|
168
|
+
raise _PtyTransportError("PTY session is no longer running")
|
|
169
|
+
if threading.current_thread() is self._thread:
|
|
170
|
+
raise _PtyTransportError(
|
|
171
|
+
"PTY methods cannot be called from the on_data callback"
|
|
172
|
+
)
|
|
173
|
+
with self._state_lock:
|
|
174
|
+
loop = self._loop
|
|
175
|
+
websocket = self._websocket
|
|
176
|
+
if loop is None or websocket is None:
|
|
177
|
+
raise _PtyTransportError("PTY transport is not connected")
|
|
178
|
+
future: Future[Any] = asyncio.run_coroutine_threadsafe(
|
|
179
|
+
websocket.send(data), loop
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
future.result(timeout=_WRITE_TIMEOUT)
|
|
183
|
+
except Exception as error:
|
|
184
|
+
raise _PtyTransportError(f"failed to write to PTY: {error}") from error
|
|
185
|
+
|
|
186
|
+
def _thread_main(self) -> None:
|
|
187
|
+
try:
|
|
188
|
+
asyncio.run(self._run())
|
|
189
|
+
except BaseException as error:
|
|
190
|
+
self._set_error(error)
|
|
191
|
+
finally:
|
|
192
|
+
self._ready.set()
|
|
193
|
+
self._done.set()
|
|
194
|
+
try:
|
|
195
|
+
self._on_done()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
async def _run(self) -> None:
|
|
200
|
+
import websockets
|
|
201
|
+
|
|
202
|
+
loop = asyncio.get_running_loop()
|
|
203
|
+
with self._state_lock:
|
|
204
|
+
self._loop = loop
|
|
205
|
+
self._run_task = asyncio.current_task()
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
async with websockets.connect(
|
|
209
|
+
self._uri,
|
|
210
|
+
ssl=self._ssl_context,
|
|
211
|
+
ping_interval=20,
|
|
212
|
+
ping_timeout=10,
|
|
213
|
+
) as websocket:
|
|
214
|
+
with self._state_lock:
|
|
215
|
+
self._websocket = websocket
|
|
216
|
+
await websocket.send(f"RESIZE:{self._cols}:{self._rows}")
|
|
217
|
+
|
|
218
|
+
async for message in websocket:
|
|
219
|
+
if isinstance(message, bytes):
|
|
220
|
+
if self._on_data is not None:
|
|
221
|
+
try:
|
|
222
|
+
self._on_data(message)
|
|
223
|
+
except BaseException as error:
|
|
224
|
+
self._set_error(
|
|
225
|
+
_PtyTransportError(
|
|
226
|
+
f"PTY on_data callback failed: {error}"
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
await websocket.close()
|
|
230
|
+
return
|
|
231
|
+
continue
|
|
232
|
+
self._handle_control(message)
|
|
233
|
+
if self._done.is_set():
|
|
234
|
+
await websocket.close()
|
|
235
|
+
await websocket.wait_closed()
|
|
236
|
+
return
|
|
237
|
+
except BaseException as error:
|
|
238
|
+
if not self._closed_by_client and not self._done.is_set():
|
|
239
|
+
self._set_error(
|
|
240
|
+
_PtyTransportError(f"PTY WebSocket connection failed: {error}")
|
|
241
|
+
)
|
|
242
|
+
finally:
|
|
243
|
+
with self._state_lock:
|
|
244
|
+
self._websocket = None
|
|
245
|
+
self._loop = None
|
|
246
|
+
self._run_task = None
|
|
247
|
+
|
|
248
|
+
if not self._closed_by_client and not self._done.is_set():
|
|
249
|
+
self._set_error(
|
|
250
|
+
_PtyTransportError("PTY connection closed before terminal status")
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _handle_control(self, message: str) -> None:
|
|
254
|
+
try:
|
|
255
|
+
event = json.loads(message)
|
|
256
|
+
except json.JSONDecodeError as error:
|
|
257
|
+
self._set_error(_PtyTransportError(f"invalid PTY control frame: {error}"))
|
|
258
|
+
return
|
|
259
|
+
if not isinstance(event, dict) or event.get("version") != _PROTOCOL_VERSION:
|
|
260
|
+
self._set_error(_PtyTransportError("unsupported PTY control frame"))
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
event_type = event.get("type")
|
|
264
|
+
session_id = event.get("session_id")
|
|
265
|
+
if not isinstance(session_id, str) or not session_id:
|
|
266
|
+
self._set_error(_PtyTransportError("PTY control frame has no session_id"))
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
if event_type == "started":
|
|
270
|
+
self._session_id = session_id
|
|
271
|
+
self._ready.set()
|
|
272
|
+
elif event_type == "exited":
|
|
273
|
+
exit_code = event.get("exit_code")
|
|
274
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
|
|
275
|
+
self._set_error(_PtyTransportError("PTY exit frame has no exit_code"))
|
|
276
|
+
return
|
|
277
|
+
self._session_id = session_id
|
|
278
|
+
self._exit_code = exit_code
|
|
279
|
+
self._ready.set()
|
|
280
|
+
self._done.set()
|
|
281
|
+
elif event_type == "error":
|
|
282
|
+
message_value = event.get("message")
|
|
283
|
+
detail = (
|
|
284
|
+
message_value if isinstance(message_value, str) else "unknown error"
|
|
285
|
+
)
|
|
286
|
+
self._session_id = session_id
|
|
287
|
+
self._set_error(_PtyTransportError(f"remote PTY error: {detail}"))
|
|
288
|
+
else:
|
|
289
|
+
self._set_error(
|
|
290
|
+
_PtyTransportError(f"unknown PTY control event {event_type!r}")
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _set_error(self, error: BaseException) -> None:
|
|
294
|
+
with self._state_lock:
|
|
295
|
+
if self._error is None:
|
|
296
|
+
self._error = error
|
|
297
|
+
self._ready.set()
|
|
298
|
+
self._done.set()
|
|
299
|
+
|
|
300
|
+
def _raise_error(self) -> None:
|
|
301
|
+
with self._state_lock:
|
|
302
|
+
error = self._error
|
|
303
|
+
if error is not None:
|
|
304
|
+
raise error
|