mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge 15e5af3b8e into 6bb99767ff
This commit is contained in:
commit
938e00b43b
3 changed files with 137 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{
|
||||
"matcher": "Shell|Read|Grep",
|
||||
"command": "node ./hooks/gitnexus-hook.cjs",
|
||||
"timeout": 10
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue