-
-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathproc-helpers.mjs
More file actions
131 lines (123 loc) · 6.76 KB
/
Copy pathproc-helpers.mjs
File metadata and controls
131 lines (123 loc) · 6.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// Process and port helpers shared by every test file that signals a pid or
// needs an unused port.
//
// One copy, because the four hand-rolled ones cost a test whose entire job was
// to keep them in sync: `no test file signals a pid it knows only by port` in
// suite-collection.test.mjs pinned the OURS expression in each file and checked
// there was exactly one lsof call in each. That guard is deleted with this
// module — a shared definition cannot drift, so there is nothing left to police.
// The drift was already starting: `listeners` was byte-identical in three files
// and an arrow function in a fourth, and freePort had three different shapes.
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import net from "node:net";
// NEVER SIGNAL A PID WE KNOW ONLY BY PORT. freePort() binds 0, reads the number
// and CLOSES, so the OS can hand it to a NEIGHBOURING TEST FILE — node:test runs
// files concurrently and several of them listen in-process. Every caller signals
// or counts what listeners() returns, so an unfiltered answer kills another
// runner: measured, and it is CI run 32087202771.
//
// The predicate is the COMMAND LINE, and it was got wrong twice before landing
// here: matching `node` alone claims every node process on the box, and matching
// a bare filename claims a test file that happens to be named for one of ours.
// A path segment of `bin/` or `proxy/` ending in `.mjs` is what only our
// binaries have.
export const OURS = /\/(?:bin|proxy)\/[\w.-]+\.mjs\b/;
// The command line of a pid, or "" if it is gone. Every case has to tell a
// holder from a proxy from a standby relay, and they are only distinguishable
// by what they are running.
export const cmdOf = (pid) => {
try { return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); }
catch { return ""; }
};
// Whoever is LISTENING on a port, by port rather than by parentage. The
// self-heal spawns a DETACHED successor, so it is nobody's child and `pgrep -P`
// cannot see it — the only durable handle on it is the address it took.
//
// Filtered HERE and not at the call sites, because it already existed at some of
// them and the rest never got it.
export function listeners(port) {
try {
return execFileSync("lsof", ["-nP", "-t", `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim().split("\n").filter(Boolean)
.filter((p) => OURS.test(cmdOf(p)));
} catch { return []; }
}
// EVERY FIXTURE ON A PORT, LISTENING OR NOT.
//
// listeners() is `lsof -sTCP:LISTEN`, so it finds a process only while it HOLDS
// THE LISTEN. The standby's whole job is to hand the listen on and keep carrying
// the address, so after a handover it is a live process no sweep can see.
// Measured, two orphans side by side:
// pid=2404217 ppid=1 port=45855 lsof-sees-it=0 bin/gap-relay.mjs
// pid=2406768 ppid=1 port=41031 lsof-sees-it=1 bin/gap-relay.mjs
// The invisible ones accumulate — ten at once here, the oldest 788 s, across
// files and runs — and they hold ports and CPU that the NEXT file's readiness
// assertions then time out on. Every "node 20 flake" on this branch has had that
// shape, including a runner found at 414 s with zero CPU, wedged rather than slow.
//
// THE PORT A FIXTURE WAS GIVEN IS IN ITS ENVIRONMENT AND STAYS THERE. That is
// the identifier that survives handing the listen on. Both markers are read
// because the trio does not agree on one: measured on a live trio,
// claude-via-proxy.mjs CACHE_FIX_PROXY_PORT=<port> (no HELD_PORT)
// gap-relay.mjs both
// proxy/server.mjs CACHE_FIX_HELD_PORT=<port>, PROXY_PORT=0
//
// Still filtered by OURS, for the same reason listeners() is: a port number is
// not ownership, and freePort() hands the same number to neighbouring files.
export function ours(port) {
const want = new RegExp(`CACHE_FIX_(?:HELD|PROXY)_PORT=${Number(port)}(?:\\s|$)`);
const out = [];
try {
// Linux: /proc is authoritative and needs no shell-out.
for (const pid of readdirSync("/proc")) {
if (!/^\d+$/.test(pid)) continue;
let env = "";
try { env = readFileSync(`/proc/${pid}/environ`, "utf8").replace(/\0/g, " "); } catch { continue; }
if (want.test(env) && OURS.test(cmdOf(pid))) out.push(pid);
}
return out;
} catch { /* no /proc: ask ps below */ }
try {
// macOS: `ps -wwE` prints the environment after the command. Verified there.
const rows = execFileSync("ps", ["-wwEo", "pid=,command="],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
for (const line of rows.split("\n")) {
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
if (m && want.test(m[2]) && OURS.test(m[2])) out.push(m[1]);
}
} catch { /* no ps either: the caller falls back to listeners() */ }
return out;
}
// A port nobody is listening on RIGHT NOW. It is released before the caller
// uses it — see the OURS note above for what that costs and how it is bounded.
export async function freePort() {
const s = net.createServer();
await new Promise((r) => s.listen(0, "127.0.0.1", r));
const p = s.address().port;
await new Promise((r) => s.close(r));
return p;
}
// EVERY variable that can give a child an outbound hop, in one list because six
// fixtures scrub it and a per-fixture copy is how one gets missed. It was: five
// of them dropped the four *_PROXY names and none dropped the two CACHE_FIX
// ones, which the relay reads FIRST (bin/gap-relay.mjs) — so a maintainer behind
// a corp proxy ran the suite, the relay carried to it, and its host:port went
// into the 503 body that a failure message now prints. This repo is public and
// that is the hostname-port class its hygiene rule bans.
export const HOP_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy",
"ALL_PROXY", "all_proxy",
"CACHE_FIX_UPSTREAM_PROXY", "CACHE_FIX_REQUIRE_HOP",
"CACHE_FIX_FALLBACK_PROXIES"];
// THE CLEANUP SET: everything on this port, listening or not. listeners() alone
// misses a standby that has handed its listen on — measured, ten such orphans at
// once, the oldest 788 s, accumulating across files and runs until a later
// file's readiness assertion times out on the CPU and ports they hold. See
// ours() for the mechanism and the two markers it reads.
// Port 0 is the "assign me one" sentinel, never a port anything holds -- and it
// is what a caller's `let port = 0` still carries if it throws before the
// assignment. ours() would then match every proxy child running with
// CACHE_FIX_PROXY_PORT=0, the operator's live one included.
export const onPort = (port) =>
Number(port) > 0 ? [...new Set([...listeners(port), ...ours(port)])] : [];