Repository navigation
fix(release): bound the Windows process probe so its poll deadlines hold #3241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
7f61f1e
3ecb005
6532f7a
090158d
8a4a8d6
119d9da
55ce142
af023a7
5543fba
7eeb843
c611cfd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, rm } from 'node:fs/promises'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty | ||
|
|
@@ -74,54 +73,77 @@ 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); | ||
| }); | ||
| } | ||
|
|
||
| export async function findRendererTarget(port, child) { | ||
| const deadline = Date.now() + 30_000; | ||
| let lastError; | ||
| /** | ||
| * 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(); | ||
| // 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 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.`); | ||
| } | ||
| 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); | ||
|
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] Make |
||
| 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}`; | ||
| } | ||
| } 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 30 seconds${ | ||
| lastError ? `: ${lastError.message}` : '' | ||
| }.`, | ||
| `Packaged Maka renderer did not expose CDP within ${elapsedSeconds} seconds: ${lastState}.`, | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -271,17 +293,28 @@ 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, | ||
| [`--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: { | ||
|
|
@@ -299,8 +332,10 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { | |
| }); | ||
|
|
||
| try { | ||
| const target = await findRendererTarget(port, child); | ||
| const deadline = Date.now() + 30_000; | ||
| 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; | ||
| let rendererState; | ||
| while (Date.now() < deadline) { | ||
| rendererState = await evaluateRenderer(target.webSocketDebuggerUrl); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,6 @@ import { | |
| isPackagedRendererUsable, | ||
| isolatedUserEnv, | ||
| RENDERER_STATE_EXPRESSION, | ||
| reserveTcpPort, | ||
| runCommand, | ||
| stopChild, | ||
| } from './verify-packaged-app.mjs'; | ||
|
|
@@ -230,14 +229,16 @@ 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); | ||
| await mkdir(home, { recursive: true }); | ||
| 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', | ||
|
|
@@ -250,11 +251,9 @@ export async function verifyWindowsAutoupdate( | |
| delete childEnv.MAKA_UPDATE_MOCK_STATE; | ||
| child = spawn( | ||
| installedExecutable, | ||
| [ | ||
| `--remote-debugging-port=${cdpPort}`, | ||
| `--user-data-dir=${userData}`, | ||
| '--enable-logging=stderr', | ||
| ], | ||
| // 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'], | ||
| { cwd: temporaryDirectory, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] }, | ||
| ); | ||
| let stderr = ''; | ||
|
|
@@ -263,7 +262,7 @@ export async function verifyWindowsAutoupdate( | |
| stderr = `${stderr}${chunk}`.slice(-16_384); | ||
| }); | ||
|
|
||
| const target = await findRendererTarget(cdpPort, child); | ||
| const target = await findRendererTarget(userData, child); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Apply the new headroom to the Windows renderer waits too, since that is the path the failures came from. |
||
| const rendererDeadline = Date.now() + 30_000; | ||
| for (;;) { | ||
| const state = await evaluateInRenderer( | ||
|
|
@@ -442,18 +441,22 @@ 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); | ||
| await mkdir(smokeHome, { recursive: true }); | ||
| 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=${smokePort}`, | ||
| '--remote-debugging-port=0', | ||
| `--user-data-dir=${smokeUserData}`, | ||
| '--enable-logging=stderr', | ||
| ], | ||
|
|
@@ -464,7 +467,7 @@ export async function verifyWindowsAutoupdate( | |
| }, | ||
| ); | ||
| try { | ||
| const smokeTarget = await findRendererTarget(smokePort, smokeChild); | ||
| const smokeTarget = await findRendererTarget(smokeUserData, smokeChild); | ||
| const deadline = Date.now() + 30_000; | ||
| for (;;) { | ||
| const state = await evaluateInRenderer( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.