vouchington-tooling 0.19.2 → 0.21.0

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 (36) hide show
  1. package/README.md +14 -9
  2. package/dist/cli/commands/gha-runtime-audit.mjs +6 -0
  3. package/dist/cli/commands/retrospective-facts.mjs +20 -7
  4. package/dist/cli/index.mjs +4 -1
  5. package/dist/cli/parse-gha-runtime-audit.d.mts +2 -0
  6. package/dist/cli/parse-gha-runtime-audit.mjs +33 -1
  7. package/dist/cli/parse.d.mts +7 -0
  8. package/dist/cli/parse.mjs +15 -0
  9. package/dist/cli/usage.d.mts +3 -1
  10. package/dist/cli/usage.mjs +44 -1
  11. package/dist/dependency-license-policy/audit-directory.d.mts +28 -0
  12. package/dist/dependency-license-policy/audit-directory.mjs +115 -0
  13. package/dist/dependency-license-policy/audit-signals.d.mts +12 -0
  14. package/dist/dependency-license-policy/audit-signals.mjs +44 -0
  15. package/dist/dependency-license-policy/audit-store.d.mts +12 -0
  16. package/dist/dependency-license-policy/audit-store.mjs +32 -0
  17. package/dist/dependency-license-policy/collect.d.mts +3 -1
  18. package/dist/dependency-license-policy/collect.mjs +16 -21
  19. package/dist/dependency-license-policy/execute-pnpm.d.mts +10 -0
  20. package/dist/dependency-license-policy/execute-pnpm.mjs +83 -0
  21. package/dist/dependency-license-policy/types.d.mts +11 -9
  22. package/dist/dependency-license-policy/workspace.d.mts +3 -1
  23. package/dist/dependency-license-policy/workspace.mjs +12 -5
  24. package/dist/gha-runtime-audit/audit.mjs +12 -4
  25. package/dist/gha-runtime-audit/family.d.mts +7 -0
  26. package/dist/gha-runtime-audit/family.mjs +79 -0
  27. package/dist/gha-runtime-audit/index.test-helpers.d.mts +5 -0
  28. package/dist/gha-runtime-audit/model.d.mts +11 -1
  29. package/dist/gha-runtime-audit/model.mjs +26 -0
  30. package/dist/gha-runtime-audit/scope.d.mts +2 -0
  31. package/dist/gha-runtime-audit/scope.mjs +16 -1
  32. package/dist/pnpm-install/support.mjs +1 -1
  33. package/package.json +1 -1
  34. package/scripts/allocate-browser-safe-ports.py +2 -583
  35. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +0 -6
  36. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +0 -103
@@ -2,15 +2,10 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
- import errno
6
- import hashlib
7
5
  import json
8
6
  import os
9
- import signal
10
7
  import socket
11
- import subprocess
12
8
  import sys
13
- import time
14
9
  from pathlib import Path
15
10
 
16
11
  SCRIPT_DIR = Path(__file__).resolve().parent
@@ -78,35 +73,12 @@ def parse_args() -> argparse.Namespace:
78
73
  parser = argparse.ArgumentParser(
79
74
  description="Allocate localhost ports that Chromium will load."
80
75
  )
81
- parser.add_argument("count", nargs="?", type=positive_int)
76
+ parser.add_argument("count", type=positive_int)
82
77
  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
78
  parser.add_argument("--policy", type=Path, default=RUNNER_PORT_POLICY_PATH)
96
79
  parser.add_argument("--forbidden-ports", type=Path, default=FETCH_FORBIDDEN_PORTS_PATH)
97
80
  args = parser.parse_args()
98
81
  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
82
  return args
111
83
 
112
84
 
