| 1 | import { createHash } from "node:crypto"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { readdirSync, readFileSync } from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | |
| 6 | export function buildIdentity(frontendDir) { |
| 7 | const hash = createHash("sha256"); |
| 8 | function visit(directory) { |
| 9 | for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { |
| 10 | const file = path.join(directory, entry.name); |
| 11 | if (entry.isDirectory()) visit(file); |
| 12 | else hash.update(path.relative(frontendDir, file)).update(readFileSync(file)); |
| 13 | } |
| 14 | } |
| 15 | visit(path.join(frontendDir, "dist")); |
| 16 | const git = (...args) => execFileSync("git", args, { cwd: frontendDir, encoding: "utf8" }).trim(); |
| 17 | const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z", "--", "."], { cwd: frontendDir, encoding: "utf8" }).split("\0").filter(Boolean).sort(); |
| 18 | const untrackedHash = createHash("sha256"); |
| 19 | for (const file of untracked) untrackedHash.update(file).update("\0").update(readFileSync(path.join(frontendDir, file))).update("\0"); |
| 20 | return { |
| 21 | sourceSHA: git("rev-parse", "HEAD"), |
| 22 | trackedDiffSHA256: createHash("sha256").update(git("diff", "HEAD", "--", ".")).digest("hex"), |
| 23 | untrackedSourceSHA256: untrackedHash.digest("hex"), |
| 24 | sourceStatus: git("status", "--porcelain", "--", "."), |
| 25 | buildSHA256: hash.digest("hex"), |
| 26 | node: process.version, platform: process.platform, arch: process.arch, |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | // IDs, not count deltas: an increasing population can hide behind simultaneous GC. |
| 31 | export function retainedCohorts(samples) { |
| 32 | const firstSeen = new Map(); |
| 33 | return samples.map((sample, index) => { |
| 34 | const ids = sample.lifecycle.liveRenderTokenIds; |
| 35 | for (const id of ids) if (!firstSeen.has(id)) firstSeen.set(id, index); |
| 36 | return { |
| 37 | phase: sample.phase, roundTrips: sample.roundTrips, |
| 38 | survivorsFromBaseline: ids.filter(id => firstSeen.get(id) === 0), |
| 39 | // Two later observations after completed round trips; not a whole-App count budget. |
| 40 | retainedPostBaseline: ids.filter(id => firstSeen.get(id) > 0 && firstSeen.get(id) < index - 1), |
| 41 | }; |
| 42 | }); |
| 43 | } |
| 44 | |
| 45 | export function evidenceIntegrity(samples) { |
| 46 | return samples.length > 0 && samples.every(({ lifecycle }) => ( |
| 47 | Array.isArray(lifecycle.liveRenderTokenIds) |
| 48 | && new Set(lifecycle.liveRenderTokenIds).size === lifecycle.liveRenderTokenIds.length |
| 49 | && lifecycle.liveRenderTokenIds.length === lifecycle.liveRenderTokens |
| 50 | && lifecycle.overflow === false && lifecycle.invariantViolations === 0 |
| 51 | && lifecycle.activeOperations >= 0 && lifecycle.activeSubscriptions >= 0 |
| 52 | )); |
| 53 | } |
| 54 | |
| 55 | // Counter stability is a screening result, not heap-retainer attribution. |
| 56 | // Weak refs observe only instrumented tokens. They cannot explain survivors |
| 57 | // outside that cohort, compiled-code growth, or the mainline control delta, |
| 58 | // so heap-retainer and control evidence stays an offline attribution duty |
| 59 | // that the automated gate can never discharge by itself. |
| 60 | export const OFFLINE_ATTRIBUTION_REASON = "heap-retainer-and-control-evidence-required"; |
| 61 | |
| 62 | // A counter excursion that fully returns to the warmed baseline is a recorded |
| 63 | // observation, not a leak signal: the objects were provably freed. Real soak |
| 64 | // data shows such blips at phase transitions (e.g. 614 listeners settling |
| 65 | // back to 512). Only a displaced final tail is persistent drift. |
| 66 | export const TRANSIENT_EXCURSION_REASON = "transient-counter-excursion"; |
| 67 | |
| 68 | // A single early baseline reading can sit above the resting value while layout |
| 69 | // cleanup still owns listeners. Every later reading then looks displaced, which |
| 70 | // the gate would misreport as persistent drift. An unsettled baseline is |
| 71 | // reported as its own blocker instead of being judged as displacement. |
| 72 | export const BASELINE_NOT_SETTLED_REASON = "baseline-not-settled"; |
| 73 | |
| 74 | // Reasons the automated gate must block on. Observations (the offline |
| 75 | // attribution duty, fully-recovered excursions) are recorded on every report |
| 76 | // but are not screening failures. |
| 77 | export function screeningBlockers(reasons) { |
| 78 | return reasons.filter((reason) => reason !== OFFLINE_ATTRIBUTION_REASON && reason !== TRANSIENT_EXCURSION_REASON); |
| 79 | } |
| 80 | |
| 81 | // The gate blocks on retention-shaped drift: a final population above the |
| 82 | // warmed baseline, or an unsettled-baseline tail that is still growing. |
| 83 | // A final population below baseline is released capacity, not retention. |
| 84 | // Intermediate excursions are kept as observations so they still get an |
| 85 | // offline explanation. When the bench ends with an explicit "settled" |
| 86 | // resting-state sample, that sample is the authoritative tail. |
| 87 | function counterDriftReason(values, phases, baselineStable = true) { |
| 88 | const baseline = values[0]; |
| 89 | const final = values.at(-1); |
| 90 | const settledTail = phases.at(-1) === "settled"; |
| 91 | if (baselineStable && final > baseline) return "persistent"; |
| 92 | if (!baselineStable && !settledTail) { |
| 93 | const tail = values.slice(1).slice(-3); |
| 94 | if (tail.length > 1 && tail.every((value, index) => index === 0 || value >= tail[index - 1]) |
| 95 | && tail.at(-1) > tail[0]) return "persistent"; |
| 96 | } |
| 97 | return values.some((value) => value !== baseline) ? "transient" : null; |
| 98 | } |
| 99 | |
| 100 | export function attributeRetention(samples, cohorts = retainedCohorts(samples)) { |
| 101 | if (!evidenceIntegrity(samples) || samples.length < 2) return { status: "needs-attribution", reasons: ["invalid-evidence"] }; |
| 102 | const retained = cohorts.some((cohort) => cohort.retainedPostBaseline.length > 0); |
| 103 | const nativeCountersValid = samples.every(({ dom }) => |
| 104 | [dom?.nodes, dom?.jsEventListeners].every(value => Number.isSafeInteger(value) && value >= 0)); |
| 105 | const released = samples.every((sample) => sample.lifecycle.activeOperations === 0); |
| 106 | const phases = samples.map((sample) => sample.phase); |
| 107 | const baselineStable = samples[0]?.baselineStable !== false; |
| 108 | const reasons = []; |
| 109 | if (retained) reasons.push("persistent-render-cohort"); |
| 110 | if (!nativeCountersValid) reasons.push("invalid-native-counters"); |
| 111 | if (!baselineStable) reasons.push(BASELINE_NOT_SETTLED_REASON); |
| 112 | if (nativeCountersValid) { |
| 113 | const nodeDrift = counterDriftReason(samples.map((sample) => sample.dom.nodes), phases, baselineStable); |
| 114 | const listenerDrift = counterDriftReason(samples.map((sample) => sample.dom.jsEventListeners), phases, baselineStable); |
| 115 | if (nodeDrift === "persistent" || listenerDrift === "persistent") reasons.push("post-gc-dom-or-listener-drift"); |
| 116 | else if (nodeDrift === "transient" || listenerDrift === "transient") reasons.push(TRANSIENT_EXCURSION_REASON); |
| 117 | } |
| 118 | const subscriptionDrift = counterDriftReason(samples.map((sample) => sample.lifecycle.activeSubscriptions), phases); |
| 119 | if (subscriptionDrift === "persistent") reasons.push("subscription-population-drift"); |
| 120 | else if (subscriptionDrift === "transient") reasons.push(TRANSIENT_EXCURSION_REASON); |
| 121 | if (!released) reasons.push("active-operations"); |
| 122 | reasons.push(OFFLINE_ATTRIBUTION_REASON); |
| 123 | return { status: "needs-attribution", reasons: [...new Set(reasons)] }; |
| 124 | } |
| 125 | |
| 126 | // CDP's DOM counter includes attached and detached nodes. Only the heap's |
| 127 | // detachedness field can label an object as detached; unknown stays unknown. |
| 128 | export function summarizeHeap(snapshot) { |
| 129 | const { node_fields: fields, node_types: types } = snapshot.snapshot.meta; |
| 130 | const width = fields.length; |
| 131 | const at = Object.fromEntries(fields.map((field, index) => [field, index])); |
| 132 | const categories = {}; |
| 133 | const detached = {}; |
| 134 | for (let offset = 0; offset < snapshot.nodes.length; offset += width) { |
| 135 | const type = types[at.type][snapshot.nodes[offset + at.type]]; |
| 136 | const category = categories[type] ??= { count: 0, selfBytes: 0 }; |
| 137 | category.count++; |
| 138 | category.selfBytes += snapshot.nodes[offset + at.self_size]; |
| 139 | if (at.detachedness !== undefined && snapshot.nodes[offset + at.detachedness] === 2) { |
| 140 | const name = snapshot.strings[snapshot.nodes[offset + at.name]]; |
| 141 | const entry = detached[name] ??= { count: 0, selfBytes: 0, ids: [] }; |
| 142 | entry.count++; |
| 143 | entry.selfBytes += snapshot.nodes[offset + at.self_size]; |
| 144 | entry.ids.push(snapshot.nodes[offset + at.id]); |
| 145 | } |
| 146 | } |
| 147 | return { categories, detached, detachednessAvailable: at.detachedness !== undefined }; |
| 148 | } |
| 149 |