Files
usenet-indexer/commands/nntp_probe.ts
T
Daan MeijerandClaude Fable 5.1 1e01702dff
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 1m14s
nntp:probe: shows the bytes a stalled reply left
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:40:02 +01:00

118 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import env from '#start/env'
import { XoverClient } from '#lib/nntp/index'
/**
* What the news server does with XOVER spans around an article that made a request hang (ranges:dates: one point
* per group timed out after 2 minutes, again on a fresh connection; LOG.md 2026-10-25 (105)). Each probe on a
* connection of its own with a short timeout: the span asked for, the reply, how many lines, how long. First the
* span itself and wider ones around it, then halves of it down to single articles, then a span past `high`.
*/
export default class NntpProbe extends BaseCommand {
static commandName = 'nntp:probe'
static description =
'Probes XOVER spans around an article that hangs, bisecting to the articles at fault'
static options: CommandOptions = { startApp: true }
@args.string({ description: 'Group' })
declare group: string
@args.string({ description: 'First article of the span that hung' })
declare article: string
@flags.number({ description: 'Articles in the span (default 32)' })
declare size?: number
@flags.number({ description: 'Seconds without data before a probe is given up (default 20)' })
declare timeout?: number
@flags.boolean({ description: 'Only the span itself and the wider ones, no bisection' })
declare noBisect: boolean
@flags.boolean({ description: 'Without XFEATURE COMPRESS GZIP' })
declare plain: boolean
@flags.string({
description: 'Only spans from the article of these sizes, e.g. "8,16,24,32,48,64"',
})
declare sizes?: string
private timeoutMs = 20_000
private info = { count: 0, low: 0, high: 0 }
async run() {
this.timeoutMs = (this.timeout ?? 20) * 1000
const a = Number(this.article)
const size = this.size ?? 32
const client = await this.connect()
this.info = await client.group(this.group)
client.close()
this.logger.info(
`${this.group}: ${this.info.count} articles, ${this.info.low}–${this.info.high}${this.plain ? '; plain' : '; compressed'}.`
)
const span = (from: number, to: number) =>
[Math.max(this.info.low, from), Math.min(this.info.high, to)] as const
if (this.sizes) {
for (const n of this.sizes.split(',').map(Number)) await this.probe(...span(a, a + n - 1))
return
}
this.logger.info('The span and wider ones:')
await this.probe(...span(a, a + size - 1))
await this.probe(...span(a, a + size * 32 - 1))
await this.probe(...span(a - size * 32, a + size - 1))
await this.probe(...span(a - 50_000, a + 49_999))
this.logger.info('Past the end, and an empty span before it:')
await this.probe(this.info.high + 100, this.info.high + 131)
await this.probe(this.info.high + 1, this.info.high + 1)
if (this.noBisect) return
this.logger.info('Halves of the span:')
await this.bisect(a, Math.min(this.info.high, a + size - 1))
}
/** Halves of a span that hangs, down to single articles; a half that answers isn't split further. */
private async bisect(from: number, to: number) {
const hung = await this.probe(from, to)
if (!hung || from === to) return
const mid = Math.floor((from + to) / 2)
await this.bisect(from, mid)
await this.bisect(mid + 1, to)
}
/** One XOVER on a fresh connection; true when it timed out. */
private async probe(from: number, to: number): Promise<boolean> {
const startedAt = Date.now()
let client: XoverClient | null = null
try {
client = await this.connect()
await client.group(this.group)
const rows = await client.xover(from, to)
const t = client.lastTiming
this.logger.info(
` XOVER ${from}-${to}: ${rows.length} lines in ${Date.now() - startedAt} ms (reply ${t.replyMs.toFixed(0)}, body ${t.bodyMs.toFixed(0)})${rows.length > 0 ? `; first ${rows[0][0]}, last ${rows[rows.length - 1][0]}` : ''}`
)
return false
} catch (error: any) {
const timedOut = /timed out/i.test(error.message)
this.logger.warning(
` XOVER ${from}-${to}: ${error.message} after ${Date.now() - startedAt} ms${client ? `, ${client.buffered} bytes buffered: ${JSON.stringify(client.bufferedHead().toString('latin1'))} (${client.bufferedHead().toString('hex')})` : ''}`
)
return timedOut
} finally {
client?.close()
}
}
private connect() {
return XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: !this.plain,
timeoutMs: this.timeoutMs,
})
}
}