Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 ee58671be4 fix(ci): close storage verifier sockets and trace browser verifier stages
Since 8b4c3de3b added the storage end-to-end check, the real-browser lane
has hit its 10-minute job limit on main and on every PR. The verifier
printed nothing before cancellation, so the stalled step was unknown.
Locally, on macOS with Chrome for Testing 155, it passes in about 70 s.

The storage site was the only verifier site closed without
`closeAllConnections()`. The external browser outlives the site, so a
socket it keeps open can hold `server.close()` and its `close` event
indefinitely. The popup, network and metadata sites already close
connections first.

Write per-stage markers to stderr so a cancelled job shows which step
stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:09:42 +00:00
4 changed files with 62 additions and 63 deletions

No files matched your search

+18 -3
View File
@@ -99,6 +99,12 @@ const scenarioFor = (browser, origin) =>
capture: { after_each_step: ["dom"], at_end: ["dom"] },
});
const storageStart = Date.now();
const stage = (name) =>
process.stderr.write(
`[verify:browser:storage] ${name} +${String(Date.now() - storageStart)}ms\n`,
);
const raceWithTimeout = (operation, timeoutMs, onTimeout = () => undefined) => {
const controller = new AbortController();
return Promise.race([
@@ -175,10 +181,9 @@ const startStorageSite = async () => {
return {
origin: `http://127.0.0.1:${port}`,
close: async () => {
server.close();
// Chrome can leave speculative TCP connections without an HTTP request;
// server.close() alone waits indefinitely for those owned sockets.
// The external browser outlives this site and can keep sockets open.
server.closeAllConnections();
server.close();
await once(server, "close");
},
};
@@ -203,8 +208,11 @@ export async function verifyScenarioStorage({
}) {
const site = await startStorageSite();
try {
stage("launch");
await verifyLaunchStorage(executable, site.origin);
stage("connect");
await verifyConnectedStorage(endpoint, targetId, site.origin);
stage("close");
return {
mocked: false,
launch: true,
@@ -227,6 +235,7 @@ const verifyLaunchStorage = async (executable, origin) => {
origin,
);
const profilesBefore = await scenarioProfiles();
stage("launch: scenario CLI");
const result = await runScenarioCli(scenario);
const capture = result.normalized_result;
assert.ok(capture, "Launch-mode storage scenario returned no result");
@@ -239,6 +248,7 @@ const verifyLaunchStorage = async (executable, origin) => {
...scenario,
actions: [scenario.actions[0]],
});
stage("launch: fresh in-process scenario");
const fresh = await createBrowserScenarioProvider(
process.env,
).captureScenario(freshScenario);
@@ -300,7 +310,9 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
"capture_browser_scenario",
);
try {
stage("connect: storage partition");
await assertStorageKeyPartition(observer, origin);
stage("connect: scenario CLI");
const result = await runScenarioCli(scenario);
const capture = result.normalized_result;
assert.ok(capture, "Connect-mode storage scenario returned no result");
@@ -311,9 +323,12 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
empty: "main-empty-seed",
});
assert.equal(capture.browser.cleanup, "disconnected-external");
stage("connect: caller debugger pause");
await verifyCallerDebuggerPause(observer, origin, scenario);
stage("connect: cancellation");
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
} finally {
stage("connect: restore target");
await raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
() => undefined,
);
+20 -4
View File
@@ -53,6 +53,12 @@ const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
process.env.REA_BROWSER_VERIFIER_URL = SCENARIO_URL_SECRET_VALUE;
const verifierRun = createVerifierRun();
const verifierStart = Date.now();
// Stage markers locate a stalled step when CI cancels the job at its limit.
const stage = (name) =>
process.stderr.write(
`[verify:browser] ${name} +${String(Date.now() - verifierStart)}ms\n`,
);
const executable = await browserExecutable();
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
@@ -109,6 +115,7 @@ try {
);
if (!observed.ok) throw observed.error;
assertObservation(observed.value, site.origin);
stage("verifyBrowserDomDestinations");
const domDestinations = await verifyBrowserDomDestinations({
cdp_endpoint: endpoint,
target_id: target,
@@ -168,6 +175,7 @@ try {
);
assertSensitiveShapes(withSource.value);
stage("verifyPublicBundle");
const bundle = await verifyPublicBundle(endpoint, target, site.origin);
const captureDiff = await provider.compareCaptures(
@@ -231,6 +239,7 @@ try {
throw new Error("Real Chrome same-origin SPA timeline was missing");
pageProxy = await startPageCdpProxy(endpoint);
stage("verifyPageScopedTransport");
await verifyPageScopedTransport(provider, pageProxy, site.origin);
const attachedScenarioInput = browserScenario(
{
@@ -277,18 +286,19 @@ try {
throw new Error("Scenario attachment terminated its external browser");
const profilesBefore = await scenarioProfiles();
process.stderr.write("Browser verifier: scenario environment\n");
stage("verifyScenarioEnvironment");
const scenarioEnvironment = await verifyScenarioEnvironment(
endpoint,
target,
site.origin,
);
process.stderr.write("Browser verifier: scenario storage\n");
stage("verifyScenarioStorage");
const scenarioStorage = await verifyScenarioStorage({
executable,
endpoint,
targetId: target,
});
stage("launchedScenario");
const launchedScenario = await createBrowserScenarioProvider(
process.env,
).captureScenario(
@@ -310,6 +320,7 @@ try {
if ([...profilesAfter].some((entry) => !profilesBefore.has(entry)))
throw new Error("Scenario launch retained a temporary browser profile");
assertScenarioCapture(launchedScenario.value);
stage("verifyScenarioFailureEvidence");
const scenarioFailure = await verifyScenarioFailureEvidence(
executable,
site.origin,
@@ -341,20 +352,24 @@ try {
`Browser scenario included unselected structured value: ${unselectedValue}`,
);
process.stderr.write("Browser verifier: large screenshot\n");
stage("verifyLargeScreenshotE2e");
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
process.stderr.write("Browser verifier: popup coverage\n");
stage("verifyPopupEventCoverage");
const popupEvents = await verifyPopupEventCoverage(executable);
stage("verifyBrowserNetworkEvidence");
const networkEvidence = await verifyBrowserNetworkEvidence(
executable,
undefined,
{ cdp_endpoint: endpoint, target_id: target },
);
stage("verifyBrowserScriptExport");
const scriptExport = await verifyBrowserScriptExport(executable, undefined, {
cdp_endpoint: endpoint,
target_id: target,
});
stage("verifyBrowserModules");
const moduleTrace = await verifyBrowserModules(executable);
stage("verifyBrowserCaptureMetadataBudget");
const captureMetadata = await verifyBrowserCaptureMetadataBudget(endpoint);
report = {
browser: observed.value.browser.product,
@@ -397,6 +412,7 @@ try {
process.stderr.write("Browser verifier cleanup: stopping owned Chrome\n");
if (browser !== undefined) await stopProcess(browser);
process.stderr.write("Browser verifier cleanup: closing fixture site\n");
stage("cleanup");
await site.close();
await rm(profile, {
recursive: true,
-36
View File
@@ -103,39 +103,3 @@ describe("read-only NativeAOT PE parser", () => {
});
});
});
describe("NativeAOT PE section virtual extent", () => {
const TEXT_VIRTUAL_SIZE = 0x188 + 8;
const RDATA_VIRTUAL_SIZE = 0x188 + 40 + 8;
const parseWithVirtualSize = (at: number, virtualSize: number) => {
const bytes = nativeAotPeFixture();
bytes.writeUInt32LE(virtualSize, at);
return parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
"0x140000000",
).summary;
};
it("does not treat raw padding past VirtualSize as executable code", () => {
// The 0x2280 MethodTable's only slot targets 0x1040, past .text's 0x40 bytes.
expect(parseWithVirtualSize(TEXT_VIRTUAL_SIZE, 0x40)).toMatchObject({
method_tables: 2,
});
});
it("does not read a frozen string from raw padding past VirtualSize", () => {
expect(parseWithVirtualSize(RDATA_VIRTUAL_SIZE, 0x300)).toMatchObject({
status: "partial",
frozen_strings: [],
});
});
it("maps raw bytes when VirtualSize is zero, as the image loader does", () => {
expect(parseWithVirtualSize(TEXT_VIRTUAL_SIZE, 0)).toMatchObject({
status: "complete",
method_tables: 3,
});
});
});
+24 -20
View File
@@ -366,13 +366,6 @@ const parsedSummary = (
readTypeDetail: () => undefined,
});
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.rawSize : section.virtualSize;
const fileBackedExtent = (section: Section): number =>
Math.min(section.rawSize, virtualExtent(section));
class PeImage {
readonly imageBase: bigint;
readonly sizeOfImage: number;
@@ -424,7 +417,10 @@ class PeImage {
});
for (const section of this.sections) {
this.#range(section.rawOffset, section.rawSize);
if (section.rva + virtualExtent(section) > this.sizeOfImage)
if (
section.rva + Math.max(section.virtualSize, section.rawSize) >
this.sizeOfImage
)
throw new Error("PE section exceeds the declared image extent");
}
}
@@ -460,11 +456,11 @@ class PeImage {
for (const section of this.sections) {
if (this.#budget !== undefined && !this.#budget.consumeWork(1))
return null;
const extent = virtualExtent(section);
const extent = Math.max(section.virtualSize, section.rawSize);
if (relative < section.rva || relative - section.rva > extent - size)
continue;
const delta = relative - section.rva;
if (delta > fileBackedExtent(section) - size) return null;
if (delta > section.rawSize - size) return null;
const offset = section.rawOffset + delta;
return this.#bytes.subarray(offset, offset + size);
}
@@ -479,7 +475,7 @@ class PeImage {
if (
(section.characteristics & 0x20000000) !== 0 &&
rva >= BigInt(section.rva) &&
rva < BigInt(section.rva + virtualExtent(section))
rva < BigInt(section.rva + section.rawSize)
)
return true;
}
@@ -496,7 +492,10 @@ class PeImage {
if (this.#budget !== undefined && !this.#budget.consumeWork(1)) return 0;
if (
rva >= BigInt(candidate.rva) &&
rva < BigInt(candidate.rva + virtualExtent(candidate))
rva <
BigInt(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
)
) {
section = candidate;
break;
@@ -504,7 +503,9 @@ class PeImage {
}
return section === undefined
? 0
: section.rva + virtualExtent(section) - Number(rva);
: section.rva +
Math.max(section.virtualSize, section.rawSize) -
Number(rva);
}
#range(offset: number, size: number): void {
if (
@@ -601,7 +602,7 @@ const findDirectory = (
)
continue;
const begin = section.rawOffset;
const end = begin + fileBackedExtent(section);
const end = begin + section.rawSize;
const first = (begin + 7) & ~7;
const candidateBytes =
first <= end - 16 ? (Math.floor((end - 16 - first) / 8) + 1) * 8 : 0;
@@ -969,10 +970,10 @@ const discoverTables = (
(section.characteristics & 0x20000000) !== 0
)
continue;
if (fileBackedExtent(section) > 0) {
if (section.rawSize > 0) {
ranges.push({
start: image.addressForRva(section.rva),
end: image.addressForRva(section.rva + fileBackedExtent(section)),
end: image.addressForRva(section.rva + section.rawSize),
});
}
}
@@ -983,7 +984,9 @@ const discoverTables = (
(candidate.characteristics & 0x20000000) === 0 &&
hydrated.base >= image.addressForRva(candidate.rva) &&
hydrated.base + BigInt(hydrated.sizeBytes) <=
image.addressForRva(candidate.rva + virtualExtent(candidate)),
image.addressForRva(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
),
);
if (section !== undefined)
ranges.push({
@@ -1589,10 +1592,11 @@ export const parseNativeAotPe = (
? "raw-image-pointer-scan"
: "raw-image-and-rehydrated-pointer-scan",
},
diagnostics:
hydrationError === null
diagnostics: [
...(hydrationError === null
? []
: [`Read-only hydration stopped: ${hydrationError}`],
: [`Read-only hydration stopped: ${hydrationError}`]),
],
limitations: reportLimitations,
};
if (Buffer.byteLength(JSON.stringify(summary)) > maximumSummaryBytes)