diff --git a/lib/nntp/xover_client.ts b/lib/nntp/xover_client.ts index 0f3de19..dcf0f51 100644 --- a/lib/nntp/xover_client.ts +++ b/lib/nntp/xover_client.ts @@ -118,7 +118,7 @@ export class XoverClient { const reply = await this.command(`XOVER ${from}-${to}`) if (reply.startsWith('420') || reply.startsWith('423')) return deflateSync(Buffer.alloc(0)) await this.expect(reply, ['224']) - if (reply.includes('COMPRESS=GZIP')) return (await this.compressedRaw()).raw + if (reply.includes('COMPRESS=GZIP')) return (await this.compressedRaw(false)).raw // Plain replies (the relay's default): compressed off the event loop, at the fastest level. return deflateAsync(Buffer.from(await this.plainBody()), { level: 1 }) }) @@ -214,19 +214,36 @@ export class XoverClient { } private async compressedBody(): Promise { - return (await this.compressedRaw()).text + return (await this.compressedRaw(true)).text } - /** A zlib/gzip stream followed by a "." line: complete once it ends in ".\r\n" and inflates. */ - private async compressedRaw(): Promise<{ raw: Buffer; text: string }> { + /** Whether what was received so far ends in `tail`, without joining the pieces. */ + private endsWith(tail: Buffer): boolean { + if (this.length < tail.length) return false + let need = tail.length + for (let i = this.chunks.length - 1; i >= 0 && need > 0; i--) { + const chunk = this.chunks[i] + const n = Math.min(need, chunk.length) + if (!chunk.subarray(chunk.length - n).equals(tail.subarray(need - n, need))) return false + need -= n + } + return need === 0 + } + + /** + * A zlib/gzip stream followed by a "." line: complete once it ends in + * ".\r\n" and inflates. The pieces are joined only then: joining them on + * every arrival was quadratic, ~15% of a prefetcher's time (LOG.md 2026-10-09). + */ + private async compressedRaw(wantText: boolean): Promise<{ raw: Buffer; text: string }> { while (true) { - const all = this.buffer() - if (all.length > DOT_LINE.length && all.subarray(all.length - DOT_LINE.length).equals(DOT_LINE)) { + if (this.length > DOT_LINE.length && this.endsWith(DOT_LINE)) { + const all = this.buffer() const raw = all.subarray(0, all.length - DOT_LINE.length) try { - const text = unzipSync(raw).toString('utf8') + const inflated = unzipSync(raw) this.take(all.length) - return { raw: Buffer.from(raw), text } + return { raw: Buffer.from(raw), text: wantText ? inflated.toString('utf8') : '' } } catch { // ".\r\n" inside the compressed data: wait for more. }