diff --git a/src/cli/account.ts b/src/cli/account.ts index 4c20d8de..44322c6b 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -4,13 +4,19 @@ import * as os from 'node:os' import * as path from 'node:path' const SERVICE_NAME = 'mppx' +const defaultCommandTimeoutMs = 10_000 + +function commandTimeoutMs() { + const value = Number.parseInt(process.env.MPPX_KEYCHAIN_COMMAND_TIMEOUT_MS ?? '', 10) + return Number.isFinite(value) && value > 0 ? value : defaultCommandTimeoutMs +} export function execCommand( command: string, args: string[], ): Promise<{ stdout: string; stderr: string; error: Error | null }> { return new Promise((resolve) => { - child.execFile(command, args, (error, stdout, stderr) => { + child.execFile(command, args, { timeout: commandTimeoutMs() }, (error, stdout, stderr) => { resolve({ stdout: stdout.trim(), stderr: stderr.trim(), error }) }) }) @@ -134,14 +140,19 @@ export function createKeychain(account = 'main') { 'account', account, ]) + const timeout = setTimeout(() => proc.kill(), commandTimeoutMs()) proc.stdin?.write(value) proc.stdin?.end() return new Promise((resolve, reject) => { proc.on('close', (code) => { + clearTimeout(timeout) if (code === 0) resolve() else reject(new Error(`secret-tool exited with code ${code}`)) }) - proc.on('error', reject) + proc.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) }) } throw new Error(`Unsupported platform: ${platform}`) diff --git a/src/middlewares/elysia.test.ts b/src/middlewares/elysia.test.ts index 1207dcdf..a8dfa8d3 100644 --- a/src/middlewares/elysia.test.ts +++ b/src/middlewares/elysia.test.ts @@ -8,11 +8,12 @@ import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' +import * as TestHttp from '~test/Http.js' import { deployEscrow } from '~test/tempo/session.js' import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js' function createServer(app: Elysia) { - return new Promise<{ url: string; close: () => void }>((resolve) => { + return new Promise((resolve) => { const server = http.createServer(async (req, res) => { const url = `http://localhost${req.url}` const headers = new Headers() @@ -27,10 +28,7 @@ function createServer(app: Elysia) { }) server.listen(0, () => { const { port } = server.address() as { port: number } - resolve({ - url: `http://localhost:${port}`, - close: () => server.close(), - }) + resolve(TestHttp.wrapServer(server, { port, url: `http://localhost:${port}` })) }) }) } diff --git a/src/middlewares/express.test.ts b/src/middlewares/express.test.ts index 0e91abaf..6d86d881 100644 --- a/src/middlewares/express.test.ts +++ b/src/middlewares/express.test.ts @@ -6,17 +6,15 @@ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' +import * as Http from '~test/Http.js' import { deployEscrow } from '~test/tempo/session.js' import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js' function createServer(app: express.Express) { - return new Promise<{ url: string; close: () => void }>((resolve) => { + return new Promise((resolve) => { const server = app.listen(0, () => { const { port } = server.address() as { port: number } - resolve({ - url: `http://localhost:${port}`, - close: () => server.close(), - }) + resolve(Http.wrapServer(server, { port, url: `http://localhost:${port}` })) }) }) } diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 828671d1..a5bcce31 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -7,16 +7,19 @@ import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' +import * as Http from '~test/Http.js' import { deployEscrow } from '~test/tempo/session.js' import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js' function createServer(app: Hono) { - return new Promise<{ url: string; close: () => void }>((resolve) => { + return new Promise((resolve) => { const server = serve({ fetch: app.fetch, port: 0 }, (info) => { - resolve({ - url: `http://localhost:${info.port}`, - close: () => server.close(), - }) + resolve( + Http.wrapServer(server as unknown as import('node:http').Server, { + port: info.port, + url: `http://localhost:${info.port}`, + }), + ) }) }) } diff --git a/src/middlewares/nextjs.test.ts b/src/middlewares/nextjs.test.ts index b73fb2f0..f4bb5ca5 100644 --- a/src/middlewares/nextjs.test.ts +++ b/src/middlewares/nextjs.test.ts @@ -7,11 +7,12 @@ import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' +import * as TestHttp from '~test/Http.js' import { deployEscrow } from '~test/tempo/session.js' import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' function createServer(handler: (request: Request) => Promise | Response) { - return new Promise<{ url: string; close: () => void }>((resolve) => { + return new Promise((resolve) => { const server = http.createServer(async (req, res) => { const url = `http://localhost${req.url}` const headers = new Headers() @@ -26,10 +27,7 @@ function createServer(handler: (request: Request) => Promise | Respons }) server.listen(0, () => { const { port } = server.address() as { port: number } - resolve({ - url: `http://localhost:${port}`, - close: () => server.close(), - }) + resolve(TestHttp.wrapServer(server, { port, url: `http://localhost:${port}` })) }) }) } diff --git a/test/Http.ts b/test/Http.ts index 389f8ee9..694da02e 100644 --- a/test/Http.ts +++ b/test/Http.ts @@ -1,8 +1,50 @@ import * as http from 'node:http' +import * as net from 'node:net' + +export type TestServer = http.Server & { + close: () => void + port: number + url: string +} + +export function wrapServer( + server: http.Server, + options: { port: number; url: string }, +): TestServer { + const sockets = new Set() + let closed = false + + // Keep test servers from holding idle keep-alive sockets open between cases. + server.keepAliveTimeout = 1 + server.maxRequestsPerSocket = 1 + + server.on('connection', (socket) => { + sockets.add(socket) + socket.setKeepAlive(false) + socket.on('close', () => sockets.delete(socket)) + }) + + const close = () => { + if (closed) return + closed = true + + server.unref() + server.close(() => {}) + server.closeIdleConnections?.() + server.closeAllConnections?.() + + for (const socket of sockets) { + socket.unref() + socket.destroy() + } + } + + return Object.assign(server, { close, port: options.port, url: options.url }) as TestServer +} export async function createServer(handleRequest: http.RequestListener) { const server = http.createServer(handleRequest) await new Promise((resolve) => server.listen(0, resolve)) const { port } = server.address() as { port: number } - return Object.assign(server, { port, url: `http://localhost:${port}` }) + return wrapServer(server, { port, url: `http://localhost:${port}` }) }