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.
Files changed (63) hide show
  1. fcloud/SKILL.md +1185 -0
  2. fcloud/__init__.py +85 -0
  3. fcloud/__main__.py +150 -0
  4. fcloud/_direct_bridge.py +1645 -0
  5. fcloud/_legacy_env.py +14 -0
  6. fcloud/cli/__init__.py +23 -0
  7. fcloud/cli/attach.py +180 -0
  8. fcloud/cli/common.py +382 -0
  9. fcloud/cli/console.py +227 -0
  10. fcloud/cli/context.py +161 -0
  11. fcloud/cli/exec_cmd.py +201 -0
  12. fcloud/cli/files.py +280 -0
  13. fcloud/cli/hardware.py +83 -0
  14. fcloud/cli/help.py +150 -0
  15. fcloud/cli/interactive.py +104 -0
  16. fcloud/cli/job.py +300 -0
  17. fcloud/cli/main.py +140 -0
  18. fcloud/cli/migration.py +276 -0
  19. fcloud/cli/mount.py +150 -0
  20. fcloud/cli/output.py +17 -0
  21. fcloud/cli/processes.py +360 -0
  22. fcloud/cli/registry.py +28 -0
  23. fcloud/cli/run.py +318 -0
  24. fcloud/cli/sessions.py +592 -0
  25. fcloud/cli/setup.py +44 -0
  26. fcloud/cli/ssh.py +255 -0
  27. fcloud/cli/sweep.py +578 -0
  28. fcloud/cli/sweep_harvest.py +187 -0
  29. fcloud/cli/sweep_watch.py +104 -0
  30. fcloud/cli/volume.py +317 -0
  31. fcloud/cli/wait.py +314 -0
  32. fcloud/cli_args.py +348 -0
  33. fcloud/client.py +632 -0
  34. fcloud/client_projects.py +52 -0
  35. fcloud/client_sessions.py +111 -0
  36. fcloud/client_volumes.py +80 -0
  37. fcloud/config.py +404 -0
  38. fcloud/direct.py +535 -0
  39. fcloud/errors.py +102 -0
  40. fcloud/fileset.py +155 -0
  41. fcloud/image.py +483 -0
  42. fcloud/job.py +249 -0
  43. fcloud/providers/__init__.py +5 -0
  44. fcloud/providers/requests_http.py +59 -0
  45. fcloud/py.typed +0 -0
  46. fcloud/session.py +442 -0
  47. fcloud/setup_cmd.py +213 -0
  48. fcloud/shell.py +558 -0
  49. fcloud/sweeps.py +557 -0
  50. fcloud/tunnel.py +251 -0
  51. fcloud/types.py +302 -0
  52. fcloud/v2_connect.py +928 -0
  53. fcloud/version.py +33 -0
  54. fcloud/volume_wait.py +131 -0
  55. fcloud/volumes.py +204 -0
  56. fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
  57. fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
  58. fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
  59. fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  60. fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
  62. fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
  63. foom/__init__.py +36 -0
@@ -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)