fix(native): supervise LLDB and preserve bounded capture uncertainty

This commit is contained in:
morluto
2026-10-09 00:20:50 +08:00
parent 488f448ae2
commit 915855e75c
20 changed files with 1690 additions and 237 deletions
+482 -72
View File
@@ -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):
+167
View File
@@ -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()
+18 -4
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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);
+71 -19
View File
@@ -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);
+13 -2
View File
@@ -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(
+45 -10
View File
@@ -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()),
}),
+5 -2
View File
@@ -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
View File
@@ -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;
}
};
+57
View File
@@ -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);
});
});
+55 -2
View File
@@ -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,
+88
View File
@@ -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", () => {
+18
View File
@@ -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];