hfdask 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.
hfdask/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Run Dask workloads on Hugging Face Jobs.
2
+
3
+ `JobSpec` describes a workload and `submit` launches it on one paid HF Job.
4
+ Multi-machine and persistent clusters use `hfdask.cluster`; the CLI ships a
5
+ locked Git project and runs an ordinary Python script from a YAML definition.
6
+
7
+ ## Module reference
8
+
9
+ - [Jobs](hfdask/jobs.html) — workload specifications and single-Job lifecycle
10
+ - [Clusters](hfdask/cluster.html) — worker groups, identities, launch, and recovery
11
+ - [Clients](hfdask/client.html) — persistent Dask connections over the encrypted mesh
12
+ - [Routing](hfdask/routing.html) — categorical worker affinity and resource reservations
13
+ - [Configuration](hfdask/config.html) — strict input schemas and validation contracts
14
+ - [CLI](hfdask/cli.html) — source packaging, staging, and script submission
15
+ - [Runner](hfdask/runner.html) — in-Job workload execution
16
+ - [Hardware](hfdask/hardware.html) — detected worker profiles and resources
17
+ - [Network](hfdask/network.html) — authenticated transport and loopback proxies
18
+
19
+ ## Lifecycle and safety
20
+
21
+ Submission reserves paid capacity. Keep recovery manifests until termination
22
+ is verified; a local timeout or client disconnect does not release remote Jobs.
23
+ HF credentials stay with the submitter. Only run trusted workloads and grant
24
+ mounts the minimum access they need.
25
+ """
26
+
27
+ from .cluster import WorkerGroup
28
+ from .jobs import Job, JobFailed, JobSpec, submit
29
+
30
+ __all__ = ["Job", "JobFailed", "JobSpec", "WorkerGroup", "submit"]
hfdask/bootstrap.py ADDED
@@ -0,0 +1,86 @@
1
+ """Stage and launch a locked project using only the image's standard library."""
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tarfile
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ SOURCE = Path("/tmp/hfdask-source/project.tar.gz")
14
+ ROOT = Path("/tmp/hfdask-project")
15
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
16
+ MAX_SOURCE_BYTES = 8 * 1024 * 1024
17
+ MAX_FILES = 2000
18
+
19
+
20
+ def require_verified_payload(payload: bytes, checksum: str) -> None:
21
+ if len(payload) > MAX_ARCHIVE_BYTES:
22
+ raise SystemExit("hfdask compressed source exceeds 8 MiB")
23
+ if hashlib.sha256(payload).hexdigest() != checksum:
24
+ raise SystemExit("hfdask source SHA256 checksum mismatch")
25
+
26
+
27
+ def require_extraction_budget(file_count: int, total: int) -> None:
28
+ if file_count > MAX_FILES or total > MAX_SOURCE_BYTES:
29
+ raise SystemExit("hfdask source archive exceeds extraction limits")
30
+
31
+
32
+ def require_safe_archive_member(
33
+ member: tarfile.TarInfo, names: set[PurePosixPath]
34
+ ) -> PurePosixPath:
35
+ path = PurePosixPath(member.name)
36
+ if (
37
+ not member.isfile()
38
+ or path.is_absolute()
39
+ or ".." in path.parts
40
+ or not path.parts
41
+ or member.size < 0
42
+ or path in names
43
+ ):
44
+ raise SystemExit("Unsafe hfdask source archive")
45
+ return path
46
+
47
+
48
+ def main() -> None:
49
+ groups = json.loads(sys.argv[1])
50
+ with SOURCE.open("rb") as source:
51
+ payload = source.read(MAX_ARCHIVE_BYTES + 1)
52
+ require_verified_payload(payload, sys.argv[2])
53
+ print("hfdask: staging project source", flush=True)
54
+ ROOT.mkdir(mode=0o700, parents=True, exist_ok=False)
55
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
56
+ members: list[tarfile.TarInfo] = []
57
+ total = 0
58
+ names: set[PurePosixPath] = set()
59
+ for member in archive:
60
+ total += member.size
61
+ require_extraction_budget(len(members) + 1, total)
62
+ path = require_safe_archive_member(member, names)
63
+ names.add(path)
64
+ members.append(member)
65
+ for member in members:
66
+ target = ROOT.joinpath(*PurePosixPath(member.name).parts)
67
+ target.parent.mkdir(parents=True, exist_ok=True)
68
+ extracted = archive.extractfile(member)
69
+ if extracted is None:
70
+ raise SystemExit("Unsafe hfdask source archive")
71
+ with extracted, target.open("xb") as output:
72
+ shutil.copyfileobj(extracted, output)
73
+ target.chmod(member.mode & 0o777)
74
+ os.chdir(ROOT)
75
+ uv = shutil.which("uv")
76
+ if uv is None:
77
+ raise SystemExit("environment.image must contain uv and Python")
78
+ print("hfdask: syncing locked environment", flush=True)
79
+ group_args = [arg for group in groups for arg in ("--group", group)]
80
+ subprocess.run([uv, "sync", "--locked", "--no-dev", *group_args], check=True)
81
+ print("hfdask: starting runner", flush=True)
82
+ os.execv(uv, [uv, "run", "--no-sync", *sys.argv[3:]])
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()
hfdask/cli.py ADDED
@@ -0,0 +1,348 @@
1
+ """YAML-driven batch submission of a locked, Git-selected source project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import subprocess
13
+ import sys
14
+ import tarfile
15
+ import tempfile
16
+ import tomllib
17
+ from dataclasses import replace
18
+ from functools import partial
19
+ from importlib import import_module
20
+ from importlib.resources import files
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any
23
+ from uuid import uuid4
24
+
25
+ from huggingface_hub import HfApi, Volume
26
+
27
+ from .cluster import Cluster, Identity, LaunchError, submit_cluster
28
+ from .config import MOUNT_SOURCE, ClusterConfig, PackageConfig, PackagePlan, ProjectConfig
29
+ from .jobs import JobSpec
30
+
31
+ # Bound both upload size and extracted working-tree size.
32
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
33
+ MAX_SOURCE_BYTES = 8 * 1024 * 1024
34
+ MAX_FILES = 2000
35
+
36
+
37
+ def _secret_path(path: PurePosixPath) -> bool:
38
+ return any(
39
+ part
40
+ in {
41
+ ".git",
42
+ ".venv",
43
+ ".ssh",
44
+ ".aws",
45
+ ".gnupg",
46
+ ".hfdask",
47
+ "mise.local.toml",
48
+ ".mise.local.toml",
49
+ ".netrc",
50
+ ".pypirc",
51
+ "credentials",
52
+ "credentials.json",
53
+ "id_rsa",
54
+ "id_ed25519",
55
+ "secrets",
56
+ ".secrets",
57
+ }
58
+ or part.startswith((".env", "secrets."))
59
+ or part.endswith((".env", ".pem", ".key", ".p12", ".pfx"))
60
+ for part in path.parts
61
+ )
62
+
63
+
64
+ def require_safe_project_file(root: Path, name: str) -> Path | None:
65
+ relative = PurePosixPath(name)
66
+ if relative.is_absolute() or ".." in relative.parts:
67
+ raise ValueError(f"Unsafe project path: {name}")
68
+ if _secret_path(relative):
69
+ return None
70
+ candidate = root
71
+ for part in relative.parts:
72
+ candidate = candidate / part
73
+ if candidate.is_symlink():
74
+ raise ValueError(f"Project symlinks are not supported: {name}; exclude with .gitignore")
75
+ if not candidate.exists(): # Tracked files may be deleted in the working tree.
76
+ return None
77
+ if not stat.S_ISREG(candidate.stat().st_mode):
78
+ raise ValueError(f"Project entry is not a regular file: {name}")
79
+ return candidate
80
+
81
+
82
+ def require_source_budget(total: int, file_count: int) -> None:
83
+ if total > MAX_SOURCE_BYTES or file_count > MAX_FILES:
84
+ raise ValueError(
85
+ "Project source exceeds 8 MiB/2000 files; move data to bucket mounts "
86
+ "and exclude generated files with .gitignore"
87
+ )
88
+
89
+
90
+ def require_project_inputs(files: dict[str, bytes], script: str) -> None:
91
+ for required in ("pyproject.toml", "uv.lock", script):
92
+ if required not in files:
93
+ raise ValueError(
94
+ f"Required project file {required} is missing or excluded; "
95
+ "run uv lock and check .gitignore"
96
+ )
97
+
98
+
99
+ def require_upload_budget(payload: bytes) -> bytes:
100
+ if len(payload) > MAX_ARCHIVE_BYTES:
101
+ raise ValueError(
102
+ "Compressed source exceeds 8 MiB upload limit; move data to bucket "
103
+ "mounts and exclude generated files with .gitignore"
104
+ )
105
+ return payload
106
+
107
+
108
+ def package_project(root: Path, script: str, groups: list[str]) -> bytes:
109
+ """Snapshot working-tree files, not HEAD; never follow project symlinks."""
110
+ inputs = PackageConfig(script=script, groups=groups)
111
+ script = inputs.script
112
+ try:
113
+ selected = subprocess.run(
114
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."],
115
+ cwd=root,
116
+ check=True,
117
+ capture_output=True,
118
+ ).stdout.split(b"\0")
119
+ # --exclude-standard only filters untracked files; also exclude ignored tracked files.
120
+ ignored = set(
121
+ subprocess.run(
122
+ ["git", "ls-files", "--cached", "--ignored", "--exclude-standard", "-z", "--", "."],
123
+ cwd=root,
124
+ check=True,
125
+ capture_output=True,
126
+ ).stdout.split(b"\0")
127
+ )
128
+ except (OSError, subprocess.CalledProcessError) as error:
129
+ raise ValueError("Run from a Git project containing pyproject.toml and uv.lock") from error
130
+ files: dict[str, bytes] = {}
131
+ modes: dict[str, int] = {}
132
+ total = 0
133
+ for raw in sorted(set(selected) - ignored - {b""}):
134
+ name = os.fsdecode(raw)
135
+ candidate = require_safe_project_file(root, name)
136
+ if candidate is None:
137
+ continue
138
+ metadata = candidate.stat()
139
+ total += metadata.st_size
140
+ require_source_budget(total, len(files) + 1)
141
+ files[name] = candidate.read_bytes()
142
+ modes[name] = 0o755 if metadata.st_mode & 0o111 else 0o644
143
+ require_project_inputs(files, script)
144
+ metadata = tomllib.loads(files["pyproject.toml"].decode())
145
+ PackagePlan.model_validate(
146
+ {
147
+ "script": script,
148
+ "groups": inputs.groups,
149
+ "project": ProjectConfig.model_validate(metadata.get("project", {})),
150
+ "dependency-groups": metadata.get("dependency-groups", {}),
151
+ }
152
+ )
153
+ buffer = io.BytesIO()
154
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
155
+ for name, data in files.items():
156
+ member = tarfile.TarInfo(name)
157
+ member.size = len(data)
158
+ member.mode = modes[name]
159
+ archive.addfile(member, io.BytesIO(data))
160
+ return require_upload_budget(buffer.getvalue())
161
+
162
+
163
+ def require_private_source_bucket(api: HfApi, bucket_id: str) -> None:
164
+ if api.bucket_info(bucket_id).private is not True:
165
+ raise ValueError(
166
+ f"Source bucket {bucket_id} must be verified private; "
167
+ "refusing upload without changing visibility"
168
+ )
169
+
170
+
171
+ def prepare_spec(spec: JobSpec, root: Path, script: str, groups: list[str], api: HfApi) -> JobSpec:
172
+ """Validate and stage source once; retained artifacts incur storage until deleted."""
173
+ payload = package_project(root, script, groups)
174
+ bucket_id = f"{spec.namespace}/jobs-artifacts"
175
+ folder = f"hfdask-source/{uuid4()}"
176
+ remote_path = f"{folder}/project.tar.gz"
177
+ api.create_bucket(bucket_id, private=True, exist_ok=True)
178
+ require_private_source_bucket(api, bucket_id)
179
+ bootstrap = files("hfdask").joinpath("bootstrap.py").read_bytes()
180
+ api.batch_bucket_files(
181
+ bucket_id,
182
+ add=[
183
+ (payload, remote_path),
184
+ (bootstrap, f"{folder}/bootstrap.py"),
185
+ ],
186
+ )
187
+ print(
188
+ f"Source artifact: hf://buckets/{bucket_id}/{remote_path} "
189
+ "(retained after run; storage charges may apply until manually deleted)",
190
+ file=sys.stderr,
191
+ )
192
+ return replace(
193
+ spec,
194
+ bootstrap=(
195
+ "python3",
196
+ "/tmp/hfdask-source/bootstrap.py",
197
+ json.dumps(groups),
198
+ hashlib.sha256(payload).hexdigest(),
199
+ ),
200
+ volumes=[
201
+ *spec.volumes,
202
+ Volume(
203
+ type="bucket",
204
+ source=bucket_id,
205
+ path=folder,
206
+ mount_path="/tmp/hfdask-source",
207
+ read_only=True,
208
+ ),
209
+ ],
210
+ )
211
+
212
+
213
+ def load_cluster(
214
+ path: Path,
215
+ root: Path,
216
+ script: str,
217
+ ) -> tuple[JobSpec, dict[str, Any], float, list[str]]:
218
+ # Load lazily so non-CLI library users do not need PyYAML at import time.
219
+ yaml = import_module("yaml")
220
+
221
+ config = ClusterConfig.model_validate(yaml.safe_load(path.read_text()))
222
+ scheduler_worker = config.coordinator.worker
223
+ volumes: list[Volume] = []
224
+ for mount in config.mounts:
225
+ location = MOUNT_SOURCE.fullmatch(mount.source)
226
+ assert location is not None # Validated by MountConfig; conversion only below.
227
+ kind, namespace, name, subfolder = location.groups()
228
+ volumes.append(
229
+ Volume(
230
+ type=kind[:-1],
231
+ source=f"{namespace}/{name}",
232
+ revision=mount.revision,
233
+ path=subfolder or "",
234
+ mount_path=mount.target,
235
+ read_only=mount.read_only,
236
+ )
237
+ )
238
+ spec = JobSpec(
239
+ namespace=config.namespace,
240
+ image=config.environment.image,
241
+ entrypoint="hfdask.runner:run_script",
242
+ kwargs={"script": script},
243
+ # YAML counts remote workers; JobSpec includes the colocated worker.
244
+ workers=config.workers.count + int(scheduler_worker),
245
+ flavor=config.workers.flavor,
246
+ timeout=config.timeout,
247
+ volumes=volumes,
248
+ env={
249
+ "PYTHONPATH": "/tmp/hfdask-project:"
250
+ + str(PurePosixPath("/tmp/hfdask-project") / PurePosixPath(script).parent),
251
+ "DASK_DISTRIBUTED__WORKER__DAEMON": "False",
252
+ "DASK_DISTRIBUTED__WORKER__MULTIPROCESSING_METHOD": "spawn",
253
+ "VLLM_WORKER_MULTIPROC_METHOD": "spawn",
254
+ },
255
+ )
256
+ options: dict[str, Any] = {
257
+ "public_relays": True,
258
+ "scheduler_worker": scheduler_worker,
259
+ "scheduler_flavor": config.coordinator.flavor,
260
+ }
261
+ return spec, options, config.timeout_seconds, config.environment.groups
262
+
263
+
264
+ def save_manifest(path: Path, cluster: Cluster) -> None:
265
+ """Atomic replacement; manifests contain only public recovery handles."""
266
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as output:
267
+ temporary = Path(output.name)
268
+ try:
269
+ json.dump(cluster.manifest(), output, indent=2)
270
+ output.write("\n")
271
+ output.flush()
272
+ os.fsync(output.fileno())
273
+ except BaseException:
274
+ temporary.unlink(missing_ok=True)
275
+ raise
276
+ try:
277
+ temporary.replace(path)
278
+ finally:
279
+ temporary.unlink(missing_ok=True)
280
+
281
+
282
+ def main(argv: list[str] | None = None) -> int:
283
+ parser = argparse.ArgumentParser(prog="hfdask")
284
+ commands = parser.add_subparsers(dest="command", required=True)
285
+ run = commands.add_parser("run", help="Run a project-relative Dask script on a YAML cluster")
286
+ run.add_argument("--cluster", type=Path, required=True)
287
+ run.add_argument(
288
+ "--manifest", type=Path, help="Public recovery manifest (default: .hfdask/run-*.json)"
289
+ )
290
+ run.add_argument("script")
291
+ args = parser.parse_args(argv)
292
+ cluster: Cluster | None = None
293
+ manifest: Path = (
294
+ args.manifest
295
+ if args.manifest is not None
296
+ else Path(".hfdask") / f"run-{secrets.token_hex(8)}.json"
297
+ )
298
+ try:
299
+ spec, options, timeout, groups = load_cluster(args.cluster, Path.cwd(), args.script)
300
+ nodes = spec.workers + 1 - int(options["scheduler_worker"])
301
+ identities = [Identity(secrets.token_bytes(32)) for _ in range(nodes)]
302
+ # Validate transport identities before reserving the manifest or submitting.
303
+ for identity in identities:
304
+ identity.public_id()
305
+
306
+ manifest.parent.mkdir(parents=True, exist_ok=True)
307
+ with manifest.open("x") as output:
308
+ output.write("{}\n")
309
+ print(f"Recovery manifest: {manifest}", file=sys.stderr)
310
+ api = HfApi()
311
+ spec = prepare_spec(spec, Path.cwd(), args.script, groups, api)
312
+ try:
313
+ cluster = submit_cluster(
314
+ spec, identities, api=api, **options, on_submitted=partial(save_manifest, manifest)
315
+ )
316
+ except LaunchError as error:
317
+ cluster = error.cluster
318
+ raise
319
+ cluster.wait(timeout=timeout)
320
+ return 0
321
+ # CLI boundary: report failures and release known jobs before returning an exit code.
322
+ except (Exception, KeyboardInterrupt) as error: # noqa: BLE001
323
+ print(f"hfdask: {error or 'interrupted'}", file=sys.stderr)
324
+ if isinstance(error, LaunchError) and error.__cause__ is not None:
325
+ print(f"Submission cause: {error.__cause__}", file=sys.stderr)
326
+ if cluster is not None:
327
+ try:
328
+ save_manifest(manifest, cluster)
329
+ # A manifest failure must not prevent cleanup; print recovery handles instead.
330
+ except Exception as save_error: # noqa: BLE001
331
+ print(
332
+ f"Manifest write failed: {save_error}; known jobs: "
333
+ f"{json.dumps(cluster.manifest())}",
334
+ file=sys.stderr,
335
+ )
336
+ try:
337
+ cluster.close()
338
+ # Report any cleanup failure without claiming that capacity was released.
339
+ except Exception as close_error: # noqa: BLE001
340
+ print(f"Cleanup unverified: {close_error}; retain {manifest}", file=sys.stderr)
341
+ interrupted = isinstance(error, KeyboardInterrupt) or (
342
+ isinstance(error, LaunchError) and isinstance(error.__cause__, KeyboardInterrupt)
343
+ )
344
+ return 130 if interrupted else 1
345
+
346
+
347
+ if __name__ == "__main__":
348
+ raise SystemExit(main())
hfdask/client.py ADDED
@@ -0,0 +1,135 @@
1
+ """External synchronous Dask clients over the cluster's encrypted mesh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import threading
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from typing import Any
11
+
12
+ from .cluster import Identity
13
+ from .config import ConnectConfig, ConnectionConfig
14
+
15
+ _connection_lock = threading.Lock()
16
+
17
+
18
+ async def _serve(
19
+ config: ConnectionConfig,
20
+ identity: Identity,
21
+ ready: concurrent.futures.Future[tuple[int, ...]],
22
+ stop: threading.Event,
23
+ timeout: float,
24
+ ) -> None:
25
+ import iroh
26
+
27
+ from .hardware import service_owners
28
+ from .network import ALPN, Mesh, fetch_worker_counts
29
+
30
+ # FFI annotates BaseEventLoop but uses the standard AbstractEventLoop interface.
31
+ iroh.iroh_ffi.uniffi_set_event_loop(asyncio.get_running_loop()) # ty: ignore[invalid-argument-type]
32
+ mode = (
33
+ iroh.RelayMode.custom_from_urls(config.relays)
34
+ if config.relays
35
+ else iroh.RelayMode.default_mode()
36
+ )
37
+ endpoint = await iroh.Endpoint.bind(
38
+ iroh.EndpointOptions(
39
+ preset=iroh.preset_n0(), secret_key=identity.secret, alpns=[ALPN], relay_mode=mode
40
+ )
41
+ )
42
+ try:
43
+ await asyncio.wait_for(endpoint.online(), timeout=timeout)
44
+ peers = [
45
+ iroh.EndpointAddr(iroh.EndpointId.from_bytes(bytes.fromhex(peer)), None, [])
46
+ for peer in config.peers
47
+ ]
48
+ nodes = config.job_nodes
49
+ workers_per_node = await fetch_worker_counts(endpoint, peers[0], timeout)
50
+ if len(workers_per_node) != nodes:
51
+ raise ConnectionError("Live worker topology does not match the manifest")
52
+ async with Mesh(
53
+ endpoint,
54
+ peers,
55
+ nodes,
56
+ services=service_owners(workers_per_node),
57
+ workers_per_node=workers_per_node,
58
+ max_connections=max(256, 4 * sum(workers_per_node)),
59
+ ):
60
+ ready.set_result(workers_per_node)
61
+ while not stop.is_set():
62
+ await asyncio.sleep(0.1)
63
+ finally:
64
+ await endpoint.close()
65
+
66
+
67
+ @contextmanager
68
+ def connect(
69
+ manifest: dict[str, Any], identity: Identity, *, timeout: float = 1200
70
+ ) -> Iterator[Any]:
71
+ """Connect to a persistent cluster without taking ownership of its HF Jobs.
72
+
73
+ Args:
74
+ manifest: Public `hfdask.cluster.Cluster.manifest` from `boot_cluster`.
75
+ identity: Private client identity supplied at boot, not a Job node identity.
76
+ timeout: Positive timeout in seconds used for mesh startup, Dask connection,
77
+ and worker readiness. These phases do not share one total deadline.
78
+
79
+ Yields:
80
+ A synchronous Dask client after the expected worker topology is ready.
81
+ It is not installed as Dask's default client.
82
+
83
+ Raises:
84
+ ValueError: If the manifest, identity, or timeout fails validation.
85
+ TimeoutError: If mesh startup or worker readiness exceeds its deadline.
86
+ RuntimeError: If this process already has a mesh client or cleanup cannot finish.
87
+
88
+ One mesh connection per host: fixed loopback ports start at 21000. Keep the
89
+ context open while using futures. Exiting closes the client and local mesh,
90
+ never the HF Jobs; explicitly call `hfdask.cluster.Cluster.close` to release them.
91
+ """
92
+ from distributed import Client
93
+
94
+ from .runner import wait_topology
95
+
96
+ connection = ConnectionConfig.model_validate(manifest.get("connection"))
97
+ ConnectConfig(timeout=timeout, connection=connection, public_id=identity.public_id())
98
+
99
+ if not _connection_lock.acquire(blocking=False):
100
+ raise RuntimeError("A mesh client is already connected in this process")
101
+ ready: concurrent.futures.Future[tuple[int, ...]] = concurrent.futures.Future()
102
+ stopped = threading.Event()
103
+ loop = asyncio.new_event_loop()
104
+ task: asyncio.Task[None] | None = None
105
+
106
+ def serve() -> None:
107
+ nonlocal task
108
+ asyncio.set_event_loop(loop)
109
+ task = loop.create_task(_serve(connection, identity, ready, stopped, timeout))
110
+ try:
111
+ loop.run_until_complete(task)
112
+ except BaseException as error: # noqa: BLE001 - propagate startup failures across threads.
113
+ if not ready.done():
114
+ ready.set_exception(error)
115
+ finally:
116
+ loop.run_until_complete(loop.shutdown_asyncgens())
117
+ loop.close()
118
+
119
+ thread = threading.Thread(target=serve, name="hfdask-client-mesh", daemon=True)
120
+ try:
121
+ thread.start()
122
+ workers_per_node = ready.result(timeout=timeout + 5)
123
+ with Client("tcp://127.0.0.1:21000", timeout=timeout, set_as_default=False) as client:
124
+ wait_topology(client, workers_per_node, timeout)
125
+ yield client
126
+ finally:
127
+ stopped.set()
128
+ if thread.ident is not None:
129
+ thread.join(timeout=5)
130
+ if thread.is_alive() and not loop.is_closed() and task is not None:
131
+ loop.call_soon_threadsafe(task.cancel)
132
+ thread.join(timeout=5)
133
+ if thread.is_alive():
134
+ raise RuntimeError("Mesh cleanup did not finish; restart this client process")
135
+ _connection_lock.release()