TruncatedReply: a compressed reply the server cut at a ".\r\n" fails in 2 s; ranges:dates samples plain
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m4s

Eweka stops a compressed XOVER reply at the first ".\r\n" in its own
output (78 01 2e 0d 0a and nothing more), which some small replies begin
with; the text path waited for more until the reply timeout. ranges:dates
uses plain connections of its own instead of the pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Fable 5.1 committed 2026-10-25 12:49:53 +01:00
1 parent 1e01702dff
commit 3cc7d9738e
5 files changed
+188 -42

No files matched your search

+31 -1
View File
@@ -36,6 +36,14 @@ const DOT_LINE = Buffer.from('.\r\n')
*/
/** The server doesn't have the article (430): taken down or expired. */
export class MissingArticle extends Error {}
/**
* A compressed reply the server cut short: it ends its compressed output at the first ".\r\n" in it, as if that
* were the terminator, and sends nothing more (Eweka; "78 01 2e 0d 0a" for 16 of 14k small XOVERs, LOG.md
* 2026-10-25 (105)). The same request without compression answers.
*/
export class TruncatedReply extends Error {}
/** How long a compressed reply that doesn't inflate may stay silent before it counts as cut short. */
const TRUNCATED_MS = 2_000
export class XoverClient {
private chunks: Buffer[] = []
@@ -244,6 +252,21 @@ export class XoverClient {
if (this.failure && this.length === 0) throw this.failure
}
/** As more(), but false when nothing came within `ms`. */
private async moreWithin(ms: number): Promise<boolean> {
if (this.failure) throw this.failure
const came = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), ms)
this.wake = () => {
clearTimeout(timer)
resolve(true)
}
})
this.wake = undefined
if (this.failure && this.length === 0) throw this.failure
return came
}
private async line(): Promise<string> {
while (true) {
const at = this.buffer().indexOf('\r\n')
@@ -389,7 +412,14 @@ export class XoverClient {
this.take(all.length)
return { raw, text: inflated.toString('utf8') }
} catch {
// ".\r\n" inside the compressed data: wait for more.
// ".\r\n" inside the compressed data: wait for more, but not for the reply timeout: the server may have
// cut its output there (TruncatedReply), and then nothing comes. Raw, the chunk is returned as it is
// and reported by the worker; the text is wanted here.
if (!(await this.moreWithin(TRUNCATED_MS))) {
this.take(all.length)
throw new TruncatedReply(`NNTP: compressed reply cut short after ${all.length} bytes`)
}
continue
}
}
await this.more()