Chrome stopped the background worker about six minutes after a site's request even with the approval window open, and the approval and the site's waiting request went with it. While any approval is pending, the worker now calls runtime.getPlatformInfo() every 20 seconds; Chrome counts each extension call as activity. The timer is the worker's own, so a minimized window does not slow it, and it stops itself once nothing is pending. tests/e2e/slowApproval.js, run by make test-e2e, leaves a transaction and a signature approval open for seven minutes in minimized windows and requires the site to receive both results. It drives Chromium over the DevTools pipe without Playwright, because Playwright keeps DevTools attached to the worker and Chrome then never stops it. Model: opus-5-5
531 lines
20 KiB
JavaScript
531 lines
20 KiB
JavaScript
// A transaction approval and a signature approval the user leaves open for
|
|
// seven minutes, their windows minimized, are still answered, and the site
|
|
// receives both results (https://git.eeqj.de/sneak/AutistMask/issues/540).
|
|
//
|
|
// Both approvals, and the site's requests waiting on them, live only in the
|
|
// background worker's memory. With nothing else going on, Chrome stopped that
|
|
// worker about six minutes after the site's request, while the window was
|
|
// still open, and the user's answer then reached nobody. While an approval is
|
|
// pending, the worker now makes an extension call every 20 seconds, which
|
|
// keeps it running (keepRunningWhileApprovalsPending() in
|
|
// src/background/index.js). The windows are minimized for the wait because
|
|
// Chrome slows a hidden page's timers to once a minute after a few minutes,
|
|
// so anything the window itself did to keep the worker running would fail
|
|
// here.
|
|
//
|
|
// A separate program from run.js, and not driven by Playwright: Playwright
|
|
// keeps DevTools attached to the background worker, and Chrome never stops a
|
|
// worker DevTools is attached to, so in run.js the worker outlives any wait
|
|
// and a wait there proves nothing. This program starts Chromium itself and
|
|
// speaks the DevTools protocol over the pipe Chromium opens for it
|
|
// (--remote-debugging-pipe), attaching to pages only, never to the worker.
|
|
// Before the approvals are raised it watches the worker stop on its own, so a
|
|
// run in which something else keeps the worker running cannot pass.
|
|
//
|
|
// The dApp page and the stub node are the Firefox suite's loopback ones
|
|
// (tests/e2e/firefox/dapp.js). The site is connected and the node chosen
|
|
// through the extension's own screens, so nothing is written to its storage
|
|
// from outside.
|
|
//
|
|
// Run through script/test-e2e, which builds the extension and the pinned
|
|
// container.
|
|
//
|
|
// node tests/e2e/slowApproval.js
|
|
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawn } = require("child_process");
|
|
|
|
const { chromium } = require("playwright-core");
|
|
const {
|
|
Transaction,
|
|
getAddress,
|
|
getBytes,
|
|
hexlify,
|
|
parseEther,
|
|
toQuantity,
|
|
toUtf8Bytes,
|
|
verifyMessage,
|
|
} = require("ethers");
|
|
|
|
const { PASSWORD } = require("./harness");
|
|
const { STUB_COUNTERPARTY } = require("./network");
|
|
const { startDappServer } = require("./firefox/dapp");
|
|
|
|
const DIST_CHROME = path.resolve(__dirname, "..", "..", "dist", "chrome");
|
|
|
|
// Chrome stopped the worker six minutes after the site's request when nothing
|
|
// kept it running, so the approvals are left open for longer than that.
|
|
const APPROVAL_WAIT_MS = 7 * 60 * 1000;
|
|
|
|
// With nothing pending, Chrome stops the worker 30 seconds after its last
|
|
// event; this is how long the program waits to see that.
|
|
const IDLE_STOP_TIMEOUT_MS = 90000;
|
|
|
|
const SIGN_HEX = hexlify(toUtf8Bytes("AutistMask e2e: a slow signature"));
|
|
|
|
const checks = [];
|
|
let failed = 0;
|
|
|
|
function check(name, cond, detail) {
|
|
checks.push(name);
|
|
if (cond) {
|
|
console.log("ok " + checks.length + " - " + name);
|
|
} else {
|
|
failed += 1;
|
|
console.log("not ok " + checks.length + " - " + name);
|
|
if (detail) console.log(" " + detail);
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Poll `probe` until it returns something truthy, and return that.
|
|
async function until(what, probe, timeout = 30000) {
|
|
const deadline = Date.now() + timeout;
|
|
for (;;) {
|
|
const value = await probe().catch(() => null);
|
|
if (value) return value;
|
|
if (Date.now() > deadline) {
|
|
throw new Error(
|
|
"gave up after " + timeout + "ms waiting for " + what,
|
|
);
|
|
}
|
|
await sleep(250);
|
|
}
|
|
}
|
|
|
|
// The DevTools protocol over Chromium's pipe: commands go to its fd 3, and
|
|
// replies and events come back on its fd 4, each one JSON message ended by a
|
|
// NUL byte. Events are not used and are dropped. Returns send(method, params,
|
|
// sessionId), which resolves with the command's result.
|
|
function devtools(browser) {
|
|
const waiting = new Map();
|
|
let nextId = 1;
|
|
let pending = "";
|
|
const replies = browser.stdio[4];
|
|
replies.setEncoding("utf8");
|
|
replies.on("data", (chunk) => {
|
|
pending += chunk;
|
|
let end;
|
|
while ((end = pending.indexOf("\0")) !== -1) {
|
|
const message = JSON.parse(pending.slice(0, end));
|
|
pending = pending.slice(end + 1);
|
|
const caller = waiting.get(message.id);
|
|
if (!caller) continue;
|
|
waiting.delete(message.id);
|
|
if (message.error) {
|
|
caller.reject(
|
|
new Error(caller.method + ": " + message.error.message),
|
|
);
|
|
} else {
|
|
caller.resolve(message.result);
|
|
}
|
|
}
|
|
});
|
|
replies.on("close", () => {
|
|
for (const caller of waiting.values()) {
|
|
caller.reject(new Error("the browser exited"));
|
|
}
|
|
waiting.clear();
|
|
});
|
|
return (method, params = {}, sessionId) =>
|
|
new Promise((resolve, reject) => {
|
|
const id = nextId++;
|
|
waiting.set(id, { method, resolve, reject });
|
|
browser.stdio[3].write(
|
|
JSON.stringify({ id, method, params, sessionId }) + "\0",
|
|
);
|
|
});
|
|
}
|
|
|
|
// Attach to a page and return a function that evaluates an expression in it,
|
|
// awaiting a promise it returns, and resolves with the value.
|
|
async function attach(send, targetId) {
|
|
const { sessionId } = await send("Target.attachToTarget", {
|
|
targetId,
|
|
flatten: true,
|
|
});
|
|
return async (expression) => {
|
|
const r = await send(
|
|
"Runtime.evaluate",
|
|
{ expression, awaitPromise: true, returnByValue: true },
|
|
sessionId,
|
|
);
|
|
if (r.exceptionDetails) {
|
|
throw new Error(
|
|
"evaluating " +
|
|
expression.slice(0, 80) +
|
|
" threw: " +
|
|
JSON.stringify(r.exceptionDetails.exception || {}),
|
|
);
|
|
}
|
|
return r.result.value;
|
|
};
|
|
}
|
|
|
|
// Whether the page has loaded and shows the element with this id: the
|
|
// `hidden` class showView() keeps on every other view is on neither it nor
|
|
// anything around it.
|
|
function shows(id) {
|
|
return (
|
|
"document.readyState === 'complete' &&" +
|
|
" !!document.getElementById(" +
|
|
JSON.stringify(id) +
|
|
") && !document.getElementById(" +
|
|
JSON.stringify(id) +
|
|
").closest('.hidden')"
|
|
);
|
|
}
|
|
|
|
// Set an input's value and fire the input event typing would fire.
|
|
function typeInto(id, value) {
|
|
return (
|
|
"(() => { const el = document.getElementById(" +
|
|
JSON.stringify(id) +
|
|
"); el.value = " +
|
|
JSON.stringify(value) +
|
|
"; el.dispatchEvent(new Event('input', { bubbles: true })); })()"
|
|
);
|
|
}
|
|
|
|
function click(id) {
|
|
return "document.getElementById(" + JSON.stringify(id) + ").click()";
|
|
}
|
|
|
|
// The outcome of a request the dApp page started with __dapp.start(), or
|
|
// {settled: "pending"} if it has not settled within `timeout`.
|
|
function settled(key, timeout = 30000) {
|
|
return (
|
|
"Promise.race([window.__dapp.settle(" +
|
|
JSON.stringify(key) +
|
|
"), new Promise((r) => setTimeout(() => r({ settled: 'pending' }), " +
|
|
timeout +
|
|
"))])"
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
|
|
console.error(
|
|
"slowApproval: no unpacked build at " +
|
|
DIST_CHROME +
|
|
" — run make build first",
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const node = await startDappServer();
|
|
const profile = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "autistmask-slow-approval-"),
|
|
);
|
|
const browser = spawn(
|
|
chromium.executablePath(),
|
|
[
|
|
"--headless",
|
|
// The container runs unprivileged, as in harness.js.
|
|
"--no-sandbox",
|
|
"--remote-debugging-pipe",
|
|
"--user-data-dir=" + profile,
|
|
"--disable-extensions-except=" + DIST_CHROME,
|
|
"--load-extension=" + DIST_CHROME,
|
|
// Nothing but the loopback dApp page and stub node can be reached.
|
|
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
|
|
"about:blank",
|
|
],
|
|
{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
|
|
);
|
|
|
|
try {
|
|
const send = devtools(browser);
|
|
const targets = async () =>
|
|
(await send("Target.getTargets")).targetInfos;
|
|
|
|
const worker = await until("the background worker", async () =>
|
|
(await targets()).find(
|
|
(t) =>
|
|
t.type === "service_worker" &&
|
|
t.url.startsWith("chrome-extension://"),
|
|
),
|
|
);
|
|
// The extension id is the host of the worker's URL, as in harness.js.
|
|
const popupUrl =
|
|
"chrome-extension://" +
|
|
new URL(worker.url).host +
|
|
"/src/popup/index.html";
|
|
const workerRunning = async () =>
|
|
(await targets()).some((t) => t.url === worker.url);
|
|
const openPage = async (url) => {
|
|
const { targetId } = await send("Target.createTarget", { url });
|
|
return { targetId, evaluate: await attach(send, targetId) };
|
|
};
|
|
const approvalPages = async () =>
|
|
(await targets()).filter(
|
|
(t) => t.type === "page" && t.url.includes("?approval="),
|
|
);
|
|
|
|
// A wallet, through the popup's own screens, and the stub node as its
|
|
// RPC endpoint, through Settings.
|
|
const popup = await openPage(popupUrl);
|
|
await until("the welcome screen", () =>
|
|
popup.evaluate(shows("view-welcome")),
|
|
);
|
|
await popup.evaluate(click("btn-welcome-add"));
|
|
await until("the add wallet screen", () =>
|
|
popup.evaluate(shows("view-add-wallet")),
|
|
);
|
|
await popup.evaluate(click("btn-generate-phrase"));
|
|
await until("a generated recovery phrase", () =>
|
|
popup.evaluate(
|
|
"document.getElementById('wallet-mnemonic').value.trim()" +
|
|
".split(/\\s+/).length >= 12",
|
|
),
|
|
);
|
|
await popup.evaluate(typeInto("add-wallet-password", PASSWORD));
|
|
await popup.evaluate(typeInto("add-wallet-password-confirm", PASSWORD));
|
|
await popup.evaluate(click("btn-add-wallet-confirm"));
|
|
await until(
|
|
"the main screen",
|
|
() => popup.evaluate(shows("view-main")),
|
|
60000,
|
|
);
|
|
await popup.evaluate(click("btn-settings"));
|
|
await until("the settings screen", () =>
|
|
popup.evaluate(shows("view-settings")),
|
|
);
|
|
await popup.evaluate(typeInto("settings-rpc", node.rpcUrl));
|
|
await popup.evaluate(click("btn-save-rpc"));
|
|
await until("the stub node saved as the RPC endpoint", () =>
|
|
popup.evaluate(
|
|
"new Promise((resolve) => chrome.storage.local.get(" +
|
|
"'autistmask', (r) => resolve(r.autistmask.rpcUrl === " +
|
|
JSON.stringify(node.rpcUrl) +
|
|
")))",
|
|
),
|
|
);
|
|
await send("Target.closeTarget", { targetId: popup.targetId });
|
|
|
|
// The site, connected through the real prompt with "Remember", so
|
|
// the connection is stored rather than held by the worker.
|
|
const dapp = await openPage(node.url);
|
|
await until("the injected provider", () =>
|
|
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
|
|
);
|
|
await dapp.evaluate(
|
|
"window.__dapp.start('accounts', 'eth_requestAccounts', [])",
|
|
);
|
|
const prompt = await until(
|
|
"the connection prompt",
|
|
async () => (await approvalPages())[0],
|
|
);
|
|
const inPrompt = await attach(send, prompt.targetId);
|
|
await until("the connection prompt's screen", () =>
|
|
inPrompt(shows("view-approve-site")),
|
|
);
|
|
await inPrompt(
|
|
"document.getElementById('approve-remember').checked = true",
|
|
);
|
|
// The prompt closes itself on the click.
|
|
await inPrompt(click("btn-approve")).catch(() => {});
|
|
const accounts = await dapp.evaluate(settled("accounts"));
|
|
if (accounts.settled !== "resolved") {
|
|
throw new Error(
|
|
"the site could not be connected: " + JSON.stringify(accounts),
|
|
);
|
|
}
|
|
const address = getAddress(accounts.result[0]);
|
|
console.log("# address " + address + ", site " + node.origin);
|
|
|
|
// The control: with nothing pending, the worker stops on its own.
|
|
// Were something else keeping it running, the wait below would
|
|
// prove nothing.
|
|
const idleStart = Date.now();
|
|
const stoppedWhenIdle = await until(
|
|
"the idle worker to stop",
|
|
async () => !(await workerRunning()),
|
|
IDLE_STOP_TIMEOUT_MS,
|
|
).catch(() => false);
|
|
check(
|
|
"with nothing pending, Chrome stops the background worker on its own",
|
|
stoppedWhenIdle,
|
|
"the worker was still running " +
|
|
IDLE_STOP_TIMEOUT_MS / 1000 +
|
|
"s after its last event, so something other than the " +
|
|
"approval windows is keeping it running",
|
|
);
|
|
console.log(
|
|
"# idle worker stopped after " +
|
|
Math.round((Date.now() - idleStart) / 1000) +
|
|
"s",
|
|
);
|
|
|
|
// A transaction approval and a signature approval, both open at once.
|
|
await dapp.evaluate(
|
|
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
|
|
JSON.stringify({
|
|
from: address,
|
|
to: STUB_COUNTERPARTY,
|
|
value: toQuantity(parseEther("0.01")),
|
|
}) +
|
|
"])",
|
|
);
|
|
await until("the transaction approval window", async () =>
|
|
(await approvalPages()).length === 1 ? true : null,
|
|
);
|
|
await dapp.evaluate(
|
|
"window.__dapp.start('sign', 'personal_sign', [" +
|
|
JSON.stringify(SIGN_HEX) +
|
|
", " +
|
|
JSON.stringify(address) +
|
|
"])",
|
|
);
|
|
const windows = await until("both approval windows", async () => {
|
|
const pages = await approvalPages();
|
|
return pages.length === 2 ? pages : null;
|
|
});
|
|
let txWindow = null;
|
|
let signWindow = null;
|
|
const windowIds = [];
|
|
for (const page of windows) {
|
|
const evaluate = await attach(send, page.targetId);
|
|
const view = await until("an approval screen", () =>
|
|
evaluate(
|
|
"(" +
|
|
shows("view-approve-tx") +
|
|
") ? 'tx' : (" +
|
|
shows("view-approve-sign") +
|
|
") ? 'sign' : null",
|
|
),
|
|
);
|
|
if (view === "tx") txWindow = evaluate;
|
|
if (view === "sign") signWindow = evaluate;
|
|
const { windowId } = await send("Browser.getWindowForTarget", {
|
|
targetId: page.targetId,
|
|
});
|
|
windowIds.push(windowId);
|
|
}
|
|
if (!txWindow || !signWindow) {
|
|
throw new Error("the two windows are not one of each kind");
|
|
}
|
|
const setWindowState = (windowState) =>
|
|
Promise.all(
|
|
windowIds.map((windowId) =>
|
|
send("Browser.setWindowBounds", {
|
|
windowId,
|
|
bounds: { windowState },
|
|
}),
|
|
),
|
|
);
|
|
|
|
// The user minimizes both windows while deciding.
|
|
await setWindowState("minimized");
|
|
const bothHidden = await until(
|
|
"both approval pages hidden",
|
|
async () =>
|
|
(await txWindow("document.visibilityState")) === "hidden" &&
|
|
(await signWindow("document.visibilityState")) === "hidden",
|
|
).catch(() => false);
|
|
check(
|
|
"both approval windows are minimized, and their pages are hidden",
|
|
bothHidden,
|
|
"a page still reports itself visible, so the wait below would " +
|
|
"not show what happens to a minimized approval",
|
|
);
|
|
|
|
// The user takes seven minutes. The worker is checked every two
|
|
// seconds, and the wait ends early if Chrome stops it.
|
|
const opened = Date.now();
|
|
let stoppedAfter = null;
|
|
while (Date.now() - opened < APPROVAL_WAIT_MS) {
|
|
if (!(await workerRunning())) {
|
|
stoppedAfter = Math.round((Date.now() - opened) / 1000);
|
|
break;
|
|
}
|
|
await sleep(2000);
|
|
}
|
|
check(
|
|
"the background worker keeps running while both approvals wait in minimized windows",
|
|
stoppedAfter === null,
|
|
"Chrome stopped the worker " +
|
|
stoppedAfter +
|
|
"s after the approvals opened",
|
|
);
|
|
|
|
// Back on screen to be answered.
|
|
await setWindowState("normal");
|
|
await txWindow(typeInto("approve-tx-password", PASSWORD));
|
|
await txWindow(click("btn-approve-tx"));
|
|
await signWindow(typeInto("approve-sign-password", PASSWORD));
|
|
// The window closes itself once the signature is handed over.
|
|
await signWindow(click("btn-approve-sign")).catch(() => {});
|
|
|
|
const tx = await dapp.evaluate(settled("tx"));
|
|
const broadcast = node.broadcast.map((raw) => Transaction.from(raw));
|
|
check(
|
|
"the transaction approved after seven minutes reaches the site",
|
|
tx.settled === "resolved" &&
|
|
broadcast.length === 1 &&
|
|
tx.result === broadcast[0].hash &&
|
|
getAddress(broadcast[0].from) === address,
|
|
"the page got " +
|
|
JSON.stringify(tx) +
|
|
" and the node received " +
|
|
broadcast.length +
|
|
" transaction(s)",
|
|
);
|
|
|
|
const sign = await dapp.evaluate(settled("sign"));
|
|
check(
|
|
"the signature approved after seven minutes reaches the site",
|
|
sign.settled === "resolved" &&
|
|
getAddress(verifyMessage(getBytes(SIGN_HEX), sign.result)) ===
|
|
address,
|
|
"the page got " + JSON.stringify(sign),
|
|
);
|
|
} catch (e) {
|
|
failed += 1;
|
|
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
|
|
} finally {
|
|
if (browser.exitCode === null && browser.signalCode === null) {
|
|
const exited = new Promise((resolve) =>
|
|
browser.once("exit", resolve),
|
|
);
|
|
browser.kill();
|
|
await exited;
|
|
}
|
|
await node.close();
|
|
// Chromium's helper processes can still be writing to the profile
|
|
// for a moment after the browser process itself has exited.
|
|
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
|
|
}
|
|
|
|
console.log("1.." + checks.length);
|
|
if (checks.length === 0) {
|
|
console.log("# FAILED: this program asserted nothing");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(
|
|
"# " +
|
|
(checks.length - failed) +
|
|
"/" +
|
|
checks.length +
|
|
" checks passed",
|
|
);
|
|
if (failed > 0) {
|
|
console.log("# FAILED");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("slowApproval: " + (e && e.stack ? e.stack : e));
|
|
process.exitCode = 1;
|
|
});
|