mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(native): supervise LLDB and preserve bounded capture uncertainty
This commit is contained in:
@@ -3,12 +3,18 @@
|
||||
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 limit is reached or
|
||||
the duration elapses. No expression is evaluated and no target code runs on
|
||||
the tracer's behalf. The result is written as JSON to the configured path.
|
||||
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
|
||||
@@ -22,9 +28,198 @@ ARGUMENT_REGISTERS = {
|
||||
MAX_SELECTOR_BYTES = 1024
|
||||
MAX_REPORTED_LOCATIONS = 64
|
||||
MAX_OTHER_STOPS = 256
|
||||
MAX_TRACE_BYTES = 8 * 1024 * 1024
|
||||
MAX_TRACE_FRAMES = 65_536
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -34,7 +229,7 @@ def _location(address, target):
|
||||
module = address.GetModule()
|
||||
symbol = address.GetSymbol()
|
||||
file_address = address.GetFileAddress()
|
||||
return {
|
||||
location = {
|
||||
"load_address": _hex(address.GetLoadAddress(target)),
|
||||
"file_address": None
|
||||
if file_address == lldb.LLDB_INVALID_ADDRESS
|
||||
@@ -43,6 +238,7 @@ def _location(address, target):
|
||||
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
|
||||
"symbol": symbol.GetName() if symbol.IsValid() else None,
|
||||
}
|
||||
return location
|
||||
|
||||
|
||||
def _no_files():
|
||||
@@ -107,10 +303,9 @@ def _wanted(spec, symbol):
|
||||
return symbol.startswith("-[" if method_type == "instance" else "+[")
|
||||
|
||||
|
||||
def _record(context, thread, index, sequence):
|
||||
def _record(context, thread, index, sequence, location, remaining_bytes):
|
||||
target, process, config = context["target"], context["process"], context["config"]
|
||||
frame = thread.GetFrameAtIndex(0)
|
||||
location = _location(frame.GetPCAddress(), target)
|
||||
symbol = location["symbol"] or ""
|
||||
if not _wanted(config["breakpoints"][index], symbol):
|
||||
return None
|
||||
@@ -133,7 +328,14 @@ def _record(context, thread, index, sequence):
|
||||
receiver_class = _dynamic_class(
|
||||
target, frame.FindRegister(names[0]).GetValueAsUnsigned()
|
||||
)
|
||||
frames = min(thread.GetNumFrames(), 1 + config["backtrace_frames"])
|
||||
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),
|
||||
@@ -142,10 +344,7 @@ def _record(context, thread, index, sequence):
|
||||
"receiver_class": receiver_class,
|
||||
"selector": selector,
|
||||
"registers": registers,
|
||||
"backtrace": [
|
||||
_location(thread.GetFrameAtIndex(depth).GetPCAddress(), target)
|
||||
for depth in range(1, frames)
|
||||
],
|
||||
"backtrace": [],
|
||||
}
|
||||
record.update(location)
|
||||
return record
|
||||
@@ -160,9 +359,62 @@ def _handle_stop(context, events, other_stops):
|
||||
index = context["breakpoint_index"].get(thread.GetStopReasonDataAtIndex(0))
|
||||
if index is None:
|
||||
continue
|
||||
record = _record(context, thread, index, len(events))
|
||||
frame = thread.GetFrameAtIndex(0)
|
||||
location = _location(frame.GetPCAddress(), context["target"])
|
||||
symbol = location["symbol"] or ""
|
||||
if not _wanted(context["config"]["breakpoints"][index], symbol):
|
||||
continue
|
||||
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, 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
|
||||
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"
|
||||
elif reason in (lldb.eStopReasonSignal, lldb.eStopReasonException):
|
||||
@@ -172,18 +424,20 @@ def _handle_stop(context, events, other_stops):
|
||||
return None
|
||||
|
||||
|
||||
def _breakpoint_report(target, created):
|
||||
def _breakpoint_report(target, created, config):
|
||||
report = []
|
||||
for index, breakpoints in enumerate(created):
|
||||
locations = []
|
||||
count = 0
|
||||
for breakpoint in breakpoints:
|
||||
count += breakpoint.GetNumLocations()
|
||||
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:
|
||||
locations.append(
|
||||
_location(breakpoint.GetLocationAtIndex(position).GetAddress(), target)
|
||||
)
|
||||
locations.append(location)
|
||||
report.append({"index": index, "location_count": count, "locations": locations})
|
||||
return report
|
||||
|
||||
@@ -191,6 +445,7 @@ def _breakpoint_report(target, created):
|
||||
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)
|
||||
@@ -217,74 +472,229 @@ def trace(debugger, config):
|
||||
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)}
|
||||
if before_digest != config["expected_sha256"]:
|
||||
return {
|
||||
"status": "target-integrity-error",
|
||||
"error": "selected executable digest changed before LLDB target creation",
|
||||
}
|
||||
error = lldb.SBError()
|
||||
target = debugger.CreateTarget(
|
||||
config["executable"], config["architecture"], None, False, error
|
||||
)
|
||||
if not target.IsValid():
|
||||
return {"status": "target-error", "error": error.GetCString()}
|
||||
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",
|
||||
}
|
||||
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()
|
||||
started = time.monotonic()
|
||||
process = target.Launch(_launch_info(target, config, listener), error)
|
||||
if not error.Success() or not process.IsValid():
|
||||
return {"status": "launch-error", "error": error.GetCString()}
|
||||
with open(config["pid_path"], "w") as handle:
|
||||
handle.write(str(process.GetProcessID()))
|
||||
context = {
|
||||
"target": target,
|
||||
"process": process,
|
||||
"config": config,
|
||||
"started": started,
|
||||
"breakpoint_index": breakpoint_index,
|
||||
}
|
||||
events = []
|
||||
other_stops = []
|
||||
outcome = None
|
||||
deadline = started + config["duration_ms"] / 1000
|
||||
event = lldb.SBEvent()
|
||||
while outcome is None:
|
||||
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):
|
||||
outcome = _handle_stop(context, events, 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)
|
||||
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(target, created),
|
||||
"events": events,
|
||||
"other_stops": other_stops,
|
||||
}
|
||||
process = None
|
||||
try:
|
||||
process = target.Launch(_launch_info(target, config, listener), error)
|
||||
if not error.Success() or not process.IsValid():
|
||||
stdout.close()
|
||||
stderr.close()
|
||||
return {"status": "launch-error", "error": error.GetCString()}
|
||||
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",
|
||||
}
|
||||
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)}
|
||||
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",
|
||||
}
|
||||
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",
|
||||
}
|
||||
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",
|
||||
}
|
||||
context = {
|
||||
"target": target,
|
||||
"process": process,
|
||||
"config": config,
|
||||
"started": started,
|
||||
"breakpoint_index": breakpoint_index,
|
||||
"retained_frames": 0,
|
||||
"retained_trace_bytes": 0,
|
||||
}
|
||||
events = []
|
||||
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):
|
||||
outcome = _handle_stop(context, events, 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)
|
||||
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
|
||||
)
|
||||
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(target, created, config),
|
||||
"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:
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Source-owned tests for the LLDB bridge's bounded retention helpers."""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb"))
|
||||
fake_lldb.LLDB_INVALID_ADDRESS = -1
|
||||
fake_lldb.eLaunchFlagDebug = 2
|
||||
fake_lldb.eLaunchFlagStopAtEntry = 4
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"rea_lldb_tracer", os.path.join(os.path.dirname(__file__), "rea_lldb_tracer.py")
|
||||
)
|
||||
tracer = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(tracer)
|
||||
|
||||
|
||||
class NativeTraceBudgetTests(unittest.TestCase):
|
||||
def test_aggregate_trace_budget_admits_exact_boundary_and_rejects_next_record(self):
|
||||
self.assertTrue(
|
||||
tracer._can_retain_trace(
|
||||
tracer.MAX_TRACE_BYTES - 10,
|
||||
tracer.MAX_TRACE_FRAMES - 2,
|
||||
10,
|
||||
2,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
tracer._can_retain_trace(
|
||||
tracer.MAX_TRACE_BYTES - 10,
|
||||
tracer.MAX_TRACE_FRAMES - 2,
|
||||
11,
|
||||
2,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
tracer._can_retain_trace(
|
||||
tracer.MAX_TRACE_BYTES - 10,
|
||||
tracer.MAX_TRACE_FRAMES - 2,
|
||||
10,
|
||||
3,
|
||||
)
|
||||
)
|
||||
|
||||
def test_oversized_producer_string_is_rejected_before_serialization(self):
|
||||
self.assertTrue(tracer._fits_trace_budget("method", 1024))
|
||||
self.assertFalse(tracer._fits_trace_budget("m" * 2048, 1024))
|
||||
self.assertGreater(
|
||||
tracer._estimated_json_size("🙂"), len('"🙂"'.encode("utf-8"))
|
||||
)
|
||||
|
||||
def test_launch_flags_preserve_default_debug_and_stop_at_entry(self):
|
||||
class Launch:
|
||||
flags = 10
|
||||
|
||||
def GetLaunchFlags(self):
|
||||
return self.flags
|
||||
|
||||
def SetLaunchFlags(self, flags):
|
||||
self.flags = flags
|
||||
|
||||
launch = Launch()
|
||||
tracer._set_launch_flags(launch)
|
||||
self.assertEqual(launch.flags, 14)
|
||||
|
||||
def test_output_pipe_counts_all_bytes_and_keeps_only_the_prefix(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fifo = os.path.join(directory, "stdout.fifo")
|
||||
capture = os.path.join(directory, "stdout.capture")
|
||||
sink = tracer._OutputPipe(fifo, capture, 5)
|
||||
sink.start()
|
||||
sink.mark_launched()
|
||||
writer = os.open(fifo, os.O_WRONLY)
|
||||
try:
|
||||
for index in range(100):
|
||||
chunk = (b"hello world" if index == 0 else b"x" * 4096)
|
||||
os.write(writer, chunk)
|
||||
finally:
|
||||
os.close(writer)
|
||||
sink.drain()
|
||||
sink.close(expected_exit=True)
|
||||
with open(capture, "rb") as retained:
|
||||
self.assertEqual(retained.read(), b"hello")
|
||||
self.assertEqual(sink.bytes, 11 + 99 * 4096)
|
||||
self.assertTrue(sink.truncated)
|
||||
self.assertTrue(sink.complete)
|
||||
|
||||
def test_output_pipe_reports_incomplete_capture_after_unexpected_close(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fifo = os.path.join(directory, "stdout.fifo")
|
||||
capture = os.path.join(directory, "stdout.capture")
|
||||
sink = tracer._OutputPipe(fifo, capture, 5)
|
||||
sink.start()
|
||||
sink.mark_launched()
|
||||
sink.close(expected_exit=False)
|
||||
self.assertFalse(sink.complete)
|
||||
self.assertTrue(sink.truncated)
|
||||
|
||||
def test_selector_location_report_uses_the_requested_method_type(self):
|
||||
class Symbol:
|
||||
def IsValid(self):
|
||||
return True
|
||||
|
||||
def GetName(self):
|
||||
return "+[Greeter shared]"
|
||||
|
||||
class FileSpec:
|
||||
def GetFilename(self):
|
||||
return "Fixture"
|
||||
|
||||
def __str__(self):
|
||||
return "/tmp/Fixture"
|
||||
|
||||
class Module:
|
||||
def IsValid(self):
|
||||
return True
|
||||
|
||||
def GetFileSpec(self):
|
||||
return FileSpec()
|
||||
|
||||
class Address:
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
def GetSymbol(self):
|
||||
return Symbol()
|
||||
|
||||
def GetFileAddress(self):
|
||||
return 1
|
||||
|
||||
def GetLoadAddress(self, target):
|
||||
return 2
|
||||
|
||||
class Location:
|
||||
def GetAddress(self):
|
||||
return Address()
|
||||
|
||||
class Breakpoint:
|
||||
def GetNumLocations(self):
|
||||
return 1
|
||||
|
||||
def GetLocationAtIndex(self, index):
|
||||
return Location()
|
||||
|
||||
report = tracer._breakpoint_report(
|
||||
object(),
|
||||
[[Breakpoint()]],
|
||||
{
|
||||
"breakpoints": [
|
||||
{
|
||||
"kind": "objc-method",
|
||||
"selector": "shared",
|
||||
"method_type": "instance",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(report[0]["location_count"], 0)
|
||||
self.assertEqual(report[0]["locations"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -188,16 +188,30 @@ arguments. The receiver class comes from LLDB's Objective-C runtime reader
|
||||
without running target code; no expression is evaluated, and REA never
|
||||
attaches to a process it did not launch.
|
||||
|
||||
The run ends when the process exits, `max_events` entries are recorded, or
|
||||
`duration_ms` elapses. In the last two cases the process is killed, and REA
|
||||
confirms that it is gone. The result reports:
|
||||
The run ends when the process exits, `max_events` entries are recorded,
|
||||
`duration_ms` elapses, or the aggregate trace resource budget is exhausted.
|
||||
Bounded termination reports partial coverage. REA checks process termination
|
||||
using a launch-time OS start identity; if ownership or cleanup cannot be
|
||||
verified, the result or error preserves that uncertainty. The result reports:
|
||||
|
||||
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream);
|
||||
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream
|
||||
while excess bytes are drained and counted without growing capture files).
|
||||
Each stream reports `complete`; when draining could not finish, its byte
|
||||
count is an observed lower bound and truncation is explicit;
|
||||
- caller-selected arguments, working directory, and environment overrides;
|
||||
- `selected_file_sha256` and LLDB module identity observations. These checks do
|
||||
not pin the executable against concurrent mutation; `loaded_image_sha256`
|
||||
remains unknown;
|
||||
- signal or exception stops;
|
||||
- every breakpoint's resolved locations; a breakpoint that matched no loaded
|
||||
code makes coverage partial;
|
||||
- limitations.
|
||||
|
||||
The bridge limits retained event JSON to 8 MiB and caller frames to 65,536
|
||||
across the run. It checks admission before retaining another complete event;
|
||||
`resource-limit` reports which observations could not be completed without
|
||||
silently changing the requested event or frame settings.
|
||||
|
||||
Only entries are observed: return values, floating-point and stack arguments,
|
||||
and inlined or `objc_direct` calls are not. The target runs with the current
|
||||
user's permissions and may change files, show UI or use the network.
|
||||
|
||||
+6
-4
@@ -532,17 +532,19 @@ conformance/vtable fixture.
|
||||
Both commands reject a changed executable digest and clean up the fixture
|
||||
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
|
||||
|
||||
`npm run verify:native-calls` needs only macOS with Command Line Tools (`clang`,
|
||||
`lldb`, `codesign`, `nm`). It compiles `tests/conformance/native/calls.m` and
|
||||
`npm run verify:native-calls` needs macOS with Command Line Tools (`clang`,
|
||||
`lldb`, `codesign`, `nm`) and Developer Mode enabled for unattended debugger
|
||||
access. Preflight reports disabled Developer Mode without changing host settings.
|
||||
It compiles `tests/conformance/native/calls.m` and
|
||||
runs `observe-native-calls` through the CLI and stdio MCP. It checks:
|
||||
|
||||
- the receiver class, selector and argument registers of every entry, and that
|
||||
breakpoint addresses equal `nm`'s symbol addresses;
|
||||
- captured stdout and an environment override;
|
||||
- captured stdout and an environment override, plus a 2 MiB flood on each
|
||||
output stream with bounded retained prefixes and exact drained-byte counts;
|
||||
- the event-limit and duration outcomes, with the process confirmed gone;
|
||||
- that a hardened-runtime copy is refused with `debugger-attach-denied`, and
|
||||
that the same copy signed with `get-task-allow` is traced.
|
||||
for the exact ABI, authority, graph and observation boundaries.
|
||||
|
||||
### Firmware adapters
|
||||
|
||||
|
||||
@@ -14,13 +14,23 @@ const environment = () => ({
|
||||
});
|
||||
|
||||
/** Execute the compiled CLI with production providers and no test adapters. */
|
||||
export async function artifactCli(command, target, arguments_ = []) {
|
||||
return (await artifactCliEvidence(command, target, arguments_))
|
||||
export async function artifactCli(
|
||||
command,
|
||||
target,
|
||||
arguments_ = [],
|
||||
options = {},
|
||||
) {
|
||||
return (await artifactCliEvidence(command, target, arguments_, options))
|
||||
.normalized_result;
|
||||
}
|
||||
|
||||
/** Execute the compiled CLI and return its complete Evidence record. */
|
||||
export async function artifactCliEvidence(command, target, arguments_ = []) {
|
||||
export async function artifactCliEvidence(
|
||||
command,
|
||||
target,
|
||||
arguments_ = [],
|
||||
{ timeoutMs = 60000 } = {},
|
||||
) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -30,7 +40,7 @@ export async function artifactCliEvidence(command, target, arguments_ = []) {
|
||||
...arguments_,
|
||||
"--json",
|
||||
],
|
||||
{ env: environment(), timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
{ env: environment(), timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = JSON.parse(stdout);
|
||||
assert.equal(evidence.error, undefined, stdout);
|
||||
|
||||
@@ -27,6 +27,14 @@ for (const tool of ["clang", "lldb", "codesign", "nm"])
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const developerMode = await exec("/usr/sbin/DevToolsSecurity", ["-status"]);
|
||||
if (
|
||||
!/enabled/iu.test(developerMode.stdout) ||
|
||||
/disabled/iu.test(developerMode.stdout)
|
||||
)
|
||||
throw new Error(
|
||||
"Native call observation verification requires macOS Developer Mode; DevToolsSecurity reports it disabled.",
|
||||
);
|
||||
|
||||
const SOURCE = fileURLToPath(
|
||||
new URL("../tests/conformance/native/calls.m", import.meta.url),
|
||||
@@ -46,6 +54,7 @@ const INPUT = {
|
||||
argument_registers: 4,
|
||||
backtrace_frames: 1,
|
||||
};
|
||||
const CLI_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Pointer-free facts that two runs of the same scenario must share. */
|
||||
const stable = (result) =>
|
||||
@@ -68,17 +77,23 @@ const symbolAddress = async (binary, symbol) => {
|
||||
};
|
||||
|
||||
const checkScenario = async (binary) => {
|
||||
const result = await artifactCli("observe-native-calls", binary, [
|
||||
JSON.stringify(INPUT),
|
||||
]);
|
||||
const result = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[JSON.stringify(INPUT)],
|
||||
{ timeoutMs: CLI_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(result.process.outcome, "exited");
|
||||
assert.equal(result.process.exit_status, 0);
|
||||
assert.equal(result.process.terminated, true);
|
||||
assert.match(result.process.stdout.text, /^world x0 40\n/u);
|
||||
assert.match(result.process.stdout.text, /marker observed\n$/u);
|
||||
assert.equal(result.process.stdout.complete, true);
|
||||
assert.equal(result.process.stderr.complete, true);
|
||||
assert.deepEqual(result.coverage, {
|
||||
status: "complete",
|
||||
event_limit_reached: false,
|
||||
resource_limit_reached: false,
|
||||
unresolved_breakpoints: [],
|
||||
});
|
||||
const greet = result.events.filter(({ symbol }) => symbol === GREET);
|
||||
@@ -115,25 +130,59 @@ const alive = (pid) => {
|
||||
};
|
||||
|
||||
const checkBounds = async (binary) => {
|
||||
const limited = await artifactCli("observe-native-calls", binary, [
|
||||
JSON.stringify({
|
||||
breakpoints: [{ kind: "function", name: "nanosleep" }],
|
||||
arguments: ["--wait"],
|
||||
max_events: 2,
|
||||
duration_ms: 30_000,
|
||||
}),
|
||||
]);
|
||||
const flooded = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[
|
||||
JSON.stringify({
|
||||
breakpoints: [{ kind: "function", name: "rea_never_called" }],
|
||||
arguments: ["--flood"],
|
||||
duration_ms: 30_000,
|
||||
}),
|
||||
],
|
||||
{ timeoutMs: CLI_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(flooded.process.outcome, "exited");
|
||||
assert.equal(flooded.process.stdout.bytes, 2 * 1024 * 1024);
|
||||
assert.equal(flooded.process.stderr.bytes, 2 * 1024 * 1024);
|
||||
assert.equal(flooded.process.stdout.text.length, 1024 * 1024);
|
||||
assert.equal(flooded.process.stderr.text.length, 1024 * 1024);
|
||||
assert.equal(flooded.process.stdout.text, "O".repeat(1024 * 1024));
|
||||
assert.equal(flooded.process.stderr.text, "E".repeat(1024 * 1024));
|
||||
assert.equal(flooded.process.stdout.truncated, true);
|
||||
assert.equal(flooded.process.stderr.truncated, true);
|
||||
assert.equal(flooded.process.stdout.complete, true);
|
||||
assert.equal(flooded.process.stderr.complete, true);
|
||||
|
||||
const limited = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[
|
||||
JSON.stringify({
|
||||
breakpoints: [{ kind: "function", name: "nanosleep" }],
|
||||
arguments: ["--wait"],
|
||||
max_events: 2,
|
||||
duration_ms: 30_000,
|
||||
}),
|
||||
],
|
||||
{ timeoutMs: CLI_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(limited.process.outcome, "event-limit");
|
||||
assert.equal(limited.events.length, 2);
|
||||
assert.equal(limited.process.terminated, true);
|
||||
assert.equal(alive(limited.process.pid), false);
|
||||
const elapsed = await artifactCli("observe-native-calls", binary, [
|
||||
JSON.stringify({
|
||||
breakpoints: [{ kind: "function", name: "rea_call_add" }],
|
||||
arguments: ["--wait"],
|
||||
duration_ms: 1_500,
|
||||
}),
|
||||
]);
|
||||
const elapsed = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[
|
||||
JSON.stringify({
|
||||
breakpoints: [{ kind: "function", name: "rea_call_add" }],
|
||||
arguments: ["--wait"],
|
||||
duration_ms: 1_500,
|
||||
}),
|
||||
],
|
||||
{ timeoutMs: CLI_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(elapsed.process.outcome, "duration-elapsed");
|
||||
assert.equal(elapsed.events.length, 0);
|
||||
assert.equal(alive(elapsed.process.pid), false);
|
||||
@@ -150,7 +199,10 @@ const cliFailure = async (binary) => {
|
||||
JSON.stringify(INPUT),
|
||||
"--json",
|
||||
],
|
||||
{ env: { ...process.env, REA_LOG_LEVEL: "silent" }, timeout: 120_000 },
|
||||
{
|
||||
env: { ...process.env, REA_LOG_LEVEL: "silent" },
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
} catch (cause) {
|
||||
return JSON.parse(cause.stdout);
|
||||
|
||||
@@ -39,26 +39,37 @@ export interface AnalysisCapturedOutput {
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** Cleanup uncertainty attached to the original typed analysis failure. */
|
||||
export interface AnalysisCleanupObservation {
|
||||
readonly reason: string;
|
||||
readonly resources: readonly string[];
|
||||
}
|
||||
|
||||
/** Optional provider-neutral context that must survive typed error projection. */
|
||||
export interface AnalysisErrorOptions extends ErrorOptions {
|
||||
readonly capturedOutput?: AnalysisCapturedOutput;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
}
|
||||
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
export abstract class AnalysisError extends Error {
|
||||
readonly capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
readonly cleanup: AnalysisCleanupObservation | undefined;
|
||||
readonly cleanupIncomplete: boolean;
|
||||
readonly cleanupResources: readonly string[];
|
||||
constructor(message: string, options?: AnalysisErrorOptions) {
|
||||
super(message, options);
|
||||
this.capturedOutput =
|
||||
options?.capturedOutput === undefined
|
||||
? undefined
|
||||
: { ...options.capturedOutput };
|
||||
this.cleanup = options?.cleanup;
|
||||
this.cleanupIncomplete = options?.cleanup !== undefined;
|
||||
this.cleanupResources = options?.cleanup?.resources ?? [];
|
||||
}
|
||||
abstract readonly _tag: AnalysisErrorTag;
|
||||
readonly userMessage: string | undefined = undefined;
|
||||
readonly userCategory: "cancelled" | undefined = undefined;
|
||||
readonly cleanupIncomplete: boolean = false;
|
||||
readonly cleanupResources: readonly string[] = [];
|
||||
readonly executionFailure: string | undefined = undefined;
|
||||
readonly partialObservation: PartialProcessCaptureObservation | undefined =
|
||||
undefined;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||
import { BinaryTargetError } from "./configurationErrors.js";
|
||||
import { BrowserObservationError } from "./browserObservationError.js";
|
||||
import { EvidenceIntegrityError } from "./evidenceErrors.js";
|
||||
import {
|
||||
HopperProcessError,
|
||||
HopperRemoteError,
|
||||
@@ -131,6 +132,53 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
new AnalysisCancelledError("observe_native_calls", {
|
||||
cleanup: {
|
||||
reason: "target process termination could not be verified",
|
||||
resources: ["native-target:4242"],
|
||||
},
|
||||
}),
|
||||
new AnalysisTimeoutError("observe_native_calls", 65_000, {
|
||||
cleanup: {
|
||||
reason: "target process identity was unavailable",
|
||||
resources: ["native-target:4242"],
|
||||
},
|
||||
}),
|
||||
])("retains incomplete cleanup for lifecycle failures: $._tag", (failure) => {
|
||||
expect(projectAnalysisError(failure)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
cleanup: "incomplete",
|
||||
cleanup_reason: expect.any(String),
|
||||
resources: ["native-target:4242"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the original failure tag and code when cleanup is incomplete", () => {
|
||||
const error = new EvidenceIntegrityError(
|
||||
"The launched native target did not match its selected digest",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "target survivor identity could not be verified",
|
||||
resources: ["native-target:4242"],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(error._tag).toBe("EvidenceIntegrityError");
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
category: "integrity_mismatch",
|
||||
details: {
|
||||
cleanup: "incomplete",
|
||||
cleanup_reason: "target survivor identity could not be verified",
|
||||
resources: ["native-target:4242"],
|
||||
execution_failure: "evidence_integrity_mismatch",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis error projection: provider failures", () => {
|
||||
it("projects the primary browser failure alongside incomplete cleanup", () => {
|
||||
const projected = projectAnalysisError(
|
||||
|
||||
@@ -42,12 +42,26 @@ export const projectAnalysisError = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection => {
|
||||
assertKnownAnalysisErrorTag(error._tag);
|
||||
const code = errorCode(error);
|
||||
const underlyingCode = underlyingErrorCode(error);
|
||||
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
|
||||
const primaryDetails = errorDetails(error);
|
||||
const details =
|
||||
error.capturedOutput === undefined
|
||||
? primaryDetails
|
||||
: { ...primaryDetails, captured_output: { ...error.capturedOutput } };
|
||||
const details = {
|
||||
...primaryDetails,
|
||||
...(error.cleanupIncomplete
|
||||
? {
|
||||
cleanup: "incomplete",
|
||||
resources: [...error.cleanupResources],
|
||||
...(error.cleanup === undefined
|
||||
? {}
|
||||
: { cleanup_reason: error.cleanup.reason }),
|
||||
execution_failure:
|
||||
primaryDetails?.execution_failure ?? underlyingCode,
|
||||
}
|
||||
: {}),
|
||||
...(error.capturedOutput === undefined
|
||||
? {}
|
||||
: { captured_output: { ...error.capturedOutput } }),
|
||||
};
|
||||
return {
|
||||
code,
|
||||
category: analysisErrorCategory(error),
|
||||
@@ -56,12 +70,13 @@ export const projectAnalysisError = (
|
||||
remediation: {
|
||||
action: analysisErrorRemediationAction(error),
|
||||
},
|
||||
...(details === undefined ? {} : { details }),
|
||||
...(Object.keys(details).length === 0 ? {} : { details }),
|
||||
};
|
||||
};
|
||||
|
||||
const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => {
|
||||
if (error.cleanupIncomplete) return "cleanup_incomplete";
|
||||
const underlyingErrorCode = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection["code"] => {
|
||||
if (error instanceof ProviderSelectionError)
|
||||
return error.reason === "provider_unavailable"
|
||||
? "provider_unavailable"
|
||||
@@ -380,11 +395,31 @@ const lifecycleErrorDetails = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, JsonValue>> | undefined => {
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return { operation: error.operation, cleanup: "complete" };
|
||||
return {
|
||||
operation: error.operation,
|
||||
cleanup: error.cleanup === undefined ? "complete" : "incomplete",
|
||||
...(error.cleanup === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup_reason: error.cleanup.reason,
|
||||
resources: [...error.cleanup.resources],
|
||||
execution_failure: "cancelled",
|
||||
}),
|
||||
};
|
||||
if (error instanceof HopperCancelledError)
|
||||
return { operation: "hopper", cleanup: "complete" };
|
||||
if (error instanceof AnalysisTimeoutError)
|
||||
return { operation: error.operation, timeout_ms: error.timeoutMs };
|
||||
return {
|
||||
operation: error.operation,
|
||||
timeout_ms: error.timeoutMs,
|
||||
...(error.cleanup === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: "incomplete",
|
||||
cleanup_reason: error.cleanup.reason,
|
||||
resources: [...error.cleanup.resources],
|
||||
}),
|
||||
};
|
||||
if (error instanceof HopperTimeoutError)
|
||||
return {
|
||||
stage: error.operation === undefined ? "startup" : error.stage,
|
||||
|
||||
@@ -129,8 +129,10 @@ export const nativeCallEventSchema = z.strictObject({
|
||||
|
||||
const capturedOutputSchema = z.strictObject({
|
||||
text: z.string(),
|
||||
/** Bytes observed by the bridge, not a total when complete is false. */
|
||||
bytes: z.number().int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
complete: z.boolean(),
|
||||
});
|
||||
|
||||
/** Observed calls, the process lifecycle, and breakpoint resolution. */
|
||||
@@ -140,7 +142,21 @@ export const nativeCallObservationResultSchema = z.strictObject({
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/u),
|
||||
architecture: z.string(),
|
||||
arguments: z.array(z.string()),
|
||||
environment: z.record(z.string(), z.string()),
|
||||
working_directory: z.string().nullable(),
|
||||
launch_identity: z.strictObject({
|
||||
/** A pathname hash does not verify the bytes mapped into the process. */
|
||||
loaded_image_sha256: z.null(),
|
||||
file_device: z.string().nullable(),
|
||||
file_inode: z.string().nullable(),
|
||||
selected_file_sha256: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/u)
|
||||
.nullable(),
|
||||
module_path: z.string().nullable(),
|
||||
module_uuid: z.string().nullable(),
|
||||
stable: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
debugger: z.strictObject({
|
||||
path: z.string(),
|
||||
@@ -154,6 +170,7 @@ export const nativeCallObservationResultSchema = z.strictObject({
|
||||
"duration-elapsed",
|
||||
"event-limit",
|
||||
"stop-limit",
|
||||
"resource-limit",
|
||||
]),
|
||||
exit_status: z.number().int().nullable(),
|
||||
exit_description: z.string().nullable(),
|
||||
@@ -187,6 +204,7 @@ export const nativeCallObservationResultSchema = z.strictObject({
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete", "partial"]),
|
||||
event_limit_reached: z.boolean(),
|
||||
resource_limit_reached: z.boolean(),
|
||||
/** Requests that matched no code in any image loaded while observing. */
|
||||
unresolved_breakpoints: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { AnalysisError } from "./analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisErrorOptions,
|
||||
} from "./analysisErrorBase.js";
|
||||
|
||||
/** A provider adapter failed outside its more precise typed variants. */
|
||||
export interface ProviderAdapterErrorOptions extends ErrorOptions {
|
||||
export interface ProviderAdapterErrorOptions extends AnalysisErrorOptions {
|
||||
readonly diagnostics?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
|
||||
+394
-109
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
nativeCallEventSchema,
|
||||
nativeCodeLocationSchema,
|
||||
@@ -19,7 +23,21 @@ import {
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
observeProcessStartIdentity,
|
||||
prepareProcessOwnershipInspection,
|
||||
signalProcessWithStartIdentity,
|
||||
} from "../process/ProcessOwnershipObservation.js";
|
||||
import type { ProcessIdentityObservation } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
ProviderStartupDeadline,
|
||||
waitForAbortableDelay,
|
||||
} from "../process/ProviderDeadline.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import { resolveXcrunTool, type ResolvedTool } from "./CommandRunner.js";
|
||||
|
||||
@@ -40,11 +58,38 @@ const tracedSchema = z.strictObject({
|
||||
status: z.literal("traced"),
|
||||
version: z.string(),
|
||||
pid: z.number().int().positive(),
|
||||
outcome: z.enum(["exited", "duration-elapsed", "event-limit", "stop-limit"]),
|
||||
outcome: z.enum([
|
||||
"exited",
|
||||
"duration-elapsed",
|
||||
"event-limit",
|
||||
"stop-limit",
|
||||
"resource-limit",
|
||||
]),
|
||||
exit_status: z.number().int().nullable(),
|
||||
exit_description: z.string().nullable(),
|
||||
killed: z.boolean(),
|
||||
terminated: z.boolean(),
|
||||
target_identity: z.strictObject({
|
||||
loaded_image_sha256: z.null(),
|
||||
selected_file_sha256: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/u)
|
||||
.nullable(),
|
||||
file_device: z.string().nullable(),
|
||||
file_inode: z.string().nullable(),
|
||||
module_path: z.string().nullable(),
|
||||
module_uuid: z.string().nullable(),
|
||||
stable: z.boolean(),
|
||||
}),
|
||||
resource_limit_reached: z.boolean(),
|
||||
target_output: z.strictObject({
|
||||
stdout_bytes: z.number().int().nonnegative(),
|
||||
stderr_bytes: z.number().int().nonnegative(),
|
||||
stdout_truncated: z.boolean(),
|
||||
stderr_truncated: z.boolean(),
|
||||
stdout_complete: z.boolean(),
|
||||
stderr_complete: z.boolean(),
|
||||
}),
|
||||
elapsed_ms: z.number().nonnegative(),
|
||||
breakpoints: z.array(
|
||||
z.strictObject({
|
||||
@@ -60,7 +105,13 @@ const tracedSchema = z.strictObject({
|
||||
const tracerOutputSchema = z.discriminatedUnion("status", [
|
||||
tracedSchema,
|
||||
z.strictObject({
|
||||
status: z.enum(["target-error", "launch-error", "tracer-error"]),
|
||||
status: z.enum([
|
||||
"target-error",
|
||||
"target-integrity-error",
|
||||
"target-identity-unavailable",
|
||||
"launch-error",
|
||||
"tracer-error",
|
||||
]),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
@@ -68,8 +119,11 @@ const tracerOutputSchema = z.discriminatedUnion("status", [
|
||||
/** Bounded text of one target output stream. */
|
||||
export interface CapturedOutput {
|
||||
readonly text: string;
|
||||
/** Bytes drained from the target, which may exceed the retained prefix. */
|
||||
readonly bytes: number;
|
||||
readonly truncated: boolean;
|
||||
/** Whether the bridge confirms it drained the stream through EOF. */
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
/** One completed LLDB observation, before projection into the result contract. */
|
||||
@@ -88,6 +142,7 @@ export interface NativeCallTracer {
|
||||
request: {
|
||||
readonly executable: string;
|
||||
readonly architecture: string;
|
||||
readonly expectedSha256: string;
|
||||
readonly input: NativeCallObservationInput;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
@@ -133,8 +188,11 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
config: join(directory, "config.json"),
|
||||
result: join(directory, "result.json"),
|
||||
pid: join(directory, "pid"),
|
||||
identityAck: join(directory, "identity.ack"),
|
||||
stdout: join(directory, "stdout"),
|
||||
stderr: join(directory, "stderr"),
|
||||
stdoutCapture: join(directory, "stdout.capture"),
|
||||
stderrCapture: join(directory, "stderr.capture"),
|
||||
};
|
||||
const { input } = request;
|
||||
await writeFile(
|
||||
@@ -142,6 +200,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
JSON.stringify({
|
||||
executable: request.executable,
|
||||
architecture: request.architecture,
|
||||
expected_sha256: request.expectedSha256,
|
||||
arguments: input.arguments,
|
||||
environment: input.environment,
|
||||
working_directory: input.working_directory ?? null,
|
||||
@@ -154,13 +213,18 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
max_events: input.max_events,
|
||||
argument_registers: input.argument_registers,
|
||||
backtrace_frames: input.backtrace_frames,
|
||||
max_output_bytes: MAX_OUTPUT_BYTES,
|
||||
stdout_path: paths.stdout,
|
||||
stderr_path: paths.stderr,
|
||||
stdout_capture_path: paths.stdoutCapture,
|
||||
stderr_capture_path: paths.stderrCapture,
|
||||
result_path: paths.result,
|
||||
pid_path: paths.pid,
|
||||
identity_ack_path: paths.identityAck,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await prepareProcessOwnershipInspection(signal);
|
||||
const exited = await runLldb(
|
||||
lldb.path,
|
||||
[
|
||||
@@ -174,20 +238,77 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
],
|
||||
{
|
||||
deadlineMs: input.duration_ms + DEADLINE_GRACE_MS,
|
||||
pidPath: paths.pid,
|
||||
identityAckPath: paths.identityAck,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const terminated = await ensureTerminated(paths.pid, request.executable);
|
||||
const output =
|
||||
exited.kind === "exited"
|
||||
? await readTracerOutput(paths.result)
|
||||
: undefined;
|
||||
let targetTermination = await ensureTerminated(
|
||||
paths.pid,
|
||||
exited.targetIdentity,
|
||||
true,
|
||||
);
|
||||
// These two bridge outcomes are emitted before a target process exists.
|
||||
// Other missing-PID cases, including a missing result, remain unknown.
|
||||
if (
|
||||
output !== undefined &&
|
||||
(output.status === "target-error" || output.status === "launch-error") &&
|
||||
(await readTargetPid(paths.pid)) === undefined
|
||||
)
|
||||
targetTermination = "terminated";
|
||||
const terminated = targetTermination === "terminated";
|
||||
const cleanupDetails = [
|
||||
exited.cleanupFailure,
|
||||
...(terminated ? [] : [targetTerminationReason(targetTermination)]),
|
||||
].filter((detail): detail is string => detail !== undefined);
|
||||
const lifecycleCleanup =
|
||||
cleanupDetails.length === 0
|
||||
? undefined
|
||||
: {
|
||||
reason: cleanupDetails.join("; "),
|
||||
resources: [
|
||||
`native-target:${String((await readTargetPid(paths.pid)) ?? "unknown")}`,
|
||||
],
|
||||
};
|
||||
if (exited.kind === "cancelled")
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(
|
||||
OPERATION,
|
||||
lifecycleCleanup === undefined
|
||||
? undefined
|
||||
: { cleanup: lifecycleCleanup },
|
||||
),
|
||||
);
|
||||
if (exited.kind === "timeout")
|
||||
return err(
|
||||
new AnalysisTimeoutError(
|
||||
OPERATION,
|
||||
input.duration_ms + DEADLINE_GRACE_MS,
|
||||
lifecycleCleanup === undefined
|
||||
? undefined
|
||||
: { cleanup: lifecycleCleanup },
|
||||
),
|
||||
);
|
||||
const output = await readTracerOutput(paths.result);
|
||||
if (exited.cleanupFailure !== undefined)
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
cleanup: lifecycleCleanup ?? {
|
||||
reason: exited.cleanupFailure,
|
||||
resources: ["lldb-process-group"],
|
||||
},
|
||||
diagnostics: {
|
||||
reason: "LLDB process-group cleanup could not be verified",
|
||||
cleanup_failure: exited.cleanupFailure,
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { target_cleanup: lifecycleCleanup }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (output === undefined)
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
@@ -195,15 +316,48 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
reason: "LLDB exited without a tracer result",
|
||||
exit_code: exited.exitCode,
|
||||
lldb_output: exited.output,
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { target_cleanup: lifecycleCleanup }),
|
||||
},
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { cleanup: lifecycleCleanup }),
|
||||
}),
|
||||
);
|
||||
if (output.status !== "traced") {
|
||||
return err(tracerFailure(output, lifecycleCleanup));
|
||||
}
|
||||
const stdout = await capturedOutput(
|
||||
paths.stdoutCapture,
|
||||
output.target_output.stdout_bytes,
|
||||
output.target_output.stdout_truncated,
|
||||
output.target_output.stdout_complete,
|
||||
);
|
||||
const stderr = await capturedOutput(
|
||||
paths.stderrCapture,
|
||||
output.target_output.stderr_bytes,
|
||||
output.target_output.stderr_truncated,
|
||||
output.target_output.stderr_complete,
|
||||
);
|
||||
if (!stdout.ok || !stderr.ok)
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
diagnostics: {
|
||||
reason: "LLDB target output capture could not be read",
|
||||
...(stdout.ok ? {} : { stdout_capture_error: stdout.error }),
|
||||
...(stderr.ok ? {} : { stderr_capture_error: stderr.error }),
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { target_cleanup: lifecycleCleanup }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (output.status !== "traced") return err(tracerFailure(output));
|
||||
return ok({
|
||||
debugger: { ...lldb, version: output.version },
|
||||
run: output,
|
||||
stdout: await capturedOutput(paths.stdout),
|
||||
stderr: await capturedOutput(paths.stderr),
|
||||
stdout: stdout.value,
|
||||
stderr: stderr.value,
|
||||
terminated,
|
||||
});
|
||||
}
|
||||
@@ -213,13 +367,36 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
const quote = (value: string): string =>
|
||||
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
|
||||
const tracerFailure = (output: {
|
||||
readonly status: "target-error" | "launch-error" | "tracer-error";
|
||||
readonly error: string | null;
|
||||
}): AnalysisError => {
|
||||
const tracerFailure = (
|
||||
output: {
|
||||
readonly status:
|
||||
| "target-error"
|
||||
| "target-integrity-error"
|
||||
| "target-identity-unavailable"
|
||||
| "launch-error"
|
||||
| "tracer-error";
|
||||
readonly error: string | null;
|
||||
},
|
||||
cleanup?: AnalysisCleanupObservation,
|
||||
): AnalysisError => {
|
||||
const options = cleanup === undefined ? undefined : { cleanup };
|
||||
const message = output.error ?? "no LLDB error text";
|
||||
if (output.status === "target-integrity-error")
|
||||
return new EvidenceIntegrityError(
|
||||
message || "The launched native target did not match its session digest",
|
||||
options,
|
||||
);
|
||||
if (output.status === "target-identity-unavailable")
|
||||
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
...options,
|
||||
diagnostics: {
|
||||
reason: message,
|
||||
constraint: "target process start identity could not be verified",
|
||||
},
|
||||
});
|
||||
if (output.status === "tracer-error")
|
||||
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
...options,
|
||||
diagnostics: { reason: `LLDB bridge failed: ${message}` },
|
||||
});
|
||||
if (output.status === "target-error")
|
||||
@@ -227,6 +404,7 @@ const tracerFailure = (output: {
|
||||
PROVIDER,
|
||||
OPERATION,
|
||||
`lldb-target-unloadable: ${message}`,
|
||||
options,
|
||||
);
|
||||
// debugserver reports denied task access as an attach failure.
|
||||
return /attach|not allowed|permission|denied|debugserver/iu.test(message)
|
||||
@@ -234,12 +412,13 @@ const tracerFailure = (output: {
|
||||
PROVIDER,
|
||||
OPERATION,
|
||||
`debugger-attach-denied: ${message}`,
|
||||
{ userMessage: ATTACH_REMEDIATION },
|
||||
{ userMessage: ATTACH_REMEDIATION, ...options },
|
||||
)
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER,
|
||||
OPERATION,
|
||||
`launch-failed: ${message}`,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -258,24 +437,40 @@ const readTracerOutput = async (
|
||||
return output.success ? output.data : undefined;
|
||||
};
|
||||
|
||||
const capturedOutput = async (path: string): Promise<CapturedOutput> => {
|
||||
let handle;
|
||||
const capturedOutput = async (
|
||||
path: string,
|
||||
observedBytes: number,
|
||||
truncated: boolean,
|
||||
complete: boolean,
|
||||
): Promise<Result<CapturedOutput, string>> => {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, "r");
|
||||
} catch {
|
||||
return { text: "", bytes: 0, truncated: false };
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const buffer = Buffer.alloc(Math.min(size, MAX_OUTPUT_BYTES));
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
return {
|
||||
const captured = {
|
||||
text: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
bytes: size,
|
||||
truncated: size > MAX_OUTPUT_BYTES,
|
||||
};
|
||||
} finally {
|
||||
bytes: observedBytes,
|
||||
truncated: truncated || size > MAX_OUTPUT_BYTES,
|
||||
complete:
|
||||
complete && bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
|
||||
} satisfies CapturedOutput;
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
return ok(captured);
|
||||
} catch (cause: unknown) {
|
||||
const failure = cause instanceof Error ? cause.message : String(cause);
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (closeCause: unknown) {
|
||||
return err(
|
||||
`${failure}; closing capture file failed: ${closeCause instanceof Error ? closeCause.message : String(closeCause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return err(failure);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,93 +479,196 @@ type LldbExit =
|
||||
readonly kind: "exited";
|
||||
readonly exitCode: number | null;
|
||||
readonly output: string;
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
}
|
||||
| { readonly kind: "cancelled" }
|
||||
| { readonly kind: "timeout" };
|
||||
| {
|
||||
readonly kind: "cancelled";
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
}
|
||||
| {
|
||||
readonly kind: "timeout";
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
};
|
||||
|
||||
/** Run LLDB in its own process group so cancellation can stop it and debugserver. */
|
||||
const runLldb = (
|
||||
/** Supervise LLDB as an owned process group while observing its target PID identity. */
|
||||
const runLldb = async (
|
||||
executable: string,
|
||||
arguments_: readonly string[],
|
||||
options: { readonly signal?: AbortSignal; readonly deadlineMs: number },
|
||||
): Promise<LldbExit> =>
|
||||
new Promise((resolve) => {
|
||||
if (options.signal?.aborted === true) {
|
||||
resolve({ kind: "cancelled" });
|
||||
return;
|
||||
}
|
||||
const child = spawn(executable, [...arguments_], {
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
let stopped: "cancelled" | "timeout" | undefined;
|
||||
const keep = (chunk: Buffer): void => {
|
||||
output = (output + chunk.toString("utf8")).slice(-DIAGNOSTIC_BYTES);
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly deadlineMs: number;
|
||||
readonly pidPath: string;
|
||||
readonly identityAckPath: string;
|
||||
},
|
||||
): Promise<LldbExit> => {
|
||||
if (options.signal?.aborted === true)
|
||||
return {
|
||||
kind: "cancelled",
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure: undefined,
|
||||
};
|
||||
child.stdout.on("data", keep);
|
||||
child.stderr.on("data", keep);
|
||||
const kill = (reason: "cancelled" | "timeout"): void => {
|
||||
stopped ??= reason;
|
||||
if (child.pid !== undefined)
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
options.deadlineMs,
|
||||
options.signal,
|
||||
);
|
||||
let targetIdentity: string | undefined;
|
||||
let supervisor: ProviderProcessSupervisor | undefined;
|
||||
try {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
command: executable,
|
||||
arguments: arguments_,
|
||||
runId: randomUUID(),
|
||||
expectedCommand: executable,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
supervisor = new ProviderProcessSupervisor(
|
||||
{
|
||||
...launched,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(launched.ownership),
|
||||
},
|
||||
{ maxDiagnosticBytes: DIAGNOSTIC_BYTES },
|
||||
);
|
||||
let identityAckWritten = false;
|
||||
while (!(await supervisor.waitForOutputClose(20))) {
|
||||
const pid = await readTargetPid(options.pidPath);
|
||||
if (pid !== undefined && !identityAckWritten) {
|
||||
let observation: ProcessIdentityObservation | undefined;
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
observation = await observeProcessStartIdentity(pid);
|
||||
} catch {
|
||||
// The group already exited.
|
||||
observation = undefined;
|
||||
}
|
||||
if (observation?.state === "readable")
|
||||
targetIdentity = observation.identity;
|
||||
await writeFile(
|
||||
options.identityAckPath,
|
||||
observation?.state === "readable" ? "readable" : "unavailable",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
identityAckWritten = true;
|
||||
}
|
||||
const interruption = deadline.interruption;
|
||||
if (interruption !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
const cleanupFailure =
|
||||
stopped.status === "incomplete" ? stopped.reason : undefined;
|
||||
return interruption === "cancelled"
|
||||
? { kind: "cancelled", targetIdentity, cleanupFailure }
|
||||
: { kind: "timeout", targetIdentity, cleanupFailure };
|
||||
}
|
||||
await waitForAbortableDelay(20, deadline.signal);
|
||||
}
|
||||
const snapshot = supervisor.snapshot();
|
||||
const stopped = await supervisor.stop();
|
||||
const cleanupFailure =
|
||||
stopped.status === "incomplete" ? stopped.reason : undefined;
|
||||
const interruption = deadline.interruption;
|
||||
if (interruption === "cancelled")
|
||||
return { kind: "cancelled", targetIdentity, cleanupFailure };
|
||||
if (interruption === "timeout")
|
||||
return { kind: "timeout", targetIdentity, cleanupFailure };
|
||||
return {
|
||||
kind: "exited",
|
||||
exitCode: snapshot.exitCode ?? null,
|
||||
output: `${snapshot.stdout.text}${snapshot.stderr.text}`.slice(
|
||||
-DIAGNOSTIC_BYTES,
|
||||
),
|
||||
targetIdentity,
|
||||
cleanupFailure,
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
kill("timeout");
|
||||
}, options.deadlineMs);
|
||||
const onAbort = (): void => {
|
||||
kill("cancelled");
|
||||
};
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const finish = (exitCode: number | null): void => {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
stopped === undefined
|
||||
? { kind: "exited", exitCode, output }
|
||||
: { kind: stopped },
|
||||
);
|
||||
};
|
||||
child.on("error", () => {
|
||||
finish(null);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
finish(code);
|
||||
});
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const interruption = deadline.interruption;
|
||||
const stopped = await supervisor?.stop();
|
||||
const cleanupFailure =
|
||||
stopped?.status === "incomplete" ? stopped.reason : undefined;
|
||||
return interruption === "timeout"
|
||||
? { kind: "timeout", targetIdentity, cleanupFailure }
|
||||
: interruption === "cancelled"
|
||||
? { kind: "cancelled", targetIdentity, cleanupFailure }
|
||||
: {
|
||||
kind: "exited",
|
||||
exitCode: null,
|
||||
output: cause instanceof Error ? cause.message : String(cause),
|
||||
targetIdentity,
|
||||
cleanupFailure,
|
||||
};
|
||||
} finally {
|
||||
supervisor?.dispose();
|
||||
deadline.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* debugserver kills the traced process when LLDB exits. Confirm it, and kill a
|
||||
* survivor only when its executable is still the traced one.
|
||||
* Confirm target exit, and signal a survivor only when its OS start identity
|
||||
* still matches the one captured while it was stopped at entry.
|
||||
*/
|
||||
const readTargetPid = async (pidPath: string): Promise<number | undefined> => {
|
||||
try {
|
||||
const pid = Number.parseInt(await readFile(pidPath, "utf8"), 10);
|
||||
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
type TargetTermination =
|
||||
| "terminated"
|
||||
| "identity-unavailable"
|
||||
| "identity-changed"
|
||||
| "signal-did-not-stop";
|
||||
|
||||
/** Only signal a surviving target when its captured OS start identity still matches. */
|
||||
const ensureTerminated = async (
|
||||
pidPath: string,
|
||||
executable: string,
|
||||
): Promise<boolean> => {
|
||||
let pid: number;
|
||||
try {
|
||||
pid = Number.parseInt(await readFile(pidPath, "utf8"), 10);
|
||||
} catch {
|
||||
// LLDB never launched the target.
|
||||
return true;
|
||||
}
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
||||
identity: string | undefined,
|
||||
missingPidIsUnknown: boolean,
|
||||
): Promise<TargetTermination> => {
|
||||
const pid = await readTargetPid(pidPath);
|
||||
if (pid === undefined)
|
||||
return missingPidIsUnknown ? "identity-unavailable" : "terminated";
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return "identity-unavailable";
|
||||
let signalDisposition: Exclude<TargetTermination, "terminated"> | undefined;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (!alive(pid)) return true;
|
||||
if (attempt === 10 && (await commandOf(pid)) === executable)
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// It exited between the checks.
|
||||
if (!alive(pid)) return "terminated";
|
||||
if (attempt === 10) {
|
||||
if (identity === undefined) signalDisposition = "identity-unavailable";
|
||||
else {
|
||||
const result = await signalProcessWithStartIdentity(
|
||||
pid,
|
||||
identity,
|
||||
"SIGKILL",
|
||||
);
|
||||
if (result === "gone") return "terminated";
|
||||
if (result === "identity-changed")
|
||||
signalDisposition = "identity-changed";
|
||||
else if (result === "unverified")
|
||||
signalDisposition = "identity-unavailable";
|
||||
else if (result === "signaled")
|
||||
signalDisposition = "signal-did-not-stop";
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return !alive(pid);
|
||||
return !alive(pid)
|
||||
? "terminated"
|
||||
: (signalDisposition ?? "identity-unavailable");
|
||||
};
|
||||
|
||||
const targetTerminationReason = (termination: TargetTermination): string => {
|
||||
switch (termination) {
|
||||
case "terminated":
|
||||
return "target process terminated";
|
||||
case "identity-unavailable":
|
||||
return "target process start identity was unavailable; survivor was left untouched";
|
||||
case "identity-changed":
|
||||
return "target PID start identity changed; replacement process was left untouched";
|
||||
case "signal-did-not-stop":
|
||||
return "verified target did not exit after SIGKILL";
|
||||
}
|
||||
};
|
||||
|
||||
const alive = (pid: number): boolean => {
|
||||
@@ -385,16 +683,3 @@ const alive = (pid: number): boolean => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const commandOf = async (pid: number): Promise<string | undefined> => {
|
||||
try {
|
||||
const { stdout } = await execFileOutput(
|
||||
"/bin/ps",
|
||||
["-o", "comm=", "-p", String(pid)],
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js";
|
||||
import { observeNativeCalls } from "./NativeCallObservation.js";
|
||||
import { nativeMachoTarget } from "../../tests/fixtures/nativeCommands.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
class NeverStartedTracer implements NativeCallTracer {
|
||||
calls = 0;
|
||||
|
||||
trace(): Promise<Result<NativeCallTrace, AnalysisError>> {
|
||||
this.calls += 1;
|
||||
throw new Error("tracer must not run after hash cancellation");
|
||||
}
|
||||
}
|
||||
|
||||
describe("native call target digest cancellation", () => {
|
||||
it("returns tagged cancellation when hashing aborts after a chunk", async () => {
|
||||
const directory = await createTestTempDirectory("rea-native-digest-");
|
||||
const path = join(directory, "fixture");
|
||||
const bytes = "selected executable";
|
||||
await writeFile(path, bytes);
|
||||
const target = {
|
||||
...nativeMachoTarget(path),
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const signal = new AbortController();
|
||||
const tracer = new NeverStartedTracer();
|
||||
|
||||
const observed = await observeNativeCalls(
|
||||
target,
|
||||
{ breakpoints: [{ kind: "function", name: "main" }] } satisfies Record<
|
||||
string,
|
||||
JsonValue
|
||||
>,
|
||||
tracer,
|
||||
signal.signal,
|
||||
async () => {
|
||||
signal.abort();
|
||||
throw new DOMException("read aborted", "AbortError");
|
||||
},
|
||||
);
|
||||
|
||||
expect(!observed.ok && observed.error).toBeInstanceOf(
|
||||
AnalysisCancelledError,
|
||||
);
|
||||
expect(tracer.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
nativeCallObservationInputSchema,
|
||||
@@ -37,6 +38,7 @@ export const observeNativeCalls = async (
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
tracer: NativeCallTracer,
|
||||
signal?: AbortSignal,
|
||||
readTargetDigest: typeof fileSha256 = fileSha256,
|
||||
): Promise<Result<NativeCallObservationResult, AnalysisError>> => {
|
||||
if (target.kind !== "executable" || target.format !== "mach-o")
|
||||
return err(
|
||||
@@ -50,7 +52,22 @@ export const observeNativeCalls = async (
|
||||
if (!parsed.success)
|
||||
return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
|
||||
// Observe the bytes the session's other Evidence describes.
|
||||
if ((await fileSha256(target.path, signal)) !== target.sha256)
|
||||
let digest: string;
|
||||
try {
|
||||
digest = await readTargetDigest(target.path, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true || isAbortError(cause))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new ProviderAdapterError("native-macos", OPERATION, {
|
||||
diagnostics: {
|
||||
reason: "Unable to verify the selected target digest",
|
||||
detail: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (digest !== target.sha256)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Native call observation target digest changed after session binding",
|
||||
@@ -62,6 +79,7 @@ export const observeNativeCalls = async (
|
||||
{
|
||||
executable: target.path,
|
||||
architecture: target.architecture,
|
||||
expectedSha256: target.sha256,
|
||||
input: parsed.data,
|
||||
},
|
||||
signal,
|
||||
@@ -70,6 +88,9 @@ export const observeNativeCalls = async (
|
||||
return ok(projectNativeCalls(target, parsed.data, traced.value));
|
||||
};
|
||||
|
||||
const isAbortError = (cause: unknown): boolean =>
|
||||
cause instanceof Error && cause.name === "AbortError";
|
||||
|
||||
/** Join the request, the tracer's run and REA's own checks into the result contract. */
|
||||
export const projectNativeCalls = (
|
||||
target: Pick<BinaryTarget, "path" | "sha256"> & {
|
||||
@@ -83,15 +104,30 @@ export const projectNativeCalls = (
|
||||
.filter(({ location_count: count }) => count === 0)
|
||||
.map(({ index }) => index);
|
||||
const eventLimit = run.outcome === "event-limit";
|
||||
const resourceLimit = run.outcome === "resource-limit";
|
||||
const partial =
|
||||
eventLimit || run.outcome === "stop-limit" || unresolved.length > 0;
|
||||
eventLimit ||
|
||||
resourceLimit ||
|
||||
run.outcome === "duration-elapsed" ||
|
||||
run.outcome === "stop-limit" ||
|
||||
unresolved.length > 0;
|
||||
return nativeCallObservationResultSchema.parse({
|
||||
target: {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
architecture: target.architecture,
|
||||
arguments: input.arguments,
|
||||
environment: input.environment,
|
||||
working_directory: input.working_directory ?? null,
|
||||
launch_identity: {
|
||||
loaded_image_sha256: run.target_identity.loaded_image_sha256,
|
||||
file_device: run.target_identity.file_device,
|
||||
file_inode: run.target_identity.file_inode,
|
||||
selected_file_sha256: run.target_identity.selected_file_sha256,
|
||||
module_path: run.target_identity.module_path,
|
||||
module_uuid: run.target_identity.module_uuid,
|
||||
stable: run.target_identity.stable,
|
||||
},
|
||||
},
|
||||
debugger: trace.debugger,
|
||||
process: {
|
||||
@@ -118,20 +154,32 @@ export const projectNativeCalls = (
|
||||
coverage: {
|
||||
status: partial ? "partial" : "complete",
|
||||
event_limit_reached: eventLimit,
|
||||
resource_limit_reached: resourceLimit,
|
||||
unresolved_breakpoints: unresolved,
|
||||
},
|
||||
limitations: [
|
||||
...NATIVE_CALL_OBSERVATION_LIMITATIONS,
|
||||
"The selected pathname digest and LLDB module identity are checked around launch. No immutable executable snapshot is used; concurrent in-place mutation or replacement and restoration cannot be ruled out, so the loaded-image digest remains unknown.",
|
||||
...(eventLimit
|
||||
? [
|
||||
`Observation stopped at max_events (${input.max_events}); later calls were not recorded and the process was killed.`,
|
||||
]
|
||||
: []),
|
||||
...(resourceLimit
|
||||
? [
|
||||
"Observation stopped before retaining the next complete event because the estimated 8 MiB trace-payload or 65,536-frame aggregate budget was reached; the process was killed.",
|
||||
]
|
||||
: []),
|
||||
...(run.outcome === "duration-elapsed"
|
||||
? [
|
||||
`The process was still running after duration_ms (${input.duration_ms}) and was killed.`,
|
||||
]
|
||||
: []),
|
||||
...(run.target_identity.stable
|
||||
? []
|
||||
: [
|
||||
"LLDB did not provide enough module identity evidence to confirm pathname and module continuity around launch.",
|
||||
]),
|
||||
...(run.outcome === "stop-limit"
|
||||
? [
|
||||
"Observation stopped after repeated signal or exception stops; the process was killed.",
|
||||
@@ -152,6 +200,11 @@ export const projectNativeCalls = (
|
||||
...(trace.stdout.truncated || trace.stderr.truncated
|
||||
? ["Target output beyond 1 MiB per stream is counted but not kept."]
|
||||
: []),
|
||||
...(!trace.stdout.complete || !trace.stderr.complete
|
||||
? [
|
||||
"Target output draining did not complete; reported byte counts are observed lower bounds, not stream totals.",
|
||||
]
|
||||
: []),
|
||||
...(run.terminated && trace.terminated
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -367,6 +367,49 @@ export const createSystemProcessOwnershipHost = (
|
||||
|
||||
export const systemProcessOwnershipHost = createSystemProcessOwnershipHost();
|
||||
|
||||
/** Observe the current OS start identity for one live PID, when supported. */
|
||||
export const observeProcessStartIdentity = async (
|
||||
pid: number,
|
||||
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
||||
): Promise<ProcessIdentityObservation | undefined> => {
|
||||
const process = (await host.listProcesses()).find(
|
||||
(entry) => entry.pid === pid,
|
||||
);
|
||||
if (process === undefined || liveProcesses([process]).length === 0)
|
||||
return undefined;
|
||||
if (host.processIdentities === undefined)
|
||||
return { state: "unavailable", reason: "process identity is unsupported" };
|
||||
return (
|
||||
(await host.processIdentities([process])).get(pid) ?? {
|
||||
state: "unavailable",
|
||||
reason: "process identity was not returned",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/** Revalidate a launch-time start identity immediately before signaling a PID. */
|
||||
export const signalProcessWithStartIdentity = async (
|
||||
pid: number,
|
||||
expectedIdentity: string,
|
||||
signal: NodeJS.Signals,
|
||||
options: {
|
||||
readonly host?: ProcessOwnershipHost;
|
||||
readonly sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
} = {},
|
||||
): Promise<"signaled" | "gone" | "identity-changed" | "unverified"> => {
|
||||
const host = options.host ?? systemProcessOwnershipHost;
|
||||
try {
|
||||
const observed = await observeProcessStartIdentity(pid, host);
|
||||
if (observed === undefined) return "gone";
|
||||
if (observed.state !== "readable") return "unverified";
|
||||
if (observed.identity !== expectedIdentity) return "identity-changed";
|
||||
(options.sendSignal ?? process.kill)(pid, signal);
|
||||
return "signaled";
|
||||
} catch {
|
||||
return "unverified";
|
||||
}
|
||||
};
|
||||
|
||||
/** Prepare native token inspection before REA launches a captured child. */
|
||||
export const prepareProcessOwnershipInspection = async (
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
|
||||
import {
|
||||
observeProcessStartIdentity,
|
||||
signalProcessWithStartIdentity,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
|
||||
const host = (identity: string | undefined): ProcessOwnershipHost => ({
|
||||
platform: "linux",
|
||||
listProcesses: async () =>
|
||||
identity === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
pid: 42,
|
||||
parentPid: 1,
|
||||
processGroupId: 42,
|
||||
uid: 1000,
|
||||
state: "S",
|
||||
command: "/bin/fixture",
|
||||
},
|
||||
],
|
||||
environment: async () => ({}),
|
||||
processIdentities: async () =>
|
||||
new Map([[42, { state: "readable", identity: identity ?? "" }]]),
|
||||
signalGroup: () => undefined,
|
||||
});
|
||||
|
||||
describe("process start identity lease", () => {
|
||||
it("captures a readable identity for a live PID", async () => {
|
||||
await expect(
|
||||
observeProcessStartIdentity(42, host("start-1")),
|
||||
).resolves.toEqual({
|
||||
state: "readable",
|
||||
identity: "start-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("signals the PID when its launch identity still matches", async () => {
|
||||
const sendSignal = vi.fn();
|
||||
await expect(
|
||||
signalProcessWithStartIdentity(42, "start-1", "SIGKILL", {
|
||||
host: host("start-1"),
|
||||
sendSignal,
|
||||
}),
|
||||
).resolves.toBe("signaled");
|
||||
expect(sendSignal).toHaveBeenCalledWith(42, "SIGKILL");
|
||||
});
|
||||
|
||||
it("does not signal a reused PID even when its command is unchanged", async () => {
|
||||
const sendSignal = vi.fn();
|
||||
await expect(
|
||||
signalProcessWithStartIdentity(42, "start-1", "SIGKILL", {
|
||||
host: host("start-2"),
|
||||
sendSignal,
|
||||
}),
|
||||
).resolves.toBe("identity-changed");
|
||||
expect(sendSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not signal when the PID identity is unavailable", async () => {
|
||||
const unavailable: ProcessOwnershipHost = {
|
||||
...host("start-1"),
|
||||
processIdentities: async () =>
|
||||
new Map([[42, { state: "unavailable", reason: "reader unavailable" }]]),
|
||||
};
|
||||
const sendSignal = vi.fn();
|
||||
await expect(
|
||||
signalProcessWithStartIdentity(42, "start-1", "SIGKILL", {
|
||||
host: unavailable,
|
||||
sendSignal,
|
||||
}),
|
||||
).resolves.toBe("unverified");
|
||||
expect(sendSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an already-gone PID without signaling", async () => {
|
||||
const sendSignal = vi.fn();
|
||||
await expect(
|
||||
signalProcessWithStartIdentity(42, "start-1", "SIGKILL", {
|
||||
host: host(undefined),
|
||||
sendSignal,
|
||||
}),
|
||||
).resolves.toBe("gone");
|
||||
expect(sendSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,15 @@ const tracer: NativeCallTracer & { calls: number } = {
|
||||
exit_description: null,
|
||||
killed: false,
|
||||
terminated: true,
|
||||
target_identity: {
|
||||
loaded_image_sha256: null,
|
||||
selected_file_sha256: request.expectedSha256,
|
||||
file_device: "1",
|
||||
file_inode: "2",
|
||||
module_path: request.executable,
|
||||
module_uuid: "fixture-uuid",
|
||||
stable: true,
|
||||
},
|
||||
elapsed_ms: 1,
|
||||
breakpoints: request.input.breakpoints.map((_, index) => ({
|
||||
index,
|
||||
@@ -50,10 +59,19 @@ const tracer: NativeCallTracer & { calls: number } = {
|
||||
locations: [],
|
||||
})),
|
||||
events: [],
|
||||
resource_limit_reached: false,
|
||||
target_output: {
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
stdout_complete: true,
|
||||
stderr_complete: true,
|
||||
},
|
||||
other_stops: [],
|
||||
},
|
||||
stdout: { text: "", bytes: 0, truncated: false },
|
||||
stderr: { text: "", bytes: 0, truncated: false },
|
||||
stdout: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
stderr: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
terminated: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -221,7 +221,8 @@ describe("HopperClient cleanup", () => {
|
||||
const client = new HopperClient({
|
||||
launcher,
|
||||
runId,
|
||||
startupTimeoutMs: 1_000,
|
||||
// Cold Darwin ownership-reader compilation runs inside launcher startup.
|
||||
startupTimeoutMs: 10_000,
|
||||
onDiagnostic: (event) => diagnostics.push(event),
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
@@ -39,10 +39,28 @@ const run = (
|
||||
version: "lldb-2100.0.17.203",
|
||||
pid: 4242,
|
||||
outcome: "exited",
|
||||
resource_limit_reached: false,
|
||||
target_output: {
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
stdout_complete: true,
|
||||
stderr_complete: true,
|
||||
},
|
||||
exit_status: 0,
|
||||
exit_description: null,
|
||||
killed: false,
|
||||
terminated: true,
|
||||
target_identity: {
|
||||
loaded_image_sha256: null,
|
||||
selected_file_sha256: "a".repeat(64),
|
||||
file_device: "1",
|
||||
file_inode: "2",
|
||||
module_path: "/tmp/Fixture",
|
||||
module_uuid: "fixture-uuid",
|
||||
stable: true,
|
||||
},
|
||||
elapsed_ms: 12.5,
|
||||
breakpoints: [
|
||||
{ index: 0, location_count: 1, locations: [LOCATION] },
|
||||
@@ -80,8 +98,8 @@ class FixtureTracer implements NativeCallTracer {
|
||||
version: "lldb-2100.0.17.203",
|
||||
},
|
||||
run: run(),
|
||||
stdout: { text: "hello\n", bytes: 6, truncated: false },
|
||||
stderr: { text: "", bytes: 0, truncated: false },
|
||||
stdout: { text: "hello\n", bytes: 6, truncated: false, complete: true },
|
||||
stderr: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
terminated: true,
|
||||
}),
|
||||
) {}
|
||||
@@ -122,13 +140,17 @@ const observe = async (
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
|
||||
describe("observe_native_calls projection", () => {
|
||||
describe("observe_native_calls projection: projects completed observations", () => {
|
||||
it("launches the active Mach-O through the tracer and projects its run", async () => {
|
||||
const tracer = new FixtureTracer();
|
||||
const target = await fixtureTarget();
|
||||
const observed = await observe(
|
||||
tracer,
|
||||
{ breakpoints: BREAKPOINTS, arguments: ["--flag"] },
|
||||
{
|
||||
breakpoints: BREAKPOINTS,
|
||||
arguments: ["--flag"],
|
||||
environment: { REA_CASE: "selected" },
|
||||
},
|
||||
target,
|
||||
);
|
||||
if (!observed.ok) throw observed.error;
|
||||
@@ -137,6 +159,7 @@ describe("observe_native_calls projection", () => {
|
||||
architecture: "arm64",
|
||||
input: {
|
||||
arguments: ["--flag"],
|
||||
environment: { REA_CASE: "selected" },
|
||||
duration_ms: 10_000,
|
||||
max_events: 1_000,
|
||||
argument_registers: 4,
|
||||
@@ -146,6 +169,7 @@ describe("observe_native_calls projection", () => {
|
||||
const result = nativeCallObservationResultSchema.parse(
|
||||
observed.value.result,
|
||||
);
|
||||
expect(result.target.environment).toEqual({ REA_CASE: "selected" });
|
||||
expect(result.breakpoints.map(({ request }) => request)).toEqual([
|
||||
{
|
||||
kind: "objc-method",
|
||||
@@ -162,6 +186,15 @@ describe("observe_native_calls projection", () => {
|
||||
terminated: true,
|
||||
stdout: { text: "hello\n" },
|
||||
});
|
||||
expect(result.target.launch_identity).toEqual({
|
||||
file_device: "1",
|
||||
file_inode: "2",
|
||||
loaded_image_sha256: null,
|
||||
selected_file_sha256: "a".repeat(64),
|
||||
module_path: "/tmp/Fixture",
|
||||
module_uuid: "fixture-uuid",
|
||||
stable: true,
|
||||
});
|
||||
expect(result.events[0]).toMatchObject({
|
||||
receiver_class: "Greeter",
|
||||
selector: "greet:times:",
|
||||
@@ -169,13 +202,16 @@ describe("observe_native_calls projection", () => {
|
||||
expect(result.coverage).toEqual({
|
||||
status: "complete",
|
||||
event_limit_reached: false,
|
||||
resource_limit_reached: false,
|
||||
unresolved_breakpoints: [],
|
||||
});
|
||||
expect(observed.value.locations).toEqual([
|
||||
{ kind: "artifact-path", path: target.path },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("observe_native_calls projection: reports partial observation coverage", () => {
|
||||
it("marks event limits, unresolved breakpoints and unconfirmed exits", async () => {
|
||||
const tracer = new FixtureTracer(
|
||||
ok({
|
||||
@@ -194,8 +230,13 @@ describe("observe_native_calls projection", () => {
|
||||
{ index: 1, location_count: 0, locations: [] },
|
||||
],
|
||||
}),
|
||||
stdout: { text: "", bytes: 0, truncated: false },
|
||||
stderr: { text: "x".repeat(16), bytes: 2_000_000, truncated: true },
|
||||
stdout: { text: "", bytes: 0, truncated: false, complete: false },
|
||||
stderr: {
|
||||
text: "x".repeat(16),
|
||||
bytes: 2_000_000,
|
||||
truncated: true,
|
||||
complete: true,
|
||||
},
|
||||
terminated: true,
|
||||
}),
|
||||
);
|
||||
@@ -210,9 +251,13 @@ describe("observe_native_calls projection", () => {
|
||||
expect(result.coverage).toEqual({
|
||||
status: "partial",
|
||||
event_limit_reached: true,
|
||||
resource_limit_reached: false,
|
||||
unresolved_breakpoints: [1],
|
||||
});
|
||||
expect(result.process.terminated).toBe(false);
|
||||
expect(result.limitations).toContain(
|
||||
"Target output draining did not complete; reported byte counts are observed lower bounds, not stream totals.",
|
||||
);
|
||||
expect(result.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Observation stopped at max_events (1); later calls were not recorded and the process was killed.",
|
||||
@@ -222,6 +267,81 @@ describe("observe_native_calls projection", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks duration and aggregate resource limits as partial coverage", async () => {
|
||||
for (const outcome of ["duration-elapsed", "resource-limit"] as const) {
|
||||
const tracer = new FixtureTracer(
|
||||
ok({
|
||||
debugger: {
|
||||
path: "/usr/bin/lldb",
|
||||
sha256: "a".repeat(64),
|
||||
version: null,
|
||||
},
|
||||
run: run({
|
||||
outcome,
|
||||
resource_limit_reached: outcome === "resource-limit",
|
||||
}),
|
||||
stdout: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
stderr: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
terminated: true,
|
||||
}),
|
||||
);
|
||||
const observed = await observe(tracer, { breakpoints: BREAKPOINTS });
|
||||
if (!observed.ok) throw observed.error;
|
||||
const result = nativeCallObservationResultSchema.parse(
|
||||
observed.value.result,
|
||||
);
|
||||
expect(result.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
resource_limit_reached: outcome === "resource-limit",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("observe_native_calls projection: preserves unknown launch identity", () => {
|
||||
it("preserves unknown launch identity without claiming digest binding", async () => {
|
||||
const tracer = new FixtureTracer(
|
||||
ok({
|
||||
debugger: {
|
||||
path: "/usr/bin/lldb",
|
||||
sha256: "a".repeat(64),
|
||||
version: null,
|
||||
},
|
||||
run: run({
|
||||
target_identity: {
|
||||
loaded_image_sha256: null,
|
||||
selected_file_sha256: null,
|
||||
file_device: null,
|
||||
file_inode: null,
|
||||
module_path: null,
|
||||
module_uuid: null,
|
||||
stable: false,
|
||||
},
|
||||
}),
|
||||
stdout: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
stderr: { text: "", bytes: 0, truncated: false, complete: true },
|
||||
terminated: true,
|
||||
}),
|
||||
);
|
||||
const observed = await observe(tracer, { breakpoints: BREAKPOINTS });
|
||||
if (!observed.ok) throw observed.error;
|
||||
const result = nativeCallObservationResultSchema.parse(
|
||||
observed.value.result,
|
||||
);
|
||||
expect(result.target.launch_identity).toEqual({
|
||||
file_device: null,
|
||||
file_inode: null,
|
||||
loaded_image_sha256: null,
|
||||
selected_file_sha256: null,
|
||||
module_path: null,
|
||||
module_uuid: null,
|
||||
stable: false,
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"LLDB did not provide enough module identity evidence to confirm pathname and module continuity around launch.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("observe_native_calls failures", () => {
|
||||
|
||||
@@ -17,8 +17,26 @@ __attribute__((noinline)) int rea_call_add(int left, int right) {
|
||||
return left + right;
|
||||
}
|
||||
|
||||
static void write_fill(int descriptor, char value) {
|
||||
char chunk[8192];
|
||||
memset(chunk, value, sizeof(chunk));
|
||||
size_t remaining = 2 * 1024 * 1024;
|
||||
while (remaining > 0) {
|
||||
size_t wanted = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
|
||||
ssize_t written = write(descriptor, chunk, wanted);
|
||||
if (written <= 0)
|
||||
return;
|
||||
remaining -= (size_t)written;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
if (argc > 1 && strcmp(argv[1], "--flood") == 0) {
|
||||
write_fill(STDOUT_FILENO, 'O');
|
||||
write_fill(STDERR_FILENO, 'E');
|
||||
return 0;
|
||||
}
|
||||
if (argc > 1 && strcmp(argv[1], "--wait") == 0)
|
||||
for (;;) sleep(1);
|
||||
ReaCallGreeter *greeter = [ReaCallGreeter new];
|
||||
|
||||
Reference in New Issue
Block a user