diff --git a/.changeset/fix-cli-info-event-count-hang.md b/.changeset/fix-cli-info-event-count-hang.md new file mode 100644 index 00000000..a1d0706d --- /dev/null +++ b/.changeset/fix-cli-info-event-count-hang.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix(cli): stop `nostream info` and `nostream invite create` from hanging indefinitely when the database is unreachable diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index b4e43b92..d70cafef 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -1,5 +1,5 @@ import fs from 'fs' -import knex from 'knex' +import { Client } from 'pg' import packageJson from '../../../package.json' import { loadMergedSettings } from '../utils/config' @@ -25,34 +25,36 @@ type I2PGuidancePayload = { } const getEventCount = async (): Promise => { - const db = knex({ - client: 'pg', - connection: process.env.DB_URI - ? process.env.DB_URI + // Uses a raw pg.Client (connect/query/end) rather than a knex pool: when the + // connection attempt itself times out, knex/tarn's pool can leave the + // underlying socket open (never disposed), which keeps this one-shot CLI + // process alive indefinitely. pg.Client.end() reliably closes the socket + // even on a failed/timed-out connect, so the process can exit normally. + const client = new Client( + process.env.DB_URI + ? { connectionString: process.env.DB_URI, connectionTimeoutMillis: 1000 } : { host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, + connectionTimeoutMillis: 1000, }, - pool: { - min: 0, - max: 1, - idleTimeoutMillis: 1000, - acquireTimeoutMillis: 1000, - propagateCreateError: false, - }, - acquireConnectionTimeout: 1000, - } as any) + ) try { - const result = await db('events').whereNull('deleted_at').count<{ count: string | number }>('* as count').first() - return Number(result?.count ?? 0) + await client.connect() + const result = await client.query('select count(*) as count from events where deleted_at is null') + return Number(result.rows[0]?.count ?? 0) } catch { return null } finally { - await db.destroy() + try { + await client.end() + } catch { + // already disconnected/never connected — nothing to clean up + } } } @@ -67,9 +69,13 @@ const getRelayUptimeSeconds = async (): Promise => { return null } - const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], { - timeoutMs: 1000, - }) + const startedAtResult = await runCommandWithOutput( + 'docker', + ['inspect', '--format', '{{.State.StartedAt}}', containerId], + { + timeoutMs: 1000, + }, + ) if (!startedAtResult.ok || startedAtResult.code !== 0) { return null } @@ -201,7 +207,7 @@ export const runInfo = async (options: InfoOptions): Promise => { 'http://127.0.0.1:7070/?page=i2p_tunnels', ]) - const matches = new Set((`${result.stdout}\n${result.stderr}`).match(/[a-z2-7]{52}\.b32\.i2p/g) ?? []) + const matches = new Set(`${result.stdout}\n${result.stderr}`.match(/[a-z2-7]{52}\.b32\.i2p/g) ?? []) if (matches.size > 0) { if (options.json) { writeJson({ diff --git a/src/cli/commands/invite.ts b/src/cli/commands/invite.ts index 1d31c676..bd345d00 100644 --- a/src/cli/commands/invite.ts +++ b/src/cli/commands/invite.ts @@ -100,14 +100,23 @@ export const openInviteDbClient = (): DatabaseClient => { return knex({ client: 'pg', + // connectionTimeoutMillis bounds the underlying pg socket connection + // attempt itself -- acquireTimeoutMillis/acquireConnectionTimeout below + // only bound how long the knex/tarn pool waits for a connection to + // become available, not the socket connect() call in flight underneath. + // Without it, an unreachable host leaves that connect() pending forever, + // so db.destroy() never resolves and this one-shot CLI process hangs + // indefinitely instead of exiting once the pool "gives up" (same root + // cause as info.ts's getEventCount(), see #739/#740). connection: process.env.DB_URI - ? process.env.DB_URI + ? { connectionString: process.env.DB_URI, connectionTimeoutMillis: CLI_DB_ACQUIRE_TIMEOUT_MS } : { host: process.env.DB_HOST, port: Number(process.env.DB_PORT ?? 5432), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, + connectionTimeoutMillis: CLI_DB_ACQUIRE_TIMEOUT_MS, }, pool: { min: 0, diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts index d6c75fb9..b8b48826 100644 --- a/test/unit/cli/cli.integration.spec.ts +++ b/test/unit/cli/cli.integration.spec.ts @@ -320,6 +320,44 @@ describe('cli integration (spawn)', function () { expect(JSON.parse(configGetResult.stdout)).to.equal(false) }) + // Regression for #739/#740: an unreachable DB (packets silently dropped, not + // refused) left knex's pool unable to close its underlying socket, keeping + // these one-shot CLI processes alive indefinitely instead of failing within + // their own configured timeout. 10.255.255.1 is a documented TEST-NET-1 + // address that reliably drops packets rather than refusing the connection, + // which is the specific failure shape that exposed the hang (a host that + // actively refuses, e.g. connection reset, does not). + const UNREACHABLE_DB_ENV = { + DB_URI: undefined, + DB_HOST: '10.255.255.1', + DB_PORT: '5432', + DB_USER: 'nostream', + DB_PASSWORD: 'nostream', + DB_NAME: 'nostream', + } + + it('exits promptly with a null event count when the database is unreachable during info --json', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-unreachable-db-info-')) + const startedAt = Date.now() + + const result = await runCli(['info', '--json'], { NOSTR_CONFIG_DIR: configDir, ...UNREACHABLE_DB_ENV }) + + expect(Date.now() - startedAt).to.be.lessThan(10000) + expect(result.code).to.equal(0) + const payload = JSON.parse(result.stdout) + expect(payload.runtime.eventCount).to.equal(null) + }) + + it('exits promptly instead of hanging when the database is unreachable during invite create', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-unreachable-db-invite-')) + const startedAt = Date.now() + + const result = await runCli(['invite', 'create'], { NOSTR_CONFIG_DIR: configDir, ...UNREACHABLE_DB_ENV }) + + expect(Date.now() - startedAt).to.be.lessThan(10000) + expect(result.code).to.equal(1) + }) + it('prints json errors for read failures in json mode', async () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-json-error-'))