Skip to content

feat(aarch64): record why the gap scan refused an address, as intel does - #330

Merged
danielplohmann merged 1 commit into
danielplohmann:masterfrom
r0ny123:fix/aarch64-gap-debug-parity
Sep 11, 2026
Merged

feat(aarch64): record why the gap scan refused an address, as intel does#330
danielplohmann merged 1 commit into
danielplohmann:masterfrom
r0ny123:fix/aarch64-gap-debug-parity

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Item 3 of #322.

intel/FunctionCandidateManager emits a LOGGER.debug on every path in its gap scan that declines a gap pointer. The AArch64 loop emitted nothing at all — its only logging anywhere was one line in the .pdata carver — so the two paths, which #312 made deliberately identical in every other respect, diverged on the one axis that matters when an address is refused and nobody can see why.

What is logged

Twelve refusals now name the address they declined, reusing the intel wording wherever the path is the same one:

path message
already inside the code map intel wording, verbatim
already inside the data map intel wording, verbatim
inside a declared .pdata extent intel wording, verbatim
a declared landing pad intel wording, verbatim
inside a declared FDE range intel wording, verbatim
pointer already handed out intel wording, verbatim
accepted as a candidate intel wording, verbatim
padding word AArch64 stride rather than intel's run length
interior indirect-branch landing pad AArch64 only
trap word AArch64 only
code filter AArch64 only
gap run flows into a mapped function AArch64 only
scan reaching the end of the image intel wording, verbatim

The one path deliberately left silent

The sweep over bytes outside every executable section refuses no candidate — it steps over what is not code at all — and it has no intel counterpart, because the intel scan does not walk non-executable bytes word by word.

Logging it costs nothing measurable but makes the output useless: on the bundled aarch64_static fixture alone it emits 156,758 identical lines, 91% of everything the loop would say. What remains is 15,709 calls on the heaviest corpus cell measured (googletest_gcc-arm64_O2-static), at 0.068µs per suppressed call — 0.02% of that run.

That figure is measured by counting calls rather than by wall clock. Paired interleaved timing on this machine put the delta at +14% to +20% with an 18–37% spread within each side, which is item 4 of this issue in miniature: the noise is larger than the effect, so the call count and the per-call cost are what the claim rests on.

Tests

tests/testAArch64GapScanLogging.py, three cases:

  • the seven refusals the bundled fixture actually walks into are asserted individually, so a path that stops logging fails as itself rather than as a count;
  • every record's address argument is inside the mapped image (which is larger than the file it came from — that caught the first version of this assertion);
  • the repeated-gap-pointer branch is driven directly, because reaching it needs a caller that asks again from the same address rather than from where the last candidate ended. That branch had no coverage before this PR, which is why it shows up here.

Verified to fail when the logging is removed: dropping the FDE-range line alone fails the first case.

logging.disable() is called at import by 68 files in this suite, which suppresses records before any handler sees them and would leave assertLogs with nothing, so each case saves and restores the global disable level.

Gates

  • python -m pytest tests/2,063 passed, 1 skipped, 2,593 subtests
  • ruff check . and ruff format --check . → clean
  • make typecheck → exit 0, 261 diagnostics, identical to master
  • Diff coverage against e9dfe5f100% (13 lines, 0 missing)

No behaviour changes and no fixture baseline moves; the only non-logging edit is the test file.

You marked these as yours in the issue, so this is offered rather than assumed — close it if you would rather take it.

The intel gap scan emits a LOGGER.debug on every path that declines a gap
pointer, so a suppression can be read back from a run. The AArch64 loop
emitted nothing at all, which left the two deliberately identical paths
diverging on the one axis that matters when something is refused and
nobody can see why.

Twelve refusals now name the address they declined, using the intel
wording wherever the path is the same one: already inside the code or
data map, padding, a declared .pdata extent, a declared landing pad, a
declared FDE range, an interior indirect-branch landing pad, a trap
word, a pointer the scan already handed out, the code filter, a run that
flows into a mapped function, and the accepted candidate.

The sweep over bytes outside every executable section is deliberately not
logged. It refuses no candidate - it steps over what is not code at all -
it has no intel counterpart, and on the bundled aarch64_static fixture
alone it would emit 156,758 identical lines, 91% of everything the loop
would say. What remains is 15,709 calls on the heaviest corpus cell
measured, costing 0.02% of that run at 0.068us per suppressed call.

The bundled fixture reaches seven of the twelve, and those are asserted
individually so a path that stops logging fails as itself. The repeated
gap pointer needs a caller that asks again from the same address rather
than from where the last candidate ended, so it is driven directly.
@danielplohmann

Copy link
Copy Markdown
Owner

Reviewed here and good to go. Verified here rather than taken on trust: every message you reuse from intel is verbatim against intel/FunctionCandidateManager.py — the code-map, data-map, .pdata extent, landing-pad, FDE-range, previously-analyzed, finishing and accepted lines all match their counterpart exactly, and the five AArch64-only ones are the paths intel has no equivalent for. The silent sweep over non-executable bytes is the right call for the reason you give, and it is also the one path in the loop that refuses no candidate.

One thing better than intel, worth saying since it is a deliberate difference rather than drift: intel logs using 0x%08x as candidate before its previously-analyzed check, so an x64 run says "using" and then says "was previously analyzed" about the same address. Your loop tests first and logs the acceptance last, so the accepted line means accepted.

One non-blocking gap, and #333 hands you the fixture that closes it. EXPECTED covers the six messages aarch64_static_xored reaches, so the other five — the .pdata extent, the landing pad, the interior BTI pad, the trap word and the code filter — are asserted nowhere, and a typo in one of those literals ships green. The .pdata one stops being unreachable the moment arm64_pe_probe_xored is in the tree: the eight addresses #333 pins are refused by exactly that path, so driving the same assertLogs over that fixture picks up the message for free. Worth folding in whenever you next touch either file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants