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.
- package/README.md +14 -9
- package/dist/cli/commands/gha-runtime-audit.mjs +6 -0
- package/dist/cli/commands/retrospective-facts.mjs +20 -7
- package/dist/cli/index.mjs +4 -1
- package/dist/cli/parse-gha-runtime-audit.d.mts +2 -0
- package/dist/cli/parse-gha-runtime-audit.mjs +33 -1
- package/dist/cli/parse.d.mts +7 -0
- package/dist/cli/parse.mjs +15 -0
- package/dist/cli/usage.d.mts +3 -1
- package/dist/cli/usage.mjs +44 -1
- package/dist/dependency-license-policy/audit-directory.d.mts +28 -0
- package/dist/dependency-license-policy/audit-directory.mjs +115 -0
- package/dist/dependency-license-policy/audit-signals.d.mts +12 -0
- package/dist/dependency-license-policy/audit-signals.mjs +44 -0
- package/dist/dependency-license-policy/audit-store.d.mts +12 -0
- package/dist/dependency-license-policy/audit-store.mjs +32 -0
- package/dist/dependency-license-policy/collect.d.mts +3 -1
- package/dist/dependency-license-policy/collect.mjs +16 -21
- package/dist/dependency-license-policy/execute-pnpm.d.mts +10 -0
- package/dist/dependency-license-policy/execute-pnpm.mjs +83 -0
- package/dist/dependency-license-policy/types.d.mts +11 -9
- package/dist/dependency-license-policy/workspace.d.mts +3 -1
- package/dist/dependency-license-policy/workspace.mjs +12 -5
- package/dist/gha-runtime-audit/audit.mjs +12 -4
- package/dist/gha-runtime-audit/family.d.mts +7 -0
- package/dist/gha-runtime-audit/family.mjs +79 -0
- package/dist/gha-runtime-audit/index.test-helpers.d.mts +5 -0
- package/dist/gha-runtime-audit/model.d.mts +11 -1
- package/dist/gha-runtime-audit/model.mjs +26 -0
- package/dist/gha-runtime-audit/scope.d.mts +2 -0
- package/dist/gha-runtime-audit/scope.mjs +16 -1
- package/dist/pnpm-install/support.mjs +1 -1
- package/package.json +1 -1
- package/scripts/allocate-browser-safe-ports.py +2 -583
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +0 -6
- 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",
|
|
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
|
-
|
|
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>;
|