118 lines
4.6 KiB
TypeScript
118 lines
4.6 KiB
TypeScript
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,
|
||
})
|
||
}
|
||
}
|