This commit is contained in:
luyua9 2026-09-25 21:55:23 +03:00 • committed by GitHub
commit 938e00b43b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 137 additions and 2 deletions

View file

@ -339,6 +339,67 @@ function resolveCliPath() {
}
}
// The Cursor host enforces hooks.json's postToolUse `timeout` (seconds)
// against the whole hook process. hooks.json ships 60s so a cold
// `npx -y gitnexus` (package download + install) can actually finish; the
// npx fallback budget is then sized from the remaining host budget minus
// headroom for node startup and the final stdout write, instead of a flat
// inner+5s that could overshoot or undercut the deadline.
const CURSOR_NPX_HEADROOM_MS = 5000;
const CURSOR_HOOK_START_MS = Date.now();
const HOOK_ENV = process['env'];
function resolveCursorHostBudgetMs() {
try {
// hooks.json ships next to this hook script
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'hooks.json'), 'utf-8'));
const seconds = manifest.hooks.postToolUse[0].timeout;
if (typeof seconds === 'number' && seconds > 0) {
return seconds * 1000;
}
} catch {
/* fall through to the shipped default */
}
return 60000;
}
// Resolve a coreutils/BSD `timeout` binary for the npx arm. The CLI behind
// npx is a grandchild (npx -> gitnexus), so the wrap leads with `-s KILL`:
// a plain SIGTERM would kill only the obedient npx parent and let the
// grandchild keep holding the LadybugDB lock after this hook released its
// slot. Mirrors the Claude adapter's wrapped npx arm; Windows stays
// unwrapped (npx is a .cmd script there).
function resolveUnixGuardTimeout() {
if (process.platform === 'win32') return null;
const override = HOOK_ENV.GITNEXUS_HOOK_TIMEOUT_PATH;
if (override === 'disabled') return null;
const candidates = [];
if (override && override.trim()) candidates.push(override.trim());
candidates.push(
'/usr/bin/timeout',
'/usr/local/bin/timeout',
'/opt/homebrew/bin/timeout',
'/usr/bin/gtimeout',
);
for (const candidate of candidates) {
try {
if (candidate.includes('/') && fs.existsSync(candidate)) return candidate;
const probe = spawnSync('which', [candidate], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
if (probe.status === 0 && String(probe.stdout).trim()) {
return String(probe.stdout).trim();
}
} catch {
/* keep probing */
}
}
return null;
}
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
if (cliPath) {
@ -350,9 +411,42 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
windowsHide: true,
});
}
const elapsed = Date.now() - CURSOR_HOOK_START_MS;
const npxTimeout = Math.max(
1000,
Math.min(timeout + 5000, resolveCursorHostBudgetMs() - CURSOR_NPX_HEADROOM_MS - elapsed),
);
const guard = resolveUnixGuardTimeout();
if (guard) {
const wrapped = spawnSync(
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil(npxTimeout / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
{
encoding: 'utf-8',
timeout: npxTimeout + 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
},
);
if (!wrapped.error || wrapped.error.code !== 'ENOENT') {
return wrapped;
}
// guard vanished between probe and spawn: fall through unwrapped
}
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
timeout: npxTimeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,

View file

@ -5,7 +5,7 @@
{
"matcher": "Shell|Read|Grep",
"command": "node ./hooks/gitnexus-hook.cjs",
"timeout": 10
"timeout": 60
}
]
}

View file

@ -441,6 +441,47 @@ describe('Cursor hook debug logging', () => {
});
});
// ─── Source code regression: npx fallback stays under the host budget ─────
describe('Cursor hook npx fallback host budget', () => {
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
const manifest = JSON.parse(
fs.readFileSync(path.join(path.dirname(CURSOR_HOOK), 'hooks.json'), 'utf-8'),
);
it('ships a postToolUse timeout with room for a cold npx install', () => {
// Cold `npx -y gitnexus` has to download + install the package; the
// original 10s budget killed the hook before the child could ever
// finish. The existing manifest test only pins (0, 120).
const seconds = manifest.hooks.postToolUse[0].timeout;
expect(seconds).toBeGreaterThanOrEqual(30);
expect(seconds).toBeLessThan(120);
});
it('sizes the npx timeout from the host budget with headroom', () => {
// Extract runGitNexusCli so the assertions are tied to the actual
// spawn wiring, not just to unrelated substrings elsewhere.
const fnStart = source.indexOf('function runGitNexusCli');
const fnBody = source.slice(fnStart, source.indexOf('\n}\n', fnStart));
// budget comes from hooks.json (seconds → ms), with headroom applied
expect(fnBody).toContain('resolveCursorHostBudgetMs() - CURSOR_NPX_HEADROOM_MS - elapsed');
// the computed budget is what reaches spawnSync (not a bare +5000)
expect(fnBody).toContain('timeout: npxTimeout');
expect(fnBody).not.toMatch(/timeout:\s*timeout\s*\+\s*5000/);
// npx grandchild is killed outright so it cannot keep the DB lock
expect(fnBody).toMatch(/['"]-s['"]/);
expect(fnBody).toMatch(/['"]KILL['"]/);
});
it('reads the shipped timeout value, not a detached literal', () => {
// resolveCursorHostBudgetMs must parse hooks.json so changing the
// manifest cannot silently desync from the hook budget.
expect(source).toContain('postToolUse[0].timeout');
expect(source).toContain('return seconds * 1000;');
});
});
// ─── Source code regression: concurrency guard (#1486) ─────────────
describe('Cursor hook concurrency guard', () => {