vouchington-tooling 0.0.8 → 0.0.10

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 (41) hide show
  1. package/README.md +16 -1
  2. package/dist/cli/commands/gha-artifacts-cleanup.d.mts +6 -0
  3. package/dist/cli/commands/gha-artifacts-cleanup.mjs +50 -0
  4. package/dist/cli/commands/http-origin.d.mts +2 -0
  5. package/dist/cli/commands/http-origin.mjs +14 -0
  6. package/dist/cli/index.mjs +11 -0
  7. package/dist/cli/parse-gha-artifacts-cleanup.d.mts +15 -0
  8. package/dist/cli/parse-gha-artifacts-cleanup.mjs +74 -0
  9. package/dist/cli/parse.d.mts +7 -2
  10. package/dist/cli/parse.mjs +36 -0
  11. package/dist/cli/usage.d.mts +1 -1
  12. package/dist/cli/usage.mjs +9 -0
  13. package/dist/gha-artifacts-cleanup/classify.d.mts +14 -0
  14. package/dist/gha-artifacts-cleanup/classify.mjs +32 -0
  15. package/dist/gha-artifacts-cleanup/commands.d.mts +23 -0
  16. package/dist/gha-artifacts-cleanup/commands.mjs +55 -0
  17. package/dist/gha-artifacts-cleanup/github.d.mts +16 -0
  18. package/dist/gha-artifacts-cleanup/github.mjs +100 -0
  19. package/dist/gha-artifacts-cleanup/index.d.mts +8 -0
  20. package/dist/gha-artifacts-cleanup/index.mjs +4 -0
  21. package/dist/gha-artifacts-cleanup/plan.d.mts +17 -0
  22. package/dist/gha-artifacts-cleanup/plan.mjs +40 -0
  23. package/dist/gha-selected-files/index.d.mts +6 -0
  24. package/dist/gha-selected-files/index.mjs +38 -0
  25. package/dist/http-origin/index.d.mts +1 -0
  26. package/dist/http-origin/index.mjs +23 -0
  27. package/dist/index.d.mts +7 -2
  28. package/dist/index.mjs +5 -1
  29. package/dist/pnpm-install/native-health.d.mts +4 -0
  30. package/dist/pnpm-install/native-health.mjs +84 -0
  31. package/dist/pnpm-install/runner.mjs +8 -3
  32. package/dist/process-line-buffer/index.d.mts +7 -0
  33. package/dist/process-line-buffer/index.mjs +28 -0
  34. package/dist/shared-context/{fake-git.test-helpers.d.mts → fake-git.d.mts} +1 -0
  35. package/dist/shared-context/{fake-git.test-helpers.mjs → fake-git.mjs} +4 -1
  36. package/dist/shared-context/index.d.mts +2 -0
  37. package/dist/shared-context/index.mjs +1 -0
  38. package/package.json +27 -1
  39. package/scripts/allocate-browser-safe-ports.py +113 -1
  40. package/scripts/gha/diagnose-port-collision.sh +232 -0
  41. package/scripts/gha/prepare-trivy-db.sh +32 -0
