Skip to content

fix(safety-bounds): #377 — software bounds enforced on the optimized path; inline UDF trap fixes the direct path's fallthrough no-op - #640

Merged
avrabe merged 1 commit into
mainfrom
fix/377-safety-bounds-optimized
Jul 8, 2026
Merged

fix(safety-bounds): #377 — software bounds enforced on the optimized path; inline UDF trap fixes the direct path's fallthrough no-op#640
avrabe merged 1 commit into
mainfrom
fix/377-safety-bounds-optimized

Conversation

@avrabe

@avrabe avrabe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Closes #377. Flight-safety: jess pins --safety-bounds software for PX4.

The bug (two halves)

  1. Optimized path: --safety-bounds software/mask were silent no-ops on the optimized codegen path (ir_to_arm) — the path that lowers the bulk of a flight loop's i32 loads/stores compiled byte-identical to none while the safety manifest claimed enforcement.
  2. Direct path: the existing check never actually trapped either — its Bhs Trap_Handler label branch resolves to an offset-0 fallthrough no-op in a self-contained image (the label is external, so resolve_label_branches leaves the placeholder). The CMP ran; the trap didn't.

Policy chosen, per mode

Mode Optimized path (before) Optimized path (now) Direct path (now)
software silent no-op inline per-access guard: ADD R12,addr,#off+size-1; CMP R12,R10; BLO +0; UDF #0 same shape — Bhs Trap_Handler → inline BLO +0; UDF #0 in all 8 access shapes
mask silent no-op loud decline → routes memory-accessing functions to the direct selector (in-place AND addr,R10 is unsound on the non-single-use optimized path) unchanged (implements masking)
mpu passthrough passthrough — path-parity pinned by test (bytes == none on both paths) unchanged
none byte-identical to before (pinned by test) unchanged

