From 04f945055fc48bd053ad78f1e6cbe506228258a7 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Mon, 5 Oct 2026 13:40:15 +0200 Subject: [PATCH] XoverClient times replies only, not idle time between commands A seeding node that waited over two minutes for the seed API (restarting) had its idle NNTP connection closed by its own timeout, and gave its range back. Now the timeout runs only while a reply is awaited. Co-Authored-By: Claude Opus 5.5 --- lib/nntp/xover_client.ts | 33 ++++++++++++++++++++++++++++----- tests/unit/xover_client.spec.ts | 13 +++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/nntp/xover_client.ts b/lib/nntp/xover_client.ts index c54de44..b56b428 100644 --- a/lib/nntp/xover_client.ts +++ b/lib/nntp/xover_client.ts @@ -35,7 +35,10 @@ export class XoverClient { private failure?: Error compressed = false - private constructor(private sock: net.Socket) { + private constructor( + private sock: net.Socket, + private timeoutMs: number + ) { sock.on('data', (chunk: Buffer) => { this.chunks.push(chunk) this.length += chunk.length @@ -57,8 +60,8 @@ export class XoverClient { const sock = options.secure ? tls.connect({ host: options.host, port: options.port, servername: options.host }) : net.connect({ host: options.host, port: options.port }) - sock.setTimeout(options.timeoutMs ?? 120_000) - const client = new XoverClient(sock) + const client = new XoverClient(sock, options.timeoutMs ?? 120_000) + sock.setTimeout(client.timeoutMs) try { await client.expect(await client.line(), ['200', '201']) if (options.user) { @@ -71,18 +74,38 @@ export class XoverClient { client.close() throw error } + // Idle between commands is fine (a node waiting for the seed API); only replies are timed. + sock.setTimeout(0) return client } async group(name: string): Promise<{ count: number; low: number; high: number }> { + return this.timed(() => this.selectGroup(name)) + } + + /** Overviews of articles `from`..`to` in the current group; an empty range gives []. */ + async xover(from: number, to: number): Promise { + return this.timed(() => this.overviews(from, to)) + } + + /** A reply that stalls for timeoutMs fails the connection; idle time between commands doesn't count. */ + private async timed(exchange: () => Promise): Promise { + this.sock.setTimeout(this.timeoutMs) + try { + return await exchange() + } finally { + this.sock.setTimeout(0) + } + } + + private async selectGroup(name: string): Promise<{ count: number; low: number; high: number }> { const reply = await this.command(`GROUP ${name}`) await this.expect(reply, ['211']) const [, count, low, high] = reply.split(' ') return { count: Number(count), low: Number(low), high: Number(high) } } - /** Overviews of articles `from`..`to` in the current group; an empty range gives []. */ - async xover(from: number, to: number): Promise { + private async overviews(from: number, to: number): Promise { const reply = await this.command(`XOVER ${from}-${to}`) if (reply.startsWith('420') || reply.startsWith('423')) return [] await this.expect(reply, ['224']) diff --git a/tests/unit/xover_client.spec.ts b/tests/unit/xover_client.spec.ts index c00bfe0..dad2196 100644 --- a/tests/unit/xover_client.spec.ts +++ b/tests/unit/xover_client.spec.ts @@ -73,4 +73,17 @@ test.group('nntp / XoverClient', () => { } }) } + + test('idle time between commands does not count against the reply timeout', async ({ assert }) => { + const { port, close } = await server() + const client = await XoverClient.connect({ host: '127.0.0.1', port, user: 'u', password: 'p', compress: true, timeoutMs: 200 }) + try { + await client.group('a.b.test') + await new Promise((resolve) => setTimeout(resolve, 500)) + assert.lengthOf(await client.xover(101, 102), 2) + } finally { + client.close() + close() + } + }) })