@@ -0,0 +1,40 @@
1
+ export function summarize(deleted) {
2
+ return {
3
+ deletedCount: deleted.length,
4
+ bytesFreed: deleted.reduce((total, artifact) => total + artifact.size_in_bytes, 0),
5
+ };
6
+ }
7
+ export function planRunDeletions(artifacts, classify) {
8
+ return artifacts.filter((artifact) => !artifact.expired && classify(artifact.name) === 'delete');
9
+ }
10
+ export function isSweepCandidate(artifact, cutoffIso, classify) {
11
+ return (!artifact.expired && artifact.created_at < cutoffIso && classify(artifact.name) === 'delete');
12
+ }
13
+ const MAX_CONSECUTIVE_EXPIRED_PAGES = 5;
14
+ const MAX_PAGES = 150;
15
+ export function shouldStopPaging(pageArtifacts, state) {
16
+ if (state.page >= MAX_PAGES)
17
+ return true;
18
+ if (pageArtifacts.length === 0)
19
+ return true;
20
+ return state.consecutiveExpiredPages >= MAX_CONSECUTIVE_EXPIRED_PAGES;
21
+ }
22
+ export function nextPagingState(pageArtifacts, state) {
23
+ const pageFullyExpired = pageArtifacts.every((artifact) => artifact.expired);
24
+ return {
25
+ page: state.page + 1,
26
+ consecutiveExpiredPages: pageFullyExpired ? state.consecutiveExpiredPages + 1 : 0,
27
+ };
28
+ }
29
+ export async function planSweepDeletions(candidates, getConclusion) {
30
+ const toDelete = [];
31
+ for (const artifact of candidates) {
32
+ const runId = artifact.workflow_run?.id;
33
+ if (runId == null)
34
+ continue;
35
+ const conclusion = await getConclusion(runId);
36
+ if (conclusion === 'success' || conclusion === 'cancelled')
37
+ toDelete.push(artifact);
38
+ }
39
+ return toDelete;
40
+ }
@@ -0,0 +1,6 @@
1
+ export declare function encodeSelectedFiles(files: readonly string[]): string;
2
+ export declare const SELECTED_FILES_ENV_MAX_BYTES = 120000;
3
+ export declare function selectedFilesExceedEnvBudget(files: readonly string[]): boolean;
4
+ export declare function decodeSelectedFiles(raw: string | undefined | null): string[];
5
+ export declare function formatMultilineOutput(key: string, value: string, createId?: () => string): string;
6
+ export declare function writeSelectedFilesOutput(key: string, files: readonly string[], createId?: () => string): void;
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { appendFileSync } from 'node:fs';
3
+ export function encodeSelectedFiles(files) {
4
+ return files.join('\n');
5
+ }
6
+ // Linux MAX_ARG_STRLEN is 131072. GitHub Actions interpolates selected-file
7
+ // lists into a step env block, so the encoded list plus the variable prefix
8
+ // must fit in one exec argument.
9
+ export const SELECTED_FILES_ENV_MAX_BYTES = 120_000;
10
+ export function selectedFilesExceedEnvBudget(files) {
11
+ return Buffer.byteLength(encodeSelectedFiles(files), 'utf8') > SELECTED_FILES_ENV_MAX_BYTES;
12
+ }
13
+ export function decodeSelectedFiles(raw) {
14
+ if (!raw)
15
+ return [];
16
+ return raw.split('\n').filter((file) => file.trim().length > 0);
17
+ }
18
+ export function formatMultilineOutput(key, value, createId = randomUUID) {
19
+ const delimiter = collisionFreeDelimiter(key, value, createId);
20
+ const body = value === '' ? '' : `${value}\n`;
21
+ return `${key}<<${delimiter}\n${body}${delimiter}\n`;
22
+ }
23
+ export function writeSelectedFilesOutput(key, files, createId = randomUUID) {
24
+ const target = process.env.GITHUB_OUTPUT;
25
+ if (target) {
26
+ appendFileSync(target, formatMultilineOutput(key, encodeSelectedFiles(files), createId));
27
+ }
28
+ console.log(`[select] ${key} (${files.length} file${files.length === 1 ? '' : 's'})`);
29
+ }
30
+ function collisionFreeDelimiter(key, value, createId) {
31
+ const prefix = key.toUpperCase().replace(/[^A-Z0-9]/g, '_');
32
+ for (let attempt = 0; attempt < 10; attempt += 1) {
33
+ const candidate = `${prefix}_${createId().toUpperCase().replaceAll('-', '_')}`;
34
+ if (!value.includes(candidate))
35
+ return candidate;
36
+ }
37
+ throw new Error(`could not create a collision-free GitHub output delimiter for ${key} after 10 attempts`);
38
+ }
@@ -0,0 +1 @@
1
+ export declare function validateOptionalHttpOrigin(value: string, fieldName?: string): void;
@@ -0,0 +1,23 @@
1
+ export function validateOptionalHttpOrigin(value, fieldName = 'origin') {
2
+ if (!value)
3
+ return;
4
+ let url;
5
+ try {
6
+ url = new URL(value);
7
+ }
8
+ catch {
9
+ throw invalidOriginError(fieldName);
10
+ }
11
+ if (!['http:', 'https:'].includes(url.protocol) ||
12
+ url.username !== '' ||
13
+ url.password !== '' ||
14
+ url.pathname !== '/' ||
15
+ url.search !== '' ||
16
+ url.hash !== '' ||
17
+ ![url.origin, `${url.origin}/`].includes(value)) {
18
+ throw invalidOriginError(fieldName);
19
+ }
20
+ }
21
+ function invalidOriginError(fieldName) {
22
+ return new Error(`${fieldName} must be empty or a pure HTTP(S) origin without credentials, path, query, or fragment`);
23
+ }
package/dist/index.d.mts CHANGED
@@ -9,5 +9,10 @@ export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManif
9
9
  export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, } from './vitest-blob-manifest/index.mts';