@@ -207,542 +179,6 @@ def allocate_ports(
207
179
  candidate.close()
208
180
 
209
181
 
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
- OWNER_PROBE_SECONDS = 0.4
215
-
216
-
217
- def resolve_workspace(workspace: str) -> str:
218
- return str(Path(workspace).resolve())
219
-
220
-
221
- def durable_identity_dir(workspace: str) -> Path:
222
- digest = hashlib.sha256(resolve_workspace(workspace).encode()).hexdigest()
223
- return Path(f"/tmp/port-hold-{digest}")
224
-
225
-
226
- def process_command_line(pid: int) -> str | None:
227
- proc_cmd = Path(f"/proc/{pid}/cmdline")
228
- try:
229
- if proc_cmd.is_file():
230
- return proc_cmd.read_bytes().replace(b"\x00", b" ").decode(errors="replace")
231
- except OSError:
232
- pass
233
- result = subprocess.run(
234
- ["ps", "-p", str(pid), "-o", "command="],
235
- capture_output=True,
236
- text=True,
237
- check=False,
238
- )
239
- if result.returncode != 0:
240
- return None
241
- return result.stdout.strip()
242
-
243
-
244
- def process_environ(pid: int) -> dict[str, str]:
245
- proc_env = Path(f"/proc/{pid}/environ")
246
- raw = b""
247
- try:
248
- if proc_env.is_file():
249
- raw = proc_env.read_bytes()
250
- except OSError:
251
- raw = b""
252
- if not raw:
253
- result = subprocess.run(
254
- ["ps", "eww", "-p", str(pid), "-o", "command="],
255
- capture_output=True,
256
- check=False,
257
- )
258
- raw = result.stdout
259
- values: dict[str, str] = {}
260
- for entry in raw.split(b"\x00" if b"\x00" in raw else b" "):
261
- if b"=" not in entry:
262
- continue
263
- key, value = entry.split(b"=", 1)
264
- decoded_key = key.decode(errors="replace")
265
- if decoded_key.isidentifier() or decoded_key.endswith("PORT_HOLD_WORKSPACE"):
266
- values[decoded_key] = value.decode(errors="replace")
267
- return values
268
-
269
-
270
- def is_our_holder(pid: int, workspace: str) -> bool:
271
- command = process_command_line(pid)
272
- if command is None or SCRIPT_NAME not in command:
273
- return False
274
- wanted = resolve_workspace(workspace)
275
- for token in command.split():
276
- try:
277
- if resolve_workspace(token) == wanted:
278
- return True
279
- except OSError:
280
- continue
281
- environ = process_environ(pid)
282
- recorded = environ.get("PORT_HOLD_WORKSPACE") or environ.get("VOUCHA_PORT_HOLD_WORKSPACE")
283
- return recorded in {wanted, workspace}
284
-
285
-
286
- def pid_is_alive(pid: int) -> bool:
287
- try:
288
- os.kill(pid, 0)
289
- except OSError:
290
- return False
291
- return True
292
-
293
-
294
- def read_pid(path: Path) -> int | None:
295
- try:
296
- return int(path.read_text().strip())
297
- except (ValueError, FileNotFoundError, ProcessLookupError):
298
- return None
299
-
300
-
301
- def workspace_holder_pid(workspace: str) -> int | None:
302
- identity = durable_identity_dir(workspace)
303
- pid = read_pid(identity / "pid")
304
- if pid is None or not pid_is_alive(pid) or not is_our_holder(pid, workspace):
305
- return None
306
- return pid
307
-
308
-
309
- def reap_stale_holder(workspace: str) -> None:
310
- identity = durable_identity_dir(workspace)
311
- pid_path = identity / "pid"
312
- if not pid_path.is_file():
313
- return
314
- pid = read_pid(pid_path)
315
- if pid is None:
316
- pid_path.unlink(missing_ok=True)
317
- return
318
- if not pid_is_alive(pid):
319
- pid_path.unlink(missing_ok=True)
320
- return
321
- if not is_our_holder(pid, workspace):
322
- return
323
- try:
324
- os.kill(pid, signal.SIGTERM)
325
- except ProcessLookupError:
326
- pid_path.unlink(missing_ok=True)
327
- return
328
- deadline = time.monotonic() + CONTROL_WAIT_SECONDS
329
- while time.monotonic() < deadline and pid_is_alive(pid):
330
- time.sleep(HOLD_POLL_SECONDS)
331
- if pid_is_alive(pid):
332
- try:
333
- os.kill(pid, signal.SIGKILL)
334
- except ProcessLookupError:
335
- pass
336
- pid_path.unlink(missing_ok=True)
337
-
338
-
339
- def _bind_unlistening(family: int, address: tuple[str, int]) -> socket.socket:
340
- bound = socket.socket(family, socket.SOCK_STREAM)
341
- bound.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
342
- if family == socket.AF_INET6:
343
- try:
344
- bound.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
345
- except OSError:
346
- pass
347
- try:
348
- bound.bind(address)
349
- except OSError:
350
- bound.close()
351
- raise
352
- return bound
353
-
354
-
355
- def bind_hold_sockets(port: int | None) -> tuple[int, list[socket.socket]]:
356
- sockets: list[socket.socket] = []
357
- linux = sys.platform.startswith("linux")
358
- chosen = port
359
-
360
- # Linux Node/docker listen on ::: / 127.0.0.1. Binding loopback first then
361
- # AF_INET6 V6ONLY=0 fails on every candidate (EADDRINUSE), which exhausted
362
- # the 16-port runner slice in CI.
363
- if linux and socket.has_ipv6:
364
- try:
365
- ipv6 = _bind_unlistening(socket.AF_INET6, ("::", 0 if port is None else port))
366
- except OSError as error:
367
- if error.errno in {errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL}:
368
- ipv6 = None
369
- else:
370
- raise
371
- if ipv6 is not None:
372
- chosen = ipv6.getsockname()[1]
373
- sockets.append(ipv6)
374
- for host in ("127.0.0.1", "0.0.0.0"):
375
- try:
376
- sockets.append(_bind_unlistening(socket.AF_INET, (host, chosen)))
377
- except OSError:
378
- pass
379
- return chosen, sockets
380
-
381
- loopback = _bind_unlistening(socket.AF_INET, ("127.0.0.1", 0 if port is None else port))
382
- chosen = loopback.getsockname()[1]
383
- sockets.append(loopback)
384
- if socket.has_ipv6:
385
- try:
386
- sockets.append(_bind_unlistening(socket.AF_INET6, ("::", chosen)))
387
- except OSError:
388
- pass
389
- try:
390
- sockets.append(_bind_unlistening(socket.AF_INET, ("0.0.0.0", chosen)))
391
- except OSError:
392
- # Darwin cannot bind 0.0.0.0 after 127.0.0.1 with SO_REUSEADDR=0.
393
- # Linux can, so a wildcard failure means a non-loopback IPv4 owner.
394
- if linux:
395
- for item in sockets:
396
- item.close()
397
- raise
398
- return chosen, sockets
399
-
400
-
401
- def allocate_hold_sockets(
402
- count: int, max_bind_attempts: int, runner_slot: int | None
403
- ) -> dict[int, list[socket.socket]]:
404
- held: dict[int, list[socket.socket]] = {}
405
- try:
406
- if runner_slot is not None:
407
- port_slice = runner_port_slice(runner_slot)
408
- if count > len(port_slice):
409
- raise RuntimeError(
410
- f"requested {count} ports exceeds runner slice capacity {len(port_slice)}"
411
- )
412
- candidates: range | list[None] = port_slice
413
- else:
414
- candidates = [None] * max_bind_attempts
415
-
416
- for candidate_port in candidates:
417
- if len(held) == count:
418
- break
419
- try:
420
- port, sockets = bind_hold_sockets(candidate_port)
421
- except OSError:
422
- continue
423
- if port in CHROMIUM_RESTRICTED_PORTS or (
424
- runner_slot is None
425
- and RUNNER_PORT_POLICY["reservedPortStart"]
426
- <= port
427
- <= RUNNER_PORT_POLICY["reservedPortEnd"]
428
- ):
429
- for item in sockets:
430
- item.close()
431
- continue
432
- held[port] = sockets
433
-
434
- if len(held) != count:
435
- location = (
436
- f"runner slice {runner_slot}" if runner_slot is not None else "ephemeral ports"
437
- )
438
- raise RuntimeError(
439
- f"failed to allocate {count} ports after {len(candidates)} bind attempts "
440
- f"from {location}"
441
- )
442
- return held
443
- except Exception:
444
- for sockets in held.values():
445
- for item in sockets:
446
- item.close()
447
- raise
448
-
449
-
450
- def write_text(path: Path, content: str) -> None:
451
- path.parent.mkdir(parents=True, exist_ok=True)
452
- path.write_text(content)
453
-
454
-
455
- def daemonize_holder() -> bool:
456
- first = os.fork()
457
- if first > 0:
458
- os.waitpid(first, 0)
459
- return False
460
- os.setsid()
461
- if os.fork() > 0:
462
- os._exit(0)
463
- os.chdir("/")
464
- devnull = os.open(os.devnull, os.O_RDWR)
465
- os.dup2(devnull, 0)
466
- os.dup2(devnull, 1)
467
- os.dup2(devnull, 2)
468
- if devnull > 2:
469
- os.close(devnull)
470
- return True
471
-
472
-
473
- def holder_loop(
474
- held: dict[int, list[socket.socket]],
475
- hold_dir: Path,
476
- workspace: str,
477
- ) -> None:
478
- identity = durable_identity_dir(workspace)
479
- write_text(identity / "workspace", resolve_workspace(workspace))
480
- write_text(identity / "pid", str(os.getpid()))
481
- write_text(hold_dir / "pid", str(os.getpid()))
482
- write_text(hold_dir / "ports", " ".join(str(port) for port in held))
483
- write_text(hold_dir / "ready", "1")
484
- release_dir = hold_dir / "release"
485
- release_dir.mkdir(parents=True, exist_ok=True)
486
-
487
- def shutdown(_signum: int | None = None, _frame: object | None = None) -> None:
488
- for sockets in held.values():
489
- for item in sockets:
490
- item.close()
491
- held.clear()
492
- (hold_dir / "stop").unlink(missing_ok=True)
493
- (identity / "pid").unlink(missing_ok=True)
494
- os._exit(0)
495
-
496
- signal.signal(signal.SIGTERM, shutdown)
497
- signal.signal(signal.SIGHUP, shutdown)
498
-
499
- while True:
500
- if not hold_dir.is_dir() or (hold_dir / "stop").is_file():
501
- shutdown()
502
- for release_file in list(release_dir.iterdir()):
503
- try:
504
- port = int(release_file.name)
505
- except ValueError:
506
- release_file.unlink(missing_ok=True)
507
- continue
508
- sockets = held.pop(port, [])
509
- for item in sockets:
510
- item.close()
511
- release_file.unlink(missing_ok=True)
512
- write_text(hold_dir / "ports", " ".join(str(item) for item in held))
513
- time.sleep(HOLD_POLL_SECONDS)
514
-
515
-
516
- def start_holder(
517
- count: int,
518
- max_bind_attempts: int,
519
- runner_slot: int | None,
520
- hold_dir: Path,
521
- workspace: str,
522
- ) -> list[int]:
523
- hold_dir = hold_dir.resolve()
524
- hold_dir.mkdir(parents=True, exist_ok=True)
525
- release_dir = hold_dir / "release"
526
- release_dir.mkdir(parents=True, exist_ok=True)
527
- (hold_dir / "ready").unlink(missing_ok=True)
528
- (hold_dir / "stop").unlink(missing_ok=True)
529
- for stale in release_dir.iterdir():
530
- stale.unlink(missing_ok=True)
531
- resolved = resolve_workspace(workspace)
532
- os.environ["PORT_HOLD_WORKSPACE"] = resolved
533
- os.environ["VOUCHA_PORT_HOLD_WORKSPACE"] = resolved
534
- reap_stale_holder(workspace)
535
- held = allocate_hold_sockets(count, max_bind_attempts, runner_slot)
536
- ports = list(held)
537
- if daemonize_holder():
538
- holder_loop(held, hold_dir, workspace)
539
- raise AssertionError("holder_loop returned")
540
- for sockets in held.values():
541
- for item in sockets:
542
- item.close()
543
- deadline = time.monotonic() + HOLD_READY_TIMEOUT_SECONDS
544
- while time.monotonic() < deadline:
545
- if (hold_dir / "ready").is_file():
546
- return ports
547
- time.sleep(HOLD_POLL_SECONDS)
548
- reap_stale_holder(workspace)
549
- raise RuntimeError(f"port holder did not become ready under {hold_dir}")
550
-
551
-
552
- def port_is_bindable(port: int) -> bool:
553
- probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
554
- probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
555
- try:
556
- probe.bind(("127.0.0.1", port))
557
- return True
558
- except OSError:
559
- return False
560
- finally:
561
- probe.close()
562
-
563
-
564
- def run_owner_probe(command: list[str]) -> str:
565
- try:
566
- result = subprocess.run(
567
- command,
568
- capture_output=True,
569
- text=True,
570
- check=False,
571
- timeout=OWNER_PROBE_SECONDS,
572
- )
573
- except (OSError, subprocess.TimeoutExpired):
574
- return ""
575
- return result.stdout
576
-
577
-
578
- def compact_probe_text(output: str) -> str:
579
- lines = [line.strip() for line in output.splitlines() if line.strip()]
580
- return " | ".join(lines)[:200]
581
-
582
-
583
- def pids_holding_port(port: int) -> list[int]:
584
- pids: list[int] = []
585
- seen: set[int] = set()
586
- for raw in run_owner_probe(["lsof", "-nP", "-t", f"-iTCP:{port}"]).split():
587
- if not raw.isdigit():
588
- continue
589
- pid = int(raw)
590
- if pid in seen:
591
- continue
592
- seen.add(pid)
593
- pids.append(pid)
594
- if pids:
595
- return pids
596
- for token in run_owner_probe(["ss", "-ntp", f"( sport = :{port} )"]).replace(",", " ").split():
597
- if not token.startswith("pid="):
598
- continue
599
- raw_pid = token[4:].split(")")[0]
600
- if not raw_pid.isdigit():
601
- continue
602
- pid = int(raw_pid)
603
- if pid in seen:
604
- continue
605
- seen.add(pid)
606
- pids.append(pid)
607
- return pids
608
-
609
-
610
- def sibling_allocator_owner(port: int, excluded_pid: int | None) -> tuple[int, str] | None:
611
- for owner_pid in pids_holding_port(port):
612
- if excluded_pid is not None and owner_pid == excluded_pid:
613
- continue
614
- command = process_command_line(owner_pid)
615
- if command is None or SCRIPT_NAME not in command:
616
- continue
617
- return owner_pid, command
618
- return None
619
-
620
-
621
- def describe_port_owner(port: int) -> str:
622
- pids = pids_holding_port(port)
623
- if pids:
624
- details: list[str] = []
625
- for owner_pid in pids:
626
- command = process_command_line(owner_pid) or "unknown"
627
- details.append(f"pid {owner_pid} {command}")
628
- return compact_probe_text("; ".join(details))
629
- lsof_text = compact_probe_text(run_owner_probe(["lsof", "-nP", f"-iTCP:{port}"]))
630
- if lsof_text:
631
- return f"lsof {lsof_text}"
632
- ss_text = compact_probe_text(run_owner_probe(["ss", "-ntp", f"( sport = :{port} )"]))
633
- if ss_text:
634
- lowered = ss_text.lower()
635
- if "time-wait" in lowered or "time_wait" in lowered:
636
- return f"TIME_WAIT {ss_text}"
637
- return f"ss {ss_text}"
638
- return "no owner"
639
-
640
-
641
- def report_sibling_owner(port: int, owner_pid: int, command: str) -> None:
642
- print(
643
- f"port {port} still held by pid {owner_pid} ({command}); leaving owner in place",
644
- file=sys.stderr,
645
- )
646
-
647
-
648
- def wait_until_port_bindable(port: int) -> None:
649
- deadline = time.monotonic() + CONTROL_WAIT_SECONDS
650
- while time.monotonic() < deadline:
651
- if port_is_bindable(port):
652
- return
653
- time.sleep(HOLD_POLL_SECONDS)
654
- raise RuntimeError(f"port {port} did not become bindable")
655
-
656
-
657
- def wait_until_port_bindable_or_sibling(port: int, excluded_pid: int | None) -> None:
658
- deadline = time.monotonic() + CONTROL_WAIT_SECONDS
659
- probed = False
660
- while time.monotonic() < deadline:
661
- if port_is_bindable(port):
662
- return
663
- if not probed:
664
- sibling = sibling_allocator_owner(port, excluded_pid)
665
- if sibling is not None:
666
- report_sibling_owner(port, sibling[0], sibling[1])
667
- return
668
- probed = True
669
- time.sleep(HOLD_POLL_SECONDS)
670
- sibling = sibling_allocator_owner(port, excluded_pid)
671
- if sibling is not None:
672
- report_sibling_owner(port, sibling[0], sibling[1])
673
- return
674
- occupancy = describe_port_owner(port)
675
- raise RuntimeError(
676
- f"port {port} did not become bindable after {CONTROL_WAIT_SECONDS}s ({occupancy})"
677
- )
678
-
679
-
680
- def request_release(hold_dir: Path, port: int) -> None:
681
- hold_dir = hold_dir.resolve()
682
- release_path = hold_dir / "release" / str(port)
683
- release_path.parent.mkdir(parents=True, exist_ok=True)
684
- release_path.write_text("1")
685
- deadline = time.monotonic() + CONTROL_WAIT_SECONDS
686
- while time.monotonic() < deadline:
687
- if port_is_bindable(port):
688
- return
689
- time.sleep(HOLD_POLL_SECONDS)
690
- raise RuntimeError(f"port {port} was not released by the holder under {hold_dir}")
691
-
692
-
693
- def request_stop(hold_dir: Path, workspace: str) -> None:
694
- hold_dir = hold_dir.resolve()
695
- hold_dir.mkdir(parents=True, exist_ok=True)
696
- ports_path = hold_dir / "ports"
697
- remaining = (
698
- [int(item) for item in ports_path.read_text().split() if item] if ports_path.is_file() else []
699
- )
700
- (hold_dir / "stop").write_text("1")
701
- pid_path = hold_dir / "pid"
702
- pid = read_pid(pid_path) if pid_path.is_file() else None
703
- stopped_live_holder = False
704
- if pid is not None and pid_is_alive(pid) and is_our_holder(pid, workspace):
705
- try:
706
- os.kill(pid, signal.SIGTERM)
707
- stopped_live_holder = True
708
- except ProcessLookupError:
709
- pass
710
- deadline = time.monotonic() + CONTROL_WAIT_SECONDS
711
- while time.monotonic() < deadline:
712
- current = read_pid(pid_path)
713
- if current is None or not pid_is_alive(current):
714
- break
715
- time.sleep(HOLD_POLL_SECONDS)
716
- successor = workspace_holder_pid(workspace)
717
- if successor is not None and successor != pid:
718
- # A replacement holder for this workspace already reaped us and took the ports.
719
- return
720
- if not stopped_live_holder:
721
- return
722
- for port in remaining:
723
- wait_until_port_bindable_or_sibling(port, pid)
724
-
725
-
726
- def check_holder(hold_dir: Path, workspace: str) -> None:
727
- hold_dir = hold_dir.resolve()
728
- pid_path = hold_dir / "pid"
729
- pid = read_pid(pid_path)
730
- if pid is None:
731
- raise RuntimeError(f"port holder pid file missing under {hold_dir}")
732
- if not pid_is_alive(pid):
733
- raise RuntimeError(f"port holder {pid} is not running")
734
- if not is_our_holder(pid, workspace):
735
- raise RuntimeError(f"pid {pid} is not the workspace port holder")
736
- ports_path = hold_dir / "ports"
737
- raw_ports = ports_path.read_text().split() if ports_path.is_file() else []
738
- ports = [int(item) for item in raw_ports if item]
739
- if not ports:
740
- raise RuntimeError("port holder has no remaining reserved ports")
741
- for port in ports:
742
- if port_is_bindable(port):
743
- raise RuntimeError(f"port {port} is bindable; holder is not reserving it")
744
-
745
-
746
182
  def resolve_runner_slot() -> int | None:
747
183
  if os.environ.get("GITHUB_ACTIONS") != "true":
748
184
  return None
@@ -752,24 +188,7 @@ def resolve_runner_slot() -> int | None:
752
188
  def main() -> int:
753
189
  args = parse_args()
754
190
  try:
755
- hold_dir = args.hold_dir.resolve() if args.hold_dir is not None else None
756
- workspace = resolve_workspace(args.workspace)
757
- if args.release is not None:
758
- request_release(hold_dir, args.release)
759
- return 0
760
- if args.stop:
761
- request_stop(hold_dir, workspace)
762
- return 0
763
- if args.check:
764
- check_holder(hold_dir, workspace)
765
- return 0
766
- runner_slot = resolve_runner_slot()
767
- if args.hold:
768
- ports = start_holder(
769
- args.count, args.max_bind_attempts, runner_slot, hold_dir, workspace
770
- )
771
- else:
772
- ports = allocate_ports(args.count, args.max_bind_attempts, runner_slot)
191
+ ports = allocate_ports(args.count, args.max_bind_attempts, resolve_runner_slot())
773
192
  except Exception as error:
774
193
  print(f"::error::{error}", file=sys.stderr)
775
194
  return 1
@@ -1,6 +0,0 @@
1
- export declare const synthesizedRunnerSlot: number;
2
- export declare const synthesizedPortRangeStart: number;
3
- export declare const synthesizedPortRangeEnd: number;
4
- export declare function requiredPort(port: number | undefined): number;
5
- export declare const synthesizedSlotHostLockTestTimeoutMs: number;
6
- export declare function withSynthesizedSlotHostLock<T>(body: () => Promise<T>): Promise<T>;