Why software = implement (not decline): it's the mode jess pins, and declining every memory-accessing function would forfeit the optimized path exactly where PX4 needs it. The guard mirrors the direct selector's end-of-access check (addr+offset+size-1 < R10) and uses the same pre-resolved numeric-guard geometry (BLO +0 skipping a 2-byte UDF) as the DivS/DivU zero traps, which resolved_branch_geometry (#604/#607) already maps — no new branch class. R12 is the encoder scratch (never allocator-assigned, #212) and is re-materialized after the guard. Base-CSE is disabled under software (its folded [R11,#imm] arm bypasses the register-shape guard); safety mode trades the size win for uniform checking. Inline UDF is the #374 bulk-memory/div-trap pattern and works identically under --relocatable (UDF → UsageFault → platform handler).

Estimator: the guard's ADD R12,…,#imm (high rd → always 32-bit ADD.W) was an estimate_arm_byte_size hole (_ => 2 default) that drifted every spanning branch by 2 bytes; new arm added + 3 pinned cases in estimator_encoder_agreement (the #511 oracle).

Red → green evidence

scripts/repro/safety_bounds_377_differential.py — wasmtime ground truth vs unicorn running synth's Thumb-2, both paths (optimized path asserted non-vacuous via SYNTH_PATH_DEBUG), 13 vectors covering in-bounds / exact-boundary (must NOT trap) / first-OOB-byte / far-OOB across all four memory opcode shapes (MemStore/MemLoad/MemStoreSubword/MemLoadSubword). A far-OOB access that unicorn faults on (UNMAPPED) is reported ERR, never a match — only a genuine UDF counts as the trap.

Build optimized path direct path
origin/main 086968a (v0.32.1) 6/13 — all 7 OOB vectors leak 6/13 — all 7 OOB vectors leak
this branch 13/13 13/13

Verification

Salvage provenance

Recovered from an interrupted session (agent hit its session limit at 170 tool uses, worktree lane-377, uncommitted). This PR completes the verification (baseline red run, workspace tests, fmt/clippy) and lands the on-disk state; rebased onto current origin/main.

🤖 Generated with Claude Code

…path; inline UDF trap replaces the fallthrough Bhs Trap_Handler

--safety-bounds software/mask were SILENT NO-OPS on the optimized codegen
path (ir_to_arm) — the path that lowers the bulk of a flight loop's i32
loads/stores compiled byte-identical to `none` while the safety manifest
claimed enforcement. Worse, the direct selector's existing check never
trapped either: its `Bhs Trap_Handler` label branch resolved to an
offset-0 fallthrough no-op in a self-contained image.

Policy per mode:
- software: inline per-access guard on the optimized path
  (`ADD R12,addr,#off+size-1; CMP R12,R10; BLO +0; UDF #0`) mirroring the
  direct selector's end-of-access check — same pre-resolved numeric-guard
  geometry as the DivS/DivU zero traps, so resolved_branch_geometry
  (#604/#607) maps it unchanged. Direct selector's 8 access shapes switch
  from `Bhs Trap_Handler` to the same inline `BLO +0; UDF #0` (#374
  bulk-memory pattern), so the trap is real in self-contained images and
  under --relocatable alike. Base-CSE is disabled under software mode
  (folded [R11,#imm] accesses would bypass the register-shape guard).
- mask: optimized path DECLINES memory-accessing functions to the direct
  selector (in-place `AND addr,R10` is unsound on the non-single-use
  optimized path) — honest degradation, never a silent drop.
- mpu/none: byte-identical to before (pinned by tests).

Estimator: new high-rd `ADD #imm` arm (32-bit ADD.W) in
estimate_arm_byte_size — the guard's end-address compute previously fell
to the `_ => 2` default and drifted every spanning branch by 2 bytes;
pinned in estimator_encoder_agreement (3 new cases).

Verification:
- scripts/repro/safety_bounds_377_differential.py: wasmtime ground truth
  vs unicorn on BOTH paths x 13 vectors (in-bounds / exact-boundary /
  first-OOB / far-OOB across all four memory opcode shapes).
  origin/main (086968a): 14 MISMATCHES (all 7 OOB vectors leak on both
  paths). This branch: 26/26 PASS.
- 8 new unit tests (guard shape/count, end-offset, mask decline, mpu
  parity, flag-off byte-identity); estimator agreement green; full
  workspace 105 suites green; frozen fixtures untouched (none use
  --safety-bounds; flag-off stream pinned byte-identical).

Salvage provenance: work recovered from an interrupted session (agent hit
its session limit at 170 tool uses in worktree lane-377); this commit
completes verification and lands the on-disk state.

Closes #377

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 24 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-synthesis/src/instruction_selector.rs 54.16% 22 Missing ⚠️
crates/synth-synthesis/src/optimizer_bridge.rs 98.95% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit 454a49b into main Jul 8, 2026
31 checks passed
@avrabe
avrabe deleted the fix/377-safety-bounds-optimized branch July 8, 2026 09:56
avrabe added a commit that referenced this pull request Jul 8, 2026
…nonzero facts (#636/#638/#639/#640/#641) (#644)

Pin sweep 0.32.1 -> 0.33.0 + CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Jul 8, 2026
…folded before the AND, final byte clamped) (#654)

--safety-bounds mask (direct/thumb-2 path) applied the mask to the
OPERAND only and added the static offset AFTER the AND:

    and.w r0, r0, r10          ; operand & R10
    movw/movt r12, #offset
    add.w r12, r0, r12         ; offset re-added AFTER the mask
    ldr.w r0, [r11, r12]       ; escapes the bound by up to ~4 GiB

Two bugs, one lowering:
1. offset ordering — WASM's effective address is `operand + offset`
   (u33); masking the operand alone lets any non-zero offset escape.
2. mask value — the AND used R10 = memory SIZE in bytes, not `size-1`:
   for the default 64 KiB memory, `addr & 0x10000` keeps only bit 16,
   remapping IN-BOUNDS accesses (0xffff -> 0).

New lowering (all 8 masking-emission sites funnel through one helper,
`mask_effective_address`):

    [movw/movt r12, #offset    ; offsets > 0xFF materialized
     add  addr, addr, r12]     ; fold the static offset FIRST
     sub  r12, r10, #1         ; mask = size-1, derived per access
     and  addr, addr, r12      ; ea & (size-1)
    [sub  r12, r12, #(size-1)  ; clamp start to size - access_size so
     cmp  addr, r12            ; the access's FINAL byte stays inside
     it hi; movhi addr, r12]   ; the bound (skipped for byte accesses)
     ldr/str [r11, addr]       ; offset 0 — nothing re-added post-mask

Soundness of ADD-then-AND (no decline needed for large offsets): the
u33 effective address `ea = operand + offset < 2^33`; ARM's ADD gives
`ea mod 2^32`, and every set bit of `size-1 < 2^32` lies below bit 32,
so `(ea mod 2^32) & (size-1) == ea & (size-1)` exactly. The final-byte
clamp mirrors #640's `offset + access_size - 1` software-guard rule and
never alters a wasm-defined access: a masked start above
`size - access_size` implies the wasm access traps, which the mask
profile deliberately replaces with a deterministic in-bounds access
(wrap-not-trap, design doc §3.1 path C).

Sites audited: the 8 generate_{load,store,i64_load,i64_store,
i64_load_into_regs,i64_store_from_regs,subword_load,subword_store}
_with_bounds_check Masking arms (all now delegate); arm_encoder has no
mask emission; bulk-memory memory.copy/fill masking remains the
separately-documented #374 gap; the optimized path already declines
mask to the direct selector (#640).

Also: the ARM backend now declines a non-power-of-two linear-memory
size under mask loudly (mirroring the RISC-V backend) — `AND (size-1)`
would silently remap in-bounds addresses.

Oracles:
- tests/issue_651_mask_effective_address.rs — executable differential:
  mini ARM interpreter runs the selected ops and checks the wasm-side
  address of the actual access. 8/8 RED on origin/main (huge-offset
  escape 0xffff0000, small-offset escape, store variant, in-bounds
  address preservation, word/i64 final-byte clamp, byte-access
  exactness, halfword boundary); 8/8 green with the fix.
- selector unit tests pin the emitted sequence (offset ADD strictly
  before the AND, MOVW/MOVT materialization, offset-0 access, no clamp
  for byte accesses).
- frozen anchors 10/10 (fixtures don't use --safety-bounds); full
  workspace suite green; fmt + clippy -D warnings clean.

Closes #651

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

--safety-bounds software/mpu are no-ops on the optimized codegen path (flight-safety: jess pins software for PX4)

1 participant