* fix: add concurrency controls for multiple sessions using the same provider Adds OS-inspired scheduling primitives (from HiveMind/AIMD research) to prevent concurrent sessions from the same provider from experiencing slowdowns, random stops, and cascading failures. Server-side: - Health check skips OpenCode restart when sessions are actively busy — a busy server under concurrent load can fail the health check timeout without being dead. Staleness guard forces restart if unhealthy+busy persists >2 minutes. - Upstream SSE stall timeout scaled from 20s to 60s to avoid unnecessary reconnections when multiple sessions are waiting for LLM responses. Client-side (HiveMind primitives, arXiv:2604.17111): - Transparent retry with exponential backoff (1s→2s→4s, max 32s) for 429/502/503/504 errors — the #1 most effective primitive from the paper. - Circuit breaker: opens after 3 consecutive retryable errors, cooldown doubles each trip (30s→60s→120s, capped 128s), matching TCP AIMD. - Per-provider session tracking with TTL eviction (1h idle sweep). - Fetch-level retry gated on AbortError/TypeError only (not DNS failures). Refs github-code-review skill findings (all 8 issues resolved). * fix: use definite assignment assertion for response variable Fixes TS2454: Variable 'response' is used before being assigned in strict mode. The for-loop body always assigns it on every path that reaches the post-loop code, but TS can't prove that. * fix: add cleanupSession to error paths and remove unreachable code P1 fixes (Greptile review): - cleanupSession called on fetch error throw path - cleanupSession called on non-retryable HTTP error throw path - Removed unreachable post-loop code (loop always terminates via return or throw) Adds explicit post-loop throw to satisfy TypeScript strict return check. * fix: address Greptile review feedback on concurrent session controls Removes client-side session tracking that leaked on normal completion paths. The session tracking was redundant — the server-side health check already reads from sessionRuntime.getSessionActivitySnapshot() for busy-session detection. Changes: - Remove activeSessions Set and all session-tracking functions from provider-tracker - Remove trackSessionStarted/cleanupSession calls from client.ts - Remove unreachable (response as Response) block after retry loop - Make upstreamStallTimeoutMs conditional: 60s when >1 sessions, 20s otherwise Refs #1069 * fix: enforce dynamic concurrency safeguards --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
205 lines
5.3 KiB
JavaScript
205 lines
5.3 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { createUpstreamSseReader } from './upstream-reader.js';
|
|
|
|
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
|
|
const encoder = new TextEncoder();
|
|
let index = 0;
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
getReader() {
|
|
return {
|
|
async read() {
|
|
if (index < blocks.length) {
|
|
return { value: encoder.encode(blocks[index++]), done: false };
|
|
}
|
|
|
|
if (!holdOpen) {
|
|
return { value: undefined, done: true };
|
|
}
|
|
|
|
return new Promise((_resolve, reject) => {
|
|
const onAbort = () => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
const error = new Error('Aborted');
|
|
error.name = 'AbortError';
|
|
reject(error);
|
|
};
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('createUpstreamSseReader', () => {
|
|
it('emits parsed events and tracks the latest event id', async () => {
|
|
const events = [];
|
|
let reader;
|
|
|
|
reader = createUpstreamSseReader({
|
|
buildUrl: () => 'http://127.0.0.1:4096/global/event',
|
|
reconnectDelayMs: 0,
|
|
fetchImpl: async (_url, options) => createSseResponse({
|
|
signal: options.signal,
|
|
blocks: [
|
|
'id: evt-1\r\ndata: {"type":"server.connected","properties":{"directory":"/tmp/project"}}\r\n\r\n',
|
|
],
|
|
}),
|
|
onEvent(event) {
|
|
events.push(event);
|
|
reader.stop();
|
|
},
|
|
});
|
|
|
|
await reader.start();
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].eventId).toBe('evt-1');
|
|
expect(events[0].directory).toBe('/tmp/project');
|
|
expect(events[0].payload).toEqual({
|
|
type: 'server.connected',
|
|
properties: {
|
|
directory: '/tmp/project',
|
|
},
|
|
});
|
|
expect(reader.getLastEventId()).toBe('evt-1');
|
|
});
|
|
|
|
it('reconnects a stalled stream with Last-Event-ID', async () => {
|
|
const fetchLastEventIds = [];
|
|
const events = [];
|
|
let attempt = 0;
|
|
let reader;
|
|
|
|
reader = createUpstreamSseReader({
|
|
buildUrl: () => 'http://127.0.0.1:4096/global/event',
|
|
stallTimeoutMs: 10,
|
|
reconnectDelayMs: 0,
|
|
fetchImpl: async (_url, options) => {
|
|
fetchLastEventIds.push(options.headers['Last-Event-ID'] ?? null);
|
|
attempt += 1;
|
|
|
|
if (attempt === 1) {
|
|
return createSseResponse({
|
|
signal: options.signal,
|
|
holdOpen: true,
|
|
blocks: [
|
|
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
|
|
],
|
|
});
|
|
}
|
|
|
|
return createSseResponse({
|
|
signal: options.signal,
|
|
blocks: [
|
|
'id: evt-2\ndata: {"type":"session.updated","properties":{}}\n\n',
|
|
],
|
|
});
|
|
},
|
|
onEvent(event) {
|
|
events.push(event.eventId);
|
|
if (event.eventId === 'evt-2') {
|
|
reader.stop();
|
|
}
|
|
},
|
|
});
|
|
|
|
await reader.start();
|
|
|
|
expect(events).toEqual(['evt-1', 'evt-2']);
|
|
expect(fetchLastEventIds.slice(0, 2)).toEqual([null, 'evt-1']);
|
|
expect(reader.getLastEventId()).toBe('evt-2');
|
|
});
|
|
|
|
it('resolves the stall timeout for each upstream read window', async () => {
|
|
const events = [];
|
|
let attempt = 0;
|
|
let currentTimeout = 10;
|
|
let reader;
|
|
|
|
reader = createUpstreamSseReader({
|
|
buildUrl: () => 'http://127.0.0.1:4096/global/event',
|
|
stallTimeoutMs: () => currentTimeout,
|
|
reconnectDelayMs: 0,
|
|
fetchImpl: async (_url, options) => {
|
|
attempt += 1;
|
|
|
|
if (attempt === 1) {
|
|
currentTimeout = 60;
|
|
return createSseResponse({
|
|
signal: options.signal,
|
|
holdOpen: true,
|
|
blocks: [
|
|
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
|
|
],
|
|
});
|
|
}
|
|
|
|
return createSseResponse({
|
|
signal: options.signal,
|
|
blocks: [
|
|
'id: evt-2\ndata: {"type":"session.updated","properties":{}}\n\n',
|
|
],
|
|
});
|
|
},
|
|
onEvent(event) {
|
|
events.push(event.eventId);
|
|
if (event.eventId === 'evt-2') {
|
|
reader.stop();
|
|
}
|
|
},
|
|
});
|
|
|
|
await reader.start();
|
|
|
|
expect(events).toEqual(['evt-1', 'evt-2']);
|
|
expect(attempt).toBe(2);
|
|
});
|
|
|
|
it('reports unavailable upstream responses and continues reconnecting until stopped', async () => {
|
|
const errors = [];
|
|
let attempt = 0;
|
|
let reader;
|
|
|
|
reader = createUpstreamSseReader({
|
|
buildUrl: () => 'http://127.0.0.1:4096/global/event',
|
|
reconnectDelayMs: 0,
|
|
fetchImpl: async (_url, options) => {
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
return { ok: false, status: 503, body: null };
|
|
}
|
|
|
|
return createSseResponse({
|
|
signal: options.signal,
|
|
blocks: [
|
|
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
|
|
],
|
|
});
|
|
},
|
|
onError(error) {
|
|
errors.push(error);
|
|
},
|
|
onEvent() {
|
|
reader.stop();
|
|
},
|
|
});
|
|
|
|
await reader.start();
|
|
|
|
expect(errors).toEqual([
|
|
expect.objectContaining({
|
|
type: 'upstream_unavailable',
|
|
status: 503,
|
|
}),
|
|
]);
|
|
expect(attempt).toBe(2);
|
|
});
|
|
});
|