fix: contain allocation failures in matcher-index compilation - #99
Merged
Merged
Conversation
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>
ApprovabilityVerdict: 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:
Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more. |
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>
This was referenced Sep 15, 2026
Contributor
Author
jaronoff97
added this pull request to stack #102
September 15, 2026 02:46
smithclay
approved these changes
Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #96 and #97. That work fixed the registry double free. Fault injection over
LogMatcherIndex.buildthen 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
processPolicyorfinishfailed, 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,
compileDatabasecalledScratch.inittwice. The second call allocated a fresh scratch and dropped it, and leftscratch_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
nullon a bad nibble. Anullreturn is a success, so theerrdefernever fired. Reachable from raw protobuf callers even though the JSON parser normalizes hex.Two
errdeferbugs inTemplate.parsecaused segfaults. The segment sweep and the listdeinitwere registered in the order that makes the sweep read the list afterdeinitpoisoned it. Separately, the literal buffer was freed on the success path and again by itserrdeferwhentoOwnedSlicefailed.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.deinitfrees everything the builder still owns.finishthen empties the moved lists, so the teardown is idempotent and correctness no longer depends onbuildchoosingerrdeferoverdefer.finishcarries anerrdefer, including the compiled databases, the exists entries, and the extension bindings.compileDatabasefrees the pattern metadata on its error path and usesScratch.initMultifor one correctly sized scratch.compilePatternsusesalignedAlloc, so the slice type carries the alignment and the compiler checks the cast.Template.parsefrees the literal buffer withdefer, exactly once, and the twoerrdeferlines are ordered correctly.buildfunctions were identical apart from their index type, including three copies of the teardownerrdefer. They now delegate to onebuildIndex.Tests
An exhaustive fault-injection sweep over
LogMatcherIndex.builddrives 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 aDebugAllocator: no leak, no double free, no crash. It reports the failing fault index and caps the loop.It uses an explicit loop rather than
checkAllAllocationFailuresbecause 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 aserror.OutOfMemory.Also added:
checkAllAllocationFailuresoverTemplate.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
patternCompilesand the validation compile now propagateerror.OutOfMemoryinstead of reporting the pattern as valid. A build can therefore fail withOutOfMemorywhere it previously failed only withTooManyPolicies. Callers that treat a build error as fatal should confirm that is still what they want.Verification
task signoffpasses: format, format check, ziglint,zig build test -Doptimize=ReleaseSafe, and the ReleaseSafe build.🤖 Generated with Claude Code