vouchington-tooling 0.0.8 → 0.0.9
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.
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type NativeFamily = 'elf' | 'macho' | 'pe';
|
|
2
|
+
export declare function nativeFamilyFromMagic(buffer: Buffer): NativeFamily | undefined;
|
|
3
|
+
export declare function expectedNativeFamily(platform?: NodeJS.Platform): NativeFamily | undefined;
|
|
4
|
+
export declare function nativeBinariesMatchRuntime(root?: string, platform?: NodeJS.Platform): Promise<boolean>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { glob, open, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const ELF = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
4
|
+
const PE = Buffer.from([0x4d, 0x5a]);
|
|
5
|
+
const MACHO = [
|
|
6
|
+
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]),
|
|
7
|
+
Buffer.from([0xfe, 0xed, 0xfa, 0xcf]),
|
|
8
|
+
Buffer.from([0xce, 0xfa, 0xed, 0xfe]),
|
|
9
|
+
Buffer.from([0xfe, 0xed, 0xfa, 0xce]),
|
|
10
|
+
Buffer.from([0xca, 0xfe, 0xba, 0xbe]),
|
|
11
|
+
Buffer.from([0xbe, 0xba, 0xfe, 0xca]),
|
|
12
|
+
];
|
|
13
|
+
function startsWith(buffer, magic) {
|
|
14
|
+
return buffer.length >= magic.length && buffer.subarray(0, magic.length).equals(magic);
|
|
15
|
+
}
|
|
16
|
+
export function nativeFamilyFromMagic(buffer) {
|
|
17
|
+
if (startsWith(buffer, ELF))
|
|
18
|
+
return 'elf';
|
|
19
|
+
if (MACHO.some((magic) => startsWith(buffer, magic)))
|
|
20
|
+
return 'macho';
|
|
21
|
+
if (startsWith(buffer, PE))
|
|
22
|
+
return 'pe';
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function expectedNativeFamily(platform = process.platform) {
|
|
26
|
+
if (platform === 'linux')
|
|
27
|
+
return 'elf';
|
|
28
|
+
if (platform === 'darwin')
|
|
29
|
+
return 'macho';
|
|
30
|
+
if (platform === 'win32')
|
|
31
|
+
return 'pe';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
async function readMagic(pathname) {
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await open(pathname, 'r');
|
|
38
|
+
const buffer = Buffer.alloc(4);
|
|
39
|
+
const { bytesRead } = await handle.read(buffer, 0, 4, 0);
|
|
40
|
+
return bytesRead === 0 ? undefined : buffer.subarray(0, bytesRead);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT')
|
|
44
|
+
return undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await handle?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function searchRoot(nodeModules) {
|
|
52
|
+
const pnpmStore = path.join(nodeModules, '.pnpm');
|
|
53
|
+
try {
|
|
54
|
+
const info = await stat(pnpmStore);
|
|
55
|
+
return info.isDirectory() ? pnpmStore : nodeModules;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return nodeModules;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function nativeBinariesMatchRuntime(root = process.cwd(), platform = process.platform) {
|
|
62
|
+
const expected = expectedNativeFamily(platform);
|
|
63
|
+
if (expected === undefined)
|
|
64
|
+
return true;
|
|
65
|
+
const nodeModules = path.join(root, 'node_modules');
|
|
66
|
+
try {
|
|
67
|
+
const info = await stat(nodeModules);
|
|
68
|
+
if (!info.isDirectory())
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const cwd = await searchRoot(nodeModules);
|
|
75
|
+
for await (const relative of glob('**/*.{node,bin}', { cwd })) {
|
|
76
|
+
const magic = await readMagic(path.join(cwd, relative));
|
|
77
|
+
if (magic === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const family = nativeFamilyFromMagic(magic);
|
|
80
|
+
if (family !== undefined && family !== expected)
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { scheduler } from 'node:timers/promises';
|
|
2
2
|
import { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mjs';
|
|
3
|
+
import { nativeBinariesMatchRuntime } from './native-health.mjs';
|
|
3
4
|
import { runPnpm } from './exec.mjs';
|
|
4
5
|
import { INSTALL_TERMINATION_FAILED } from './process.mjs';
|
|
5
6
|
import { formatReleaseAgeFailure, isReleaseAgeViolation } from './release-age.mjs';
|
|
@@ -47,11 +48,15 @@ async function persistent(options) {
|
|
|
47
48
|
const runCapture = (args) => runPnpm(args, options, true);
|
|
48
49
|
const fingerprint = await persistentMetadataFingerprint(runCapture, options.installScripts);
|
|
49
50
|
const stamped = await persistentMetadataMatches(fingerprint);
|
|
51
|
+
const nativesMatch = await nativeBinariesMatchRuntime();
|
|
52
|
+
const provenanceOk = stamped && nativesMatch;
|
|
50
53
|
// An absent tree has nothing to repair, so one ordinary install below matches the
|
|
51
54
|
// reconciled end state. Check first: an install would otherwise make the tree non-cold.
|
|
52
|
-
const cold = !
|
|
53
|
-
if (!
|
|
54
|
-
console.warn(
|
|
55
|
+
const cold = !provenanceOk && (await persistentDependencyTreeIsCold());
|
|
56
|
+
if (!provenanceOk && !cold) {
|
|
57
|
+
console.warn(stamped && !nativesMatch
|
|
58
|
+
? 'persistent optional native binaries do not match this runtime; reconciling'
|
|
59
|
+
: 'persistent dependency metadata provenance is missing or changed; reconciling');
|
|
55
60
|
await reconcileOrFail(options, runCapture);
|
|
56
61
|
await writePersistentMetadataStamp(fingerprint);
|
|
57
62
|
return 'persistent metadata reconciled';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -211,6 +211,7 @@ SCRIPT_NAME = "allocate-browser-safe-ports.py"
|
|
|
211
211
|
HOLD_POLL_SECONDS = 0.05
|
|
212
212
|
HOLD_READY_TIMEOUT_SECONDS = 2.0
|
|
213
213
|
CONTROL_WAIT_SECONDS = 2.0
|
|
214
|
+
OWNER_PROBE_SECONDS = 0.4
|
|
214
215
|
|
|
215
216
|
|
|
216
217
|
def resolve_workspace(workspace: str) -> str:
|
|
@@ -560,6 +561,90 @@ def port_is_bindable(port: int) -> bool:
|
|
|
560
561
|
probe.close()
|
|
561
562
|
|
|
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
|
+
|
|
563
648
|
def wait_until_port_bindable(port: int) -> None:
|
|
564
649
|
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
565
650
|
while time.monotonic() < deadline:
|
|
@@ -569,6 +654,29 @@ def wait_until_port_bindable(port: int) -> None:
|
|
|
569
654
|
raise RuntimeError(f"port {port} did not become bindable")
|
|
570
655
|
|
|
571
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
|
+
|
|
572
680
|
def request_release(hold_dir: Path, port: int) -> None:
|
|
573
681
|
hold_dir = hold_dir.resolve()
|
|
574
682
|
release_path = hold_dir / "release" / str(port)
|
|
@@ -592,9 +700,11 @@ def request_stop(hold_dir: Path, workspace: str) -> None:
|
|
|
592
700
|
(hold_dir / "stop").write_text("1")
|
|
593
701
|
pid_path = hold_dir / "pid"
|
|
594
702
|
pid = read_pid(pid_path) if pid_path.is_file() else None
|
|
703
|
+
stopped_live_holder = False
|
|
595
704
|
if pid is not None and pid_is_alive(pid) and is_our_holder(pid, workspace):
|
|
596
705
|
try:
|
|
597
706
|
os.kill(pid, signal.SIGTERM)
|
|
707
|
+
stopped_live_holder = True
|
|
598
708
|
except ProcessLookupError:
|
|
599
709
|
pass
|
|
600
710
|
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
@@ -607,8 +717,10 @@ def request_stop(hold_dir: Path, workspace: str) -> None:
|
|
|
607
717
|
if successor is not None and successor != pid:
|
|
608
718
|
# A replacement holder for this workspace already reaped us and took the ports.
|
|
609
719
|
return
|
|
720
|
+
if not stopped_live_holder:
|
|
721
|
+
return
|
|
610
722
|
for port in remaining:
|
|
611
|
-
|
|
723
|
+
wait_until_port_bindable_or_sibling(port, pid)
|
|
612
724
|
|
|
613
725
|
|
|
614
726
|
def check_holder(hold_dir: Path, workspace: str) -> None:
|