From 7f61f1e50112f300708602816b91004191205bce Mon Sep 17 00:00:00 2001 From: Joob1n Date: Wed, 19 Aug 2026 16:20:46 +0800 Subject: [PATCH 1/9] fix(release): read the CDP port Chromium bound instead of reserving one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaged-renderer smoke reserved a TCP port, released it, and handed the number to Electron, leaving a window in which anything else on the runner could bind it first — and when the poll then timed out, the log named neither the polled port nor which of the three causes applied (issue #3196; it has now hit the same release check twice more). The smoke now launches with --remote-debugging-port=0 and reads the port Chromium actually bound from the DevToolsActivePort file it writes into the isolated user-data directory, so the race is gone rather than reported. The timeout message classifies what remains: a missing port file means the browser never opened an endpoint; a port plus fetch errors means it bound where the poll looked and did not answer in time. Verified against a real packaged app: the smoke passes end to end with the port discovered from the file. Fixes #3196 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 86 +++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 1292068a66..1fcbd0e378 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1,8 +1,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { access, mkdir } from 'node:fs/promises'; -import { createServer } from 'node:net'; +import { access, mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty @@ -74,53 +73,65 @@ export async function assertMissing(path) { throw new Error(`Forbidden release resource exists: ${path}`); } -async function reserveTcpPort() { - const server = createServer(); - await new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolvePromise); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Could not reserve a CDP port.'); - } - await new Promise((resolvePromise, reject) => { - server.close((error) => (error ? reject(error) : resolvePromise())); - }); - return address.port; -} - function delay(milliseconds) { return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); }); } -async function findRendererTarget(port, child) { - const deadline = Date.now() + 30_000; +/** + * The port Chromium actually bound, read from the DevToolsActivePort file it + * writes into the user-data directory. The verifier used to reserve a port, + * release it, and hand the number to Electron — leaving a window in which + * anything else on the runner could take it, and a timeout log that could not + * say whether the app was even listening where the poll looked (issue #3196). + */ +async function readDevToolsPort(userDataDirectory) { + try { + const content = await readFile(join(userDataDirectory, 'DevToolsActivePort'), 'utf8'); + const port = Number.parseInt(content.split('\n')[0] ?? '', 10); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +async function findRendererTarget(userDataDirectory, child) { + const startedAt = Date.now(); + const deadline = startedAt + 30_000; + let port = null; let lastError; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Packaged Maka exited before its renderer was ready.`); } - try { - const response = await fetch(`http://127.0.0.1:${port}/json/list`); - if (response.ok) { - const targets = await response.json(); - const page = targets.find( - (target) => target.type === 'page' && target.webSocketDebuggerUrl, - ); - if (page) return page; + port ??= await readDevToolsPort(userDataDirectory); + if (port !== null) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + if (response.ok) { + const targets = await response.json(); + const page = targets.find( + (target) => target.type === 'page' && target.webSocketDebuggerUrl, + ); + if (page) return page; + } + } catch (error) { + lastError = error; } - } catch (error) { - lastError = error; } await delay(250); } + // Say which of the three causes this is, so classifying the failure does not + // take a re-run: no port file means the browser never opened an endpoint; + // a port plus fetch errors means it bound somewhere the poll could reach + // but never answered. + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); throw new Error( - `Packaged Maka renderer did not expose CDP within 30 seconds${ - lastError ? `: ${lastError.message}` : '' + `Packaged Maka renderer did not expose CDP within ${elapsedSeconds} seconds: ${ + port === null + ? 'DevToolsActivePort was never written under the user-data directory' + : `polled port ${port} from DevToolsActivePort${lastError ? `; last error: ${lastError.message}` : ''}` }.`, ); } @@ -244,7 +255,6 @@ export function isolatedUserEnv(homeDirectory, { temporaryDirectory = homeDirect } export async function smokePackagedRenderer(executable, { workingDirectory } = {}) { - const port = await reserveTcpPort(); const home = join(workingDirectory, 'home'); const userData = join(workingDirectory, 'user-data'); const userEnv = isolatedUserEnv(home); @@ -254,7 +264,11 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); const child = spawn( executable, - [`--remote-debugging-port=${port}`, `--user-data-dir=${userData}`, '--enable-logging=stderr'], + // Port 0: Chromium binds a free port itself and records it in the + // user-data directory's DevToolsActivePort file, which is where the poll + // reads it back — no reserve-then-release window for another process on + // the runner to take the number first. + ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], { cwd: workingDirectory, env: { @@ -272,7 +286,7 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { }); try { - const target = await findRendererTarget(port, child); + const target = await findRendererTarget(userData, child); const deadline = Date.now() + 30_000; let rendererState; while (Date.now() < deadline) { From 3ecb005b3db3ae980d070b960d51478aed65cee5 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Wed, 19 Aug 2026 16:41:32 +0800 Subject: [PATCH 2/9] fix(release): delete the previous instance's DevToolsActivePort first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first CI run of the port-file diagnostics answered the question the old log could not: the poll read port 51921 from DevToolsActivePort and fetch failed for the full 30 seconds. Chromium removes that file only on a clean exit, and the upgrade-lifecycle check reuses one user-data directory across two app versions with a kill between them — so the file belonged to the previous instance and pointed at a port nothing listened on. Deleting it before spawning means whatever appears was written by this child. Verified by pre-seeding a stale file naming a dead port: the smoke passes against a real packaged app. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 1fcbd0e378..d76e1ff40d 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { access, mkdir, readFile } from 'node:fs/promises'; +import { access, mkdir, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty @@ -260,6 +260,14 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { const userEnv = isolatedUserEnv(home); await mkdir(home, { recursive: true }); await mkdir(userData, { recursive: true }); + // Chromium removes DevToolsActivePort only on a clean exit, and the + // upgrade-lifecycle check reuses one user-data directory across two app + // versions with a SIGKILL between them — so a file found here can belong to + // the previous instance, pointing the poll at a port nothing listens on + // anymore. Deleting it first means whatever appears was written by this + // child. This is what the first run of the port-file diagnostics caught: + // the poll read a bound-looking port and fetch failed for the full window. + await rm(join(userData, 'DevToolsActivePort'), { force: true }); await mkdir(userEnv.APPDATA, { recursive: true }); await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); const child = spawn( From 6532f7a2ddd82635d88b7513e55ff6a51dd7ace9 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Wed, 19 Aug 2026 16:55:33 +0800 Subject: [PATCH 3/9] fix(release): give CDP discovery the time a cold runner actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second diagnostics run separated another cause: the standalone smoke read the child's own port, /json/list answered throughout, and no debuggable page target appeared within the old 30-second line — a healthy app on a cold Windows runner that scans a first-run executable, consistent with the step having been observed at 74 seconds. Per this file's own rule that a wrong deadline fails a good release, target discovery and the renderer-usable loop both get 120 seconds (the child exiting still fails immediately; the workflow timeout stays the outer bound), and the timeout message now states where discovery stalled — no port file, a port that never answers, an unexpected HTTP status, and an endpoint with no page target are four different faults. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index d76e1ff40d..755b8ebd55 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -98,9 +98,15 @@ async function readDevToolsPort(userDataDirectory) { async function findRendererTarget(userDataDirectory, child) { const startedAt = Date.now(); - const deadline = startedAt + 30_000; + // Generous on purpose: a cold Windows runner scans a first-run executable + // before letting it serve, and the port-file diagnostics showed a healthy + // app answering /json/list with no page target yet past the old 30-second + // line (the step itself had been observed taking 74 seconds). The child + // exiting still fails immediately; the workflow timeout is the outer bound + // for everything else. + const deadline = startedAt + 120_000; let port = null; - let lastError; + let lastState = 'DevToolsActivePort was never written under the user-data directory'; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Packaged Maka exited before its renderer was ready.`); @@ -115,24 +121,23 @@ async function findRendererTarget(userDataDirectory, child) { (target) => target.type === 'page' && target.webSocketDebuggerUrl, ); if (page) return page; + lastState = `port ${port} answered with ${targets.length} targets but no debuggable page`; + } else { + lastState = `port ${port} answered HTTP ${response.status}`; } } catch (error) { - lastError = error; + lastState = `port ${port} did not answer: ${error.message}`; } } await delay(250); } - // Say which of the three causes this is, so classifying the failure does not - // take a re-run: no port file means the browser never opened an endpoint; - // a port plus fetch errors means it bound somewhere the poll could reach - // but never answered. + // Say exactly where discovery stalled, so classifying the failure does not + // take a re-run: no port file, a port that never answers, an unexpected + // HTTP status, and a healthy endpoint with no page target are four + // different faults. const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); throw new Error( - `Packaged Maka renderer did not expose CDP within ${elapsedSeconds} seconds: ${ - port === null - ? 'DevToolsActivePort was never written under the user-data directory' - : `polled port ${port} from DevToolsActivePort${lastError ? `; last error: ${lastError.message}` : ''}` - }.`, + `Packaged Maka renderer did not expose CDP within ${elapsedSeconds} seconds: ${lastState}.`, ); } @@ -295,7 +300,9 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { try { const target = await findRendererTarget(userData, child); - const deadline = Date.now() + 30_000; + // Same headroom as target discovery: a cold runner that was slow to serve + // CDP is as slow to mount React, and a wrong deadline fails a good build. + const deadline = Date.now() + 120_000; let rendererState; while (Date.now() < deadline) { rendererState = await evaluateRenderer(target.webSocketDebuggerUrl); From 090158d8d7985160290506f2b3d254d348b6616e Mon Sep 17 00:00:00 2001 From: Joob1n Date: Wed, 19 Aug 2026 19:57:43 +0800 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20restart=20CI=20=E2=80=94=20test=5F?= =?UTF-8?q?workspaces=20hung=20past=20three=20hours?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J From 119d9da76be8b23a9a71929a97d02b170e6f9d11 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 20 Aug 2026 11:00:55 +0800 Subject: [PATCH 5/9] fix(release): bound each CDP poll attempt so the deadline stays honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third diagnostics run surfaced a genuine app fault — the installed candidate's Runtime Host stopped responding during startup, so the main process never served CDP — but the loop reported 355 seconds against a 120-second deadline, because a fetch against a bound-but-unresponsive endpoint hangs for undici's 300-second headers timeout. Each attempt now carries a 2-second abort, so the poll keeps its cadence and the deadline means what it says; the app's own fatal stderr still rides along in the failure message, which is how this fault was attributed at all. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index dbd5936177..05fd9f4c6d 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -114,7 +114,13 @@ export async function findRendererTarget(userDataDirectory, child) { port ??= await readDevToolsPort(userDataDirectory); if (port !== null) { try { - const response = await fetch(`http://127.0.0.1:${port}/json/list`); + // Per-attempt timeout: undici's default headers timeout is 300 seconds, + // so a single hanging attempt against a bound-but-unresponsive endpoint + // (a main process stuck in startup) would otherwise blow straight + // through the loop's deadline — one run overshot it to 355 seconds. + const response = await fetch(`http://127.0.0.1:${port}/json/list`, { + signal: AbortSignal.timeout(2_000), + }); if (response.ok) { const targets = await response.json(); const page = targets.find( From 55ce142dc37fc204e113ce9cd22ff6972fa6caab Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 20 Aug 2026 14:39:06 +0800 Subject: [PATCH 6/9] fix(release): bound the Windows process probe so its poll deadlines hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approved run reached the auto-update step, waited for the upgraded app to relaunch, and hung there for 59 minutes until the runner cancelled the job — past a 120-second deadline. The app had in fact relaunched (the runner terminated it as an orphan at cleanup); the `Get-CimInstance Win32_Process` probe never returned, and a poll loop only checks its deadline between probes, so an unbounded probe makes the deadline a lie. Same defect class as the CDP poll this PR already bounded, one layer down. The probe now carries a 30-second timeout — far above a normal query, far below the 60s and 120s deadlines above it — so a stuck WMI query fails naming itself instead of freezing the job. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-windows-installer-lifecycle.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/verify-windows-installer-lifecycle.mjs b/scripts/verify-windows-installer-lifecycle.mjs index 4f20445308..e12d8ad8b7 100644 --- a/scripts/verify-windows-installer-lifecycle.mjs +++ b/scripts/verify-windows-installer-lifecycle.mjs @@ -37,7 +37,18 @@ $matches = @( ) $matches | ConvertTo-Json -Compress `; - const { stdout } = await run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]); + // Bounded because this probe runs inside deadline-bounded poll loops, and a + // loop only checks its deadline between probes: an unbounded one makes the + // deadline a lie. A `Get-CimInstance Win32_Process` query hung for the rest + // of the job during an auto-update relaunch, so the 120s relaunch deadline + // never fired and the runner cancelled the run an hour later. + const { stdout } = await run( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', script], + { + timeoutMs: 30_000, + }, + ); if (!stdout.trim()) return []; const parsed = JSON.parse(stdout); return Array.isArray(parsed) ? parsed : [parsed]; From af023a7ff95d12f44496dd2c9a87c71aac1af977 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 20 Aug 2026 15:04:51 +0800 Subject: [PATCH 7/9] fix(release): let the process-poll deadline outrank a stalled probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounding the probe turned a 59-minute zombie job into a 74-second failure, and the next run showed why that is not enough: the Windows process query stalls at one specific moment — while NSIS hands off and relaunches the upgraded app, with the process table in flux — so the probe timing out aborted a verification whose 120-second deadline had barely started, about a relaunch that had in fact happened. A bounded probe that gives up says nothing about the processes, so the loops now treat a probe failure as an unknown round and keep polling; their own deadline stays the authority and reports the last probe failure alongside, so a persistent stall is still visible and is not mistaken for a missing app. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-windows-autoupdate.mjs | 26 +++++++++++++++---- .../verify-windows-installer-lifecycle.mjs | 21 ++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index 72bcb958dd..66434dbeaf 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -400,13 +400,29 @@ export async function verifyWindowsAutoupdate( step('waiting for the upgraded app to relaunch automatically'); const relaunchDeadline = Date.now() + 120_000; let relaunched = []; + let probeError; for (;;) { - relaunched = (await listInstalledProcesses(installDirectory)).filter( - (processInfo) => basename(processInfo.path).toLowerCase() === executableName.toLowerCase(), - ); - if (relaunched.length > 0) break; + try { + relaunched = (await listInstalledProcesses(installDirectory)).filter( + (processInfo) => + basename(processInfo.path).toLowerCase() === executableName.toLowerCase(), + ); + probeError = undefined; + if (relaunched.length > 0) break; + } catch (error) { + // The Windows process query stalls exactly here — while NSIS is + // handing off and relaunching, the process table is in flux — so a + // bounded probe that gives up says nothing about the relaunch. This + // deadline stays the authority; the last probe failure is reported + // with it so a persistent stall is not mistaken for a missing app. + probeError = error; + } if (Date.now() >= relaunchDeadline) { - throw new Error('The installer did not relaunch the upgraded app within 120s.'); + throw new Error( + `The installer did not relaunch the upgraded app within 120s.${ + probeError ? `\nLast process probe failed: ${probeError.message}` : '' + }`, + ); } await delay(1_000); } diff --git a/scripts/verify-windows-installer-lifecycle.mjs b/scripts/verify-windows-installer-lifecycle.mjs index e12d8ad8b7..220c2410be 100644 --- a/scripts/verify-windows-installer-lifecycle.mjs +++ b/scripts/verify-windows-installer-lifecycle.mjs @@ -64,16 +64,29 @@ export async function waitForInstalledProcessesToExit( } = {}, ) { const deadline = Date.now() + timeoutMs; - let processes = await listProcesses(installDirectory); - while (processes.length > 0) { + let processes = []; + let probeError; + for (;;) { + try { + processes = await listProcesses(installDirectory); + probeError = undefined; + if (processes.length === 0) return; + } catch (error) { + // A bounded probe that fails says nothing about the processes, so the + // loop's own deadline stays the authority — the Windows process query + // stalls while an installer is churning the process table, and one + // stalled probe must not decide a verification. + probeError = error; + } if (Date.now() >= deadline) { const summary = processes.map(({ processId, name }) => `${name} (${processId})`).join(', '); throw new Error( - `Installed Maka processes did not exit within ${timeoutMs}ms: ${summary || ''}.`, + `Installed Maka processes did not exit within ${timeoutMs}ms: ${summary || ''}.${ + probeError ? `\nLast process probe failed: ${probeError.message}` : '' + }`, ); } await sleep(pollIntervalMs); - processes = await listProcesses(installDirectory); } } From 7eeb843e20fc96efb973e4880f6289babaebe6da Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 20 Aug 2026 16:33:23 +0800 Subject: [PATCH 8/9] fix(release): size the process probe to its budget and say what was observed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the probe bound was the right idea at the wrong proportion: 30 seconds inside a 60-second poll left room for two attempts, so a single stall could consume the whole window — a loop that can only try twice cannot survive a stall, which is the entire point of tolerating one. The probe is now 10 seconds and takes the budget as a parameter. The deadline messages also claimed more than they knew. Never having read the process table is a different fault from processes refusing to exit, or from an installer failing to relaunch, and only some of those are about Maka; each loop now reports which of the two it observed. Renderer readiness moves to one exported constant. The auto-update path kept two 30-second waits after the shared smoke was raised to 120 for slow cold Windows runners, which is exactly the drift a shared number prevents; the single-evaluation and taskkill timeouts stay where they are, being bounded calls rather than readiness waits. `findRendererTarget` also re-reads DevToolsActivePort each round instead of latching the first value, so a caller that leaves a predecessor's file behind converges once the child overwrites it rather than polling a dead port for the full deadline. Verified against a real packaged app with a stale file pre-seeded. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 29 ++++++++++++------- scripts/verify-windows-autoupdate.mjs | 18 ++++++++---- .../verify-windows-installer-lifecycle.mjs | 25 ++++++++++++---- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 05fd9f4c6d..52142c7bd8 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -79,6 +79,15 @@ function delay(milliseconds) { }); } +/** + * One budget for every wait a cold Windows runner can stretch — exposing CDP + * and mounting React alike. The runner scans a first-run executable before + * letting it serve, and a step that had been observed taking 74 seconds was + * failing a 30-second line. Holding it in one place is what keeps a second + * launch path from silently keeping the old, too-tight deadline. + */ +export const RENDERER_READY_TIMEOUT_MS = 120_000; + /** * The port Chromium actually bound, read from the DevToolsActivePort file it * writes into the user-data directory. The verifier used to reserve a port, @@ -98,20 +107,20 @@ async function readDevToolsPort(userDataDirectory) { export async function findRendererTarget(userDataDirectory, child) { const startedAt = Date.now(); - // Generous on purpose: a cold Windows runner scans a first-run executable - // before letting it serve, and the port-file diagnostics showed a healthy - // app answering /json/list with no page target yet past the old 30-second - // line (the step itself had been observed taking 74 seconds). The child - // exiting still fails immediately; the workflow timeout is the outer bound - // for everything else. - const deadline = startedAt + 120_000; + const deadline = startedAt + RENDERER_READY_TIMEOUT_MS; let port = null; let lastState = 'DevToolsActivePort was never written under the user-data directory'; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Packaged Maka exited before its renderer was ready.`); } - port ??= await readDevToolsPort(userDataDirectory); + // Re-read rather than latch the first value: Chromium writes this file at + // startup, so a caller that left a predecessor's file in place is polling + // a dead port only until the child overwrites it. Callers should still + // remove it before spawning — a stale file that is never overwritten + // cannot be told from a fresh one — but the poll converges either way. + const current = await readDevToolsPort(userDataDirectory); + if (current !== null) port = current; if (port !== null) { try { // Per-attempt timeout: undici's default headers timeout is 300 seconds, @@ -333,9 +342,7 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { try { const target = await findRendererTarget(userData, child); - // Same headroom as target discovery: a cold runner that was slow to serve - // CDP is as slow to mount React, and a wrong deadline fails a good build. - const deadline = Date.now() + 120_000; + const deadline = Date.now() + RENDERER_READY_TIMEOUT_MS; let rendererState; while (Date.now() < deadline) { rendererState = await evaluateRenderer(target.webSocketDebuggerUrl); diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index 66434dbeaf..3b048a503e 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -9,6 +9,7 @@ import { findRendererTarget, isPackagedRendererUsable, isolatedUserEnv, + RENDERER_READY_TIMEOUT_MS, RENDERER_STATE_EXPRESSION, runCommand, stopChild, @@ -263,7 +264,7 @@ export async function verifyWindowsAutoupdate( }); const target = await findRendererTarget(userData, child); - const rendererDeadline = Date.now() + 30_000; + const rendererDeadline = Date.now() + RENDERER_READY_TIMEOUT_MS; for (;;) { const state = await evaluateInRenderer( target.webSocketDebuggerUrl, @@ -400,6 +401,7 @@ export async function verifyWindowsAutoupdate( step('waiting for the upgraded app to relaunch automatically'); const relaunchDeadline = Date.now() + 120_000; let relaunched = []; + let observed = false; let probeError; for (;;) { try { @@ -407,6 +409,7 @@ export async function verifyWindowsAutoupdate( (processInfo) => basename(processInfo.path).toLowerCase() === executableName.toLowerCase(), ); + observed = true; probeError = undefined; if (relaunched.length > 0) break; } catch (error) { @@ -418,10 +421,15 @@ export async function verifyWindowsAutoupdate( probeError = error; } if (Date.now() >= relaunchDeadline) { + // Never having read the process table is a different fault from the + // installer failing to relaunch, and saying the second when only the + // first is known sends the next reader after the wrong thing. throw new Error( - `The installer did not relaunch the upgraded app within 120s.${ - probeError ? `\nLast process probe failed: ${probeError.message}` : '' - }`, + observed + ? `The installer did not relaunch the upgraded app within 120s.${ + probeError ? `\nThe last probe also failed: ${probeError.message}` : '' + }` + : `Could not read the process table within 120s, so whether the installer relaunched the upgraded app is unknown.\nLast process probe failed: ${probeError?.message}`, ); } await delay(1_000); @@ -484,7 +492,7 @@ export async function verifyWindowsAutoupdate( ); try { const smokeTarget = await findRendererTarget(smokeUserData, smokeChild); - const deadline = Date.now() + 30_000; + const deadline = Date.now() + RENDERER_READY_TIMEOUT_MS; for (;;) { const state = await evaluateInRenderer( smokeTarget.webSocketDebuggerUrl, diff --git a/scripts/verify-windows-installer-lifecycle.mjs b/scripts/verify-windows-installer-lifecycle.mjs index 220c2410be..4e14c3c2f2 100644 --- a/scripts/verify-windows-installer-lifecycle.mjs +++ b/scripts/verify-windows-installer-lifecycle.mjs @@ -21,7 +21,10 @@ function delay(milliseconds) { return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); } -export async function listInstalledProcesses(installDirectory, { run = runCommand } = {}) { +export async function listInstalledProcesses( + installDirectory, + { run = runCommand, timeoutMs = 10_000 } = {}, +) { const root = `${resolve(installDirectory)}${sep}`; const script = String.raw` $root = [IO.Path]::GetFullPath(${powerShellLiteral(root)}) @@ -42,12 +45,15 @@ $matches | ConvertTo-Json -Compress // deadline a lie. A `Get-CimInstance Win32_Process` query hung for the rest // of the job during an auto-update relaunch, so the 120s relaunch deadline // never fired and the runner cancelled the run an hour later. + // + // Well under the 60s and 120s budgets above it, so a stalled probe leaves + // room for several more attempts rather than consuming the whole window: a + // loop that can only try twice cannot survive a stall, which is the entire + // point of tolerating one. const { stdout } = await run( 'powershell', ['-NoProfile', '-NonInteractive', '-Command', script], - { - timeoutMs: 30_000, - }, + { timeoutMs }, ); if (!stdout.trim()) return []; const parsed = JSON.parse(stdout); @@ -65,10 +71,12 @@ export async function waitForInstalledProcessesToExit( ) { const deadline = Date.now() + timeoutMs; let processes = []; + let observed = false; let probeError; for (;;) { try { processes = await listProcesses(installDirectory); + observed = true; probeError = undefined; if (processes.length === 0) return; } catch (error) { @@ -79,10 +87,17 @@ export async function waitForInstalledProcessesToExit( probeError = error; } if (Date.now() >= deadline) { + // Never having seen the process table is a different fault from seeing + // processes that would not exit, and only one of them is about Maka. + if (!observed) { + throw new Error( + `Could not read the process table within ${timeoutMs}ms, so whether installed Maka processes exited is unknown.\nLast process probe failed: ${probeError?.message}`, + ); + } const summary = processes.map(({ processId, name }) => `${name} (${processId})`).join(', '); throw new Error( `Installed Maka processes did not exit within ${timeoutMs}ms: ${summary || ''}.${ - probeError ? `\nLast process probe failed: ${probeError.message}` : '' + probeError ? `\nThe last probe also failed: ${probeError.message}` : '' }`, ); } From c611cfd7b1708a2a051047676d1b726ae69ae37c Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 20 Aug 2026 17:45:01 +0800 Subject: [PATCH 9/9] refactor(release): hand CDP port discovery to #3265 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3265 fixes the same reserve-then-release race by reading the port from Chromium's `DevTools listening on …` stderr line, which is fresh per launch and therefore cannot go stale — the failure mode this PR had to guard against explicitly, because the upgrade-lifecycle check reuses one user-data directory across two app versions with a kill in between. That mechanism needs no delete-before-spawn convention, so it wins on merit and the port half goes there, along with the per-attempt poll bound both PRs arrived at independently. What remains is disjoint at file level and nobody else has it: the Windows process probe that hung a job for 59 minutes past a 120-second deadline, and the poll semantics that let a stalled probe be an unknown round rather than a verdict. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- scripts/verify-packaged-app.mjs | 120 +++++++++----------------- scripts/verify-windows-autoupdate.mjs | 63 ++++---------- 2 files changed, 57 insertions(+), 126 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 52142c7bd8..a8b6daa235 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1,7 +1,8 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { access, mkdir, readFile, rm } from 'node:fs/promises'; +import { access, mkdir } from 'node:fs/promises'; +import { createServer } from 'node:net'; import { join } from 'node:path'; // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty @@ -73,86 +74,54 @@ export async function assertMissing(path) { throw new Error(`Forbidden release resource exists: ${path}`); } +export async function reserveTcpPort() { + const server = createServer(); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Could not reserve a CDP port.'); + } + await new Promise((resolvePromise, reject) => { + server.close((error) => (error ? reject(error) : resolvePromise())); + }); + return address.port; +} + function delay(milliseconds) { return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); }); } -/** - * One budget for every wait a cold Windows runner can stretch — exposing CDP - * and mounting React alike. The runner scans a first-run executable before - * letting it serve, and a step that had been observed taking 74 seconds was - * failing a 30-second line. Holding it in one place is what keeps a second - * launch path from silently keeping the old, too-tight deadline. - */ -export const RENDERER_READY_TIMEOUT_MS = 120_000; - -/** - * The port Chromium actually bound, read from the DevToolsActivePort file it - * writes into the user-data directory. The verifier used to reserve a port, - * release it, and hand the number to Electron — leaving a window in which - * anything else on the runner could take it, and a timeout log that could not - * say whether the app was even listening where the poll looked (issue #3196). - */ -async function readDevToolsPort(userDataDirectory) { - try { - const content = await readFile(join(userDataDirectory, 'DevToolsActivePort'), 'utf8'); - const port = Number.parseInt(content.split('\n')[0] ?? '', 10); - return Number.isInteger(port) && port > 0 ? port : null; - } catch { - return null; - } -} - -export async function findRendererTarget(userDataDirectory, child) { - const startedAt = Date.now(); - const deadline = startedAt + RENDERER_READY_TIMEOUT_MS; - let port = null; - let lastState = 'DevToolsActivePort was never written under the user-data directory'; +export async function findRendererTarget(port, child) { + const deadline = Date.now() + 30_000; + let lastError; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Packaged Maka exited before its renderer was ready.`); } - // Re-read rather than latch the first value: Chromium writes this file at - // startup, so a caller that left a predecessor's file in place is polling - // a dead port only until the child overwrites it. Callers should still - // remove it before spawning — a stale file that is never overwritten - // cannot be told from a fresh one — but the poll converges either way. - const current = await readDevToolsPort(userDataDirectory); - if (current !== null) port = current; - if (port !== null) { - try { - // Per-attempt timeout: undici's default headers timeout is 300 seconds, - // so a single hanging attempt against a bound-but-unresponsive endpoint - // (a main process stuck in startup) would otherwise blow straight - // through the loop's deadline — one run overshot it to 355 seconds. - const response = await fetch(`http://127.0.0.1:${port}/json/list`, { - signal: AbortSignal.timeout(2_000), - }); - if (response.ok) { - const targets = await response.json(); - const page = targets.find( - (target) => target.type === 'page' && target.webSocketDebuggerUrl, - ); - if (page) return page; - lastState = `port ${port} answered with ${targets.length} targets but no debuggable page`; - } else { - lastState = `port ${port} answered HTTP ${response.status}`; - } - } catch (error) { - lastState = `port ${port} did not answer: ${error.message}`; + try { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + if (response.ok) { + const targets = await response.json(); + const page = targets.find( + (target) => target.type === 'page' && target.webSocketDebuggerUrl, + ); + if (page) return page; } + } catch (error) { + lastError = error; } await delay(250); } - // Say exactly where discovery stalled, so classifying the failure does not - // take a re-run: no port file, a port that never answers, an unexpected - // HTTP status, and a healthy endpoint with no page target are four - // different faults. - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); throw new Error( - `Packaged Maka renderer did not expose CDP within ${elapsedSeconds} seconds: ${lastState}.`, + `Packaged Maka renderer did not expose CDP within 30 seconds${ + lastError ? `: ${lastError.message}` : '' + }.`, ); } @@ -302,28 +271,17 @@ export function isolatedUserEnv(homeDirectory, { temporaryDirectory = homeDirect } export async function smokePackagedRenderer(executable, { workingDirectory } = {}) { + const port = await reserveTcpPort(); const home = join(workingDirectory, 'home'); const userData = join(workingDirectory, 'user-data'); const userEnv = isolatedUserEnv(home); await mkdir(home, { recursive: true }); await mkdir(userData, { recursive: true }); - // Chromium removes DevToolsActivePort only on a clean exit, and the - // upgrade-lifecycle check reuses one user-data directory across two app - // versions with a SIGKILL between them — so a file found here can belong to - // the previous instance, pointing the poll at a port nothing listens on - // anymore. Deleting it first means whatever appears was written by this - // child. This is what the first run of the port-file diagnostics caught: - // the poll read a bound-looking port and fetch failed for the full window. - await rm(join(userData, 'DevToolsActivePort'), { force: true }); await mkdir(userEnv.APPDATA, { recursive: true }); await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); const child = spawn( executable, - // Port 0: Chromium binds a free port itself and records it in the - // user-data directory's DevToolsActivePort file, which is where the poll - // reads it back — no reserve-then-release window for another process on - // the runner to take the number first. - ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], + [`--remote-debugging-port=${port}`, `--user-data-dir=${userData}`, '--enable-logging=stderr'], { cwd: workingDirectory, env: { @@ -341,8 +299,8 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { }); try { - const target = await findRendererTarget(userData, child); - const deadline = Date.now() + RENDERER_READY_TIMEOUT_MS; + const target = await findRendererTarget(port, child); + const deadline = Date.now() + 30_000; let rendererState; while (Date.now() < deadline) { rendererState = await evaluateRenderer(target.webSocketDebuggerUrl); diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index 3b048a503e..21dbe836ba 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -9,8 +9,8 @@ import { findRendererTarget, isPackagedRendererUsable, isolatedUserEnv, - RENDERER_READY_TIMEOUT_MS, RENDERER_STATE_EXPRESSION, + reserveTcpPort, runCommand, stopChild, } from './verify-packaged-app.mjs'; @@ -230,6 +230,7 @@ export async function verifyWindowsAutoupdate( await access(uninstaller); step('launching the installed candidate against the loopback feed'); + const cdpPort = await reserveTcpPort(); const home = join(temporaryDirectory, 'home'); const userData = join(temporaryDirectory, 'user-data'); const userEnv = isolatedUserEnv(home); @@ -237,9 +238,6 @@ export async function verifyWindowsAutoupdate( await mkdir(userData, { recursive: true }); await mkdir(userEnv.APPDATA, { recursive: true }); await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); - // A DevToolsActivePort left by an earlier instance would point the poll at - // a dead port; whatever appears after this belongs to the child below. - await rm(join(userData, 'DevToolsActivePort'), { force: true }); const childEnv = { ...process.env, MAKA_SKIP_SHELL_ENV: '1', @@ -252,9 +250,11 @@ export async function verifyWindowsAutoupdate( delete childEnv.MAKA_UPDATE_MOCK_STATE; child = spawn( installedExecutable, - // Port 0: Chromium binds a free port and records it in DevToolsActivePort, - // which is where findRendererTarget reads it back. - ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], + [ + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${userData}`, + '--enable-logging=stderr', + ], { cwd: temporaryDirectory, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] }, ); let stderr = ''; @@ -263,8 +263,8 @@ export async function verifyWindowsAutoupdate( stderr = `${stderr}${chunk}`.slice(-16_384); }); - const target = await findRendererTarget(userData, child); - const rendererDeadline = Date.now() + RENDERER_READY_TIMEOUT_MS; + const target = await findRendererTarget(cdpPort, child); + const rendererDeadline = Date.now() + 30_000; for (;;) { const state = await evaluateInRenderer( target.webSocketDebuggerUrl, @@ -401,36 +401,13 @@ export async function verifyWindowsAutoupdate( step('waiting for the upgraded app to relaunch automatically'); const relaunchDeadline = Date.now() + 120_000; let relaunched = []; - let observed = false; - let probeError; for (;;) { - try { - relaunched = (await listInstalledProcesses(installDirectory)).filter( - (processInfo) => - basename(processInfo.path).toLowerCase() === executableName.toLowerCase(), - ); - observed = true; - probeError = undefined; - if (relaunched.length > 0) break; - } catch (error) { - // The Windows process query stalls exactly here — while NSIS is - // handing off and relaunching, the process table is in flux — so a - // bounded probe that gives up says nothing about the relaunch. This - // deadline stays the authority; the last probe failure is reported - // with it so a persistent stall is not mistaken for a missing app. - probeError = error; - } + relaunched = (await listInstalledProcesses(installDirectory)).filter( + (processInfo) => basename(processInfo.path).toLowerCase() === executableName.toLowerCase(), + ); + if (relaunched.length > 0) break; if (Date.now() >= relaunchDeadline) { - // Never having read the process table is a different fault from the - // installer failing to relaunch, and saying the second when only the - // first is known sends the next reader after the wrong thing. - throw new Error( - observed - ? `The installer did not relaunch the upgraded app within 120s.${ - probeError ? `\nThe last probe also failed: ${probeError.message}` : '' - }` - : `Could not read the process table within 120s, so whether the installer relaunched the upgraded app is unknown.\nLast process probe failed: ${probeError?.message}`, - ); + throw new Error('The installer did not relaunch the upgraded app within 120s.'); } await delay(1_000); } @@ -465,6 +442,7 @@ export async function verifyWindowsAutoupdate( requireWindowsSandbox: true, requireDisclaimer: true, smokeRenderer: async (executable, { workingDirectory }) => { + const smokePort = await reserveTcpPort(); const smokeHome = join(workingDirectory, 'home'); const smokeUserData = join(workingDirectory, 'user-data'); const smokeEnv = isolatedUserEnv(smokeHome); @@ -472,15 +450,10 @@ export async function verifyWindowsAutoupdate( await mkdir(smokeUserData, { recursive: true }); await mkdir(smokeEnv.APPDATA, { recursive: true }); await mkdir(smokeEnv.LOCALAPPDATA, { recursive: true }); - // This directory is shared across the lifecycle's app versions, and - // Chromium removes DevToolsActivePort only on a clean exit — a stale - // file here pointed the poll at the previous instance's dead port. - await rm(join(smokeUserData, 'DevToolsActivePort'), { force: true }); const smokeChild = spawn( executable, - // Port 0: the bound port comes back through DevToolsActivePort. [ - '--remote-debugging-port=0', + `--remote-debugging-port=${smokePort}`, `--user-data-dir=${smokeUserData}`, '--enable-logging=stderr', ], @@ -491,8 +464,8 @@ export async function verifyWindowsAutoupdate( }, ); try { - const smokeTarget = await findRendererTarget(smokeUserData, smokeChild); - const deadline = Date.now() + RENDERER_READY_TIMEOUT_MS; + const smokeTarget = await findRendererTarget(smokePort, smokeChild); + const deadline = Date.now() + 30_000; for (;;) { const state = await evaluateInRenderer( smokeTarget.webSocketDebuggerUrl,