TruncatedReply: a compressed reply the server cut at a ".\r\n" fails in 2 s; ranges:dates samples plain
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:
1 parent
1e01702dff
commit
3cc7d9738e
5 files changed
+188
-42
No files matched your search
+8
-1
@@ -1,4 +1,11 @@
|
||||
export { XoverClient, MissingArticle, parseOverviews, type Overview, type XoverClientOptions } from './xover_client.js'
|
||||
export {
|
||||
XoverClient,
|
||||
MissingArticle,
|
||||
TruncatedReply,
|
||||
parseOverviews,
|
||||
type Overview,
|
||||
type XoverClientOptions,
|
||||
} from './xover_client.js'
|
||||
export { parseXrefGroups } from './xref.js'
|
||||
export { parseGroupResponse, type GroupInfo } from './group.js'
|
||||
export { hedge, type Request, type Hedged } from './hedge.js'
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user