Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-cli-info-event-count-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

fix(cli): stop `nostream info` and `nostream invite create` from hanging indefinitely when the database is unreachable
48 changes: 27 additions & 21 deletions src/cli/commands/info.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,34 +25,36 @@ type I2PGuidancePayload = {
}

const getEventCount = async (): Promise<number | null> => {
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
}
}
}

Expand All @@ -67,9 +69,13 @@ const getRelayUptimeSeconds = async (): Promise<number | null> => {
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
}
Expand Down Expand Up @@ -201,7 +207,7 @@ export const runInfo = async (options: InfoOptions): Promise<number> => {
'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({
Expand Down
11 changes: 10 additions & 1 deletion src/cli/commands/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions test/unit/cli/cli.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'))

Expand Down
Loading