From 162e5b0ea22b0c1bc79cd1264b29cb66688b151e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 22 May 2026 21:55:10 -0600 Subject: [PATCH 1/3] fix(tests): add AbortSignal timeouts and per-test timeouts to thread-management suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch calls had no timeout, so a slow or unavailable Harper instance on Windows could leave them hanging indefinitely — causing the test subprocess to produce no TAP output and surface as a cryptic file:1:1 failure. Add AbortSignal.timeout(5000) to every fetch and { timeout: 15000 } to each test so failures produce a clear message instead of a silent process crash. Also extract authHeader/opsRequest helpers to remove the duplicated Basic-auth encoding. Co-Authored-By: Claude Sonnet 4.6 --- .../server/thread-management.test.ts | 105 +++++------------- 1 file changed, 30 insertions(+), 75 deletions(-) diff --git a/integrationTests/server/thread-management.test.ts b/integrationTests/server/thread-management.test.ts index 66d08bf40..caad33a55 100644 --- a/integrationTests/server/thread-management.test.ts +++ b/integrationTests/server/thread-management.test.ts @@ -10,6 +10,21 @@ import { strictEqual } from 'node:assert/strict'; import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; +const REQUEST_TIMEOUT_MS = 5000; + +function authHeader(ctx: ContextWithHarper) { + return `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; +} + +function opsRequest(ctx: ContextWithHarper, body: string) { + return fetch(ctx.harper.operationsAPIURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader(ctx) }, + body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); +} + suite('Thread Management', (ctx: ContextWithHarper) => { before(async () => { await startHarper(ctx, { config: {}, env: {} }); @@ -19,20 +34,10 @@ suite('Thread Management', (ctx: ContextWithHarper) => { await teardownHarper(ctx); }); - test('server handles concurrent requests across threads', async () => { - // Send multiple concurrent requests to verify thread handling + test('server handles concurrent requests across threads', { timeout: 15000 }, async () => { const requests = []; for (let i = 0; i < 20; i++) { - requests.push( - fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, - }, - body: JSON.stringify({ operation: 'describe_all' }), - }) - ); + requests.push(opsRequest(ctx, JSON.stringify({ operation: 'describe_all' }))); } const responses = await Promise.all(requests); @@ -42,78 +47,28 @@ suite('Thread Management', (ctx: ContextWithHarper) => { } }); - test('server recovers from malformed requests without affecting subsequent requests', async () => { - // Send multiple malformed requests - const badRequests = []; - for (let i = 0; i < 5; i++) { - badRequests.push( - fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, - }, - body: 'not json', - }) - ); - } - - const badResponses = await Promise.all(badRequests); + test('server recovers from malformed requests without affecting subsequent requests', { timeout: 15000 }, async () => { + const badResponses = await Promise.all( + Array.from({ length: 5 }, () => opsRequest(ctx, 'not json')) + ); for (const response of badResponses) { strictEqual(response.status, 400); } - // Server should still handle good requests after bad ones - const goodRequests = []; - for (let i = 0; i < 5; i++) { - goodRequests.push( - fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, - }, - body: JSON.stringify({ operation: 'describe_all' }), - }) - ); - } - - const goodResponses = await Promise.all(goodRequests); + const goodResponses = await Promise.all( + Array.from({ length: 5 }, () => opsRequest(ctx, JSON.stringify({ operation: 'describe_all' }))) + ); for (const response of goodResponses) { strictEqual(response.status, 200, 'Server should recover and handle valid requests'); } }); - test('server handles mixed concurrent valid and invalid requests', async () => { - // Mix of good and bad requests simultaneously - const requests = []; - for (let i = 0; i < 20; i++) { - if (i % 3 === 0) { - // Bad request - requests.push( - fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, - }, - body: 'invalid json', - }).then((r) => ({ status: r.status, expected: 400 })) - ); - } else { - // Good request - requests.push( - fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, - }, - body: JSON.stringify({ operation: 'describe_all' }), - }).then((r) => ({ status: r.status, expected: 200 })) - ); - } - } + test('server handles mixed concurrent valid and invalid requests', { timeout: 15000 }, async () => { + const requests = Array.from({ length: 20 }, (_, i) => + i % 3 === 0 + ? opsRequest(ctx, 'invalid json').then((r) => ({ status: r.status, expected: 400 })) + : opsRequest(ctx, JSON.stringify({ operation: 'describe_all' })).then((r) => ({ status: r.status, expected: 200 })) + ); const results = await Promise.all(requests); From 822cb988b59cd5bf398fd112e8e1ff825b54a8e7 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 22 May 2026 22:00:04 -0600 Subject: [PATCH 2/3] style: apply prettier formatting to thread-management.test.ts Co-Authored-By: Claude Sonnet 4.6 --- .../server/thread-management.test.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/integrationTests/server/thread-management.test.ts b/integrationTests/server/thread-management.test.ts index caad33a55..2e7c022c9 100644 --- a/integrationTests/server/thread-management.test.ts +++ b/integrationTests/server/thread-management.test.ts @@ -19,7 +19,7 @@ function authHeader(ctx: ContextWithHarper) { function opsRequest(ctx: ContextWithHarper, body: string) { return fetch(ctx.harper.operationsAPIURL, { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: authHeader(ctx) }, + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader(ctx) }, body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); @@ -47,27 +47,32 @@ suite('Thread Management', (ctx: ContextWithHarper) => { } }); - test('server recovers from malformed requests without affecting subsequent requests', { timeout: 15000 }, async () => { - const badResponses = await Promise.all( - Array.from({ length: 5 }, () => opsRequest(ctx, 'not json')) - ); - for (const response of badResponses) { - strictEqual(response.status, 400); - } + test( + 'server recovers from malformed requests without affecting subsequent requests', + { timeout: 15000 }, + async () => { + const badResponses = await Promise.all(Array.from({ length: 5 }, () => opsRequest(ctx, 'not json'))); + for (const response of badResponses) { + strictEqual(response.status, 400); + } - const goodResponses = await Promise.all( - Array.from({ length: 5 }, () => opsRequest(ctx, JSON.stringify({ operation: 'describe_all' }))) - ); - for (const response of goodResponses) { - strictEqual(response.status, 200, 'Server should recover and handle valid requests'); + const goodResponses = await Promise.all( + Array.from({ length: 5 }, () => opsRequest(ctx, JSON.stringify({ operation: 'describe_all' }))) + ); + for (const response of goodResponses) { + strictEqual(response.status, 200, 'Server should recover and handle valid requests'); + } } - }); + ); test('server handles mixed concurrent valid and invalid requests', { timeout: 15000 }, async () => { const requests = Array.from({ length: 20 }, (_, i) => i % 3 === 0 ? opsRequest(ctx, 'invalid json').then((r) => ({ status: r.status, expected: 400 })) - : opsRequest(ctx, JSON.stringify({ operation: 'describe_all' })).then((r) => ({ status: r.status, expected: 200 })) + : opsRequest(ctx, JSON.stringify({ operation: 'describe_all' })).then((r) => ({ + status: r.status, + expected: 200, + })) ); const results = await Promise.all(requests); From 218286745967d8e9b3ebe2fca3147863fc94d96d Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 22 May 2026 22:05:38 -0600 Subject: [PATCH 3/3] fix(tests): flush pending subscription events before closing in replay tests After await concurrentWrites, all puts have committed but their subscription events may still be queued as async callbacks. Calling subscription.return() immediately can close the stream before those callbacks fire, silently dropping the last write(s). Adding await delay(200) gives pending events time to arrive so the assertions see all committed ids. Fixes the intermittent "missing concurrent id N" failure in the startTime, count, and !omitCurrent concurrent-write tests. Co-Authored-By: Claude Sonnet 4.6 --- unitTests/resources/subscriptionReplay.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unitTests/resources/subscriptionReplay.test.js b/unitTests/resources/subscriptionReplay.test.js index 0d6630e36..99d94d39b 100644 --- a/unitTests/resources/subscriptionReplay.test.js +++ b/unitTests/resources/subscriptionReplay.test.js @@ -127,6 +127,10 @@ describe('Subscription replay', () => { })(); const events = await collect(subscription, 100); await concurrentWrites; + // All writes have committed; wait for any pending subscription events to flush + // before closing. Without this, the last write's event can be queued but not + // yet delivered when return() closes the stream. + await delay(200); subscription.return?.(); const ids = new Set(events.map((e) => e.id)); @@ -180,6 +184,7 @@ describe('Subscription replay', () => { })(); const events = await collect(subscription, 150); await concurrentWrites; + await delay(200); subscription.return?.(); // concurrent writes should arrive @@ -236,6 +241,7 @@ describe('Subscription replay', () => { })(); const events = await collect(subscription, 150); await concurrentWrites; + await delay(200); subscription.return?.(); // every updated key MUST be delivered with its final value at least once, even if