"""#44352 — one run, five instruments, four arms. The PR's claim is that snow's hookRequest retains closed windows because it assigns the accessor onto the per-window instance, and that moving the assignment to the prototype releases them. This runs four arms against five different instruments so the exhibit shows which instruments can see the effect and which cannot. A2 pre-fix assign requestWindow onto the window instance B2 the fix assign onto DocumentPictureInPicture.prototype P positive hold a strong reference — MUST survive, or the canary is blind Z negative no treatment — MUST be reclaimed, or the canary is stuck on Everything happens inside a single marionette call: a page global written in one call is undefined in the next, so a harness that opens windows in one call and counts in another reads an empty list and reports 0 for every arm. """ import os, types, json os.environ.setdefault("FF_BIN", "/tmp/ff-153.0/firefox/firefox") N = int(os.environ.get("N", "8")) SNAP = os.environ.get("SNAP", "on") src = open("measure6.py").read().split('if __name__')[0] mod = types.ModuleType("m"); exec(compile(src, "measure6.py", "exec"), mod.__dict__) SCRIPT = r""" const cb = arguments[arguments.length-1], arm = arguments[0], N = arguments[1]; const out = { arm: arm, opened: 0, alive: 0, strongHeld: 0, detachedBytes: 0, censusCount: 0, censusBytes: 0, pathVerdict: "-", notes: [] }; (async () => { const refs = [], strong = []; for (let i = 0; i < N; i++) { const w = Services.ww.openWindow(null, "https://example.com/?x=" + i, "_blank", "chrome,width=120,height=120", null); await new Promise(r => setTimeout(r, 250)); try { const cw = w; if (arm === "A2") { try { cw.documentPictureInPicture.requestWindow = function () { return cw; }; } catch (e) {} } else if (arm === "B2") { try { cw.DocumentPictureInPicture.prototype.requestWindow = function () { return cw; }; } catch (e) {} } else if (arm === "P") { strong.push(cw.document); } refs.push(Cu.getWeakReference(cw.document)); } catch (e) {} try { w.close(); } catch (e) {} } out.opened = refs.length; out.strongHeld = strong.length; await new Promise(r => setTimeout(r, 400)); for (let i = 0; i < 15; i++) { Cu.forceGC(); Cu.forceCC(); } await new Promise(r => setTimeout(r, 300)); // 1. weak-reference canary — per-object liveness const live = []; for (const r of refs) { const d = r.get(); if (d) live.push(d); } out.alive = live.length; // 2. memory reporters — the about:memory numbers try { const mgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService(Ci.nsIMemoryReporterManager); await new Promise((resolve) => { mgr.getReports({ callback(process, path, kind, units, amount) { if (!path.includes("window-objects")) return; if (!path.includes("detached") && !path.includes("top(none)")) return; if (units === 0) out.detachedBytes += amount; }, }, null, { callback() { resolve(); } }, null, false); }); } catch (e) { out.notes.push("reporters: " + e); } // 3-5. heap snapshot: census, then shortest-path to a retained document. // Optional: each snapshot costs ~100 MB of disk. if (arguments[2] !== "off") try { const snap = ChromeUtils.readHeapSnapshot(ChromeUtils.saveHeapSnapshot({ runtime: true })); const c = snap.takeCensus({ breakdown: { by: "objectClass", then: { by: "count", count: true, bytes: true } } }); if (c.Window) { out.censusCount = c.Window.count; out.censusBytes = c.Window.bytes; } if (live.length) { const tid = ChromeUtils.getObjectNodeId(live[0]); const dt = snap.computeDominatorTree(); try { const m = snap.computeShortestPaths(dt.root, [tid], 3); const paths = m.get(tid); out.pathVerdict = paths && paths.length ? "harness frame only" : "no path"; } catch (e) { out.pathVerdict = "id absent"; } } else { out.pathVerdict = "n/a (none alive)"; } } catch (e) { out.notes.push("snapshot: " + e); } cb(JSON.stringify(out)); })(); """ p = mod.launch() rows = [] try: from marionette_driver.marionette import Marionette cli = Marionette(host="localhost", port=mod.PORT); cli.start_session() cli.set_context("chrome"); cli.timeout.script = 300 print("#44352 snow hookRequest window retention -- Firefox 153.0 headless") print("N=%d windows/arm 15x Cu.forceGC()+Cu.forceCC() single chrome-context call" % N) print("=" * 86) print("%-3s %-26s %-13s %-11s %-13s %s" % ("arm", "canary alive/opened", "reporters", "census", "census", "shortestPath")) print("%-3s %-26s %-13s %-11s %-13s %s" % ("", "(per-object liveness)", "detached B", "Window n", "Window bytes", "to live doc")) print("-" * 86) for arm in os.environ.get("ARMS", "A2,B2,P,Z").split(","): r = json.loads(cli.execute_async_script(SCRIPT, script_args=(arm, N, SNAP))) rows.append(r) flag = "" if r["arm"] == "P": flag = " <- must be %d" % r["opened"] if r["arm"] == "Z": flag = " <- must be 0" print("%-3s %-26s %-13s %-11s %-13s %s" % (r["arm"], "%d / %d%s" % (r["alive"], r["opened"], flag), "{:,}".format(r["detachedBytes"]), "not run" if SNAP == "off" else r["censusCount"], "not run" if SNAP == "off" else "{:,}".format(r["censusBytes"]), "not run" if SNAP == "off" else r["pathVerdict"])) for n in r["notes"]: print(" note:", n[:170]) print("-" * 86) a2 = next((x for x in rows if x["arm"] == "A2"), None) b2 = next((x for x in rows if x["arm"] == "B2"), None) pp = next((x for x in rows if x["arm"] == "P"), None) zz = next((x for x in rows if x["arm"] == "Z"), None) if pp and zz: valid = pp["alive"] == pp["opened"] and zz["alive"] == 0 print("controls: P=%d/%d Z=%d/%d -> run is %s" % (pp["alive"], pp["opened"], zz["alive"], zz["opened"], "VALID" if valid else "VOID")) if a2 and b2: print("canary : A2 %d vs B2 %d -> %s" % (a2["alive"], b2["alive"], "DISCRIMINATES (A2 retains more)" if a2["alive"] > b2["alive"] else "no separation" if a2["alive"] == b2["alive"] else "wrong direction -- noise, not signal")) # A leak in A2 must show as A2 > B2. An inequality in the other direction is # noise, not signal -- label it that way rather than counting it as a result. d = a2["detachedBytes"] - b2["detachedBytes"] print("reporters: A2 - B2 = %+d B -> %s" % (d, "blind (no difference)" if d == 0 else "DISCRIMINATES (A2 retains more)" if d > 0 else "wrong direction -- noise, not signal")) if pp: cal = pp["detachedBytes"] - b2["detachedBytes"] print(" calibration: P vs B2 differ by %s B = %d x %s B/document," % ("{:,}".format(cal), pp["opened"], "{:,}".format(cal // pp["opened"]))) print(" so the reporter does resolve a held document -- and reads %d B for A2." % d) if SNAP == "off": print("census : not run this pass (SNAP=off)") else: cd = a2["censusBytes"] - b2["censusBytes"] print("census : A2 - B2 = %+d B -> %s" % (cd, "blind (no difference)" if cd == 0 else "DISCRIMINATES (A2 retains more)" if cd > 0 else "wrong direction -- noise, not signal")) finally: try: p.kill() except Exception: pass json.dump(rows, open("instruments-result.json", "w"), indent=1)