Skip to content

fix: contain allocation failures in matcher-index compilation - #99

Merged
jaronoff97 merged 4 commits into
masterfrom
fix/matcher-index-allocation-failures
Sep 15, 2026
Merged

jaronoff97 merged 4 commits into
masterfrom
fix/matcher-index-allocation-failures

Conversation

@jaronoff97

@jaronoff97 jaronoff97 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #96 and #97. That work fixed the registry double free. Fault injection over LogMatcherIndex.build then found the next layer: the index builder frees nothing on its error path, plus a family of ownership bugs around it.

A failed compile leaked every durable allocation made so far. Two paths in the redact template parser used freed or poisoned memory and crashed.

The bugs

The builder had no error-path teardown. When processPolicy or finish failed, the rate limiters, compiled redacts, typed-check bytes, path storage, id storage, and compiled Hyperscan databases all leaked. All three signal builders share the code, so all three leaked.

Several appends could orphan a copy. The pattern was always the same: duplicate a value, then append it to the list that owns it. A failed append left the copy with no owner. This hit the policy id, the extension config, the attribute path segments, and the typed-check value.

The Hyperscan pattern buffer had the wrong alignment. It was allocated as bytes and cast to Pattern. An allocator that returns 1-aligned memory traps. A plain arena reproduced it.

Scratch space was allocated twice and sized wrong. When a key had both a positive and a negated database, compileDatabase called Scratch.init twice. The second call allocated a fresh scratch and dropped it, and left scratch_pool[0] sized for the positive database only. Scanning the negated database with it is invalid Hyperscan usage. The leak goes through the C allocator, so no Zig allocator can see it.

Invalid hex leaked its decode buffer. The branch allocated first, then returned null on a bad nibble. A null return is a success, so the errdefer never fired. Reachable from raw protobuf callers even though the JSON parser normalizes hex.

Two errdefer bugs in Template.parse caused segfaults. The segment sweep and the list deinit were registered in the order that makes the sweep read the list after deinit poisoned it. Separately, the literal buffer was freed on the success path and again by its errdefer when toOwnedSlice failed.

Two paths reported an allocation failure as a valid pattern. A transient failure to allocate says nothing about the pattern, and swallowing it hid the real cause.

The fixes

  • IndexBuilder.deinit frees everything the builder still owns. finish then empties the moved lists, so the teardown is idempotent and correctness no longer depends on build choosing errdefer over defer.
  • Every allocation in finish carries an errdefer, including the compiled databases, the exists entries, and the extension bindings.
  • compileDatabase frees the pattern metadata on its error path and uses Scratch.initMulti for one correctly sized scratch.
  • Reserve-first appends close each orphan window. The value belongs to its container the moment it exists.
  • compilePatterns uses alignedAlloc, so the slice type carries the alignment and the compiler checks the cast.
  • Hex nibbles are validated before the buffer is allocated, so invalid input allocates nothing.
  • Template.parse frees the literal buffer with defer, exactly once, and the two errdefer lines are ordered correctly.
  • The three build functions were identical apart from their index type, including three copies of the teardown errdefer. They now delegate to one buildIndex.

Tests

An exhaustive fault-injection sweep over LogMatcherIndex.build drives a policy with an attribute path, a rate limit, a redact, an exists matcher, a negated literal, and typed byte and hex equality. At every fault point it asserts containment through a DebugAllocator: no leak, no double free, no crash. It reports the failing fault index and caps the loop.

It uses an explicit loop rather than checkAllAllocationFailures because compilation deliberately converts some failures into "this policy is invalid, keep building the rest", per the spec's Error Handling section. An induced failure therefore does not always surface as error.OutOfMemory.

Also added: checkAllAllocationFailures over Template.parse, a test that invalid hex is skipped and allocates nothing, and a test that valid hex still decodes.

Scope of the guarantee

The sweep proves containment for Zig-side allocations only. Hyperscan and the third-party regex engine allocate through the C allocator, so failures inside them are neither injected nor checked. The fixture carries no extension, so the extension config copy is not covered. The redact rule carries no regex, because the regex engine leaks on its own error path and would mask our cleanup. This change does not touch the dependency or its pin.

Because all three signal types now share one build path, log coverage exercises the same teardown that metric and trace builds use.

Behavior change for callers

patternCompiles and the validation compile now propagate error.OutOfMemory instead of reporting the pattern as valid. A build can therefore fail with OutOfMemory where it previously failed only with TooManyPolicies. Callers that treat a build error as fatal should confirm that is still what they want.

Verification

  • task signoff passes: format, format check, ziglint, zig build test -Doptimize=ReleaseSafe, and the ReleaseSafe build.
  • Zig 0.16.0 on macOS arm64.

🤖 Generated with Claude Code

Follow-up to #96/#97. Fault injection over `LogMatcherIndex.build` found
that the index builder frees nothing on its error path, plus a family of
ownership bugs around it. A failed compile leaked every durable allocation
made so far, and two paths in the redact template parser used freed or
poisoned memory.

