vouchington-tooling 0.0.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 ADDED
@@ -0,0 +1,38 @@
1
+ # vouchington-tooling
2
+
3
+ Libraries and the `vouchington` CLI.
4
+
5
+ ```bash
6
+ npm install vouchington-tooling
7
+ # optional, only if you import vouchington-tooling/sql-ast
8
+ npm install @libpg-query/parser
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ vouchington --help
15
+ vouchington runner-port-policy
16
+ vouchington runner-port-policy --file ./policy.json
17
+ vouchington runner-port-policy --reserved 2200
18
+ vouchington with-host-lock --name expensive-build --timeout-seconds 60 -- make build
19
+ ```
20
+
21
+ Host-lock environment:
22
+
23
+ | Variable | Default | Meaning |
24
+ | --------------------------------------- | --------------------- | ------------------------------------------- |
25
+ | `HOST_LOCK_ROOT` | `/tmp/host-lock-$UID` | Absolute lock directory root |
26
+ | `HOST_LOCK_LEASE_SECONDS` | `60` | Reclaim ceiling for a held lock |
27
+ | `HOST_LOCK_PROCESS_GROUP_DRAIN_SECONDS` | `30` | Time to wait for the command process group |
28
+ | `HOST_LOCK_ACTIVE` | unset | Set while a lock is held; nested locks fail |
29
+
30
+ ## Library
31
+
32
+ ```ts
33
+ import {
34
+ isRunnerReservedPort,
35
+ listenOnRunnerUnreservedEphemeralPort,
36
+ runnerPortPolicy,
37
+ } from 'vouchington-tooling/runner-port-policy'
38
+ ```
@@ -0,0 +1,4 @@
1
+ export declare function runRunnerPortPolicy(options: {
2
+ file?: string;
3
+ reserved?: number;
4
+ }): number;
@@ -0,0 +1,11 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { isRunnerReservedPort, loadRunnerPortPolicy, runnerPortPolicy, } from '../../runner-port-policy/index.mjs';
3
+ export function runRunnerPortPolicy(options) {
4
+ const policy = options.file ? loadRunnerPortPolicy(pathToFileURL(options.file)) : runnerPortPolicy;
5
+ if (options.reserved !== undefined) {
6
+ process.stdout.write(`${String(isRunnerReservedPort(options.reserved, policy))}\n`);
7
+ return 0;
8
+ }
9
+ process.stdout.write(`${JSON.stringify(policy, null, 2)}\n`);
10
+ return 0;
11
+ }
@@ -0,0 +1,6 @@
1
+ import { type SpawnSyncReturns } from 'node:child_process';
2
+ export type HostLockSpawn = (command: string, args: readonly string[], options: {
3
+ stdio: 'inherit';
4
+ }) => Pick<SpawnSyncReturns<Buffer>, 'error' | 'status'>;
5
+ export declare function hostLockScriptPath(): string;
6
+ export declare function runWithHostLock(args: readonly string[], spawn?: HostLockSpawn): number;
@@ -0,0 +1,14 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ const scriptUrl = new URL('../../../scripts/host-lock/with-host-lock.sh', import.meta.url);
4
+ export function hostLockScriptPath() {
5
+ return fileURLToPath(scriptUrl);
6
+ }
7
+ export function runWithHostLock(args, spawn = spawnSync) {
8
+ const result = spawn('bash', [hostLockScriptPath(), ...args], {
9
+ stdio: 'inherit',
10
+ });
11
+ if (result.error)
12
+ throw result.error;
13
+ return result.status ?? 1;
14
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export declare function runCli(argv?: readonly string[]): number;
3
+ export declare function isMainModule(metaUrl: string, argv1: string | undefined): boolean;
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { readPackageVersion } from '../package-version.mjs';
5
+ import { runRunnerPortPolicy } from './commands/runner-port-policy.mjs';
6
+ import { runWithHostLock } from './commands/with-host-lock.mjs';
7
+ import { parseCli } from './parse.mjs';
8
+ import { printUsage } from './usage.mjs';
9
+ export function runCli(argv = process.argv) {
10
+ const parsed = parseCli(argv);
11
+ switch (parsed.kind) {
12
+ case 'help':
13
+ printUsage();
14
+ return 0;
15
+ case 'version':
16
+ process.stdout.write(`${readInstalledVersion()}\n`);
17
+ return 0;
18
+ case 'error':
19
+ process.stderr.write(`vouchington: ${parsed.message}\n`);
20
+ printUsage(process.stderr);
21
+ return 2;
22
+ case 'runner-port-policy':
23
+ return runRunnerPortPolicy(parsed);
24
+ case 'with-host-lock':
25
+ return runWithHostLock(parsed.args);
26
+ }
27
+ }
28
+ function readInstalledVersion() {
29
+ return readPackageVersion(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')));
30
+ }
31
+ export function isMainModule(metaUrl, argv1) {
32
+ return argv1 !== undefined && metaUrl === pathToFileURL(argv1).href;
33
+ }
34
+ /* v8 ignore next 3 */
35
+ if (isMainModule(import.meta.url, process.argv[1])) {
36
+ process.exitCode = runCli();
37
+ }
@@ -0,0 +1,16 @@
1
+ export type ParsedCli = {
2
+ kind: 'help';
3
+ } | {
4
+ kind: 'version';
5
+ } | {
6
+ kind: 'error';
7
+ message: string;
8
+ } | {
9
+ kind: 'runner-port-policy';
10
+ file?: string;
11
+ reserved?: number;
12
+ } | {
13
+ kind: 'with-host-lock';
14
+ args: string[];
15
+ };
16
+ export declare function parseCli(argv: readonly string[]): ParsedCli;
@@ -0,0 +1,47 @@
1
+ export function parseCli(argv) {
2
+ const args = argv.slice(2);
3
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h')
4
+ return { kind: 'help' };
5
+ if (args[0] === '--version' || args[0] === '-v')
6
+ return { kind: 'version' };
7
+ const [command, ...rest] = args;
8
+ if (command === 'runner-port-policy')
9
+ return parseRunnerPortPolicy(rest);
10
+ if (command === 'with-host-lock')
11
+ return { kind: 'with-host-lock', args: rest };
12
+ return { kind: 'error', message: `unknown command: ${command}` };
13
+ }
14
+ function parseRunnerPortPolicy(args) {
15
+ let file;
16
+ let reserved;
17
+ for (let index = 0; index < args.length; index += 1) {
18
+ const flag = args[index];
19
+ if (flag === '--file') {
20
+ const value = args[index + 1];
21
+ if (value === undefined)
22
+ return { kind: 'error', message: '--file requires a path' };
23
+ file = value;
24
+ index += 1;
25
+ continue;
26
+ }
27
+ if (flag === '--reserved') {
28
+ const value = args[index + 1];
29
+ if (value === undefined)
30
+ return { kind: 'error', message: '--reserved requires a port' };
31
+ const port = Number(value);
32
+ if (!Number.isInteger(port))
33
+ return { kind: 'error', message: '--reserved must be an integer' };
34
+ reserved = port;
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (flag === '--help' || flag === '-h')
39
+ return { kind: 'help' };
40
+ return { kind: 'error', message: `unknown runner-port-policy option: ${flag}` };
41
+ }
42
+ return {
43
+ kind: 'runner-port-policy',
44
+ ...(file === undefined ? {} : { file }),
45
+ ...(reserved === undefined ? {} : { reserved }),
46
+ };
47
+ }
@@ -0,0 +1,2 @@
1
+ export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n";
2
+ export declare function printUsage(stream?: NodeJS.WritableStream): void;
@@ -0,0 +1,27 @@
1
+ export const USAGE = `Usage: vouchington <command> [options]
2
+
3
+ Commands:
4
+ runner-port-policy Print or validate a runner port policy
5
+ with-host-lock Run a command under a host-wide lock
6
+
7
+ Options:
8
+ -h, --help Show this help
9
+ -v, --version Print the package version
10
+
11
+ runner-port-policy
12
+ (no args) Print the shipped policy as JSON
13
+ --file <path> Validate and print a policy file
14
+ --reserved <port> Print true if the port is reserved
15
+
16
+ with-host-lock
17
+ --name <family>
18
+ [--slots <n>]
19
+ --timeout-seconds <n>
20
+ [--command-timeout-seconds <n>]
21
+ [--failure-diagnostics <absolute-script>]
22
+ [--on-acquire-timeout fail|run-unlocked]
23
+ -- <command> [args...]
24
+ `;
25
+ export function printUsage(stream = process.stdout) {
26
+ stream.write(USAGE);
27
+ }
@@ -0,0 +1,21 @@
1
+ import { execFile, type ChildProcess } from 'node:child_process';
2
+ export declare const execFileAsync: typeof execFile.__promisify__;
3
+ export declare const hostLockScript: string;
4
+ export declare function makeHome(): Promise<string>;
5
+ export declare function hostLockEnv(home: string, overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
6
+ export declare function hostLockArgs(name: string, timeoutSeconds: number, command: string[], extraArgs?: string[]): string[];
7
+ export declare function spawnHostLock(home: string, name: string, timeoutSeconds: number, command: string[], env?: NodeJS.ProcessEnv, extraArgs?: string[]): ChildProcess;
8
+ export declare function completion(child: ChildProcess): Promise<{
9
+ code: number | null;
10
+ stderr: string;
11
+ }>;
12
+ export declare function waitForPath(path: string): Promise<void>;
13
+ export declare function cleanupTestHomes(): Promise<void>;
14
+ export declare function withFinalTimeoutProbe(home: string, assertProbe: ({ lock, marker, contenderDone, }: {
15
+ lock: string;
16
+ marker: string;
17
+ contenderDone: Promise<{
18
+ code: number | null;
19
+ stderr: string;
20
+ }>;
21
+ }) => Promise<void>): Promise<void>;
@@ -0,0 +1,124 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ export const execFileAsync = promisify(execFile);
7
+ export const hostLockScript = join(process.cwd(), 'packages/vouchington-tooling/scripts/host-lock/with-host-lock.sh');
8
+ const testHomes = [];
9
+ export async function makeHome() {
10
+ const home = await mkdtemp(join(tmpdir(), 'host-lock-'));
11
+ testHomes.push(home);
12
+ return home;
13
+ }
14
+ function hostLockRoot(home) {
15
+ return join(home, '.cache/host-lock');
16
+ }
17
+ export function hostLockEnv(home, overrides = {}) {
18
+ return {
19
+ ...process.env,
20
+ HOME: home,
21
+ HOST_LOCK_ROOT: hostLockRoot(home),
22
+ ...overrides,
23
+ };
24
+ }
25
+ export function hostLockArgs(name, timeoutSeconds, command, extraArgs = []) {
26
+ return [
27
+ hostLockScript,
28
+ '--name',
29
+ name,
30
+ '--timeout-seconds',
31
+ String(timeoutSeconds),
32
+ ...extraArgs,
33
+ '--',
34
+ ...command,
35
+ ];
36
+ }
37
+ export function spawnHostLock(home, name, timeoutSeconds, command, env = {}, extraArgs = []) {
38
+ return spawn('bash', hostLockArgs(name, timeoutSeconds, command, extraArgs), {
39
+ env: hostLockEnv(home, env),
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ });
42
+ }
43
+ export function completion(child) {
44
+ return new Promise((resolve, reject) => {
45
+ let stderr = '';
46
+ child.stderr?.on('data', (chunk) => {
47
+ stderr += String(chunk);
48
+ });
49
+ child.once('error', reject);
50
+ child.once('close', (code) => resolve({ code, stderr }));
51
+ });
52
+ }
53
+ export async function waitForPath(path) {
54
+ const deadline = Date.now() + 5e3;
55
+ while (Date.now() < deadline) {
56
+ try {
57
+ await stat(path);
58
+ return;
59
+ }
60
+ catch {
61
+ await new Promise((resolve) => setTimeout(resolve, 20));
62
+ }
63
+ }
64
+ throw new Error(`Timed out waiting for ${path}`);
65
+ }
66
+ export async function cleanupTestHomes() {
67
+ await Promise.all(testHomes.splice(0).map((home) => rm(home, { force: true, recursive: true })));
68
+ }
69
+ export async function withFinalTimeoutProbe(home, assertProbe) {
70
+ const lock = join(home, '.cache/host-lock/expensive-build.lock.d');
71
+ const sleepBin = join(home, 'sleep-bin');
72
+ const acquisitionWaitMarker = join(home, 'acquisition-wait-started');
73
+ const acquisitionWaitRelease = join(home, 'acquisition-wait-release');
74
+ const marker = join(home, 'ran');
75
+ const acquireTimeoutS = 2;
76
+ const { stdout: realSleep } = await execFileAsync('which', ['sleep']);
77
+ const owner = spawn(realSleep.trim(), ['30'], { stdio: 'ignore' });
78
+ const ownerDone = completion(owner);
79
+ let contender;
80
+ let contenderDone;
81
+ try {
82
+ if (owner.pid == null)
83
+ throw new Error('Could not start final-timeout-probe owner.');
84
+ await mkdir(lock, { recursive: true });
85
+ await Promise.all([
86
+ writeFile(join(lock, 'owner.pid'), `${owner.pid}\n`),
87
+ writeFile(join(lock, 'owner.token'), 'live-owner\n'),
88
+ mkdir(sleepBin),
89
+ ]);
90
+ const sleepShim = join(sleepBin, 'sleep');
91
+ await writeFile(sleepShim, String.raw `#!/usr/bin/env bash
92
+ if [ ! -f "$HOST_LOCK_ACQUISITION_WAIT_RELEASE" ] && [ "$#" -eq 1 ] && [[ "$1" =~ ^[0-9]*[1-9][0-9]*$ ]]; then
93
+ : >"$HOST_LOCK_ACQUISITION_WAIT_MARKER"
94
+ while [ ! -f "$HOST_LOCK_ACQUISITION_WAIT_RELEASE" ]; do
95
+ "$HOST_LOCK_REAL_SLEEP" 0.02
96
+ done
97
+ fi
98
+ exec "$HOST_LOCK_REAL_SLEEP" "$@"
99
+ `, { mode: 0o755 });
100
+ contender = spawnHostLock(home, 'expensive-build', acquireTimeoutS, ['touch', marker], {
101
+ PATH: `${sleepBin}:${process.env.PATH ?? ''}`,
102
+ HOST_LOCK_REAL_SLEEP: realSleep.trim(),
103
+ HOST_LOCK_ACQUISITION_WAIT_MARKER: acquisitionWaitMarker,
104
+ HOST_LOCK_ACQUISITION_WAIT_RELEASE: acquisitionWaitRelease,
105
+ });
106
+ const activeContenderDone = completion(contender);
107
+ contenderDone = activeContenderDone;
108
+ await waitForPath(acquisitionWaitMarker);
109
+ owner.kill('SIGTERM');
110
+ await Promise.all([
111
+ ownerDone,
112
+ new Promise((resolve) => setTimeout(resolve, acquireTimeoutS * 1000 + 200)),
113
+ ]);
114
+ await writeFile(acquisitionWaitRelease, '');
115
+ const probe = { lock, marker, contenderDone: activeContenderDone };
116
+ await assertProbe(probe);
117
+ }
118
+ finally {
119
+ await writeFile(acquisitionWaitRelease, '').catch(() => undefined);
120
+ owner.kill('SIGKILL');
121
+ contender?.kill('SIGKILL');
122
+ await Promise.all([ownerDone, ...(contenderDone ? [contenderDone] : [])]);
123
+ }
124
+ }
@@ -0,0 +1,3 @@
1
+ export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mts';
2
+ export type { EphemeralListenerOptions, RunnerPortPolicy } from './runner-port-policy/index.mts';
3
+ export { extractAlterTableAddColumnLocations, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, } from './sql-ast/index.mts';
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mjs';
2
+ export { extractAlterTableAddColumnLocations, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, } from './sql-ast/index.mjs';
@@ -0,0 +1 @@
1
+ export declare function readPackageVersion(parsed: unknown): string;
@@ -0,0 +1,9 @@
1
+ export function readPackageVersion(parsed) {
2
+ if (typeof parsed !== 'object' || parsed === null || !('version' in parsed)) {
3
+ throw new Error('package.json is missing version');
4
+ }
5
+ const { version } = parsed;
6
+ if (typeof version !== 'string')
7
+ throw new Error('package.json version must be a string');
8
+ return version;
9
+ }
@@ -0,0 +1,25 @@
1
+ import type { Server } from 'node:net';
2
+ export interface RunnerPortPolicy {
3
+ reservedPortStart: number;
4
+ reservedPortEnd: number;
5
+ portsPerRunner: number;
6
+ minimumRunnerSlot: number;
7
+ maximumRunnerSlot: number;
8
+ }
9
+ type ListenOnHost = (server: Server, host: string) => Promise<void>;
10
+ export interface EphemeralListenerOptions {
11
+ isAllowedPort?: (port: number) => boolean;
12
+ listen?: ListenOnHost;
13
+ maxBindAttempts?: number;
14
+ policy?: RunnerPortPolicy;
15
+ }
16
+ export declare class EphemeralListenerAttemptsExhaustedError extends Error {
17
+ readonly maxBindAttempts: number;
18
+ constructor(maxBindAttempts: number);
19
+ }
20
+ export declare const runnerPortPolicy: RunnerPortPolicy;
21
+ export declare function isRunnerReservedPort(port: number, policy?: RunnerPortPolicy): boolean;
22
+ export declare function listenOnRunnerUnreservedEphemeralPort(server: Server, host: string, options?: EphemeralListenerOptions): Promise<number>;
23
+ export declare function loadRunnerPortPolicy(policyUrl?: URL): RunnerPortPolicy;
24
+ export declare function validateRunnerPortPolicy(parsedPolicy: unknown, policyUrl?: URL): RunnerPortPolicy;
25
+ export {};
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const shippedPolicyUrl = new URL('runner-port-policy.json', import.meta.url);
3
+ const MAX_EPHEMERAL_BIND_ATTEMPTS = 100;
4
+ export class EphemeralListenerAttemptsExhaustedError extends Error {
5
+ maxBindAttempts;
6
+ constructor(maxBindAttempts) {
7
+ super(`Failed to bind an allowed port outside the reserved runner range after ${maxBindAttempts} attempts`);
8
+ this.name = 'EphemeralListenerAttemptsExhaustedError';
9
+ this.maxBindAttempts = maxBindAttempts;
10
+ }
11
+ }
12
+ export const runnerPortPolicy = loadRunnerPortPolicy();
13
+ export function isRunnerReservedPort(port, policy = runnerPortPolicy) {
14
+ return port >= policy.reservedPortStart && port <= policy.reservedPortEnd;
15
+ }
16
+ export async function listenOnRunnerUnreservedEphemeralPort(server, host, options = {}) {
17
+ const policy = options.policy ?? runnerPortPolicy;
18
+ const maxBindAttempts = options.maxBindAttempts ?? MAX_EPHEMERAL_BIND_ATTEMPTS;
19
+ for (let attempt = 0; attempt < maxBindAttempts; attempt += 1) {
20
+ await (options.listen ?? listenOnHost)(server, host);
21
+ const port = getBoundPort(server);
22
+ if (!isRunnerReservedPort(port, policy) && (options.isAllowedPort?.(port) ?? true)) {
23
+ return port;
24
+ }
25
+ await closeServer(server);
26
+ }
27
+ throw new EphemeralListenerAttemptsExhaustedError(maxBindAttempts);
28
+ }
29
+ export function loadRunnerPortPolicy(policyUrl = shippedPolicyUrl) {
30
+ return validateRunnerPortPolicy(JSON.parse(readFileSync(policyUrl, 'utf8')), policyUrl);
31
+ }
32
+ export function validateRunnerPortPolicy(parsedPolicy, policyUrl) {
33
+ if (typeof parsedPolicy !== 'object' || parsedPolicy === null || Array.isArray(parsedPolicy)) {
34
+ throwInvalidPolicy(policyUrl);
35
+ }
36
+ const policy = parsedPolicy;
37
+ const requiredKeys = [
38
+ 'reservedPortStart',
39
+ 'reservedPortEnd',
40
+ 'portsPerRunner',
41
+ 'minimumRunnerSlot',
42
+ 'maximumRunnerSlot',
43
+ ];
44
+ if (Object.keys(policy).length !== requiredKeys.length ||
45
+ requiredKeys.some((key) => !Number.isInteger(policy[key]))) {
46
+ throwInvalidPolicy(policyUrl);
47
+ }
48
+ const validatedPolicy = policy;
49
+ const { reservedPortStart: start, reservedPortEnd: end, portsPerRunner, minimumRunnerSlot, maximumRunnerSlot, } = validatedPolicy;
50
+ if (start < 1 ||
51
+ end > 65_535 ||
52
+ start > end ||
53
+ portsPerRunner <= 0 ||
54
+ minimumRunnerSlot !== 1 ||
55
+ maximumRunnerSlot < minimumRunnerSlot ||
56
+ end - start + 1 !== portsPerRunner * maximumRunnerSlot) {
57
+ throwInvalidPolicy(policyUrl);
58
+ }
59
+ return Object.freeze(validatedPolicy);
60
+ }
61
+ function throwInvalidPolicy(policyUrl) {
62
+ const location = policyUrl?.pathname ?? 'runner-port-policy';
63
+ throw new Error(`Invalid CI runner port policy at ${location}`);
64
+ }
65
+ function listenOnHost(server, host) {
66
+ return new Promise((resolve, reject) => {
67
+ server.once('error', reject);
68
+ server.listen(0, host, () => {
69
+ server.off('error', reject);
70
+ resolve();
71
+ });
72
+ });
73
+ }
74
+ function getBoundPort(server) {
75
+ const address = server.address();
76
+ if (!address || typeof address === 'string') {
77
+ throw new Error('Failed to allocate listener port');
78
+ }
79
+ return address.port;
80
+ }
81
+ function closeServer(server) {
82
+ return new Promise((resolve, reject) => {
83
+ server.close((error) => {
84
+ if (error)
85
+ reject(error);
86
+ else
87
+ resolve();
88
+ });
89
+ });
90
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "reservedPortStart": 2200,
3
+ "reservedPortEnd": 2999,
4
+ "portsPerRunner": 16,
5
+ "minimumRunnerSlot": 1,
6
+ "maximumRunnerSlot": 50
7
+ }
@@ -0,0 +1,12 @@
1
+ type LibPgQuery = typeof import('@libpg-query/parser');
2
+ export declare class MissingSqlAstParserError extends Error {
3
+ constructor();
4
+ }
5
+ export declare function extractAlterTableAddColumnLocations(content: string): number[];
6
+ /**
7
+ * Ensures the @libpg-query/parser WASM module is loaded.
8
+ * Must be awaited once before any synchronous parseSync() calls.
9
+ */
10
+ export declare function initSqlAst(importer?: () => Promise<LibPgQuery>): Promise<void>;
11
+ export declare function lineOfUtf8ByteOffset(content: string | Buffer, byteOffset: number): number;
12
+ export {};
@@ -0,0 +1,79 @@
1
+ let parser;
2
+ let moduleLoad;
3
+ export class MissingSqlAstParserError extends Error {
4
+ constructor() {
5
+ super('vouchington-tooling/sql-ast requires the optional dependency @libpg-query/parser. Install it in the consuming package.');
6
+ this.name = 'MissingSqlAstParserError';
7
+ }
8
+ }
9
+ export function extractAlterTableAddColumnLocations(content) {
10
+ const locations = [];
11
+ const parseResult = requireParser().parseSync(content);
12
+ for (const rawStmt of parseResult.stmts ?? []) {
13
+ const node = rawStmt.stmt;
14
+ if (!node || !('AlterTableStmt' in node))
15
+ continue;
16
+ for (const rawCommand of node.AlterTableStmt.cmds ?? []) {
17
+ if (!rawCommand || !('AlterTableCmd' in rawCommand))
18
+ continue;
19
+ const command = rawCommand.AlterTableCmd;
20
+ if (command.subtype !== 'AT_AddColumn')
21
+ continue;
22
+ const definition = command.def;
23
+ if (!definition || !('ColumnDef' in definition))
24
+ continue;
25
+ locations.push(rawStmt.stmt_location ?? 0);
26
+ }
27
+ }
28
+ return locations;
29
+ }
30
+ /**
31
+ * Ensures the @libpg-query/parser WASM module is loaded.
32
+ * Must be awaited once before any synchronous parseSync() calls.
33
+ */
34
+ export function initSqlAst(importer = () => import('@libpg-query/parser')) {
35
+ return (moduleLoad ??= loadParser(importer)
36
+ .then(async (loaded) => {
37
+ await loaded.loadModule();
38
+ parser = loaded;
39
+ })
40
+ .catch((error) => {
41
+ moduleLoad = undefined;
42
+ parser = undefined;
43
+ throw error;
44
+ }));
45
+ }
46
+ export function lineOfUtf8ByteOffset(content, byteOffset) {
47
+ const buffer = typeof content === 'string' ? Buffer.from(content, 'utf8') : content;
48
+ if (byteOffset > buffer.length) {
49
+ throw new RangeError(`byteOffset ${byteOffset} is out of range for buffer length ${buffer.length}`);
50
+ }
51
+ let line = 1;
52
+ for (let i = 0; i < byteOffset; i++) {
53
+ if (buffer[i] === 10)
54
+ line++;
55
+ }
56
+ return line;
57
+ }
58
+ function requireParser() {
59
+ if (!parser) {
60
+ throw new Error('initSqlAst() must be awaited before calling parse helpers');
61
+ }
62
+ return parser;
63
+ }
64
+ async function loadParser(importer) {
65
+ try {
66
+ return await importer();
67
+ }
68
+ catch (error) {
69
+ if (isModuleNotFound(error))
70
+ throw new MissingSqlAstParserError();
71
+ throw error;
72
+ }
73
+ }
74
+ function isModuleNotFound(error) {
75
+ return (typeof error === 'object' &&
76
+ error !== null &&
77
+ 'code' in error &&
78
+ error.code === 'ERR_MODULE_NOT_FOUND');
79
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "vouchington-tooling",
3
+ "version": "0.0.0",
4
+ "description": "Vouchington CLI and extractable tooling libraries.",
5
+ "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/vouchington/vouchington-tooling/issues"
8
+ },
9
+ "license": "MIT",
10
+ "author": "Jonathan Ong",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/vouchington/vouchington-tooling.git",
14
+ "directory": "packages/vouchington-tooling"
15
+ },
16
+ "bin": {
17
+ "vouchington": "dist/cli/index.mjs"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "scripts",
22
+ "README.md"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.mjs",
26
+ "types": "./dist/index.d.mts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs",
31
+ "default": "./dist/index.mjs"
32
+ },
33
+ "./runner-port-policy": {
34
+ "types": "./dist/runner-port-policy/index.d.mts",
35
+ "import": "./dist/runner-port-policy/index.mjs",
36
+ "default": "./dist/runner-port-policy/index.mjs"
37
+ },
38
+ "./sql-ast": {
39
+ "types": "./dist/sql-ast/index.d.mts",
40
+ "import": "./dist/sql-ast/index.mjs",
41
+ "default": "./dist/sql-ast/index.mjs"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "node scripts/build.mjs",
50
+ "prepack": "pnpm run build",
51
+ "typecheck": "tsc --noEmit --project tsconfig.json"
52
+ },
53
+ "optionalDependencies": {
54
+ "@libpg-query/parser": "^17.6.10"
55
+ },
56
+ "engines": {
57
+ "node": ">=24.0.0"
58
+ }
59
+ }
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { chmod, copyFile, mkdir, rm } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const packageRoot = new URL('..', import.meta.url)
6
+ const dist = new URL('../dist', import.meta.url)
7
+
8
+ await rm(dist, { recursive: true, force: true })
9
+ execFileSync('tsc', ['--project', 'tsconfig.build.json'], {
10
+ stdio: 'inherit',
11
+ cwd: fileURLToPath(packageRoot),
12
+ })
13
+ await mkdir(new URL('../dist/runner-port-policy', import.meta.url), { recursive: true })
14
+ await copyFile(
15
+ new URL('../src/runner-port-policy/runner-port-policy.json', import.meta.url),
16
+ new URL('../dist/runner-port-policy/runner-port-policy.json', import.meta.url),
17
+ )
18
+ await chmod(new URL('../dist/cli/index.mjs', import.meta.url), 0o755)
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -u
4
+
5
+ usage() {
6
+ echo 'usage: with-host-lock.sh --name <family> [--slots <positive-int>] --timeout-seconds <positive-int> [--command-timeout-seconds <nonnegative-int>] [--failure-diagnostics <absolute-script-path>] [--on-acquire-timeout fail|run-unlocked] -- <command> [args...]' >&2
7
+ }
8
+
9
+ fail() {
10
+ echo "with-host-lock: $1" >&2
11
+ exit 2
12
+ }
13
+
14
+ [ -z "${HOST_LOCK_ACTIVE:-}" ] || fail 'nested host locks are not allowed'
15
+
16
+ name=''
17
+ slot_count=1
18
+ slots_explicit=0
19
+ timeout_seconds=''
20
+ command_timeout_seconds=0
21
+ failure_diagnostics=''
22
+ on_acquire_timeout=fail
23
+ process_group_drain_seconds=${HOST_LOCK_PROCESS_GROUP_DRAIN_SECONDS:-30}
24
+ lease_seconds=${HOST_LOCK_LEASE_SECONDS:-60}
25
+
26
+ case "$process_group_drain_seconds" in
27
+ '' | *[!0-9]*) fail 'HOST_LOCK_PROCESS_GROUP_DRAIN_SECONDS must be a nonnegative integer' ;;
28
+ esac
29
+
30
+ case "$lease_seconds" in
31
+ '' | *[!0-9]*) fail 'HOST_LOCK_LEASE_SECONDS must be a positive integer of at least 4' ;;
32
+ esac
33
+ [ "$lease_seconds" -ge 4 ] || fail 'HOST_LOCK_LEASE_SECONDS must be a positive integer of at least 4'
34
+ # Refresh at a quarter of the lease so three consecutive missed refreshes still
35
+ # precede expiry; the margin survives any change to the lease.
36
+ lease_refresh_seconds=$((lease_seconds / 4))
37
+
38
+ while [ "$#" -gt 0 ]; do
39
+ case "$1" in
40
+ --name)
41
+ [ "$#" -ge 2 ] || fail '--name requires a lock family name'
42
+ name=$2
43
+ shift 2
44
+ ;;
45
+ --slots)
46
+ [ "$#" -ge 2 ] || fail '--slots requires a positive integer'
47
+ slot_count=$2
48
+ slots_explicit=1
49
+ shift 2
50
+ ;;
51
+ --timeout-seconds)
52
+ [ "$#" -ge 2 ] || fail '--timeout-seconds requires a positive integer'
53
+ timeout_seconds=$2
54
+ shift 2
55
+ ;;
56
+ --command-timeout-seconds)
57
+ [ "$#" -ge 2 ] || fail '--command-timeout-seconds requires a nonnegative integer'
58
+ command_timeout_seconds=$2
59
+ shift 2
60
+ ;;
61
+ --failure-diagnostics)
62
+ [ "$#" -ge 2 ] || fail '--failure-diagnostics requires an absolute script path'
63
+ failure_diagnostics=$2
64
+ shift 2
65
+ ;;
66
+ --on-acquire-timeout)
67
+ [ "$#" -ge 2 ] || fail "--on-acquire-timeout requires 'fail' or 'run-unlocked'"
68
+ on_acquire_timeout=$2
69
+ shift 2
70
+ ;;
71
+ --)
72
+ shift
73
+ break
74
+ ;;
75
+ *)
76
+ usage
77
+ fail "unknown argument: $1"
78
+ ;;
79
+ esac
80
+ done
81
+
82
+ [ -n "$name" ] || {
83
+ usage
84
+ fail 'missing --name'
85
+ }
86
+ case "$name" in
87
+ *[!A-Za-z0-9._-]* | '' | [.-]*) fail "invalid lock family name: $name" ;;
88
+ esac
89
+
90
+ case "$slot_count" in
91
+ '' | *[!0-9]* | 0) fail '--slots must be a positive integer' ;;
92
+ esac
93
+
94
+ case "$timeout_seconds" in
95
+ '' | *[!0-9]* | 0) fail '--timeout-seconds must be a positive integer' ;;
96
+ esac
97
+
98
+ case "$command_timeout_seconds" in
99
+ '' | *[!0-9]*) fail '--command-timeout-seconds must be a nonnegative integer' ;;
100
+ esac
101
+
102
+ case "$failure_diagnostics" in
103
+ '') ;;
104
+ /*) ;;
105
+ *) fail '--failure-diagnostics must be an absolute script path' ;;
106
+ esac
107
+
108
+ case "$on_acquire_timeout" in
109
+ fail | run-unlocked) ;;
110
+ *) fail "--on-acquire-timeout must be 'fail' or 'run-unlocked'" ;;
111
+ esac
112
+
113
+ [ "$#" -gt 0 ] || {
114
+ usage
115
+ fail 'missing command after --'
116
+ }
117
+
118
+ run_failure_diagnostics() {
119
+ [ -n "$failure_diagnostics" ] || return 0
120
+ bash "$failure_diagnostics" || true
121
+ }
122
+
123
+ lock_root=${HOST_LOCK_ROOT:-/tmp/host-lock-$UID}
124
+ case "$lock_root" in
125
+ /*) ;;
126
+ *) fail 'HOST_LOCK_ROOT must be an absolute path' ;;
127
+ esac
128
+ selected_name=$name
129
+ lock_dir=''
130
+ reap_dir=''
131
+
132
+ select_slot() {
133
+ slot_number=$1
134
+ if [ "$slots_explicit" -eq 0 ]; then
135
+ selected_name=$name
136
+ else
137
+ selected_name=$name-slot-$slot_number
138
+ fi
139
+ lock_dir=$lock_root/$selected_name.lock.d
140
+ reap_dir=$lock_root/$selected_name.lock.reap.d
141
+ }
142
+
143
+ [ ! -L "$lock_root" ] || fail "lock root must not be a symbolic link: $lock_root"
144
+ old_umask=$(umask)
145
+ umask 077
146
+ mkdir -p "$lock_root" || fail "cannot create lock root: $lock_root"
147
+ umask "$old_umask"
148
+ [ ! -L "$lock_root" ] || fail "lock root must not be a symbolic link: $lock_root"
149
+ lock_root_uid=$(stat -f '%u' "$lock_root" 2>/dev/null) ||
150
+ lock_root_uid=$(stat -c '%u' "$lock_root" 2>/dev/null) ||
151
+ fail "cannot inspect lock root ownership: $lock_root"
152
+ [ "$lock_root_uid" = "$UID" ] || fail "lock root is not owned by uid $UID: $lock_root"
153
+ chmod 700 "$lock_root" || fail "cannot make lock root private: $lock_root"
154
+
155
+ process_is_live() {
156
+ candidate_pid=$1
157
+ candidate_kill_error=$(kill -0 "$candidate_pid" 2>&1) && return 0
158
+ case "$candidate_kill_error" in
159
+ *[Oo]peration*not*permitted* | *[Pp]ermission*denied*) return 0 ;;
160
+ esac
161
+ ps -p "$candidate_pid" -o pid= 2>/dev/null | awk 'NF { found = 1 } END { exit !found }'
162
+ }
163
+
164
+ process_group_is_live() {
165
+ candidate_pgid=$1
166
+ candidate_kill_error=$(kill -0 -- "-$candidate_pgid" 2>&1) && return 0
167
+ case "$candidate_kill_error" in
168
+ *[Oo]peration*not*permitted* | *[Pp]ermission*denied*) return 0 ;;
169
+ esac
170
+ ps -e -o pgid= 2>/dev/null |
171
+ awk -v candidate_pgid="$candidate_pgid" '$1 == candidate_pgid { found = 1; exit } END { exit !found }'
172
+ }
173
+
174
+ directory_mtime() {
175
+ directory=$1
176
+ mtime=$(stat -f '%m' "$directory" 2>/dev/null) ||
177
+ mtime=$(stat -c '%Y' "$directory" 2>/dev/null) || return 1
178
+ case "$mtime" in
179
+ '' | *[!0-9]*) return 1 ;;
180
+ esac
181
+ printf '%s\n' "$mtime"
182
+ }
183
+
184
+ owned_directory_is_stale() {
185
+ owned_directory=$1
186
+ ownerless_grace_seconds=$2
187
+ valid_owner_metadata=0
188
+ if [ -f "$owned_directory/owner.pid" ] || [ -f "$owned_directory/owner.pgid" ]; then
189
+ recorded_pid=$(sed -n '1p' "$owned_directory/owner.pid" 2>/dev/null || true)
190
+ case "$recorded_pid" in
191
+ '' | *[!0-9]*) ;;
192
+ *)
193
+ valid_owner_metadata=1
194
+ process_is_live "$recorded_pid" && return 1
195
+ ;;
196
+ esac
197
+ recorded_pgid=$(sed -n '1p' "$owned_directory/owner.pgid" 2>/dev/null || true)
198
+ case "$recorded_pgid" in
199
+ '' | *[!0-9]*) ;;
200
+ *)
201
+ valid_owner_metadata=1
202
+ process_group_is_live "$recorded_pgid" && return 1
203
+ ;;
204
+ esac
205
+ [ "$valid_owner_metadata" -eq 0 ] || return 0
206
+ fi
207
+
208
+ mtime=$(directory_mtime "$owned_directory") || return 1
209
+ now=$(date +%s)
210
+ [ $((now - mtime)) -gt "$ownerless_grace_seconds" ]
211
+ }
212
+
213
+ reaper_owner_pid=''
214
+ reaper_owner_token=''
215
+
216
+ owns_reaper() {
217
+ current_reaper_pid=$(sed -n '1p' "$reap_dir/owner.pid" 2>/dev/null || true)
218
+ current_reaper_token=$(sed -n '1p' "$reap_dir/owner.token" 2>/dev/null || true)
219
+ [ "$current_reaper_pid" = "$reaper_owner_pid" ] &&
220
+ [ "$current_reaper_token" = "$reaper_owner_token" ]
221
+ }
222
+
223
+ cleanup_reaper() {
224
+ if [ -n "$reaper_owner_token" ] && owns_reaper; then
225
+ rm -rf "$reap_dir"
226
+ fi
227
+ reaper_owner_pid=''
228
+ reaper_owner_token=''
229
+ }
230
+
231
+ recover_stale_reaper() {
232
+ [ -d "$reap_dir" ] || return 0
233
+ stale_reaper_pid=$(sed -n '1p' "$reap_dir/owner.pid" 2>/dev/null || true)
234
+ stale_reaper_token=$(sed -n '1p' "$reap_dir/owner.token" 2>/dev/null || true)
235
+ owned_directory_is_stale "$reap_dir" 10 || return 1
236
+
237
+ current_reaper_pid=$(sed -n '1p' "$reap_dir/owner.pid" 2>/dev/null || true)
238
+ current_reaper_token=$(sed -n '1p' "$reap_dir/owner.token" 2>/dev/null || true)
239
+ if [ "$current_reaper_pid" = "$stale_reaper_pid" ] &&
240
+ [ "$current_reaper_token" = "$stale_reaper_token" ]; then
241
+ rm -rf "$reap_dir"
242
+ fi
243
+ }
244
+
245
+ # Owner liveness may only accelerate reclamation, never delay it past the lease:
246
+ # owned_directory_is_stale reclaims a provably-dead or metadata-less owner
247
+ # immediately, and the mtime check below is an unconditional ceiling regardless
248
+ # of what the owner metadata claims. This is what recovers a crash that killed
249
+ # the wrapper between `mkdir "$lock_dir"` and the owner.pid/owner.token writes
250
+ # (dead-but-valid metadata reclaims immediately; ownerless metadata otherwise
251
+ # would not) and a live-but-stale holder whose PID has been reused by the OS.
252
+ lock_is_reclaimable() {
253
+ owned_directory_is_stale "$lock_dir" "$lease_seconds" && return 0
254
+ mtime=$(directory_mtime "$lock_dir") || return 1
255
+ [ $(($(date +%s) - mtime)) -gt "$lease_seconds" ]
256
+ }
257
+
258
+ reclaim_stale_lock() {
259
+ recover_stale_reaper || true
260
+ mkdir "$reap_dir" 2>/dev/null || return 1
261
+ reaper_owner_pid=$$
262
+ reaper_owner_token="reaper-$$-$(date +%s)-$RANDOM"
263
+ printf '%s\n' "$reaper_owner_pid" >"$reap_dir/owner.pid"
264
+ printf '%s\n' "$reaper_owner_token" >"$reap_dir/owner.token"
265
+
266
+ if [ -d "$lock_dir" ] && lock_is_reclaimable && owns_reaper; then
267
+ rm -rf "$lock_dir"
268
+ fi
269
+ cleanup_reaper
270
+ }
271
+
272
+ try_acquire_selected_slot() {
273
+ [ ! -d "$reap_dir" ] || return 1
274
+ mkdir "$lock_dir" 2>/dev/null || return 1
275
+ acquired=1
276
+ }
277
+
278
+ report_acquire_timeout_owners() {
279
+ waited_seconds=$(($(date +%s) - start_time))
280
+ slot_number=1
281
+ while [ "$slot_number" -le "$slot_count" ]; do
282
+ select_slot "$slot_number"
283
+ recorded_pid=$(sed -n '1p' "$lock_dir/owner.pid" 2>/dev/null || true)
284
+ recorded_pgid=$(sed -n '1p' "$lock_dir/owner.pgid" 2>/dev/null || true)
285
+ case "$recorded_pid" in
286
+ '' | *[!0-9]*) recorded_pid=missing ;;
287
+ esac
288
+ case "$recorded_pgid" in
289
+ '' | *[!0-9]*) recorded_pgid=missing ;;
290
+ esac
291
+ echo "with-host-lock: $name waited ${waited_seconds}s; $selected_name owner pid=$recorded_pid pgid=$recorded_pgid" >&2
292
+ slot_number=$((slot_number + 1))
293
+ done
294
+ }
295
+
296
+ start_time=$(date +%s)
297
+ deadline=$((start_time + timeout_seconds))
298
+ acquired=0
299
+
300
+ while :; do
301
+ slot_number=1
302
+ while [ "$slot_number" -le "$slot_count" ]; do
303
+ select_slot "$slot_number"
304
+ try_acquire_selected_slot && break
305
+ reclaim_stale_lock || true
306
+ try_acquire_selected_slot && break
307
+ slot_number=$((slot_number + 1))
308
+ done
309
+ [ "$acquired" -eq 0 ] || break
310
+
311
+ now=$(date +%s)
312
+ if [ "$now" -ge "$deadline" ]; then
313
+ if [ "$on_acquire_timeout" = 'run-unlocked' ]; then
314
+ echo "with-host-lock: $name lock not acquired within ${timeout_seconds}s; running unlocked" >&2
315
+ report_acquire_timeout_owners
316
+ break
317
+ fi
318
+ echo "with-host-lock: $name lock not acquired within ${timeout_seconds}s" >&2
319
+ report_acquire_timeout_owners
320
+ run_failure_diagnostics
321
+ exit 1
322
+ fi
323
+ remaining_seconds=$((deadline - now))
324
+ sleep_seconds=2
325
+ if [ "$remaining_seconds" -lt "$sleep_seconds" ]; then
326
+ sleep_seconds=$remaining_seconds
327
+ fi
328
+ [ "$sleep_seconds" -gt 0 ] && sleep "$sleep_seconds"
329
+ done
330
+
331
+ owner_pid=$$
332
+ owner_token=''
333
+ temporary_start_release=''
334
+ if [ "$acquired" -eq 1 ]; then
335
+ owner_token="$$-$(date +%s)-$RANDOM"
336
+ printf '%s\n' "$owner_pid" >"$lock_dir/owner.pid"
337
+ printf '%s\n' "$owner_token" >"$lock_dir/owner.token"
338
+ waited_seconds=$(($(date +%s) - start_time))
339
+ echo "with-host-lock: $name acquired after ${waited_seconds}s" >&2
340
+ fi
341
+
342
+ cleanup_lock() {
343
+ stop_lease_heartbeat
344
+ if [ -n "$temporary_start_release" ]; then
345
+ rm -f "$temporary_start_release" 2>/dev/null || true
346
+ temporary_start_release=''
347
+ fi
348
+ [ "$acquired" -eq 1 ] || return 0
349
+ current_pid=$(sed -n '1p' "$lock_dir/owner.pid" 2>/dev/null || true)
350
+ current_token=$(sed -n '1p' "$lock_dir/owner.token" 2>/dev/null || true)
351
+ if [ "$current_pid" = "$owner_pid" ] && [ "$current_token" = "$owner_token" ]; then
352
+ rm -rf "$lock_dir"
353
+ fi
354
+ acquired=0
355
+ }
356
+
357
+ child_pid=''
358
+ signal_forwarded=0
359
+ pending_signal=''
360
+ watchdog_pid=''
361
+ command_timeout_marker=''
362
+ lease_heartbeat_pid=''
363
+
364
+ # shellcheck disable=SC2329 # Invoked by the INT and TERM traps below.
365
+ forward_signal() {
366
+ pending_signal=$1
367
+ if [ -n "$child_pid" ]; then
368
+ kill -s "$pending_signal" -- "-$child_pid" 2>/dev/null || true
369
+ fi
370
+ }
371
+
372
+ wait_for_process_group_until() {
373
+ deadline=$1
374
+ while process_group_is_live "$child_pid"; do
375
+ [ "$(date +%s)" -lt "$deadline" ] || return 1
376
+ sleep 0.1
377
+ done
378
+ }
379
+
380
+ # Escalates TERM then KILL against the command's process group, giving it up to
381
+ # grace_seconds to drain at each step. Shared by the post-command drain below and
382
+ # by the command-timeout watchdog, so there is exactly one kill/verify sequence.
383
+ terminate_process_group() {
384
+ grace_seconds=$1
385
+ kill -s TERM -- "-$child_pid" 2>/dev/null || true
386
+ term_deadline=$(($(date +%s) + grace_seconds))
387
+ wait_for_process_group_until "$term_deadline" && return 0
388
+ kill -s KILL -- "-$child_pid" 2>/dev/null || true
389
+ kill_deadline=$(($(date +%s) + grace_seconds))
390
+ wait_for_process_group_until "$kill_deadline"
391
+ }
392
+
393
+ start_command_timeout_watchdog() {
394
+ [ "$command_timeout_seconds" -gt 0 ] || return 0
395
+ # Create a unique marker filename without leaving a file behind — the
396
+ # watchdog recreates it via : > only when the timeout actually fires.
397
+ command_timeout_marker=$(mktemp "${TMPDIR:-/tmp}/with-host-lock-timeout.XXXXXX") ||
398
+ fail 'cannot create command-timeout marker file'
399
+ rm -f "$command_timeout_marker"
400
+ set -m
401
+ (
402
+ sleep "$command_timeout_seconds"
403
+ if kill -0 "$child_pid" 2>/dev/null; then
404
+ : >"$command_timeout_marker"
405
+ echo "with-host-lock: $name command exceeded ${command_timeout_seconds}s; terminating its process group" >&2
406
+ terminate_process_group 5 || true
407
+ fi
408
+ ) &
409
+ watchdog_pid=$!
410
+ set +m
411
+ }
412
+
413
+ stop_command_timeout_watchdog() {
414
+ [ -n "$watchdog_pid" ] || return 0
415
+ kill -s KILL -- "-$watchdog_pid" 2>/dev/null || true
416
+ wait "$watchdog_pid" 2>/dev/null || true
417
+ watchdog_pid=''
418
+ }
419
+
420
+ # Keeps the held lock directory's mtime within the reclamation lease while this
421
+ # wrapper owns it. Re-reads the lock metadata every tick (rather than trusting a
422
+ # snapshot) so it stops touching as soon as the lock is reclaimed, handed to a
423
+ # replacement owner, or its recorded owner dies — an orphaned heartbeat must let
424
+ # the lease lapse, not hold it open forever.
425
+ #
426
+ # Closes its own stdout/stderr instead of inheriting the wrapper's: a SIGKILL of
427
+ # the wrapper cannot run the EXIT trap that would otherwise stop this subshell,
428
+ # so it can outlive the wrapper as an orphan. An orphan that still held the
429
+ # wrapper's piped stdout/stderr open would wedge any caller waiting on those
430
+ # pipes to reach EOF (e.g. a `child_process` "close" event) until the command it
431
+ # is heartbeating for eventually exits too.
432
+ start_lease_heartbeat() {
433
+ [ "$acquired" -eq 1 ] || return 0
434
+ set -m
435
+ (
436
+ while sleep "$lease_refresh_seconds"; do
437
+ current_token=$(sed -n '1p' "$lock_dir/owner.token" 2>/dev/null || true)
438
+ [ "$current_token" = "$owner_token" ] || exit 0
439
+ current_pid=$(sed -n '1p' "$lock_dir/owner.pid" 2>/dev/null || true)
440
+ current_pgid=$(sed -n '1p' "$lock_dir/owner.pgid" 2>/dev/null || true)
441
+ owner_live=0
442
+ case "$current_pid" in
443
+ '' | *[!0-9]*) ;;
444
+ *) process_is_live "$current_pid" && owner_live=1 ;;
445
+ esac
446
+ case "$current_pgid" in
447
+ '' | *[!0-9]*) ;;
448
+ *) process_group_is_live "$current_pgid" && owner_live=1 ;;
449
+ esac
450
+ [ "$owner_live" -eq 1 ] || exit 0
451
+ touch "$lock_dir" 2>/dev/null || exit 0
452
+ done
453
+ ) </dev/null >/dev/null 2>&1 &
454
+ lease_heartbeat_pid=$!
455
+ set +m
456
+ }
457
+
458
+ stop_lease_heartbeat() {
459
+ [ -n "$lease_heartbeat_pid" ] || return 0
460
+ kill -s KILL -- "-$lease_heartbeat_pid" 2>/dev/null || true
461
+ wait "$lease_heartbeat_pid" 2>/dev/null || true
462
+ lease_heartbeat_pid=''
463
+ }
464
+
465
+ trap cleanup_lock EXIT
466
+ trap 'signal_forwarded=1; forward_signal INT' INT
467
+ trap 'signal_forwarded=1; forward_signal TERM' TERM
468
+
469
+ if [ "$acquired" -eq 1 ]; then
470
+ start_release=$lock_dir/start.release
471
+ else
472
+ start_release=$(mktemp "${TMPDIR:-/tmp}/with-host-lock-release.XXXXXX") ||
473
+ fail 'cannot create release marker file'
474
+ temporary_start_release=$start_release
475
+ fi
476
+
477
+ export HOST_LOCK_ACTIVE=1
478
+
479
+ # Job control keeps an asynchronous command's SIGINT disposition intact. Without
480
+ # it, non-interactive Bash starts background commands with SIGINT ignored.
481
+ set -m
482
+ bash -c '
483
+ wrapper_pid=$1
484
+ start_release=$2
485
+ shift 2
486
+ while [ ! -f "$start_release" ]; do
487
+ kill -0 "$wrapper_pid" 2>/dev/null || exit 1
488
+ sleep 0.05
489
+ done
490
+ exec "$@"
491
+ ' with-host-lock-child "$$" "$start_release" "$@" &
492
+ child_pid=$!
493
+ if [ "$acquired" -eq 1 ]; then
494
+ owner_pid=$child_pid
495
+ printf '%s\n' "$owner_pid" >"$lock_dir/owner.pid"
496
+ printf '%s\n' "$child_pid" >"$lock_dir/owner.pgid"
497
+ fi
498
+ : >"$start_release"
499
+ if [ -n "$pending_signal" ]; then
500
+ kill -s "$pending_signal" -- "-$child_pid" 2>/dev/null || true
501
+ fi
502
+ set +m
503
+
504
+ start_command_timeout_watchdog
505
+ start_lease_heartbeat
506
+
507
+ while :; do
508
+ signal_forwarded=0
509
+ wait "$child_pid"
510
+ status=$?
511
+ if [ "$signal_forwarded" -eq 0 ] || ! kill -0 "$child_pid" 2>/dev/null; then
512
+ break
513
+ fi
514
+ done
515
+
516
+ stop_command_timeout_watchdog
517
+
518
+ process_group_drain_deadline=$(($(date +%s) + process_group_drain_seconds))
519
+ if ! wait_for_process_group_until "$process_group_drain_deadline"; then
520
+ echo "with-host-lock: $name command left processes running; terminating its process group" >&2
521
+ if ! terminate_process_group 5; then
522
+ echo "with-host-lock: $name process group survived SIGKILL; retaining lock ownership" >&2
523
+ acquired=0
524
+ exit 1
525
+ fi
526
+ fi
527
+
528
+ if [ -n "$command_timeout_marker" ] && [ -f "$command_timeout_marker" ]; then
529
+ status=124
530
+ fi
531
+ if [ "$status" -ne 0 ]; then
532
+ run_failure_diagnostics
533
+ fi
534
+ [ -z "$command_timeout_marker" ] || rm -f "$command_timeout_marker" 2>/dev/null || true
535
+ cleanup_lock
536
+ trap - EXIT INT TERM
537
+ exit "$status"