fix(lease): raise capability-probe timeout to 10s on both halves (#869) (#1207)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
This commit was merged in pull request #1207.
This commit is contained in:
@@ -7,11 +7,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
LEASE_ACTIVATION_CAPABILITY,
|
||||
LEASE_CAPABILITY_PROBE_COMMAND,
|
||||
LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
||||
defaultCapabilityProbe,
|
||||
defaultResolveCliEntry,
|
||||
defaultSupervisorProbe,
|
||||
leaseEnforcementActivatable,
|
||||
registerLeaseCapabilityProbe,
|
||||
type CapabilityProbeExecFile,
|
||||
type LeaseActivationCapability,
|
||||
type SupervisorProbeResult,
|
||||
} from './lease-activation-probe.js';
|
||||
@@ -35,6 +37,17 @@ const presentSupervisor: SupervisorProbeResult = {
|
||||
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
||||
};
|
||||
|
||||
function withScratchCli<T>(run: (cliPath: string) => T): T {
|
||||
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
|
||||
try {
|
||||
const cliPath = join(scratchDir, 'cli.js');
|
||||
writeFileSync(cliPath, '// isolated fake; injected execFile means this is never executed\n');
|
||||
return run(cliPath);
|
||||
} finally {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('leaseEnforcementActivatable', () => {
|
||||
it('is false when the activation capability is absent (null)', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
@@ -100,15 +113,6 @@ describe('leaseEnforcementActivatable', () => {
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the real default probes when no deps are injected (does not throw)', () => {
|
||||
// No live broker / built CLI is guaranteed in a test environment, so this
|
||||
// only asserts the predicate degrades to a safe boolean rather than
|
||||
// throwing — the fail-closed behavior itself is covered by the injected
|
||||
// cases above.
|
||||
expect(() => leaseEnforcementActivatable()).not.toThrow();
|
||||
expect(typeof leaseEnforcementActivatable()).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultCapabilityProbe', () => {
|
||||
@@ -127,6 +131,61 @@ describe('defaultCapabilityProbe', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the exact ten-second timeout to the injected child-process transport', () => {
|
||||
withScratchCli((cliPath) => {
|
||||
let captured:
|
||||
| {
|
||||
file: string;
|
||||
args: string[];
|
||||
options: Parameters<CapabilityProbeExecFile>[2];
|
||||
}
|
||||
| undefined;
|
||||
const execFile: CapabilityProbeExecFile = (file, args, options) => {
|
||||
captured = { file, args, options };
|
||||
return JSON.stringify(LEASE_ACTIVATION_CAPABILITY);
|
||||
};
|
||||
|
||||
const result = defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile });
|
||||
|
||||
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
|
||||
expect(captured).toEqual({
|
||||
file: process.execPath,
|
||||
args: [cliPath, LEASE_CAPABILITY_PROBE_COMMAND],
|
||||
options: {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10_000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
},
|
||||
});
|
||||
expect(captured?.options.timeout).toBe(LEASE_CAPABILITY_PROBE_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['timeout', Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })],
|
||||
['spawn error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })],
|
||||
['nonzero exit', Object.assign(new Error('child exited 1'), { status: 1 })],
|
||||
])('returns null (fail-closed) on child-process %s', (_failure, error) => {
|
||||
withScratchCli((cliPath) => {
|
||||
const execFile: CapabilityProbeExecFile = () => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not-json'],
|
||||
['malformed object', JSON.stringify({ name: LEASE_ACTIVATION_CAPABILITY.name })],
|
||||
])('returns null (fail-closed) on %s output', (_failure, output) => {
|
||||
withScratchCli((cliPath) => {
|
||||
const execFile: CapabilityProbeExecFile = () => output;
|
||||
|
||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
||||
// A prior version of this test staged the stub cli.js at the package's
|
||||
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
||||
|
||||
@@ -55,6 +55,19 @@ export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
|
||||
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
||||
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
||||
|
||||
/**
|
||||
* Budget for the out-of-process capability probe. The probe launches a fresh
|
||||
* Node process on the built CLI entrypoint, whose cold start alone measures
|
||||
* 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
|
||||
* previous 2s budget made the probe time out and report NO capability on
|
||||
* such hosts, failing every launch with the #869 skew message even though
|
||||
* the capability matched. The timeout only bounds the pathological hang
|
||||
* case; the happy path returns as soon as the probe exits. Mirrors
|
||||
* PROBE_TIMEOUT_SECONDS in the enforcement half
|
||||
* (framework/tools/lease-broker/activation_version_gate.py).
|
||||
*/
|
||||
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;
|
||||
|
||||
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
||||
return (
|
||||
candidate !== null &&
|
||||
@@ -110,12 +123,28 @@ export function defaultResolveCliEntry(
|
||||
return join(dirname(mainEntry), 'cli.js');
|
||||
}
|
||||
|
||||
/** Narrow injectable seam for the synchronous child process used by the
|
||||
* capability probe. */
|
||||
export type CapabilityProbeExecFile = (
|
||||
file: string,
|
||||
args: string[],
|
||||
options: {
|
||||
encoding: BufferEncoding;
|
||||
timeout: number;
|
||||
stdio: ['ignore', 'pipe', 'ignore'];
|
||||
},
|
||||
) => string;
|
||||
|
||||
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
||||
export interface CapabilityProbeDeps {
|
||||
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
||||
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
||||
* location in tests — never at the real package's `dist/`. */
|
||||
resolveCliEntry?: () => string;
|
||||
/** Execute the resolved CLI entrypoint. Defaults to the real
|
||||
* `execFileSync`. Inject so transport behavior and options can be tested
|
||||
* without spawning a process. */
|
||||
execFile?: CapabilityProbeExecFile;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +168,10 @@ export function defaultCapabilityProbe(
|
||||
const cliEntry = resolveCliEntry();
|
||||
if (!existsSync(cliEntry)) return null;
|
||||
|
||||
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
|
||||
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user