fix: improve connection reliability with global timeout and IPv4

- Use 127.0.0.1 instead of localhost to avoid DNS/IPv6 resolution issues
- Add 15s global timeout wrapper around connect() to prevent hanging forever
- Reduce HTTP retry attempts from 30 to 5 (maintainLoop retries anyway)
- Fix WebSocket cleanup: close socket on timeout to prevent orphaned connections
- Improve settled state tracking in WebSocket connection promise
This commit is contained in:
Tommy D. Rossi
2026-01-14 12:13:25 +01:00
parent 0f980f2fa5
commit 8b9fe157b4
+37 -23
View File
@@ -6,7 +6,7 @@ import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol' import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol'
const RELAY_PORT = process.env.PLAYWRITER_PORT const RELAY_PORT = process.env.PLAYWRITER_PORT
const RELAY_URL = `ws://localhost:${RELAY_PORT}/extension` const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
@@ -34,7 +34,19 @@ class ConnectionManager {
return this.connectionPromise return this.connectionPromise
} }
this.connectionPromise = this.connect() // Wrap connect() with a global timeout to ensure it never hangs forever.
// This protects against edge cases where individual timeouts don't fire
// (e.g., DNS resolution hangs, AbortSignal doesn't work, etc.)
const GLOBAL_TIMEOUT_MS = 15000
this.connectionPromise = Promise.race([
this.connect(),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Connection timeout (global)'))
}, GLOBAL_TIMEOUT_MS)
})
])
try { try {
await this.connectionPromise await this.connectionPromise
} finally { } finally {
@@ -43,22 +55,21 @@ class ConnectionManager {
} }
private async connect(): Promise<void> { private async connect(): Promise<void> {
logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`) logger.debug(`Waiting for server at http://127.0.0.1:${RELAY_PORT}...`)
// Retry for up to 30 seconds with 1s intervals, then give up (maintain loop will retry later) // Retry for up to 5 seconds with 1s intervals, then give up (maintain loop will retry later)
const maxAttempts = 30 // Using fewer attempts since maintainLoop retries every 3 seconds anyway
const maxAttempts = 5
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) await fetch(`http://127.0.0.1:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
logger.debug('Server is available') logger.debug('Server is available')
break break
} catch { } catch {
if (attempt === maxAttempts - 1) { if (attempt === maxAttempts - 1) {
throw new Error('Server not available') throw new Error('Server not available')
} }
if (attempt % 5 === 0) { logger.debug(`Server not available, retrying... (attempt ${attempt + 1}/${maxAttempts})`)
logger.debug(`Server not available, retrying... (attempt ${attempt + 1}/${maxAttempts})`)
}
await sleep(1000) await sleep(1000)
} }
} }
@@ -67,17 +78,20 @@ class ConnectionManager {
const socket = new WebSocket(RELAY_URL) const socket = new WebSocket(RELAY_URL)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let timeoutFired = false let settled = false
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
timeoutFired = true if (settled) return
settled = true
logger.debug('WebSocket connection TIMEOUT after 5 seconds') logger.debug('WebSocket connection TIMEOUT after 5 seconds')
try {
socket.close()
} catch {}
reject(new Error('Connection timeout')) reject(new Error('Connection timeout'))
}, 5000) }, 5000)
socket.onopen = () => { socket.onopen = () => {
if (timeoutFired) { if (settled) return
return settled = true
}
logger.debug('WebSocket connected') logger.debug('WebSocket connected')
clearTimeout(timeout) clearTimeout(timeout)
resolve() resolve()
@@ -85,18 +99,18 @@ class ConnectionManager {
socket.onerror = (error) => { socket.onerror = (error) => {
logger.debug('WebSocket error during connection:', error) logger.debug('WebSocket error during connection:', error)
if (!timeoutFired) { if (settled) return
clearTimeout(timeout) settled = true
reject(new Error('WebSocket connection failed')) clearTimeout(timeout)
} reject(new Error('WebSocket connection failed'))
} }
socket.onclose = (event) => { socket.onclose = (event) => {
logger.debug('WebSocket closed during connection:', { code: event.code, reason: event.reason }) logger.debug('WebSocket closed during connection:', { code: event.code, reason: event.reason })
if (!timeoutFired) { if (settled) return
clearTimeout(timeout) settled = true
reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) clearTimeout(timeout)
} reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
} }
}) })
@@ -236,7 +250,7 @@ class ConnectionManager {
// When replaced by another extension, poll until slot is free // When replaced by another extension, poll until slot is free
if (store.getState().connectionState === 'extension-replaced') { if (store.getState().connectionState === 'extension-replaced') {
try { try {
const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
const data = await response.json() const data = await response.json()
if (!data.connected) { if (!data.connected) {
store.setState({ connectionState: 'idle', errorText: undefined }) store.setState({ connectionState: 'idle', errorText: undefined })