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 +38 -0
- package/dist/cli/commands/runner-port-policy.d.mts +4 -0
- package/dist/cli/commands/runner-port-policy.mjs +11 -0
- package/dist/cli/commands/with-host-lock.d.mts +6 -0
- package/dist/cli/commands/with-host-lock.mjs +14 -0
- package/dist/cli/index.d.mts +3 -0
- package/dist/cli/index.mjs +37 -0
- package/dist/cli/parse.d.mts +16 -0
- package/dist/cli/parse.mjs +47 -0
- package/dist/cli/usage.d.mts +2 -0
- package/dist/cli/usage.mjs +27 -0
- package/dist/host-lock/with-host-lock.test-helpers.d.mts +21 -0
- package/dist/host-lock/with-host-lock.test-helpers.mjs +124 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +2 -0
- package/dist/package-version.d.mts +1 -0
- package/dist/package-version.mjs +9 -0
- package/dist/runner-port-policy/index.d.mts +25 -0
- package/dist/runner-port-policy/index.mjs +90 -0
- package/dist/runner-port-policy/runner-port-policy.json +7 -0
- package/dist/sql-ast/index.d.mts +12 -0
- package/dist/sql-ast/index.mjs +79 -0
- package/package.json +59 -0
- package/scripts/build.mjs +18 -0
- package/scripts/host-lock/with-host-lock.sh +537 -0
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,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,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
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -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,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"
|