fix: recover relay startup from EADDRINUSE
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.60
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Fix relay startup EADDRINUSE timeouts**: If the relay port is already bound but `/version` is not responding, Playwriter now detects the listening PID(s), stops the existing process, and only then starts the relay (the 5s startup timeout now measures post-spawn readiness, not port cleanup time).
|
||||||
|
- **Harden port-kill implementation**: Replaced Playwriter's port killer with an implementation that mirrors `kill-port-process` (lsof/grep/awk/xargs on unix; taskkill on Windows) and includes the `xargs.stdout` pipe fix from upstream PR #199.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **Add kill-port subprocess test**: New test starts a real HTTP server subprocess on an ephemeral port, measures kill latency, and asserts the port is released.
|
||||||
|
|
||||||
## 0.0.59
|
## 0.0.59
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "playwriter",
|
"name": "playwriter",
|
||||||
"description": "",
|
"description": "",
|
||||||
"version": "0.0.59",
|
"version": "0.0.60",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
+126
-45
@@ -1,14 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-platform process termination for a TCP port.
|
* Cross-platform process termination for a TCP port.
|
||||||
* Replaces external kill-port-process dependency for bunx/runtime stability.
|
*
|
||||||
|
* This mirrors the approach used by the `kill-port-process` npm package:
|
||||||
|
* https://github.com/hilleer/kill-port-process
|
||||||
|
*
|
||||||
|
* Important fix (ported from https://github.com/hilleer/kill-port-process/pull/199):
|
||||||
|
* do NOT pipe `xargs.stdout` into `process.stdin` (stdin is not writable and can
|
||||||
|
* throw `dest.end is not a function`). We simply don't pipe `xargs` stdout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
export type KillPortSignal = 'SIGTERM' | 'SIGKILL'
|
||||||
|
|
||||||
function isValidPort(port: number): boolean {
|
function isValidPort(port: number): boolean {
|
||||||
return Number.isInteger(port) && port > 0 && port <= 65535
|
return Number.isInteger(port) && port > 0 && port <= 65535
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,28 @@ function parsePids(output: string): number[] {
|
|||||||
return [...new Set(pids)]
|
return [...new Set(pids)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUnixLsofPids(stdout: string): number[] {
|
||||||
|
const pids = stdout
|
||||||
|
.split(/\r?\n/g)
|
||||||
|
.map((line) => {
|
||||||
|
return line.trim()
|
||||||
|
})
|
||||||
|
.filter((line) => {
|
||||||
|
// lsof output starts with a header row; lines we care about contain LISTEN.
|
||||||
|
return Boolean(line) && !line.startsWith('COMMAND') && line.includes('LISTEN')
|
||||||
|
})
|
||||||
|
.map((line) => {
|
||||||
|
const columns = line.split(/\s+/g)
|
||||||
|
// `awk '{print $2}'` in kill-port-process extracts PID from 2nd column.
|
||||||
|
return Number(columns[1] || '')
|
||||||
|
})
|
||||||
|
.filter((pid) => {
|
||||||
|
return Number.isInteger(pid) && pid > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...new Set(pids)]
|
||||||
|
}
|
||||||
|
|
||||||
function parseWindowsNetstatPids(output: string, port: number): number[] {
|
function parseWindowsNetstatPids(output: string, port: number): number[] {
|
||||||
const rows = output
|
const rows = output
|
||||||
.split(/\r?\n/g)
|
.split(/\r?\n/g)
|
||||||
@@ -85,13 +115,14 @@ async function getPidsForPortWindows(port: number): Promise<number[]> {
|
|||||||
|
|
||||||
async function getPidsForPortUnix(port: number): Promise<number[]> {
|
async function getPidsForPortUnix(port: number): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP:' + String(port), '-sTCP:LISTEN', '-t'])
|
const { stdout } = await execFileAsync('lsof', ['-i', `tcp:${port}`])
|
||||||
const pids = parsePids(stdout)
|
const pids = parseUnixLsofPids(stdout)
|
||||||
if (pids.length > 0) {
|
if (pids.length > 0) {
|
||||||
return pids
|
return pids
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// Fallback for Linux environments that may not have `lsof`.
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('fuser', [`${port}/tcp`])
|
const { stdout } = await execFileAsync('fuser', [`${port}/tcp`])
|
||||||
return parsePids(stdout)
|
return parsePids(stdout)
|
||||||
@@ -100,19 +131,27 @@ async function getPidsForPortUnix(port: number): Promise<number[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
/**
|
||||||
return new Promise((resolve) => {
|
* Return PIDs (if any) that are currently LISTENing on the given TCP port.
|
||||||
setTimeout(resolve, ms)
|
*
|
||||||
})
|
* This is intentionally separate from killPortProcess(): callers sometimes need
|
||||||
|
* to detect/diagnose EADDRINUSE before attempting to start a server.
|
||||||
|
*/
|
||||||
|
export async function getListeningPidsForPort({ port }: { port: number }): Promise<number[]> {
|
||||||
|
if (!isValidPort(port)) {
|
||||||
|
throw new Error(`Invalid port: ${port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.platform() === 'win32'
|
||||||
|
? await getPidsForPortWindows(port)
|
||||||
|
: await getPidsForPortUnix(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProcessAlive(pid: number): boolean {
|
function toError(value: unknown): Error {
|
||||||
try {
|
if (value instanceof Error) {
|
||||||
process.kill(pid, 0)
|
return value
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return new Error(String(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function terminatePidWindows(pid: number): Promise<void> {
|
async function terminatePidWindows(pid: number): Promise<void> {
|
||||||
@@ -121,47 +160,89 @@ async function terminatePidWindows(pid: number): Promise<void> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function terminatePidUnix(pid: number): Promise<void> {
|
async function unixKillPortUsingPipes({ port, signal }: { port: number; signal: KillPortSignal }): Promise<void> {
|
||||||
try {
|
const killCommand = signal === 'SIGTERM' ? '-15' : '-9'
|
||||||
process.kill(pid, 'SIGTERM')
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(200)
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (!isProcessAlive(pid)) {
|
const lsof = spawn('lsof', ['-i', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
return
|
const grep = spawn('grep', ['LISTEN'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
}
|
const awk = spawn('awk', ['{print $2}'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
|
const xargs = spawn('xargs', ['kill', killCommand], { stdio: ['pipe', 'ignore', 'pipe'] })
|
||||||
|
|
||||||
try {
|
if (!lsof.stdout || !grep.stdin || !grep.stdout || !awk.stdin || !awk.stdout || !xargs.stdin) {
|
||||||
process.kill(pid, 'SIGKILL')
|
reject(new Error('Failed to create stdio pipes for kill-port process chain'))
|
||||||
} catch {}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lsof.stdout.pipe(grep.stdin)
|
||||||
|
grep.stdout.pipe(awk.stdin)
|
||||||
|
awk.stdout.pipe(xargs.stdin)
|
||||||
|
|
||||||
|
// IMPORTANT: do not pipe xargs stdout anywhere.
|
||||||
|
|
||||||
|
const stderrChunks: string[] = []
|
||||||
|
const collectStderr = (name: string, stream: NodeJS.ReadableStream | null) => {
|
||||||
|
stream?.on('data', (data: unknown) => {
|
||||||
|
const text = data instanceof Buffer ? data.toString() : String(data)
|
||||||
|
stderrChunks.push(`${name} - ${text}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
collectStderr('lsof', lsof.stderr)
|
||||||
|
collectStderr('grep', grep.stderr)
|
||||||
|
collectStderr('awk', awk.stderr)
|
||||||
|
collectStderr('xargs', xargs.stderr)
|
||||||
|
|
||||||
|
const onError = (name: string) => {
|
||||||
|
return (error: unknown) => {
|
||||||
|
reject(new Error(`kill-port process error in ${name}`, { cause: toError(error) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lsof.on('error', onError('lsof'))
|
||||||
|
grep.on('error', onError('grep'))
|
||||||
|
awk.on('error', onError('awk'))
|
||||||
|
xargs.on('error', onError('xargs'))
|
||||||
|
|
||||||
|
xargs.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra = stderrChunks.length > 0 ? `\n${stderrChunks.join('\n')}` : ''
|
||||||
|
reject(new Error(`Failed to kill process on port ${port} (exit code ${code}).${extra}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill any listening process bound to the provided TCP port.
|
* Kill any listening process bound to the provided TCP port.
|
||||||
*/
|
*/
|
||||||
export async function killPortProcess({ port }: { port: number }): Promise<void> {
|
export async function killPortProcess({
|
||||||
|
port,
|
||||||
|
signal = 'SIGKILL',
|
||||||
|
}: {
|
||||||
|
port: number
|
||||||
|
signal?: KillPortSignal
|
||||||
|
}): Promise<void> {
|
||||||
if (!isValidPort(port)) {
|
if (!isValidPort(port)) {
|
||||||
throw new Error(`Invalid port: ${port}`)
|
throw new Error(`Invalid port: ${port}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pids = os.platform() === 'win32'
|
if (os.platform() === 'win32') {
|
||||||
? await getPidsForPortWindows(port)
|
const pids = await getListeningPidsForPort({ port })
|
||||||
: await getPidsForPortUnix(port)
|
const currentPid = process.pid
|
||||||
|
const targetPids = pids.filter((pid) => {
|
||||||
const currentPid = process.pid
|
return pid !== currentPid
|
||||||
const targetPids = pids.filter((pid) => {
|
})
|
||||||
return pid !== currentPid
|
await Promise.all(
|
||||||
})
|
targetPids.map(async (pid) => {
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
targetPids.map(async (pid) => {
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
await terminatePidWindows(pid)
|
await terminatePidWindows(pid)
|
||||||
return
|
}),
|
||||||
}
|
)
|
||||||
await terminatePidUnix(pid)
|
return
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
|
await unixKillPortUsingPipes({ port, signal })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import pc from 'picocolors'
|
import pc from 'picocolors'
|
||||||
import { killPortProcess } from './kill-port.js'
|
import { getListeningPidsForPort, killPortProcess } from './kill-port.js'
|
||||||
import { VERSION, sleep, LOG_FILE_PATH } from './utils.js'
|
import { VERSION, sleep, LOG_FILE_PATH } from './utils.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
@@ -71,11 +71,23 @@ export async function waitForExtension(options: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killRelayServer(port: number): Promise<void> {
|
async function killRelayServer(options: { port: number; waitForFreeMs?: number }): Promise<void> {
|
||||||
|
const { port, waitForFreeMs = 3000 } = options
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await killPortProcess({ port })
|
await killPortProcess({ port })
|
||||||
await sleep(500)
|
} catch {
|
||||||
} catch {}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
while (Date.now() - startTime < waitForFreeMs) {
|
||||||
|
const pids = await getListeningPidsForPort({ port }).catch(() => [])
|
||||||
|
if (pids.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,12 +140,18 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
|||||||
if (serverVersion !== null) {
|
if (serverVersion !== null) {
|
||||||
if (restartOnVersionMismatch) {
|
if (restartOnVersionMismatch) {
|
||||||
logger?.log(pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`))
|
logger?.log(pc.yellow(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`))
|
||||||
await killRelayServer(RELAY_PORT)
|
await killRelayServer({ port: RELAY_PORT })
|
||||||
} else {
|
} else {
|
||||||
// Server is running but different version, just use it
|
// Server is running but different version, just use it
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
|
||||||
|
if (listeningPids.length > 0) {
|
||||||
|
logger?.log(pc.yellow(`Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`))
|
||||||
|
await killRelayServer({ port: RELAY_PORT })
|
||||||
|
}
|
||||||
|
|
||||||
logger?.log(pc.dim('CDP relay server not running, starting it...'))
|
logger?.log(pc.dim('CDP relay server not running, starting it...'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +170,11 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
|||||||
|
|
||||||
serverProcess.unref()
|
serverProcess.unref()
|
||||||
|
|
||||||
|
const startTimeoutMs = 5000
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
while (Date.now() - startTime < startTimeoutMs) {
|
||||||
await sleep(500)
|
await sleep(200)
|
||||||
const newVersion = await getRelayServerVersion(RELAY_PORT)
|
const newVersion = await getRelayServerVersion(RELAY_PORT)
|
||||||
if (newVersion) {
|
if (newVersion) {
|
||||||
logger?.log(pc.green('CDP relay server started successfully'))
|
logger?.log(pc.green('CDP relay server started successfully'))
|
||||||
@@ -163,5 +183,6 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`)
|
const waitedMs = Date.now() - startTime
|
||||||
|
throw new Error(`Failed to start CDP relay server within ${waitedMs}ms. Check logs at: ${LOG_FILE_PATH}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,11 +261,13 @@ describe('Relay Navigation Tests', () => {
|
|||||||
errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test',
|
errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test',
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonCount = await withTimeout({
|
await withTimeout({
|
||||||
promise: pluginFrame.locator('button').count(),
|
promise: pluginFrame.locator('button').first().waitFor({ state: 'attached' }),
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
errorMessage: 'Timed out counting button locator in empty-src iframe test',
|
errorMessage: 'Timed out waiting for button locator in empty-src iframe test',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const buttonCount = await pluginFrame.locator('button').count()
|
||||||
expect(buttonCount).toBe(1)
|
expect(buttonCount).toBe(1)
|
||||||
} finally {
|
} finally {
|
||||||
await withTimeout({
|
await withTimeout({
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Test that killPortProcess can terminate a real subprocess bound to a TCP port.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import net from 'node:net'
|
||||||
|
import { getListeningPidsForPort, killPortProcess } from '../src/kill-port.js'
|
||||||
|
|
||||||
|
async function getFreeTcpPort(): Promise<number> {
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.on('error', (error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close(() => {
|
||||||
|
reject(new Error('Failed to get ephemeral port'))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { port } = address
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForListeningPidCount({
|
||||||
|
port,
|
||||||
|
predicate,
|
||||||
|
timeoutMs,
|
||||||
|
}: {
|
||||||
|
port: number
|
||||||
|
predicate: (pids: number[]) => boolean
|
||||||
|
timeoutMs: number
|
||||||
|
}): Promise<number[]> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
const pids = await getListeningPidsForPort({ port }).catch(() => [])
|
||||||
|
if (predicate(pids)) {
|
||||||
|
return pids
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pids = await getListeningPidsForPort({ port }).catch(() => [])
|
||||||
|
throw new Error(`Timed out waiting for port ${port} pid condition (pids: ${pids.join(', ') || 'none'})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('killPortProcess', () => {
|
||||||
|
it('kills a real http server subprocess and reports timing', async () => {
|
||||||
|
const port = await getFreeTcpPort()
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
[
|
||||||
|
"const http = require('http');",
|
||||||
|
'const port = Number(process.env.TEST_PORT);',
|
||||||
|
"const host = '127.0.0.1';",
|
||||||
|
"const server = http.createServer((req, res) => { res.statusCode = 200; res.end('ok'); });",
|
||||||
|
'server.listen(port, host, () => { console.log(`listening:${port}`); });',
|
||||||
|
// Keep the process alive.
|
||||||
|
'setInterval(() => {}, 1000);',
|
||||||
|
].join(' '),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: { ...process.env, TEST_PORT: String(port) },
|
||||||
|
stdio: 'ignore',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForListeningPidCount({
|
||||||
|
port,
|
||||||
|
predicate: (pids) => pids.length > 0,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
|
await killPortProcess({ port })
|
||||||
|
|
||||||
|
await waitForListeningPidCount({
|
||||||
|
port,
|
||||||
|
predicate: (pids) => pids.length === 0,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - start
|
||||||
|
console.log(`[kill-port] port ${port} killed in ${elapsedMs}ms`) // for perf visibility in CI logs
|
||||||
|
|
||||||
|
const maxMs = process.platform === 'win32' ? 15000 : 5000
|
||||||
|
expect(elapsedMs).toBeLessThan(maxMs)
|
||||||
|
} finally {
|
||||||
|
// Best-effort cleanup if the kill failed for any reason.
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user