fcloud-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.
- fcloud/SKILL.md +1185 -0
- fcloud/__init__.py +85 -0
- fcloud/__main__.py +150 -0
- fcloud/_direct_bridge.py +1645 -0
- fcloud/_legacy_env.py +14 -0
- fcloud/cli/__init__.py +23 -0
- fcloud/cli/attach.py +180 -0
- fcloud/cli/common.py +382 -0
- fcloud/cli/console.py +227 -0
- fcloud/cli/context.py +161 -0
- fcloud/cli/exec_cmd.py +201 -0
- fcloud/cli/files.py +280 -0
- fcloud/cli/hardware.py +83 -0
- fcloud/cli/help.py +150 -0
- fcloud/cli/interactive.py +104 -0
- fcloud/cli/job.py +300 -0
- fcloud/cli/main.py +140 -0
- fcloud/cli/migration.py +276 -0
- fcloud/cli/mount.py +150 -0
- fcloud/cli/output.py +17 -0
- fcloud/cli/processes.py +360 -0
- fcloud/cli/registry.py +28 -0
- fcloud/cli/run.py +318 -0
- fcloud/cli/sessions.py +592 -0
- fcloud/cli/setup.py +44 -0
- fcloud/cli/ssh.py +255 -0
- fcloud/cli/sweep.py +578 -0
- fcloud/cli/sweep_harvest.py +187 -0
- fcloud/cli/sweep_watch.py +104 -0
- fcloud/cli/volume.py +317 -0
- fcloud/cli/wait.py +314 -0
- fcloud/cli_args.py +348 -0
- fcloud/client.py +632 -0
- fcloud/client_projects.py +52 -0
- fcloud/client_sessions.py +111 -0
- fcloud/client_volumes.py +80 -0
- fcloud/config.py +404 -0
- fcloud/direct.py +535 -0
- fcloud/errors.py +102 -0
- fcloud/fileset.py +155 -0
- fcloud/image.py +483 -0
- fcloud/job.py +249 -0
- fcloud/providers/__init__.py +5 -0
- fcloud/providers/requests_http.py +59 -0
- fcloud/py.typed +0 -0
- fcloud/session.py +442 -0
- fcloud/setup_cmd.py +213 -0
- fcloud/shell.py +558 -0
- fcloud/sweeps.py +557 -0
- fcloud/tunnel.py +251 -0
- fcloud/types.py +302 -0
- fcloud/v2_connect.py +928 -0
- fcloud/version.py +33 -0
- fcloud/volume_wait.py +131 -0
- fcloud/volumes.py +204 -0
- fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
- fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
- fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
- fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
- fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
- fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
- foom/__init__.py +36 -0
fcloud/_direct_bridge.py
ADDED
|
@@ -0,0 +1,1645 @@
|
|
|
1
|
+
"""Synchronous bridge from the sync Client API to the async DirectClient.
|
|
2
|
+
|
|
3
|
+
fcloud.Client is synchronous; fcloud.direct.DirectClient is async
|
|
4
|
+
(aiohttp WebSockets). This bridge runs a dedicated asyncio event loop
|
|
5
|
+
in a background thread and marshals sync callers onto it via
|
|
6
|
+
run_coroutine_threadsafe.
|
|
7
|
+
|
|
8
|
+
Session lifecycle: dispatcher allocates a host and mints a token,
|
|
9
|
+
then the bridge opens a data-plane WS directly to the host for
|
|
10
|
+
execute/spawn/poll/file ops. The dispatcher is always consulted
|
|
11
|
+
for allocation, token refresh, and teardown.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import concurrent.futures
|
|
18
|
+
import json
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from fcloud import v2_connect, volume_wait
|
|
31
|
+
from fcloud.errors import ClientTooOldError, FcloudError, FcloudRuntimeError
|
|
32
|
+
from fcloud.fileset import UploadOptions, collect_upload_files
|
|
33
|
+
from fcloud.types import ExecResult, Process, ProcessStatus, ProjectInfo, SessionInfo
|
|
34
|
+
from fcloud.version import CLIENT_NAME, VERSION
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from fcloud.direct import DirectClient
|
|
38
|
+
|
|
39
|
+
_TERMINAL_STATUSES = {"closing", "closed", "failed"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _session_info_from_allocation(
|
|
43
|
+
allocation: dict[str, Any], project_id: str, host_id: str = "",
|
|
44
|
+
) -> SessionInfo:
|
|
45
|
+
"""SessionInfo sourced from the dispatcher's allocation response —
|
|
46
|
+
server truth as of the allocation, never a locally-invented status.
|
|
47
|
+
Fields an older dispatcher doesn't send stay "" (explicitly unknown).
|
|
48
|
+
project_id is a locally-known fact (the server has no project concept).
|
|
49
|
+
"""
|
|
50
|
+
return SessionInfo(
|
|
51
|
+
session_id=str(allocation.get("sid") or ""),
|
|
52
|
+
project_id=project_id,
|
|
53
|
+
host_id=host_id or str(allocation.get("hid") or ""),
|
|
54
|
+
status=str(allocation.get("status") or ""),
|
|
55
|
+
lifecycle=str(allocation.get("lifecycle") or ""),
|
|
56
|
+
activity=str(allocation.get("activity") or ""),
|
|
57
|
+
created_at=str(allocation.get("created_at") or ""),
|
|
58
|
+
last_active=str(allocation.get("updated_at") or ""),
|
|
59
|
+
allocated_at=str(allocation.get("allocated_at") or ""),
|
|
60
|
+
ephemeral=bool(allocation.get("ephemeral")),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _can_handle_interrupt_on_main_thread() -> bool:
|
|
65
|
+
"""signal.signal only works on the main thread, and only if the
|
|
66
|
+
signal module exposes SIGINT (it doesn't on some embedded builds).
|
|
67
|
+
Library callers running execs from worker threads fall back to the
|
|
68
|
+
older cancel+reap path."""
|
|
69
|
+
if threading.current_thread() is not threading.main_thread():
|
|
70
|
+
return False
|
|
71
|
+
return getattr(signal, "SIGINT", None) is not None
|
|
72
|
+
|
|
73
|
+
_CLEANUP_TIMEOUT_S = 5.0
|
|
74
|
+
_DEFAULT_EXEC_OUTPUT_LIMIT_BYTES = 64 * 1024
|
|
75
|
+
_OUTPUT_DRAIN_TIMEOUT_S = 2.0
|
|
76
|
+
# After interrupting a foreground exec we kill the remote process and
|
|
77
|
+
# briefly wait for it to actually exit, so the follow-up release sees
|
|
78
|
+
# no running process and tears the ephemeral session down promptly.
|
|
79
|
+
_INTERRUPT_KILL_TIMEOUT_S = 8.0
|
|
80
|
+
_VALID_OUTPUT_RANGES = {"all", "head", "middle", "tail"}
|
|
81
|
+
_TRANSIENT_POLL_RETRY_DELAY_S = 1.0
|
|
82
|
+
# How long the foreground poll keeps retrying a failing host before it
|
|
83
|
+
# declares the host LOST (vs a momentary blip). Past this, the bridge
|
|
84
|
+
# returns status="host_lost" so the CLI can ride a spot migration instead
|
|
85
|
+
# of either hanging to the run deadline or falsely reporting exit 0.
|
|
86
|
+
_HOST_LOSS_GRACE_S = 6.0
|
|
87
|
+
|
|
88
|
+
# Files at or below this size ride inline over the WS in batched
|
|
89
|
+
# write_files frames. Larger files are uploaded straight to S3 via a
|
|
90
|
+
# dispatcher-minted pre-signed URL and pulled by the host (sync_pull).
|
|
91
|
+
# The pre-signed path is a single PUT, so it inherits S3's 5 GiB
|
|
92
|
+
# per-object PUT ceiling (enforced by the dispatcher at presign time).
|
|
93
|
+
_INLINE_FILE_LIMIT = 5 * 1024 * 1024
|
|
94
|
+
# Keep each inline frame under ~8 MiB raw so its base64 fits the host's
|
|
95
|
+
# 64 MiB WS read-limit with headroom.
|
|
96
|
+
_INLINE_BATCH_BYTES = 8 * 1024 * 1024
|
|
97
|
+
# (connect, read) timeouts for direct S3 PUT/GET. The long read budget
|
|
98
|
+
# covers multi-hundred-MB transfers.
|
|
99
|
+
_BLOB_HTTP_TIMEOUT = (30, 3600)
|
|
100
|
+
# Pre-signed URLs expire (dispatcher TTL: 15 min) and the dispatcher caps
|
|
101
|
+
# one presign request at 1000 hashes — so URLs are minted in small
|
|
102
|
+
# per-chunk batches right before each chunk's PUTs. One up-front batch
|
|
103
|
+
# for the whole tree 403'd on slow uploads.
|
|
104
|
+
_PRESIGN_CHUNK = 100
|
|
105
|
+
# Keepalive cadence during the (otherwise WS-silent) S3 PUT loop. Keeps
|
|
106
|
+
# the host's transfer lease fresh and proves the WS is alive.
|
|
107
|
+
_UPLOAD_KEEPALIVE_S = 60.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class _CapturedOutput:
|
|
112
|
+
text: str
|
|
113
|
+
total_bytes: int
|
|
114
|
+
retained_bytes: int
|
|
115
|
+
truncated: bool
|
|
116
|
+
range_name: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _OutputCapture:
|
|
120
|
+
"""Spool command output so completed exec results can be sliced safely."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._file = tempfile.TemporaryFile()
|
|
124
|
+
self._total_bytes = 0
|
|
125
|
+
|
|
126
|
+
def write(self, text: str) -> None:
|
|
127
|
+
data = text.encode("utf-8", errors="replace")
|
|
128
|
+
self._total_bytes += len(data)
|
|
129
|
+
self._file.write(data)
|
|
130
|
+
|
|
131
|
+
def result(
|
|
132
|
+
self,
|
|
133
|
+
limit_bytes: int | None,
|
|
134
|
+
range_name: str,
|
|
135
|
+
) -> _CapturedOutput:
|
|
136
|
+
if range_name not in _VALID_OUTPUT_RANGES:
|
|
137
|
+
range_name = "tail"
|
|
138
|
+
if limit_bytes is None or range_name == "all":
|
|
139
|
+
return self._read_range(0, self._total_bytes, False, "all")
|
|
140
|
+
if self._total_bytes <= limit_bytes:
|
|
141
|
+
return self._read_range(0, self._total_bytes, False, "all")
|
|
142
|
+
start = self._range_start(limit_bytes, range_name)
|
|
143
|
+
return self._read_range(start, limit_bytes, True, range_name)
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
self._file.close()
|
|
147
|
+
|
|
148
|
+
def _range_start(self, limit_bytes: int, range_name: str) -> int:
|
|
149
|
+
if range_name == "head":
|
|
150
|
+
return 0
|
|
151
|
+
if range_name == "middle":
|
|
152
|
+
return max(0, (self._total_bytes - limit_bytes) // 2)
|
|
153
|
+
return max(0, self._total_bytes - limit_bytes)
|
|
154
|
+
|
|
155
|
+
def _read_range(
|
|
156
|
+
self,
|
|
157
|
+
start: int,
|
|
158
|
+
size: int,
|
|
159
|
+
truncated: bool,
|
|
160
|
+
range_name: str,
|
|
161
|
+
) -> _CapturedOutput:
|
|
162
|
+
self._file.flush()
|
|
163
|
+
self._file.seek(start)
|
|
164
|
+
data = self._file.read(size)
|
|
165
|
+
return _CapturedOutput(
|
|
166
|
+
text=data.decode("utf-8", errors="replace"),
|
|
167
|
+
total_bytes=self._total_bytes,
|
|
168
|
+
retained_bytes=len(data),
|
|
169
|
+
truncated=truncated,
|
|
170
|
+
range_name=range_name,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _persistent_session_status_printer(frame: dict[str, Any]) -> None:
|
|
175
|
+
status = str(frame.get("status") or "")
|
|
176
|
+
event = frame.get("event", "?")
|
|
177
|
+
detail = str(frame.get("detail") or "")
|
|
178
|
+
if event == "environment_reset":
|
|
179
|
+
# The host reset everything outside /workspace at resume (image
|
|
180
|
+
# re-baseline or an incomplete close sync) — without this banner
|
|
181
|
+
# the user's first symptom is broken imports with a clean
|
|
182
|
+
# `pip list`.
|
|
183
|
+
print(
|
|
184
|
+
f" ⟵ environment reset — {detail}",
|
|
185
|
+
file=sys.stderr, flush=True,
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
if event == "workload_oom":
|
|
189
|
+
# The kernel OOM-killed the sandbox, taking the run with it. The
|
|
190
|
+
# session itself was rebuilt and still holds the user's files, so the
|
|
191
|
+
# useful thing to say is "retry", not "it broke".
|
|
192
|
+
print(f" ⟵ out of memory — {detail}", file=sys.stderr, flush=True)
|
|
193
|
+
return
|
|
194
|
+
if event == "memory_pressure":
|
|
195
|
+
# Said BEFORE the kill, while the user can still act on it.
|
|
196
|
+
print(f" ⟵ memory pressure — {detail}", file=sys.stderr, flush=True)
|
|
197
|
+
return
|
|
198
|
+
if "host_interrupted" in detail or event == "host_interrupted":
|
|
199
|
+
sid = str(frame.get("sid") or "")
|
|
200
|
+
hint = f" sid={sid}" if sid else ""
|
|
201
|
+
print(
|
|
202
|
+
f" \u27f5 host interrupted \u2014 files preserved. "
|
|
203
|
+
f"It comes back online automatically next time you use it.{hint}",
|
|
204
|
+
file=sys.stderr, flush=True,
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
if status not in _TERMINAL_STATUSES:
|
|
208
|
+
return
|
|
209
|
+
print(
|
|
210
|
+
f" \u27f5 session_status: {status} ({event}) {detail}".rstrip(),
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
flush=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _workspace_relative_path(path: str) -> str:
|
|
217
|
+
"""Convert CLI /workspace paths to direct host workspace-relative paths."""
|
|
218
|
+
normalized = path.replace("\\", "/")
|
|
219
|
+
if normalized == "/workspace":
|
|
220
|
+
return ""
|
|
221
|
+
if normalized.startswith("/workspace/"):
|
|
222
|
+
return normalized.removeprefix("/workspace/")
|
|
223
|
+
return normalized
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _hash_file(path) -> str:
|
|
227
|
+
"""SHA-256 hex of a file, streamed so memory stays flat for large
|
|
228
|
+
files. Matches the host/dispatcher content-addressed blob key."""
|
|
229
|
+
import hashlib
|
|
230
|
+
|
|
231
|
+
h = hashlib.sha256()
|
|
232
|
+
with open(path, "rb") as fh:
|
|
233
|
+
while chunk := fh.read(1024 * 1024):
|
|
234
|
+
h.update(chunk)
|
|
235
|
+
return h.hexdigest()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _is_connection_error(exc: BaseException) -> bool:
|
|
239
|
+
"""True for errors meaning the WS/TCP connection died (safe to
|
|
240
|
+
reconnect and retry an idempotent request), as opposed to the host
|
|
241
|
+
rejecting the request. "closing transport" is aiohttp's message when
|
|
242
|
+
a write races a dropped connection."""
|
|
243
|
+
if isinstance(exc, ConnectionError):
|
|
244
|
+
return True
|
|
245
|
+
try:
|
|
246
|
+
import aiohttp
|
|
247
|
+
except ImportError:
|
|
248
|
+
pass
|
|
249
|
+
else:
|
|
250
|
+
if isinstance(exc, aiohttp.ClientConnectionError):
|
|
251
|
+
return True
|
|
252
|
+
return "closing transport" in str(exc).lower()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _s3_route_note(large: list[tuple[str, Path, int]]) -> str:
|
|
256
|
+
"""Name the files that took the presigned-S3 route, for an error that
|
|
257
|
+
would otherwise blame the whole command."""
|
|
258
|
+
if not large:
|
|
259
|
+
return "no files (the S3 path was not used)"
|
|
260
|
+
shown = ", ".join(
|
|
261
|
+
f"{dest} ({sz / 1e6:.1f} MB)" for dest, _p, sz in large[:5]
|
|
262
|
+
)
|
|
263
|
+
if len(large) > 5:
|
|
264
|
+
shown += f", +{len(large) - 5} more"
|
|
265
|
+
return (
|
|
266
|
+
f"{len(large)} file(s) over "
|
|
267
|
+
f"{_INLINE_FILE_LIMIT // (1 << 20)} MiB: {shown}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _raise_if_client_too_old(status_code: int, data: object) -> None:
|
|
272
|
+
"""Turn the dispatcher's version-floor rejection into the typed error.
|
|
273
|
+
|
|
274
|
+
The blob presign calls below talk to the dispatcher directly instead of
|
|
275
|
+
going through Client._get/_post, so they need the same translation —
|
|
276
|
+
and this is the exact path where a too-old client used to fail as an S3
|
|
277
|
+
``SignatureDoesNotMatch`` instead of as "upgrade your client"."""
|
|
278
|
+
if not isinstance(data, dict):
|
|
279
|
+
return
|
|
280
|
+
if data.get("code") != "client_too_old" and status_code != 426:
|
|
281
|
+
return
|
|
282
|
+
raise ClientTooOldError(
|
|
283
|
+
str(data.get("error") or "client too old for this deployment"),
|
|
284
|
+
status_code, str(data.get("min_client_version") or ""),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _dispatcher_headers(api_key: str) -> dict[str, str]:
|
|
289
|
+
"""JSON + auth + the client-version handshake (see fcloud.version)."""
|
|
290
|
+
headers = {
|
|
291
|
+
"Content-Type": "application/json",
|
|
292
|
+
"X-Fcloud-Client": CLIENT_NAME,
|
|
293
|
+
"X-Fcloud-Client-Version": VERSION,
|
|
294
|
+
"User-Agent": f"{CLIENT_NAME}/{VERSION}",
|
|
295
|
+
}
|
|
296
|
+
if api_key:
|
|
297
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
298
|
+
return headers
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _blob_put(url: str, path, headers: dict[str, str] | None = None) -> None:
|
|
302
|
+
"""Stream a file to a pre-signed S3 PUT URL.
|
|
303
|
+
|
|
304
|
+
``headers`` are the ones the dispatcher says the signature covers —
|
|
305
|
+
today the ``x-amz-checksum-sha256`` binding that ties the object to its
|
|
306
|
+
content hash. Sending anything else would break the signature, so only
|
|
307
|
+
the dispatcher-supplied set is forwarded, exactly as given.
|
|
308
|
+
"""
|
|
309
|
+
import requests as _requests
|
|
310
|
+
|
|
311
|
+
with open(path, "rb") as fh:
|
|
312
|
+
resp = _requests.put(
|
|
313
|
+
url, data=fh, headers=headers or None, timeout=_BLOB_HTTP_TIMEOUT,
|
|
314
|
+
)
|
|
315
|
+
if resp.status_code >= 300:
|
|
316
|
+
raise FcloudRuntimeError(
|
|
317
|
+
f"S3 upload failed ({resp.status_code}): {resp.text[:200]}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class _BridgeConfig:
|
|
323
|
+
dispatcher_url: str
|
|
324
|
+
api_key: str = ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _config_from_env() -> _BridgeConfig:
|
|
328
|
+
"""Build bridge config. Always routes through the dispatcher."""
|
|
329
|
+
from fcloud.config import resolve_url
|
|
330
|
+
|
|
331
|
+
return _BridgeConfig(dispatcher_url=resolve_url(None))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _DirectBridge:
|
|
335
|
+
"""Owns a background asyncio loop and one DirectClient per live
|
|
336
|
+
session. Every public method is sync — callers don't see the
|
|
337
|
+
loop. Thread-safe for simultaneous calls from different threads.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(self, cfg: _BridgeConfig) -> None:
|
|
341
|
+
self._cfg = cfg
|
|
342
|
+
self._loop = asyncio.new_event_loop()
|
|
343
|
+
self._thread = threading.Thread(
|
|
344
|
+
target=self._loop.run_forever,
|
|
345
|
+
name="fcloud-direct-bridge",
|
|
346
|
+
daemon=True,
|
|
347
|
+
)
|
|
348
|
+
self._thread.start()
|
|
349
|
+
self._lock = threading.Lock()
|
|
350
|
+
# sid → DirectClient. Each entered session owns one
|
|
351
|
+
# WebSocket to the host.
|
|
352
|
+
self._clients: dict[str, DirectClient] = {}
|
|
353
|
+
# project_id → image_spec (JSON). create_project stashes
|
|
354
|
+
# here; create_session looks up to seed setup_session.
|
|
355
|
+
self._project_specs: dict[str, str] = {}
|
|
356
|
+
self._session_projects: dict[str, str] = {}
|
|
357
|
+
|
|
358
|
+
# ---- internal plumbing ----------------------------------------
|
|
359
|
+
|
|
360
|
+
def _await(self, coro, *, timeout: float | None = None):
|
|
361
|
+
"""Run `coro` on the bridge's loop and block until done.
|
|
362
|
+
Exceptions are re-raised on the calling thread.
|
|
363
|
+
|
|
364
|
+
A KeyboardInterrupt (Ctrl-C) or a timeout cancels the coroutine
|
|
365
|
+
on the loop before propagating, so an interrupted caller does
|
|
366
|
+
not leave work running on the bridge thread (which would, e.g.,
|
|
367
|
+
keep polling a remote process and hold the session open)."""
|
|
368
|
+
fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
369
|
+
try:
|
|
370
|
+
return fut.result(timeout=timeout)
|
|
371
|
+
except (concurrent.futures.TimeoutError, KeyboardInterrupt):
|
|
372
|
+
fut.cancel()
|
|
373
|
+
try:
|
|
374
|
+
asyncio.run_coroutine_threadsafe(
|
|
375
|
+
asyncio.sleep(0),
|
|
376
|
+
self._loop,
|
|
377
|
+
).result(timeout=0.1)
|
|
378
|
+
fut.result(timeout=0.1)
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
def _cleanup_client(self, client: DirectClient) -> None:
|
|
384
|
+
"""Best-effort local socket cleanup; dispatcher owns teardown."""
|
|
385
|
+
try:
|
|
386
|
+
self._await(client.__aexit__(None, None, None), timeout=_CLEANUP_TIMEOUT_S)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# ---- project surface ------------------------------------------
|
|
391
|
+
|
|
392
|
+
def create_project(
|
|
393
|
+
self,
|
|
394
|
+
name: str,
|
|
395
|
+
*,
|
|
396
|
+
image: Any | None = None,
|
|
397
|
+
image_id: str = "",
|
|
398
|
+
datastore_id: str = "",
|
|
399
|
+
) -> ProjectInfo:
|
|
400
|
+
"""Synthesize a ProjectInfo locally. The image spec (if any)
|
|
401
|
+
is stashed for later setup_session calls."""
|
|
402
|
+
project_id = f"direct-proj-{uuid.uuid4().hex[:8]}"
|
|
403
|
+
spec_json = ""
|
|
404
|
+
if image is not None:
|
|
405
|
+
spec_json = json.dumps(image.to_dict())
|
|
406
|
+
image_id = image_id or image.spec_hash()
|
|
407
|
+
|
|
408
|
+
info = ProjectInfo(
|
|
409
|
+
project_id=project_id,
|
|
410
|
+
name=name,
|
|
411
|
+
image_id=image_id,
|
|
412
|
+
datastore_id=datastore_id,
|
|
413
|
+
)
|
|
414
|
+
if spec_json:
|
|
415
|
+
with self._lock:
|
|
416
|
+
self._project_specs[project_id] = spec_json
|
|
417
|
+
return info
|
|
418
|
+
|
|
419
|
+
# ---- session surface ------------------------------------------
|
|
420
|
+
|
|
421
|
+
def _resolve_spec(self, project_id: str, sku: str) -> str:
|
|
422
|
+
"""Return the project's image spec JSON, or the SKU default."""
|
|
423
|
+
with self._lock:
|
|
424
|
+
spec_json = self._project_specs.get(project_id, "")
|
|
425
|
+
if not spec_json:
|
|
426
|
+
from fcloud.image import Image
|
|
427
|
+
|
|
428
|
+
spec_json = json.dumps(Image.default_for_sku(sku).to_dict())
|
|
429
|
+
return spec_json
|
|
430
|
+
|
|
431
|
+
def create_cold_session(
|
|
432
|
+
self,
|
|
433
|
+
project_id: str,
|
|
434
|
+
*,
|
|
435
|
+
sku: str = "",
|
|
436
|
+
volume_mounts: list[dict[str, str]] | None = None,
|
|
437
|
+
min_disk_gb: int = 0,
|
|
438
|
+
keep_warm_seconds: int = 0,
|
|
439
|
+
provider: str = "",
|
|
440
|
+
region: str = "",
|
|
441
|
+
) -> SessionInfo:
|
|
442
|
+
"""Register a cold session (filesystem only, no host) and return
|
|
443
|
+
immediately. The session is brought online transparently on first
|
|
444
|
+
use via the resume-on-attach path. This is what `fcloud create`
|
|
445
|
+
uses — a session is a filesystem, not a held GPU.
|
|
446
|
+
|
|
447
|
+
Declared volumes are persisted on the cold record so they're composed
|
|
448
|
+
into the rootfs at setup when the session is first used."""
|
|
449
|
+
spec_json = self._resolve_spec(project_id, sku)
|
|
450
|
+
allocation = v2_connect.allocate_session(
|
|
451
|
+
self._cfg.dispatcher_url,
|
|
452
|
+
self._cfg.api_key,
|
|
453
|
+
image_spec=spec_json,
|
|
454
|
+
sku=sku,
|
|
455
|
+
cold=True,
|
|
456
|
+
volume_mounts=volume_mounts,
|
|
457
|
+
min_disk_gb=min_disk_gb,
|
|
458
|
+
keep_warm_seconds=keep_warm_seconds,
|
|
459
|
+
provider=provider,
|
|
460
|
+
region=region,
|
|
461
|
+
)
|
|
462
|
+
sid = allocation["sid"]
|
|
463
|
+
with self._lock:
|
|
464
|
+
self._project_specs.setdefault(sid, spec_json)
|
|
465
|
+
self._session_projects[sid] = project_id
|
|
466
|
+
return _session_info_from_allocation(allocation, project_id)
|
|
467
|
+
|
|
468
|
+
def _attach_volumes(
|
|
469
|
+
self, sid: str, volume_mounts: list[dict[str, str]] | None,
|
|
470
|
+
client: DirectClient | None = None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Attach requested volumes to the now-live session and, when a
|
|
473
|
+
connected ``client`` is given, wait until their files have landed.
|
|
474
|
+
|
|
475
|
+
Idempotent server-side: re-attaching a volume already mounted at
|
|
476
|
+
the same path is a no-op, so callers may re-pass ``--volume`` (or
|
|
477
|
+
a fcloud.json ``volumes`` default) on every invocation safely. The
|
|
478
|
+
attach itself returns as soon as the host registers the mount; the
|
|
479
|
+
pull runs in the background, so without the wait a command issued
|
|
480
|
+
right after could run against a half-populated directory.
|
|
481
|
+
"""
|
|
482
|
+
mounts = [m for m in (volume_mounts or []) if m.get("name")]
|
|
483
|
+
if not mounts:
|
|
484
|
+
return
|
|
485
|
+
v2_connect.attach_volumes(
|
|
486
|
+
self._cfg.dispatcher_url,
|
|
487
|
+
self._cfg.api_key,
|
|
488
|
+
sid,
|
|
489
|
+
mounts,
|
|
490
|
+
)
|
|
491
|
+
if client is not None:
|
|
492
|
+
self._await(volume_wait.wait_volumes_hydrated(
|
|
493
|
+
client, [str(m["name"]) for m in mounts],
|
|
494
|
+
))
|
|
495
|
+
|
|
496
|
+
def create_session(
|
|
497
|
+
self,
|
|
498
|
+
project_id: str,
|
|
499
|
+
*,
|
|
500
|
+
sku: str = "",
|
|
501
|
+
volume_mounts: list[dict[str, str]] | None = None,
|
|
502
|
+
keep_warm_seconds: int = 0,
|
|
503
|
+
min_disk_gb: int = 0,
|
|
504
|
+
provider: str = "",
|
|
505
|
+
region: str = "",
|
|
506
|
+
ephemeral: bool = False,
|
|
507
|
+
) -> SessionInfo:
|
|
508
|
+
"""Allocate a session on this host. Returns a SessionInfo
|
|
509
|
+
with status=active once setup_session resolves. If the
|
|
510
|
+
project was created via create_project, its image_spec is
|
|
511
|
+
used; otherwise defaults to debian_slim.
|
|
512
|
+
|
|
513
|
+
ephemeral=True requests a run-to-completion job session: the
|
|
514
|
+
workspace is never persisted, the session cannot be resumed or
|
|
515
|
+
mounted after close, and durable outputs must go to volumes (or
|
|
516
|
+
be downloaded while the session is live)."""
|
|
517
|
+
spec_json = self._resolve_spec(project_id, sku)
|
|
518
|
+
# Captured as soon as the dispatcher queues the session, so an
|
|
519
|
+
# interrupt during provisioning (Ctrl-C while we poll/connect)
|
|
520
|
+
# can cancel it instead of leaving it stuck in "preparing".
|
|
521
|
+
pending_sid = ""
|
|
522
|
+
# State-aware narration: the fleet's provisioning feed distinguishes
|
|
523
|
+
# a real cloud launch ("provisioning/booting ...") from a queue that
|
|
524
|
+
# is just waiting for a busy host to free up (e.g. mid-teardown).
|
|
525
|
+
reporter = v2_connect.ProgressReporter(sku=sku)
|
|
526
|
+
|
|
527
|
+
def _on_queued(queued_sid: str) -> None:
|
|
528
|
+
nonlocal pending_sid
|
|
529
|
+
pending_sid = queued_sid
|
|
530
|
+
reporter.on_queued(queued_sid)
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
allocation = v2_connect.allocate_session(
|
|
534
|
+
self._cfg.dispatcher_url,
|
|
535
|
+
self._cfg.api_key,
|
|
536
|
+
image_spec=spec_json,
|
|
537
|
+
sku=sku,
|
|
538
|
+
keep_warm_seconds=keep_warm_seconds,
|
|
539
|
+
volume_mounts=volume_mounts,
|
|
540
|
+
min_disk_gb=min_disk_gb,
|
|
541
|
+
provider=provider,
|
|
542
|
+
region=region,
|
|
543
|
+
# Names also drive the dispatcher's root-volume sizing.
|
|
544
|
+
volume_names=[
|
|
545
|
+
m["name"] for m in (volume_mounts or []) if m.get("name")
|
|
546
|
+
],
|
|
547
|
+
ephemeral=ephemeral,
|
|
548
|
+
on_queued=_on_queued,
|
|
549
|
+
on_wait=reporter.on_wait,
|
|
550
|
+
on_provisioning=reporter.on_provisioning,
|
|
551
|
+
)
|
|
552
|
+
pending_sid = allocation["sid"]
|
|
553
|
+
client = self._connect_ready_client(allocation, spec_json, sku)
|
|
554
|
+
# Volumes are composed into the rootfs at setup (sent with the
|
|
555
|
+
# allocate request above) — no post-live attach needed.
|
|
556
|
+
except BaseException:
|
|
557
|
+
# Allocation failed or was interrupted (KeyboardInterrupt
|
|
558
|
+
# included). Hand the half-provisioned session back to the
|
|
559
|
+
# dispatcher so it doesn't linger as "preparing".
|
|
560
|
+
self._abandon_pending_session(pending_sid)
|
|
561
|
+
raise
|
|
562
|
+
sid = allocation["sid"]
|
|
563
|
+
with self._lock:
|
|
564
|
+
self._clients[sid] = client
|
|
565
|
+
self._session_projects[sid] = project_id
|
|
566
|
+
# SessionInfo has no `sku` field; sku only matters at
|
|
567
|
+
# allocation time and we've already picked a host.
|
|
568
|
+
del sku
|
|
569
|
+
# Status/lifecycle come from the dispatcher's allocation response —
|
|
570
|
+
# never invented locally. host_id is the one local fact worth
|
|
571
|
+
# preferring: the WS handshake just completed against it.
|
|
572
|
+
return _session_info_from_allocation(
|
|
573
|
+
allocation, project_id, host_id=client._session.hid,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _connect_ready_client(
|
|
577
|
+
self, allocation: dict[str, Any], spec_json: str, sku: str,
|
|
578
|
+
) -> DirectClient:
|
|
579
|
+
"""Open the data-plane WS and block until the host reports ready.
|
|
580
|
+
Tears the local socket down if readiness fails so we never leak a
|
|
581
|
+
half-open client; the dispatcher session is cleaned up by the
|
|
582
|
+
caller's allocation guard."""
|
|
583
|
+
client = self._await(
|
|
584
|
+
v2_connect.connect_with_retry(
|
|
585
|
+
allocation,
|
|
586
|
+
dispatcher_url=self._cfg.dispatcher_url,
|
|
587
|
+
api_key=self._cfg.api_key,
|
|
588
|
+
sku=sku,
|
|
589
|
+
image_spec=spec_json,
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
try:
|
|
593
|
+
self._await(client.wait_for_ready())
|
|
594
|
+
except BaseException:
|
|
595
|
+
self._cleanup_client(client)
|
|
596
|
+
raise
|
|
597
|
+
return client
|
|
598
|
+
|
|
599
|
+
def _abandon_pending_session(self, sid: str) -> None:
|
|
600
|
+
"""Tell the dispatcher to drop a session we never finished
|
|
601
|
+
bringing up. Best-effort: a failure here must not mask the
|
|
602
|
+
original error (often a KeyboardInterrupt) we're unwinding."""
|
|
603
|
+
if not sid:
|
|
604
|
+
return
|
|
605
|
+
try:
|
|
606
|
+
v2_connect.close_session(
|
|
607
|
+
self._cfg.dispatcher_url,
|
|
608
|
+
self._cfg.api_key,
|
|
609
|
+
sid,
|
|
610
|
+
)
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
# NOTE deliberately no get_session here. _clients/_session_projects are
|
|
615
|
+
# connection bookkeeping — which WS to use for ops, which project a sid
|
|
616
|
+
# was created under. They are NOT session state: an entry lingers after
|
|
617
|
+
# a host dies and is absent in every process that didn't attach. A
|
|
618
|
+
# bridge get_session fabricated status from this map and starved a
|
|
619
|
+
# fresh-process provision poller for 10 minutes per session, which once
|
|
620
|
+
# caused an outage. Server-state reads go through Client.describe_session.
|
|
621
|
+
|
|
622
|
+
def attach_session(
|
|
623
|
+
self,
|
|
624
|
+
session_id: str,
|
|
625
|
+
*,
|
|
626
|
+
volume_mounts: list[dict[str, str]] | None = None,
|
|
627
|
+
image_spec: str = "",
|
|
628
|
+
sku: str = "",
|
|
629
|
+
) -> SessionInfo:
|
|
630
|
+
"""Connect to an existing session (fresh token, new WS).
|
|
631
|
+
|
|
632
|
+
Session warmth is invisible to the caller. If the session is
|
|
633
|
+
already active this reconnects directly; if it was closed
|
|
634
|
+
(reaped while idle) it is transparently resumed — rebuilt on a
|
|
635
|
+
fresh host with its filesystem restored. Either way the method
|
|
636
|
+
gets a fresh token, opens a data-plane WS, and waits for ready.
|
|
637
|
+
Use ``detach_session`` to drop the WS without closing the
|
|
638
|
+
session.
|
|
639
|
+
|
|
640
|
+
A QUEUED/cold session blocks here while the host provisions; the
|
|
641
|
+
``ProgressReporter`` narrates each stage on stderr (the same UX the
|
|
642
|
+
create path already has) so ``fcloud exec --on <sid>`` no longer looks
|
|
643
|
+
hung. Ctrl-C still cancels: the callbacks only print, so an interrupt
|
|
644
|
+
propagates out of ``reconnect_session``/``connect_with_retry`` exactly
|
|
645
|
+
as before.
|
|
646
|
+
|
|
647
|
+
``image_spec`` (when non-empty) is forwarded on the resume path
|
|
648
|
+
so an edited fcloud.json takes effect on the next use of an
|
|
649
|
+
existing session. Has no effect when the session is already
|
|
650
|
+
active — the running image cannot be swapped under a live host.
|
|
651
|
+
"""
|
|
652
|
+
reporter = v2_connect.ProgressReporter()
|
|
653
|
+
allocation = v2_connect.reconnect_session(
|
|
654
|
+
self._cfg.dispatcher_url,
|
|
655
|
+
self._cfg.api_key,
|
|
656
|
+
session_id,
|
|
657
|
+
sku=sku,
|
|
658
|
+
image_spec=image_spec,
|
|
659
|
+
**reporter.callbacks(),
|
|
660
|
+
)
|
|
661
|
+
client = self._await(
|
|
662
|
+
v2_connect.connect_with_retry(
|
|
663
|
+
allocation,
|
|
664
|
+
dispatcher_url=self._cfg.dispatcher_url,
|
|
665
|
+
api_key=self._cfg.api_key,
|
|
666
|
+
on_status=_persistent_session_status_printer,
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
try:
|
|
670
|
+
self._await(client.wait_for_ready())
|
|
671
|
+
reporter.ready()
|
|
672
|
+
# Attach the requested volumes once the session is online,
|
|
673
|
+
# whether it was already active or transparently resumed.
|
|
674
|
+
self._attach_volumes(session_id, volume_mounts, client)
|
|
675
|
+
except Exception:
|
|
676
|
+
self._cleanup_client(client)
|
|
677
|
+
raise
|
|
678
|
+
with self._lock:
|
|
679
|
+
self._clients[session_id] = client
|
|
680
|
+
project_id = self._session_projects.get(session_id, "")
|
|
681
|
+
return _session_info_from_allocation(
|
|
682
|
+
allocation, project_id, host_id=str(allocation.get("hid") or ""),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def session_host_id(self, session_id: str) -> str:
|
|
686
|
+
"""Return the host id of a currently-attached session, or "".
|
|
687
|
+
|
|
688
|
+
Lets reconnect paths compare the host before and after a drop, so
|
|
689
|
+
a reconnect to the same host is not reported as a migration.
|
|
690
|
+
"""
|
|
691
|
+
with self._lock:
|
|
692
|
+
client = self._clients.get(session_id)
|
|
693
|
+
if client is None or client._session is None:
|
|
694
|
+
return ""
|
|
695
|
+
return client._session.hid
|
|
696
|
+
|
|
697
|
+
def detach_session(self, session_id: str) -> None:
|
|
698
|
+
"""Drop the data-plane WS without closing the session.
|
|
699
|
+
|
|
700
|
+
The session stays alive on the host (subject to its idle
|
|
701
|
+
timeout). Reconnect later with ``attach_session``.
|
|
702
|
+
"""
|
|
703
|
+
with self._lock:
|
|
704
|
+
client = self._clients.pop(session_id, None)
|
|
705
|
+
if client is None:
|
|
706
|
+
return
|
|
707
|
+
self._cleanup_client(client)
|
|
708
|
+
|
|
709
|
+
def close_session(self, session_id: str) -> dict[str, Any]:
|
|
710
|
+
with self._lock:
|
|
711
|
+
client = self._clients.pop(session_id, None)
|
|
712
|
+
if client is not None:
|
|
713
|
+
self._cleanup_client(client)
|
|
714
|
+
with self._lock:
|
|
715
|
+
self._session_projects.pop(session_id, None)
|
|
716
|
+
return v2_connect.close_session(
|
|
717
|
+
self._cfg.dispatcher_url,
|
|
718
|
+
self._cfg.api_key,
|
|
719
|
+
session_id,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def release_session(self, session_id: str) -> dict[str, Any]:
|
|
723
|
+
"""Soft-release: ask the host to tear the session down only if
|
|
724
|
+
nothing else is using it (no other connection, no in-flight op,
|
|
725
|
+
no running process), then drop the local WS. Unlike
|
|
726
|
+
``close_session`` this never force-closes a session that other
|
|
727
|
+
terminals or background processes still need — it is the exit
|
|
728
|
+
path for ephemeral ``exec``/``run``/``shell`` commands.
|
|
729
|
+
"""
|
|
730
|
+
with self._lock:
|
|
731
|
+
client = self._clients.get(session_id)
|
|
732
|
+
result: dict[str, Any] = {"released": False, "torn_down": False}
|
|
733
|
+
if client is not None:
|
|
734
|
+
try:
|
|
735
|
+
result = self._await(
|
|
736
|
+
client._request( # noqa: SLF001
|
|
737
|
+
{
|
|
738
|
+
"type": "release_session",
|
|
739
|
+
"session_id": session_id,
|
|
740
|
+
},
|
|
741
|
+
timeout=_CLEANUP_TIMEOUT_S,
|
|
742
|
+
),
|
|
743
|
+
timeout=_CLEANUP_TIMEOUT_S + 1.0,
|
|
744
|
+
)
|
|
745
|
+
except Exception:
|
|
746
|
+
pass
|
|
747
|
+
self.detach_session(session_id)
|
|
748
|
+
return result
|
|
749
|
+
|
|
750
|
+
# ---- exec surface ---------------------------------------------
|
|
751
|
+
|
|
752
|
+
def session_execute(
|
|
753
|
+
self,
|
|
754
|
+
session_id: str,
|
|
755
|
+
command: list[str],
|
|
756
|
+
*,
|
|
757
|
+
timeout_ms: int = 30_000,
|
|
758
|
+
workdir: str = "",
|
|
759
|
+
env: dict[str, str] | None = None,
|
|
760
|
+
output_limit_bytes: int | None = _DEFAULT_EXEC_OUTPUT_LIMIT_BYTES,
|
|
761
|
+
output_range: str = "tail",
|
|
762
|
+
on_output: Callable[[str], None] | None = None,
|
|
763
|
+
on_start: Callable[[str], None] | None = None,
|
|
764
|
+
pty: dict[str, int] | None = None,
|
|
765
|
+
) -> ExecResult:
|
|
766
|
+
client = self._require_client(session_id)
|
|
767
|
+
resp = self._await(
|
|
768
|
+
client.spawn(
|
|
769
|
+
command,
|
|
770
|
+
work_dir=workdir,
|
|
771
|
+
env=env or {},
|
|
772
|
+
pty=pty,
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
pid = resp.get("process_id", "")
|
|
776
|
+
if not pid:
|
|
777
|
+
raise FcloudRuntimeError(f"spawn failed: {resp}")
|
|
778
|
+
# The process now exists with a durable id, before we block on its
|
|
779
|
+
# output. Surface the id here so a caller can persist it (e.g. to a
|
|
780
|
+
# --emit-pid file) even if the blocking wait's stdio is later eaten
|
|
781
|
+
# by a `| tail` or a backgrounded shell.
|
|
782
|
+
if on_start is not None:
|
|
783
|
+
on_start(pid)
|
|
784
|
+
proc = Process(
|
|
785
|
+
process_id=pid,
|
|
786
|
+
session_id=session_id,
|
|
787
|
+
status=ProcessStatus(resp.get("status") or "running"),
|
|
788
|
+
)
|
|
789
|
+
return self._run_capture_with_interrupt_kill(
|
|
790
|
+
client,
|
|
791
|
+
proc,
|
|
792
|
+
timeout_ms,
|
|
793
|
+
output_limit_bytes,
|
|
794
|
+
output_range,
|
|
795
|
+
on_output,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
def _run_capture_with_interrupt_kill(
|
|
799
|
+
self,
|
|
800
|
+
client: DirectClient,
|
|
801
|
+
proc: Process,
|
|
802
|
+
timeout_ms: int,
|
|
803
|
+
output_limit_bytes: int | None,
|
|
804
|
+
output_range: str,
|
|
805
|
+
on_output: Callable[[str], None] | None,
|
|
806
|
+
) -> ExecResult:
|
|
807
|
+
"""Run the streaming-capture coroutine, translating a Ctrl-C
|
|
808
|
+
into a remote kill that lets streaming finish naturally.
|
|
809
|
+
|
|
810
|
+
Cancelling the capture coroutine on SIGINT (the previous
|
|
811
|
+
behavior) tears down the WS subscribe before the remote process
|
|
812
|
+
actually exits, so any output produced during the kill window
|
|
813
|
+
lands in the durable log but never reaches the user's terminal.
|
|
814
|
+
Instead we install a SIGINT handler that schedules the remote
|
|
815
|
+
kill while the capture keeps running; output keeps printing
|
|
816
|
+
until the remote sees the signal and the stream ends. A
|
|
817
|
+
``_INTERRUPT_KILL_TIMEOUT_S`` watchdog bounds the wait so a
|
|
818
|
+
remote that ignores the kill cannot wedge the terminal, and a
|
|
819
|
+
second Ctrl-C force-quits immediately.
|
|
820
|
+
"""
|
|
821
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
822
|
+
self._capture_spawned_exec(
|
|
823
|
+
client, proc, timeout_ms,
|
|
824
|
+
output_limit_bytes, output_range, on_output,
|
|
825
|
+
),
|
|
826
|
+
self._loop,
|
|
827
|
+
)
|
|
828
|
+
if not _can_handle_interrupt_on_main_thread():
|
|
829
|
+
# Library use from non-main threads (no signal handling
|
|
830
|
+
# there): fall back to the cancel+reap path so a
|
|
831
|
+
# KeyboardInterrupt from the calling thread still tears
|
|
832
|
+
# the session down promptly.
|
|
833
|
+
try:
|
|
834
|
+
return fut.result()
|
|
835
|
+
except KeyboardInterrupt:
|
|
836
|
+
fut.cancel()
|
|
837
|
+
self._kill_after_interrupt(client, proc)
|
|
838
|
+
raise
|
|
839
|
+
|
|
840
|
+
interrupted = threading.Event()
|
|
841
|
+
watchdog: list[threading.Timer | None] = [None]
|
|
842
|
+
prev = signal.getsignal(signal.SIGINT)
|
|
843
|
+
|
|
844
|
+
def handler(_signum, _frame) -> None:
|
|
845
|
+
if interrupted.is_set():
|
|
846
|
+
# Second Ctrl-C: stop streaming immediately. Restoring
|
|
847
|
+
# the previous handler lets a third Ctrl-C reach the
|
|
848
|
+
# default behavior (e.g. pytest's). fut.cancel() makes
|
|
849
|
+
# fut.result() return CancelledError quickly.
|
|
850
|
+
signal.signal(signal.SIGINT, prev)
|
|
851
|
+
if watchdog[0] is not None:
|
|
852
|
+
watchdog[0].cancel()
|
|
853
|
+
fut.cancel()
|
|
854
|
+
raise KeyboardInterrupt
|
|
855
|
+
interrupted.set()
|
|
856
|
+
sys.stderr.write(
|
|
857
|
+
"\nInterrupting — waiting for remote process to exit "
|
|
858
|
+
"(press Ctrl-C again to force-quit).\n",
|
|
859
|
+
)
|
|
860
|
+
sys.stderr.flush()
|
|
861
|
+
try:
|
|
862
|
+
asyncio.run_coroutine_threadsafe(
|
|
863
|
+
client.kill(proc.process_id),
|
|
864
|
+
self._loop,
|
|
865
|
+
)
|
|
866
|
+
except Exception:
|
|
867
|
+
pass
|
|
868
|
+
timer = threading.Timer(
|
|
869
|
+
_INTERRUPT_KILL_TIMEOUT_S, fut.cancel,
|
|
870
|
+
)
|
|
871
|
+
timer.daemon = True
|
|
872
|
+
timer.start()
|
|
873
|
+
watchdog[0] = timer
|
|
874
|
+
|
|
875
|
+
signal.signal(signal.SIGINT, handler)
|
|
876
|
+
try:
|
|
877
|
+
try:
|
|
878
|
+
result = fut.result()
|
|
879
|
+
except concurrent.futures.CancelledError:
|
|
880
|
+
if interrupted.is_set():
|
|
881
|
+
raise KeyboardInterrupt from None
|
|
882
|
+
raise
|
|
883
|
+
finally:
|
|
884
|
+
signal.signal(signal.SIGINT, prev)
|
|
885
|
+
if watchdog[0] is not None:
|
|
886
|
+
watchdog[0].cancel()
|
|
887
|
+
|
|
888
|
+
if interrupted.is_set():
|
|
889
|
+
raise KeyboardInterrupt
|
|
890
|
+
return result
|
|
891
|
+
|
|
892
|
+
def _kill_after_interrupt(self, client: DirectClient, proc: Process) -> None:
|
|
893
|
+
"""Best-effort kill + reap of a process whose foreground caller
|
|
894
|
+
was interrupted. Never raises — the caller is already unwinding a
|
|
895
|
+
KeyboardInterrupt and just needs the remote work stopped. Used
|
|
896
|
+
only on the non-main-thread fallback path; the main-thread path
|
|
897
|
+
kills via the SIGINT handler and lets streaming complete."""
|
|
898
|
+
try:
|
|
899
|
+
self._await(client.kill(proc.process_id), timeout=_CLEANUP_TIMEOUT_S)
|
|
900
|
+
except BaseException:
|
|
901
|
+
return
|
|
902
|
+
deadline = time.time() + _INTERRUPT_KILL_TIMEOUT_S
|
|
903
|
+
while time.time() < deadline:
|
|
904
|
+
try:
|
|
905
|
+
data = self._await(
|
|
906
|
+
client.poll(proc.process_id, lines=0),
|
|
907
|
+
timeout=_CLEANUP_TIMEOUT_S,
|
|
908
|
+
)
|
|
909
|
+
except BaseException:
|
|
910
|
+
return
|
|
911
|
+
if ProcessStatus(data.get("status") or "running") != ProcessStatus.RUNNING:
|
|
912
|
+
return
|
|
913
|
+
time.sleep(0.1)
|
|
914
|
+
|
|
915
|
+
async def _capture_spawned_exec(
|
|
916
|
+
self,
|
|
917
|
+
client: DirectClient,
|
|
918
|
+
proc: Process,
|
|
919
|
+
timeout_ms: int,
|
|
920
|
+
output_limit_bytes: int | None,
|
|
921
|
+
output_range: str,
|
|
922
|
+
on_output: Callable[[str], None] | None = None,
|
|
923
|
+
) -> ExecResult:
|
|
924
|
+
start = time.time()
|
|
925
|
+
deadline = start + max(0.0, timeout_ms / 1000)
|
|
926
|
+
capture = _OutputCapture()
|
|
927
|
+
capture_err = _OutputCapture()
|
|
928
|
+
collector = asyncio.create_task(
|
|
929
|
+
self._collect_process_output(
|
|
930
|
+
client, proc.process_id, capture, capture_err, on_output,
|
|
931
|
+
),
|
|
932
|
+
)
|
|
933
|
+
try:
|
|
934
|
+
return await self._poll_captured_exec(
|
|
935
|
+
client,
|
|
936
|
+
proc,
|
|
937
|
+
deadline,
|
|
938
|
+
start,
|
|
939
|
+
collector,
|
|
940
|
+
capture,
|
|
941
|
+
capture_err,
|
|
942
|
+
output_limit_bytes,
|
|
943
|
+
output_range,
|
|
944
|
+
)
|
|
945
|
+
finally:
|
|
946
|
+
# On a normal return the poll loop has already drained the
|
|
947
|
+
# collector; on cancellation (the caller was interrupted) it
|
|
948
|
+
# has not, so stop it here before closing the spool it writes
|
|
949
|
+
# to. _cancel_output_collection is idempotent.
|
|
950
|
+
await self._cancel_output_collection(collector)
|
|
951
|
+
capture.close()
|
|
952
|
+
capture_err.close()
|
|
953
|
+
|
|
954
|
+
async def _collect_process_output(
|
|
955
|
+
self,
|
|
956
|
+
client: DirectClient,
|
|
957
|
+
process_id: str,
|
|
958
|
+
capture: _OutputCapture,
|
|
959
|
+
capture_err: _OutputCapture,
|
|
960
|
+
on_output: Callable[[str], None] | None = None,
|
|
961
|
+
) -> None:
|
|
962
|
+
"""Spool process output, and mirror each chunk to ``on_output``
|
|
963
|
+
live as it arrives (used to stream to the console in real time).
|
|
964
|
+
|
|
965
|
+
Frames are routed by their ``stream`` label so the exec reply can
|
|
966
|
+
report stdout and stderr separately; a PTY genuinely merges the
|
|
967
|
+
streams, so pty (and unlabeled) frames count as stdout.
|
|
968
|
+
|
|
969
|
+
``on_output`` runs on the bridge's event-loop thread while the
|
|
970
|
+
calling thread is parked in ``_await``, so it is the sole writer
|
|
971
|
+
to the console — no interleaving with the caller. A failing sink
|
|
972
|
+
must not kill output collection, so its errors are swallowed.
|
|
973
|
+
"""
|
|
974
|
+
async for frame in client.subscribe(process_id):
|
|
975
|
+
if frame.get("type") == "stream_end":
|
|
976
|
+
return
|
|
977
|
+
text = str(frame.get("data") or "")
|
|
978
|
+
if not text:
|
|
979
|
+
continue
|
|
980
|
+
if frame.get("stream") == "stderr":
|
|
981
|
+
capture_err.write(text)
|
|
982
|
+
else:
|
|
983
|
+
capture.write(text)
|
|
984
|
+
if on_output is not None:
|
|
985
|
+
try:
|
|
986
|
+
on_output(text)
|
|
987
|
+
except Exception:
|
|
988
|
+
pass
|
|
989
|
+
|
|
990
|
+
async def _poll_captured_exec(
|
|
991
|
+
self,
|
|
992
|
+
client: DirectClient,
|
|
993
|
+
proc: Process,
|
|
994
|
+
deadline: float,
|
|
995
|
+
start: float,
|
|
996
|
+
collector: asyncio.Task,
|
|
997
|
+
capture: _OutputCapture,
|
|
998
|
+
capture_err: _OutputCapture,
|
|
999
|
+
output_limit_bytes: int | None,
|
|
1000
|
+
output_range: str,
|
|
1001
|
+
) -> ExecResult:
|
|
1002
|
+
poll_delays = [0.01, 0.05, 0.1, 0.25, 2.0]
|
|
1003
|
+
polls = 0
|
|
1004
|
+
last_data: dict[str, Any] = {"status": "running"}
|
|
1005
|
+
first_fail: float | None = None
|
|
1006
|
+
while True:
|
|
1007
|
+
try:
|
|
1008
|
+
data = await client.poll(proc.process_id, lines=0)
|
|
1009
|
+
except (TimeoutError, ConnectionError, OSError, asyncio.TimeoutError):
|
|
1010
|
+
# If the host announced `host_interrupted` over the WS
|
|
1011
|
+
# right before this poll failed, the process was SIGTERM'd on
|
|
1012
|
+
# an EVACUATE (non-checkpointable) reclaim — it is gone for
|
|
1013
|
+
# good, no restore is coming. Surface that terminal status.
|
|
1014
|
+
if getattr(client, "_host_interrupted", False):
|
|
1015
|
+
await self._cancel_output_collection(collector)
|
|
1016
|
+
captured = capture.result(output_limit_bytes, output_range)
|
|
1017
|
+
captured_err = capture_err.result(
|
|
1018
|
+
output_limit_bytes, output_range,
|
|
1019
|
+
)
|
|
1020
|
+
result = self._captured_exec_result(
|
|
1021
|
+
proc, last_data, start, "host_terminated",
|
|
1022
|
+
captured, captured_err,
|
|
1023
|
+
)
|
|
1024
|
+
result.returncode = None
|
|
1025
|
+
result.message = (
|
|
1026
|
+
"Host was interrupted (spot reclaim or eviction); "
|
|
1027
|
+
f"the process did not survive. Reconnect with fcloud logs "
|
|
1028
|
+
f"{proc.session_id} {proc.process_id} --output all "
|
|
1029
|
+
"for any captured output."
|
|
1030
|
+
)
|
|
1031
|
+
return result
|
|
1032
|
+
# No host_interrupted notice: the host vanished mid-stream — a
|
|
1033
|
+
# spot MIGRATE (the session is being checkpointed/restored on a
|
|
1034
|
+
# new host) or a hard drop. Retry briefly for a momentary blip,
|
|
1035
|
+
# then return "host_lost" so the caller can ride the migration
|
|
1036
|
+
# — rather than hanging to the run deadline or, worse, returning
|
|
1037
|
+
# a false "still running" (exit 0) that masks a lost host.
|
|
1038
|
+
now = time.time()
|
|
1039
|
+
if first_fail is None:
|
|
1040
|
+
first_fail = now
|
|
1041
|
+
if now - first_fail >= _HOST_LOSS_GRACE_S or now >= deadline:
|
|
1042
|
+
await self._cancel_output_collection(collector)
|
|
1043
|
+
captured = capture.result(output_limit_bytes, output_range)
|
|
1044
|
+
captured_err = capture_err.result(
|
|
1045
|
+
output_limit_bytes, output_range,
|
|
1046
|
+
)
|
|
1047
|
+
result = self._captured_exec_result(
|
|
1048
|
+
proc, last_data, start, "host_lost",
|
|
1049
|
+
captured, captured_err,
|
|
1050
|
+
)
|
|
1051
|
+
result.returncode = None
|
|
1052
|
+
result.message = (
|
|
1053
|
+
"Host connection lost — checking whether the session is "
|
|
1054
|
+
"migrating to a new host."
|
|
1055
|
+
)
|
|
1056
|
+
return result
|
|
1057
|
+
await asyncio.sleep(
|
|
1058
|
+
min(_TRANSIENT_POLL_RETRY_DELAY_S, max(0.0, deadline - time.time())),
|
|
1059
|
+
)
|
|
1060
|
+
continue
|
|
1061
|
+
first_fail = None
|
|
1062
|
+
last_data = data
|
|
1063
|
+
# NOTE: a reply with no `status` is still read as RUNNING here, and
|
|
1064
|
+
# that is deliberate. It looks like a bug — the host answers a
|
|
1065
|
+
# status-less error frame for a process whose sandbox was rebuilt
|
|
1066
|
+
# under it, and this loop will poll that corpse until --wait
|
|
1067
|
+
# expires. But "no status" is NOT specific to a dead process: the
|
|
1068
|
+
# client plane answers every op with a status-less
|
|
1069
|
+
# `{"ok": false, "error": ...}` whenever the session is not
|
|
1070
|
+
# PhaseReady, which is exactly what a session being CHECKPOINTED
|
|
1071
|
+
# OR RESTORED looks like. Treating that as "your process died"
|
|
1072
|
+
# would abandon a migration the caller is supposed to ride, and
|
|
1073
|
+
# ending a live training run early is far worse than waiting.
|
|
1074
|
+
#
|
|
1075
|
+
# The host resolves this authoritatively instead: once the rebuilt
|
|
1076
|
+
# container no longer owns the process, `poll` answers from the
|
|
1077
|
+
# durable index with a real terminal status, and the branch below
|
|
1078
|
+
# ends the wait in ~2s. A client-side verdict would need the host to say
|
|
1079
|
+
# explicitly "this process is gone and is not coming back", which
|
|
1080
|
+
# the protocol has no way to express today.
|
|
1081
|
+
status = ProcessStatus(data.get("status") or "running")
|
|
1082
|
+
if status != ProcessStatus.RUNNING:
|
|
1083
|
+
await self._finish_output_collection(collector)
|
|
1084
|
+
captured = capture.result(output_limit_bytes, output_range)
|
|
1085
|
+
captured_err = capture_err.result(
|
|
1086
|
+
output_limit_bytes, output_range,
|
|
1087
|
+
)
|
|
1088
|
+
durable = await self._durable_exec_result(
|
|
1089
|
+
client,
|
|
1090
|
+
proc,
|
|
1091
|
+
data,
|
|
1092
|
+
start,
|
|
1093
|
+
output_limit_bytes,
|
|
1094
|
+
output_range,
|
|
1095
|
+
)
|
|
1096
|
+
if durable is not None:
|
|
1097
|
+
return durable
|
|
1098
|
+
return self._captured_exec_result(
|
|
1099
|
+
proc,
|
|
1100
|
+
data,
|
|
1101
|
+
start,
|
|
1102
|
+
"exited",
|
|
1103
|
+
captured,
|
|
1104
|
+
captured_err,
|
|
1105
|
+
)
|
|
1106
|
+
if time.time() >= deadline:
|
|
1107
|
+
await self._cancel_output_collection(collector)
|
|
1108
|
+
captured = capture.result(output_limit_bytes, output_range)
|
|
1109
|
+
captured_err = capture_err.result(
|
|
1110
|
+
output_limit_bytes, output_range,
|
|
1111
|
+
)
|
|
1112
|
+
result = self._captured_exec_result(
|
|
1113
|
+
proc,
|
|
1114
|
+
data,
|
|
1115
|
+
start,
|
|
1116
|
+
"running",
|
|
1117
|
+
captured,
|
|
1118
|
+
captured_err,
|
|
1119
|
+
)
|
|
1120
|
+
result.returncode = None
|
|
1121
|
+
result.message = (
|
|
1122
|
+
"Command is still running; use fcloud logs "
|
|
1123
|
+
f"{proc.session_id} {proc.process_id} --follow"
|
|
1124
|
+
)
|
|
1125
|
+
return result
|
|
1126
|
+
delay = poll_delays[min(polls, len(poll_delays) - 1)]
|
|
1127
|
+
polls += 1
|
|
1128
|
+
await asyncio.sleep(min(delay, max(0.0, deadline - time.time())))
|
|
1129
|
+
|
|
1130
|
+
async def _finish_output_collection(self, collector: asyncio.Task) -> None:
|
|
1131
|
+
try:
|
|
1132
|
+
await asyncio.wait_for(collector, timeout=_OUTPUT_DRAIN_TIMEOUT_S)
|
|
1133
|
+
except asyncio.TimeoutError:
|
|
1134
|
+
await self._cancel_output_collection(collector)
|
|
1135
|
+
except Exception:
|
|
1136
|
+
await asyncio.gather(collector, return_exceptions=True)
|
|
1137
|
+
|
|
1138
|
+
async def _cancel_output_collection(self, collector: asyncio.Task) -> None:
|
|
1139
|
+
if collector.done():
|
|
1140
|
+
await asyncio.gather(collector, return_exceptions=True)
|
|
1141
|
+
return
|
|
1142
|
+
collector.cancel()
|
|
1143
|
+
await asyncio.gather(collector, return_exceptions=True)
|
|
1144
|
+
|
|
1145
|
+
async def _durable_exec_result(
|
|
1146
|
+
self,
|
|
1147
|
+
client: DirectClient,
|
|
1148
|
+
proc: Process,
|
|
1149
|
+
data: dict,
|
|
1150
|
+
start: float,
|
|
1151
|
+
output_limit_bytes: int | None,
|
|
1152
|
+
output_range: str,
|
|
1153
|
+
) -> ExecResult | None:
|
|
1154
|
+
try:
|
|
1155
|
+
log = await client.read_log(
|
|
1156
|
+
proc.process_id,
|
|
1157
|
+
stream="stdout",
|
|
1158
|
+
output_range=output_range,
|
|
1159
|
+
output_bytes=output_limit_bytes,
|
|
1160
|
+
)
|
|
1161
|
+
except Exception:
|
|
1162
|
+
return None
|
|
1163
|
+
# stderr is best-effort: a missing stderr log must not discard the
|
|
1164
|
+
# durable stdout read (the captured fallback would merge the streams).
|
|
1165
|
+
stderr = ""
|
|
1166
|
+
try:
|
|
1167
|
+
err_log = await client.read_log(
|
|
1168
|
+
proc.process_id,
|
|
1169
|
+
stream="stderr",
|
|
1170
|
+
output_range=output_range,
|
|
1171
|
+
output_bytes=output_limit_bytes,
|
|
1172
|
+
)
|
|
1173
|
+
stderr = str(err_log.get("output", ""))
|
|
1174
|
+
except Exception:
|
|
1175
|
+
pass
|
|
1176
|
+
return ExecResult(
|
|
1177
|
+
stdout=str(log.get("output", "")),
|
|
1178
|
+
stderr=stderr,
|
|
1179
|
+
returncode=int(data.get("exit_code", -1)),
|
|
1180
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
1181
|
+
status="exited",
|
|
1182
|
+
process_id=proc.process_id,
|
|
1183
|
+
session_id=proc.session_id,
|
|
1184
|
+
stdout_truncated=bool(log.get("output_truncated", False)),
|
|
1185
|
+
stdout_total_bytes=int(log.get("output_total_bytes", 0)),
|
|
1186
|
+
stdout_retained_bytes=int(log.get("output_retained_bytes", 0)),
|
|
1187
|
+
stdout_range=str(log.get("output_range", output_range)),
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
def _captured_exec_result(
|
|
1191
|
+
self,
|
|
1192
|
+
proc: Process,
|
|
1193
|
+
data: dict,
|
|
1194
|
+
start: float,
|
|
1195
|
+
status: str,
|
|
1196
|
+
captured: _CapturedOutput,
|
|
1197
|
+
captured_err: _CapturedOutput,
|
|
1198
|
+
) -> ExecResult:
|
|
1199
|
+
return ExecResult(
|
|
1200
|
+
stdout=captured.text,
|
|
1201
|
+
stderr=captured_err.text,
|
|
1202
|
+
returncode=int(data.get("exit_code", -1)),
|
|
1203
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
1204
|
+
status=status,
|
|
1205
|
+
process_id=proc.process_id,
|
|
1206
|
+
session_id=proc.session_id,
|
|
1207
|
+
stdout_truncated=captured.truncated,
|
|
1208
|
+
stdout_total_bytes=captured.total_bytes,
|
|
1209
|
+
stdout_retained_bytes=captured.retained_bytes,
|
|
1210
|
+
stdout_range=captured.range_name,
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
def session_spawn(
|
|
1214
|
+
self,
|
|
1215
|
+
session_id: str,
|
|
1216
|
+
command: list[str],
|
|
1217
|
+
*,
|
|
1218
|
+
workdir: str = "",
|
|
1219
|
+
env: dict[str, str] | None = None,
|
|
1220
|
+
pty: dict[str, int] | None = None,
|
|
1221
|
+
) -> Process:
|
|
1222
|
+
client = self._require_client(session_id)
|
|
1223
|
+
resp = self._await(
|
|
1224
|
+
client.spawn(
|
|
1225
|
+
command,
|
|
1226
|
+
work_dir=workdir,
|
|
1227
|
+
env=env or {},
|
|
1228
|
+
pty=pty,
|
|
1229
|
+
)
|
|
1230
|
+
)
|
|
1231
|
+
pid = resp.get("process_id", "")
|
|
1232
|
+
if not pid:
|
|
1233
|
+
raise FcloudRuntimeError(f"spawn failed: {resp}")
|
|
1234
|
+
return Process(
|
|
1235
|
+
process_id=pid,
|
|
1236
|
+
session_id=session_id,
|
|
1237
|
+
status=ProcessStatus(resp.get("status") or "running"),
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
def session_poll(
|
|
1241
|
+
self,
|
|
1242
|
+
session_id: str,
|
|
1243
|
+
process_id: str,
|
|
1244
|
+
lines: int = 20,
|
|
1245
|
+
) -> dict:
|
|
1246
|
+
client = self._require_client(session_id)
|
|
1247
|
+
return self._await(client.poll(process_id, lines=lines))
|
|
1248
|
+
|
|
1249
|
+
def session_logs(
|
|
1250
|
+
self,
|
|
1251
|
+
session_id: str,
|
|
1252
|
+
process_id: str,
|
|
1253
|
+
*,
|
|
1254
|
+
stream: str = "combined",
|
|
1255
|
+
output_limit_bytes: int | None = None,
|
|
1256
|
+
output_range: str = "all",
|
|
1257
|
+
) -> dict:
|
|
1258
|
+
client = self._require_client(session_id)
|
|
1259
|
+
return self._await(
|
|
1260
|
+
client.read_log(
|
|
1261
|
+
process_id,
|
|
1262
|
+
stream=stream,
|
|
1263
|
+
output_range=output_range,
|
|
1264
|
+
output_bytes=output_limit_bytes,
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
def session_kill(
|
|
1269
|
+
self,
|
|
1270
|
+
session_id: str,
|
|
1271
|
+
process_id: str,
|
|
1272
|
+
) -> dict:
|
|
1273
|
+
client = self._require_client(session_id)
|
|
1274
|
+
return self._await(client.kill(process_id))
|
|
1275
|
+
|
|
1276
|
+
def session_sync(self, session_id: str) -> dict:
|
|
1277
|
+
client = self._require_client(session_id)
|
|
1278
|
+
return self._await(client.sync_session())
|
|
1279
|
+
|
|
1280
|
+
def watch_process(self, session_id: str, process_id: str):
|
|
1281
|
+
client = self._require_client(session_id)
|
|
1282
|
+
stream = client.subscribe(process_id)
|
|
1283
|
+
try:
|
|
1284
|
+
while True:
|
|
1285
|
+
try:
|
|
1286
|
+
yield self._await(stream.__anext__())
|
|
1287
|
+
except StopAsyncIteration:
|
|
1288
|
+
return
|
|
1289
|
+
finally:
|
|
1290
|
+
self._await(stream.aclose())
|
|
1291
|
+
|
|
1292
|
+
# ---- file I/O -------------------------------------------------
|
|
1293
|
+
|
|
1294
|
+
def upload_files(
|
|
1295
|
+
self,
|
|
1296
|
+
session_id: str,
|
|
1297
|
+
local_path: str,
|
|
1298
|
+
remote_path: str = "",
|
|
1299
|
+
options: UploadOptions | None = None,
|
|
1300
|
+
) -> dict:
|
|
1301
|
+
"""Push files from local_path into the session's workspace.
|
|
1302
|
+
|
|
1303
|
+
Which files ship is decided by :mod:`fcloud.fileset` (hidden entries,
|
|
1304
|
+
dependency dirs and symlinks are skipped unless ``options`` says
|
|
1305
|
+
otherwise). Small files ride inline over the WS in batched
|
|
1306
|
+
write_files frames. Large files (> _INLINE_FILE_LIMIT) are uploaded
|
|
1307
|
+
directly to S3 via a dispatcher-minted pre-signed URL, then pulled by
|
|
1308
|
+
the host (sync_pull) — no bytes through the dispatcher, up to 5 GiB
|
|
1309
|
+
per file (the single-PUT ceiling; the dispatcher rejects larger
|
|
1310
|
+
files at presign time). If the dispatcher has no S3 configured,
|
|
1311
|
+
large files fall back to the inline path.
|
|
1312
|
+
"""
|
|
1313
|
+
collected = collect_upload_files(
|
|
1314
|
+
local_path, remote_path, options, dest_for=_workspace_relative_path,
|
|
1315
|
+
)
|
|
1316
|
+
client = self._require_client(session_id)
|
|
1317
|
+
files = collected.files
|
|
1318
|
+
|
|
1319
|
+
small = [(dest, p) for dest, p, sz in files if sz <= _INLINE_FILE_LIMIT]
|
|
1320
|
+
large = [(dest, p, sz) for dest, p, sz in files if sz > _INLINE_FILE_LIMIT]
|
|
1321
|
+
|
|
1322
|
+
written = self._upload_inline(client, small)
|
|
1323
|
+
# Which files take the S3 route is invisible from the outside, and
|
|
1324
|
+
# that route has its own failure modes (presign, signing, quota).
|
|
1325
|
+
# A 6.3 MB results JSON inside an otherwise tiny code upload once
|
|
1326
|
+
# routed the whole batch through a broken presigned path and the
|
|
1327
|
+
# failure was attributable to the command, not to a file. Name
|
|
1328
|
+
# them, and name them on failure too.
|
|
1329
|
+
try:
|
|
1330
|
+
written += self._upload_via_s3(client, session_id, large)
|
|
1331
|
+
except FcloudError as exc:
|
|
1332
|
+
raise type(exc)(
|
|
1333
|
+
f"{exc} — failed on the S3 upload path, taken by "
|
|
1334
|
+
f"{_s3_route_note(large)}"
|
|
1335
|
+
) from exc
|
|
1336
|
+
if written != len(files):
|
|
1337
|
+
raise FcloudRuntimeError(
|
|
1338
|
+
f"upload incomplete: host wrote {written}/{len(files)} files"
|
|
1339
|
+
)
|
|
1340
|
+
return {
|
|
1341
|
+
"status": "ok",
|
|
1342
|
+
"written": written,
|
|
1343
|
+
"skipped": [],
|
|
1344
|
+
"bytes_total": collected.bytes_total,
|
|
1345
|
+
"files_count": len(files),
|
|
1346
|
+
"skipped_hidden": collected.skipped_hidden,
|
|
1347
|
+
"skipped_symlinks": collected.skipped_symlinks,
|
|
1348
|
+
"skipped_other": collected.skipped_other,
|
|
1349
|
+
"skip_note": collected.skip_note(),
|
|
1350
|
+
"inline_files": len(small),
|
|
1351
|
+
"s3_files": [{"path": dest, "size": sz} for dest, _p, sz in large],
|
|
1352
|
+
"s3_threshold_bytes": _INLINE_FILE_LIMIT,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
def _upload_inline(
|
|
1356
|
+
self, client: DirectClient, items: list[tuple[str, Path]],
|
|
1357
|
+
) -> int:
|
|
1358
|
+
"""Send small files inline over the WS, batched to keep each
|
|
1359
|
+
frame under the read-limit. Returns the number written."""
|
|
1360
|
+
written = 0
|
|
1361
|
+
batch: list[tuple[str, bytes]] = []
|
|
1362
|
+
batch_size = 0
|
|
1363
|
+
|
|
1364
|
+
def flush() -> None:
|
|
1365
|
+
nonlocal written, batch, batch_size
|
|
1366
|
+
if not batch:
|
|
1367
|
+
return
|
|
1368
|
+
r = self._await(client.write_files(batch))
|
|
1369
|
+
skipped = r.get("skipped", [])
|
|
1370
|
+
if isinstance(skipped, list) and skipped:
|
|
1371
|
+
raise FcloudRuntimeError(
|
|
1372
|
+
"upload failed: " + "; ".join(str(s) for s in skipped)
|
|
1373
|
+
)
|
|
1374
|
+
written += int(r.get("written", 0))
|
|
1375
|
+
batch = []
|
|
1376
|
+
batch_size = 0
|
|
1377
|
+
|
|
1378
|
+
for dest, p in items:
|
|
1379
|
+
data = p.read_bytes()
|
|
1380
|
+
if batch_size + len(data) > _INLINE_BATCH_BYTES and batch:
|
|
1381
|
+
flush()
|
|
1382
|
+
batch.append((dest, data))
|
|
1383
|
+
batch_size += len(data)
|
|
1384
|
+
flush()
|
|
1385
|
+
return written
|
|
1386
|
+
|
|
1387
|
+
def _upload_via_s3(
|
|
1388
|
+
self,
|
|
1389
|
+
client: DirectClient,
|
|
1390
|
+
session_id: str,
|
|
1391
|
+
items: list[tuple[str, Path, int]],
|
|
1392
|
+
) -> int:
|
|
1393
|
+
"""Upload large files to S3 via pre-signed PUT URLs, then have
|
|
1394
|
+
the host pull them by hash. Falls back to the inline path when
|
|
1395
|
+
the dispatcher has no S3 configured. Returns the number the
|
|
1396
|
+
host confirms it wrote.
|
|
1397
|
+
|
|
1398
|
+
The PUT loop is bracketed by a host-side transfer lease
|
|
1399
|
+
(begin/end_transfer, best-effort — the host TTL covers a dead
|
|
1400
|
+
client) and shadowed by a WS keepalive task, so the empty reaper
|
|
1401
|
+
does not close the session during the WS-silent transfer and the
|
|
1402
|
+
final sync_pull does not land on a corpse."""
|
|
1403
|
+
if not items:
|
|
1404
|
+
return 0
|
|
1405
|
+
|
|
1406
|
+
hashed = [(dest, p, sz, _hash_file(p)) for dest, p, sz in items]
|
|
1407
|
+
self._best_effort(client.begin_transfer())
|
|
1408
|
+
keepalive = asyncio.run_coroutine_threadsafe(
|
|
1409
|
+
self._upload_keepalive(client), self._loop,
|
|
1410
|
+
)
|
|
1411
|
+
try:
|
|
1412
|
+
bucket = self._put_blobs_chunked(hashed)
|
|
1413
|
+
if bucket is None:
|
|
1414
|
+
# S3 unconfigured — degrade to inline (host caps apply).
|
|
1415
|
+
return self._upload_inline(
|
|
1416
|
+
client, [(d, p) for d, p, _, _ in hashed],
|
|
1417
|
+
)
|
|
1418
|
+
manifest = [
|
|
1419
|
+
{"path": dest, "hash": h, "size": sz}
|
|
1420
|
+
for dest, _p, sz, h in hashed
|
|
1421
|
+
]
|
|
1422
|
+
return self._sync_pull_checked(session_id, bucket, manifest)
|
|
1423
|
+
finally:
|
|
1424
|
+
keepalive.cancel()
|
|
1425
|
+
# The sync_pull retry may have swapped the client; release
|
|
1426
|
+
# the lease on whichever WS is live now.
|
|
1427
|
+
with self._lock:
|
|
1428
|
+
live = self._clients.get(session_id) or client
|
|
1429
|
+
self._best_effort(live.end_transfer())
|
|
1430
|
+
|
|
1431
|
+
def _put_blobs_chunked(
|
|
1432
|
+
self, hashed: list[tuple[str, Path, int, str]],
|
|
1433
|
+
) -> str | None:
|
|
1434
|
+
"""PUT blobs to S3, minting pre-signed URLs one chunk at a time
|
|
1435
|
+
so every signature is fresh when used (see _PRESIGN_CHUNK).
|
|
1436
|
+
Returns the bucket, or None when S3 is unconfigured."""
|
|
1437
|
+
bucket: str | None = None
|
|
1438
|
+
for start in range(0, len(hashed), _PRESIGN_CHUNK):
|
|
1439
|
+
chunk = hashed[start:start + _PRESIGN_CHUNK]
|
|
1440
|
+
urls = self._request_upload_urls(
|
|
1441
|
+
[{"hash": h, "size": sz} for _, _, sz, h in chunk]
|
|
1442
|
+
)
|
|
1443
|
+
if urls is None:
|
|
1444
|
+
return None
|
|
1445
|
+
bucket, uploads, exists, put_headers = urls
|
|
1446
|
+
for _dest, p, _sz, h in chunk:
|
|
1447
|
+
if h in exists:
|
|
1448
|
+
continue # deduped: blob already in S3
|
|
1449
|
+
put_url = uploads.get(h)
|
|
1450
|
+
if not put_url:
|
|
1451
|
+
raise FcloudRuntimeError(f"no upload URL minted for {p}")
|
|
1452
|
+
_blob_put(put_url, p, put_headers.get(h))
|
|
1453
|
+
return bucket
|
|
1454
|
+
|
|
1455
|
+
def _sync_pull_checked(
|
|
1456
|
+
self, session_id: str, bucket: str, manifest: list[dict],
|
|
1457
|
+
) -> int:
|
|
1458
|
+
"""Send sync_pull and validate the host's ack. Reconnects and
|
|
1459
|
+
retries once on a connection error ("Cannot write to closing
|
|
1460
|
+
transport", peer reset): the S3 objects are durable and
|
|
1461
|
+
content-addressed, so a second sync_pull is idempotent.
|
|
1462
|
+
Raises unless the host confirms every manifest entry."""
|
|
1463
|
+
client = self._require_client(session_id)
|
|
1464
|
+
try:
|
|
1465
|
+
resp = self._await(client.sync_pull(bucket, manifest))
|
|
1466
|
+
except Exception as exc:
|
|
1467
|
+
if not _is_connection_error(exc):
|
|
1468
|
+
raise
|
|
1469
|
+
client = self._reattach_client(session_id)
|
|
1470
|
+
resp = self._await(client.sync_pull(bucket, manifest))
|
|
1471
|
+
# The host reports a failed request as {"stderr": msg, "returncode":
|
|
1472
|
+
# -1} (hostservice errorResponse), not under an "error" key — reading
|
|
1473
|
+
# only "error" turned every real host failure into a misleading
|
|
1474
|
+
# "0/N landed". Surface the actual message.
|
|
1475
|
+
host_err = resp.get("error") or (
|
|
1476
|
+
resp.get("stderr") if resp.get("returncode") == -1 else None
|
|
1477
|
+
)
|
|
1478
|
+
if host_err:
|
|
1479
|
+
raise FcloudRuntimeError(f"host pull failed: {host_err}")
|
|
1480
|
+
added = int(resp.get("files_added", 0))
|
|
1481
|
+
requested = int(resp.get("files_requested", len(manifest)))
|
|
1482
|
+
if resp.get("status") == "partial" or added < requested:
|
|
1483
|
+
raise FcloudRuntimeError(
|
|
1484
|
+
f"host pull incomplete: {added}/{requested} "
|
|
1485
|
+
f"large files landed"
|
|
1486
|
+
)
|
|
1487
|
+
return added
|
|
1488
|
+
|
|
1489
|
+
def _reattach_client(self, session_id: str) -> DirectClient:
|
|
1490
|
+
"""Re-open the data-plane WS for a session whose connection died
|
|
1491
|
+
mid-upload: fresh dispatcher token, connect_with_retry, wait for
|
|
1492
|
+
ready, then swap the client table entry."""
|
|
1493
|
+
with self._lock:
|
|
1494
|
+
old = self._clients.pop(session_id, None)
|
|
1495
|
+
if old is not None:
|
|
1496
|
+
self._cleanup_client(old)
|
|
1497
|
+
allocation = v2_connect.reconnect_session(
|
|
1498
|
+
self._cfg.dispatcher_url, self._cfg.api_key, session_id,
|
|
1499
|
+
)
|
|
1500
|
+
client = self._await(
|
|
1501
|
+
v2_connect.connect_with_retry(
|
|
1502
|
+
allocation,
|
|
1503
|
+
dispatcher_url=self._cfg.dispatcher_url,
|
|
1504
|
+
api_key=self._cfg.api_key,
|
|
1505
|
+
)
|
|
1506
|
+
)
|
|
1507
|
+
try:
|
|
1508
|
+
self._await(client.wait_for_ready())
|
|
1509
|
+
except BaseException:
|
|
1510
|
+
self._cleanup_client(client)
|
|
1511
|
+
raise
|
|
1512
|
+
with self._lock:
|
|
1513
|
+
self._clients[session_id] = client
|
|
1514
|
+
return client
|
|
1515
|
+
|
|
1516
|
+
def _best_effort(self, coro) -> None:
|
|
1517
|
+
"""Run a fire-and-forget frame (transfer-lease bookkeeping) and
|
|
1518
|
+
swallow failures: an older host that doesn't know the frame, or
|
|
1519
|
+
a dying WS, must not fail the upload itself — the host-side
|
|
1520
|
+
lease TTL covers a client that never sends end_transfer."""
|
|
1521
|
+
try:
|
|
1522
|
+
self._await(coro, timeout=30.0)
|
|
1523
|
+
except Exception: # noqa: BLE001 — see docstring
|
|
1524
|
+
pass
|
|
1525
|
+
|
|
1526
|
+
async def _upload_keepalive(self, client: DirectClient) -> None:
|
|
1527
|
+
"""Periodic cheap frame over the session WS while the PUT loop
|
|
1528
|
+
runs on the calling thread. Without it the WS carries zero
|
|
1529
|
+
traffic for the whole transfer, the host lease expires under a
|
|
1530
|
+
slow single-file PUT, and the failure only surfaces at the final
|
|
1531
|
+
sync_pull send. Errors are swallowed — reconnection is
|
|
1532
|
+
owned by the sync_pull retry."""
|
|
1533
|
+
while True:
|
|
1534
|
+
await asyncio.sleep(_UPLOAD_KEEPALIVE_S)
|
|
1535
|
+
try:
|
|
1536
|
+
await client.sync_check()
|
|
1537
|
+
except Exception: # noqa: BLE001 — keepalive is best-effort
|
|
1538
|
+
pass
|
|
1539
|
+
|
|
1540
|
+
def _request_upload_urls(
|
|
1541
|
+
self, files: list[dict],
|
|
1542
|
+
) -> tuple[str, dict, set, dict] | None:
|
|
1543
|
+
"""Ask the dispatcher for pre-signed PUT URLs. Returns
|
|
1544
|
+
(bucket, {hash: url}, {existing hashes}, {hash: signed headers}),
|
|
1545
|
+
or None if the dispatcher reports S3 is unconfigured. The headers
|
|
1546
|
+
(``put_headers`` in the response, absent from older dispatchers)
|
|
1547
|
+
must accompany the PUT verbatim — see _blob_put."""
|
|
1548
|
+
import requests as _requests
|
|
1549
|
+
|
|
1550
|
+
headers = _dispatcher_headers(self._cfg.api_key)
|
|
1551
|
+
resp = _requests.post(
|
|
1552
|
+
self._cfg.dispatcher_url + "/v1/blobs/upload-url",
|
|
1553
|
+
json={"files": files},
|
|
1554
|
+
headers=headers,
|
|
1555
|
+
timeout=30,
|
|
1556
|
+
)
|
|
1557
|
+
if resp.status_code == 503:
|
|
1558
|
+
return None
|
|
1559
|
+
data = resp.json()
|
|
1560
|
+
_raise_if_client_too_old(resp.status_code, data)
|
|
1561
|
+
if resp.status_code >= 400 or "error" in data:
|
|
1562
|
+
raise FcloudRuntimeError(
|
|
1563
|
+
data.get("error") or f"presign failed: status {resp.status_code}"
|
|
1564
|
+
)
|
|
1565
|
+
return (
|
|
1566
|
+
data.get("bucket", ""),
|
|
1567
|
+
data.get("uploads", {}) or {},
|
|
1568
|
+
set(data.get("exists", []) or []),
|
|
1569
|
+
data.get("put_headers", {}) or {},
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
def download_file(
|
|
1573
|
+
self,
|
|
1574
|
+
session_id: str,
|
|
1575
|
+
remote_path: str,
|
|
1576
|
+
) -> bytes:
|
|
1577
|
+
"""Read a single file from the session's workspace and return
|
|
1578
|
+
its bytes. Small files come back inline over the WS; large files
|
|
1579
|
+
are fetched from S3 via a pre-signed GET (the host returns a blob
|
|
1580
|
+
reference). Raises FileNotFoundError if not present."""
|
|
1581
|
+
import base64 as _b64
|
|
1582
|
+
|
|
1583
|
+
client = self._require_client(session_id)
|
|
1584
|
+
resp = self._await(
|
|
1585
|
+
client.read_file_meta(remote_path, _INLINE_FILE_LIMIT)
|
|
1586
|
+
)
|
|
1587
|
+
if resp.get("status") != "ok":
|
|
1588
|
+
err = resp.get("stderr") or resp.get("error") or str(resp)
|
|
1589
|
+
raise FileNotFoundError(f"download {remote_path}: {err}")
|
|
1590
|
+
if resp.get("mode") == "blob":
|
|
1591
|
+
return self._download_blob(resp["hash"])
|
|
1592
|
+
return _b64.b64decode(resp.get("content_b64", ""))
|
|
1593
|
+
|
|
1594
|
+
def _download_blob(self, file_hash: str) -> bytes:
|
|
1595
|
+
"""Fetch a content-addressed blob from S3 via a dispatcher-minted
|
|
1596
|
+
pre-signed GET URL."""
|
|
1597
|
+
import requests as _requests
|
|
1598
|
+
|
|
1599
|
+
headers = _dispatcher_headers(self._cfg.api_key)
|
|
1600
|
+
resp = _requests.post(
|
|
1601
|
+
self._cfg.dispatcher_url + "/v1/blobs/download-url",
|
|
1602
|
+
json={"hashes": [file_hash]},
|
|
1603
|
+
headers=headers,
|
|
1604
|
+
timeout=30,
|
|
1605
|
+
)
|
|
1606
|
+
data = resp.json()
|
|
1607
|
+
_raise_if_client_too_old(resp.status_code, data)
|
|
1608
|
+
if resp.status_code >= 400 or "error" in data:
|
|
1609
|
+
raise FcloudRuntimeError(
|
|
1610
|
+
data.get("error")
|
|
1611
|
+
or f"download presign failed: status {resp.status_code}"
|
|
1612
|
+
)
|
|
1613
|
+
url = (data.get("urls") or {}).get(file_hash)
|
|
1614
|
+
if not url:
|
|
1615
|
+
raise FcloudRuntimeError(f"no download URL minted for {file_hash}")
|
|
1616
|
+
got = _requests.get(url, timeout=_BLOB_HTTP_TIMEOUT)
|
|
1617
|
+
if got.status_code >= 300:
|
|
1618
|
+
raise FcloudRuntimeError(
|
|
1619
|
+
f"S3 download failed ({got.status_code}): {got.text[:200]}"
|
|
1620
|
+
)
|
|
1621
|
+
return got.content
|
|
1622
|
+
|
|
1623
|
+
def _require_client(self, session_id: str) -> DirectClient:
|
|
1624
|
+
with self._lock:
|
|
1625
|
+
client = self._clients.get(session_id)
|
|
1626
|
+
if client is None:
|
|
1627
|
+
raise FcloudRuntimeError(
|
|
1628
|
+
f"session {session_id} is not active on this direct "
|
|
1629
|
+
f"client (was it closed? only sessions created via "
|
|
1630
|
+
f"this process have WS handles here)"
|
|
1631
|
+
)
|
|
1632
|
+
return client
|
|
1633
|
+
|
|
1634
|
+
# ---- shutdown -------------------------------------------------
|
|
1635
|
+
|
|
1636
|
+
def shutdown(self) -> None:
|
|
1637
|
+
"""Close every live WS and stop the event loop. Safe to
|
|
1638
|
+
call multiple times."""
|
|
1639
|
+
with self._lock:
|
|
1640
|
+
clients = list(self._clients.items())
|
|
1641
|
+
self._clients.clear()
|
|
1642
|
+
for _, c in clients:
|
|
1643
|
+
self._cleanup_client(c)
|
|
1644
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
1645
|
+
self._thread.join(timeout=_CLEANUP_TIMEOUT_S)
|