Skip to content

Commit a9a1010

Browse files
authored
Fix early Issues disconnect handling (#135)
1 parent 91e49c1 commit a9a1010

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

‎test/issue-board.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,53 @@ describe('issue endpoints', { timeout: 30_000 }, () => {
171171
} finally { spy.mockRestore(); await app.close(); }
172172
});
173173

174+
it('stops waiting when the response closed before the Issues listener was attached', async () => {
175+
root = mkdtempSync(join(tmpdir(), 'codeboost-issues-'));
176+
const { gateway, calls } = heldGateway();
177+
const waits: Promise<unknown>[] = [];
178+
let closed = false;
179+
let closedBeforeRefresh = false;
180+
let disconnectNext = true;
181+
const resumeHandler = deferred<void>();
182+
const original = IssueBoard.prototype.refresh;
183+
const spy = vi.spyOn(IssueBoard.prototype, 'refresh').mockImplementation(function (this: IssueBoard, signal) {
184+
closedBeforeRefresh = closed;
185+
const wait = original.call(this, signal);
186+
waits.push(wait.then(() => 'settled', error => String(error)));
187+
return wait;
188+
});
189+
const app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, gateway,
190+
undefined, undefined, undefined, { beforeIssueRefreshWait: () => resumeHandler.promise });
191+
app.server.prependListener('request', (req, res) => {
192+
if (req.url !== '/api/issues' || !disconnectNext) return;
193+
disconnectNext = false;
194+
req.once('end', () => {
195+
res.once('close', () => { closed = true; resumeHandler.resolve(); });
196+
res.destroy();
197+
});
198+
});
199+
try {
200+
const leaving = call(app.url, app.token, 'POST', { action: 'refresh' }).catch(() => undefined);
201+
await expect.poll(() => waits.length).toBe(1);
202+
expect(closedBeforeRefresh).toBe(true);
203+
const staying = call(app.url, app.token, 'POST', { action: 'refresh' });
204+
await expect.poll(() => waits.length).toBe(2);
205+
let settled = false;
206+
void waits[0]!.then(() => { settled = true; });
207+
await expect.poll(() => settled, { timeout: 500 }).toBe(true);
208+
expect(await waits[0]).toContain('Client disconnected');
209+
expect(calls[0]!.signal!.aborted).toBe(false);
210+
calls[0]!.result.resolve(snapshot());
211+
await expect(staying).resolves.toMatchObject({ status: 200, body: { state: { state: 'fresh' } } });
212+
expect(calls).toHaveLength(1);
213+
await leaving;
214+
} finally {
215+
calls[0]?.result.resolve(snapshot());
216+
spy.mockRestore();
217+
await app.close();
218+
}
219+
});
220+
174221
it('shutdown aborts an admitted refresh and waits for the retrieval to settle', async () => {
175222
root = mkdtempSync(join(tmpdir(), 'codeboost-issues-'));
176223
const { gateway, calls } = heldGateway();

‎web/server.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ export type RunnerSetup = (service: ReviewService, capability: ShutdownCapabilit
5050
export const RUNNER_NOT_CONFIGURED = 'The runner is not configured. Add a runner block to the review configuration and restart codeboost.';
5151
/** Demos never run the runner, whatever their configuration says. */
5252
export const RUNNER_NOT_IN_DEMO = 'Demos do not run the runner. Use a review configuration with a runner block.';
53-
export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent, mergeGateway?: MergeGateway, shutdownDrainMs = MAX_SHUTDOWN_DRAIN_MS, issueGateway?: IssueGateway, runnerDeps?: RunnerDeps, planningInput?: PlanningDeps | PlanningSetup, runnerSetup?: RunnerSetup) {
53+
export interface ServerTestHooks {
54+
/** Test-only gate for forcing a response lifecycle transition before the Issues wait is armed. */
55+
beforeIssueRefreshWait?(): Promise<void> | void;
56+
}
57+
export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent, mergeGateway?: MergeGateway, shutdownDrainMs = MAX_SHUTDOWN_DRAIN_MS, issueGateway?: IssueGateway, runnerDeps?: RunnerDeps, planningInput?: PlanningDeps | PlanningSetup, runnerSetup?: RunnerSetup, testHooks: ServerTestHooks = {}) {
5458
if (!Number.isSafeInteger(shutdownDrainMs) || shutdownDrainMs < 1 || shutdownDrainMs > MAX_SHUTDOWN_DRAIN_MS) throw new Error('Invalid shutdown drain deadline.');
5559
const service = new ReviewService(config), token = randomBytes(32).toString('hex');
5660
let questions: Questions, merges: MergeCoordinator | null, issues: IssueBoard, runner: RunnerCoordinator | null, suggestions: SuggestionCoordinator | null;
@@ -488,10 +492,13 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
488492
if (stopping && input.action === 'merge') { json(503, { error: 'The review server is shutting down.' }); return; }
489493
if(path==='/api/issues') {
490494
if(input?.action!=='refresh')throw new Error('Unsupported issue action.');
495+
await testHooks.beforeIssueRefreshWait?.();
491496
// A departing browser stops waiting; the board keeps the shared refresh for other callers.
492497
const departed=new AbortController();
493498
const depart=()=>{if(!res.writableEnded)departed.abort(new Error('Client disconnected.'));};
494499
res.once('close',depart);
500+
// `close` may have fired while the request body was being read, before this listener existed.
501+
if(res.closed||res.destroyed)depart();
495502
try { json(200,await issues.refresh(AbortSignal.any([requestAbort.signal,departed.signal]))); }
496503
finally { res.removeListener('close',depart); }
497504
return;

0 commit comments

Comments
 (0)