test(hls): [PKG-HLS-SDK-01] capture scoped Firefox native exceptions

This commit is contained in:
Harvey Zhao committed 2026-09-15 17:33:49 +08:00
1 parent e0c5cf27de
commit 2802cbacc5
14 files changed
+26842 -4

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
// Read only exception/module/thread-name metadata; this is not a stack unwinder.
export function inspectFirefoxMinidump(bytes) {
const region = (offset, size) => {
assert(Number.isSafeInteger(offset) && Number.isSafeInteger(size) && offset >= 0 && size >= 0 && offset + size <= bytes.length, 'Truncated minidump region')
return bytes.subarray(offset, offset + size)
}
const u32 = offset => region(offset, 4).readUInt32LE()
const u64 = offset => region(offset, 8).readBigUInt64LE()
const hex = value => `0x${value.toString(16)}`
const string = (offset) => {
const size = u32(offset)
assert.equal(size % 2, 0, 'Invalid UTF-16 minidump string')
return region(offset + 4, size).toString('utf16le')
}
assert.equal(region(0, 4).toString('ascii'), 'MDMP', 'Invalid minidump signature')
const count = u32(8)
assert(count <= 4096, 'Excessive minidump stream count')
const streams = Array.from({ length: count }, (_, i) => {
const at = u32(12) + i * 12
const stream = { type: u32(at), size: u32(at + 4), rva: u32(at + 8) }
region(stream.rva, stream.size)
return stream
})
const stream = (type, minimum) => {
const matches = streams.filter(item => item.type === type)
assert(matches.length === 1 && matches[0].size >= minimum, `Missing, duplicated or truncated stream ${type}`)
return matches[0]
}
const exceptionStream = stream(6, 168)
const e = exceptionStream.rva
const parameters = u32(e + 32)
assert(parameters <= 15, 'Invalid exception parameter count')
const exception = { threadId: u32(e), code: hex(u32(e + 8)), flags: u32(e + 12), address: hex(u64(e + 24)), parameters: Array.from({ length: parameters }, (_, i) => hex(u64(e + 40 + i * 8))) }
const moduleStream = stream(4, 4)
const moduleCount = u32(moduleStream.rva)
assert(moduleCount * 108 + 4 <= moduleStream.size, 'Truncated module list')
const modules = Array.from({ length: moduleCount }, (_, i) => {
const at = moduleStream.rva + 4 + 108 * i
return { name: string(u32(at + 20)), base: hex(u64(at)), size: u32(at + 8), codeView: region(u32(at + 80), u32(at + 76)).toString('hex') }
})
const faultModule = modules.find(module => BigInt(module.base) <= BigInt(exception.address) && BigInt(exception.address) < BigInt(module.base) + BigInt(module.size))
let threadName
if (streams.some(item => item.type === 24)) {
const names = stream(24, 4)
const count = u32(names.rva)
assert(count * 12 + 4 <= names.size, 'Truncated thread name list')
for (let i = 0; i < count; i++) {
const at = names.rva + 4 + i * 12
if (u32(at) === exception.threadId)
threadName = string(Number(u64(at + 4)))
}
}
return { exception, threadName, faultModule, moduleOffset: faultModule && hex(BigInt(exception.address) - BigInt(faultModule.base)), moduleCount, stackResolved: false }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.equal(process.argv.length, 3, 'Usage: node refactor/scripts/firefox-minidump.mjs <dump>')
console.log(JSON.stringify(inspectFirefoxMinidump(fs.readFileSync(process.argv[2])), null, 2))
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Exercise binary diagnostic metadata without requiring a real browser crash.
import { test } from 'node:test'
import { inspectFirefoxMinidump } from './firefox-minidump.mjs'
function fixture() {
const bytes = Buffer.alloc(512)
bytes.write('MDMP')
bytes.writeUInt32LE(3, 8)
bytes.writeUInt32LE(32, 12)
for (const [i, type, size, rva] of [[0, 6, 168, 80], [1, 4, 112, 248], [2, 24, 16, 360]]) {
bytes.writeUInt32LE(type, 32 + i * 12)
bytes.writeUInt32LE(size, 36 + i * 12)
bytes.writeUInt32LE(rva, 40 + i * 12)
}
bytes.writeUInt32LE(123, 80)
bytes.writeUInt32LE(0xC0000005, 88)
bytes.writeBigUInt64LE(0x10000000008n, 104)
bytes.writeUInt32LE(2, 112)
bytes.writeBigUInt64LE(8n, 128)
bytes.writeUInt32LE(1, 248)
bytes.writeBigUInt64LE(0x10000000000n, 252)
bytes.writeUInt32LE(1024, 260)
bytes.writeUInt32LE(380, 272)
bytes.writeUInt32LE(14, 380)
bytes.write('xul.dll', 384, 'utf16le')
bytes.writeUInt32LE(1, 360)
bytes.writeUInt32LE(123, 364)
bytes.writeBigUInt64LE(410n, 368)
bytes.writeUInt32LE(20, 410)
bytes.write('DOM Worker', 414, 'utf16le')
return bytes
}
test('exception metadata identifies the module and thread without inventing a stack', () => {
const result = inspectFirefoxMinidump(fixture())
assert.equal(result.exception.code, '0xc0000005')
assert.deepEqual(result.exception.parameters, ['0x0', '0x8'])
assert.equal(result.faultModule.name, 'xul.dll')
assert.equal(result.moduleOffset, '0x8')
assert.equal(result.threadName, 'DOM Worker')
assert.equal(result.stackResolved, false)
})
test('invalid binary regions, counts and encoding are rejected', () => {
assert.throws(() => inspectFirefoxMinidump(Buffer.alloc(4)), /signature/)
assert.throws(() => inspectFirefoxMinidump(fixture().subarray(0, 100)), /Truncated/)
for (const [offset, value, message] of [[8, 5000, /stream count/], [112, 16, /parameter count/], [248, 1000, /module list/], [360, 1000, /thread name list/], [380, 15, /UTF-16/]]) {
const bytes = fixture()
bytes.writeUInt32LE(value, offset)
assert.throws(() => inspectFirefoxMinidump(bytes), message)
}
})
test('addresses outside all module ranges remain unattributed', () => {
const bytes = fixture()
bytes.writeBigUInt64LE(8n, 104)
assert.equal(inspectFirefoxMinidump(bytes).faultModule, undefined)
})
@@ -0,0 +1,25 @@
import path from 'node:path'
// Require a live, unchanged runner and a complete creation-ordered ancestry chain.
export function selectFirefoxProcesses(rows, root, executable) {
const samePath = value => typeof value === 'string' && path.win32.normalize(value).toLowerCase() === path.win32.normalize(executable).toLowerCase()
const byId = new Map(rows.map(row => [row.pid, row]))
const currentRoot = byId.get(root.pid)
if (!currentRoot || !Number.isFinite(Date.parse(root.created)) || currentRoot.created !== root.created || currentRoot.executable !== root.executable)
return []
return rows.filter((row) => {
if (row.pid === root.pid || !samePath(row.executable))
return false
const seen = new Set([row.pid])
let child = row
while (child.parent !== root.pid) {
const parent = byId.get(child.parent)
if (!parent || seen.has(parent.pid) || !samePath(parent.executable) || !Number.isFinite(Date.parse(parent.created)) || Date.parse(parent.created) > Date.parse(child.created))
return false
seen.add(parent.pid)
child = parent
}
return Number.isFinite(Date.parse(row.created)) && Number.isFinite(Date.parse(child.created))
&& Date.parse(child.created) >= Date.parse(root.created)
})
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Native debugger target selection must be checked independently of Windows attachment.
import { test } from 'node:test'
import { selectFirefoxProcesses } from './firefox-process-scope.mjs'
const executable = 'C:\\test\\firefox.exe'
const root = { pid: 1, parent: 0, created: '2026-09-15T00:00:00.000Z', executable: 'C:\\node.exe' }
const row = (pid, parent, extra = {}) => ({ pid, parent, created: '2026-09-15T00:00:01.000Z', executable, ...extra })
test('only the diagnostic runner own Firefox tree is selected', () => {
const rows = [root, row(2, 1), row(3, 2), row(4, 99), row(5, 4), row(6, 1, { executable: 'C:\\user\\firefox.exe' }), row(7, 6), row(8, 2, { executable: 'c:/TEST/firefox.exe' })]
assert.deepEqual(selectFirefoxProcesses(rows, root, executable).map(item => item.pid), [2, 3, 8])
})
test('missing and reused runner PIDs reject every target', () => {
assert.deepEqual(selectFirefoxProcesses([row(2, 1)], root, executable), [])
assert.deepEqual(selectFirefoxProcesses([{ ...root, created: '2026-09-15T00:00:02.000Z' }, row(2, 1)], root, executable), [])
assert.deepEqual(selectFirefoxProcesses([{ ...root, executable: 'C:\\other.exe' }, row(2, 1)], root, executable), [])
})
test('reused ancestor PIDs, cycles, missing ancestry and invalid creation times fail closed', () => {
const rows = [root, row(2, 1, { created: '2026-09-15T00:00:03.000Z' }), row(3, 2), row(4, 5), row(5, 4), row(6, 88), row(7, 1, { created: 'invalid' }), row(8, 1, { created: '2026-09-14T00:00:00.000Z' }), row(9, 7)]
assert.deepEqual(selectFirefoxProcesses(rows, root, executable).map(item => item.pid), [2])
})
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { setTimeout } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { firefox } from '@playwright/test'
import { selectFirefoxProcesses } from './firefox-process-scope.mjs'
const execute = promisify(execFile)
const repo = fileURLToPath(new URL('../../', import.meta.url))
const sha256 = filename => createHash('sha256').update(fs.readFileSync(filename)).digest('hex')
const powershell = script => execute('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 15000, maxBuffer: 4 * 1024 * 1024 })
async function snapshot() {
const { stdout } = await powershell('Get-CimInstance Win32_Process -Filter "Name = \'firefox.exe\' OR Name = \'node.exe\'" | ForEach-Object { [pscustomobject]@{ pid=[int]$_.ProcessId; parent=[int]$_.ParentProcessId; created=$_.CreationDate.ToUniversalTime().ToString("o"); executable=$_.ExecutablePath } } | ConvertTo-Json -Compress')
const rows = JSON.parse(stdout)
return Array.isArray(rows) ? rows : [rows]
}
async function main() {
assert.equal(process.platform, 'win32', 'ProcDump diagnostic requires Windows')
assert.equal(process.versions.node, fs.readFileSync(path.join(repo, '.node-version'), 'utf8').trim().replace(/^v/, ''))
const [toolOption, toolPath, separator, ...args] = process.argv.slice(2)
assert(toolOption === '--procdump' && toolPath && separator === '--', 'Usage: node refactor/scripts/hls-native-diagnostic.mjs --procdump <procdump64.exe> -- <hls-sdk-diagnostic options>')
const tool = path.resolve(toolPath)
assert.equal(sha256(tool), 'd1fc99ae304bd1d2bf28abeb62531da959e2431916194981b88c958fd713a8e6', 'Use the verified ProcDump 12.01 executable')
// Paths travel through an environment variable, never through PowerShell source text.
const signature = await execute('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', '$s = Get-AuthenticodeSignature -LiteralPath $env:ARTPLAYER_PROCDUMP; [pscustomobject]@{status=$s.Status.ToString(); signer=$s.SignerCertificate.Subject; version=(Get-Item -LiteralPath $env:ARTPLAYER_PROCDUMP).VersionInfo.FileVersion} | ConvertTo-Json -Compress'], { windowsHide: true, timeout: 15000, env: { ...process.env, ARTPLAYER_PROCDUMP: tool } })
const identity = JSON.parse(signature.stdout)
assert.equal(identity.status, 'Valid')
assert(identity.signer.startsWith('CN=Microsoft Corporation,'))
assert.equal(identity.version, '12.01')
const directory = fs.mkdtempSync(path.join(repo, 'refactor/.cache/hls-native-'))
const runnerPath = path.join(repo, 'refactor/scripts/hls-sdk-diagnostic.mjs')
const executable = firefox.executablePath()
const report = { started: new Date().toISOString(), directory, args, tool: { ...identity, sha256: sha256(tool) }, firefox: { executable, sha256: sha256(executable) }, runnerSHA256: sha256(runnerPath), wrapperSHA256: sha256(fileURLToPath(import.meta.url)), scopeSHA256: sha256(fileURLToPath(new URL('./firefox-process-scope.mjs', import.meta.url))), attachments: [], errors: [] }
const handles = []
const runnerLog = fs.openSync(path.join(directory, 'runner.log'), 'wx')
const runner = spawn(process.execPath, [runnerPath, ...args], { cwd: repo, windowsHide: true, stdio: ['ignore', runnerLog, runnerLog] })
let finished = false
const completion = new Promise((resolve) => {
runner.on('error', (error) => {
report.errors.push(String(error))
finished = true
resolve()
})
runner.on('close', (code, signal) => {
report.runnerExit = { code, signal }
finished = true
resolve()
})
})
console.log(`Native diagnostic: ${directory}; runner PID ${runner.pid}`)
try {
const root = (await snapshot()).find(row => row.pid === runner.pid)
assert(root, 'Diagnostic runner exited before its process identity could be verified')
report.root = root
const seen = new Set()
// Completion is updated by the child-process event handlers.
// eslint-disable-next-line no-unmodified-loop-condition -- The runner close event ends observation.
while (!finished) {
const rows = await snapshot()
for (const target of selectFirefoxProcesses(rows, root, executable)) {
const key = `${target.pid}:${target.created}`
if (seen.has(key))
continue
seen.add(key)
assert(seen.size <= 200, 'Native diagnostic process limit reached')
const entry = { ...target, started: new Date().toISOString(), log: `procdump-${target.pid}-${handles.length}.log`, dump: `firefox-${target.pid}-${handles.length}.dmp` }
report.attachments.push(entry)
const log = fs.openSync(path.join(directory, entry.log), 'wx')
const child = spawn(tool, ['-accepteula', '-mm', '-e', '-n', '1', '-at', '10', String(target.pid), path.join(directory, entry.dump)], { windowsHide: true, stdio: ['ignore', log, log] })
const handle = { target, child, entry, log, finished: false }
handle.completion = new Promise((resolve) => {
child.on('error', (error) => {
entry.error = String(error)
handle.finished = true
resolve()
})
child.on('close', (code, signal) => {
entry.exit = { code, signal }
handle.finished = true
resolve()
})
})
handles.push(handle)
}
await setTimeout(250)
}
}
catch (error) {
report.errors.push(String(error))
}
finally {
// The bounded existing runner owns browser shutdown. Never kill the debuggee.
await completion
for (const handle of handles) {
if (!handle.finished) {
try {
const live = (await snapshot()).find(row => row.pid === handle.target.pid)
if (live?.created === handle.target.created && live.executable === handle.target.executable) {
const result = await execute(tool, ['-cancel', String(handle.target.pid)], { windowsHide: true, timeout: 15000 })
handle.entry.cancel = result.stdout
}
}
catch (error) {
handle.entry.cancelError = String(error)
}
await Promise.race([handle.completion, setTimeout(15000)])
}
handle.entry.monitorStopped = handle.finished
fs.closeSync(handle.log)
}
fs.closeSync(runnerLog)
report.finished = new Date().toISOString()
report.monitoringHadFailures = !handles.length || handles.some(handle => handle.entry.error || handle.entry.exit?.code !== 0 || !handle.finished)
report.dumps = fs.readdirSync(directory).filter(name => name.endsWith('.dmp')).map(name => ({ name, bytes: fs.statSync(path.join(directory, name)).size, sha256: sha256(path.join(directory, name)) }))
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ runnerExit: report.runnerExit, attachments: handles.length, dumps: report.dumps, errors: report.errors, monitorsStopped: handles.every(handle => handle.finished), monitoringHadFailures: report.monitoringHadFailures }))
}
process.exitCode = report.runnerExit?.code === 0 && !report.errors.length && !report.monitoringHadFailures ? 0 : 1
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})