vouchington-tooling 0.0.4 → 0.0.6

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 (52) hide show
  1. package/README.md +10 -0
  2. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +6 -0
  3. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +103 -0
  4. package/dist/cli/commands/github-output.test-helpers.d.mts +1 -0
  5. package/dist/cli/commands/github-output.test-helpers.mjs +32 -0
  6. package/dist/cli/commands/pnpm-install.d.mts +1 -0
  7. package/dist/cli/commands/pnpm-install.mjs +27 -0
  8. package/dist/cli/commands/spawn-script.d.mts +5 -0
  9. package/dist/cli/commands/spawn-script.mjs +7 -0
  10. package/dist/cli/commands/vitest-blob-manifest.d.mts +1 -0
  11. package/dist/cli/commands/vitest-blob-manifest.mjs +11 -0
  12. package/dist/cli/index.mjs +28 -0
  13. package/dist/cli/parse.d.mts +11 -0
  14. package/dist/cli/parse.mjs +14 -0
  15. package/dist/cli/script-path.d.mts +1 -0
  16. package/dist/cli/script-path.mjs +4 -0
  17. package/dist/cli/usage.d.mts +1 -1
  18. package/dist/cli/usage.mjs +16 -1
  19. package/dist/index.d.mts +6 -0
  20. package/dist/index.mjs +3 -0
  21. package/dist/pnpm-install/exec.d.mts +8 -0
  22. package/dist/pnpm-install/exec.mjs +88 -0
  23. package/dist/pnpm-install/index.d.mts +7 -0
  24. package/dist/pnpm-install/index.mjs +6 -0
  25. package/dist/pnpm-install/metadata.d.mts +5 -0
  26. package/dist/pnpm-install/metadata.mjs +91 -0
  27. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.d.mts +22 -0
  28. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +174 -0
  29. package/dist/pnpm-install/process.d.mts +11 -0
  30. package/dist/pnpm-install/process.mjs +81 -0
  31. package/dist/pnpm-install/release-age.d.mts +9 -0
  32. package/dist/pnpm-install/release-age.mjs +59 -0
  33. package/dist/pnpm-install/runner.d.mts +2 -0
  34. package/dist/pnpm-install/runner.mjs +106 -0
  35. package/dist/pnpm-install/support.d.mts +30 -0
  36. package/dist/pnpm-install/support.mjs +143 -0
  37. package/dist/shared-context/fake-git.test-helpers.d.mts +11 -0
  38. package/dist/shared-context/fake-git.test-helpers.mjs +59 -0
  39. package/dist/shared-context/index.d.mts +26 -0
  40. package/dist/shared-context/index.mjs +89 -0
  41. package/dist/vitest-blob-manifest/cli.d.mts +2 -0
  42. package/dist/vitest-blob-manifest/cli.mjs +33 -0
  43. package/dist/vitest-blob-manifest/index.d.mts +37 -0
  44. package/dist/vitest-blob-manifest/index.mjs +134 -0
  45. package/package.json +16 -1
  46. package/scripts/allocate-browser-safe-ports.py +661 -0
  47. package/scripts/fetch-forbidden-ports.json +7 -0
  48. package/scripts/gha/check-needs-results.sh +19 -0
  49. package/scripts/gha/download-with-diagnostics.sh +52 -0
  50. package/scripts/gha/host-pressure-diagnostics.sh +179 -0
  51. package/scripts/gha/write-github-multiline-output.sh +60 -0
  52. package/scripts/runner-port-policy.json +7 -0