10
10
  export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mts';
11
11
  export type { InstallOptions, Lifecycle } from './pnpm-install/index.mts';
12
- export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mts';
13
- export type { NamedCheck, SharedContext } from './shared-context/index.mts';
12
+ export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mts';
13
+ export type { FakeGitOptions, NamedCheck, SharedContext } from './shared-context/index.mts';
14
+ export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mts';
15
+ export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mts';
16
+ export type { ArtifactClassification, ArtifactClassifier, ArtifactPatterns, CleanupRequest, DeletionSummary, } from './gha-artifacts-cleanup/index.mts';
17
+ export { validateOptionalHttpOrigin } from './http-origin/index.mts';
18
+ export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mts';
package/dist/index.mjs CHANGED
@@ -4,4 +4,8 @@ export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, re
4
4
  export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
5
5
  export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManifest, serializeVitestBlobManifest, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, } from './vitest-blob-manifest/index.mjs';
6
6
  export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
7
- export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mjs';
7
+ export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mjs';
8
+ export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mjs';
9
+ export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mjs';
10
+ export { validateOptionalHttpOrigin } from './http-origin/index.mjs';
11
+ export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mjs';
@@ -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 = !stamped && (await persistentDependencyTreeIsCold());
53
- if (!stamped && !cold) {
54
- console.warn('persistent dependency metadata provenance is missing or changed; reconciling');
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';
@@ -0,0 +1,7 @@
1
+ export declare const DEFAULT_TRUNCATED_LINE_MARKER = " [oversized line truncated] ";
2
+ export declare const DEFAULT_MAX_PENDING_LINE_LENGTH: number;
3
+ export declare function boundPendingLine(value: string, marker?: string, maxLength?: number): string;
4
+ export declare function splitCompleteLines(value: string): {
5
+ complete: string[];
6
+ pending: string;
7
+ };
@@ -0,0 +1,28 @@
1
+ export const DEFAULT_TRUNCATED_LINE_MARKER = ' [oversized line truncated] ';
2
+ export const DEFAULT_MAX_PENDING_LINE_LENGTH = 64 * 1024;
3
+ export function boundPendingLine(value, marker = DEFAULT_TRUNCATED_LINE_MARKER, maxLength = DEFAULT_MAX_PENDING_LINE_LENGTH) {
4
+ if (value.length <= maxLength)
5
+ return value;
6
+ if (marker.length >= maxLength)
7
+ return marker;
8
+ const retainedLength = maxLength - marker.length;
9
+ const headLength = Math.floor(retainedLength / 2);
10
+ const tailLength = retainedLength - headLength;
11
+ return value.slice(0, headLength) + marker + value.slice(-tailLength);
12
+ }
13
+ export function splitCompleteLines(value) {
14
+ const complete = [];
15
+ let start = 0;
16
+ for (let index = 0; index < value.length; index += 1) {
17
+ const character = value[index];
18
+ if (character !== '\n' && character !== '\r')
19
+ continue;
20
+ if (character === '\r' && index === value.length - 1)
21
+ break;
22
+ if (character === '\r' && value[index + 1] === '\n')
23
+ index += 1;
24
+ complete.push(value.slice(start, index + 1));
25
+ start = index + 1;
26
+ }
27
+ return { complete, pending: value.slice(start) };
28
+ }
@@ -8,4 +8,5 @@ export type FakeGitOptions = {
8
8
  trackedFiles?: readonly string[];
9
9
  };
10
10
  export declare function installFakeGit({ binDir, isInsideWorkTree, lsFilesExitCode, lsFilesStderr, pathPrefix, repoRoot, trackedFiles, }: FakeGitOptions): void;
11
+ export declare function setFakeGitTrackedFiles(files: readonly string[]): void;
11
12
  export declare function clearFakeGitEnv(): void;
@@ -15,7 +15,10 @@ export function installFakeGit({ binDir, isInsideWorkTree = true, lsFilesExitCod
15
15
  process.env[FAKE_GIT_LS_FILES_STDERR] = lsFilesStderr;
16
16
  if (repoRoot !== undefined)
17
17
  process.env[FAKE_GIT_ROOT] = repoRoot;
18
- process.env[FAKE_GIT_FILES] = [...trackedFiles].toSorted().join('\n');
18
+ setFakeGitTrackedFiles(trackedFiles);
19
+ }
20
+ export function setFakeGitTrackedFiles(files) {
21
+ process.env[FAKE_GIT_FILES] = [...files].toSorted().join('\n');
19
22
  }
20
23
  export function clearFakeGitEnv() {
21
24
  process.env.FAKE_GIT_INSIDE = 'false';
@@ -1,3 +1,5 @@
1
+ export { clearFakeGitEnv, installFakeGit, setFakeGitTrackedFiles } from './fake-git.mts';
2
+ export type { FakeGitOptions } from './fake-git.mts';
1
3
  export declare function gitEnv(): NodeJS.ProcessEnv;
2
4
  export interface SharedContext {
3
5
  repoRoot: string;
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  const execFileAsync = promisify(execFile);
6
+ export { clearFakeGitEnv, installFakeGit, setFakeGitTrackedFiles } from './fake-git.mjs';
6
7
  export function gitEnv() {
7
8
  return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
8
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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": {
@@ -65,6 +65,26 @@
65
65
  "import": "./dist/shared-context/index.mjs",
66
66
  "default": "./dist/shared-context/index.mjs"
67
67
  },
68
+ "./gha-selected-files": {
69
+ "types": "./dist/gha-selected-files/index.d.mts",
70
+ "import": "./dist/gha-selected-files/index.mjs",
71
+ "default": "./dist/gha-selected-files/index.mjs"
72
+ },
73
+ "./gha-artifacts-cleanup": {
74
+ "types": "./dist/gha-artifacts-cleanup/index.d.mts",
75
+ "import": "./dist/gha-artifacts-cleanup/index.mjs",
76
+ "default": "./dist/gha-artifacts-cleanup/index.mjs"
77
+ },
78
+ "./http-origin": {
79
+ "types": "./dist/http-origin/index.d.mts",
80
+ "import": "./dist/http-origin/index.mjs",
81
+ "default": "./dist/http-origin/index.mjs"
82
+ },
83
+ "./process-line-buffer": {
84
+ "types": "./dist/process-line-buffer/index.d.mts",
85
+ "import": "./dist/process-line-buffer/index.mjs",
86
+ "default": "./dist/process-line-buffer/index.mjs"
87
+ },
68
88
  "./package.json": "./package.json"
69
89
  },
70
90
  "publishConfig": {
@@ -75,6 +95,12 @@
75
95
  "prepack": "pnpm run build",
76
96
  "typecheck": "tsc --noEmit --project tsconfig.json"
77
97
  },
98
+ "dependencies": {
99
+ "picomatch": "4.0.5"
100
+ },
101
+ "devDependencies": {
102
+ "@types/picomatch": "^4.0.3"
103
+ },
78
104
  "optionalDependencies": {
79
105
  "@libpg-query/parser": "^18.0.0"
80
106
  },
@@ -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
- wait_until_port_bindable(port)
723
+ wait_until_port_bindable_or_sibling(port, pid)
612
724
 
613
725
 
614
726
  def check_holder(hold_dir: Path, workspace: str) -> None: