diff --git a/commands/nntp_probe.ts b/commands/nntp_probe.ts new file mode 100644 index 0000000..74c02f4 --- /dev/null +++ b/commands/nntp_probe.ts @@ -0,0 +1,105 @@ +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 + + 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}.` + ) + const span = (from: number, to: number) => + [Math.max(this.info.low, from), Math.min(this.info.high, to)] as const + 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 { + 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` : ''}` + ) + 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: true, + timeoutMs: this.timeoutMs, + }) + } +}