test/connect-unary-timeout.test.js
Rama master · réplica de lectura del repositorio original.
// GAP-1 (RATE-RETRY audit, 2026-10-15): the GetUserJwt unary mint had NO timeout.
//
// The mint runs BEFORE the chat request is built (streamChat awaits it), and
// mintUserJwt coalesces concurrent mints per (token, host). So one upstream that
// accepts the TCP connection and then never answers hung the chat request forever
// AND amplified that hang to every other request sharing the token — with no timer
// running anywhere: the stream's own absolute deadline starts after the mint, and
// a cold cache entry has nothing to serve.
//
// These tests pin the bound and the three properties it must have:
// 1. the mint resolves null at the deadline — the same resolve-null contract as
// every other failure in postConnectUnary — instead of hanging;
// 2. it does not fire early: a mint that degrades to null immediately is a mint
// that silently disables the opt-in feature;
// 3. the deadline destroys the socket and releases the in-flight slot, so the
// next request mints again instead of joining a promise that already gave up.
//
// The hanging transport is injected through the same __setRequestImpl seam the
// body-bound tests use, so no socket is opened (test/setup-env.mjs also installs a
// fail-closed network guard). The module is imported as a namespace on purpose:
// pre-fix there is no USER_JWT_MINT_TIMEOUT_MS export, and a named import would
// fail at load time — turning the behavioural red (the mint hangs) into an import
// error that says nothing about the bug.
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
const connect = await import('../src/devin-connect.js');
const { mintUserJwt, invalidateUserJwtCache, __setRequestImpl } = connect;
const { writeStringField } = await import('../src/proto.js');
const { wrapEnvelope } = await import('../src/connect.js');
const TOKEN = 'devin-session-token$gap1';
const JWT = 'header.payload.signature';
// Generous next to the deadlines under test (150-250ms) so a loaded machine
// cannot turn "the bound works" into a flaky watchdog hit; still far below the
// 15s default, so a missing deadline cannot pass by luck.
const WATCHDOG_MS = 3000;
const WATCHDOG = Symbol('watchdog: the mint never settled');
/** Await `p` with a watchdog that is cleared as soon as either side settles. */
async function withWatchdog(p, ms = WATCHDOG_MS) {
let timer;
const watchdog = new Promise((resolve) => { timer = setTimeout(() => resolve(WATCHDOG), ms); });
try {
return await Promise.race([p, watchdog]);
} finally {
clearTimeout(timer);
}
}
/**
* A transport that accepts the request and then never answers: no response
* callback, no error, no close. `destroy()` is recorded because the bound is only
* a bound if it also stops the connection from sitting there — resolving null
* while leaving the request open leaks one connection per mint.
*/
function hangingTransport() {
const state = { calls: 0, destroyed: 0 };
state.impl = () => {
state.calls += 1;
const req = new EventEmitter();
req.write = () => {};
req.end = () => {};
req.destroy = () => { state.destroyed += 1; };
return req;
};
return state;
}
/**
* A transport that answers with a valid credential after `delayMs`. The delay is
* what makes the "0 must not mean instant" case deterministic: an instant timer
* fires first and the mint returns null, while the documented fallback (15s)
* leaves the response time to arrive.
*/
function answeringTransport(delayMs = 5) {
return (_opts, cb) => {
const listeners = {};
const res = {
statusCode: 200,
on(ev, fn) { listeners[ev] = fn; return res; },
};
setTimeout(() => {
cb(res);
listeners.data?.(wrapEnvelope(writeStringField(1, JWT), { compress: false }));
listeners.end?.();
}, delayMs);
const req = new EventEmitter();
req.write = () => {};
req.end = () => {};
req.destroy = () => {};
return req;
};
}
beforeEach(() => { invalidateUserJwtCache(); });
afterEach(() => { __setRequestImpl(null); invalidateUserJwtCache(); });
describe('the GetUserJwt mint is bounded (GAP-1)', () => {
it('resolves null at the deadline instead of hanging, and destroys the request', async () => {
const transport = hangingTransport();
__setRequestImpl(transport.impl);
const timeoutMs = 250;
const started = Date.now();
const result = await withWatchdog(mintUserJwt(TOKEN, { timeoutMs }));
const elapsed = Date.now() - started;
assert.notEqual(result, WATCHDOG, `the mint must settle within ${timeoutMs}ms, not hang`);
assert.equal(result, null, 'a timed-out mint degrades to null, exactly like a non-200');
assert.ok(
elapsed >= timeoutMs / 2,
`settled after ${elapsed}ms — the deadline must be a deadline, not an early bail`,
);
assert.equal(transport.calls, 1);
assert.equal(transport.destroyed, 1, 'the deadline must destroy the request it gave up on');
});
it('releases the in-flight slot, so the next mint opens a NEW request', async () => {
const transport = hangingTransport();
__setRequestImpl(transport.impl);
const first = await withWatchdog(mintUserJwt(TOKEN, { timeoutMs: 150 }));
assert.equal(first, null, 'the first mint must degrade to null, not hang');
const second = await withWatchdog(mintUserJwt(TOKEN, { timeoutMs: 150 }));
assert.equal(second, null, 'the second mint must settle too, not join a promise that gave up');
assert.equal(
transport.calls, 2,
'a timed-out mint must leave _userJwtInflight, or every later request re-awaits the hang',
);
assert.equal(transport.destroyed, 2);
});
it('bounds every waiter of a coalesced burst at the ONE shared deadline', async () => {
const transport = hangingTransport();
__setRequestImpl(transport.impl);
const results = await withWatchdog(Promise.all([
mintUserJwt(TOKEN, { timeoutMs: 250 }),
mintUserJwt(TOKEN, { timeoutMs: 250 }),
mintUserJwt(TOKEN, { timeoutMs: 250 }),
]));
assert.notEqual(results, WATCHDOG, 'every waiter of the coalesced mint must settle');
assert.deepEqual(results, [null, null, null]);
assert.equal(transport.calls, 1, 'the burst still mints exactly once');
assert.equal(transport.destroyed, 1, 'one request, one deadline — not one timer per waiter');
});
it('defaults to 15s, and a 0 override falls back to it instead of arming an instant timer', async () => {
assert.equal(
connect.USER_JWT_MINT_TIMEOUT_MS, 15_000,
'the documented default: 15s, the ceiling for an RPC that gates a user request',
);
__setRequestImpl(answeringTransport(5));
assert.equal(
await withWatchdog(mintUserJwt(TOKEN, { timeoutMs: 0 })),
JWT,
'a 0 override must not fire on the next turn of the loop and kill a healthy mint',
);
});
});