Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c99912e8d | ||
|
|
b3a60b6b03 | ||
|
|
d5b5441a3a | ||
|
|
4f83c64da8 | ||
|
|
780191226c | ||
|
|
1738989590 | ||
|
|
3f18439acd | ||
|
|
dd401a2804 | ||
|
|
2bc898fae2 | ||
|
|
b2e743ef24 | ||
|
|
51b3418f2b | ||
|
|
3bb09b8d2d | ||
|
|
3deda4781d | ||
|
|
2ac4795df5 | ||
|
|
a588dc6376 |
@@ -244,20 +244,6 @@ def _hex(value):
|
||||
return "0x%x" % value
|
||||
|
||||
|
||||
def _source_location(address):
|
||||
"""Preserve available line-entry facets without acquiring source files."""
|
||||
entry = address.GetLineEntry()
|
||||
if not entry.IsValid():
|
||||
return None
|
||||
file_spec = entry.GetFileSpec()
|
||||
file_path = str(file_spec) if file_spec.IsValid() else None
|
||||
line = entry.GetLine() or None
|
||||
column = entry.GetColumn() or None
|
||||
if not file_path and line is None and column is None:
|
||||
return None
|
||||
return {"file_path": file_path or None, "line": line, "column": column}
|
||||
|
||||
|
||||
def _location(address, target):
|
||||
"""Load and file address, module and symbol of one code address."""
|
||||
module = address.GetModule()
|
||||
@@ -271,7 +257,6 @@ def _location(address, target):
|
||||
"module": module.GetFileSpec().GetFilename() if module.IsValid() else None,
|
||||
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
|
||||
"symbol": symbol.GetName() if symbol.IsValid() else None,
|
||||
"source_location": _source_location(address),
|
||||
}
|
||||
return location
|
||||
|
||||
|
||||
@@ -22,45 +22,6 @@ spec.loader.exec_module(tracer)
|
||||
|
||||
|
||||
class NativeTraceBudgetTests(unittest.TestCase):
|
||||
def test_source_coordinates_preserve_partial_metadata_without_reading_source(self):
|
||||
def address(valid=True, file_valid=True, line=23, column=7):
|
||||
class SourceFileSpec:
|
||||
def IsValid(self):
|
||||
return file_valid
|
||||
|
||||
def __str__(self):
|
||||
return "/missing/é space/fixture.pas"
|
||||
|
||||
file_spec = SourceFileSpec()
|
||||
entry = types.SimpleNamespace(
|
||||
IsValid=lambda: valid,
|
||||
GetFileSpec=lambda: file_spec,
|
||||
GetLine=lambda: line,
|
||||
GetColumn=lambda: column,
|
||||
)
|
||||
return types.SimpleNamespace(GetLineEntry=lambda: entry)
|
||||
|
||||
self.assertEqual(
|
||||
tracer._source_location(address()),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": 7},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(column=0)),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": None},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(file_valid=False)),
|
||||
{"file_path": None, "line": 23, "column": 7},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(line=0)),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": None, "column": 7},
|
||||
)
|
||||
self.assertIsNone(tracer._source_location(address(valid=False)))
|
||||
self.assertIsNone(
|
||||
tracer._source_location(address(file_valid=False, line=0, column=0))
|
||||
)
|
||||
|
||||
def test_journal_checks_exact_byte_budget_before_writing(self):
|
||||
record = {"kind": "other-stop", "reason": "signal"}
|
||||
encoded_size = len((tracer.json.dumps(record) + "\n").encode("utf-8"))
|
||||
@@ -193,9 +154,6 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
return FileSpec()
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
@@ -269,24 +227,7 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
def GetFileSpec(self):
|
||||
return FileSpec()
|
||||
|
||||
class SourceFileSpec:
|
||||
def IsValid(self):
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return source_path
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
if source_path is None:
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
return types.SimpleNamespace(
|
||||
IsValid=lambda: True,
|
||||
GetFileSpec=SourceFileSpec,
|
||||
GetLine=lambda: 15,
|
||||
GetColumn=lambda: 3,
|
||||
)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
@@ -313,35 +254,19 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
breakpoint_count = 1000
|
||||
created = [[Breakpoint()] for _ in range(breakpoint_count)]
|
||||
config = {"breakpoints": [{"kind": "objc-method"}] * breakpoint_count}
|
||||
for source_path in (None, "/tmp/" + "源🚀" * 3000 + "/calls.pas"):
|
||||
with self.subTest(source_path_present=source_path is not None):
|
||||
report, locations_truncated = tracer._breakpoint_report(
|
||||
object(), created, config
|
||||
)
|
||||
retained_bytes = sum(
|
||||
len(json.dumps(location).encode("utf-8"))
|
||||
+ (1 if index > 0 else 0)
|
||||
for row in report
|
||||
for index, location in enumerate(row["locations"])
|
||||
)
|
||||
self.assertTrue(locations_truncated)
|
||||
self.assertEqual(
|
||||
sum(row["location_count"] for row in report), 64_000
|
||||
)
|
||||
self.assertLess(
|
||||
sum(len(row["locations"]) for row in report), 64_000
|
||||
)
|
||||
self.assertLessEqual(
|
||||
retained_bytes, tracer.MAX_BREAKPOINT_LOCATION_BYTES
|
||||
)
|
||||
if source_path is not None:
|
||||
self.assertTrue(
|
||||
any(
|
||||
location["source_location"]["file_path"] == source_path
|
||||
for row in report
|
||||
for location in row["locations"]
|
||||
)
|
||||
)
|
||||
report, locations_truncated = tracer._breakpoint_report(
|
||||
object(), created, config
|
||||
)
|
||||
retained_bytes = sum(
|
||||
len(json.dumps(location).encode("utf-8"))
|
||||
+ (1 if index > 0 else 0)
|
||||
for row in report
|
||||
for index, location in enumerate(row["locations"])
|
||||
)
|
||||
self.assertTrue(locations_truncated)
|
||||
self.assertEqual(sum(row["location_count"] for row in report), 64_000)
|
||||
self.assertLess(sum(len(row["locations"]) for row in report), 64_000)
|
||||
self.assertLessEqual(retained_bytes, tracer.MAX_BREAKPOINT_LOCATION_BYTES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -426,25 +426,6 @@ 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.
|
||||
|
||||
Resolved breakpoint locations, entries and caller frames also retain
|
||||
`source_location` from LLDB's line tables when available. Its `file_path`,
|
||||
one-based `line` and one-based `column` are independently nullable. An invalid
|
||||
line entry is null; historical captures may omit the field. This is debug metadata
|
||||
attribution: REA does not read that source path or verify the source file bytes.
|
||||
Absent coordinates do not imply that the executable has no source code.
|
||||
|
||||
Free Pascal programs with retained native symbols and usable DWARF can use this
|
||||
same bounded call observation on macOS. Select an exact emitted symbol from the
|
||||
binary rather than assuming a source procedure name. The owned Pascal fixture
|
||||
uses an explicit C calling convention and a public symbol; its verification
|
||||
covers call entries, raw integer registers and source coordinates, without
|
||||
establishing typed Pascal locals or interactive stepping. After building REA,
|
||||
run the real lane with an existing caller-selected compiler:
|
||||
|
||||
```sh
|
||||
REA_VERIFY_FPC_COMMAND=/absolute/path/to/fpc npm run verify:native:pascal-calls
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Implementation plan
|
||||
|
||||
## Boundary and contract
|
||||
|
||||
Extend nativeCodeLocationSchema in src/domain/native/nativeCallObservation.ts.
|
||||
Reuse that shape for events, breakpoint locations, backtraces and retained journal
|
||||
events. Keep the field optional for older evidence and nullable for current LLDB
|
||||
observations with unavailable metadata. Each inner facet can be independently
|
||||
unavailable; avoid dropping a valid line number because a file path is absent.
|
||||
|
||||
Read SBAddress.GetLineEntry once in the existing Python _location producer. A
|
||||
valid SBFileSpec supplies its displayed path without filesystem access. Convert
|
||||
zero line/column to null. An invalid entry returns null. Existing estimation and
|
||||
serialization budget checks already traverse the location object recursively.
|
||||
|
||||
## Incremental work and validation
|
||||
|
||||
1. Define contracts and criteria before code; add failing producer tests.
|
||||
2. Extend the canonical schema and _location producer. Update existing fake
|
||||
address objects to implement the actual LLDB line-entry interface.
|
||||
3. Extend provider/MCP boundary coverage and retained-journal coverage for the
|
||||
additive field; preserve omission compatibility.
|
||||
4. Extend the existing real macOS verifier with source-coordinate assertions
|
||||
against its own Objective-C/C fixture built with debug information.
|
||||
5. Update the native investigation guide; run Python tests, focused TypeScript
|
||||
tests, repository checks and the real verifier if host authority permits.
|
||||
|
||||
## Effects and compatibility
|
||||
|
||||
The existing LLDB operation already owns its breakpoint callbacks and process
|
||||
lifecycle. This adds a metadata read within that observation. It does not introduce
|
||||
target execution or a new provider beyond existing behavior. No source file is
|
||||
acquired from a path in debug information. Consumers of historical evidence retain
|
||||
omission; consumers of new captures see either a partial/full object or null.
|
||||
|
||||
## References
|
||||
|
||||
- https://lldb.llvm.org/python_api/lldb.SBAddress.html
|
||||
- https://lldb.llvm.org/python_api/lldb.SBLineEntry.html
|
||||
@@ -1,36 +0,0 @@
|
||||
# Native call source coordinates
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/2382
|
||||
|
||||
## Problem and outcome
|
||||
|
||||
The existing bounded macOS LLDB call observation retains module, symbol and
|
||||
addresses, but loses compiler-provided source coordinates. Retain line-entry
|
||||
metadata at the same producer boundary for breakpoint locations, call entries
|
||||
and caller frames.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add optional nullable source_location to the canonical native code location.
|
||||
A present object contains nullable file_path, line and column. Positive line
|
||||
and column values are one-based. Provider zero values mean unavailable.
|
||||
- Preserve partial facts from a valid line entry. Invalid entries or entries
|
||||
without any usable facet produce null. Historical captures may omit the field.
|
||||
- Read only SBAddress line metadata; do not resolve paths, read source contents,
|
||||
evaluate expressions or change the existing launch, stop or cleanup policy.
|
||||
- The field establishes LLDB line-table attribution, not verified source bytes.
|
||||
- Existing trace and breakpoint byte budgets include the new representation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Producer tests cover valid, invalid and partial entries, Unicode paths and absent
|
||||
coordinates. Existing evidence without the field remains accepted. CLI/MCP and
|
||||
partial retained-journal paths preserve the canonical field. The real native-call
|
||||
verifier checks source coordinates for its owned debug build, including caller
|
||||
frames and breakpoint locations, and reports actual host/provider coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New debugger adapters, interactive stepping, source breakpoints, source-file
|
||||
association verification, source reads, Pascal/Delphi compatibility claims and
|
||||
Java/Android/CLR runtime support.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
The checked items below record work completed on an earlier revision. The
|
||||
current revision's final caller-frame assertions, marker-derived line
|
||||
expectations, timeout handling, and Unicode budget case remain source-reviewed
|
||||
only; the full native-call verifier is incomplete.
|
||||
|
||||
- [x] Define the single-feature scope, contract and implementation plan.
|
||||
- [x] Reproduce lost source coordinates with producer regression tests.
|
||||
- [x] Retain line-entry metadata in the producer and canonical schema.
|
||||
- [x] Verify boundary/MCP propagation and historical omission.
|
||||
- [x] Historical: verify real CLI/MCP call coordinates on an owned Free Pascal 3.2.2 program.
|
||||
- [x] Historical: update documentation; pass Python, focused TypeScript, static and discovery checks.
|
||||
- [ ] Complete the full native-call verifier after investigating #2397.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Historical verification
|
||||
|
||||
The records below describe earlier revisions, before the final caller-frame
|
||||
assertions, marker-derived line expectations, forced compiler timeout termination,
|
||||
and large Unicode source-location budget coverage. They do not validate the
|
||||
current revision. Those final changes were reviewed from source only; tests and
|
||||
automated checks were not run at the user’s explicit request.
|
||||
|
||||
Host: macOS ARM64. Compiler: caller-installed Free Pascal 3.2.2, Darwin AArch64.
|
||||
Debugger: LLDB 2103.0.34.103 from Command Line Tools.
|
||||
|
||||
- Python producer/budget/output tests: 10 passed.
|
||||
- Focused native provider, retained-journal and MCP tests: 18 passed.
|
||||
- Real Pascal CLI and stdio MCP: three expected calls, integer register values
|
||||
0/1/2, source path and positive line attribution, successful target exit.
|
||||
- Repository static checks: six tasks and architecture guards passed.
|
||||
- Test discovery: all 982 test files discovered exactly once.
|
||||
|
||||
The complete native-call verifier did not pass. One run reported exited instead
|
||||
of event-limit for capped overlapping breakpoints. Another reported unavailable
|
||||
launch start identity in a later capped scenario. Investigation:
|
||||
https://github.com/morluto/rea/issues/2397. These results have not yet been isolated
|
||||
on unchanged upstream. The source-coordinate PR was draft at that historical checkpoint.
|
||||
|
||||
The first real native scenario and CLI/MCP source-coordinate comparisons completed
|
||||
before those broader failures. The Pascal lane verifies bounded native call
|
||||
observation; it does not establish typed Pascal local values, source breakpoints,
|
||||
interactive stepping, Delphi compatibility or a GDB provider.
|
||||
|
||||
## Unmeasured costs and outstanding execution
|
||||
|
||||
Repeated source paths expand event, caller-frame and breakpoint-location payloads.
|
||||
Equivalent before/after completed workflows have not been measured for agent tokens,
|
||||
elapsed time, CPU time or peak process-family memory, for either cold or warm runs.
|
||||
No byte count is presented as a token or performance measurement. Representative
|
||||
measurements remain outstanding before integration; they were not executed under
|
||||
the current instruction to skip tests and provider workloads.
|
||||
|
||||
The current caller-frame equality checks, marker-derived source lines, forced
|
||||
compiler timeout termination and Unicode source-path budget case remain unverified
|
||||
by execution. Earlier Pascal CLI/MCP observations establish only the historical
|
||||
coverage described above.
|
||||
@@ -246,7 +246,6 @@
|
||||
"prepare": "node scripts/prepare.mjs",
|
||||
"verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs",
|
||||
"verify:native-calls": "npm run build:cached && node scripts/verify-native-calls.mjs",
|
||||
"verify:native:pascal-calls": "npm run build:cached && node scripts/verify-native-pascal-calls.mjs",
|
||||
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial",
|
||||
"test:focused": "node scripts/run-development-tests.mjs focused",
|
||||
"test:sqlite": "npm run test:prepare && npm run test:sqlite:run",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Allocate under a caller-selected parent and return the host's canonical spelling. */
|
||||
export const createCanonicalTemporaryDirectory = async (
|
||||
prefix,
|
||||
parent = tmpdir(),
|
||||
) => {
|
||||
const allocatedPath = await mkdtemp(join(parent, prefix));
|
||||
try {
|
||||
return { path: await realpath(allocatedPath), allocatedPath };
|
||||
} catch (cause) {
|
||||
await rm(allocatedPath, { recursive: true, force: true });
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,36 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
// CPython's interface option ends interpreter parsing; following values are
|
||||
// script arguments. -W/-X consume either an attached value or the next operand.
|
||||
// https://docs.python.org/3.12/using/cmdline.html
|
||||
const pythonScriptOperand = (arguments_) => {
|
||||
for (let index = 1; index < arguments_.length; index++) {
|
||||
const option = arguments_[index];
|
||||
if (option === "--") return arguments_[index + 1];
|
||||
if (option === "-" || option === "") return undefined;
|
||||
if (!option.startsWith("-")) return option;
|
||||
if (option === "--check-hash-based-pycs") {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (/^--(?:help(?:-env|-xoptions|-all)?|version)$/u.test(option))
|
||||
return undefined;
|
||||
if (option.startsWith("--"))
|
||||
throw new Error(`Unknown Python helper invocation option: ${option}`);
|
||||
for (let character = 1; character < option.length; character++) {
|
||||
const flag = option[character];
|
||||
if ("cmh?V".includes(flag)) return undefined;
|
||||
if (flag === "W" || flag === "X") {
|
||||
if (character === option.length - 1) index++;
|
||||
break;
|
||||
}
|
||||
if (!"bBdEiIOPqRsSuvx".includes(flag))
|
||||
throw new Error(`Unknown Python helper invocation option: ${option}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Observe leftover demo processes on a dedicated clean Linux runner, including
|
||||
@@ -18,9 +46,6 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
],
|
||||
procRoot = "/proc",
|
||||
) => {
|
||||
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
const launchers = [];
|
||||
for (const path of launcherPaths) {
|
||||
try {
|
||||
@@ -29,20 +54,55 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (launchers.length === 0) return;
|
||||
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
||||
// Compare file identity, not a display path or a process name.
|
||||
let inspectionError;
|
||||
for (const entry of await readdir(procRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
||||
let executable;
|
||||
let retainedRuntime;
|
||||
try {
|
||||
executable = await stat(`${procRoot}/${entry.name}/exe`, {
|
||||
bigint: true,
|
||||
});
|
||||
// procfs preserves argument boundaries. A caller merely mentioning a
|
||||
// runtime in its script text or other arguments is not that runtime.
|
||||
const commandLine = await readFile(
|
||||
`${procRoot}/${entry.name}/cmdline`,
|
||||
"utf8",
|
||||
);
|
||||
const arguments_ = commandLine.split("\0");
|
||||
// The kernel command field identifies renamed runtime processes such
|
||||
// as Xvfb. Keep it separate from arguments so ordinary script text does
|
||||
// not count as a retained runtime.
|
||||
const kernelCommand = (
|
||||
await readFile(`${procRoot}/${entry.name}/comm`, "utf8")
|
||||
).replace(/\n$/u, "");
|
||||
if (
|
||||
commandLine.length === 0 &&
|
||||
/^python3(?:\.\d+)?$/u.test(kernelCommand)
|
||||
)
|
||||
throw new Error(
|
||||
"Cannot identify a command-less Python process on the clean Linux runner",
|
||||
);
|
||||
retainedRuntime =
|
||||
kernelCommand === "Hopper" ||
|
||||
kernelCommand === "Xvfb" ||
|
||||
arguments_[0] === "/opt/hopper/bin/Hopper" ||
|
||||
basename(arguments_[0] ?? "") === "Xvfb" ||
|
||||
(/^python3(?:\.\d+)?$/u.test(basename(arguments_[0] ?? "")) &&
|
||||
arguments_.some(
|
||||
(value) => basename(value) === "hopper-demo-x11.py",
|
||||
) &&
|
||||
basename(pythonScriptOperand(arguments_) ?? "") ===
|
||||
"hopper-demo-x11.py");
|
||||
if (!retainedRuntime && launchers.length > 0) {
|
||||
const executable = await stat(`${procRoot}/${entry.name}/exe`, {
|
||||
bigint: true,
|
||||
});
|
||||
retainedRuntime = launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||
// Other users' executables may be unreadable. The setup lane launches
|
||||
// Other users' process facts may be unreadable. The setup lane launches
|
||||
// providers as the caller; unreadable caller-owned processes fail closed.
|
||||
if (error?.code === "EACCES") {
|
||||
const owner = await stat(`${procRoot}/${entry.name}`).catch((cause) => {
|
||||
@@ -56,11 +116,7 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
inspectionError ??= error;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
)
|
||||
)
|
||||
if (retainedRuntime)
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
}
|
||||
if (inspectionError !== undefined) throw inspectionError;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
type VerifierCall = (
|
||||
name: string,
|
||||
arguments_?: Record<string, unknown>,
|
||||
@@ -25,6 +29,3 @@ export function verifyHopperStringObjects(
|
||||
}[];
|
||||
readonly displayEscapesExcludedFromMatching: true;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
@@ -60,10 +60,6 @@ const stable = (result) =>
|
||||
file_address: event.file_address,
|
||||
selector: event.selector,
|
||||
receiver_class: event.receiver_class,
|
||||
source_location: event.source_location,
|
||||
caller_source_locations: event.backtrace.map(
|
||||
(frame) => frame.source_location,
|
||||
),
|
||||
counter:
|
||||
event.symbol === GREET
|
||||
? event.registers[3].value
|
||||
@@ -99,18 +95,6 @@ const checkScenario = async (binary) => {
|
||||
});
|
||||
const greet = result.events.filter(({ symbol }) => symbol === GREET);
|
||||
const add = result.events.filter(({ symbol }) => symbol === "rea_call_add");
|
||||
for (const location of [
|
||||
...result.events,
|
||||
...result.events.flatMap((event) => event.backtrace),
|
||||
...result.breakpoints.flatMap((breakpoint) => breakpoint.locations),
|
||||
]) {
|
||||
assert.equal(location.source_location?.file_path, SOURCE);
|
||||
assert.ok(location.source_location.line > 0);
|
||||
assert.ok(
|
||||
location.source_location.column === null ||
|
||||
location.source_location.column > 0,
|
||||
);
|
||||
}
|
||||
assert.equal(greet.length, 3, JSON.stringify(result));
|
||||
assert.equal(add.length, 3, JSON.stringify(result));
|
||||
for (const [index, event] of greet.entries()) {
|
||||
@@ -283,8 +267,6 @@ try {
|
||||
const binary = join(directory, "ReaCalls");
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
"-g",
|
||||
"-O0",
|
||||
"-fobjc-arc",
|
||||
"-framework",
|
||||
"Foundation",
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runOwnedCommand } from "../dist/process/OwnedCommand.js";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
import { createCanonicalTemporaryDirectory } from "./lib/canonical-temporary-directory.mjs";
|
||||
|
||||
const cancellation = new AbortController();
|
||||
let shutdownSignal;
|
||||
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
|
||||
const forwardCancellation = (signal) => {
|
||||
if (shutdownSignal !== undefined) return;
|
||||
shutdownSignal = signal;
|
||||
cancellation.abort(new Error(`Verification cancelled by ${signal}`));
|
||||
};
|
||||
const signalHandlers = Object.fromEntries(
|
||||
Object.keys(signalExitCodes).map((signal) => [
|
||||
signal,
|
||||
() => forwardCancellation(signal),
|
||||
]),
|
||||
);
|
||||
for (const [signal, handler] of Object.entries(signalHandlers))
|
||||
process.on(signal, handler);
|
||||
|
||||
const execute = async (command, arguments_, { timeout }) => {
|
||||
const result = await runOwnedCommand(
|
||||
{
|
||||
command,
|
||||
arguments: arguments_,
|
||||
runId: `rea-verify-pascal-${randomUUID()}`,
|
||||
expectedCommand: null,
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
},
|
||||
{ timeoutMs: timeout, diagnosticBytes: 1024 * 1024 },
|
||||
{ signal: cancellation.signal },
|
||||
);
|
||||
return { stdout: result.stdout.text, stderr: result.stderr.text };
|
||||
};
|
||||
const compiler = process.env.REA_VERIFY_FPC_COMMAND;
|
||||
if (process.platform !== "darwin" || !isAbsolute(compiler ?? ""))
|
||||
throw new Error(
|
||||
"Requires macOS and an absolute caller-selected REA_VERIFY_FPC_COMMAND; build the runtime first.",
|
||||
);
|
||||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/native/calls.pas", import.meta.url),
|
||||
);
|
||||
const fixtureLines = (await readFile(source, "utf8")).split(/\r?\n/u);
|
||||
const callerLines = new Set(
|
||||
["REA_VERIFY_CALLER_LOOP", "REA_VERIFY_CALLER_CALL"].map((marker) => {
|
||||
const matches = fixtureLines.flatMap((line, index) =>
|
||||
line.includes(marker) ? [index + 1] : [],
|
||||
);
|
||||
assert.equal(matches.length, 1, `fixture has one ${marker} marker`);
|
||||
return matches[0];
|
||||
}),
|
||||
);
|
||||
const allocatedDirectory = await createCanonicalTemporaryDirectory(
|
||||
"rea-pascal-calls-",
|
||||
);
|
||||
let directory = allocatedDirectory.path;
|
||||
const input = {
|
||||
breakpoints: [{ kind: "function", name: "rea_pascal_call" }],
|
||||
argument_registers: 1,
|
||||
backtrace_frames: 1,
|
||||
duration_ms: 30_000,
|
||||
};
|
||||
const inspect = (result) => {
|
||||
assert.equal(result.process.outcome, "exited");
|
||||
assert.equal(result.process.exit_status, 0);
|
||||
assert.equal(result.process.terminated, true);
|
||||
assert.equal(result.process.stdout.text, "pascal 0\npascal 1\npascal 2\n");
|
||||
assert.equal(result.events.length, 3);
|
||||
assert.deepEqual(
|
||||
result.events.map((event) => event.registers[0].value),
|
||||
["0x0", "0x1", "0x2"],
|
||||
);
|
||||
assert.equal(result.breakpoints[0].location_count, 1);
|
||||
for (const event of result.events) {
|
||||
assert.equal(event.backtrace.length, 1);
|
||||
assert.equal(event.backtrace[0].source_location?.file_path, source);
|
||||
assert.equal(event.backtrace[0].module_path, join(directory, "calls"));
|
||||
assert.ok(
|
||||
callerLines.has(event.backtrace[0].source_location.line),
|
||||
"caller is the Pascal loop/call site",
|
||||
);
|
||||
}
|
||||
for (const location of [
|
||||
...result.events,
|
||||
...result.breakpoints[0].locations,
|
||||
]) {
|
||||
assert.equal(location.source_location?.file_path, source);
|
||||
assert.ok(location.source_location.line > 0);
|
||||
}
|
||||
};
|
||||
const stable = (result) =>
|
||||
result.events.map((event) => ({
|
||||
file_address: event.file_address,
|
||||
source_location: event.source_location,
|
||||
registers: event.registers,
|
||||
callers: event.backtrace.map((frame) => ({
|
||||
file_address: frame.file_address,
|
||||
symbol: frame.symbol,
|
||||
source_location: frame.source_location,
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
const version = (
|
||||
await execute(compiler, ["-iV"], { timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
const architecture = (
|
||||
await execute(compiler, ["-iTP"], {
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
await execute(
|
||||
compiler,
|
||||
[
|
||||
"-dDEBUG",
|
||||
"-gw3",
|
||||
"-O-",
|
||||
"-Xs-",
|
||||
`-FE${directory}`,
|
||||
`-FU${directory}`,
|
||||
source,
|
||||
],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
const binary = join(directory, "calls");
|
||||
const cli = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[JSON.stringify(input)],
|
||||
{ timeoutMs: 180_000 },
|
||||
);
|
||||
inspect(cli);
|
||||
await withArtifactMcp(binary, async (client) => {
|
||||
const mcp = await artifactMcpResult(client, "observe_native_calls", input);
|
||||
inspect(mcp);
|
||||
assert.deepEqual(stable(mcp), stable(cli));
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
mocked: false,
|
||||
compiler,
|
||||
version,
|
||||
architecture,
|
||||
debugger: cli.debugger,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
scope:
|
||||
"bounded native call entries and compiler-provided source coordinates; no typed Pascal locals or interactive stepping",
|
||||
}),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (shutdownSignal === undefined) throw cause;
|
||||
const cleanupFailure = cause?.cleanupFailure;
|
||||
if (cleanupFailure !== undefined && cleanupFailure !== null) {
|
||||
console.error(
|
||||
`Verification cancelled by ${shutdownSignal}; owned compiler cleanup is incomplete: ${cleanupFailure}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.error(
|
||||
`Verification cancelled by ${shutdownSignal}; owned compiler process group was stopped.`,
|
||||
);
|
||||
process.exitCode = signalExitCodes[shutdownSignal];
|
||||
}
|
||||
} finally {
|
||||
for (const signal of Object.keys(signalExitCodes))
|
||||
process.off(signal, signalHandlers[signal]);
|
||||
await rm(allocatedDirectory.allocatedPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
@@ -42,29 +41,22 @@ export class WasmArtifactService {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
const pathIssues = [
|
||||
{ value: input.data.path, path: ["path"] },
|
||||
...input.data.glue_paths.map((value, index) => ({
|
||||
value,
|
||||
path: ["glue_paths", index],
|
||||
})),
|
||||
...input.data.candidate_paths.map((value, index) => ({
|
||||
value,
|
||||
path: ["candidate_paths", index],
|
||||
})),
|
||||
].flatMap<AnalysisInputIssue>(({ value, path }) =>
|
||||
isAbsolute(value)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
],
|
||||
);
|
||||
if (pathIssues.length > 0)
|
||||
return err(new AnalysisInputError(OPERATION, undefined, pathIssues));
|
||||
if (
|
||||
![
|
||||
input.data.path,
|
||||
...input.data.glue_paths,
|
||||
...input.data.candidate_paths,
|
||||
].every(isAbsolute)
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
|
||||
@@ -114,18 +114,6 @@ export const nativeCodeLocationSchema = z.strictObject({
|
||||
module: z.string().nullable(),
|
||||
module_path: z.string().nullable(),
|
||||
symbol: z.string().nullable(),
|
||||
/** LLDB line-table attribution; does not verify source file contents. */
|
||||
source_location: z
|
||||
.strictObject({
|
||||
file_path: z.string().min(1).nullable(),
|
||||
line: z.number().int().positive().nullable(),
|
||||
column: z.number().int().positive().nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Compiler or debugger source attribution only; REA does not read or verify the named source-file bytes.",
|
||||
),
|
||||
});
|
||||
|
||||
/** One observed entry into a requested function or method. */
|
||||
|
||||
@@ -46,7 +46,6 @@ export const projectLldbPartialObservation = (input: {
|
||||
limitations: [
|
||||
"The LLDB run did not produce a complete native call observation result.",
|
||||
"The target SHA-256 identifies the selected artifact; LLDB did not verify the bytes loaded into the process.",
|
||||
"Source paths and lines are LLDB line-table attribution; REA did not read or verify the referenced source bytes.",
|
||||
"Captured output byte counts are observed lower bounds because LLDB did not report final stream counters.",
|
||||
...input.journal.limitations,
|
||||
...(input.journalLimitations ?? []),
|
||||
|
||||
@@ -31,7 +31,6 @@ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [
|
||||
"Registers are the raw integer argument registers at entry. For Objective-C methods the first two hold the receiver and the selector.",
|
||||
"Receiver classes come from LLDB's Objective-C runtime reader without running target code; they are null for class methods and for receivers LLDB cannot classify.",
|
||||
"Calls that never reach a symbol's entry, such as inlined code or objc_direct methods called directly, are not observed.",
|
||||
"Source locations come from LLDB line-table metadata; REA does not read or verify the named source-file contents.",
|
||||
"The target is launched directly under LLDB, not through LaunchServices; LLDB disables ASLR for it, so load addresses can equal file addresses.",
|
||||
"The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.",
|
||||
] as const;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createCanonicalTemporaryDirectory } from "../../../scripts/lib/canonical-temporary-directory.mjs";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("uses the canonical spelling for a verifier directory allocated through a symlink", async () => {
|
||||
const root = await createTestTempDirectory("rea-pascal-verifier-path-");
|
||||
const alias = join(root, "alias");
|
||||
await symlink(
|
||||
root,
|
||||
alias,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const allocated = await createCanonicalTemporaryDirectory(
|
||||
"rea-pascal-calls-",
|
||||
alias,
|
||||
);
|
||||
try {
|
||||
expect(allocated.path).toBe(await realpath(allocated.allocatedPath));
|
||||
expect(allocated.path).not.toBe(
|
||||
join(alias, basename(allocated.allocatedPath)),
|
||||
);
|
||||
} finally {
|
||||
await rm(allocated.allocatedPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"forwards verifier SIGTERM to the owned compiler group and removes its temporary directory",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-pascal-verifier-cancel-");
|
||||
const fakeCompiler = join(root, "fake-fpc");
|
||||
const pidFile = join(root, "compiler-pids.txt");
|
||||
await writeFile(
|
||||
fakeCompiler,
|
||||
'#!/usr/bin/env node\nimport { spawn } from "node:child_process";\nimport { writeFileSync } from "node:fs";\nconst child = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { stdio: "ignore" });\nwriteFileSync(process.env.REA_VERIFY_TEST_PID_FILE, `${process.pid} ${child.pid}\\n`);\nawait new Promise(() => undefined);\n',
|
||||
);
|
||||
await chmod(fakeCompiler, 0o700);
|
||||
const verifier = spawn(
|
||||
process.execPath,
|
||||
["scripts/verify-native-pascal-calls.mjs"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: root,
|
||||
REA_VERIFY_FPC_COMMAND: fakeCompiler,
|
||||
REA_VERIFY_TEST_PID_FILE: pidFile,
|
||||
},
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
try {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let pids: number[] | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
pids = (await readFile(pidFile, "utf8"))
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.map(Number);
|
||||
break;
|
||||
} catch (cause) {
|
||||
if (cause.code !== "ENOENT") throw cause;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
expect(pids, "the fake compiler started").toHaveLength(2);
|
||||
verifier.kill("SIGTERM");
|
||||
const result = await new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>((resolve, reject) => {
|
||||
verifier.once("error", reject);
|
||||
verifier.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
expect(result).toEqual({ code: 143, signal: null });
|
||||
for (const pid of pids ?? [])
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
expect(
|
||||
(await readdir(root)).filter((name) =>
|
||||
name.startsWith("rea-pascal-calls-"),
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
verifier.kill("SIGKILL");
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -61,16 +61,7 @@ const tracer: NativeCallTracer & { calls: number } = {
|
||||
breakpoints: request.input.breakpoints.map((_, index) => ({
|
||||
index,
|
||||
location_count: 1,
|
||||
locations: [
|
||||
{
|
||||
load_address: "0x1000",
|
||||
file_address: "0x1000",
|
||||
module: "Tool",
|
||||
module_path: request.executable,
|
||||
symbol: "open",
|
||||
source_location: { file_path: null, line: 23, column: null },
|
||||
},
|
||||
],
|
||||
locations: [],
|
||||
})),
|
||||
events: [],
|
||||
resource_limit_reached: false,
|
||||
@@ -133,11 +124,6 @@ it("routes observe_native_calls through MCP with schema-checked input and output
|
||||
.parse(called.structuredContent).normalized_result,
|
||||
);
|
||||
expect(result.process).toMatchObject({ pid: 99, outcome: "exited" });
|
||||
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual({
|
||||
file_path: null,
|
||||
line: 23,
|
||||
column: null,
|
||||
});
|
||||
expect(result.breakpoints[0]?.request).toEqual({
|
||||
kind: "function",
|
||||
name: "open",
|
||||
|
||||
@@ -215,97 +215,6 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attributes rejected WASM paths to their selected fields before provider work", async () => {
|
||||
const value = wasmArtifactFixture();
|
||||
value.glue = [
|
||||
{
|
||||
artifact: { path: "/glue.js", sha256: "b".repeat(64), bytes: 0 },
|
||||
parse_status: "complete",
|
||||
references: [],
|
||||
},
|
||||
];
|
||||
value.candidates.push({
|
||||
path: "/other.wasm",
|
||||
sha256: "c".repeat(64),
|
||||
bytes: 8,
|
||||
});
|
||||
const validInput = {
|
||||
path: value.artifact.path,
|
||||
glue_paths: ["/glue.js"],
|
||||
candidate_paths: ["/other.wasm"],
|
||||
};
|
||||
const inspect = vi.fn(() =>
|
||||
Promise.resolve(ok(wasmArtifactExecution(value))),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
serverOptions: {
|
||||
wasmArtifact: new WasmArtifactService({
|
||||
close: () => Promise.resolve(),
|
||||
inspect,
|
||||
}),
|
||||
},
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
for (const [input, paths] of [
|
||||
[{ path: "relative.wasm" }, [["path"]]],
|
||||
[
|
||||
{ path: value.artifact.path, glue_paths: ["relative.js"] },
|
||||
[["glue_paths", 0]],
|
||||
],
|
||||
[
|
||||
{ path: value.artifact.path, candidate_paths: ["relative.wasm"] },
|
||||
[["candidate_paths", 0]],
|
||||
],
|
||||
[
|
||||
{
|
||||
path: value.artifact.path,
|
||||
glue_paths: ["/glue.js", "relative.js"],
|
||||
candidate_paths: ["/other.wasm", "relative.wasm"],
|
||||
},
|
||||
[
|
||||
["glue_paths", 1],
|
||||
["candidate_paths", 1],
|
||||
],
|
||||
],
|
||||
[
|
||||
{
|
||||
path: "relative.wasm",
|
||||
glue_paths: ["relative.js"],
|
||||
candidate_paths: ["relative-candidate.wasm"],
|
||||
},
|
||||
[["path"], ["glue_paths", 0], ["candidate_paths", 0]],
|
||||
],
|
||||
] as const) {
|
||||
const response = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: input,
|
||||
});
|
||||
const failure = parseMcpToolError(response).error;
|
||||
expect(failure.code).toBe("invalid_request");
|
||||
const { issues } = z
|
||||
.object({ issues: z.array(z.unknown()) })
|
||||
.parse(failure.details);
|
||||
expect(issues).toEqual(
|
||||
paths.map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
})),
|
||||
);
|
||||
}
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
const valid = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: validInput,
|
||||
});
|
||||
expect(valid.isError).not.toBe(true);
|
||||
expect(inspect).toHaveBeenCalledExactlyOnceWith(
|
||||
validInput,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
await harness.client.ping();
|
||||
});
|
||||
|
||||
it("rejects a WASM provider response with a non-file subject before recording Evidence", async () => {
|
||||
const value = wasmArtifactFixture();
|
||||
const execution = wasmArtifactExecution(value);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { assertCleanLinuxHopperRuntime } from "../../../scripts/lib/clean-linux-hopper-runtime.mjs";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
it.runIf(process.platform === "linux").each([
|
||||
["Xvfb", "Clean Linux runner retained a Hopper demo process"],
|
||||
["Hopper", "Clean Linux runner retained a Hopper demo process"],
|
||||
["python3", "Cannot identify a command-less Python process"],
|
||||
["unrelated", undefined],
|
||||
] as const)(
|
||||
"observes an unreaped %s without signaling or reaping it",
|
||||
async (name, refusal) => {
|
||||
const directory = await createTestTempDirectory("rea-runtime-zombie-");
|
||||
const procRoot = join(directory, "proc");
|
||||
await mkdir(procRoot);
|
||||
const executable = join(directory, name);
|
||||
await copyFile("/usr/bin/true", executable);
|
||||
await chmod(executable, 0o700);
|
||||
// WNOWAIT observes exit without reaping. The parent keeps the zombie until
|
||||
// the test closes its stdin, then waitpid performs the actual cleanup.
|
||||
const parent = execFileAsync(
|
||||
"/usr/bin/python3",
|
||||
[
|
||||
"-c",
|
||||
`
|
||||
import os, sys
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
os.execl(sys.argv[1], sys.argv[1])
|
||||
try:
|
||||
os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT)
|
||||
print(pid, flush=True)
|
||||
sys.stdin.buffer.read(1)
|
||||
finally:
|
||||
os.waitpid(pid, 0)
|
||||
`,
|
||||
executable,
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const ready = new Promise<number>((resolve, reject) => {
|
||||
parent.child.stdout?.once("data", (data: Buffer) =>
|
||||
resolve(Number(data.toString().trim())),
|
||||
);
|
||||
parent.catch(reject);
|
||||
});
|
||||
let zombiePid: number | undefined;
|
||||
try {
|
||||
zombiePid = await ready;
|
||||
expect(Number.isSafeInteger(zombiePid)).toBe(true);
|
||||
const prefix = `/proc/${String(zombiePid)}`;
|
||||
const procEntry = join(procRoot, String(zombiePid));
|
||||
await mkdir(procEntry);
|
||||
for (const name of ["cmdline", "comm"])
|
||||
await symlink(`${prefix}/${name}`, join(procEntry, name));
|
||||
expect(await readFile(`${prefix}/cmdline`, "utf8")).toBe("");
|
||||
expect((await readFile(`${prefix}/comm`, "utf8")).trim()).toBe(name);
|
||||
const observation = assertCleanLinuxHopperRuntime([], procRoot);
|
||||
if (refusal === undefined)
|
||||
await expect(observation).resolves.toBeUndefined();
|
||||
else await expect(observation).rejects.toThrow(refusal);
|
||||
expect(await readFile(`${prefix}/stat`, "utf8")).toContain(
|
||||
`(${name}) Z `,
|
||||
);
|
||||
} finally {
|
||||
parent.child.stdin?.end("reap");
|
||||
await parent;
|
||||
if (zombiePid !== undefined)
|
||||
await expect(
|
||||
readFile(`/proc/${String(zombiePid)}/stat`, "utf8"),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"does not treat runtime names in another process's arguments as a runtime",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-runtime-arguments-");
|
||||
const procRoot = join(directory, "proc");
|
||||
await mkdir(procRoot);
|
||||
const observer = new URL(
|
||||
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const code = `
|
||||
import { mkdir, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)});
|
||||
const entry = join(${JSON.stringify(procRoot)}, String(process.pid));
|
||||
await mkdir(entry);
|
||||
await symlink("/proc/" + process.pid + "/cmdline", join(entry, "cmdline"));
|
||||
await symlink("/proc/" + process.pid + "/comm", join(entry, "comm"));
|
||||
await assertCleanLinuxHopperRuntime([], ${JSON.stringify(procRoot)});
|
||||
console.log('clean');
|
||||
`;
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
code,
|
||||
procRoot,
|
||||
"Xvfb",
|
||||
"/opt/hopper/bin/Hopper",
|
||||
"hopper-demo-x11.py",
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
expect(stdout.trim()).toBe("clean");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"distinguishes an ordinary Node script with the helper's filename",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-runtime-arguments-");
|
||||
const procRoot = join(directory, "proc");
|
||||
await mkdir(procRoot);
|
||||
const script = join(directory, "hopper-demo-x11.py");
|
||||
const observer = new URL(
|
||||
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const code = `
|
||||
import { mkdir, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
const entry = join(${JSON.stringify(procRoot)}, String(process.pid));
|
||||
await mkdir(entry);
|
||||
await symlink("/proc/" + process.pid + "/cmdline", join(entry, "cmdline"));
|
||||
await symlink("/proc/" + process.pid + "/comm", join(entry, "comm"));
|
||||
const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)});
|
||||
await assertCleanLinuxHopperRuntime([], ${JSON.stringify(procRoot)});
|
||||
console.log('clean');
|
||||
`;
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", code, script],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
expect(stdout.trim()).toBe("clean");
|
||||
},
|
||||
);
|
||||
|
||||
const launchReapedOrphan = (runId: string, parentCode: string) => {
|
||||
// The short-lived Node launcher still exits before observation. A retained
|
||||
// subreaper adopts its child and waits after owned cleanup, even when the
|
||||
// container's PID 1 does not reap. It is a sibling of the verifier below.
|
||||
const reaper = execFileAsync(
|
||||
"/usr/bin/python3",
|
||||
[
|
||||
"-c",
|
||||
`
|
||||
import ctypes, json, os, subprocess, sys
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
|
||||
raise OSError(ctypes.get_errno(), "PR_SET_CHILD_SUBREAPER")
|
||||
launcher = subprocess.run(sys.argv[1:], capture_output=True, text=True, check=True)
|
||||
child = json.loads(launcher.stdout)
|
||||
try:
|
||||
print(json.dumps(child), flush=True)
|
||||
sys.stdin.buffer.read(1)
|
||||
finally:
|
||||
os.waitpid(child["pid"], 0)
|
||||
`,
|
||||
process.execPath,
|
||||
"-e",
|
||||
parentCode,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
||||
timeout: 40_000,
|
||||
},
|
||||
);
|
||||
const ready = new Promise<{ pid: number; parentPid: number }>(
|
||||
(resolve, reject) => {
|
||||
reaper.child.stdout?.once("data", (data: Buffer) => {
|
||||
try {
|
||||
resolve(JSON.parse(data.toString()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
reaper.then(
|
||||
() => reject(new Error("Fixture reaper exited before readiness")),
|
||||
reject,
|
||||
);
|
||||
},
|
||||
);
|
||||
return { reaper, ready };
|
||||
};
|
||||
|
||||
const observeFromSeparateVerifier = async (
|
||||
runId: string,
|
||||
launcherPaths: string[],
|
||||
procRoot: string,
|
||||
) => {
|
||||
const observer = new URL(
|
||||
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const verifierRun = new URL(
|
||||
"../../../scripts/lib/verifier-run.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const { stdout: observed } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)});
|
||||
const { completeVerifierRun } = await import(${JSON.stringify(verifierRun)});
|
||||
const { process_lineage } = await completeVerifierRun({
|
||||
run_id: ${JSON.stringify(runId)}, verifier_pid: process.pid, parent_pid: process.ppid,
|
||||
});
|
||||
let refusal;
|
||||
try { await assertCleanLinuxHopperRuntime(${JSON.stringify(launcherPaths)}, ${JSON.stringify(procRoot)}); }
|
||||
catch (error) { refusal = error.message; }
|
||||
console.log(JSON.stringify({ process_lineage, refusal }));
|
||||
`,
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
return JSON.parse(observed) as {
|
||||
process_lineage: {
|
||||
status: string;
|
||||
descendants: { pid: number }[];
|
||||
};
|
||||
refusal?: string;
|
||||
};
|
||||
};
|
||||
|
||||
it.runIf(process.platform === "linux").each([
|
||||
["Xvfb-named Node", null, true],
|
||||
["Python helper", [], true],
|
||||
["Python helper with -u", ["-u"], true],
|
||||
[
|
||||
"Python helper with combined/value options",
|
||||
["-Bu", "-W", "ignore", "-X", "dev", "--check-hash-based-pycs", "always"],
|
||||
true,
|
||||
],
|
||||
["Python helper after --", ["--"], true],
|
||||
[
|
||||
"Python -c script argument",
|
||||
["-c", "import time; print('ready', flush=True); time.sleep(30)"],
|
||||
false,
|
||||
],
|
||||
["Python -m script argument", ["-m", "unrelated"], false],
|
||||
["alternate launcher", null, true],
|
||||
] as const)(
|
||||
"classifies a reparented %s without signaling it",
|
||||
async (mode, pythonOptions, retained) => {
|
||||
const runId = randomUUID();
|
||||
const childCode =
|
||||
"process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');";
|
||||
const directory = await createTestTempDirectory("rea-clean-runtime-test-");
|
||||
const procRoot = join(directory, "proc");
|
||||
await mkdir(procRoot);
|
||||
const launcher = join(directory, "Hopper");
|
||||
const alternate = join(directory, "Hopper-alternate");
|
||||
const helper = join(directory, "hopper-demo-x11.py");
|
||||
const alternateMode = mode === "alternate launcher";
|
||||
const pythonMode = pythonOptions !== null;
|
||||
if (alternateMode) {
|
||||
await copyFile("/usr/bin/sleep", launcher);
|
||||
await chmod(launcher, 0o700);
|
||||
await symlink(launcher, alternate);
|
||||
}
|
||||
if (pythonMode) {
|
||||
await writeFile(
|
||||
helper,
|
||||
"import time\nprint('ready', flush=True)\ntime.sleep(30)\n",
|
||||
);
|
||||
await copyFile(helper, join(directory, "unrelated.py"));
|
||||
}
|
||||
// The fixture must be the cause of a refusal; a retained host runtime
|
||||
// would otherwise mask a broken classification or executable-identity check.
|
||||
await assertCleanLinuxHopperRuntime(
|
||||
alternateMode ? [launcher] : [],
|
||||
procRoot,
|
||||
);
|
||||
const command = alternateMode
|
||||
? alternate
|
||||
: pythonMode
|
||||
? "/usr/bin/python3"
|
||||
: process.execPath;
|
||||
const arguments_ = alternateMode
|
||||
? ["30"]
|
||||
: pythonMode
|
||||
? [...pythonOptions, helper]
|
||||
: ["-e", childCode];
|
||||
const stdio = alternateMode
|
||||
? "ignore"
|
||||
: pythonMode
|
||||
? ["ignore", "pipe", "ignore"]
|
||||
: ["ignore", "ignore", "ignore", "ipc"];
|
||||
const parentCode = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const child = spawn(${JSON.stringify(command)}, ${JSON.stringify(arguments_)}, {
|
||||
detached: true, stdio: ${JSON.stringify(stdio)}, cwd: ${JSON.stringify(directory)}
|
||||
});
|
||||
child.once('error', () => process.exit(1));
|
||||
const ready = () => {
|
||||
process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid }));
|
||||
if (child.connected) child.disconnect();
|
||||
child.stdout?.destroy();
|
||||
child.unref();
|
||||
};
|
||||
${pythonMode ? "child.stdout.once('data', ready);" : `child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, ready);`}
|
||||
`;
|
||||
const { reaper, ready } = launchReapedOrphan(runId, parentCode);
|
||||
let orphanPid: number | undefined;
|
||||
try {
|
||||
const { pid, parentPid } = await ready;
|
||||
orphanPid = pid;
|
||||
const procEntry = join(procRoot, String(pid));
|
||||
await mkdir(procEntry);
|
||||
for (const name of ["cmdline", "comm", "exe"])
|
||||
await symlink(`/proc/${String(pid)}/${name}`, join(procEntry, name));
|
||||
const { stdout: coordinates } = await execFileAsync("ps", [
|
||||
"-p",
|
||||
String(pid),
|
||||
"-o",
|
||||
"ppid=,pgid=",
|
||||
]);
|
||||
const [currentParent, group] = coordinates
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.map(Number);
|
||||
expect(currentParent).not.toBe(parentPid);
|
||||
expect(currentParent).toBe(reaper.child.pid);
|
||||
expect(group).toBe(pid);
|
||||
|
||||
if (alternateMode) {
|
||||
const commandLine = await readFile(
|
||||
`/proc/${String(pid)}/cmdline`,
|
||||
"utf8",
|
||||
);
|
||||
expect(commandLine.split("\0")[0]).toBe(alternate);
|
||||
await unlink(alternate);
|
||||
}
|
||||
if (pythonMode) {
|
||||
const commandLine = await readFile(
|
||||
`/proc/${String(pid)}/cmdline`,
|
||||
"utf8",
|
||||
);
|
||||
expect(
|
||||
commandLine.split("\0").slice(0, pythonOptions.length + 2),
|
||||
).toEqual([command, ...pythonOptions, helper]);
|
||||
}
|
||||
const { process_lineage: lineage, refusal } =
|
||||
await observeFromSeparateVerifier(
|
||||
runId,
|
||||
alternateMode ? [launcher] : [],
|
||||
procRoot,
|
||||
);
|
||||
expect(lineage.status).toBe("verified");
|
||||
expect(lineage.descendants.some((p) => p.pid === pid)).toBe(false);
|
||||
if (retained)
|
||||
expect(refusal).toBe(
|
||||
"Clean Linux runner retained a Hopper demo process",
|
||||
);
|
||||
else expect(refusal).toBeUndefined();
|
||||
// Observation must not terminate the fixture, even though it resembles Xvfb.
|
||||
const { stdout: alive } = await execFileAsync("ps", [
|
||||
"-p",
|
||||
String(pid),
|
||||
"-o",
|
||||
"stat=",
|
||||
]);
|
||||
expect(alive.trim()).not.toMatch(/^Z/u);
|
||||
} finally {
|
||||
try {
|
||||
if (orphanPid !== undefined) {
|
||||
const cleanup = await cleanupOwnedProcessGroup({
|
||||
runId,
|
||||
leaderPid: orphanPid,
|
||||
processGroupId: orphanPid,
|
||||
});
|
||||
expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
reaper.child.stdin?.end("reap");
|
||||
await reaper;
|
||||
if (orphanPid !== undefined)
|
||||
await expect(
|
||||
readFile(`/proc/${String(orphanPid)}/stat`, "utf8"),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -50,25 +50,6 @@ const observation = (
|
||||
};
|
||||
|
||||
describe("LLDB retained observations", () => {
|
||||
it("preserves partial source coordinates while accepting historical omission", () => {
|
||||
const attributed = {
|
||||
...event,
|
||||
source_location: {
|
||||
file_path: "/missing/fixture.pas",
|
||||
line: 23,
|
||||
column: null,
|
||||
},
|
||||
};
|
||||
const journal =
|
||||
[event, attributed]
|
||||
.map((entry) => JSON.stringify({ kind: "event", event: entry }))
|
||||
.join("\n") + "\n";
|
||||
expect(observation("cancelled", journal).events).toEqual([
|
||||
event,
|
||||
attributed,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancellation", "cancelled"],
|
||||
["bridge failure", "tracer-failure"],
|
||||
|
||||
@@ -30,11 +30,6 @@ const LOCATION = {
|
||||
module: "Fixture",
|
||||
module_path: "/tmp/Fixture",
|
||||
symbol: "-[Greeter greet:times:]",
|
||||
source_location: {
|
||||
file_path: "/not-acquired/é space/fixture.pas",
|
||||
line: 23,
|
||||
column: null,
|
||||
},
|
||||
};
|
||||
|
||||
const run = (
|
||||
@@ -84,7 +79,7 @@ const run = (
|
||||
{ name: "x0", value: "0x6000" },
|
||||
{ name: "x1", value: "0x7000" },
|
||||
],
|
||||
backtrace: [{ ...LOCATION, source_location: null }],
|
||||
backtrace: [],
|
||||
},
|
||||
],
|
||||
other_stops: [],
|
||||
@@ -207,12 +202,7 @@ describe("observe_native_calls projection: projects completed observations", ()
|
||||
expect(result.events[0]).toMatchObject({
|
||||
receiver_class: "Greeter",
|
||||
selector: "greet:times:",
|
||||
source_location: LOCATION.source_location,
|
||||
backtrace: [{ ...LOCATION, source_location: null }],
|
||||
});
|
||||
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual(
|
||||
LOCATION.source_location,
|
||||
);
|
||||
expect(result.coverage).toEqual({
|
||||
status: "complete",
|
||||
event_limit_reached: false,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
program ReaPascalCalls;
|
||||
|
||||
{$mode objfpc}
|
||||
{$inline off}
|
||||
|
||||
procedure ReaPascalCall(Value: LongInt); cdecl; public name 'rea_pascal_call';
|
||||
begin
|
||||
WriteLn('pascal ', Value);
|
||||
end;
|
||||
|
||||
var
|
||||
Index: LongInt;
|
||||
begin
|
||||
for Index := 0 to 2 do { REA_VERIFY_CALLER_LOOP }
|
||||
ReaPascalCall(Index); { REA_VERIFY_CALLER_CALL }
|
||||
end.
|
||||
@@ -74,7 +74,11 @@ it
|
||||
if (alternateMode) {
|
||||
const processEntry = join(procRoot, String(pid));
|
||||
await mkdir(processEntry);
|
||||
await symlink(`/proc/${String(pid)}/exe`, join(processEntry, "exe"));
|
||||
for (const name of ["cmdline", "comm", "exe"])
|
||||
await symlink(
|
||||
`/proc/${String(pid)}/${name}`,
|
||||
join(processEntry, name),
|
||||
);
|
||||
}
|
||||
const { stdout: coordinates } = await execFileAsync("ps", [
|
||||
"-p",
|
||||
|
||||
Reference in New Issue
Block a user