Source code

Revision control

Copy as Markdown

Other Tools

Test Info:

"""Tests for the hang-signature heuristics."""
import json
import os
import re
import sys
import mozunit
_HERE = os.path.dirname(os.path.abspath(__file__))
_AGGREGATION_DIR = os.path.dirname(_HERE)
if _AGGREGATION_DIR not in sys.path:
sys.path.insert(0, _AGGREGATION_DIR)
from heuristics import apply_hang_signature_heuristics # noqa: E402
def test_empty_stack_returns_empty():
assert apply_hang_signature_heuristics([]) == []
def test_pure_mozilla_stack_unchanged():
stack = [("main", "xul"), ("DoStuff", "xul"), ("Inner", "xul")]
assert apply_hang_signature_heuristics(stack) == stack
def test_pure_non_mozilla_stack_unchanged():
stack = [("a", "kernel32"), ("b", "kernel32"), ("c", "kernel32")]
assert apply_hang_signature_heuristics(stack) == stack
def test_non_mozilla_collapsed_to_entry_point():
stack = [
("main", "xul"),
("Bar", "xul"),
("CallSys", "xul"),
("sys1", "kernel32"),
("sys2", "kernel32"),
("sys3", "kernel32"),
]
expected = [
("main", "xul"),
("Bar", "xul"),
("CallSys", "xul"),
("sys1", "kernel32"),
]
assert apply_hang_signature_heuristics(stack) == expected
def test_interposed_nt_alone_does_not_trigger_mozilla_boundary():
stack = [
("foo::InterposedNt::CallSys", "xul"),
("sys1", "kernel32"),
("sys2", "kernel32"),
]
assert apply_hang_signature_heuristics(stack) == stack
def test_interposed_nt_does_not_act_as_boundary_but_outer_moz_frame_does():
stack = [
("main", "xul"),
("foo::InterposedNt::CallSys", "xul"),
("sys1", "kernel32"),
("sys2", "kernel32"),
]
expected = [("main", "xul"), ("foo::InterposedNt::CallSys", "xul")]
assert apply_hang_signature_heuristics(stack) == expected
def test_nested_event_loops_trimmed_to_innermost():
stack = [
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("HandlerA", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("InnerHandler", "xul"),
("leaf", "xul"),
]
expected = [("InnerHandler", "xul"), ("leaf", "xul")]
assert apply_hang_signature_heuristics(stack) == expected
def test_spidermonkey_internals_between_js_frames_dropped():
stack = [
("doJsCall.js:10", ""),
("js::InternalCallOrConstruct", ""),
("static bool Interpret(JSContext*)", ""),
("inner.js:42", ""),
]
expected = [("doJsCall.js:10", ""), ("inner.js:42", "")]
assert apply_hang_signature_heuristics(stack) == expected
def test_xpconnect_internals_dropped():
stack = [
("script.js:99", ""),
("static bool XPC_WN_CallMethod(JSContext*)", ""),
("XPCWrappedNative::CallMethod(XPCCallContext&)", "xul"),
("XPTC__InvokebyIndex", "xul"),
("native_leaf", "xul"),
]
expected = [("script.js:99", ""), ("native_leaf", "xul")]
assert apply_hang_signature_heuristics(stack) == expected
def test_pdb_suffix_matched_as_mozilla():
stack = [
("main", "xul.pdb"),
("Bar", "xul.pdb"),
("sys1", "kernel32.pdb"),
("sys2", "kernel32.pdb"),
]
expected = [
("main", "xul.pdb"),
("Bar", "xul.pdb"),
("sys1", "kernel32.pdb"),
]
assert apply_hang_signature_heuristics(stack) == expected
def test_event_loop_at_leaf_returns_empty():
stack = [
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
]
assert apply_hang_signature_heuristics(stack) == []
# Reference: a faithful Python port of getHangFrames from hang-stats/bhr.js,
# used as an oracle for the parity tests below.
_REFERENCE_MOZ_LIBS = {"xul", "XUL", "libxul.so", "mozglue", "libmozglue.so"}
_REFERENCE_JS_INTERNAL_PREFIXES = (
"js::",
"JS::",
"static bool InternalCall",
"static bool Interpret",
"static bool js::",
"bool js::",
"static bool SetExistingProperty",
"(unresolved)",
)
_REFERENCE_JS_FUNC_RE = re.compile(r"\.js|\.xul|^self-hosted:")
def _reference_is_moz_lib(lib_name):
# Mirrors isMozLib in bhr.js; operates on the lib's .name, which has had
# any trailing .pdb stripped.
if lib_name is None:
return False
if lib_name.endswith(".pdb"):
lib_name = lib_name[:-4]
return lib_name in _REFERENCE_MOZ_LIBS
def _reference_is_js_func_name(func_name):
return _REFERENCE_JS_FUNC_RE.search(func_name) is not None
def reference_get_hang_frames(stack):
# Walks leaf-first to match getHangFrames. Input is outer-first, so
# reverse on entry and reverse again on exit. gShowTasks is treated as
# False since the upstream job never sets it.
frames = []
should_remove_prefix = True
for func_name, lib_name in reversed(stack):
if func_name.startswith("nsThread::ProcessNextEvent(bool"):
break
if (
should_remove_prefix
and _reference_is_moz_lib(lib_name)
and "::InterposedNt" not in func_name
):
should_remove_prefix = False
if len(frames) > 1:
for i in range(len(frames) - 1):
frames[i]["hidden"] = "Foreign code"
if not lib_name and _reference_is_js_func_name(func_name) and frames:
i = len(frames) - 1
while i and frames[i]["funcName"].startswith(
_REFERENCE_JS_INTERNAL_PREFIXES
):
i -= 1
anchor = frames[i]
anchor_is_js = (
not anchor["libName"] and _reference_is_js_func_name(anchor["funcName"])
) or anchor["funcName"].startswith("static bool XPC_WN_")
if anchor_is_js:
for ii in range(i + 1, len(frames)):
frames[ii]["hidden"] = "JS Engine Internal"
if (
len(frames) > 3
and frames[-3]["funcName"]
in ("XPTC__InvokebyIndex", "NS_InvokeByIndex")
and frames[-2]["funcName"].startswith("XPCWrappedNative::CallMethod(")
and frames[-1]["funcName"].startswith("static bool XPC_WN_")
):
for ii in range(len(frames) - 3, len(frames)):
frames[ii]["hidden"] = "JS Engine Internal"
frames.append({"funcName": func_name, "libName": lib_name, "hidden": ""})
visible = [(f["funcName"], f["libName"]) for f in frames if not f["hidden"]]
visible.reverse()
return visible
PARITY_STACKS = [
[],
[("main", "xul")],
[("main", "xul"), ("DoStuff", "xul"), ("leaf", "xul")],
[("a", "kernel32"), ("b", "kernel32"), ("c", "kernel32")],
[
("main", "xul"),
("Bar", "xul"),
("CallSys", "xul"),
("sys1", "kernel32"),
("sys2", "kernel32"),
("sys3", "kernel32"),
],
[
("main", "xul"),
("foo::InterposedNt::CallSys", "xul"),
("sys1", "kernel32"),
("sys2", "kernel32"),
],
[
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("Handler", "xul"),
("leaf", "xul"),
],
[
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("HandlerA", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("InnerHandler", "xul"),
("leaf", "xul"),
],
[
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
],
[
("doJsCall.js:10", ""),
("js::InternalCallOrConstruct", ""),
("static bool Interpret(JSContext*)", ""),
("inner.js:42", ""),
],
[
("XPTC__InvokebyIndex", "xul"),
("XPCWrappedNative::CallMethod(XPCCallContext&)", "xul"),
("static bool XPC_WN_CallMethod(JSContext*)", ""),
("script.js:99", ""),
],
[
("main", "xul.pdb"),
("Bar", "xul.pdb"),
("sys1", "kernel32.pdb"),
("sys2", "kernel32.pdb"),
],
[
("script.js:1", ""),
("Bar", "xul"),
("leaf", "xul"),
],
[
("main", "xul"),
("nsThread::ProcessNextEvent(bool, bool*)", "xul"),
("Handler", "xul"),
("XPTC__InvokebyIndex", "xul"),
("XPCWrappedNative::CallMethod(XPCCallContext&)", "xul"),
("static bool XPC_WN_CallMethod(JSContext*)", ""),
("outer.js:1", ""),
("js::Interpret", ""),
("inner.js:5", ""),
("sysA", "kernel32"),
("sysB", "kernel32"),
],
[
("main", "mozglue"),
("Helper", "mozglue"),
("syscall", "kernel32"),
],
[
("main", "libxul.so"),
("Bar", "libxul.so"),
("sys", "libc.so"),
],
]
def test_parity_with_reference_frontend_logic_on_synthetic_stacks():
for stack in PARITY_STACKS:
assert apply_hang_signature_heuristics(stack) == reference_get_hang_frames(
stack
), f"parity mismatch on stack: {stack}"
def _reconstruct_real_stack(thread, sample_idx):
stack_table = thread["stackTable"]
func_table = thread["funcTable"]
libs = thread["libs"]
string_array = thread["stringArray"]
result = []
stack_idx = thread["sampleTable"]["stack"][sample_idx]
while stack_idx:
func_idx = stack_table["func"][stack_idx]
prefix = stack_table["prefix"][stack_idx]
func_name = string_array[func_table["name"][func_idx]]
lib_idx = func_table["lib"][func_idx]
if lib_idx is None:
lib_name = ""
else:
lib_name = libs[lib_idx]["name"]
result.append((func_name, lib_name))
stack_idx = prefix
result.reverse()
return result
def test_parity_with_reference_on_real_hang_aggregates():
# Load any locally-available pre-migration backend output, reconstruct
# each sampled stack, and confirm both implementations produce the same
# visible frames. Skipped when the fixture isn't present (CI, fresh
# checkouts).
sample_paths = [
os.path.expanduser("~/hang-stats/hangs_main_current.json"),
os.path.expanduser("~/hang-stats/hangs_main_20260502.json"),
]
sample_paths = [p for p in sample_paths if os.path.exists(p)]
if not sample_paths:
import pytest
pytest.skip("no recorded hang_aggregates samples available locally")
checked = 0
for path in sample_paths:
with open(path) as f:
data = json.load(f)
for thread in data["threads"]:
n_samples = len(thread["sampleTable"]["stack"])
cap = min(n_samples, 2000)
for i in range(cap):
stack = _reconstruct_real_stack(thread, i)
ours = apply_hang_signature_heuristics(stack)
theirs = reference_get_hang_frames(stack)
assert ours == theirs, (
f"parity mismatch in {os.path.basename(path)} "
f"thread={thread['name']} sample={i}\n"
f" input: {stack}\n ours: {ours}\n ref: {theirs}"
)
checked += 1
assert checked > 0, "no real samples were actually checked"
if __name__ == "__main__":
mozunit.main()