📦 deps(thirdparty): update snapshots
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
"""Parsers for Xcode Instruments .trace files via xctrace export."""
|
||||
+187
@@ -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)
|
||||
+179
@@ -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]
|
||||
+291
@@ -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
|
||||
+108
@@ -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: 250ms–500ms, 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
|
||||
+145
@@ -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")
|
||||
+243
@@ -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"250ms–1s={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] + "…"
|
||||
+195
@@ -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
|
||||
+135
@@ -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
|
||||
+117
@@ -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
|
||||
+224
@@ -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)
|
||||
Reference in New Issue
Block a user