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 { 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, }) } }