mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
feat(native): retain LLDB source coordinates and verify Pascal call entries
Preserve partial line-table attribution for native locations and historical evidence compatibility. Verify owned Free Pascal call entries through CLI/MCP. Refs #2382, #2372, #2397 Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -244,6 +244,20 @@ 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()
|
||||
@@ -257,6 +271,7 @@ 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,6 +22,45 @@ 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"))
|
||||
@@ -154,6 +193,9 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
return FileSpec()
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
@@ -228,6 +270,9 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
return FileSpec()
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
|
||||
@@ -426,6 +426,25 @@ 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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Tasks
|
||||
|
||||
- [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] Verify real CLI/MCP call coordinates on an owned Free Pascal 3.2.2 program.
|
||||
- [x] Update documentation; pass Python, focused TypeScript, static and discovery checks.
|
||||
- [ ] Complete the full native-call verifier after investigating #2397.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Verification
|
||||
|
||||
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 remains draft.
|
||||
|
||||
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.
|
||||
@@ -246,6 +246,7 @@
|
||||
"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",
|
||||
|
||||
@@ -60,6 +60,10 @@ 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
|
||||
@@ -95,6 +99,18 @@ 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()) {
|
||||
@@ -267,6 +283,8 @@ try {
|
||||
const binary = join(directory, "ReaCalls");
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
"-g",
|
||||
"-O0",
|
||||
"-fobjc-arc",
|
||||
"-framework",
|
||||
"Foundation",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
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 directory = await mkdtemp(join(tmpdir(), "rea-pascal-calls-"));
|
||||
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 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,
|
||||
}));
|
||||
try {
|
||||
const version = (await execute(compiler, ["-iV"])).stdout.trim();
|
||||
const architecture = (await execute(compiler, ["-iTP"])).stdout.trim();
|
||||
await execute(compiler, [
|
||||
"-dDEBUG",
|
||||
"-gw3",
|
||||
"-O-",
|
||||
"-Xs-",
|
||||
`-FE${directory}`,
|
||||
`-FU${directory}`,
|
||||
source,
|
||||
]);
|
||||
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",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -114,6 +114,15 @@ 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(),
|
||||
});
|
||||
|
||||
/** One observed entry into a requested function or method. */
|
||||
|
||||
@@ -61,7 +61,16 @@ const tracer: NativeCallTracer & { calls: number } = {
|
||||
breakpoints: request.input.breakpoints.map((_, index) => ({
|
||||
index,
|
||||
location_count: 1,
|
||||
locations: [],
|
||||
locations: [
|
||||
{
|
||||
load_address: "0x1000",
|
||||
file_address: "0x1000",
|
||||
module: "Tool",
|
||||
module_path: request.executable,
|
||||
symbol: "open",
|
||||
source_location: { file_path: null, line: 23, column: null },
|
||||
},
|
||||
],
|
||||
})),
|
||||
events: [],
|
||||
resource_limit_reached: false,
|
||||
@@ -124,6 +133,11 @@ 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",
|
||||
|
||||
@@ -50,6 +50,25 @@ 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,6 +30,11 @@ 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 = (
|
||||
@@ -79,7 +84,7 @@ const run = (
|
||||
{ name: "x0", value: "0x6000" },
|
||||
{ name: "x1", value: "0x7000" },
|
||||
],
|
||||
backtrace: [],
|
||||
backtrace: [{ ...LOCATION, source_location: null }],
|
||||
},
|
||||
],
|
||||
other_stops: [],
|
||||
@@ -202,7 +207,12 @@ 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,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
ReaPascalCall(Index);
|
||||
end.
|
||||
Reference in New Issue
Block a user