Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fdbd1d5
xref: lift MOV-source classifier into testable parser helper (phase 4…
zachgenius May 16, 2026
1e8d525
runtime: multi-subscriber notification sinks (§2 phase 2 prereq)
zachgenius May 16, 2026
b94326d
daemon: multi-client socket listener (§2 phase 2)
zachgenius May 16, 2026
311c439
xref: conditional-branch boundary reset on cross-function target (pha…
zachgenius May 16, 2026
f10c04c
chained_fixups: thread SBTarget triple through FAT slice picker (phas…
zachgenius May 16, 2026
3fc62e1
ldb-cli: auto-spawn ldbd when --socket path has no daemon (§2 phase 2)
zachgenius May 16, 2026
9b820b1
xref: function_starts set as stripped-binary boundary backstop (phase…
zachgenius May 16, 2026
72785ff
daemon: shutdown RPC + signal-driven accept-loop wakeup (§2 phase 2)
zachgenius May 16, 2026
c83d3b0
xref: PC-relative literal-load provenance bump (phase 4 item 4)
zachgenius May 16, 2026
31121eb
chained_fixups: BindInfo schema, deferred imports-table walk (phase 4…
zachgenius May 16, 2026
ac655ec
ldbd: --listen-idle-timeout N for opt-in idle shutdown (§2 phase 2)
zachgenius May 16, 2026
5de6798
xref: real-world C fixture exercising multi-pattern xref pipeline (ph…
zachgenius May 16, 2026
2b170ce
worklog: phase 4 closure (7 items) + doc 35 rewrite to "shipped"
zachgenius May 16, 2026
c1a9a0e
dispatcher: recursive_mutex for dispatch_mu_ + docs/worklog (§2 phase 2)
zachgenius May 16, 2026
2e6f4ed
runtime: shared_ptr storage for NotificationSink subscribers (§2 C1 U…
zachgenius May 16, 2026
bad8f90
daemon: gate workers on shutdown latch (§2 phase 2 I2)
zachgenius May 16, 2026
2978590
daemon: SO_SNDTIMEO on accepted sockets (§2 phase 2 I3)
zachgenius May 16, 2026
ced9f17
xref: cond-branch cleanup — preserve fall-through, same-fn no-poison
zachgenius May 16, 2026
8c03765
daemon: atomic-line stderr, atomic shutdown-pipe write end (§2 I4+N3+N4)
zachgenius May 16, 2026
ff0a89b
xref: clobber-by-default for destination registers (C3+C4)
zachgenius May 16, 2026
5fc4232
fix(backend): FAT picker honors triple match even when slice has no c…
zachgenius May 16, 2026
9397c03
ldb-cli: validate $LDB_LDBD_SPAWN points at ldbd (§2 phase 2 I5+N1+N2…
zachgenius May 16, 2026
716689b
smoke: honest framing for socket_multiclient test (§2 phase 2 N6)
zachgenius May 16, 2026
0b85d09
docs: phase-2 wrap — honest scope, phase-3 deferrals, worklog (§2 I1)
zachgenius May 16, 2026
3ba2399
xref: thread XrefProvenance through find_string_xrefs (I1+I2)
zachgenius May 16, 2026
7d6ef74
xref: parser hardening + adversarial fixture rewrites (§6 phase 4 cle…
zachgenius May 16, 2026
2c54814
Merge branch 'fix/socket-daemon-phase2'
zachgenius May 16, 2026
2c1ad49
Merge branch 'fix/chained-fixups-phase4'
zachgenius May 16, 2026
81d2b97
ci(daemon): Linux portability fixes — SO_PEERCRED + warn_unused_result
zachgenius May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 281 additions & 93 deletions docs/35-field-report-followups.md

Large diffs are not rendered by default.

527 changes: 527 additions & 0 deletions docs/WORKLOG.md

Large diffs are not rendered by default.

66 changes: 63 additions & 3 deletions include/ldb/backend/chained_fixups.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>

Expand Down Expand Up @@ -36,16 +39,61 @@ struct SegmentInfo {
std::size_t data_size = 0;
};

// Phase 4 item 6 (docs/35-field-report-followups.md §3): per-slot
// bind information. Populated by the imports-table walk (phase 5);
// schema lives here today so callers can begin coding against it
// while the actual walk is being implemented.
//
// A bind is a chain entry that references an imported symbol from
// another module (e.g. malloc, free, _objc_msgSend). dyld resolves
// the bind at load time by looking up the symbol in the dependent
// dylib's exports table. Phase 4 only records WHICH symbol is bound
// at each slot; resolving the symbol's load address (resolved_addr)
// requires a process attached to LDB OR a cross-module symbol-index
// query, both of which are phase 5 territory.
struct BindInfo {
// Symbol the slot is bound to, e.g. "_malloc", "_objc_msgSend".
// Empty when the imports-table parser hasn't been wired (phase 5).
std::string name;

// Addend applied to the symbol's runtime address. Most binds have
// addend = 0 (the slot holds the symbol's exact address); a non-zero
// addend is common for re-exported aliases or field-of-imported-
// struct patterns.
std::int64_t addend = 0;

// Ordinal into the imports table (DYLD_CHAINED_IMPORT,
// _IMPORT_ADDEND, or _IMPORT_ADDEND64 record). Stored for
// diagnostic / round-trip purposes; consumers should usually read
// `name` and `resolved_addr` instead.
std::uint32_t ordinal = 0;

// Resolved load address of the bound symbol, set when a process is
// attached and SBTarget::FindSymbols(name) returned a live mapping.
// Empty when static-only (no process) or the symbol couldn't be
// resolved.
std::optional<std::uint64_t> resolved_addr;
};

struct ChainedFixupMap {
// rva: image-base-relative VM offset of the pointer slot. Add this
// to the runtime image_base to get the load-time slot address; this
// is NOT a file offset. Value is the 64-bit pointer dyld would have
// written into that slot. For rebases, this is image_base +
// rebase_target_offset (or the raw target VA for vmaddr-style
// formats). For binds, this is 0 — phase 1 does not resolve binds.
// Phase 2 wires in the imports table.
// formats). For binds, this is 0 — phase 4 records bind metadata in
// `binds` (below) but doesn't synthesise a resolved value because
// the imports-table walk is phase 5.
std::unordered_map<std::uint64_t, std::uint64_t> resolved;

// Phase 4 item 6: per-slot bind metadata. Keyed by the same rva as
// `resolved`. When a slot is a bind, `resolved[rva]` stays 0 and
// `binds[rva]` carries the symbol name + addend + (optionally)
// resolved load address. Empty in phase 4 when the imports-table
// walk hasn't been implemented yet; phase 5 will populate it from
// dyld_chained_fixups_header::imports_offset.
std::unordered_map<std::uint64_t, BindInfo> binds;

// Image base derived from the first chain-bearing segment's
// (vm_addr - segment_offset) pair. Zero when no chained fixups are
// present (extract_chained_fixups_from_macho on a non-Mach-O / non-
Expand Down Expand Up @@ -84,7 +132,19 @@ ChainedFixupMap parse_chained_fixups(
//
// `macho_bytes` must outlive this call but the map's resolved table
// owns its own storage and survives the byte buffer's destruction.
//
// `triple` is the SBTarget triple of the LOADED slice (e.g.
// "arm64e-apple-macosx14.0.0", "arm64-apple-ios13.0", "x86_64-apple-
// macosx-"). Phase 4 item 5 (docs/35-field-report-followups.md §3)
// uses it to pick the right slice from a FAT (universal) Mach-O:
// - triple substring "arm64e-" → prefer CPU_SUBTYPE_ARM64E (= 2)
// - triple substring "arm64-" → prefer CPU_SUBTYPE_ARM64_ALL/_V8
// - triple substring "x86_64-" → CPU_TYPE_X86_64 (no chained fixups
// today; we still skip past it)
// Empty triple falls back to the phase-3 preference order (arm64e
// then arm64). Non-FAT inputs ignore the triple entirely.
ChainedFixupMap extract_chained_fixups_from_macho(
const std::uint8_t* macho_bytes, std::size_t macho_size);
const std::uint8_t* macho_bytes, std::size_t macho_size,
std::string_view triple = {});

} // namespace ldb::backend
127 changes: 100 additions & 27 deletions src/backend/chained_fixups.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <cstring>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

namespace ldb::backend {
Expand Down Expand Up @@ -435,16 +436,58 @@ ChainedFixupMap extract_chained_fixups_from_thin_macho(
return parse_chained_fixups(fixups_payload, fixups_size, segments);
}

// Phase 4 item 2 (docs/35-field-report-followups.md §3): classify a
// triple string into the (cpu_type, cpu_subtype) pair the FAT picker
// should prefer. Returns false when the triple is empty or doesn't
// name a known arch — in which case the picker falls back to the
// phase-3 preference order (arm64e > arm64).
//
// Triple substring -> (cpu_type, cpu_subtype) table:
// "arm64e-" -> CPU_TYPE_ARM64, CPU_SUBTYPE_ARM64E (2)
// "arm64-" -> CPU_TYPE_ARM64, CPU_SUBTYPE_ARM64_ALL (0)
// "x86_64-" -> CPU_TYPE_X86_64, any subtype (x86_64 has no chained
// fixups today; the picker still skips past it the
// same way phase 3 did)
//
// "arm64-" matching must come AFTER "arm64e-" — the LLDB-reported
// triple for arm64e binaries contains "arm64e-", which starts with
// "arm64" without the trailing dash. The substring check is bracketed
// by the dash so we don't accidentally match "arm64-" inside
// "arm64e-apple-...".
constexpr std::uint32_t kCpuTypeX86_64 = 0x01000007;

bool triple_to_preferred_arch(std::string_view triple,
std::uint32_t* cpu_type,
std::uint32_t* cpu_subtype) {
if (triple.empty()) return false;
if (triple.find("arm64e-") != std::string_view::npos) {
*cpu_type = kCpuTypeArm64;
*cpu_subtype = kCpuSubTypeArm64E;
return true;
}
if (triple.find("arm64-") != std::string_view::npos) {
*cpu_type = kCpuTypeArm64;
*cpu_subtype = 0; // ARM64_ALL — the picker also accepts _V8 (1)
return true;
}
if (triple.find("x86_64-") != std::string_view::npos) {
*cpu_type = kCpuTypeX86_64;
*cpu_subtype = 0;
return true;
}
// Unknown / unhandled triple — fall back to preference order.
return false;
}

// FAT slice selection (docs/35-field-report-followups.md §3 phase 3
// gate 5). Iterate the fat_arch[] table, prefer arm64e, then arm64,
// then anything else, then dispatch to the thin parser on the picked
// slice's (offset, size) sub-region. The phase-3 acceptance criteria
// say "match the SBTarget's triple"; here we approximate that with
// arm64e-then-arm64 preference because the xref pipeline only
// produces chained-fixup output on those archs.
// gate 5; phase 4 item 2). Iterate the fat_arch[] table; if a triple
// hint is supplied, try the matching slice first. Otherwise fall back
// to the phase-3 preference order (arm64e > arm64). The phase-3
// acceptance criteria said "match the SBTarget's triple"; phase 4
// closes the loop by actually threading it through.
ChainedFixupMap extract_chained_fixups_from_fat(
const std::uint8_t* fat_bytes, std::size_t fat_size,
bool is_fat64) {
bool is_fat64, std::string_view triple) {
if (fat_bytes == nullptr || fat_size < 8) return {};
// fat_header: magic[0..4] nfat_arch[4..8]. Big-endian on disk.
const std::uint32_t nfat_arch = read_u32_be(fat_bytes + 4);
Expand Down Expand Up @@ -501,24 +544,52 @@ ChainedFixupMap extract_chained_fixups_from_fat(
fat_bytes + a.offset, static_cast<std::size_t>(a.size));
};

// Slice preference: arm64e first, then plain arm64. We treat a
// slice with an EMPTY resolved map as "this slice has no chained
// fixups, try the next" rather than "use this empty result." That
// means a FAT binary whose arm64e slice has chained fixups but
// whose arm64 slice doesn't will return the arm64e result; a FAT
// binary whose arm64 slice has fixups but arm64e doesn't will
// fall through to the arm64 slice.
// Phase 4 item 2 + cleanup C5: if the caller provided a triple and
// a slice with the matching (cpu_type, cpu_subtype) EXISTS in the
// FAT, that slice's parse result wins — even when its resolved map
// is empty. The C5 silent-wrong-result bug was returning a
// DIFFERENT slice's result (with that slice's image_base) when the
// triple-matched slice happened to be a classic LC_DYLD_INFO_ONLY
// binary with no chained fixups; the caller's xref scan then
// resolved every ADRP page through the wrong slice's image_base
// and silently produced garbage.
//
// Hazard: if BOTH slices have chained fixups but with different
// image_bases (which happens when the slices have different
// segment layouts — possible after a thinning + repacking
// pipeline), the arm64e slice wins and its image_base is what we
// hand back. The caller then walks the LLDB-loaded slice (which
// might be arm64) and tries to resolve its file-addresses against
// the wrong image_base, producing zero matches. Phase-4 follow-up
// tracked in the worklog: thread the SBTarget's triple through
// extract_chained_fixups_from_macho so the picker matches what
// LLDB actually loaded.
// Correct semantics: if the triple matched ANY slice in the FAT,
// honour LLDB's choice and return THAT slice's parse — including
// the empty-fixups case. Only fall back to phase-3 preference
// when NO slice in the FAT matches the triple at all.
std::uint32_t triple_cpu_type = 0, triple_cpu_subtype = 0;
if (triple_to_preferred_arch(triple, &triple_cpu_type,
&triple_cpu_subtype)) {
for (const auto& a : archs) {
if (a.cpu_type == triple_cpu_type &&
a.cpu_subtype_masked == triple_cpu_subtype) {
return pick_and_run(a);
}
}
// ARM64_ALL match also accepts CPU_SUBTYPE_ARM64_V8 (=1). The
// exact-match pass above would have missed a V8-tagged slice;
// the second pass below catches it. Skip when the triple
// demanded arm64e — V8 is not arm64e.
if (triple_cpu_type == kCpuTypeArm64 &&
triple_cpu_subtype == 0) {
for (const auto& a : archs) {
if (a.cpu_type == kCpuTypeArm64 &&
a.cpu_subtype_masked == 1) {
return pick_and_run(a);
}
}
}
// No slice in the FAT matches the triple at all. Fall through
// to the preference order below — this is the legitimate
// "triple says x86_64 but the FAT only ships arm64{e}" path.
}

// Phase-3 preference order (also the fallback when triple is empty
// or didn't match a known arch). arm64e first, then plain arm64.
// A slice with an EMPTY resolved map is treated as "no chained
// fixups in this slice; try the next" rather than "use this empty
// result."
for (const auto& a : archs) {
if (a.cpu_type == kCpuTypeArm64 &&
a.cpu_subtype_masked == kCpuSubTypeArm64E) {
Expand All @@ -541,19 +612,21 @@ ChainedFixupMap extract_chained_fixups_from_fat(
} // namespace

ChainedFixupMap extract_chained_fixups_from_macho(
const std::uint8_t* macho_bytes, std::size_t macho_size) {
const std::uint8_t* macho_bytes, std::size_t macho_size,
std::string_view triple) {
if (macho_bytes == nullptr || macho_size < 8) {
return {};
}
const std::uint32_t magic = read_u32(macho_bytes);
if (magic == kFatMagicLE) {
return extract_chained_fixups_from_fat(macho_bytes, macho_size,
/*is_fat64=*/false);
/*is_fat64=*/false, triple);
}
if (magic == kFatMagic64LE) {
return extract_chained_fixups_from_fat(macho_bytes, macho_size,
/*is_fat64=*/true);
/*is_fat64=*/true, triple);
}
// Thin Mach-O — no slice to pick, triple is irrelevant.
return extract_chained_fixups_from_thin_macho(macho_bytes, macho_size);
}

Expand Down
43 changes: 40 additions & 3 deletions src/backend/debugger_backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,37 @@ struct XrefProvenance {
// addition (docs/35-field-report-followups.md §3 improvement 3).
std::uint32_t adrp_pair_writeback_cleared = 0;

// Phase 4 item 1 (post-cleanup, docs/35-field-report-followups.md §3):
// conditional branch (b.cond / cbz / cbnz / tbz / tbnz) whose target
// sat in a different function. The branch target is recorded as a
// function_start hint so gate 3 fires when the scanner later reaches
// it. The source-side fall-through tracking is intentionally
// preserved (the original phase-4 unconditional clear was a silent
// wrong-result regression — see cleanup C1). A non-zero value signals
// the scanner saw a cross-function cond-branch and bookkept its
// target.
std::uint32_t adrp_pair_cond_branch_recorded = 0;

// Phase 4 item 3 (docs/35-field-report-followups.md §3): the scanner
// crossed an instruction whose address was previously recorded as a
// function start (a B / BR / BL target inside __TEXT/__text) and
// reset adrp_regs. Catches the stripped-binary case where two
// adjacent functions both report function_name_at = "" and gate 1
// can't tell them apart.
std::uint32_t adrp_pair_function_start_reset = 0;

// Phase 4 item 4 (docs/35-field-report-followups.md §3): the
// scanner saw a load/store it deliberately gave up on resolving
// (pre/post-indexed LDR with untracked base, PC-relative literal
// load, ...). Distinct from adrp_pair_skipped (which is the
// register-offset case); together they cover the universe of
// memops the heuristic can't statically resolve.
std::uint32_t adrp_pair_unresolvable_load = 0;

// Human-readable warnings — phase 3 starts with a single
// "register-offset LDR skipped" warning when adrp_pair_skipped > 0.
// Phase 4 will extend with more codes as additional patterns
// accumulate (auth-rebase semantics, multi-start pages, ...).
// Phase 4 extends with codes for conditional-branch resets,
// function-start resets, and other unresolvable-load shapes.
std::vector<std::string> warnings;
};

Expand Down Expand Up @@ -626,8 +653,18 @@ class DebuggerBackend {
// Returns a result per matching StringMatch; each carries the
// string and the xrefs to its address. Empty result = string not
// found OR no xrefs. Throws backend::Error for invalid target_id.
//
// The `provenance` out-param (optional) is the AGGREGATE of every
// underlying xref_address call's provenance — counters sum, warning
// strings concat. Phase-4 cleanup I1
// (docs/35-field-report-followups.md §3): the prior signature had
// no provenance, so `string.xref` callers silently lost every
// adrp_pair_* diagnostic the ADRP-pair resolver produced. Without
// these counters an agent can't decide whether the heuristic was
// authoritative on this binary.
virtual std::vector<StringXrefResult>
find_string_xrefs(TargetId tid, const std::string& text) = 0;
find_string_xrefs(TargetId tid, const std::string& text,
XrefProvenance* provenance = nullptr) = 0;

// --- Process lifecycle -------------------------------------------------
//
Expand Down
3 changes: 2 additions & 1 deletion src/backend/gdbmi/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,8 @@ GdbMiBackend::xref_address(TargetId tid, std::uint64_t,
}

std::vector<StringXrefResult>
GdbMiBackend::find_string_xrefs(TargetId tid, const std::string&) {
GdbMiBackend::find_string_xrefs(TargetId tid, const std::string&,
XrefProvenance*) {
// Same scope decision as xref_address — composed of find_strings
// (also punted on this backend) + xref_address (also punted), so
// the result would always be empty even if we wired it up. Keep
Expand Down
3 changes: 2 additions & 1 deletion src/backend/gdbmi/backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ class GdbMiBackend final : public DebuggerBackend {
xref_address(TargetId tid, std::uint64_t addr,
XrefProvenance* provenance = nullptr) override;
std::vector<StringXrefResult>
find_string_xrefs(TargetId tid, const std::string& text) override;
find_string_xrefs(TargetId tid, const std::string& text,
XrefProvenance* provenance = nullptr) override;

// ── threads / frames / values ──────────────────────────────────────
std::vector<ThreadInfo> list_threads(TargetId tid) override;
Expand Down
Loading
Loading