blob: 2bd56c5d65695d40d01069aba20d5d201cff6855 [file]
import mmap
import os
import struct
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
_MAGIC = 0xFDCA975E
_FILE_HEADER_SIZE = 256
_FRAME_HEADER_SIZE = 24
_FRAME_ALIGN = 8
_FRAME_CTRDEF = 8
_FRAME_CTRSET = 9
_FRAME_MARK = 10
# Frame types considered "data" by libsysprof when guessing a capture's end
# time (everything except TIMESTAMP, CTRDEF, FILE_CHUNK, JITMAP, METADATA,
# and OVERLAY).
_DATA_FRAME_TYPES = {2, 3, 4, 5, 6, 9, 10, 12, 14, 16, 17}
_COUNTER_TYPE_DOUBLE = 1
def parse(file_path: str, marks: bool, counters: bool) -> Dict[str, Any]:
assert marks or counters
with open(file_path, "rb") as fileobj:
with mmap.mmap(fileobj.fileno(), 0, access=mmap.ACCESS_READ) as data:
return _parse_capture(data, file_path, marks, counters)
def _parse_capture(
data: mmap.mmap, file_path: str, marks: bool, counters: bool
) -> Dict[str, Any]:
logging.info("Parsing .syscap file...")
magic, version_bits = struct.unpack_from("<II", data, 0)
if magic != _MAGIC:
raise ValueError(f"{file_path} is not a sysprof capture file")
if not (version_bits >> 8) & 0x1:
raise NotImplementedError("Big-endian captures are not supported")
capture_time = _read_cstring(data, 8, 64) or ""
header_time, header_end_time = struct.unpack_from("<qq", data, 72)
frames = _index_frames(data)
frames.sort(key=lambda entry: entry[3])
guessed_end_nsec = 0
parsed_marks: List[Dict[str, Any]] = []
counter_defs: Dict[int, Dict[str, Any]] = {}
counter_order: List[int] = []
for offset, length, frame_type, time in frames:
if frame_type in _DATA_FRAME_TYPES and time > guessed_end_nsec:
guessed_end_nsec = time
if frame_type == _FRAME_MARK:
duration = struct.unpack_from("<q", data, offset + 24)[0]
end_time = time + duration
if end_time > guessed_end_nsec:
guessed_end_nsec = end_time
if marks:
parsed_marks.append(
_parse_mark(data, offset, length, duration, end_time)
)
elif frame_type == _FRAME_CTRDEF and counters:
_parse_ctrdef(data, offset, counter_defs, counter_order)
elif frame_type == _FRAME_CTRSET and counters:
_parse_ctrset(data, offset, length, time, counter_defs)
parsed_marks.sort(key=lambda mark: (mark["group"], mark["name"], mark["end_time"]))
parsed_counters = [
{
"category": counter_defs[counter_id]["category"],
"name": counter_defs[counter_id]["name"],
"description": counter_defs[counter_id]["description"],
"values": counter_defs[counter_id]["values"],
}
for counter_id in counter_order
]
end_nsec = guessed_end_nsec if guessed_end_nsec > header_time else header_end_time
return {
"document": {
"title": os.path.basename(file_path),
"subtitle": _format_subtitle(capture_time),
"timespan": [header_time, end_nsec],
},
"marks": parsed_marks,
"counters": parsed_counters,
}
def _index_frames(data: mmap.mmap) -> List[Tuple[int, int, int, int]]:
frames = []
pos = _FILE_HEADER_SIZE
total_len = len(data)
while pos < total_len - 2:
(frame_len,) = struct.unpack_from("<H", data, pos)
if frame_len < _FRAME_HEADER_SIZE or frame_len % _FRAME_ALIGN != 0:
break
frame_type = data[pos + 16]
(frame_time,) = struct.unpack_from("<q", data, pos + 8)
frames.append((pos, frame_len, frame_type, frame_time))
pos += frame_len
return frames
def _parse_mark(
data: mmap.mmap, offset: int, length: int, duration: int, end_time: int
) -> Dict[str, Any]:
# The frame header is len, cpu, pid, time, so the process that emitted the mark
# is four bytes in. The group names its kind, e.g. "WebKit (Web)", which two
# processes of one kind share.
(pid,) = struct.unpack_from("<i", data, offset + 4)
group = _read_cstring(data, offset + 32, 24) or ""
name = _read_cstring(data, offset + 56, 40) or ""
message = _read_cstring(data, offset + 96, length - 96) or ""
return {
"name": name,
"message": message,
"duration": duration,
"end_time": end_time,
"group": group,
"pid": pid,
}
def _parse_ctrdef(
data: mmap.mmap,
offset: int,
counter_defs: Dict[int, Dict[str, Any]],
counter_order: List[int],
) -> None:
(n_counters,) = struct.unpack_from("<H", data, offset + 24)
base = offset + 32
for i in range(n_counters):
counter_offset = base + i * 128
category = _read_cstring(data, counter_offset, 32) or ""
name = _read_cstring(data, counter_offset + 32, 32) or ""
description = _read_cstring(data, counter_offset + 64, 52) or ""
(id_and_type,) = struct.unpack_from("<I", data, counter_offset + 116)
counter_id = id_and_type & 0xFFFFFF
counter_type = (id_and_type >> 24) & 0xFF
if counter_id not in counter_defs:
counter_defs[counter_id] = {
"category": category,
"name": name,
"description": description,
"type": counter_type,
"values": [],
}
counter_order.append(counter_id)
def _parse_ctrset(
data: mmap.mmap,
offset: int,
length: int,
time: int,
counter_defs: Dict[int, Dict[str, Any]],
) -> None:
(n_groups,) = struct.unpack_from("<H", data, offset + 24)
base = offset + 32
for group_index in range(n_groups):
group_offset = base + group_index * 96
if group_offset + 96 > offset + length:
break
for slot in range(8):
(counter_id,) = struct.unpack_from("<I", data, group_offset + slot * 4)
if counter_id == 0:
break
counter = counter_defs.get(counter_id)
if counter is None:
continue
value_offset = group_offset + 32 + slot * 8
if counter["type"] == _COUNTER_TYPE_DOUBLE:
(value,) = struct.unpack_from("<d", data, value_offset)
else:
(value,) = struct.unpack_from("<q", data, value_offset)
counter["values"].append({"time": time, "offset": time, "value": value})
def _read_cstring(data: mmap.mmap, offset: int, max_len: int) -> Optional[str]:
end = offset + max_len
field = bytes(data[offset:end])
terminator = field.find(b"\x00")
if terminator == -1:
return None
return field[:terminator].decode("utf-8", errors="replace")
def _format_subtitle(capture_time: str) -> Optional[str]:
if not capture_time:
return None
try:
parsed = datetime.strptime(capture_time, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc
)
except ValueError:
return f"Recording at {capture_time}"
return parsed.astimezone().strftime("Recording at %X %x")