Merge origin/main into gsta-96-dedupe (GStack Minder rule-14 exception for #3453: CI does not run on the conflicted head; CHANGELOG resolved additively)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 05:25:56 +00:00
commit 517b605692
59 files changed
+1158 -171

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
/**
* #3415: gstack's shell bins start Bun with
* --config="${GSTACK_BUN_CONFIG:-/dev/null}", and each defines
* GSTACK_BUN_CONFIG as NUL when OSTYPE is msys or cygwin and cygpath is on PATH
* (Git Bash, MSYS2, Cygwin). Native bun.exe cannot
* open /dev/null (EINVAL), so a Windows bash with MSYS path conversion off
* (MSYS_NO_PATHCONV=1, MSYS2_ARG_CONV_EXCL=*) made the literal spelling fail:
* the record bins wrote nothing, and gstack-timeline-log still exited 0.
*
* On every platform the free lanes run, windows-latest included, the record
* bins run through lib/script-invocation.ts with conversion off in a hostile
* repository: each writes its record, and the repository's bunfig.toml preload
* and .env never load. On POSIX a simulated Windows bash (OSTYPE=msys, a
* cygpath, a bun that rejects /dev/null the way bun.exe does) reproduces the
* bug; its controls, without OSTYPE=msys, show each bin fails loudly with
* nothing written instead of exiting 0. An OSTYPE=msys inherited by a POSIX
* bash with no cygpath keeps /dev/null, so a file named NUL in the repository
* is never read as Bun's config.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runScriptSync } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
const IS_WINDOWS = process.platform === 'win32';
let tmp = '';
let repo = '';
let stateRoot = '';
let evilRoot = '';
let preloadMarker = '';
let fakeBun = '';
let crashBun = '';
const BINS: Array<{ bin: string; input: string; log: string }> = [
{ bin: 'gstack-review-log', log: 'main-reviews.jsonl', input: JSON.stringify({ skill: 'plan-eng-review', timestamp: '2026-10-25T00:00:00Z', status: 'clean', unresolved: 0, critical_gaps: 0, issues_found: 0, mode: 'FULL_REVIEW', commit: 'abc1234' }) },
{ bin: 'gstack-question-log', log: 'question-log.jsonl', input: JSON.stringify({ skill: 'ship', question_id: 'ship-test-failure-triage', question_summary: 'Tests failed', options_count: 3, user_choice: 'fix-now', recommended: 'fix-now', session_id: 's1' }) },
{ bin: 'gstack-learnings-log', log: 'learnings.jsonl', input: JSON.stringify({ skill: 'review', type: 'pitfall', key: 'n-plus-one', insight: 'fixture insight', confidence: 8, source: 'observed' }) },
{ bin: 'gstack-timeline-log', log: 'timeline.jsonl', input: JSON.stringify({ skill: 'review', event: 'started', branch: 'main' }) },
];
function env(pathPrefix?: string, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) if (!/^(GSTACK_|MSYS|OSTYPE$)/.test(k)) out[k] = v;
const PATH = Object.keys(out).find(k => k.toUpperCase() === 'PATH') ?? 'PATH';
if (pathPrefix) out[PATH] = pathPrefix + path.delimiter + (out[PATH] ?? '');
return { ...out, HOME: path.join(tmp, 'home'), GSTACK_STATE_ROOT: stateRoot, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*', ...extra };
}
function run(bin: string, input: string, pathPrefix?: string, extra: Record<string, string> = {}, rest: string[] = []) {
return runScriptSync(path.join(ROOT, 'bin', bin), [input, ...rest], { cwd: repo, env: env(pathPrefix, extra), encoding: 'utf8', timeout: 60_000 });
}
function records(log: string): string[] {
const projects = path.join(stateRoot, 'projects');
if (!fs.existsSync(projects)) return [];
return fs.readdirSync(projects).flatMap(slug => {
const file = path.join(projects, slug, log);
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : [];
});
}
function resetState(): void {
fs.rmSync(stateRoot, { recursive: true, force: true });
fs.rmSync(preloadMarker, { force: true });
fs.mkdirSync(stateRoot, { recursive: true });
}
function writeExec(file: string, text: string): void {
fs.writeFileSync(file, text);
fs.chmodSync(file, 0o755);
}
beforeAll(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bunconfig-')));
repo = path.join(tmp, 'repo');
stateRoot = path.join(tmp, 'state');
evilRoot = path.join(tmp, 'evil');
preloadMarker = path.join(tmp, 'preload-ran');
for (const d of [repo, stateRoot, path.join(tmp, 'home')]) fs.mkdirSync(d, { recursive: true });
for (const args of [['init', '-q', '-b', 'main'], ['-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']]) {
expect(spawnSync('git', args, { cwd: repo, timeout: 30_000 }).status).toBe(0);
}
fs.writeFileSync(path.join(repo, '.env'), `GSTACK_STATE_ROOT=${evilRoot}\nGSTACK_HOME=${evilRoot}\n`);
fs.writeFileSync(path.join(repo, 'preload.ts'), `require('fs').writeFileSync(${JSON.stringify(preloadMarker)}, 'ran');\n`);
fs.writeFileSync(path.join(repo, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
if (IS_WINDOWS) return;
fakeBun = path.join(tmp, 'fake-bun');
fs.mkdirSync(fakeBun);
writeExec(path.join(fakeBun, 'bun'), [
'#!/usr/bin/env bash',
'a=()',
'for x in "$@"; do',
' case "$x" in',
' --config=/dev/null) echo "EINVAL: Invalid argument (open())" >&2; echo "while reading config \\"/dev/null\\"" >&2; exit 1 ;;',
' --config=NUL) a+=(--config=/dev/null) ;;',
' *) a+=("$x") ;;',
' esac',
'done',
`exec ${JSON.stringify(process.execPath)} "\${a[@]}"`,
'',
].join('\n'));
crashBun = path.join(tmp, 'crash-bun');
fs.mkdirSync(crashBun);
writeExec(path.join(crashBun, 'bun'), '#!/usr/bin/env bash\nexit 3\n');
writeExec(path.join(fakeBun, 'cygpath'), '#!/usr/bin/env bash\nprintf \'%s\\n\' "${@: -1}"\n');
});
afterAll(() => { if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); });
describe('#3415: record bins start Bun on a Windows bash with path conversion off', () => {
test('fixture control: a plain bun in the repository runs its bunfig preload', () => {
const r = spawnSync(process.execPath, ['-e', '0'], { cwd: repo, encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(fs.existsSync(preloadMarker)).toBe(true);
fs.rmSync(preloadMarker);
});
for (const { bin, input, log } of BINS) {
test(`${bin} writes its record and ignores the repository bunfig.toml and .env`, () => {
resetState();
const r = run(bin, input);
expect(r.stderr).not.toContain('EINVAL');
expect(r.status).toBe(0);
expect(records(log).length).toBe(1);
expect(fs.existsSync(preloadMarker)).toBe(false);
expect(fs.existsSync(evilRoot)).toBe(false);
});
}
test('gstack-timeline-log still skips invalid input silently with exit 0', () => {
resetState();
for (const bad of ['not json', '{"skill":"review"}', 'null']) {
const r = run('gstack-timeline-log', bad);
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
}
expect(records('timeline.jsonl')).toEqual([]);
});
});
describe.skipIf(IS_WINDOWS)('#3415: simulated Windows bash (POSIX hosts)', () => {
test('gstack-developer-profile --log-session reports a Bun failure instead of skipping it, and still skips invalid input silently', () => {
resetState();
const session = JSON.stringify({ date: '2026-10-25', mode: 'startup' });
const failed = run('gstack-developer-profile', '--log-session', fakeBun, {}, [session]);
expect(failed.stderr).toContain('EINVAL');
expect(failed.stderr).toContain('nothing was logged');
expect(failed.status).not.toBe(0);
for (const bad of ['not json', '{"date":"2026-10-25"}', 'null']) {
const r = run('gstack-developer-profile', '--log-session', undefined, {}, [bad]);
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
}
});
test('a Bun that dies with exit 3 (how a Bun crash exits on Windows) is reported, never read as invalid input', () => {
resetState();
const timeline = run('gstack-timeline-log', JSON.stringify({ skill: 'review', event: 'started' }), crashBun);
expect(timeline.status).not.toBe(0);
expect(timeline.stderr).toContain('bun exited 3');
expect(records('timeline.jsonl')).toEqual([]);
const profile = run('gstack-developer-profile', '--log-session', crashBun, {}, [JSON.stringify({ date: '2026-10-25', mode: 'startup' })]);
expect(profile.status).not.toBe(0);
expect(profile.stderr).toContain('bun exited 3');
});
for (const { bin, input, log } of BINS) {
test(`${bin} passes NUL and writes its record`, () => {
resetState();
const r = run(bin, input, fakeBun, { OSTYPE: 'msys' });
expect(r.stderr).not.toContain('EINVAL');
expect(r.status).toBe(0);
expect(records(log).length).toBe(1);
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test(`an inherited OSTYPE=msys without cygpath keeps /dev/null: ${bin} never reads a repository file named NUL`, () => {
resetState();
const nul = path.join(repo, 'NUL');
fs.writeFileSync(nul, 'preload = ["./preload.ts"]\n');
try {
const r = run(bin, input, undefined, { OSTYPE: 'msys' });
expect(r.status).toBe(0);
expect(records(log).length).toBe(1);
expect(fs.existsSync(preloadMarker)).toBe(false);
} finally {
fs.rmSync(nul, { force: true });
fs.rmSync(preloadMarker, { force: true });
}
});
test(`control: when Bun rejects the config, ${bin} fails loudly and writes nothing`, () => {
resetState();
const r = run(bin, input, fakeBun);
expect(r.stderr).toContain('EINVAL');
expect(r.status).not.toBe(0);
expect(records(log)).toEqual([]);
});
}
});
+60 -8
View File
@@ -6,9 +6,11 @@
* kinds are held to the isolated form:
* 1. bun shebangs: exactly ISOLATED_SHEBANG (`bun run <file>` ignores the
* shebang, so callers carry the flags too);
* 2. shell callers in shipped shell files, skill templates, generated skill
* docs and resolvers: `bun --no-env-file --config=/dev/null` before
* `run`, `-e`/`-p` or a script path;
* 2. shell callers: skill templates, generated skill docs and resolvers
* spell `bun --no-env-file --config=/dev/null` before `run`, `-e`/`-p`
* or a script path; shipped shell files spell SHELL_FLAGS after
* CONFIG_DEF, so a Windows bash with MSYS path conversion off passes
* NUL instead of a /dev/null native Bun cannot open (#3415);
* 3. TypeScript spawns of a Bun: bunIsolationArgs() (or browse's
* bunChildFlags()) in the argument list;
* 4. scriptInvocation, which launches gstack's own bun bins;
@@ -27,6 +29,8 @@ import { emptyBunConfig as browseEmptyBunConfig } from '../browse/src/bun-child-
const ROOT = path.resolve(import.meta.dir, '..');
const FLAGS = '--no-env-file --config=/dev/null';
const ISOLATED_SHEBANG = `#!/usr/bin/env -S bun ${FLAGS}`;
const SHELL_FLAGS = '--no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"';
const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac';
const TRACKED = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.split('\0').filter(Boolean)
.filter(f => !/(^|\/)test\//.test(f) && fs.existsSync(path.join(ROOT, f)) && fs.statSync(path.join(ROOT, f)).isFile());
@@ -43,7 +47,7 @@ const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?BUN(?:_[A-Z]+)?\}?"|\$\{?BUN(?
/** Whether the text after a `bun` word starts a program: -e/-p, a script path, `run <var>` or a bin path. A package script (`bun run build`) is not one. */
function runsCode(rest: string): boolean {
const r = rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+)\s+)*/, '');
const r = rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+|config(?:=\S*|\s+\S+))\s+)*/, '');
return /^(?:run\s+)?(?:-e|-p|--eval|--print)(?:\s|=|$)/.test(r)
|| /^(?:run\s+)?["']?[^\s"'|;&)]*\.(?:ts|js|mjs|cjs|tsx)(?:["'\s)|;&\\`]|$)/.test(r)
|| /^run\s+["']?\$\{?[A-Za-z_]/.test(r)
@@ -51,14 +55,14 @@ function runsCode(rest: string): boolean {
}
/** Bun invocations on one shell line that start a program without the isolation flags. */
function unisolatedBunCalls(line: string): string[] {
function unisolatedBunCalls(line: string, flags = FLAGS): string[] {
if (/^\s*(#|\/\/|\*)/.test(line)) return [];
const out: string[] = [];
for (const m of line.matchAll(BUN_WORD)) {
const at = m.index! + m[1]!.length;
if (/\bln\s+-\w+\s+$/.test(line.slice(0, at))) continue;
const rest = line.slice(at + m[2]!.length);
if (!rest.startsWith(` ${FLAGS}`) && runsCode(rest)) out.push(line.slice(at, at + 80));
if (!rest.startsWith(` ${flags}`) && runsCode(rest)) out.push(line.slice(at, at + 80));
}
return out;
}
@@ -69,6 +73,26 @@ const SHELL_EXEMPT: Record<string, string> = {
'skillify/SKILL.md': 'generated from skillify/SKILL.md.tmpl',
};
/** Run only on Linux (the Ubicloud VM or its Linux-only local fallback), where /dev/null is the spelling; remote command strings expand there. */
const SHELL_LINUX_ONLY = (f: string) => f.startsWith('scripts/ubicloud/');
/** Lines of shell text that use GSTACK_BUN_CONFIG with no CONFIG_DEF line before them, or define it any other way. A `local` definition covers only the rest of its function. */
function configDefinitionGaps(text: string): string[] {
const gaps: string[] = [];
let defined = false;
let definedInFunction = false;
text.split('\n').forEach((line, i) => {
if (/^\s*(?:local\s+)?GSTACK_BUN_CONFIG=/.test(line)) {
const local = /^\s*local\s/.test(line);
if (line.trim().replace(/^local\s+/, '') !== CONFIG_DEF) gaps.push(`${i + 1}: redefined: ${line.trim()}`);
else if (local) definedInFunction = true;
else defined = true;
} else if (/^}/.test(line)) definedInFunction = false;
else if (!defined && !definedInFunction && line.includes('${GSTACK_BUN_CONFIG')) gaps.push(`${i + 1}: used before CONFIG_DEF`);
});
return gaps;
}
const isShell = (f: string) => f.endsWith('.sh') || /^#!.*\b(?:ba|z)?sh\b/.test(firstLine(f));
const isSkillSource = (f: string) => f.endsWith('.tmpl') || /^scripts\/resolvers\/.+\.ts$/.test(f)
|| /(^|\/)SKILL\.md$/.test(f) || /\/sections\/[^/]+\.md$/.test(f);
@@ -116,13 +140,41 @@ describe('GSTA-56: gstack never takes configuration from the audited repository'
]) expect(unisolatedBunCalls(ok)).toEqual([]);
});
test('in shipped shell files the detector requires the Windows-safe, fail-closed config spelling (#3415)', () => {
for (const bad of [
`printf x | bun ${FLAGS} -e "console.log(1)"`,
'bun --no-env-file --config="$GSTACK_BUN_CONFIG" -e 1',
'bun --no-env-file --config= -e 1',
'bun --no-env-file --config=NUL run "$X/bin/gstack-design-detect.ts"',
]) expect(unisolatedBunCalls(bad, SHELL_FLAGS)).not.toEqual([]);
for (const ok of [
`printf x | bun ${SHELL_FLAGS} -e "console.log(1)"`,
`exec bun ${SHELL_FLAGS} "$HERE/question-log-hook.ts"`,
`probe_cmd="bun ${SHELL_FLAGS} --eval 'import x from \\"y\\"'"`,
]) expect(unisolatedBunCalls(ok, SHELL_FLAGS)).toEqual([]);
});
test('the config-definition check flags a missing, late or altered GSTACK_BUN_CONFIG definition', () => {
const use = `bun ${SHELL_FLAGS} -e 1`;
expect(configDefinitionGaps(`#!/usr/bin/env bash\n${CONFIG_DEF}\n${use}\n`)).toEqual([]);
expect(configDefinitionGaps(`f() {\n local ${CONFIG_DEF}\n ${use}\n}\n`)).toEqual([]);
expect(configDefinitionGaps(`f() {\n local ${CONFIG_DEF}\n}\ng() {\n ${use}\n}\n`)).toEqual(['5: used before CONFIG_DEF']);
expect(configDefinitionGaps(`#!/usr/bin/env bash\n${use}\n`)).toEqual(['2: used before CONFIG_DEF']);
expect(configDefinitionGaps(`${use}\n${CONFIG_DEF}\n`)).toEqual(['1: used before CONFIG_DEF']);
expect(configDefinitionGaps(`GSTACK_BUN_CONFIG=./bunfig.toml\n${use}\n`)).toEqual(['1: redefined: GSTACK_BUN_CONFIG=./bunfig.toml', '2: used before CONFIG_DEF']);
expect(configDefinitionGaps(`${CONFIG_DEF}\nGSTACK_BUN_CONFIG=\n${use}\n`)).toEqual(['2: redefined: GSTACK_BUN_CONFIG=']);
});
test('shell callers, skill templates, generated skill docs and resolvers pass the isolation flags', () => {
const offenders: string[] = [];
for (const f of TRACKED) {
if (f.startsWith('.github/') || SHELL_EXEMPT[f] || !(isSkillSource(f) || isShell(f))) continue;
fs.readFileSync(path.join(ROOT, f), 'utf8').split('\n').forEach((line, i) => {
for (const call of unisolatedBunCalls(line)) offenders.push(`${f}:${i + 1}: ${call}`);
const flags = isSkillSource(f) || SHELL_LINUX_ONLY(f) ? FLAGS : SHELL_FLAGS;
const text = fs.readFileSync(path.join(ROOT, f), 'utf8');
text.split('\n').forEach((line, i) => {
for (const call of unisolatedBunCalls(line, flags)) offenders.push(`${f}:${i + 1}: ${call}`);
});
if (flags === SHELL_FLAGS) for (const gap of configDefinitionGaps(text)) offenders.push(`${f}:${gap}`);
}
expect(offenders).toEqual([]);
});
+17
View File
@@ -5,6 +5,9 @@
* --config=nul for the native bun.exe, which Bun 1.4.2 reads as an empty
* config; a native Windows spawn has no /dev/null, so TypeScript callers use
* bunIsolationArgs() (lib/bun-isolation.ts) instead of spelling the flag.
* With conversion off (MSYS_NO_PATHCONV=1) bun.exe gets /dev/null itself and
* fails with EINVAL, so gstack's shell files resolve GSTACK_BUN_CONFIG to NUL
* there (#3415).
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
@@ -15,6 +18,8 @@ import { bunIsolationArgs } from '../lib/bun-isolation';
import { bashCommandInvocation } from '../lib/script-invocation';
const PRINT = 'console.log("env=" + (process.env.GSTACK_LAUNCH_PROBE ?? "unset"))';
const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac';
const NO_PATHCONV = { MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' };
let project = '';
let preloadMarker = '';
@@ -61,6 +66,18 @@ describe('GSTA-56: isolated Bun launches on this platform', () => {
expectIsolated(bash('bun --no-env-file --config=/dev/null run probe.ts'));
});
test('a shipped shell file spelling, with MSYS path conversion off', () => {
expectIsolated(bash(`${CONFIG_DEF}; bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" -e '${PRINT}'`, NO_PATHCONV));
expectIsolated(bash(`${CONFIG_DEF}; bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" run probe.ts`, NO_PATHCONV));
});
test.if(process.platform === 'win32')('control: with MSYS path conversion off, bun.exe cannot open --config=/dev/null', () => {
const r = bash(`bun --no-env-file --config=/dev/null -e '${PRINT}'`, NO_PATHCONV);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('EINVAL');
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test('a TypeScript spawn with bunIsolationArgs()', () => {
expectIsolated(spawnSync(process.execPath, [...bunIsolationArgs(), 'probe.ts'], { cwd: project, encoding: 'utf8', timeout: 60_000 }));
});
+94
View File
@@ -0,0 +1,94 @@
/**
* #3330: gstack-lease took its lock with `ln -s <holder token> .lock`. Git
* for Windows' default symlink mode (MSYS unset) cannot link to a target that
* does not exist, so the lock was never taken and every skill start waited out
* 300 tries (one report: 499 s) and still wrote no lease. The lock is now a
* directory (mkdir) with an owner file. test/gstack-lease.test.ts pins the full
* contract but is POSIX-bound (mode bits, sleep children), so this file is the
* one the curated windows-free-tests lane runs: it launches the bin through
* lib/script-invocation.ts, which is Git Bash on windows-latest.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BIN_DIR, runBashSync, runScriptSync } from '../lib/script-invocation';
const LEASE = path.join(BIN_DIR, 'gstack-lease');
const fwd = (p: string) => p.replace(/\\/g, '/');
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});
// No inherited lease dir from the test preload, and Git Bash's default symlink mode (no winsymlinks).
const { GSTACK_LEASE_DIR: _preload, MSYS: _msys, CYGWIN: _cygwin, GSTACK_LEASE_LOCK_TRIES: _tries, ...baseEnv } = process.env as Record<string, string>;
function checkout() {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-gb-')));
dirs.push(dir);
const r = spawnSync('git', ['init', '-q', dir], { encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error(r.stderr);
return { dir, leases: path.join(dir, '.git', 'gstack-leases') };
}
/** A BASH_ENV file in which `ln -s` behaves as Git Bash's default: linking to a missing target fails. */
function gitBashLn(dir: string): string {
const file = path.join(dir, 'git-bash-ln.sh');
fs.writeFileSync(file, 'ln() { if [ "$1" = -s ] && [ ! -e "$2" ]; then echo "ln: failed to create symbolic link \'$3\': No such file or directory" >&2; return 1; fi; command ln "$@"; }\n');
return file;
}
/** A BASH_ENV file that logs every `sleep` (one per failed lock try) to $WAITS. */
function sleepLog(dir: string): string {
const file = path.join(dir, 'sleep-log.sh');
fs.writeFileSync(file, 'sleep() { echo "$*" >> "$WAITS"; command sleep "$@"; }\n');
return file;
}
describe('gstack-lease under Git Bash semantics (#3330)', () => {
test('touch takes the lock on its first try with the default tries and writes the lease, leaving no lock behind', () => {
const { dir, leases } = checkout();
const waits = path.join(dir, 'waits.log');
const r = runScriptSync(LEASE, ['touch', '--root', fwd(dir), '--pid', '4242'], { encoding: 'utf8', env: { ...baseEnv, BASH_ENV: fwd(sleepLog(dir)), WAITS: fwd(waits) }, timeout: 120_000 });
expect(r.status, `${r.error ?? ''}${r.stdout}${r.stderr}`).toBe(0);
expect(fs.existsSync(waits), 'lock() slept between tries').toBe(false);
expect(fs.readdirSync(leases)).toEqual(['4242']);
});
test('a host whose ln -s cannot link to a missing target still takes the lock (negative control: the shim really fails ln -s)', () => {
const { dir, leases } = checkout();
const shim = gitBashLn(dir);
const probe = runBashSync('ln -s "1 2" "$PROBE"', { encoding: 'utf8', env: { ...baseEnv, BASH_ENV: fwd(shim), PROBE: fwd(path.join(dir, 'probe')) }, timeout: 30_000 });
expect(probe.status, `${probe.error ?? ''}${probe.stderr}`).not.toBe(0);
expect(probe.stderr).toContain('failed to create symbolic link');
const r = runScriptSync(LEASE, ['touch', '--root', fwd(dir), '--pid', '4243'], { encoding: 'utf8', env: { ...baseEnv, BASH_ENV: fwd(shim), GSTACK_LEASE_LOCK_TRIES: '3' }, timeout: 120_000 });
expect(r.status, `${r.error ?? ''}${r.stdout}${r.stderr}`).toBe(0);
expect(fs.readdirSync(leases)).toEqual(['4243']);
});
test('a lock held by a live Git Bash process is kept (touch exits 3 naming it); a dead holder\'s lock is broken', () => {
const { dir, leases } = checkout();
const script = [
'set -u',
'mkdir -p "$LEASES/.lock" && printf "%s \\n" "$$" > "$LEASES/.lock/owner"',
'GSTACK_LEASE_LOCK_TRIES=2 bash "$LEASE" touch --root "$ROOT" --pid 4244; echo "held=$? holder=$$ owner=$(head -1 "$LEASES/.lock/owner")"',
'( exit 0 ) & dead=$!; wait "$dead"',
'printf "%s \\n" "$dead" > "$LEASES/.lock/owner"',
'GSTACK_LEASE_LOCK_TRIES=2 bash "$LEASE" touch --root "$ROOT" --pid 4245; echo "dead=$?"',
].join('\n');
const r = runBashSync(script, {
encoding: 'utf8', timeout: 120_000,
env: { ...baseEnv, LEASE: fwd(LEASE), ROOT: fwd(dir), LEASES: fwd(leases) },
});
const out = `${r.error ?? ''}${r.stdout}${r.stderr}`;
expect(r.status, out).toBe(0);
const holder = /held=(\d+) holder=(\d+) owner=(\d+)/.exec(r.stdout);
expect(holder?.[1], out).toBe('3');
expect(holder?.[3], out).toBe(holder?.[2]);
expect(r.stderr).toContain(`locked by live process ${holder?.[2]}`);
expect(r.stdout, out).toContain('dead=0');
expect(fs.readdirSync(leases).sort()).toEqual(['4245']);
});
});
+382 -17
View File
@@ -59,6 +59,24 @@ function otherStart(recorded: string): string {
return recorded.startsWith('linux ') ? recorded.replace(/\d+$/, (n) => String(Number(n) + 1)) : 'ps Thu Jan 1 00:00:00 1970';
}
/** A directory lock as gstack-lease takes it: <path>/owner holds the holder's token. */
function plantLock(lockPath: string, token: string) {
fs.mkdirSync(lockPath, { recursive: true });
fs.writeFileSync(path.join(lockPath, 'owner'), token + '\n');
}
const ownerOf = (lockPath: string) => fs.readFileSync(path.join(lockPath, 'owner'), 'utf8').trim();
/** Backdate a lock a minute: an ownerless lock is broken only once it is 2 s old. */
function age(lockPath: string) {
const minuteAgo = new Date(Date.now() - 60_000);
fs.utimesSync(lockPath, minuteAgo, minuteAgo);
}
/** Each lock layout a holder can leave: the directory lock, and the symlink lock an older gstack took. */
const LAYOUTS: Array<[string, (lockPath: string, token: string) => void]> = [
['directory lock', plantLock],
['legacy symlink lock', (lockPath, token) => fs.symlinkSync(token, lockPath)],
];
/** A BASH_ENV file whose functions shadow commands inside the lease script (fault injection). */
function bashEnv(dir: string, body: string): string {
const file = path.join(dir, 'bash-env.sh');
@@ -72,6 +90,8 @@ describe('gstack-lease', () => {
expect(fs.statSync(LEASE).mode & 0o111).not.toBe(0);
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
const help = spawnSync('bash', [LEASE, '--help'], { encoding: 'utf8', timeout: 30_000 });
expect(help.stdout).toContain('print `<pid> <age>m alive|dead|stale` for every lease');
});
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => {
@@ -160,7 +180,7 @@ describe('gstack-lease', () => {
const renewed = path.join(dir, 'renewed');
fs.writeFileSync(renewed, fresh);
// The holder renews just before reclaim takes the lock, as if touch ran after the stale verdict.
const racing = bashEnv(dir, `ln() { case "$*" in *"/.lock") command cp "${renewed}" "${file}" ;; esac; command ln "$@"; }\n`);
const racing = bashEnv(dir, `mkdir() { case "$*" in *"/.lock") command cp "${renewed}" "${file}" ;; esac; command mkdir "$@"; }\n`);
const r = run(['live', '--root', dir], { BASH_ENV: racing });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('reclaimed');
@@ -168,23 +188,24 @@ describe('gstack-lease', () => {
expect(fs.readdirSync(leases).filter((n) => n.startsWith('.'))).toEqual([]);
});
test('a lock whose holder died, or whose pid now belongs to another process, is broken at once', () => {
test.each(LAYOUTS)('a %s whose holder died, or whose pid now belongs to another process, is broken at once', (_layout, plant) => {
const { dir, leases } = checkout();
const holder = sleeper();
fs.mkdirSync(leases, { recursive: true });
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock'));
plant(path.join(leases, '.lock'), `${deadPid()} `);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, '.lock'))).toBe(false);
const identity = fs.readFileSync(path.join(leases, String(holder)), 'utf8').trim();
expect(identity).toMatch(/^(linux|ps) /);
fs.symlinkSync(`${holder} ${otherStart(identity)}`, path.join(leases, '.lock'));
plant(path.join(leases, '.lock'), `${holder} ${otherStart(identity)}`);
const again = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(again.status, again.stdout + again.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, '.lock'))).toBe(false);
expect(fs.readdirSync(leases).filter((n) => n.startsWith('.'))).toEqual([]);
});
test('a live holder\'s lock is never broken, however old: touch exits 3 without writing and a reclaim keeps the lease', () => {
test.each(LAYOUTS)('a live holder\'s %s is never broken, however old: touch exits 3 without writing and a reclaim keeps the lease', (layout, plant) => {
const { dir, leases } = checkout();
const owner = sleeper();
const holder = sleeper();
@@ -192,9 +213,10 @@ describe('gstack-lease', () => {
expect(run(['touch', '--root', dir, '--pid', String(holder)]).status).toBe(0);
const ownerToken = `${owner} ${fs.readFileSync(path.join(leases, String(owner)), 'utf8').trim()}`;
const lockPath = path.join(leases, '.lock');
fs.symlinkSync(ownerToken, lockPath);
plant(lockPath, ownerToken);
const old = new Date(Date.now() - 5 * 60_000);
fs.lutimesSync(lockPath, old, old);
if (layout === 'directory lock') fs.utimesSync(path.join(lockPath, 'owner'), old, old);
const holderFile = path.join(leases, String(holder));
const stale = otherStart(fs.readFileSync(holderFile, 'utf8').trim()) + '\n';
fs.writeFileSync(holderFile, stale);
@@ -207,7 +229,7 @@ describe('gstack-lease', () => {
expect(l.stderr).not.toContain('reclaimed');
expect(l.stdout).toContain(`${holder} `);
expect(fs.existsSync(holderFile)).toBe(true);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
expect(layout === 'directory lock' ? ownerOf(lockPath) : fs.readlinkSync(lockPath)).toBe(ownerToken);
});
test('two breakers of one dead lock: the second rereads under .lock.break and leaves the lock the first one took', () => {
@@ -217,21 +239,21 @@ describe('gstack-lease', () => {
expect(run(['touch', '--root', dir, '--pid', String(owner)]).status).toBe(0);
const ownerToken = `${owner} ${fs.readFileSync(path.join(leases, String(owner)), 'utf8').trim()}`;
const lockPath = path.join(leases, '.lock');
fs.symlinkSync(`${deadPid()} `, lockPath);
plantLock(lockPath, `${deadPid()} `);
// Between this breaker's dead verdict and its .lock.break, another breaker removes the dead lock and takes it.
const first = bashEnv(dir, `ln() { case "$*" in *"/.lock.break") command rm -f "${lockPath}"; command ln -s "${ownerToken}" "${lockPath}" ;; esac; command ln "$@"; }\n`);
const first = bashEnv(dir, `mkdir() { case "$*" in *"/.lock.break") command rm -rf "${lockPath}"; command mkdir "${lockPath}"; printf '%s\\n' "${ownerToken}" > "${lockPath}/owner" ;; esac; command mkdir "$@"; }\n`);
const t = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: first, GSTACK_LEASE_LOCK_TRIES: '3' });
expect(t.status, t.stdout + t.stderr).toBe(3);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
expect(fs.existsSync(path.join(leases, '.lock.break'))).toBe(false);
expect(ownerOf(lockPath)).toBe(ownerToken);
expect(fs.readdirSync(leases).filter((n) => n.startsWith('.')).sort()).toEqual(['.lock']);
});
test('a .lock.break left by a dead breaker is cleared and the dead lock is still broken', () => {
test.each(LAYOUTS)('a .lock.break (%s) left by a dead breaker is cleared and the dead lock is still broken', (_layout, plant) => {
const { dir, leases } = checkout();
const holder = sleeper();
fs.mkdirSync(leases, { recursive: true });
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock'));
fs.symlinkSync(`${deadPid()} `, path.join(leases, '.lock.break'));
plant(path.join(leases, '.lock'), `${deadPid()} `);
plant(path.join(leases, '.lock.break'), `${deadPid()} `);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '5' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(true);
@@ -249,12 +271,355 @@ describe('gstack-lease', () => {
fs.writeFileSync(holderFile, otherStart(fs.readFileSync(holderFile, 'utf8').trim()) + '\n');
const lockPath = path.join(leases, '.lock');
// While reclaim deletes the stale lease under its lock, the lock changes hands.
const swap = bashEnv(dir, `rm() { if [ "$*" = "-f ${holderFile}" ]; then command rm -f "${lockPath}"; command ln -s "${ownerToken}" "${lockPath}"; fi; command rm "$@"; }\n`);
const swap = bashEnv(dir, `rm() { if [ "$*" = "-f ${holderFile}" ]; then command rm -rf "${lockPath}"; command mkdir "${lockPath}"; printf '%s\\n' "${ownerToken}" > "${lockPath}/owner"; fi; command rm "$@"; }\n`);
const r = run(['live', '--root', dir, '--exclude-ancestry-of', String(owner)], { BASH_ENV: swap });
expect(r.stderr).toContain(`reclaimed stale lease ${holder}`);
expect(fs.readlinkSync(lockPath)).toBe(ownerToken);
expect(ownerOf(lockPath)).toBe(ownerToken);
});
test('a lock held by a running gstack-lease excludes a second taker, which names it; both leases land once it is released', async () => {
const { dir, leases } = checkout();
const [first, second] = [sleeper(), sleeper()];
// The first touch holds the lock across its prune (find runs under the lock) until the test releases it.
const release = path.join(dir, 'release');
const slow = bashEnv(dir, `find() { case "$*" in *'+120'*) until [ -e "${release}" ]; do sleep 0.05; done ;; esac; command find "$@"; }\n`);
const holder = spawn('bash', [LEASE, 'touch', '--root', dir, '--pid', String(first)], { env: { ...baseEnv, BASH_ENV: slow }, stdio: 'ignore' });
children.push(holder);
const exited = new Promise<number | null>((resolve) => holder.on('exit', resolve));
const owner = path.join(leases, '.lock', 'owner');
for (let i = 0; i < 500 && !fs.existsSync(owner); i++) await Bun.sleep(20);
expect(fs.readFileSync(owner, 'utf8')).toMatch(new RegExp(`^${holder.pid} `));
const blocked = run(['touch', '--root', dir, '--pid', String(second)], { GSTACK_LEASE_LOCK_TRIES: '3' });
fs.writeFileSync(release, '');
expect(blocked.status, blocked.stdout + blocked.stderr).toBe(3);
expect(blocked.stderr).toContain(`locked by live process ${holder.pid};`);
expect(await exited).toBe(0);
expect(run(['touch', '--root', dir, '--pid', String(second)]).status).toBe(0);
expect(fs.readdirSync(leases).sort()).toEqual([String(first), String(second)].sort());
}, 30_000);
test('parallel touches all take the lock in turn and leave no lock, claim or temp file behind', async () => {
const { dir, leases } = checkout();
const pids = Array.from({ length: 16 }, (_, i) => 4300 + i);
// The prune runs under the lock: a second process inside it at the same time logs an overlap.
const inside = path.join(dir, 'inside');
const overlaps = path.join(dir, 'overlaps.log');
const probe = bashEnv(dir, `find() { case "$*" in *'+120'*) command mkdir "${inside}" 2>/dev/null || echo overlap >> "${overlaps}"; sleep 0.05; command rmdir "${inside}" 2>/dev/null ;; esac; command find "$@"; }\n`);
const codes = await Promise.all(pids.map((p) => new Promise<number | null>((resolve) => {
const c = spawn('bash', [LEASE, 'touch', '--root', dir, '--pid', String(p)], { env: { ...baseEnv, BASH_ENV: probe }, stdio: 'ignore' });
children.push(c);
c.on('exit', resolve);
})));
expect(codes).toEqual(pids.map(() => 0));
expect(fs.existsSync(overlaps), 'two touches were inside the lock at once').toBe(false);
expect(fs.readdirSync(leases).sort()).toEqual(pids.map(String).sort());
}, 30_000);
// #3330: a taker killed between its mkdir and its owner rename leaves an ownerless lock.
test('an ownerless lock is broken once every claimant is gone, and kept while a live claimant may still own it', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const lockPath = path.join(leases, '.lock');
fs.mkdirSync(lockPath, { recursive: true });
age(lockPath);
fs.writeFileSync(path.join(leases, `.lock.claim.${holder}`), `${holder} \n`);
const kept = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(kept.status, kept.stdout + kept.stderr).toBe(3);
expect(kept.stderr).toContain('cannot take the lease directory lock');
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
expect(fs.readdirSync(lockPath)).toEqual([]);
const dead = deadPid();
fs.renameSync(path.join(leases, `.lock.claim.${holder}`), path.join(leases, `.lock.claim.${dead}`));
fs.writeFileSync(path.join(leases, `.lock.claim.${dead}`), `${dead} \n`);
fs.writeFileSync(path.join(leases, '.lock.claim.1'), '');
age(lockPath);
const broken = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(broken.status, broken.stdout + broken.stderr).toBe(0);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
fs.rmSync(path.join(leases, `.lock.claim.${dead}`));
fs.mkdirSync(lockPath);
age(lockPath);
const unclaimed = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(unclaimed.status, unclaimed.stdout + unclaimed.stderr).toBe(0);
expect(fs.existsSync(lockPath)).toBe(false);
});
// Value: protects=an ownerless lock younger than 2 s is never broken, so a live taker whose claim landed after the breaker read the claims keeps
// the lock it is about to publish; a malformed lock (empty owner, pid 0, or a stray file) is broken once old instead of wedging every touch;
// fails_when=orphaned() drops its age check, or treats only a missing owner file (not an empty or invalid one) as ownerless;
// why_new=the ownerless cases above all backdate the lock; nothing pins the young-lock guard or the malformed shapes;
// seam=none; no_claim=the claim-read race itself (a young unclaimed lock stands in for a taker whose claim the breaker missed)
test('an ownerless lock is broken only once it is 2 s old, and a malformed lock is treated as ownerless', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const lockPath = path.join(leases, '.lock');
fs.mkdirSync(lockPath, { recursive: true });
// The clock reads the lock's own mtime, so the lock stays 0 s old however slow the runner is.
const frozen = bashEnv(dir, `date() { case "$1" in +%s) command stat -c %Y "${lockPath}" 2>/dev/null || command stat -f %m "${lockPath}" ;; *) command date "$@" ;; esac; }\n`);
const young = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3', BASH_ENV: frozen });
expect(young.status, young.stdout + young.stderr).toBe(3);
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
const shapes: Array<[string, () => void]> = [
['an empty owner file', () => plantLock(lockPath, '')],
['owner pid 0', () => plantLock(lockPath, '0 ')],
['a zero-padded owner pid', () => plantLock(lockPath, '00 ')],
['a non-numeric owner', () => plantLock(lockPath, 'garbage x')],
['an ownerless lock an hour ahead (clock skew)', () => fs.mkdirSync(lockPath)],
['a stray file', () => { fs.rmSync(lockPath, { recursive: true, force: true }); fs.writeFileSync(lockPath, ''); }],
];
for (const [shape, plant] of shapes) {
fs.rmSync(lockPath, { recursive: true, force: true });
fs.rmSync(path.join(leases, String(holder)), { force: true });
plant();
age(lockPath);
if (shape.includes('clock skew')) {
const hourAhead = new Date(Date.now() + 3_600_000);
fs.utimesSync(lockPath, hourAhead, hourAhead);
}
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(r.status, `${shape}: ${r.stdout}${r.stderr}`).toBe(0);
expect(fs.readdirSync(leases), shape).toEqual([String(holder)]);
}
});
// Value: protects=a claim without a start identity left by a killed taker neither blocks recovery of an ownerless lock (even when its pid is
// alive again) nor lingers once 2 minutes old, while a claim whose recorded identity is still live is honored however old;
// fails_when=orphaned() honors identity-less claims of any age, expires live identified claims, or touch's prune skips claim files;
// why_new=the ownerless cases plant fresh claims only; nothing ages one or checks the prune for claims;
// seam=none; no_claim=real pid reuse (a live sleeper stands in for the reused pid)
test('an identity-less claim older than 2 minutes is abandoned and pruned; an old claim with a live identity is honored', () => {
const { dir, leases } = checkout();
const [holder, reused] = [sleeper(), sleeper()];
const lockPath = path.join(leases, '.lock');
fs.mkdirSync(lockPath, { recursive: true });
age(lockPath);
const claim = path.join(leases, `.lock.claim.${reused}`);
fs.writeFileSync(claim, `${reused} \n`);
const fiveMinutesAgo = new Date(Date.now() - 300_000);
fs.utimesSync(claim, fiveMinutesAgo, fiveMinutesAgo);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
// A claim that records its claimant's start identity is honored however old, so a stalled live taker keeps its lock.
expect(run(['touch', '--root', dir, '--pid', String(reused)]).status).toBe(0);
const identity = fs.readFileSync(path.join(leases, String(reused)), 'utf8').trim();
expect(identity, 'this host records start identities').not.toBe('');
fs.rmSync(path.join(leases, String(reused)));
fs.mkdirSync(lockPath);
age(lockPath);
fs.writeFileSync(claim, `${reused} ${identity}\n`);
fs.utimesSync(claim, fiveMinutesAgo, fiveMinutesAgo);
const kept = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(kept.status, kept.stdout + kept.stderr).toBe(3);
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(claim)).toBe(true);
});
// Value: protects=a live holder's lock is never broken because its owner file could not be read (a Windows sharing violation, a permission error);
// fails_when=orphaned() treats an unreadable owner (empty owner_of output) as no owner;
// why_new=every ownerless case removes or empties the owner file; none makes a present owner unreadable;
// seam=none; no_claim=a real sharing violation (a head shim fails reads of the owner file)
test('a lock whose owner file cannot be read is kept, not taken for ownerless', () => {
const { dir, leases } = checkout();
const [holder, owner] = [sleeper(), sleeper()];
const lockPath = path.join(leases, '.lock');
plantLock(lockPath, `${owner} `);
age(lockPath);
const shim = bashEnv(dir, 'head() { case "$*" in *"/.lock/owner") return 1 ;; esac; command head "$@"; }\n');
const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3', BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(ownerOf(lockPath)).toBe(String(owner));
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
});
// Value: protects=a live holder that never lets go bounds touch at 30 s even with the default 300 tries, a non-numeric
// GSTACK_LEASE_LOCK_TRIES falls back to the default instead of looping forever, and a visibly held lock is never mkdir'd;
// fails_when=lock() loses its SECONDS deadline or its tries validation, or take() stops checking the path first;
// why_new=every live-holder case lowers the tries; none runs the default budget, a bad value or counts mkdir calls;
// seam=none; no_claim=real elapsed time (a sleep shim advances SECONDS 10 s per wait)
test('a live holder bounds touch by a 30 s deadline, and a non-numeric tries value cannot loop forever', () => {
const { dir, leases } = checkout();
const [holder, owner] = [sleeper(), sleeper()];
plantLock(path.join(leases, '.lock'), `${owner} `);
const waits = path.join(dir, 'waits.log');
const mkdirs = path.join(dir, 'mkdirs.log');
const shim = bashEnv(dir, `sleep() { echo "$*" >> "${waits}"; SECONDS=$((SECONDS + 10)); }\nmkdir() { case "$*" in *"/.lock") echo "$*" >> "${mkdirs}" ;; esac; command mkdir "$@"; }\n`);
for (const tries of [undefined, 'abc']) {
fs.rmSync(waits, { force: true });
const env: Record<string, string> = { BASH_ENV: shim };
if (tries) env.GSTACK_LEASE_LOCK_TRIES = tries;
const r = spawnSync('bash', [LEASE, 'touch', '--root', dir, '--pid', String(holder)], { encoding: 'utf8', env: { ...baseEnv, ...env }, timeout: 20_000 });
expect(r.error?.message ?? '', `tries=${tries}: touch did not end`).not.toContain('ETIMEDOUT');
expect(r.status, `tries=${tries}: ${r.stdout}${r.stderr}`).toBe(3);
expect(r.stderr).toContain(`locked by live process ${owner};`);
expect(r.stderr).not.toContain('integer expression');
expect(fs.readFileSync(waits, 'utf8').trim().split('\n').length, `tries=${tries}`).toBeLessThanOrEqual(3);
}
expect(fs.existsSync(mkdirs), 'take() ran mkdir on a visibly held lock').toBe(false);
expect(ownerOf(path.join(leases, '.lock'))).toBe(String(owner));
}, 30_000);
// Value: protects=a holder that resumes past the 120-minute prune window keeps its own lock metadata, so unlock still recognizes and removes it;
// fails_when=touch's prune recurses into .lock (deleting owner) or matches lock files at the top level;
// why_new=the prune test plants only lease files; none ages the lock's owner file before the prune runs;
// seam=none; no_claim=a real suspension (a BASH_ENV find shim backdates the owner file just before the prune)
test('the 120-minute prune never deletes the lock it runs under', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const shim = bashEnv(dir, 'find() { command touch -d "3 hours ago" "$1/.lock/owner" 2>/dev/null || command touch -t 197001010000 "$1/.lock/owner"; command find "$@"; }\n');
// Renamed-aside copies a failed delete left behind are pruned too.
fs.mkdirSync(path.join(leases, '.lock.gone.99999'), { recursive: true });
fs.writeFileSync(path.join(leases, '.lock.gone.99999', 'owner'), '99999 \n');
fs.mkdirSync(path.join(leases, '.lock.break.gone.99998'));
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
});
// Value: protects=once a lock is renamed aside it is released: a failed delete of the renamed copy never removes a lock a new taker made at .lock;
// fails_when=drop() reports failure after a successful rename, so unlock's in-place rm -rf fallback runs on the new taker's lock;
// why_new=the rename-aside tests fail the rename itself; none lets the rename succeed and the delete fail;
// seam=none; no_claim=a real new taker in that window (the rm shim plants one, then fails like a Windows file in use)
test('a renamed-aside lock that cannot be deleted never takes a new taker\'s lock with it', () => {
const { dir, leases } = checkout();
const [holder, taker] = [sleeper(), sleeper()];
const lockPath = path.join(leases, '.lock');
const shim = bashEnv(dir, `rm() { case "$*" in *".lock.gone."*) command mkdir "${lockPath}" && printf '%s \\n' "${taker}" > "${lockPath}/owner"; return 1 ;; esac; command rm "$@"; }\n`);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(ownerOf(lockPath)).toBe(String(taker));
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(true);
});
test('a lock that cannot be taken while nobody holds it fails at once instead of waiting out the tries', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const waits = path.join(dir, 'waits.log');
const broken = bashEnv(dir, `mkdir() { case "$*" in *"/.lock") return 1 ;; esac; command mkdir "$@"; }\nsleep() { echo "$*" >> "${waits}"; command sleep "$@"; }\n`);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: broken });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(fs.existsSync(waits), 'lock() slept between tries').toBe(false);
expect(r.stderr).toContain('cannot take the lease directory lock');
expect(r.stderr).not.toContain('locked by live process');
expect(fs.readdirSync(leases)).toEqual([]);
});
// Value: protects=a .lock.break held by a live breaker (owner, or claim on an ownerless dir) is never cleared; an ownerless one with no claimant is;
// fails_when=break_lock's liveness check on .lock.break is dropped, or orphaned() ignores a live claim;
// why_new=the existing .lock.break test plants only a dead breaker's owner, never a live breaker or an ownerless one;
// seam=none; no_claim=two live breakers racing for real (sleeping children stand in as the live holders)
test('a .lock.break whose breaker lives (owner or claim) is never cleared; an ownerless one with no live claimant is', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const breaker = sleeper();
const lockPath = path.join(leases, '.lock');
const breakPath = path.join(leases, '.lock.break');
plantLock(lockPath, `${deadPid()} `);
plantLock(breakPath, `${breaker} `);
const owned = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(owned.status, owned.stdout + owned.stderr).toBe(3);
expect(ownerOf(breakPath)).toBe(String(breaker));
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
fs.rmSync(breakPath, { recursive: true });
fs.mkdirSync(breakPath);
age(breakPath);
fs.writeFileSync(path.join(leases, `.lock.break.claim.${breaker}`), `${breaker} \n`);
const claimed = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' });
expect(claimed.status, claimed.stdout + claimed.stderr).toBe(3);
expect(fs.readdirSync(breakPath)).toEqual([]);
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
fs.rmSync(path.join(leases, `.lock.break.claim.${breaker}`));
age(breakPath);
const orphaned = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '5' });
expect(orphaned.status, orphaned.stdout + orphaned.stderr).toBe(0);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
});
// Value: protects=a lock dir whose owner rename failed is removed, never left ownerless; touch exits 3 saying it cannot take the lock;
// fails_when=take() stops removing the lock dir after a failed owner rename;
// why_new=the ownerless-lock test plants the dir by hand; none fails take() between its mkdir and its owner rename;
// seam=none; no_claim=real mv failure modes (a BASH_ENV shim fails the claim-to-owner rename)
test('a lock whose owner cannot be published is removed, not left ownerless, and touch fails at once', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const shim = bashEnv(dir, 'mv() { case "$*" in *".lock.claim."*) return 1 ;; esac; command mv "$@"; }\n');
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stderr).toContain('cannot take the lease directory lock');
expect(r.stderr).not.toContain('locked by live process');
expect(fs.readdirSync(leases)).toEqual([]);
});
// Value: protects=a lock that vanishes between a failed take and its check is retaken at once: touch writes its lease, no sleep, no lock left;
// fails_when=the vanished-lock retry limit drops to 1, so one release race exits 3;
// why_new=the fails-at-once test pins only the upper bound (every take fails); no test fails a single take;
// seam=none; no_claim=a real release race (a BASH_ENV shim fails the first mkdir of the lock)
test('a lock released between a failed take and its check is retaken without waiting', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const waits = path.join(dir, 'waits.log');
const once = path.join(dir, 'failed-once');
const shim = bashEnv(dir, `mkdir() { case "$*" in *"/.lock") if [ ! -e "${once}" ]; then : > "${once}"; return 1; fi ;; esac; command mkdir "$@"; }\nsleep() { echo "$*" >> "${waits}"; command sleep "$@"; }\n`);
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.existsSync(once), 'the shim never failed a take').toBe(true);
expect(fs.existsSync(waits), 'lock() slept between tries').toBe(false);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
});
// Value: protects=unlock removes the caller's own lock even when renaming it aside fails: touch exits 0, lease written, no .lock left behind;
// fails_when=the rm -rf fallback after drop in unlock() is removed;
// why_new=no test fails the rename-aside step of drop(), so unlock's fallback never runs;
// seam=none; no_claim=real mv failure modes such as a Windows file in use (a BASH_ENV shim fails the .lock.gone rename)
test('unlock still removes its own lock when renaming it aside fails', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const shim = bashEnv(dir, 'mv() { case "$*" in *".lock.gone."*) return 1 ;; esac; command mv "$@"; }\n');
const r = run(['touch', '--root', dir, '--pid', String(holder)], { BASH_ENV: shim });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
});
// Value: protects=a dead holder's lock that cannot be renamed aside is removed in place, and one that cannot be removed at all ends touch
// with exit 3 within the tries limit instead of spinning, without calling the dead holder live;
// fails_when=break_lock reports success after a failed drop and lock() loops without sleeping or counting tries;
// why_new=the dead-holder tests always let the rename-aside succeed; the tries limit is only reached through the live-holder path;
// seam=none; no_claim=real mv/rm failure modes such as a Windows file in use (a BASH_ENV shim fails the .lock.gone rename and the rm)
test('a dead holder\'s lock that cannot be renamed aside ends touch with a verdict instead of spinning', () => {
const { dir, leases } = checkout();
const holder = sleeper();
const dead = deadPid();
const touch = (shim: string) => spawnSync('bash', [LEASE, 'touch', '--root', dir, '--pid', String(holder)], {
encoding: 'utf8', env: { ...baseEnv, BASH_ENV: shim, GSTACK_LEASE_LOCK_TRIES: '3' }, timeout: 8_000,
});
const noRename = 'mv() { case "$*" in *".lock.gone."*) return 1 ;; esac; command mv "$@"; }\n';
plantLock(path.join(leases, '.lock'), `${dead} `);
const removed = touch(bashEnv(dir, noRename));
expect(removed.error?.message ?? '', 'touch did not end within 8 s').not.toContain('ETIMEDOUT');
expect(removed.status, removed.stdout + removed.stderr).toBe(0);
expect(fs.readdirSync(leases)).toEqual([String(holder)]);
fs.rmSync(path.join(leases, String(holder)));
plantLock(path.join(leases, '.lock'), `${dead} `);
const stuck = touch(bashEnv(dir, noRename + 'rm() { case "$*" in *"/.lock") return 1 ;; esac; command rm "$@"; }\n'));
expect(stuck.error?.message ?? '', 'touch did not end within 8 s').not.toContain('ETIMEDOUT');
expect(stuck.status, stuck.stdout + stuck.stderr).toBe(3);
expect(stuck.stderr).toContain('cannot take the lease directory lock');
expect(stuck.stderr).not.toContain('locked by live process');
expect(ownerOf(path.join(leases, '.lock'))).toBe(String(dead));
expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false);
}, 20_000);
test('a lease without a recorded identity (older gstack) counts while fresh and is stale past the 120-minute window', () => {
const { dir, leases } = checkout();
const holder = sleeper();
@@ -333,7 +698,7 @@ describe('gstack-lease', () => {
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
const leaseDir = path.join(state, 'leases');
fs.mkdirSync(leaseDir);
fs.symlinkSync(`${sleeper()} `, path.join(leaseDir, '.lock'));
plantLock(path.join(leaseDir, '.lock'), `${sleeper()} `);
const r = spawnSync(START, ['--skill', 'testskill', '--parent-pid', '424244'], {
encoding: 'utf8', cwd: home, env: { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir, GSTACK_LEASE_LOCK_TRIES: '2' }, timeout: 30_000,
});
+8 -3
View File
@@ -6,10 +6,15 @@ const SCRIPT = path.join(import.meta.dir, "..", "bin", "gstack-learnings-search"
describe("gstack-learnings-search injection prevention", () => {
const script = readFileSync(SCRIPT, "utf-8");
const bunBlockAt = script.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "');
test("the bun -e block the checks below read is present", () => {
expect(bunBlockAt).toBeGreaterThanOrEqual(0);
});
test("no shell interpolation inside bun -e string", () => {
// Extract the bun -e block (everything between `bun -e "` and the closing `"`)
const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "'));
const bunBlock = script.slice(bunBlockAt);
// Should NOT contain ${VAR} patterns (shell interpolation)
// These are RCE vectors: a malicious learnings entry with '; rm -rf / ;' in the
@@ -27,7 +32,7 @@ describe("gstack-learnings-search injection prevention", () => {
});
test("uses process.env for all user-controlled values", () => {
const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "'));
const bunBlock = script.slice(bunBlockAt);
// Must use process.env for TYPE, QUERY, LIMIT.
// SLUG and CROSS are no longer threaded as env vars inside the bun
@@ -52,7 +57,7 @@ describe("gstack-learnings-search injection prevention", () => {
});
test("current vs cross-project rows distinguished by inline tags, not SLUG env (#1619)", () => {
const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "'));
const bunBlock = script.slice(bunBlockAt);
// The bun block must inspect the per-line tag to mark cross-project rows.
// The current shape emits `current\t<json>` or `cross\t<json>` from the
// upstream pipe (via emit_tagged_file). Inside the bun block, the script
+1 -1
View File
@@ -16,7 +16,7 @@ import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const PLAN_ARG = '${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}';
const GENERATOR = /(?:\bbun(?: --no-env-file --config=\/dev\/null)?|\bbun_cmd|_gstack_render_bun) run (?:gen:skill-docs(?::user)?|scripts\/gen-skill-docs\.ts) --[a-z]/;
const GENERATOR = /(?:\bbun(?: --no-env-file --config=(?:\/dev\/null|"\$\{GSTACK_BUN_CONFIG:-\/dev\/null\}"))?|\bbun_cmd|_gstack_render_bun) run (?:gen:skill-docs(?::user)?|scripts\/gen-skill-docs\.ts) --[a-z]/;
const CONFIG_READ = '[ "$("$GSTACK_CONFIG" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off"';
/** Generator invocations in a file: not comments, and not echoed or logged instructions. */
+2 -2
View File
@@ -210,8 +210,8 @@ describe('setup: heal-first ordering + visibility', () => {
describe('gstack-settings-hook: shared prelude (dedupe key == prune predicate)', () => {
test('every bun script call site uses the shared JS prelude concatenation', () => {
const codeLines = hookBinSrc.split('\n').filter((l) => !l.trim().startsWith('#'));
const bunCalls = codeLines.filter((l) => l.includes('bun --no-env-file --config=/dev/null -e '));
const preludeCalls = codeLines.filter((l) => l.includes(`bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'`));
const bunCalls = codeLines.filter((l) => l.includes('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '));
const preludeCalls = codeLines.filter((l) => l.includes(`bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'`));
expect(bunCalls.length).toBeGreaterThanOrEqual(6);
expect(preludeCalls.length).toBe(bunCalls.length);
});