Builder teardown
- Add `IndexBuilder.deinit`, which frees the rate limiters, compiled
  redacts, typed-check bytes, path storage, and id storage. All three
  signal builders call it through `errdefer`.
- Give every allocation in `finish` an `errdefer`, including the compiled
  databases and the exists entries. Ownership moves to the index only at
  the final return, so the two teardowns never overlap.
- Free the pattern metadata on the `compileDatabase` error path.

Ownership windows
- Reserve the list slot before each dupe in `storePolicyInfo`,
  `dupeKeyIfNeeded`, the `finish` loop, and the typed-check append, so no
  copy can be orphaned by a failed append.
- Guard the rate limiter with an `errdefer` for the window before it is
  stored.

Correctness
- Fix the alignment of the Hyperscan pattern buffer in `compilePatterns`.
  It was allocated as bytes and cast to `Pattern`, which traps on an
  allocator that returns 1-aligned memory.
- Validate hex nibbles before allocating the decode buffer. Invalid input
  returns null, which is a success, so no `errdefer` could free it.
- Propagate allocation failures out of `patternCompiles` and the
  validation compile instead of reporting the pattern as valid.

redact.zig
- Swap the two `errdefer` lines in `Template.parse`. They ran
  last-in-first-out, so the segment sweep read the list after `deinit`
  poisoned it.
- Free the literal buffer with `defer`, not `errdefer` plus an explicit
  call. A failing `toOwnedSlice` used to deinit it a second time.

Tests
- Exhaustive fault-injection sweep over `LogMatcherIndex.build` using a
  policy with an attribute path, a rate limit, a redact, an exists
  matcher, and typed byte and hex equality. It checks containment at
  every fault point: no leak, no double free, no crash.
- `checkAllAllocationFailures` over `Template.parse`.
- Invalid hex is skipped and allocates nothing; valid hex still decodes.

Known limitation: the sweep uses a redact rule with no regex. The
third-party regex engine leaks on its own error path, and this change
does not touch the dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macroscopeapp

macroscopeapp Bot commented Sep 15, 2026

Copy link
Copy Markdown

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — The PR addresses well-defined allocation and cleanup failures, but it also introduces substantial ownership-transfer and teardown logic across the shared matcher-index build path and changes Hyperscan initialization for negated matchers. That core-runtime scope and complexity warrant human review.

Not approved because:

  • Monthly spending limit reached (workspace setting). Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

jaronoff97 and others added 3 commits September 14, 2026 22:31
Scratch allocation
- `compileDatabase` called `Scratch.init` twice when a key had both a
  positive and a negated database. The second call allocated a fresh
  scratch and dropped it, and left `scratch_pool[0]` sized for the
  positive database only, which is invalid Hyperscan usage when scanning
  the negated one. Use `Scratch.initMulti`, which threads one scratch
  through both `hs_alloc_scratch` calls. The leak is invisible to the
  Zig allocator because it goes through the C allocator.

Builder teardown is now idempotent
- After a successful `finish`, the builder's fields still aliased the
  storage it had handed to the index. Only `errdefer` over `defer` in
  `build` kept that safe. `finish` now empties the moved lists, so
  `deinit` is a no-op afterwards and the invariant is gone.
- Add the missing `errdefer` on `extension_bindings`. It was correct only
  because nothing fallible followed it.

One build path for three signals
- The three `build` functions were identical apart from their index type,
  including three copies of the teardown `errdefer`. Factor the lifecycle
  into `buildIndex`, which they now delegate to. Metric and trace builds
  exercise the same teardown the log sweep covers.

Test
- Add a negated literal matcher to the sweep fixture, so the negated
  pattern list, the second database, and `policies_with_negation` are
  covered.
- Report the failing `fail_index`, cap the loop, and take the leak
  verdict before propagating an unexpected error so `deinit` always runs.
- State the scope of the guarantee: the DebugAllocator sees Zig-side
  allocations only, and the fixture carries no extension.

Note for callers: `patternCompiles` and the validation compile now
propagate `error.OutOfMemory` instead of reporting the pattern as valid.
A build can therefore fail with `OutOfMemory` where it previously failed
only with `TooManyPolicies`. Callers that treat a build error as fatal
should confirm that is still what they want.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Satisfies ziglint Z023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The builder teardown is idempotent now, so the comment no longer needs to
argue that errdefer is safe. Either errdefer or defer works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jaronoff97

Copy link
Copy Markdown
Contributor Author

Stacked follow-up: #101 fixes the dangling-span issue (#100) that came out of the review here. It targets this branch, so its diff is only the span work. I will retarget it to master once this merges.

@jaronoff97
jaronoff97 added this pull request to stack #102 September 15, 2026 02:46
@jaronoff97
jaronoff97 merged commit a77808c into master Sep 15, 2026
6 checks passed
@jaronoff97
jaronoff97 deleted the fix/matcher-index-allocation-failures branch September 15, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants