fix: harden desktop monitoring recovery
Desktop Client Build / Resolve Build Metadata (push) Successful in 37s
Backend CI / Backend (push) Failing after 9m26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m29s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 35s
Desktop Client Build / Resolve Build Metadata (push) Successful in 37s
Backend CI / Backend (push) Failing after 9m26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m29s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 35s
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
import type { BrowserWindow as ElectronBrowserWindow, Session } from 'electron/main'
|
||||
import { BrowserWindow, nativeTheme } from 'electron/main'
|
||||
import type { Browser as PlaywrightBrowser, Page as PlaywrightPage } from 'playwright-core'
|
||||
@@ -13,9 +15,23 @@ const hiddenPlaywrightReaperIntervalMs = 60_000
|
||||
const defaultConnectTimeoutMs = 10_000
|
||||
const defaultPageTimeoutMs = 45_000
|
||||
const hiddenBootstrapMetaName = 'geo-hidden-playwright-token'
|
||||
const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? '9339', 10)
|
||||
const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`
|
||||
const cdpTargetsListURL = `${cdpEndpointURL}/json/list`
|
||||
const preferredCDPPort = normalizeCDPPort(process.env.GEO_ELECTRON_CDP_PORT, 9339)
|
||||
const dynamicCDPPortMin = normalizeCDPPort(process.env.GEO_ELECTRON_CDP_PORT_MIN, 9339)
|
||||
const dynamicCDPPortMax = normalizeCDPPort(process.env.GEO_ELECTRON_CDP_PORT_MAX, 20000)
|
||||
const cdpCircuitOpenTTLms = 60_000
|
||||
const cdpCircuitProbeTimeoutMs = 2_000
|
||||
|
||||
let cdpPort = preferredCDPPort
|
||||
let cdpEndpointURL = endpointURLForPort(cdpPort)
|
||||
let cdpTargetsListURL = `${cdpEndpointURL}/json/list`
|
||||
|
||||
interface PlaywrightCDPHealthState {
|
||||
status: 'unknown' | 'healthy' | 'unavailable'
|
||||
checkedAt: number | null
|
||||
unavailableSince: number | null
|
||||
nextRetryAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
interface HiddenPlaywrightRecord {
|
||||
key: string
|
||||
@@ -41,11 +57,95 @@ const records = new Map<string, HiddenPlaywrightRecord>()
|
||||
let reaperHandle: ReturnType<typeof setInterval> | null = null
|
||||
let connectPromise: Promise<PlaywrightBrowser> | null = null
|
||||
let connectedBrowser: PlaywrightBrowser | null = null
|
||||
let cdpHealthState: PlaywrightCDPHealthState = {
|
||||
status: 'unknown',
|
||||
checkedAt: null,
|
||||
unavailableSince: null,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
}
|
||||
|
||||
export function getPlaywrightCDPPort(): number {
|
||||
return cdpPort
|
||||
}
|
||||
|
||||
export async function preparePlaywrightCDPPort(): Promise<number> {
|
||||
const port = await findAvailableCDPPort()
|
||||
setPlaywrightCDPPort(port)
|
||||
return port
|
||||
}
|
||||
|
||||
export function isPlaywrightCDPUnavailableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
error.message === 'playwright_cdp_endpoint_unavailable' ||
|
||||
error.message === 'playwright_cdp_port_unavailable' ||
|
||||
error.message.includes('playwright_cdp_endpoint_unavailable') ||
|
||||
error.message.includes('connectOverCDP')
|
||||
)
|
||||
}
|
||||
|
||||
export async function ensurePlaywrightCDPReady(options: { force?: boolean } = {}): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (
|
||||
!options.force &&
|
||||
cdpHealthState.status === 'healthy' &&
|
||||
cdpHealthState.checkedAt &&
|
||||
now - cdpHealthState.checkedAt < 15_000
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!options.force &&
|
||||
cdpHealthState.status === 'unavailable' &&
|
||||
cdpHealthState.nextRetryAt &&
|
||||
cdpHealthState.nextRetryAt > now
|
||||
) {
|
||||
throw new Error(cdpHealthState.lastError || 'playwright_cdp_endpoint_unavailable')
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForCDPEndpoint(cdpCircuitProbeTimeoutMs)
|
||||
markPlaywrightCDPHealthy()
|
||||
} catch (error) {
|
||||
markPlaywrightCDPUnavailable(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlaywrightCDPExecutionAvailable(now = Date.now()): boolean {
|
||||
if (connectedBrowser?.isConnected()) {
|
||||
return true
|
||||
}
|
||||
if (cdpHealthState.status !== 'unavailable') {
|
||||
return true
|
||||
}
|
||||
return Boolean(cdpHealthState.nextRetryAt && cdpHealthState.nextRetryAt <= now)
|
||||
}
|
||||
|
||||
export function getHiddenPlaywrightSnapshot() {
|
||||
return {
|
||||
cdpPort,
|
||||
cdpEndpointURL,
|
||||
connected: Boolean(connectedBrowser?.isConnected()),
|
||||
pageCount: records.size,
|
||||
idleTTLms: hiddenPlaywrightIdleTTLms,
|
||||
health: { ...cdpHealthState },
|
||||
records: [...records.values()].map((record) => ({
|
||||
accountId: record.accountId,
|
||||
title: record.title,
|
||||
targetURL: record.targetURL,
|
||||
retainCount: record.retainCount,
|
||||
activatedAt: record.activatedAt,
|
||||
lastReleasedAt: record.lastReleasedAt,
|
||||
windowDestroyed: record.window.isDestroyed(),
|
||||
pageClosed: record.page.isClosed(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function startHiddenPlaywrightReaper(): void {
|
||||
if (reaperHandle) {
|
||||
return
|
||||
@@ -155,26 +255,6 @@ export function reapIdleHiddenPlaywrightPages(
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
export function getHiddenPlaywrightSnapshot() {
|
||||
return {
|
||||
cdpPort,
|
||||
cdpEndpointURL,
|
||||
connected: Boolean(connectedBrowser?.isConnected()),
|
||||
pageCount: records.size,
|
||||
idleTTLms: hiddenPlaywrightIdleTTLms,
|
||||
records: [...records.values()].map((record) => ({
|
||||
accountId: record.accountId,
|
||||
title: record.title,
|
||||
targetURL: record.targetURL,
|
||||
retainCount: record.retainCount,
|
||||
activatedAt: record.activatedAt,
|
||||
lastReleasedAt: record.lastReleasedAt,
|
||||
windowDestroyed: record.window.isDestroyed(),
|
||||
pageClosed: record.page.isClosed(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function createLease(record: HiddenPlaywrightRecord): HiddenPlaywrightLease {
|
||||
return {
|
||||
accountId: record.accountId,
|
||||
@@ -271,6 +351,7 @@ async function connectBrowserOverCDP(): Promise<PlaywrightBrowser> {
|
||||
|
||||
connectPromise = (async () => {
|
||||
await waitForCDPEndpoint()
|
||||
markPlaywrightCDPHealthy()
|
||||
const { chromium } = await import('playwright-core')
|
||||
await pruneStaleCDPTargets()
|
||||
|
||||
@@ -292,6 +373,9 @@ async function connectBrowserOverCDP(): Promise<PlaywrightBrowser> {
|
||||
|
||||
try {
|
||||
return await connectPromise
|
||||
} catch (error) {
|
||||
markPlaywrightCDPUnavailable(error)
|
||||
throw error
|
||||
} finally {
|
||||
if (!connectedBrowser?.isConnected()) {
|
||||
connectPromise = null
|
||||
@@ -314,7 +398,7 @@ async function waitForCDPEndpoint(timeoutMs = defaultConnectTimeoutMs): Promise<
|
||||
await wait(200)
|
||||
}
|
||||
|
||||
throw new Error('playwright_cdp_endpoint_unavailable')
|
||||
throw new Error(`playwright_cdp_endpoint_unavailable:${cdpEndpointURL}`)
|
||||
}
|
||||
|
||||
async function openBrowserOverCDP(
|
||||
@@ -330,6 +414,7 @@ async function openBrowserOverCDP(
|
||||
})
|
||||
|
||||
connectedBrowser = browser
|
||||
markPlaywrightCDPHealthy()
|
||||
return browser
|
||||
}
|
||||
|
||||
@@ -468,3 +553,104 @@ function wait(timeMs: number): Promise<void> {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeCDPPort(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? '', 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function endpointURLForPort(port: number): string {
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
function setPlaywrightCDPPort(port: number): void {
|
||||
cdpPort = port
|
||||
cdpEndpointURL = endpointURLForPort(port)
|
||||
cdpTargetsListURL = `${cdpEndpointURL}/json/list`
|
||||
}
|
||||
|
||||
async function findAvailableCDPPort(): Promise<number> {
|
||||
if (await canBindPort(preferredCDPPort)) {
|
||||
return preferredCDPPort
|
||||
}
|
||||
|
||||
const min = Math.min(dynamicCDPPortMin, dynamicCDPPortMax)
|
||||
const max = Math.max(dynamicCDPPortMin, dynamicCDPPortMax)
|
||||
const start = Math.max(min, preferredCDPPort)
|
||||
for (let port = start; port <= max; port += 1) {
|
||||
if (port === preferredCDPPort) {
|
||||
continue
|
||||
}
|
||||
if (await canBindPort(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
for (let port = min; port < start; port += 1) {
|
||||
if (port === preferredCDPPort) {
|
||||
continue
|
||||
}
|
||||
if (await canBindPort(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
const reserved = await reserveEphemeralPort()
|
||||
return reserved
|
||||
}
|
||||
|
||||
function canBindPort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
server.listen({ host: '127.0.0.1', port }, () => {
|
||||
server.close(() => {
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function reserveEphemeralPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once('error', reject)
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(() => {
|
||||
if (port > 0) {
|
||||
resolve(port)
|
||||
} else {
|
||||
reject(new Error('playwright_cdp_port_unavailable'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function markPlaywrightCDPHealthy(): void {
|
||||
cdpHealthState = {
|
||||
status: 'healthy',
|
||||
checkedAt: Date.now(),
|
||||
unavailableSince: null,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
}
|
||||
}
|
||||
|
||||
function markPlaywrightCDPUnavailable(error: unknown): void {
|
||||
const now = Date.now()
|
||||
cdpHealthState = {
|
||||
status: 'unavailable',
|
||||
checkedAt: now,
|
||||
unavailableSince: cdpHealthState.unavailableSince ?? now,
|
||||
nextRetryAt: now + cdpCircuitOpenTTLms,
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user