@@ -0,0 +1,661 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import errno
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
17
+ FETCH_FORBIDDEN_PORTS_PATH = SCRIPT_DIR / "fetch-forbidden-ports.json"
18
+ RUNNER_PORT_POLICY_PATH = SCRIPT_DIR / "runner-port-policy.json"
19
+ CHROMIUM_RESTRICTED_PORTS: frozenset[int] = frozenset()
20
+
21
+
22
+ def load_forbidden_ports(path: Path) -> frozenset[int]:
23
+ return frozenset(json.loads(path.read_text()))
24
+
25
+
26
+ def load_runner_port_policy(path: Path) -> dict[str, int]:
27
+ policy = json.loads(path.read_text())
28
+ required_keys = {
29
+ "reservedPortStart",
30
+ "reservedPortEnd",
31
+ "portsPerRunner",
32
+ "minimumRunnerSlot",
33
+ "maximumRunnerSlot",
34
+ }
35
+ if set(policy) != required_keys or not all(
36
+ type(policy[key]) is int for key in required_keys
37
+ ):
38
+ raise RuntimeError(f"invalid runner port policy at {path}")
39
+
40
+ start = policy["reservedPortStart"]
41
+ end = policy["reservedPortEnd"]
42
+ ports_per_runner = policy["portsPerRunner"]
43
+ minimum_slot = policy["minimumRunnerSlot"]
44
+ maximum_slot = policy["maximumRunnerSlot"]
45
+ if (
46
+ start < 1
47
+ or end > 65535
48
+ or start > end
49
+ or ports_per_runner <= 0
50
+ or minimum_slot != 1
51
+ or maximum_slot < minimum_slot
52
+ or end - start + 1 != ports_per_runner * maximum_slot
53
+ ):
54
+ raise RuntimeError(f"invalid runner port policy at {path}")
55
+ return policy
56
+
57
+
58
+ RUNNER_PORT_POLICY: dict[str, int] = {}
59
+
60
+
61
+ def configure_policy(policy_path: Path, forbidden_path: Path) -> None:
62
+ global CHROMIUM_RESTRICTED_PORTS, FETCH_FORBIDDEN_PORTS_PATH, RUNNER_PORT_POLICY
63
+ global RUNNER_PORT_POLICY_PATH
64
+ FETCH_FORBIDDEN_PORTS_PATH = forbidden_path
65
+ RUNNER_PORT_POLICY_PATH = policy_path
66
+ CHROMIUM_RESTRICTED_PORTS = load_forbidden_ports(forbidden_path)
67
+ RUNNER_PORT_POLICY = load_runner_port_policy(policy_path)
68
+
69
+
70
+ def positive_int(value: str) -> int:
71
+ parsed = int(value)
72
+ if parsed <= 0:
73
+ raise argparse.ArgumentTypeError("must be greater than zero")
74
+ return parsed
75
+
76
+
77
+ def parse_args() -> argparse.Namespace:
78
+ parser = argparse.ArgumentParser(
79
+ description="Allocate localhost ports that Chromium will load."
80
+ )
81
+ parser.add_argument("count", nargs="?", type=positive_int)
82
+ parser.add_argument("--max-bind-attempts", type=int, default=1000)
83
+ parser.add_argument("--hold", action="store_true")
84
+ parser.add_argument("--hold-dir", type=Path)
85
+ parser.add_argument("--release", type=positive_int)
86
+ parser.add_argument("--stop", action="store_true")
87
+ parser.add_argument("--check", action="store_true")
88
+ parser.add_argument(
89
+ "--workspace",
90
+ default=os.environ.get("PORT_HOLD_WORKSPACE")
91
+ or os.environ.get("VOUCHA_PORT_HOLD_WORKSPACE")
92
+ or os.environ.get("GITHUB_WORKSPACE")
93
+ or os.getcwd(),
94
+ )
95
+ parser.add_argument("--policy", type=Path, default=RUNNER_PORT_POLICY_PATH)
96
+ parser.add_argument("--forbidden-ports", type=Path, default=FETCH_FORBIDDEN_PORTS_PATH)
97
+ args = parser.parse_args()
98
+ configure_policy(args.policy, args.forbidden_ports)
99
+ control_modes = [args.release is not None, args.stop, args.check]
100
+ if sum(control_modes) > 1:
101
+ parser.error("use only one of --release, --stop, or --check")
102
+ if args.hold and any(control_modes):
103
+ parser.error("--hold cannot be combined with --release, --stop, or --check")
104
+ if (args.hold or any(control_modes)) and args.hold_dir is None:
105
+ parser.error("--hold-dir is required for --hold, --release, --stop, and --check")
106
+ if args.hold and args.count is None:
107
+ parser.error("count is required with --hold")
108
+ if not args.hold and not any(control_modes) and args.count is None:
109
+ parser.error("count is required")
110
+ return args
111
+
112
+
113
+ def numeric_runner_slots(*paths: str) -> list[int]:
114
+ slots: list[int] = []
115
+ for path in paths:
116
+ if not path:
117
+ continue
118
+ parts = Path(path).parts
119
+ for index, part in enumerate(parts[:-2]):
120
+ if part not in {"actions-runner", "actions-runners"}:
121
+ continue
122
+ candidate = parts[index + 1]
123
+ if parts[index + 2] != "_work":
124
+ continue
125
+ if candidate.isdecimal():
126
+ if candidate != str(int(candidate)):
127
+ raise RuntimeError(
128
+ f"runner slot {candidate} must use canonical decimal spelling"
129
+ )
130
+ slots.append(int(candidate))
131
+ return slots
132
+
133
+
134
+ def detect_runner_slot(github_workspace: str, cwd: str) -> int | None:
135
+ slots = numeric_runner_slots(github_workspace, cwd)
136
+ if not slots:
137
+ return None
138
+ unique_slots = set(slots)
139
+ if len(unique_slots) != 1:
140
+ raise RuntimeError(
141
+ "found conflicting numeric GitHub Actions runner slots in "
142
+ "GITHUB_WORKSPACE/current directory"
143
+ )
144
+ return slots[0]
145
+
146
+
147
+ def runner_port_slice(slot: int) -> range:
148
+ minimum_slot = RUNNER_PORT_POLICY["minimumRunnerSlot"]
149
+ maximum_slot = RUNNER_PORT_POLICY["maximumRunnerSlot"]
150
+ if slot < minimum_slot or slot > maximum_slot:
151
+ raise RuntimeError(
152
+ f"runner slot {slot} is outside the supported range "
153
+ f"{minimum_slot}-{maximum_slot}"
154
+ )
155
+ start = RUNNER_PORT_POLICY["reservedPortStart"] + (
156
+ slot - minimum_slot
157
+ ) * RUNNER_PORT_POLICY["portsPerRunner"]
158
+ return range(start, start + RUNNER_PORT_POLICY["portsPerRunner"])
159
+
160
+
161
+ def allocate_ports(
162
+ count: int, max_bind_attempts: int, runner_slot: int | None = None
163
+ ) -> list[int]:
164
+ sockets: list[socket.socket] = []
165
+ try:
166
+ if runner_slot is not None:
167
+ port_slice = runner_port_slice(runner_slot)
168
+ if count > len(port_slice):
169
+ raise RuntimeError(
170
+ f"requested {count} ports exceeds runner slice capacity {len(port_slice)}"
171
+ )
172
+ candidates = port_slice
173
+ else:
174
+ candidates = range(max_bind_attempts)
175
+
176
+ for candidate_port in candidates:
177
+ if len(sockets) == count:
178
+ break
179
+ candidate = socket.socket()
180
+ try:
181
+ candidate.bind(("", candidate_port if runner_slot is not None else 0))
182
+ except OSError:
183
+ candidate.close()
184
+ continue
185
+ port = candidate.getsockname()[1]
186
+ if port in CHROMIUM_RESTRICTED_PORTS or (
187
+ runner_slot is None
188
+ and RUNNER_PORT_POLICY["reservedPortStart"]
189
+ <= port
190
+ <= RUNNER_PORT_POLICY["reservedPortEnd"]
191
+ ):
192
+ candidate.close()
193
+ continue
194
+ sockets.append(candidate)
195
+
196
+ if len(sockets) != count:
197
+ location = (
198
+ f"runner slice {runner_slot}" if runner_slot is not None else "ephemeral ports"
199
+ )
200
+ raise RuntimeError(
201
+ f"failed to allocate {count} ports after {len(candidates)} bind attempts "
202
+ f"from {location}"
203
+ )
204
+ return [candidate.getsockname()[1] for candidate in sockets]
205
+ finally:
206
+ for candidate in sockets:
207
+ candidate.close()
208
+
209
+
210
+ SCRIPT_NAME = "allocate-browser-safe-ports.py"
211
+ HOLD_POLL_SECONDS = 0.05
212
+ HOLD_READY_TIMEOUT_SECONDS = 2.0
213
+ CONTROL_WAIT_SECONDS = 2.0
214
+
215
+
216
+ def resolve_workspace(workspace: str) -> str:
217
+ return str(Path(workspace).resolve())
218
+
219
+
220
+ def durable_identity_dir(workspace: str) -> Path:
221
+ digest = hashlib.sha256(resolve_workspace(workspace).encode()).hexdigest()
222
+ return Path(f"/tmp/port-hold-{digest}")
223
+
224
+
225
+ def process_command_line(pid: int) -> str | None:
226
+ proc_cmd = Path(f"/proc/{pid}/cmdline")
227
+ try:
228
+ if proc_cmd.is_file():
229
+ return proc_cmd.read_bytes().replace(b"\x00", b" ").decode(errors="replace")
230
+ except OSError:
231
+ pass
232
+ result = subprocess.run(
233
+ ["ps", "-p", str(pid), "-o", "command="],
234
+ capture_output=True,
235
+ text=True,
236
+ check=False,
237
+ )
238
+ if result.returncode != 0:
239
+ return None
240
+ return result.stdout.strip()
241
+
242
+
243
+ def process_environ(pid: int) -> dict[str, str]:
244
+ proc_env = Path(f"/proc/{pid}/environ")
245
+ raw = b""
246
+ try:
247
+ if proc_env.is_file():
248
+ raw = proc_env.read_bytes()
249
+ except OSError:
250
+ raw = b""
251
+ if not raw:
252
+ result = subprocess.run(
253
+ ["ps", "eww", "-p", str(pid), "-o", "command="],
254
+ capture_output=True,
255
+ check=False,
256
+ )
257
+ raw = result.stdout
258
+ values: dict[str, str] = {}
259
+ for entry in raw.split(b"\x00" if b"\x00" in raw else b" "):
260
+ if b"=" not in entry:
261
+ continue
262
+ key, value = entry.split(b"=", 1)
263
+ decoded_key = key.decode(errors="replace")
264
+ if decoded_key.isidentifier() or decoded_key.endswith("PORT_HOLD_WORKSPACE"):
265
+ values[decoded_key] = value.decode(errors="replace")
266
+ return values
267
+
268
+
269
+ def is_our_holder(pid: int, workspace: str) -> bool:
270
+ command = process_command_line(pid)
271
+ if command is None or SCRIPT_NAME not in command:
272
+ return False
273
+ wanted = resolve_workspace(workspace)
274
+ for token in command.split():
275
+ try:
276
+ if resolve_workspace(token) == wanted:
277
+ return True
278
+ except OSError:
279
+ continue
280
+ environ = process_environ(pid)
281
+ recorded = environ.get("PORT_HOLD_WORKSPACE") or environ.get("VOUCHA_PORT_HOLD_WORKSPACE")
282
+ return recorded in {wanted, workspace}
283
+
284
+
285
+ def pid_is_alive(pid: int) -> bool:
286
+ try:
287
+ os.kill(pid, 0)
288
+ except OSError:
289
+ return False
290
+ return True
291
+
292
+
293
+ def read_pid(path: Path) -> int | None:
294
+ try:
295
+ return int(path.read_text().strip())
296
+ except (ValueError, FileNotFoundError, ProcessLookupError):
297
+ return None
298
+
299
+
300
+ def reap_stale_holder(workspace: str) -> None:
301
+ identity = durable_identity_dir(workspace)
302
+ pid_path = identity / "pid"
303
+ if not pid_path.is_file():
304
+ return
305
+ pid = read_pid(pid_path)
306
+ if pid is None:
307
+ pid_path.unlink(missing_ok=True)
308
+ return
309
+ if not pid_is_alive(pid):
310
+ pid_path.unlink(missing_ok=True)
311
+ return
312
+ if not is_our_holder(pid, workspace):
313
+ return
314
+ try:
315
+ os.kill(pid, signal.SIGTERM)
316
+ except ProcessLookupError:
317
+ pid_path.unlink(missing_ok=True)
318
+ return
319
+ deadline = time.monotonic() + CONTROL_WAIT_SECONDS
320
+ while time.monotonic() < deadline and pid_is_alive(pid):
321
+ time.sleep(HOLD_POLL_SECONDS)
322
+ if pid_is_alive(pid):
323
+ try:
324
+ os.kill(pid, signal.SIGKILL)
325
+ except ProcessLookupError:
326
+ pass
327
+ pid_path.unlink(missing_ok=True)
328
+
329
+
330
+ def _bind_unlistening(family: int, address: tuple[str, int]) -> socket.socket:
331
+ bound = socket.socket(family, socket.SOCK_STREAM)
332
+ bound.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
333
+ if family == socket.AF_INET6:
334
+ try:
335
+ bound.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
336
+ except OSError:
337
+ pass
338
+ try:
339
+ bound.bind(address)
340
+ except OSError:
341
+ bound.close()
342
+ raise
343
+ return bound
344
+
345
+
346
+ def bind_hold_sockets(port: int | None) -> tuple[int, list[socket.socket]]:
347
+ sockets: list[socket.socket] = []
348
+ linux = sys.platform.startswith("linux")
349
+ chosen = port
350
+
351
+ # Linux Node/docker listen on ::: / 127.0.0.1. Binding loopback first then
352
+ # AF_INET6 V6ONLY=0 fails on every candidate (EADDRINUSE), which exhausted
353
+ # the 16-port runner slice in CI.
354
+ if linux and socket.has_ipv6:
355
+ try:
356
+ ipv6 = _bind_unlistening(socket.AF_INET6, ("::", 0 if port is None else port))
357
+ except OSError as error:
358
+ if error.errno in {errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL}:
359
+ ipv6 = None
360
+ else:
361
+ raise
362
+ if ipv6 is not None:
363
+ chosen = ipv6.getsockname()[1]
364
+ sockets.append(ipv6)
365
+ for host in ("127.0.0.1", "0.0.0.0"):
366
+ try:
367
+ sockets.append(_bind_unlistening(socket.AF_INET, (host, chosen)))
368
+ except OSError:
369
+ pass
370
+ return chosen, sockets
371
+
372
+ loopback = _bind_unlistening(socket.AF_INET, ("127.0.0.1", 0 if port is None else port))
373
+ chosen = loopback.getsockname()[1]
374
+ sockets.append(loopback)
375
+ if socket.has_ipv6:
376
+ try:
377
+ sockets.append(_bind_unlistening(socket.AF_INET6, ("::", chosen)))
378
+ except OSError:
379
+ pass
380
+ try:
381
+ sockets.append(_bind_unlistening(socket.AF_INET, ("0.0.0.0", chosen)))
382
+ except OSError:
383
+ # Darwin cannot bind 0.0.0.0 after 127.0.0.1 with SO_REUSEADDR=0.
384
+ # Linux can, so a wildcard failure means a non-loopback IPv4 owner.
385
+ if linux:
386
+ for item in sockets:
387
+ item.close()
388
+ raise
389
+ return chosen, sockets
390
+
391
+
392
+ def allocate_hold_sockets(
393
+ count: int, max_bind_attempts: int, runner_slot: int | None
394
+ ) -> dict[int, list[socket.socket]]:
395
+ held: dict[int, list[socket.socket]] = {}
396
+ try:
397
+ if runner_slot is not None:
398
+ port_slice = runner_port_slice(runner_slot)
399
+ if count > len(port_slice):
400
+ raise RuntimeError(
401
+ f"requested {count} ports exceeds runner slice capacity {len(port_slice)}"
402
+ )
403
+ candidates: range | list[None] = port_slice
404
+ else:
405
+ candidates = [None] * max_bind_attempts
406
+
407
+ for candidate_port in candidates:
408
+ if len(held) == count:
409
+ break
410
+ try:
411
+ port, sockets = bind_hold_sockets(candidate_port)
412
+ except OSError:
413
+ continue
414
+ if port in CHROMIUM_RESTRICTED_PORTS or (
415
+ runner_slot is None
416
+ and RUNNER_PORT_POLICY["reservedPortStart"]
417
+ <= port
418
+ <= RUNNER_PORT_POLICY["reservedPortEnd"]
419
+ ):
420
+ for item in sockets:
421
+ item.close()
422
+ continue
423
+ held[port] = sockets
424
+
425
+ if len(held) != count:
426
+ location = (
427
+ f"runner slice {runner_slot}" if runner_slot is not None else "ephemeral ports"
428
+ )
429
+ raise RuntimeError(
430
+ f"failed to allocate {count} ports after {len(candidates)} bind attempts "
431
+ f"from {location}"
432
+ )
433
+ return held
434
+ except Exception:
435
+ for sockets in held.values():
436
+ for item in sockets:
437
+ item.close()
438
+ raise
439
+
440
+
441
+ def write_text(path: Path, content: str) -> None:
442
+ path.parent.mkdir(parents=True, exist_ok=True)
443
+ path.write_text(content)
444
+
445
+
446
+ def daemonize_holder() -> bool:
447
+ first = os.fork()
448
+ if first > 0:
449
+ os.waitpid(first, 0)
450
+ return False
451
+ os.setsid()
452
+ if os.fork() > 0:
453
+ os._exit(0)
454
+ os.chdir("/")
455
+ devnull = os.open(os.devnull, os.O_RDWR)
456
+ os.dup2(devnull, 0)
457
+ os.dup2(devnull, 1)
458
+ os.dup2(devnull, 2)
459
+ if devnull > 2:
460
+ os.close(devnull)
461
+ return True
462
+
463
+
464
+ def holder_loop(
465
+ held: dict[int, list[socket.socket]],
466
+ hold_dir: Path,
467
+ workspace: str,
468
+ ) -> None:
469
+ identity = durable_identity_dir(workspace)
470
+ write_text(identity / "workspace", resolve_workspace(workspace))
471
+ write_text(identity / "pid", str(os.getpid()))
472
+ write_text(hold_dir / "pid", str(os.getpid()))
473
+ write_text(hold_dir / "ports", " ".join(str(port) for port in held))
474
+ write_text(hold_dir / "ready", "1")
475
+ release_dir = hold_dir / "release"
476
+ release_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ def shutdown(_signum: int | None = None, _frame: object | None = None) -> None:
479
+ for sockets in held.values():
480
+ for item in sockets:
481
+ item.close()
482
+ held.clear()
483
+ (hold_dir / "stop").unlink(missing_ok=True)
484
+ (identity / "pid").unlink(missing_ok=True)
485
+ os._exit(0)
486
+
487
+ signal.signal(signal.SIGTERM, shutdown)
488
+ signal.signal(signal.SIGHUP, shutdown)
489
+
490
+ while True:
491
+ if not hold_dir.is_dir() or (hold_dir / "stop").is_file():
492
+ shutdown()
493
+ for release_file in list(release_dir.iterdir()):
494
+ try:
495
+ port = int(release_file.name)
496
+ except ValueError:
497
+ release_file.unlink(missing_ok=True)
498
+ continue
499
+ sockets = held.pop(port, [])
500
+ for item in sockets:
501
+ item.close()
502
+ release_file.unlink(missing_ok=True)
503
+ write_text(hold_dir / "ports", " ".join(str(item) for item in held))
504
+ time.sleep(HOLD_POLL_SECONDS)
505
+
506
+
507
+ def start_holder(
508
+ count: int,
509
+ max_bind_attempts: int,
510
+ runner_slot: int | None,
511
+ hold_dir: Path,
512
+ workspace: str,
513
+ ) -> list[int]:
514
+ hold_dir = hold_dir.resolve()
515
+ hold_dir.mkdir(parents=True, exist_ok=True)
516
+ release_dir = hold_dir / "release"
517
+ release_dir.mkdir(parents=True, exist_ok=True)
518
+ (hold_dir / "ready").unlink(missing_ok=True)
519
+ (hold_dir / "stop").unlink(missing_ok=True)
520
+ for stale in release_dir.iterdir():
521
+ stale.unlink(missing_ok=True)
522
+ resolved = resolve_workspace(workspace)
523
+ os.environ["PORT_HOLD_WORKSPACE"] = resolved
524
+ os.environ["VOUCHA_PORT_HOLD_WORKSPACE"] = resolved
525
+ reap_stale_holder(workspace)
526
+ held = allocate_hold_sockets(count, max_bind_attempts, runner_slot)
527
+ ports = list(held)
528
+ if daemonize_holder():
529
+ holder_loop(held, hold_dir, workspace)
530
+ raise AssertionError("holder_loop returned")
531
+ for sockets in held.values():
532
+ for item in sockets:
533
+ item.close()
534
+ deadline = time.monotonic() + HOLD_READY_TIMEOUT_SECONDS
535
+ while time.monotonic() < deadline:
536
+ if (hold_dir / "ready").is_file():
537
+ return ports
538
+ time.sleep(HOLD_POLL_SECONDS)
539
+ reap_stale_holder(workspace)
540
+ raise RuntimeError(f"port holder did not become ready under {hold_dir}")
541
+
542
+
543
+ def port_is_bindable(port: int) -> bool:
544
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
545
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
546
+ try:
547
+ probe.bind(("127.0.0.1", port))
548
+ return True
549
+ except OSError:
550
+ return False
551
+ finally:
552
+ probe.close()
553
+
554
+
555
+ def wait_until_port_bindable(port: int) -> None:
556
+ deadline = time.monotonic() + CONTROL_WAIT_SECONDS
557
+ while time.monotonic() < deadline:
558
+ if port_is_bindable(port):
559
+ return
560
+ time.sleep(HOLD_POLL_SECONDS)
561
+ raise RuntimeError(f"port {port} did not become bindable")
562
+
563
+
564
+ def request_release(hold_dir: Path, port: int) -> None:
565
+ hold_dir = hold_dir.resolve()
566
+ release_path = hold_dir / "release" / str(port)
567
+ release_path.parent.mkdir(parents=True, exist_ok=True)
568
+ release_path.write_text("1")
569
+ deadline = time.monotonic() + CONTROL_WAIT_SECONDS
570
+ while time.monotonic() < deadline:
571
+ if port_is_bindable(port):
572
+ return
573
+ time.sleep(HOLD_POLL_SECONDS)
574
+ raise RuntimeError(f"port {port} was not released by the holder under {hold_dir}")
575
+
576
+
577
+ def request_stop(hold_dir: Path, workspace: str) -> None:
578
+ hold_dir = hold_dir.resolve()
579
+ hold_dir.mkdir(parents=True, exist_ok=True)
580
+ ports_path = hold_dir / "ports"
581
+ remaining = (
582
+ [int(item) for item in ports_path.read_text().split() if item] if ports_path.is_file() else []
583
+ )
584
+ (hold_dir / "stop").write_text("1")
585
+ pid_path = hold_dir / "pid"
586
+ pid = read_pid(pid_path) if pid_path.is_file() else None
587
+ if pid is not None and pid_is_alive(pid) and is_our_holder(pid, workspace):
588
+ try:
589
+ os.kill(pid, signal.SIGTERM)
590
+ except ProcessLookupError:
591
+ pass
592
+ deadline = time.monotonic() + CONTROL_WAIT_SECONDS
593
+ while time.monotonic() < deadline:
594
+ current = read_pid(pid_path)
595
+ if current is None or not pid_is_alive(current):
596
+ break
597
+ time.sleep(HOLD_POLL_SECONDS)
598
+ for port in remaining:
599
+ wait_until_port_bindable(port)
600
+
601
+
602
+ def check_holder(hold_dir: Path, workspace: str) -> None:
603
+ hold_dir = hold_dir.resolve()
604
+ pid_path = hold_dir / "pid"
605
+ pid = read_pid(pid_path)
606
+ if pid is None:
607
+ raise RuntimeError(f"port holder pid file missing under {hold_dir}")
608
+ if not pid_is_alive(pid):
609
+ raise RuntimeError(f"port holder {pid} is not running")
610
+ if not is_our_holder(pid, workspace):
611
+ raise RuntimeError(f"pid {pid} is not the workspace port holder")
612
+ ports_path = hold_dir / "ports"
613
+ raw_ports = ports_path.read_text().split() if ports_path.is_file() else []
614
+ ports = [int(item) for item in raw_ports if item]
615
+ if not ports:
616
+ raise RuntimeError("port holder has no remaining reserved ports")
617
+ for port in ports:
618
+ if port_is_bindable(port):
619
+ raise RuntimeError(f"port {port} is bindable; holder is not reserving it")
620
+
621
+
622
+ def resolve_runner_slot() -> int | None:
623
+ if os.environ.get("GITHUB_ACTIONS") != "true":
624
+ return None
625
+ return detect_runner_slot(os.environ.get("GITHUB_WORKSPACE", ""), os.getcwd())
626
+
627
+
628
+ def main() -> int:
629
+ args = parse_args()
630
+ try:
631
+ hold_dir = args.hold_dir.resolve() if args.hold_dir is not None else None
632
+ workspace = resolve_workspace(args.workspace)
633
+ if args.release is not None:
634
+ request_release(hold_dir, args.release)
635
+ return 0
636
+ if args.stop:
637
+ request_stop(hold_dir, workspace)
638
+ return 0
639
+ if args.check:
640
+ check_holder(hold_dir, workspace)
641
+ return 0
642
+ runner_slot = resolve_runner_slot()
643
+ if args.hold:
644
+ ports = start_holder(
645
+ args.count, args.max_bind_attempts, runner_slot, hold_dir, workspace
646
+ )
647
+ else:
648
+ ports = allocate_ports(args.count, args.max_bind_attempts, runner_slot)
649
+ except Exception as error:
650
+ print(f"::error::{error}", file=sys.stderr)
651
+ return 1
652
+
653
+ print(" ".join(str(port) for port in ports))
654
+ return 0
655
+
656
+
657
+ if FETCH_FORBIDDEN_PORTS_PATH.is_file() and RUNNER_PORT_POLICY_PATH.is_file():
658
+ configure_policy(RUNNER_PORT_POLICY_PATH, FETCH_FORBIDDEN_PORTS_PATH)
659
+
660
+ if __name__ == "__main__":
661
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ [
2
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
3
+ 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
4
+ 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
5
+ 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
6
+ 6669, 6679, 6697, 10080
7
+ ]
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ label="${1:-required jobs}"
5
+ results="${RESULTS:-}"
6
+
7
+ if [ -z "$results" ]; then
8
+ echo "::error::RESULTS is required"
9
+ exit 1
10
+ fi
11
+
12
+ echo "Job results: $results"
13
+
14
+ if echo "$results" | grep -qE '"result":\s*"(failure|cancelled)"'; then
15
+ echo "One or more ${label} failed or were cancelled"
16
+ exit 1
17
+ fi
18
+
19
+ echo "All ${label} passed or were skipped"