Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
Gergő Magyar 2026-05-23 16:46:30 +01:00 • committed by GitHub
commit 6ccbde5aaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 268 additions and 217 deletions

View file

@ -164,6 +164,137 @@ function makeMiniRepoCopy(basename: string, prefix: string): string {
return repo;
}
/**
* Detects libuv-emitted bind-restriction errors (EACCES / EPERM /
* EADDRNOTAVAIL on `listen` or `bind`) so the host-flag tests can
* tolerate CI/sandbox environments that forbid loopback binding.
*
* Match policy: every alternative MUST carry a `listen ` or `bind `
* prefix. Bare "permission denied" / "operation not permitted"
* substrings in stderr (e.g. from a Node fs EACCES during module
* loading) MUST NOT match — those represent real test failures that
* should not be silently swallowed.
*/
function isEvalServerBindRestriction(stderr: string): boolean {
return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test(
stderr,
);
}
// Subprocess timeout for eval-server READY signal. Must be < the
// outer vitest test budget (35s) so the reject branch fires before
// vitest gives up on the test.
const EVAL_SERVER_READY_TIMEOUT_MS = 30000;
/**
* Drives the spawn → settle → timer → stderr → close lifecycle shared by
* the `eval-server --host` integration tests. Each test passes test-specific
* spawn args, a timeout message, and an `onStdout` callback that owns the
* READY-signal parsing and any post-READY probing.
*
* The helper owns: spawn wiring, child.once('error'), the setTimeout
* timer, stderr accumulation + 'unknown option' fast-reject, and the close
* handler's priority chain (unknown-option → bind-restriction → unexpected-exit
* reject). Tests that need to short-circuit before `await` may inspect
* `isSettled()`; `settle()` itself is idempotent so calling it after the
* promise has already resolved is a safe no-op.
*/
function runEvalServerHostFlagTest(
spawnArgs: string[],
opts: {
timeoutMsg: string;
onStdout: (params: {
stdoutBuffer: string;
isSettled: () => boolean;
settle: (fn: () => void) => void;
resolve: () => void;
reject: (err: Error) => void;
}) => void | Promise<void>;
},
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
['--import', tsxImportUrl, cliEntry, 'eval-server', ...spawnArgs],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGTERM');
fn();
};
child.once('error', (err) => {
settle(() => reject(new Error(`Failed to spawn eval-server: ${err.message}`)));
});
const timer = setTimeout(() => {
settle(() => reject(new Error(opts.timeoutMsg)));
}, EVAL_SERVER_READY_TIMEOUT_MS);
// The `settled` flag is set synchronously by settle(); onStdout callbacks
// that await can rely on isSettled() reflecting any close-handler
// settlement that occurred during the await. Do not make settle() async.
child.stdout.on('data', async (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (settled) return;
try {
await opts.onStdout({
stdoutBuffer,
isSettled: () => settled,
settle,
resolve,
reject,
});
} catch (err) {
settle(() => reject(err instanceof Error ? err : new Error(String(err))));
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString();
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
}
});
child.once('close', (code) => {
if (settled) return;
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
return;
}
if (isEvalServerBindRestriction(stderrBuffer)) {
settle(() => {
console.warn(
`[test tolerated] eval-server could not bind in this environment; --host wiring not verified.\nstderr: ${stderrBuffer.trim()}`,
);
resolve();
});
return;
}
settle(() =>
reject(
new Error(
`eval-server exited unexpectedly (code=${code}) before READY signal.\nstderr: ${stderrBuffer.trim() || '<empty>'}`,
),
),
);
});
});
}
describe('CLI end-to-end', () => {
it('status command exits cleanly', () => {
const result = runCli('status', MINI_REPO);
@ -1263,43 +1394,12 @@ describe('CLI end-to-end', () => {
describe('eval-server --host flag', () => {
it('emits READY signal containing the bound host 127.0.0.1', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--import',
tsxImportUrl,
cliEntry,
'eval-server',
'--port',
'0',
'--host',
'127.0.0.1',
'--idle-timeout',
'3',
],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGTERM');
fn();
};
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) {
return runEvalServerHostFlagTest(
['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server did not emit READY signal within 30s',
onStdout({ stdoutBuffer, settle, resolve, reject }) {
if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) return;
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) {
settle(resolve);
} else {
@ -1311,199 +1411,108 @@ describe('CLI end-to-end', () => {
),
);
}
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString();
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
}
});
const timer = setTimeout(() => {
settle(() => reject(new Error('eval-server did not emit READY signal within 30s')));
}, 30000);
});
},
},
);
}, 35000);
it('binds to 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--import',
tsxImportUrl,
cliEntry,
'eval-server',
'--port',
'0',
'--host',
'0.0.0.0',
'--idle-timeout',
'3',
],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
return runEvalServerHostFlagTest(
['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server --host 0.0.0.0 did not emit READY signal within 30s',
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:'));
if (!readyLine || isSettled()) return;
let stdoutBuffer = '';
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGTERM');
fn();
};
child.stdout.on('data', async (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:'));
if (!readyLine || settled) return;
// Parse the actual OS-assigned port from the READY signal
const boundPort = readyLine.split(':').pop()?.trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)));
return;
}
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
try {
const res = await fetch(`http://127.0.0.1:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
// Parse the actual OS-assigned port from the READY signal
const boundPort = readyLine.split(':').pop()?.trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() =>
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
);
return;
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`,
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
try {
const res = await fetch(`http://127.0.0.1:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`,
),
),
),
);
}
});
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
if (text.includes('unknown option') || text.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`)));
}
});
const timer = setTimeout(() => {
settle(() =>
reject(new Error('eval-server --host 0.0.0.0 did not emit READY signal within 30s')),
);
}, 30000);
});
);
}
},
},
);
}, 35000);
it('emits READY signal with bound IP (not literal "localhost") when --host localhost is used', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--import',
tsxImportUrl,
cliEntry,
'eval-server',
'--port',
'0',
'--host',
'localhost',
'--idle-timeout',
'3',
],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
return runEvalServerHostFlagTest(
['--port', '0', '--host', 'localhost', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server --host localhost did not emit READY signal within 30s',
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
if (!readyLine || isSettled()) return;
let stdoutBuffer = '';
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGTERM');
fn();
};
child.stdout.on('data', async (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
if (!readyLine || settled) return;
// The signal must contain a real bound IP, not the literal input string
if (readyLine.includes(':localhost:')) {
settle(() =>
reject(
new Error(
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
// The signal must contain a real bound IP, not the literal input string
if (readyLine.includes(':localhost:')) {
settle(() =>
reject(
new Error(
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
),
),
),
);
return;
}
// Parse host and port: everything after the prefix up to the last colon
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
const lastColon = withoutPrefix.lastIndexOf(':');
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)));
return;
}
// Probe /health at the bound address to confirm the server is reachable
try {
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
);
return;
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
// Parse host and port: everything after the prefix up to the last colon
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
const lastColon = withoutPrefix.lastIndexOf(':');
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() =>
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
);
return;
}
// Probe /health at the bound address to confirm the server is reachable
try {
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
),
),
),
);
}
});
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
if (text.includes('unknown option') || text.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`)));
}
});
const timer = setTimeout(() => {
settle(() =>
reject(new Error('eval-server --host localhost did not emit READY signal within 30s')),
);
}, 30000);
});
);
}
},
},
);
}, 35000);
});
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
/**
* Inline copy of the production regex predicate. The production
* function lives inside `cli-e2e.test.ts` as a test-internal helper;
* if it later moves to a shared util this test should import that
* util directly. For now we duplicate the regex so a precedence
* regression in cli-e2e.test.ts fails this unit test as well as the
* integration tests.
*/
function isEvalServerBindRestriction(stderr: string): boolean {
return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test(
stderr,
);
}
describe('isEvalServerBindRestriction', () => {
describe('matches genuine libuv bind-restriction errors', () => {
it.each([
['Error: listen EACCES: permission denied 127.0.0.1:80'],
['Error: listen EPERM: operation not permitted 0.0.0.0:80'],
['Error: listen EADDRNOTAVAIL: address not available 192.168.1.99:0'],
['something then bind EACCES: permission denied'],
['LISTEN EACCES: permission denied'],
])('matches %p', (stderr) => {
expect(isEvalServerBindRestriction(stderr)).toBe(true);
});
});
describe('does not match unrelated stderr', () => {
it.each([
["Error: EACCES: permission denied, open '/etc/shadow'"],
['Error: ENOENT: no such file or directory'],
['sandbox blocked: operation not permitted'],
['GITNEXUS_EVAL_SERVER_READY:127.0.0.1:5173'],
[''],
['unknown option --host'],
])('does not match %p', (stderr) => {
expect(isEvalServerBindRestriction(stderr)).toBe(false);
});
});
});