📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-07-01 16:02:41 +00:00
parent 8301f01888
commit c824ba9d7b
2449 changed files with 555104 additions and 9259 deletions
@@ -0,0 +1 @@
"""Parsers for Xcode Instruments .trace files via xctrace export."""
@@ -0,0 +1,187 @@
"""SwiftUI cause-graph lane (`swiftui-causes` schema).
Instruments emits one row per edge in SwiftUI's dependency graph: every time
a source node (a state change, user defaults observer, system event, etc.)
propagates to a destination node (a body evaluation, layout, creation), a
row is written with both endpoints as metadata values.
This lane aggregates those edges two ways:
- **By source node** — which attribute graph nodes are driving the most
updates overall. The canonical "why is my app thrashing?" view; a
`UserDefaultObserver.send()` showing up with 11k outgoing edges is a
feedback storm.
- **By destination node** — which views/modifiers receive the most
invalidations, and from whom. Use this to trace a hot view back to the
source that keeps poking it.
The analyzer's main lane (`swiftui`) tells you *what* updates are
expensive; this lane tells you *why* they keep happening.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
SCHEMA = "swiftui-causes"
# Metadata nodes render as space-separated field dumps ("A gray icon n/a n/a").
# We aggregate on the full fmt string so callers can spot specific edges like
# "@AppStorage TextStyleModifier.fontOption", but also expose the short head
# ("@AppStorage", "Creation of App", ...) for coarser grouping.
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
top_k_per_node: int = 5,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
if SCHEMA not in toc_schemas:
return {
"lane": "swiftui-causes",
"available": False,
"notes": [
"SwiftUI causes data not present (requires SwiftUI template on a real device).",
],
}
xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
source_edges: Counter[str] = Counter()
destination_edges: Counter[str] = Counter()
fanout: dict[str, Counter[str]] = defaultdict(Counter)
fanin: dict[str, Counter[str]] = defaultdict(Counter)
label_counts: Counter[str] = Counter()
total_edges = 0
for row in stream:
time_el = xml_utils.first_present(row, "timestamp", "time")
if time_el is not None:
t_ns = xml_utils.int_text(stream.resolve(time_el))
if t_ns is not None and not xml_utils.in_window(t_ns, window):
continue
src = _fmt(row, stream, "source-node")
dst = _fmt(row, stream, "destination-node")
if not src or not dst:
continue
source_edges[src] += 1
destination_edges[dst] += 1
fanout[src][dst] += 1
fanin[dst][src] += 1
label = _fmt(row, stream, "label")
if label:
label_counts[label] += 1
total_edges += 1
top_sources = [
{
"source": src,
"edges": count,
"top_destinations": [
{"destination": d, "edges": c}
for d, c in fanout[src].most_common(top_k_per_node)
],
}
for src, count in source_edges.most_common(top_n)
]
top_destinations = [
{
"destination": dst,
"edges": count,
"top_sources": [
{"source": s, "edges": c}
for s, c in fanin[dst].most_common(top_k_per_node)
],
}
for dst, count in destination_edges.most_common(top_n)
]
return {
"lane": "swiftui-causes",
"available": True,
"schema_used": SCHEMA,
"metrics": {
"total_edges": total_edges,
"unique_sources": len(source_edges),
"unique_destinations": len(destination_edges),
"top_labels": dict(label_counts.most_common(top_n)),
},
"top_sources": top_sources,
"top_destinations": top_destinations,
"notes": [],
}
def fanin_for(
trace_path: Path,
toc_schemas: frozenset[str],
destination_contains: str,
top_k: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
"""Return the top source nodes feeding any destination whose fmt string
contains `destination_contains` (case-insensitive substring).
Used when the agent has a suspect view from the `swiftui` lane and wants
to know *who keeps invalidating it*. Does a full pass over the causes
schema each time — cheap enough at typical trace sizes.
"""
if SCHEMA not in toc_schemas:
return {"available": False, "matches": []}
needle = destination_contains.lower()
xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
matches: dict[str, Counter[str]] = defaultdict(Counter)
totals: Counter[str] = Counter()
for row in stream:
time_el = xml_utils.first_present(row, "timestamp", "time")
if time_el is not None:
t_ns = xml_utils.int_text(stream.resolve(time_el))
if t_ns is not None and not xml_utils.in_window(t_ns, window):
continue
dst = _fmt(row, stream, "destination-node")
if not dst or needle not in dst.lower():
continue
src = _fmt(row, stream, "source-node")
if not src:
continue
matches[dst][src] += 1
totals[dst] += 1
out = []
for dst, count in totals.most_common(top_k):
out.append({
"destination": dst,
"total_incoming_edges": count,
"top_sources": [
{"source": s, "edges": c}
for s, c in matches[dst].most_common(top_k)
],
})
return {"available": True, "matches": out}
def _fmt(row, stream, key: str) -> str | None:
el = row.get(key)
if el is None:
return None
resolved = stream.resolve(el)
return resolved.get("fmt") or xml_utils.str_text(resolved)
@@ -0,0 +1,179 @@
"""Cross-lane correlation: for each hang and top-N worst hitches, aggregate
Time Profiler samples and SwiftUI updates whose timestamps fall inside the
event window [start, start+duration]. Uses bisect so lookups stay O(log N)
per event.
"""
from __future__ import annotations
from bisect import bisect_left, bisect_right
from collections import defaultdict
from typing import Any
def build(lanes: dict[str, dict], top_hitches: int = 5, top_symbols: int = 5) -> list[dict]:
"""Produce a list of correlation entries.
`lanes` is a dict keyed by lane name (time-profiler, hangs, hitches,
swiftui) of their analyzer outputs.
"""
tp = lanes.get("time-profiler")
hangs = lanes.get("hangs")
hitches = lanes.get("hitches")
swiftui = lanes.get("swiftui")
tp_index = _build_time_profile_index(tp)
sui_events = (swiftui or {}).get("_events") if swiftui and swiftui.get("available") else None
correlations: list[dict] = []
if hangs and hangs.get("available"):
for h in hangs.get("_events", []):
correlations.append(
_correlate_event(
trigger_lane="hangs",
start_ns=h["start_ns"],
end_ns=h["end_ns"],
extra={"hang_type": h["hang_type"]},
tp_index=tp_index,
sui_events=sui_events,
top_symbols=top_symbols,
)
)
if hitches and hitches.get("available"):
worst_hitches = hitches.get("_events", [])[:top_hitches]
for hi in worst_hitches:
correlations.append(
_correlate_event(
trigger_lane="hitches",
start_ns=hi["start_ns"],
end_ns=hi["end_ns"],
extra={
"frame_duration_ms": hi["frame_duration_ms"],
"hitch_duration_ms": hi["hitch_duration_ms"],
},
tp_index=tp_index,
sui_events=sui_events,
top_symbols=top_symbols,
)
)
return correlations
# --- Internal -------------------------------------------------------------
def _build_time_profile_index(tp: dict | None):
if not tp or not tp.get("available"):
return None
samples = tp.get("_samples") or []
if not samples:
return None
# Samples are already sorted by time in time_profiler.analyze.
times = [s["time_ns"] for s in samples]
return {"times": times, "samples": samples}
def _correlate_event(
trigger_lane: str,
start_ns: int,
end_ns: int,
extra: dict,
tp_index: dict | None,
sui_events: list[dict] | None,
top_symbols: int,
) -> dict[str, Any]:
entry: dict[str, Any] = {
"trigger": {
"lane": trigger_lane,
"start_ms": round(start_ns / 1_000_000, 2),
"end_ms": round(end_ns / 1_000_000, 2),
"duration_ms": round((end_ns - start_ns) / 1_000_000, 2),
**extra,
},
}
if tp_index is not None:
tp = _time_profile_hot_symbols(
tp_index, start_ns, end_ns, top_symbols
)
duration_ns = end_ns - start_ns
# Sample rate is 1ms/sample on standard Time Profiler. If the window
# is N ms long we'd expect ~N main-thread samples if main was fully
# running; fewer means main was blocked (I/O, lock, etc.).
expected_if_running = max(1, duration_ns // 1_000_000)
coverage_pct = min(100.0, 100.0 * tp["samples_main"] / expected_if_running)
entry["time_profiler_main_thread"] = {
"samples_in_window": tp["samples_total"],
"samples_on_main": tp["samples_main"],
"main_running_coverage_pct": round(coverage_pct, 1),
"hot_symbols": tp["hot_symbols"],
}
if sui_events is not None:
sui_overlap = _swiftui_overlaps(sui_events, start_ns, end_ns)
entry["swiftui_overlapping_updates"] = sui_overlap
return entry
def _time_profile_hot_symbols(
tp_index: dict, start_ns: int, end_ns: int, top_n: int
) -> dict:
"""Return main-thread hot symbols in the given window.
Hang/hitch/SwiftUI correlations are all main-thread responsiveness
problems, so worker-thread symbols are noise. We also return a coverage
metric — when main was blocked on I/O or a lock, the window will have
far fewer samples than its duration would predict, and that signal is
what tells the agent "this was blocked, not CPU-bound".
"""
times = tp_index["times"]
samples = tp_index["samples"]
lo = bisect_left(times, start_ns)
hi = bisect_right(times, end_ns)
window = samples[lo:hi]
if not window:
return {"samples_total": 0, "samples_main": 0, "hot_symbols": []}
main_samples = [s for s in window if s["is_main"]]
weight_by_symbol: dict[str, int] = defaultdict(int)
count_by_symbol: dict[str, int] = defaultdict(int)
for s in main_samples:
weight_by_symbol[s["leaf_symbol"]] += s["weight_ns"]
count_by_symbol[s["leaf_symbol"]] += 1
total_weight = sum(weight_by_symbol.values()) or 1
ranked = sorted(weight_by_symbol.items(), key=lambda kv: kv[1], reverse=True)
hot = []
for symbol, weight in ranked[:top_n]:
hot.append({
"symbol": symbol,
"samples": count_by_symbol[symbol],
"weight_ms": round(weight / 1_000_000, 2),
"percent_of_main": round(100.0 * weight / total_weight, 2),
})
return {
"samples_total": len(window),
"samples_main": len(main_samples),
"hot_symbols": hot,
}
def _swiftui_overlaps(
events: list[dict], start_ns: int, end_ns: int
) -> list[dict]:
# Events aren't guaranteed sorted by start_ns here (we sort by duration in
# swiftui.analyze). Linear scan; SwiftUI event counts are typically small.
out: list[dict] = []
for e in events:
if e["end_ns"] < start_ns or e["start_ns"] > end_ns:
continue
out.append({
"view": e["view"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
})
# Worst first.
out.sort(key=lambda x: x["duration_ms"], reverse=True)
return out[:10]
@@ -0,0 +1,291 @@
"""Discovery helpers for os_log messages and os_signpost intervals.
These let an agent locate a focus window (e.g. "after the log saying X",
"during signpost Y") before running the main lane analysis.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
OS_LOG_SCHEMA = "os-log"
OS_SIGNPOST_SCHEMA = "os-signpost"
OS_SIGNPOST_INTERVAL_SCHEMA = "os-signpost-interval"
def list_logs(
trace_path: Path,
toc_schemas: frozenset[str],
subsystem: str | None = None,
category: str | None = None,
message_contains: str | None = None,
message_type: str | None = None,
limit: int | None = None,
window_ns: tuple[int, int] | None = None,
run: int = 1,
) -> list[dict[str, Any]]:
"""Return os_log entries, optionally filtered. Case-insensitive contains.
`limit` counts *post-filter* matches — including the window filter — so
the caller gets N matching logs inside the window rather than the first
N matching logs that might all fall outside it.
"""
if OS_LOG_SCHEMA not in toc_schemas:
return []
xml_bytes = xctrace.export_schema(trace_path, OS_LOG_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
needle = message_contains.lower() if message_contains else None
out: list[dict[str, Any]] = []
for row in stream:
time_el = row.get("time")
if time_el is None:
continue
time_ns = xml_utils.int_text(stream.resolve(time_el))
if time_ns is None:
continue
if not xml_utils.in_window(time_ns, window_ns):
continue
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
typ = _str_of(row, stream, "message-type")
fmt = _str_of(row, stream, "format-string")
msg = _str_of(row, stream, "message") or fmt
if subsystem and (sub or "") != subsystem:
continue
if category and (cat or "") != category:
continue
if message_type and (typ or "") != message_type:
continue
if needle and needle not in (msg or "").lower() and needle not in (fmt or "").lower():
continue
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
out.append({
"time_ns": time_ns,
"time_ms": round(time_ns / 1_000_000, 3),
"type": typ,
"subsystem": sub,
"category": cat,
"process": process,
"message": msg,
"format_string": fmt,
})
if limit is not None and len(out) >= limit:
break
out.sort(key=lambda e: e["time_ns"])
return out
def list_signposts(
trace_path: Path,
toc_schemas: frozenset[str],
name_contains: str | None = None,
subsystem: str | None = None,
category: str | None = None,
window_ns: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, list[dict[str, Any]]]:
"""Return signpost intervals (paired begin/end) plus single-point events.
Shape: { "intervals": [...], "events": [...] }. Intervals have
start_ms/end_ms/duration_ms; events have a single time_ms.
Reads two complementary schemas:
* `os-signpost-interval`: already-paired intervals (this is where
user-emitted signposts like com.example.MyApp typically land).
* `os-signpost`: raw begin/end/event rows; we pair begins with ends
ourselves and fall back to point events for unpaired rows. Most
Apple-framework signposts (CloudKit, AppKit, …) live here.
Filters are AND-combined. `name_contains` is a case-insensitive substring
match. `window_ns` keeps intervals that overlap the window (not strict
containment) and point events whose timestamp falls inside it.
"""
# The two signpost schemas overlap: every paired begin/end in `os-signpost`
# also shows up as a row in `os-signpost-interval`. To avoid duplicates we
# prefer the pre-paired schema for intervals and only mine `os-signpost`
# for point events (and for begin/end pairing as a fallback when the
# interval schema is missing — older traces).
intervals: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
has_intervals = OS_SIGNPOST_INTERVAL_SCHEMA in toc_schemas
if has_intervals:
intervals.extend(_read_interval_schema(trace_path, run=run))
if OS_SIGNPOST_SCHEMA in toc_schemas:
more_intervals, more_events = _read_event_schema(trace_path, run=run)
if not has_intervals:
intervals.extend(more_intervals)
events.extend(more_events)
intervals.sort(key=lambda i: i["start_ns"])
events.sort(key=lambda e: e["time_ns"])
needle = name_contains.lower() if name_contains else None
def _matches(entry: dict) -> bool:
if subsystem and (entry.get("subsystem") or "") != subsystem:
return False
if category and (entry.get("category") or "") != category:
return False
if needle and needle not in (entry.get("name") or "").lower():
return False
return True
if subsystem or category or needle:
intervals = [i for i in intervals if _matches(i)]
events = [e for e in events if _matches(e)]
if window_ns is not None:
s, e = window_ns
intervals = [
i for i in intervals
if not (i["end_ns"] < s or i["start_ns"] > e)
]
events = [ev for ev in events if s <= ev["time_ns"] <= e]
return {"intervals": intervals, "events": events}
def _read_interval_schema(trace_path: Path, run: int = 1) -> list[dict[str, Any]]:
"""Read the os-signpost-interval schema (pre-paired intervals)."""
xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_INTERVAL_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
out: list[dict[str, Any]] = []
for row in stream:
start_el = xml_utils.first_present(row, "start", "time")
dur_el = row.get("duration")
if start_el is None or dur_el is None:
continue
start_ns = xml_utils.int_text(stream.resolve(start_el))
dur_ns = xml_utils.int_text(stream.resolve(dur_el))
if start_ns is None or dur_ns is None:
continue
end_ns = start_ns + dur_ns
name = _str_of(row, stream, "name")
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
signpost_id = _str_of(row, stream, "identifier") or _str_of(row, stream, "signpost-id")
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
out.append({
"start_ns": start_ns,
"end_ns": end_ns,
"duration_ns": dur_ns,
"start_ms": round(start_ns / 1_000_000, 3),
"end_ms": round(end_ns / 1_000_000, 3),
"duration_ms": round(dur_ns / 1_000_000, 3),
"name": name,
"subsystem": sub,
"category": cat,
"process": process,
"signpost_id": signpost_id,
})
return out
def _read_event_schema(
trace_path: Path,
run: int = 1,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Read the os-signpost schema and pair begin/end rows into intervals."""
xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
pending: dict[tuple, dict] = {}
intervals: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
for row in stream:
time_el = xml_utils.first_present(row, "time", "start")
if time_el is None:
continue
time_ns = xml_utils.int_text(stream.resolve(time_el))
if time_ns is None:
continue
name = _str_of(row, stream, "name")
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
event_type = _str_of(row, stream, "event-type") or _str_of(row, stream, "message-type")
signpost_id = _str_of(row, stream, "signpost-id") or _str_of(row, stream, "identifier")
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
key = (process, sub, cat, name, signpost_id)
etype = (event_type or "").lower()
if etype in ("begin", "interval begin", "start"):
pending[key] = {"start_ns": time_ns, "name": name,
"subsystem": sub, "category": cat,
"process": process, "signpost_id": signpost_id}
elif etype in ("end", "interval end", "stop"):
start = pending.pop(key, None)
if start is not None:
dur_ns = time_ns - start["start_ns"]
intervals.append({
**start,
"end_ns": time_ns,
"duration_ns": dur_ns,
"start_ms": round(start["start_ns"] / 1_000_000, 3),
"end_ms": round(time_ns / 1_000_000, 3),
"duration_ms": round(dur_ns / 1_000_000, 3),
})
else:
events.append(_point_event(time_ns, name, sub, cat,
process, signpost_id, event_type))
else:
events.append(_point_event(time_ns, name, sub, cat,
process, signpost_id, event_type))
# Unclosed begins are surfaced as point events so nothing is silently dropped.
for info in pending.values():
events.append(_point_event(info["start_ns"], info["name"],
info["subsystem"], info["category"],
info["process"], info["signpost_id"],
"Begin (unclosed)"))
return intervals, events
def _point_event(time_ns, name, subsystem, category, process, signpost_id, event_type):
return {
"time_ns": time_ns,
"time_ms": round(time_ns / 1_000_000, 3),
"name": name,
"subsystem": subsystem,
"category": category,
"process": process,
"signpost_id": signpost_id,
"event_type": event_type,
}
def _str_of(row, stream, key):
el = row.get(key)
if el is None:
return None
resolved = stream.resolve(el)
txt = xml_utils.str_text(resolved) or resolved.get("fmt")
return txt
@@ -0,0 +1,108 @@
"""Hangs lane parser (schema `potential-hangs`).
The schema lacks inline backtraces — stacks come from Time Profiler samples
that overlap each hang's window. Correlation is done later in correlate.py.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
PREFERRED_SCHEMAS = ("potential-hangs",)
FALLBACK_SCHEMAS = ("main-thread-hang", "hang", "hangs")
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "hangs",
"available": False,
"notes": ["Hangs data not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
hangs: list[dict] = []
for row in stream:
start_el = row.get("start")
dur_el = row.get("duration")
type_el = row.get("hang-type")
thread_el = row.get("thread")
if start_el is None or dur_el is None:
continue
start_ns = xml_utils.int_text(stream.resolve(start_el))
duration_ns = xml_utils.int_text(stream.resolve(dur_el))
if start_ns is None or duration_ns is None:
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
continue
hang_type = xml_utils.str_text(stream.resolve(type_el)) if type_el is not None else None
thread = xml_utils.extract_thread(thread_el, stream) if thread_el is not None else None
hangs.append({
"start_ns": start_ns,
"duration_ns": duration_ns,
"end_ns": start_ns + duration_ns,
"duration_ms": round(duration_ns / 1_000_000, 2),
"start_ms": round(start_ns / 1_000_000, 2),
"hang_type": hang_type or "Hang",
"thread": thread,
})
hangs.sort(key=lambda h: h["duration_ns"], reverse=True)
total_ms = sum(h["duration_ms"] for h in hangs)
worst = hangs[0] if hangs else None
# Severity buckets per Apple docs (Microhang: 250ms500ms, Hang: ≥500ms).
# We bucket by raw duration so the agent can reason about it.
buckets = {"lt_250ms": 0, "250ms_1s": 0, "gt_1s": 0}
for h in hangs:
if h["duration_ms"] < 250:
buckets["lt_250ms"] += 1
elif h["duration_ms"] < 1000:
buckets["250ms_1s"] += 1
else:
buckets["gt_1s"] += 1
top_offenders = [
{
"start_ms": h["start_ms"],
"duration_ms": h["duration_ms"],
"hang_type": h["hang_type"],
"thread": (h["thread"] or {}).get("name", ""),
}
for h in hangs[:top_n]
]
return {
"lane": "hangs",
"available": True,
"schema_used": schema,
"metrics": {
"count": len(hangs),
"total_duration_ms": round(total_ms, 2),
"worst_duration_ms": worst["duration_ms"] if worst else 0,
"severity_buckets": buckets,
},
"top_offenders": top_offenders,
"notes": [],
"_events": hangs, # retained for correlation
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
if s in available:
return s
return None
@@ -0,0 +1,145 @@
"""Animation hitches lane parser.
Xcode 26 schema `hitches` columns: start, duration (hitch time), process,
is-system, swap-id, label, display, narrative-description. The
narrative-description field carries Apple's own attribution (e.g.
"Potentially expensive app update(s)") which is the highest-signal column.
"""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
CANDIDATE_SCHEMAS = ("hitches", "animation-hitch", "hitch")
START_KEYS = ("start", "time", "sample-time")
DURATION_KEYS = ("duration", "hitch-duration", "frame-duration")
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "hitches",
"available": False,
"notes": ["Animation hitches not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
events: list[dict] = []
narrative_counts: Counter[str] = Counter()
system_count = 0
for row in stream:
start_ns = _first_int(row, stream, START_KEYS)
duration_ns = _first_int(row, stream, DURATION_KEYS)
if start_ns is None or duration_ns is None:
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
continue
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream)
if process_el is not None else None
)
narrative_el = row.get("narrative-description")
narrative = xml_utils.str_text(stream.resolve(narrative_el)) if narrative_el is not None else None
if narrative:
narrative_counts[narrative] += 1
is_system_el = row.get("is-system")
is_system = _bool_text(stream.resolve(is_system_el)) if is_system_el is not None else None
if is_system:
system_count += 1
events.append({
"start_ns": start_ns,
"end_ns": start_ns + duration_ns,
"duration_ns": duration_ns,
"hitch_duration_ns": duration_ns, # Xcode 26 `duration` == hitch time
"frame_duration_ns": None,
"hitch_duration_ms": round(duration_ns / 1_000_000, 2),
"frame_duration_ms": None,
"start_ms": round(start_ns / 1_000_000, 2),
"process": (process or {}).get("name"),
"narrative": narrative,
"is_system": bool(is_system) if is_system is not None else None,
})
events.sort(key=lambda e: e["duration_ns"], reverse=True)
total_hitch_ms = sum(e["hitch_duration_ms"] for e in events)
worst = events[0] if events else None
per_process: dict[str, int] = {}
for e in events:
key = e["process"] or "unknown"
per_process[key] = per_process.get(key, 0) + 1
top_offenders = [
{
"start_ms": e["start_ms"],
"hitch_duration_ms": e["hitch_duration_ms"],
"frame_duration_ms": e["frame_duration_ms"],
"process": e["process"],
"narrative": e["narrative"],
"is_system": e["is_system"],
}
for e in events[:top_n]
]
return {
"lane": "hitches",
"available": True,
"schema_used": schema,
"metrics": {
"count": len(events),
"total_hitch_ms": round(total_hitch_ms, 2),
"worst_hitch_ms": worst["hitch_duration_ms"] if worst else 0,
"per_process": per_process,
"system_hitches": system_count,
"app_hitches": len(events) - system_count,
"narrative_breakdown": dict(narrative_counts.most_common()),
},
"top_offenders": top_offenders,
"notes": [],
"_events": events,
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in CANDIDATE_SCHEMAS:
if s in available:
return s
return None
def _first_int(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
val = xml_utils.int_text(stream.resolve(el))
if val is not None:
return val
return None
def _bool_text(elem) -> bool | None:
txt = xml_utils.str_text(elem)
if txt is None:
return None
return txt.strip() in ("1", "true", "True", "YES", "Yes")
@@ -0,0 +1,243 @@
"""Markdown summary renderer for the combined trace analysis."""
from __future__ import annotations
def render(result: dict) -> str:
lines: list[str] = []
trace = result.get("trace", "?")
header = result.get("xctrace_version") or ""
template = result.get("template") or ""
duration_s = result.get("duration_s")
lines.append(f"# Instruments Trace Analysis")
meta = [p for p in [f"Trace: `{trace}`", header, template] if p]
lines.append("".join(meta))
if duration_s is not None:
lines.append(f"Recording duration: {duration_s:.2f}s")
lines.append("")
lanes_by_name = {lane["lane"]: lane for lane in result.get("lanes", [])}
_render_time_profiler(lines, lanes_by_name.get("time-profiler"))
_render_hangs(lines, lanes_by_name.get("hangs"))
_render_hitches(lines, lanes_by_name.get("hitches"))
_render_swiftui(lines, lanes_by_name.get("swiftui"))
_render_causes(lines, lanes_by_name.get("swiftui-causes"))
_render_correlations(lines, result.get("correlations", []))
return "\n".join(lines).rstrip() + "\n"
def _skipped_block(title: str, lane: dict | None) -> list[str]:
if lane is None:
return [f"## {title} — skipped (lane module not run)", ""]
notes = lane.get("notes") or []
note_text = f"{notes[0]}" if notes else ""
return [f"## {title} — skipped{note_text}", ""]
def _render_time_profiler(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Time Profiler", lane))
return
m = lane["metrics"]
lines.append(
f"## Time Profiler — {m['total_samples']:,} samples, "
f"{m['total_weight_ms']:.0f}ms CPU time"
)
if m.get("processes"):
lines.append(f"Processes: {', '.join(m['processes'])}")
lines.append("")
if lane["top_offenders"]:
lines.append("Top offenders:")
for i, o in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. `{_truncate(o['symbol'], 90)}` — "
f"{o['percent']:.1f}% ({o['weight_ms']:.0f}ms, "
f"{o['samples']} samples, {_short_thread(o['thread'])})"
)
for note in lane.get("notes") or []:
lines.append(f"> {note}")
lines.append("")
def _render_hangs(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Hangs", lane))
return
m = lane["metrics"]
buckets = m["severity_buckets"]
lines.append(
f"## Hangs — {m['count']} hangs, {m['total_duration_ms']:.0f}ms total, "
f"worst {m['worst_duration_ms']:.0f}ms"
)
lines.append(
f"Severity: <250ms={buckets['lt_250ms']}, "
f"250ms1s={buckets['250ms_1s']}, >1s={buckets['gt_1s']}"
)
lines.append("")
for i, h in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. {h['duration_ms']:.0f}ms {h['hang_type']} at "
f"{h['start_ms']:.2f}ms on {_short_thread(h['thread'])}"
)
lines.append("")
def _render_hitches(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Animation Hitches", lane))
return
m = lane["metrics"]
lines.append(
f"## Animation Hitches — {m['count']} hitches, "
f"{m['total_hitch_ms']:.0f}ms total, worst {m['worst_hitch_ms']:.0f}ms"
)
if m.get("per_process"):
pp = ", ".join(f"{k}={v}" for k, v in m["per_process"].items())
lines.append(f"By process: {pp}")
lines.append("")
if m.get("narrative_breakdown"):
nb = ", ".join(f"{k}={v}" for k, v in m["narrative_breakdown"].items() if k)
if nb:
lines.append(f"Apple attribution: {nb}")
if m.get("system_hitches") is not None:
lines.append(
f"System vs app: system={m['system_hitches']}, app={m['app_hitches']}"
)
lines.append("")
for i, h in enumerate(lane["top_offenders"], 1):
narrative = f"{h['narrative']}" if h.get("narrative") else ""
src = " [system]" if h.get("is_system") else ""
proc = f" ({h['process']})" if h.get("process") else ""
lines.append(
f"{i}. {h['hitch_duration_ms']:.0f}ms at {h['start_ms']:.2f}ms"
f"{proc}{src}{narrative}"
)
lines.append("")
def _render_swiftui(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("SwiftUI", lane))
return
m = lane["metrics"]
lines.append(
f"## SwiftUI — {m['total_events']:,} updates across "
f"{m['unique_views']} views, {m['total_duration_ms']:.0f}ms total"
)
if m.get("severity_breakdown"):
sb = ", ".join(f"{k}={v}" for k, v in m["severity_breakdown"].items())
lines.append(f"Severity: {sb}")
if m.get("update_type_breakdown"):
ub = ", ".join(f"{k}={v}" for k, v in m["update_type_breakdown"].items())
lines.append(f"Update types: {ub}")
lines.append("")
if lane["top_offenders"]:
lines.append("Heaviest views (by total body time):")
for i, v in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. `{_truncate(v['view'], 80)}` — {v['total_ms']:.0f}ms total, "
f"{v['count']} updates (avg {v['avg_ms']:.2f}ms)"
)
if lane.get("high_severity_events"):
lines.append("")
lines.append("High-severity updates:")
for i, e in enumerate(lane["high_severity_events"][:5], 1):
cat = f" [{e['category']}]" if e.get("category") else ""
lines.append(
f"{i}. `{_truncate(e['view'], 60)}` — "
f"{e['severity']} ({e['duration_ms']:.2f}ms at {e['start_ms']:.2f}ms){cat}"
)
lines.append("")
def _render_causes(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("SwiftUI Cause Graph", lane))
return
m = lane["metrics"]
lines.append(
f"## SwiftUI Cause Graph — {m['total_edges']:,} edges, "
f"{m['unique_sources']} sources → {m['unique_destinations']} destinations"
)
lines.append("")
if lane.get("top_sources"):
lines.append("Top sources (who's driving the most updates):")
for i, s in enumerate(lane["top_sources"][:5], 1):
lines.append(f"{i}. `{_truncate(s['source'], 80)}` — {s['edges']:,} edges")
for d in s["top_destinations"][:3]:
lines.append(
f" → `{_truncate(d['destination'], 70)}` {d['edges']:,}"
)
if lane.get("top_destinations"):
lines.append("")
lines.append("Top destinations (who's being invalidated most):")
for i, d in enumerate(lane["top_destinations"][:5], 1):
lines.append(f"{i}. `{_truncate(d['destination'], 80)}` — {d['edges']:,} edges")
for s in d["top_sources"][:3]:
lines.append(
f" ← `{_truncate(s['source'], 70)}` {s['edges']:,}"
)
lines.append("")
def _render_correlations(lines: list[str], correlations: list[dict]) -> None:
if not correlations:
return
lines.append("## Correlations")
lines.append("")
for c in correlations:
t = c["trigger"]
head = (
f"- **{t['lane']}** at {t['start_ms']:.2f}ms "
f"({t['duration_ms']:.0f}ms)"
)
if t.get("hang_type"):
head += f"{t['hang_type']}"
lines.append(head)
tp = c.get("time_profiler_main_thread")
if tp is not None:
cov = tp["main_running_coverage_pct"]
lines.append(
f" - Main thread: {tp['samples_on_main']} running samples "
f"({cov:.0f}% coverage — "
f"{'blocked' if cov < 25 else 'mostly running'})"
)
for s in tp["hot_symbols"][:3]:
lines.append(
f" · `{_truncate(s['symbol'], 80)}` "
f"{s['percent_of_main']:.0f}% ({s['samples']} samples)"
)
if not tp["hot_symbols"]:
lines.append(" · no main-thread samples in window")
sui = c.get("swiftui_overlapping_updates")
if sui:
for s in sui[:3]:
lines.append(
f" - SwiftUI: `{s['view']}` {s['duration_ms']:.2f}ms "
f"(at {s['start_ms']:.2f}ms)"
)
lines.append("")
def _short_thread(name: str) -> str:
if not name:
return ""
if name.startswith("Main Thread") or name == "main":
return "main"
# "NowPlaying Gigs (0x251990d) (NowPlaying Gigs, pid: 28401)" -> "tid 0x251990d"
tid_start = name.find("(0x")
if tid_start != -1:
start = tid_start + 1
end = name.find(")", start)
if end != -1:
return f"tid {name[start:end]}"
return name[:40]
def _truncate(s: str, n: int) -> str:
if len(s) <= n:
return s
return s[: n - 1] + ""
@@ -0,0 +1,195 @@
"""SwiftUI lane parser (Xcode 26+).
Primary schema is `swiftui-updates` with columns: start, duration, id,
update-type, allocations, description, category, view-hierarchy, module,
view-name, process, thread, root-causes, severity, cause-graph-node,
full-cause-graph-node.
We aggregate by view-name across all SwiftUI schemas (future-proofing against
schema renames) and break severity out separately so the agent can focus on
the high-severity rows.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
START_KEYS = ("start", "time", "sample-time", "timestamp")
DURATION_KEYS = ("duration", "body-duration", "update-duration")
VIEW_KEYS = ("view-name", "view", "view-type", "name", "type")
MODULE_KEYS = ("module",)
CATEGORY_KEYS = ("category",)
UPDATE_TYPE_KEYS = ("update-type",)
SEVERITY_KEYS = ("severity",)
DESCRIPTION_KEYS = ("description",)
HIGH_SEVERITIES = {"High", "Very High", "Severe", "Critical"}
# Ongoing / unterminated updates carry a sentinel duration (≈ UINT64_MAX-ish).
# Any duration longer than an hour is almost certainly that sentinel and would
# break aggregates + the correlation overlap check.
_SENTINEL_DURATION_NS = 60 * 60 * 1_000_000_000 # 1 hour
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schemas = sorted(
s for s in toc_schemas
if s.startswith("swiftui") and not s.endswith("-causes")
)
if not schemas:
return {
"lane": "swiftui",
"available": False,
"notes": ["SwiftUI lane not in trace (Xcode 26+ SwiftUI template required)."],
}
events: list[dict] = []
per_view_total_ns: dict[str, int] = defaultdict(int)
per_view_count: dict[str, int] = defaultdict(int)
severity_counts: Counter[str] = Counter()
update_type_counts: Counter[str] = Counter()
category_counts: Counter[str] = Counter()
for schema in schemas:
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
for row in stream:
start_ns = _first_int(row, stream, START_KEYS)
dur_ns = _first_int(row, stream, DURATION_KEYS)
if start_ns is None or dur_ns is None:
continue
if dur_ns < 0 or dur_ns > _SENTINEL_DURATION_NS:
# Unterminated / ongoing update; skip so it doesn't poison
# totals and the correlation overlap check.
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + dur_ns, window):
continue
view = _first_str(row, stream, VIEW_KEYS)
module = _first_str(row, stream, MODULE_KEYS)
category = _first_str(row, stream, CATEGORY_KEYS)
update_type = _first_str(row, stream, UPDATE_TYPE_KEYS)
severity = _first_str(row, stream, SEVERITY_KEYS)
description = _first_str(row, stream, DESCRIPTION_KEYS)
# Fall back through description → category → update-type so the
# agent sees "EnvironmentWriter: RootEnvironment" instead of
# "<unknown>" when SwiftUI doesn't record a view type.
if not view:
view = description or category or update_type or "<unknown>"
per_view_total_ns[view] += dur_ns
per_view_count[view] += 1
if severity:
severity_counts[severity] += 1
if update_type:
update_type_counts[update_type] += 1
if category:
category_counts[category] += 1
events.append({
"schema": schema,
"start_ns": start_ns,
"end_ns": start_ns + dur_ns,
"duration_ns": dur_ns,
"duration_ms": round(dur_ns / 1_000_000, 2),
"start_ms": round(start_ns / 1_000_000, 2),
"view": view,
"module": module,
"category": category,
"update_type": update_type,
"severity": severity,
"description": description,
})
events.sort(key=lambda e: e["duration_ns"], reverse=True)
top_by_total = sorted(
per_view_total_ns.items(), key=lambda kv: kv[1], reverse=True
)[:top_n]
top_offenders = [
{
"view": view,
"total_ms": round(total_ns / 1_000_000, 2),
"count": per_view_count[view],
"avg_ms": round(total_ns / per_view_count[view] / 1_000_000, 2),
}
for view, total_ns in top_by_total
]
high_severity = [
{
"view": e["view"],
"severity": e["severity"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
"category": e["category"],
"update_type": e["update_type"],
"description": e["description"],
}
for e in events if e["severity"] in HIGH_SEVERITIES
][:top_n]
longest = [
{
"view": e["view"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
"category": e["category"],
"update_type": e["update_type"],
"severity": e["severity"],
}
for e in events[:top_n]
]
return {
"lane": "swiftui",
"available": True,
"schemas_used": schemas,
"metrics": {
"total_events": len(events),
"unique_views": len(per_view_total_ns),
"total_duration_ms": round(
sum(per_view_total_ns.values()) / 1_000_000, 2
),
"severity_breakdown": dict(severity_counts.most_common()),
"update_type_breakdown": dict(update_type_counts.most_common()),
"category_breakdown": dict(category_counts.most_common()),
},
"top_offenders": top_offenders,
"longest_single_events": longest,
"high_severity_events": high_severity,
"notes": [],
"_events": events,
}
def _first_int(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
val = xml_utils.int_text(stream.resolve(el))
if val is not None:
return val
return None
def _first_str(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
resolved = stream.resolve(el)
txt = xml_utils.str_text(resolved) or resolved.get("fmt")
if txt:
return txt
return None
@@ -0,0 +1,135 @@
"""Time Profiler lane parser (schema `time-profile`).
Aggregates CPU samples by leaf symbol, keeps per-sample rows so that other
lanes can correlate by timestamp window.
"""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
PREFERRED_SCHEMAS = ("time-profile",)
FALLBACK_SCHEMAS = ("time-sample",) # no symbolication; used only if nothing else
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "time-profiler",
"available": False,
"notes": ["Time Profiler data not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
samples: list[dict] = []
symbol_weight: dict[str, int] = defaultdict(int)
symbol_samples: dict[str, int] = defaultdict(int)
symbol_thread: dict[str, str] = {}
processes: set[str] = set()
total_weight = 0
min_time: int | None = None
max_time: int | None = None
for row in stream:
time_el = row.get("time")
weight_el = row.get("weight")
thread_el = row.get("thread")
stack_el = row.get("stack")
if stack_el is None or time_el is None or thread_el is None:
continue
sample_time_ns = xml_utils.int_text(stream.resolve(time_el))
if not xml_utils.in_window(sample_time_ns, window):
continue
weight_ns = xml_utils.int_text(stream.resolve(weight_el)) or 0
frames = xml_utils.extract_backtrace(stack_el, stream, max_frames=20)
if not frames:
continue
thread = xml_utils.extract_thread(thread_el, stream)
process_name = (thread.get("process") or {}).get("name")
if process_name:
processes.add(process_name)
leaf = xml_utils.top_symbol(frames)
symbol_weight[leaf] += weight_ns
symbol_samples[leaf] += 1
symbol_thread.setdefault(
leaf, "main" if thread["is_main"] else thread.get("name", "")
)
total_weight += weight_ns
if sample_time_ns is not None:
min_time = sample_time_ns if min_time is None else min(min_time, sample_time_ns)
max_time = sample_time_ns if max_time is None else max(max_time, sample_time_ns)
samples.append({
"time_ns": sample_time_ns,
"weight_ns": weight_ns,
"thread_name": thread["name"],
"is_main": thread["is_main"],
"process": process_name,
"leaf_symbol": leaf,
"frames": frames[:5],
})
samples.sort(key=lambda s: s["time_ns"])
top = sorted(
symbol_weight.items(), key=lambda kv: kv[1], reverse=True
)[:top_n]
top_offenders = [
{
"symbol": sym,
"weight_ns": w,
"weight_ms": round(w / 1_000_000, 2),
"samples": symbol_samples[sym],
"percent": round(100.0 * w / total_weight, 2) if total_weight else 0.0,
"thread": symbol_thread.get(sym, ""),
}
for sym, w in top
]
notes: list[str] = []
if schema in FALLBACK_SCHEMAS:
notes.append(
f"Using fallback schema `{schema}`; backtraces may be unsymbolicated."
)
return {
"lane": "time-profiler",
"available": True,
"schema_used": schema,
"metrics": {
"total_samples": len(samples),
"total_weight_ns": total_weight,
"total_weight_ms": round(total_weight / 1_000_000, 2),
"window_start_ns": min_time,
"window_end_ns": max_time,
"processes": sorted(processes),
},
"top_offenders": top_offenders,
"notes": notes,
# Internal: retained for correlation. Stripped before JSON emission
# if --slim is requested by the orchestrator.
"_samples": samples,
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
if s in available:
return s
return None
@@ -0,0 +1,117 @@
"""Thin wrapper around the `xctrace` CLI."""
from __future__ import annotations
import subprocess
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class RunInfo:
"""Per-run metadata and schemas. Instruments traces can hold multiple runs."""
number: int
template_name: str | None
duration_s: float | None
start_date: str | None
end_date: str | None
schemas: frozenset[str]
@dataclass(frozen=True)
class TraceInfo:
xctrace_version: str
runs: tuple[RunInfo, ...]
def get_run(self, number: int) -> RunInfo:
for r in self.runs:
if r.number == number:
return r
available = ", ".join(str(r.number) for r in self.runs)
raise KeyError(f"run {number} not in trace (available: {available})")
def version() -> str:
out = subprocess.run(
["xctrace", "version"], capture_output=True, text=True, check=True
)
return out.stdout.strip()
def toc(trace_path: Path) -> TraceInfo:
"""Export the trace's table of contents and return per-run metadata.
The TOC is small (a few KB) so we load it fully rather than streaming.
"""
xml_bytes = _run_export(trace_path, ["--toc"])
root = ET.fromstring(xml_bytes)
instruments = _find_text(root, ".//instruments-version") or ""
runs: list[RunInfo] = []
for run_el in root.iterfind("./run"):
number_attr = run_el.get("number")
if not number_attr:
continue
try:
number = int(number_attr)
except ValueError:
continue
if number <= 0:
continue
schemas: set[str] = set()
for table in run_el.iterfind("./data/table"):
schema = table.get("schema")
if schema:
schemas.add(schema)
summary = run_el.find("./info/summary")
if summary is not None:
template = _find_text(summary, "./template-name")
duration = _find_text(summary, "./duration")
start = _find_text(summary, "./start-date")
end = _find_text(summary, "./end-date")
else:
template = duration = start = end = None
runs.append(RunInfo(
number=number,
template_name=template,
duration_s=float(duration) if duration else None,
start_date=start,
end_date=end,
schemas=frozenset(schemas),
))
runs.sort(key=lambda r: r.number)
return TraceInfo(
xctrace_version=instruments,
runs=tuple(runs),
)
def export_schema(trace_path: Path, schema: str, run: int = 1) -> bytes:
"""Export one schema's data as XML bytes from the given run.
Callers are expected to iterparse the result rather than build a full tree
for large schemas (time-profile can be tens of MB).
"""
xpath = f'/trace-toc/run[@number="{run}"]/data/table[@schema="{schema}"]'
return _run_export(trace_path, ["--xpath", xpath])
def _run_export(trace_path: Path, extra_args: list[str]) -> bytes:
cmd = ["xctrace", "export", "--input", str(trace_path), *extra_args]
proc = subprocess.run(cmd, capture_output=True, check=False)
if proc.returncode != 0:
raise RuntimeError(
f"xctrace export failed ({proc.returncode}): "
f"{proc.stderr.decode(errors='replace').strip()}"
)
return proc.stdout
def _find_text(root: ET.Element, path: str) -> str | None:
el = root.find(path)
return el.text if el is not None and el.text else None
@@ -0,0 +1,224 @@
"""Streaming XML helpers for xctrace export output.
Instruments XML deduplicates repeated values with `id`/`ref` attributes that
can span the whole document, so we stream rows with iterparse while keeping
a global id cache for later ref lookups.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
@dataclass(frozen=True)
class Column:
mnemonic: str # e.g. "time", "weight", "stack"
engineering_type: str # e.g. "sample-time", "weight", "tagged-backtrace"
class RowStream:
"""Iterate <row> elements of a single <table> schema export.
Yields `dict[str, Element]` keyed by column mnemonic. Elements inside a
yielded row are live ET elements (rooted in the id cache where applicable
so ref resolution via `resolve()` remains valid after the row is yielded).
"""
def __init__(self, xml_bytes: bytes):
self._xml = xml_bytes
self.columns: list[Column] = []
self._id_cache: dict[str, ET.Element] = {}
def resolve(self, element: ET.Element) -> ET.Element:
"""If the element is a ref, return the referenced element; else self."""
ref = element.get("ref")
if ref is None:
return element
target = self._id_cache.get(ref)
if target is None:
return element # unresolved; return the ref element itself
return target
def __iter__(self) -> Iterator[dict[str, ET.Element]]:
# iterparse fires `end` events once an element is fully parsed, so ids
# are visible to descendants via the cache. We only need `end` events;
# row bodies are reconstructed from the end element itself in _row_dict.
#
# NOTE: we intentionally don't call `elem.clear()` after yielding a row.
# Instruments' XML is a single shared doc where any row can `ref` an
# `id` defined earlier (threads, processes, stacks, metadata), and
# clearing would break those later lookups. The tradeoff is peak RAM
# ≈ document size. That's fine for typical traces up to a few hundred
# MB; very large exports may need a smarter pass that first indexes
# referenced ids and only retains those.
schema_seen = False
context = ET.iterparse(_bytes_to_file(self._xml), events=("end",))
for _event, elem in context:
eid = elem.get("id")
if eid is not None:
self._id_cache[eid] = elem
if elem.tag == "schema" and not schema_seen:
self.columns = _parse_columns(elem)
schema_seen = True
continue
if elem.tag == "row":
yield _row_dict(elem, self.columns)
# Do not clear elem — children referenced via id may still be needed.
def _parse_columns(schema_el: ET.Element) -> list[Column]:
cols: list[Column] = []
for col in schema_el.findall("col"):
mnemonic = (col.findtext("mnemonic") or "").strip()
etype = (col.findtext("engineering-type") or "").strip()
if mnemonic:
cols.append(Column(mnemonic=mnemonic, engineering_type=etype))
return cols
def _row_dict(row_el: ET.Element, cols: list[Column]) -> dict[str, ET.Element]:
# Row children map positionally to columns. <sentinel/> marks a missing
# optional value for that column.
result: dict[str, ET.Element] = {}
children = list(row_el)
for idx, child in enumerate(children):
if idx >= len(cols):
break
if child.tag == "sentinel":
continue
result[cols[idx].mnemonic] = child
return result
def _bytes_to_file(data: bytes):
import io
return io.BytesIO(data)
# --- Extraction helpers ---------------------------------------------------
def int_text(elem: ET.Element | None) -> int | None:
if elem is None or elem.text is None:
return None
try:
return int(elem.text)
except ValueError:
return None
def str_text(elem: ET.Element | None) -> str | None:
if elem is None or elem.text is None:
return None
return elem.text
def fmt_attr(elem: ET.Element | None) -> str | None:
"""Return the human-readable `fmt` attribute if present."""
if elem is None:
return None
return elem.get("fmt")
def extract_thread(thread_el: ET.Element, stream: RowStream) -> dict:
"""Parse a <thread> element into name, tid, process dict.
Handles ref-style threads by resolving through the stream's id cache.
"""
resolved = stream.resolve(thread_el)
name = resolved.get("fmt", "")
tid_el = resolved.find("tid")
process_el = resolved.find("process")
process = extract_process(process_el, stream) if process_el is not None else None
return {
"name": name,
"tid": int_text(tid_el),
"process": process,
"is_main": name.startswith("Main Thread") if name else False,
}
def extract_process(process_el: ET.Element, stream: RowStream) -> dict:
resolved = stream.resolve(process_el)
name = resolved.get("fmt", "")
pid_el = resolved.find("pid")
return {
"name": _clean_process_name(name),
"pid": int_text(pid_el),
}
def _clean_process_name(fmt: str) -> str:
# "NowPlaying Gigs (28401)" -> "NowPlaying Gigs"
if " (" in fmt and fmt.endswith(")"):
return fmt.rsplit(" (", 1)[0]
return fmt
def extract_backtrace(
bt_el: ET.Element, stream: RowStream, max_frames: int = 20
) -> list[dict]:
"""Return a list of frame dicts from a <tagged-backtrace> or <backtrace>.
Frames are ordered leaf-first (top of stack first), matching Instruments'
display order.
"""
resolved = stream.resolve(bt_el)
inner = resolved.find("backtrace")
if inner is None:
inner = resolved
frames: list[dict] = []
for frame_el in inner.findall("frame"):
f = stream.resolve(frame_el)
frames.append({
"name": f.get("name") or "",
"addr": f.get("addr") or "",
})
if len(frames) >= max_frames:
break
return frames
def top_symbol(frames: list[dict]) -> str:
"""Pick the leaf symbol, falling back to addr if unsymbolicated."""
if not frames:
return "<empty-stack>"
first = frames[0]
return first.get("name") or first.get("addr") or "<unknown>"
def first_present(row: dict, *keys: str) -> ET.Element | None:
"""Return the first row column whose key exists.
`row[key] or row[other_key]` is unsafe here: Element is falsy when it has
no children (a common case for leaf <event-time>, <start-time>, etc.), so
`or` short-circuits past valid leaf elements. This walks keys explicitly.
"""
for key in keys:
el = row.get(key)
if el is not None:
return el
return None
def in_window(time_ns: int | None, window: tuple[int, int] | None) -> bool:
"""Return True if time_ns is inside [start, end] (inclusive), or window is None."""
if window is None:
return True
if time_ns is None:
return False
start, end = window
return start <= time_ns <= end
def event_overlaps_window(
start_ns: int, end_ns: int, window: tuple[int, int] | None
) -> bool:
"""Return True if [start, end] overlaps [window.start, window.end]."""
if window is None:
return True
w_start, w_end = window
return not (end_ns < w_start or start_ns > w_end)