* fix(cleanup): preserve owner recovery instructions Keep cleanup remediation through combined errors and direct or managed one-shot analysis while retaining primary diagnostics. Refs: #4227 * fix(hopper): recover late startup and retained cleanup Authenticate initial and cleanup handshakes through the same identity owner. Expose startup and cleanup activity, retain health after target retirement, and expire abandoned unauthenticated listeners without timing out authenticated work. Refs: #4227 * fix(providers): retain composite lifecycle facts across retries Preserve observed child health with incomplete peer coverage, retry only unresolved clients, and retain all child cleanup recovery actions. Refs: #4233 * fix(process): honor cancellation through capture cleanup Check the request signal after cleanup and before final publication, retaining completed observations under typed cancellation. Refs: #4005 * fix(native): preserve failed LLDB launch ownership and exit proof Publish failed-launch process identity and observed exit separately from object validity. Retire only the exact owned process and retain uncertainty when exit cannot be established. Refs: #4232 * fix(native): honor cancellation after trace finalization Check cancellation after runtime-root cleanup and retain collected trace observations as partial cancellation evidence. Refs: #4009 * fix(cli): preserve analysis results when provider cleanup fails Reuse the existing one-shot finalizer across thirteen command families to retain completed Evidence and primary diagnostics on close failure. Cover success, denied cleanup, and combined primary/cleanup failure through the public CLI. Refs: #3935 * docs(lifecycle): define completion invariants and regression scenarios Document distinct ownership/readiness/exit facts, late cancellation and atomic publication boundaries, partial child cleanup, and diagnostic retention. Link representative regressions and route contributor guidance through AGENTS.md. Refs: #4227 * fix(session): retain run identity through pending cleanup * fix(cli): preserve original cleanup resource diagnostics * fix(native): retain completed facts through finalization failure * fix(providers): report retained cleanup paths at their owners
918 lines
34 KiB
Python
918 lines
34 KiB
Python
"""Bounded native call observation executed inside LLDB's embedded Python.
|
|
|
|
REA imports this module into `lldb --batch` and runs `rea_trace <config.json>`.
|
|
It launches one owned process, stops at the requested functions and
|
|
Objective-C methods, records each entry from registers and memory reads only,
|
|
continues, and kills the process once it exits, the event or aggregate resource
|
|
limit is reached, or the duration elapses. Target output is drained through
|
|
bounded FIFOs. No expression is evaluated and no target code runs on the
|
|
tracer's behalf. The result is written as JSON to the configured path.
|
|
"""
|
|
|
|
import errno
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import select
|
|
import threading
|
|
import time
|
|
|
|
import lldb
|
|
|
|
ARGUMENT_REGISTERS = {
|
|
"arm64": ["x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"],
|
|
"arm64e": ["x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"],
|
|
"x86_64": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"],
|
|
"x86_64h": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"],
|
|
}
|
|
MAX_SELECTOR_BYTES = 1024
|
|
MAX_REPORTED_LOCATIONS = 64
|
|
MAX_OTHER_STOPS = 256
|
|
MAX_TRACE_BYTES = 8 * 1024 * 1024
|
|
MAX_TRACE_FRAMES = 65_536
|
|
MAX_OBSERVATION_JOURNAL_BYTES = MAX_TRACE_BYTES
|
|
MAX_BREAKPOINT_LOCATION_BYTES = 8 * 1024 * 1024
|
|
MAX_TARGET_OUTPUT_BYTES = 1024 * 1024
|
|
OUTPUT_DRAIN_BYTES_PER_PASS = 1024 * 1024
|
|
OUTPUT_EOF_WAIT_SECONDS = 1.0
|
|
OUTPUT_READER_JOIN_SECONDS = 0.5
|
|
LISTENER_NAME = "rea-native-call-observation"
|
|
|
|
|
|
def _can_retain_trace(current_bytes, current_frames, event_bytes, event_frames):
|
|
"""Whether a complete event fits both aggregate trace budgets."""
|
|
return (
|
|
current_bytes + event_bytes <= MAX_TRACE_BYTES
|
|
and current_frames + event_frames <= MAX_TRACE_FRAMES
|
|
)
|
|
|
|
|
|
def _estimated_json_size(value):
|
|
"""Estimate JSON size without encoding potentially oversized values."""
|
|
if isinstance(value, str):
|
|
# json.dumps defaults to ensure_ascii=True; supplementary characters
|
|
# can become two six-byte Unicode escapes.
|
|
return len(value) * 12 + 2
|
|
if value is None or isinstance(value, (bool, int, float)):
|
|
return 32
|
|
if isinstance(value, dict):
|
|
return 2 + sum(
|
|
len(key) * 6 + 4 + _estimated_json_size(item)
|
|
for key, item in value.items()
|
|
)
|
|
if isinstance(value, (list, tuple)):
|
|
return 2 + sum(_estimated_json_size(item) + 1 for item in value)
|
|
return 64
|
|
|
|
|
|
def _fits_trace_budget(value, remaining_bytes):
|
|
return _estimated_json_size(value) <= remaining_bytes
|
|
|
|
|
|
class _ObservationJournal:
|
|
"""Flush a bounded JSONL prefix so observations survive tracer failure."""
|
|
|
|
def __init__(self, handle, max_bytes=MAX_OBSERVATION_JOURNAL_BYTES):
|
|
self.handle = handle
|
|
self.max_bytes = max_bytes
|
|
self.bytes = 0
|
|
|
|
def append(self, record):
|
|
encoded = (json.dumps(record) + "\n").encode("utf-8")
|
|
if self.bytes + len(encoded) > self.max_bytes:
|
|
return False
|
|
self.handle.write(encoded)
|
|
self.handle.flush()
|
|
self.bytes += len(encoded)
|
|
return True
|
|
|
|
|
|
def _set_launch_flags(launch):
|
|
launch.SetLaunchFlags(
|
|
launch.GetLaunchFlags()
|
|
| lldb.eLaunchFlagDebug
|
|
| lldb.eLaunchFlagStopAtEntry
|
|
)
|
|
|
|
|
|
def _hash_file(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as target_file:
|
|
while True:
|
|
chunk = target_file.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _file_identity(path):
|
|
info = os.stat(path)
|
|
return str(info.st_dev), str(info.st_ino)
|
|
|
|
|
|
def _module_identity(target):
|
|
module = target.FindModule(target.GetExecutable())
|
|
if not module.IsValid():
|
|
return None, None, None
|
|
spec = module.GetFileSpec()
|
|
path = str(spec) if spec.IsValid() else None
|
|
identity = None
|
|
if path:
|
|
try:
|
|
identity = _file_identity(path)
|
|
except OSError:
|
|
pass
|
|
return path, identity, module.GetUUIDString() or None
|
|
|
|
|
|
def _wait_for_identity_ack(config):
|
|
"""Hold the target at entry while the host adapter captures its start identity."""
|
|
path = config.get("identity_ack_path")
|
|
if not path:
|
|
return
|
|
while True:
|
|
try:
|
|
with open(path, "r") as handle:
|
|
return handle.read(32).strip()
|
|
except FileNotFoundError:
|
|
time.sleep(0.025)
|
|
|
|
|
|
class _OutputPipe:
|
|
"""Drain a target FIFO while retaining only its bounded output prefix."""
|
|
|
|
def __init__(self, fifo_path, capture_path, max_bytes):
|
|
os.mkfifo(fifo_path, 0o600)
|
|
self.path = fifo_path
|
|
try:
|
|
self.fd = os.open(fifo_path, os.O_RDONLY | os.O_NONBLOCK)
|
|
except Exception:
|
|
os.unlink(fifo_path)
|
|
raise
|
|
try:
|
|
self.capture = open(capture_path, "wb")
|
|
except Exception:
|
|
os.close(self.fd)
|
|
os.unlink(fifo_path)
|
|
raise
|
|
self.max_bytes = max_bytes
|
|
self.bytes = 0
|
|
self.truncated = False
|
|
self.closed = False
|
|
self.stop_reader = threading.Event()
|
|
self.reader_thread = None
|
|
self.reader_error = None
|
|
self.launched = False
|
|
self.eof = threading.Event()
|
|
self.complete = False
|
|
|
|
def start(self):
|
|
if self.reader_thread is None:
|
|
self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
|
|
self.reader_thread.start()
|
|
|
|
def _reader_loop(self):
|
|
try:
|
|
while not self.stop_reader.is_set():
|
|
readable, _, _ = select.select([self.fd], [], [], 0.05)
|
|
if not readable:
|
|
continue
|
|
if self.drain(OUTPUT_DRAIN_BYTES_PER_PASS) == 0:
|
|
# A FIFO read end is readable at EOF before the target opens it.
|
|
time.sleep(0.01)
|
|
except Exception as error:
|
|
self.reader_error = error
|
|
self.stop_reader.set()
|
|
|
|
def mark_launched(self):
|
|
self.launched = True
|
|
|
|
def drain(self, max_bytes=OUTPUT_DRAIN_BYTES_PER_PASS):
|
|
consumed = 0
|
|
while consumed < max_bytes:
|
|
try:
|
|
chunk = os.read(self.fd, min(65_536, max_bytes - consumed))
|
|
except OSError as error:
|
|
if error.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
return consumed
|
|
raise
|
|
if not chunk:
|
|
if self.launched:
|
|
self.eof.set()
|
|
return consumed
|
|
consumed += len(chunk)
|
|
self.bytes += len(chunk)
|
|
remaining = max(0, self.max_bytes - self.capture.tell())
|
|
if remaining:
|
|
self.capture.write(chunk[:remaining])
|
|
if len(chunk) > remaining:
|
|
self.truncated = True
|
|
|
|
def close(self, expected_exit=False):
|
|
if self.closed:
|
|
return
|
|
if expected_exit and self.launched:
|
|
self.eof.wait(OUTPUT_EOF_WAIT_SECONDS)
|
|
self.stop_reader.set()
|
|
if self.reader_thread is not None:
|
|
self.reader_thread.join(OUTPUT_READER_JOIN_SECONDS)
|
|
stopped = self.reader_thread is None or not self.reader_thread.is_alive()
|
|
if stopped:
|
|
try:
|
|
self.drain(OUTPUT_DRAIN_BYTES_PER_PASS)
|
|
finally:
|
|
try:
|
|
self.capture.close()
|
|
finally:
|
|
os.close(self.fd)
|
|
self.closed = True
|
|
else:
|
|
# A daemon reader may still be unwinding a blocked filesystem write.
|
|
# Leave its handles alone rather than racing that thread at shutdown.
|
|
self.closed = True
|
|
self.complete = bool(
|
|
stopped
|
|
and (not self.launched or (expected_exit and self.eof.is_set()))
|
|
and self.reader_error is None
|
|
)
|
|
if not self.complete:
|
|
self.truncated = True
|
|
|
|
|
|
def _hex(value):
|
|
return "0x%x" % value
|
|
|
|
|
|
def _location(address, target):
|
|
"""Load and file address, module and symbol of one code address."""
|
|
module = address.GetModule()
|
|
symbol = address.GetSymbol()
|
|
file_address = address.GetFileAddress()
|
|
location = {
|
|
"load_address": _hex(address.GetLoadAddress(target)),
|
|
"file_address": None
|
|
if file_address == lldb.LLDB_INVALID_ADDRESS
|
|
else _hex(file_address),
|
|
"module": module.GetFileSpec().GetFilename() if module.IsValid() else None,
|
|
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
|
|
"symbol": symbol.GetName() if symbol.IsValid() else None,
|
|
}
|
|
return location
|
|
|
|
|
|
def _no_files():
|
|
return lldb.SBFileSpecList()
|
|
|
|
|
|
def _create_breakpoints(target, spec):
|
|
"""LLDB breakpoints for one request; a class-qualified method may need two."""
|
|
if spec["kind"] == "function":
|
|
modules = lldb.SBFileSpecList()
|
|
if spec.get("module"):
|
|
modules.Append(lldb.SBFileSpec(spec["module"], False))
|
|
return [
|
|
target.BreakpointCreateByName(
|
|
spec["name"], lldb.eFunctionNameTypeAuto, modules, _no_files()
|
|
)
|
|
]
|
|
class_name = spec.get("class_name")
|
|
method_type = spec.get("method_type", "any")
|
|
if class_name is None:
|
|
return [
|
|
target.BreakpointCreateByName(
|
|
spec["selector"], lldb.eFunctionNameTypeSelector, _no_files(), _no_files()
|
|
)
|
|
]
|
|
prefixes = {"instance": ["-"], "class": ["+"], "any": ["-", "+"]}[method_type]
|
|
return [
|
|
target.BreakpointCreateByName(
|
|
"%s[%s %s]" % (prefix, class_name, spec["selector"]),
|
|
lldb.eFunctionNameTypeFull,
|
|
_no_files(),
|
|
_no_files(),
|
|
)
|
|
for prefix in prefixes
|
|
]
|
|
|
|
|
|
def _dynamic_class(target, pointer):
|
|
"""Class of an Objective-C receiver from LLDB's runtime reader, without running code."""
|
|
if pointer == 0:
|
|
return None
|
|
data = lldb.SBData.CreateDataFromUInt64Array(
|
|
target.GetByteOrder(), target.GetAddressByteSize(), [pointer]
|
|
)
|
|
value = target.CreateValueFromData(
|
|
"receiver", data, target.GetBasicType(lldb.eBasicTypeObjCID)
|
|
)
|
|
dynamic = value.GetDynamicValue(lldb.eDynamicDontRunTarget)
|
|
name = dynamic.GetTypeName() if dynamic.IsValid() else None
|
|
if not name or name in ("id", "objc_object *"):
|
|
return None
|
|
return name[:-2] if name.endswith(" *") else name
|
|
|
|
|
|
def _wanted(spec, symbol):
|
|
"""Selector-only requests may admit only instance or only class methods."""
|
|
if spec["kind"] != "objc-method" or spec.get("class_name") is not None:
|
|
return True
|
|
method_type = spec.get("method_type", "any")
|
|
if method_type == "any":
|
|
return True
|
|
return symbol.startswith("-[" if method_type == "instance" else "+[")
|
|
|
|
|
|
def _read_register(frame, name):
|
|
error = lldb.SBError()
|
|
value = frame.FindRegister(name)
|
|
if not value.IsValid():
|
|
raise RuntimeError("LLDB breakpoint register %s is unavailable" % name)
|
|
number = value.GetValueAsUnsigned(error)
|
|
if error.Fail():
|
|
raise RuntimeError(
|
|
"LLDB breakpoint register %s could not be read: %s" % (name, error)
|
|
)
|
|
return number
|
|
|
|
|
|
def _record(context, thread, frame, index, sequence, location, remaining_bytes):
|
|
target, process, config = context["target"], context["process"], context["config"]
|
|
symbol = location["symbol"] or ""
|
|
if not _wanted(config["breakpoints"][index], symbol):
|
|
return None
|
|
names = ARGUMENT_REGISTERS.get(config["architecture"], [])
|
|
objc_method = symbol.startswith("-[") or symbol.startswith("+[")
|
|
values = {
|
|
name: _read_register(frame, name)
|
|
for name in names[: max(config["argument_registers"], 2 if objc_method else 0)]
|
|
}
|
|
registers = [
|
|
{"name": name, "value": _hex(values[name])}
|
|
for name in names[: config["argument_registers"]]
|
|
]
|
|
receiver_class = None
|
|
selector = None
|
|
if objc_method:
|
|
error = lldb.SBError()
|
|
text = process.ReadCStringFromMemory(
|
|
values[names[1]],
|
|
MAX_SELECTOR_BYTES,
|
|
error,
|
|
)
|
|
selector = text if error.Success() and text else None
|
|
if symbol.startswith("-["):
|
|
receiver_class = _dynamic_class(target, values[names[0]])
|
|
estimated_base = (
|
|
_estimated_json_size(location)
|
|
+ _estimated_json_size(receiver_class)
|
|
+ _estimated_json_size(selector)
|
|
+ 2048
|
|
)
|
|
if estimated_base > remaining_bytes:
|
|
raise ValueError("trace event exceeds aggregate byte budget")
|
|
record = {
|
|
"sequence": sequence,
|
|
"elapsed_ms": round((time.monotonic() - context["started"]) * 1000, 3),
|
|
"thread_id": thread.GetThreadID(),
|
|
"breakpoint_index": index,
|
|
"receiver_class": receiver_class,
|
|
"selector": selector,
|
|
"registers": registers,
|
|
"backtrace": [],
|
|
}
|
|
record.update(location)
|
|
return record
|
|
|
|
|
|
# One trace runs in each owned LLDB subprocess. Its callbacks execute at the
|
|
# breakpoint before LLDB resumes, unlike snapshots of later public stop events.
|
|
_TRACE_CONTEXT = None
|
|
|
|
|
|
def _handle_breakpoint(context, frame, index):
|
|
"""Read one hit while LLDB's breakpoint callback owns the stopped frame."""
|
|
if not frame.IsValid() or frame.GetPC() == lldb.LLDB_INVALID_ADDRESS:
|
|
raise RuntimeError("LLDB breakpoint callback did not supply a readable frame")
|
|
thread = frame.GetThread()
|
|
events = context["events"]
|
|
location = _location(frame.GetPCAddress(), context["target"])
|
|
symbol = location["symbol"] or ""
|
|
if not _wanted(context["config"]["breakpoints"][index], symbol):
|
|
return None
|
|
frame_count = max(
|
|
0,
|
|
min(thread.GetNumFrames() - 1, context["config"]["backtrace_frames"]),
|
|
)
|
|
event_frame_count = 1 + frame_count
|
|
if not _can_retain_trace(
|
|
context["retained_trace_bytes"],
|
|
context["retained_frames"],
|
|
0,
|
|
event_frame_count,
|
|
):
|
|
return "resource-limit"
|
|
try:
|
|
remaining_bytes = MAX_TRACE_BYTES - context["retained_trace_bytes"]
|
|
record = _record(
|
|
context, thread, frame, index, len(events), location, remaining_bytes
|
|
)
|
|
except ValueError:
|
|
return "resource-limit"
|
|
if record is not None:
|
|
if not _fits_trace_budget(record, remaining_bytes):
|
|
return "resource-limit"
|
|
record_bytes = len(json.dumps(record).encode("utf-8"))
|
|
if context["retained_trace_bytes"] + record_bytes > MAX_TRACE_BYTES:
|
|
return "resource-limit"
|
|
for depth in range(1, frame_count + 1):
|
|
try:
|
|
location = _location(
|
|
thread.GetFrameAtIndex(depth).GetPCAddress(), context["target"]
|
|
)
|
|
except ValueError:
|
|
return "resource-limit"
|
|
if (
|
|
_estimated_json_size(location) + record_bytes + 1
|
|
> MAX_TRACE_BYTES - context["retained_trace_bytes"]
|
|
):
|
|
return "resource-limit"
|
|
location_bytes = len(json.dumps(location).encode("utf-8"))
|
|
addition = location_bytes + (1 if record["backtrace"] else 0)
|
|
if (
|
|
context["retained_trace_bytes"]
|
|
+ record_bytes
|
|
+ addition
|
|
> MAX_TRACE_BYTES
|
|
):
|
|
return "resource-limit"
|
|
record["backtrace"].append(location)
|
|
record_bytes += addition
|
|
journal = context.get("observation_journal")
|
|
if journal is not None and not journal.append(
|
|
{"kind": "event", "event": record}
|
|
):
|
|
return "resource-limit"
|
|
events.append(record)
|
|
context["retained_frames"] += event_frame_count
|
|
context["retained_trace_bytes"] += record_bytes
|
|
if len(events) >= context["config"]["max_events"]:
|
|
return "event-limit"
|
|
return None
|
|
|
|
|
|
def breakpoint_hit(frame, location, internal_dict):
|
|
"""LLDB callback: capture before resuming, and stop on a limit or failure."""
|
|
context = _TRACE_CONTEXT
|
|
if context is None or context["outcome"] is not None or context["failure"] is not None:
|
|
return True
|
|
try:
|
|
index = context["breakpoint_index"][location.GetBreakpoint().GetID()]
|
|
context["outcome"] = _handle_breakpoint(context, frame, index)
|
|
except Exception as error:
|
|
context["failure"] = "%s: %s" % (type(error).__name__, error)
|
|
return True
|
|
return context["outcome"] is not None
|
|
|
|
|
|
def _handle_stop(context, other_stops):
|
|
"""Retain non-breakpoint stop observations; callbacks already capture hits."""
|
|
for thread in context["process"]:
|
|
if thread.GetStopReason() in (lldb.eStopReasonSignal, lldb.eStopReasonException):
|
|
stop_description = thread.GetStopDescription(256)
|
|
journal = context.get("observation_journal")
|
|
if journal is not None and not journal.append(
|
|
{"kind": "other-stop", "reason": stop_description}
|
|
):
|
|
return "resource-limit"
|
|
other_stops.append(stop_description)
|
|
if len(other_stops) >= MAX_OTHER_STOPS:
|
|
return "stop-limit"
|
|
return None
|
|
|
|
|
|
def _breakpoint_report(target, created, config):
|
|
report = []
|
|
retained_location_bytes = 0
|
|
locations_truncated = False
|
|
for index, breakpoints in enumerate(created):
|
|
locations = []
|
|
count = 0
|
|
for breakpoint in breakpoints:
|
|
for position in range(breakpoint.GetNumLocations()):
|
|
address = breakpoint.GetLocationAtIndex(position).GetAddress()
|
|
location = _location(address, target)
|
|
if not _wanted(config["breakpoints"][index], location["symbol"] or ""):
|
|
continue
|
|
count += 1
|
|
if len(locations) < MAX_REPORTED_LOCATIONS:
|
|
remaining_bytes = (
|
|
MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes
|
|
)
|
|
if not _fits_trace_budget(location, remaining_bytes):
|
|
locations_truncated = True
|
|
continue
|
|
location_bytes = len(json.dumps(location).encode("utf-8"))
|
|
addition = location_bytes + (1 if locations else 0)
|
|
if addition > remaining_bytes:
|
|
locations_truncated = True
|
|
continue
|
|
locations.append(location)
|
|
retained_location_bytes += addition
|
|
report.append({"index": index, "location_count": count, "locations": locations})
|
|
return report, locations_truncated
|
|
|
|
|
|
def _launch_info(target, config, listener):
|
|
launch = lldb.SBLaunchInfo(config["arguments"])
|
|
launch.SetListener(listener)
|
|
_set_launch_flags(launch)
|
|
environment = target.GetEnvironment()
|
|
for key, value in config["environment"].items():
|
|
environment.Set(key, value, True)
|
|
launch.SetEnvironment(environment, False)
|
|
if config.get("working_directory"):
|
|
launch.SetWorkingDirectory(config["working_directory"])
|
|
launch.AddOpenFileAction(0, "/dev/null", True, False)
|
|
launch.AddOpenFileAction(1, config["stdout_path"], False, True)
|
|
launch.AddOpenFileAction(2, config["stderr_path"], False, True)
|
|
return launch
|
|
|
|
|
|
def _wait_for_exit(listener, process, seconds):
|
|
event = lldb.SBEvent()
|
|
deadline = time.monotonic() + seconds
|
|
while process.GetState() != lldb.eStateExited and time.monotonic() < deadline:
|
|
listener.WaitForEvent(1, event)
|
|
|
|
|
|
def _failed_launch_description(process):
|
|
if process.GetState() != lldb.eStateExited or process.GetExitStatus() != -1:
|
|
return None
|
|
description = process.GetExitDescription()
|
|
if isinstance(description, str) and description.lower().startswith(("attach failed", "launch failed")):
|
|
return description
|
|
return None
|
|
|
|
|
|
def _retire_failed_launch(process, config, listener, stdout, stderr):
|
|
"""Reconcile an SBProcess returned alongside a failed Launch error."""
|
|
if not process.IsValid():
|
|
stdout.close()
|
|
stderr.close()
|
|
return False
|
|
|
|
pid = process.GetProcessID()
|
|
if pid > 0:
|
|
# Even an already-exited process needs its PID published so the host
|
|
# can distinguish a completed launch failure from an unknown target.
|
|
with open(config["pid_path"], "w") as handle:
|
|
handle.write(str(pid))
|
|
|
|
if process.GetState() != lldb.eStateExited:
|
|
stdout.mark_launched()
|
|
stderr.mark_launched()
|
|
if pid > 0:
|
|
# Hold a surviving target until the host has captured its start
|
|
# identity. If that proof is unavailable, Kill still addresses
|
|
# this exact SBProcess created by the current launch request.
|
|
_wait_for_identity_ack(config)
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
|
|
exit_observed = process.GetState() == lldb.eStateExited
|
|
expected_exit = exit_observed
|
|
stdout.close(expected_exit=expected_exit)
|
|
stderr.close(expected_exit=expected_exit)
|
|
return exit_observed
|
|
|
|
|
|
def trace(debugger, config):
|
|
global _TRACE_CONTEXT
|
|
debugger.SetAsync(True)
|
|
# Stop at the symbol itself, where argument registers still hold the ABI
|
|
# arguments, even when line tables would let LLDB skip the prologue.
|
|
lldb.SBDebugger.SetInternalVariable(
|
|
"target.skip-prologue", "false", debugger.GetInstanceName()
|
|
)
|
|
try:
|
|
before_digest = _hash_file(config["executable"])
|
|
before_identity = _file_identity(config["executable"])
|
|
except OSError as error:
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": str(error),
|
|
"target_process_created": False,
|
|
}
|
|
if before_digest != config["expected_sha256"]:
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": "selected executable digest changed before LLDB target creation",
|
|
"target_process_created": False,
|
|
}
|
|
error = lldb.SBError()
|
|
target = debugger.CreateTarget(
|
|
config["executable"], config["architecture"], None, False, error
|
|
)
|
|
if not target.IsValid():
|
|
return {
|
|
"status": "target-error",
|
|
"error": error.GetCString(),
|
|
"target_process_created": False,
|
|
}
|
|
module_path_before, module_file_identity_before, module_uuid_before = (
|
|
_module_identity(target)
|
|
)
|
|
if (
|
|
module_file_identity_before is not None
|
|
and module_file_identity_before != before_identity
|
|
):
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": "LLDB executable module resolved to a different pre-launch file identity",
|
|
"target_process_created": False,
|
|
}
|
|
created = [_create_breakpoints(target, spec) for spec in config["breakpoints"]]
|
|
breakpoint_index = {}
|
|
for index, breakpoints in enumerate(created):
|
|
for breakpoint in breakpoints:
|
|
breakpoint_index[breakpoint.GetID()] = index
|
|
listener = lldb.SBListener(LISTENER_NAME)
|
|
output_budget = config.get("max_output_bytes", MAX_TARGET_OUTPUT_BYTES)
|
|
stdout = _OutputPipe(
|
|
config["stdout_path"], config["stdout_capture_path"], output_budget
|
|
)
|
|
try:
|
|
stderr = _OutputPipe(
|
|
config["stderr_path"], config["stderr_capture_path"], output_budget
|
|
)
|
|
except Exception:
|
|
stdout.close()
|
|
raise
|
|
stdout.start()
|
|
stderr.start()
|
|
observation_journal = None
|
|
started = time.monotonic()
|
|
process = None
|
|
try:
|
|
observation_path = config.get("observation_path")
|
|
if observation_path:
|
|
observation_journal = _ObservationJournal(
|
|
open(observation_path, "wb")
|
|
)
|
|
process = target.Launch(_launch_info(target, config, listener), error)
|
|
if not error.Success() or not process.IsValid():
|
|
exit_observed = _retire_failed_launch(
|
|
process, config, listener, stdout, stderr
|
|
)
|
|
return {
|
|
"status": "launch-error",
|
|
"error": error.GetCString(),
|
|
**(
|
|
{"target_process_created": True}
|
|
if process.IsValid()
|
|
else {}
|
|
),
|
|
**(
|
|
{"target_process_exit_observed": True}
|
|
if exit_observed
|
|
else {}
|
|
),
|
|
}
|
|
launch_failure = _failed_launch_description(process)
|
|
if launch_failure is not None:
|
|
exit_observed = _retire_failed_launch(
|
|
process, config, listener, stdout, stderr
|
|
)
|
|
return {
|
|
"status": "launch-error",
|
|
"error": launch_failure,
|
|
"target_process_created": True,
|
|
**(
|
|
{"target_process_exit_observed": True}
|
|
if exit_observed
|
|
else {}
|
|
),
|
|
}
|
|
stdout.mark_launched()
|
|
stderr.mark_launched()
|
|
# Publish the PID before any post-launch file or module inspection. The
|
|
# process is stopped at entry and remains there until identity_ack.
|
|
with open(config["pid_path"], "w") as handle:
|
|
handle.write(str(process.GetProcessID()))
|
|
identity_ack = _wait_for_identity_ack(config)
|
|
if identity_ack != "readable":
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
expected_exit = process.GetState() == lldb.eStateExited
|
|
stdout.close(expected_exit=expected_exit)
|
|
stderr.close(expected_exit=expected_exit)
|
|
return {
|
|
"status": "target-identity-unavailable",
|
|
"error": "host could not establish the launched process identity; target was stopped",
|
|
"target_process_created": True,
|
|
}
|
|
try:
|
|
after_digest = _hash_file(config["executable"])
|
|
after_identity = _file_identity(config["executable"])
|
|
except OSError as identity_error:
|
|
process.Kill()
|
|
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": str(identity_error),
|
|
"target_process_created": True,
|
|
}
|
|
if after_digest != config["expected_sha256"] or after_identity != before_identity:
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": "selected executable digest or file identity changed during LLDB launch",
|
|
"target_process_created": True,
|
|
}
|
|
module_path_after, module_file_identity_after, module_uuid_after = (
|
|
_module_identity(target)
|
|
)
|
|
if (
|
|
module_file_identity_after is not None
|
|
and module_file_identity_after != before_identity
|
|
):
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": "LLDB executable module resolved to a different file identity",
|
|
"target_process_created": True,
|
|
}
|
|
if (
|
|
module_uuid_before is not None
|
|
and module_uuid_after is not None
|
|
and module_uuid_before != module_uuid_after
|
|
):
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
stdout.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
stderr.close(expected_exit=process.GetState() == lldb.eStateExited)
|
|
return {
|
|
"status": "target-integrity-error",
|
|
"error": "LLDB executable module identity changed during launch",
|
|
"target_process_created": True,
|
|
}
|
|
context = {
|
|
"target": target,
|
|
"process": process,
|
|
"config": config,
|
|
"started": started,
|
|
"breakpoint_index": breakpoint_index,
|
|
"retained_frames": 0,
|
|
"retained_trace_bytes": 0,
|
|
"observation_journal": observation_journal,
|
|
}
|
|
events = []
|
|
context.update({"events": events, "outcome": None, "failure": None})
|
|
_TRACE_CONTEXT = context
|
|
for breakpoints in created:
|
|
for breakpoint in breakpoints:
|
|
breakpoint.SetScriptCallbackFunction("rea_lldb_tracer.breakpoint_hit")
|
|
other_stops = []
|
|
outcome = None
|
|
deadline = started + config["duration_ms"] / 1000
|
|
event = lldb.SBEvent()
|
|
while outcome is None:
|
|
if stdout.reader_error is not None:
|
|
raise RuntimeError("stdout capture failed: %s" % stdout.reader_error)
|
|
if stderr.reader_error is not None:
|
|
raise RuntimeError("stderr capture failed: %s" % stderr.reader_error)
|
|
if time.monotonic() >= deadline:
|
|
outcome = "duration-elapsed"
|
|
break
|
|
if not listener.WaitForEvent(1, event):
|
|
continue
|
|
if not lldb.SBProcess.EventIsProcessEvent(event):
|
|
continue
|
|
state = lldb.SBProcess.GetStateFromEvent(event)
|
|
if state == lldb.eStateExited:
|
|
outcome = "exited"
|
|
elif state == lldb.eStateStopped and not lldb.SBProcess.GetRestartedFromEvent(event):
|
|
if context["failure"] is not None:
|
|
raise RuntimeError(context["failure"])
|
|
outcome = context["outcome"] or _handle_stop(context, other_stops)
|
|
if outcome is None:
|
|
process.Continue()
|
|
pid = process.GetProcessID()
|
|
exit_status = None
|
|
exit_description = None
|
|
if outcome == "exited":
|
|
exit_status = process.GetExitStatus()
|
|
exit_description = process.GetExitDescription()
|
|
else:
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
process_exited = process.GetState() == lldb.eStateExited
|
|
stdout.close(expected_exit=process_exited)
|
|
stderr.close(expected_exit=process_exited)
|
|
# SBTarget.Launch may succeed before debugserver reports an asynchronous
|
|
# attach failure as an exited SBProcess. Its -1 status is not a target
|
|
# exit code; preserve the debugger's concrete launch denial.
|
|
launch_failure = _failed_launch_description(process)
|
|
if launch_failure is not None:
|
|
return {
|
|
"status": "launch-error",
|
|
"error": launch_failure,
|
|
"target_process_created": True,
|
|
}
|
|
launch_identity_stable = (
|
|
module_path_before is not None
|
|
and module_path_after == module_path_before
|
|
and module_file_identity_before == before_identity
|
|
and module_file_identity_after == module_file_identity_before
|
|
and module_uuid_before is not None
|
|
and module_uuid_after == module_uuid_before
|
|
)
|
|
breakpoint_report, breakpoint_locations_truncated = _breakpoint_report(
|
|
target, created, config
|
|
)
|
|
return {
|
|
"status": "traced",
|
|
"version": lldb.SBDebugger.GetVersionString().splitlines()[0],
|
|
"pid": pid,
|
|
"outcome": outcome,
|
|
"exit_status": exit_status,
|
|
"exit_description": exit_description,
|
|
"killed": outcome != "exited",
|
|
"terminated": process.GetState() == lldb.eStateExited,
|
|
"elapsed_ms": round((time.monotonic() - started) * 1000, 3),
|
|
"breakpoints": breakpoint_report,
|
|
"breakpoint_locations_truncated": breakpoint_locations_truncated,
|
|
"events": events,
|
|
"resource_limit_reached": outcome == "resource-limit",
|
|
"target_output": {
|
|
"stdout_bytes": stdout.bytes,
|
|
"stderr_bytes": stderr.bytes,
|
|
"stdout_truncated": stdout.truncated,
|
|
"stderr_truncated": stderr.truncated,
|
|
"stdout_complete": stdout.complete,
|
|
"stderr_complete": stderr.complete,
|
|
},
|
|
"target_identity": {
|
|
"selected_file_sha256": after_digest,
|
|
"loaded_image_sha256": None,
|
|
"file_device": after_identity[0],
|
|
"file_inode": after_identity[1],
|
|
"module_path": module_path_after,
|
|
"module_uuid": module_uuid_after,
|
|
# This records matching path/module identity observations. LLDB
|
|
# does not expose the exact mapped bytes, so it never proves a digest.
|
|
"stable": launch_identity_stable,
|
|
},
|
|
"other_stops": other_stops,
|
|
}
|
|
|
|
finally:
|
|
_TRACE_CONTEXT = None
|
|
if observation_journal is not None:
|
|
try:
|
|
observation_journal.handle.close()
|
|
except Exception:
|
|
pass
|
|
if process is not None:
|
|
try:
|
|
if process.IsValid() and process.GetState() != lldb.eStateExited:
|
|
process.Kill()
|
|
_wait_for_exit(listener, process, 5)
|
|
except Exception:
|
|
pass
|
|
for output_pipe in (stdout, stderr):
|
|
try:
|
|
output_pipe.close(
|
|
expected_exit=(
|
|
process is not None
|
|
and process.IsValid()
|
|
and process.GetState() == lldb.eStateExited
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run(debugger, command, result, internal_dict):
|
|
"""LLDB command entry point: `rea_trace <config.json>`."""
|
|
with open(command.strip()) as handle:
|
|
config = json.load(handle)
|
|
try:
|
|
output = trace(debugger, config)
|
|
except Exception as error: # Reported as a tracer failure, never re-raised into LLDB.
|
|
output = {"status": "tracer-error", "error": "%s: %s" % (type(error).__name__, error)}
|
|
with open(config["result_path"], "w") as handle:
|
|
json.dump(output, handle)
|
|
|
|
|
|
def __lldb_init_module(debugger, internal_dict):
|
|
debugger.HandleCommand("command script add -f rea_lldb_tracer.run rea_trace")
|