π Module Scanned
src/game/ (automated audit scan)
π Summary
AudioSystemManager.deinit calls self.audio_system.deinit() which internally frees the AudioSystem heap allocation via self.allocator.destroy(self). Control then returns to AudioSystemManager.deinit and dereferences self.audio_system.allocator to free the manager itself, reading freed memory (use-after-free / CWE-416). The bug also causes a double-destroy hazard if the allocator's backing page is reused.
π Location
- File:
src/game/audio_system_manager.zig:18-22
- Function/Scope:
AudioSystemManager.deinit
The free of the inner struct happens at:
- File:
modules/engine-audio/src/system.zig:78
- Function/Scope:
AudioSystem.deinit
π΄ Severity: High
- Critical: Crashes, data corruption, security vulnerabilities, GPU device loss
- High: Memory safety bugs (use-after-free), undefined behavior, resource lifecycle violations
- Medium: Performance degradation, missing error handling, suboptimal patterns
- Low: Code style, dead code, minor improvements
π₯ Impact
Whenever the application exits normally (or fails late in init thanks to the errdefer audio_manager.deinit() chain in src/game/app.zig:189), the heap allocator will see reads of an already-freed AudioSystem struct. In a debug/safety allocator this surfaces as a panic ("invalid free" or "use after free"); in release builds it can return a stale allocator pointer that mutates arbitrary memory, silently corrupts the next allocation, or causes a double-free of the manager when the allocator tracks its own state. Because this is on the shutdown path it can manifest as mysterious late-exit crashes that are hard to reproduce and attribute.
π Evidence
src/game/audio_system_manager.zig:
pub fn deinit(self: *AudioSystemManager) void {
self.audio_system.deinit(); // (1) AudioSystem.deinit frees
const allocator = self.audio_system.allocator; // (2) UAF: AudioSystem memory was freed
allocator.destroy(self);
}
modules/engine-audio/src/system.zig:
pub fn deinit(self: *AudioSystem) void {
self.stopAll();
if (self.backend_ptr) |ptr| {
const backend_inst: *sdl_backend.SDLAudioBackend = @ptrCast(@alignCast(ptr));
backend_inst.destroy();
self.backend_ptr = null;
}
self.manager.deinit();
self.allocator.destroy(self); // frees the AudioSystem heap allocation
}
Call site in src/game/app.zig:
errdefer audio_manager.deinit(); // line 189 β triggered on any later init failure
...
self.audio_manager.deinit(); // line 284 β normal shutdown
The pattern of "inner type destroys itself inside its own deinit" is fragile and has already been a source of UAF bugs in this codebase (cf. the comment block at system.zig:62-69 referencing issue #683 about Mixer/SoundData ordering).
π οΈ Proposed Fix
Pick one of the following, in order of preference:
-
Capture the allocator before delegating in AudioSystemManager.deinit:
pub fn deinit(self: *AudioSystemManager) void {
const allocator = self.audio_system.allocator; // capture first
self.audio_system.deinit(); // AudioSystem still valid here
allocator.destroy(self);
}
Optionally, also stash the allocator inside AudioSystemManager at init time so it doesn't depend on the inner struct at all.
-
Make AudioSystem.deinit non-destructive on self: split it so AudioSystem no longer calls self.allocator.destroy(self); let the caller (AudioSystemManager) own the destroy. Update all callers (AudioSystem.init already does errdefer allocator.destroy(self) so the error path is covered). This is more invasive but eliminates the "inner self-destroy" footgun for every future caller and matches the convention recommended in the comment block at system.zig:62-69.
A regression test should be added in src/tests.zig that constructs an AudioSystemManager with a debug-tracking allocator (e.g., std.testing.allocator) and calls deinit, asserting no leak/double-free is reported.
β
Acceptance Criteria
π References
π Module Scanned
src/game/(automated audit scan)π Summary
AudioSystemManager.deinitcallsself.audio_system.deinit()which internally frees theAudioSystemheap allocation viaself.allocator.destroy(self). Control then returns toAudioSystemManager.deinitand dereferencesself.audio_system.allocatorto free the manager itself, reading freed memory (use-after-free / CWE-416). The bug also causes a double-destroy hazard if the allocator's backing page is reused.π Location
src/game/audio_system_manager.zig:18-22AudioSystemManager.deinitThe free of the inner struct happens at:
modules/engine-audio/src/system.zig:78AudioSystem.deinitπ΄ Severity: High
π₯ Impact
Whenever the application exits normally (or fails late in init thanks to the
errdefer audio_manager.deinit()chain insrc/game/app.zig:189), the heap allocator will see reads of an already-freedAudioSystemstruct. In a debug/safety allocator this surfaces as a panic ("invalid free" or "use after free"); in release builds it can return a stale allocator pointer that mutates arbitrary memory, silently corrupts the next allocation, or causes a double-free of the manager when the allocator tracks its own state. Because this is on the shutdown path it can manifest as mysterious late-exit crashes that are hard to reproduce and attribute.π Evidence
src/game/audio_system_manager.zig:modules/engine-audio/src/system.zig:Call site in
src/game/app.zig:The pattern of "inner type destroys itself inside its own
deinit" is fragile and has already been a source of UAF bugs in this codebase (cf. the comment block atsystem.zig:62-69referencing issue #683 about Mixer/SoundData ordering).π οΈ Proposed Fix
Pick one of the following, in order of preference:
Capture the allocator before delegating in
AudioSystemManager.deinit:Optionally, also stash the allocator inside
AudioSystemManageratinittime so it doesn't depend on the inner struct at all.Make
AudioSystem.deinitnon-destructive onself: split it soAudioSystemno longer callsself.allocator.destroy(self); let the caller (AudioSystemManager) own the destroy. Update all callers (AudioSystem.initalready doeserrdefer allocator.destroy(self)so the error path is covered). This is more invasive but eliminates the "inner self-destroy" footgun for every future caller and matches the convention recommended in the comment block atsystem.zig:62-69.A regression test should be added in
src/tests.zigthat constructs anAudioSystemManagerwith a debug-tracking allocator (e.g.,std.testing.allocator) and callsdeinit, asserting no leak/double-free is reported.β Acceptance Criteria
AudioSystemManager.deinitno longer readsself.audio_systemafteraudio_system.deinithas returnednix develop --command zig build testpasses with no leak or double-free diagnostics when the audio manager is exercisedsrc/tests.zigthat constructs and destroys anAudioSystemManagerusingstd.testing.allocatorand confirms clean teardownπ References
modules/engine-audio/src/system.zig:62-69referencing issue [Audit][High] Dangling pointer risk: Mixer holds pointers to SoundData that can be freed by SoundManagerΒ #683 (Mixer/SoundData UAF) for the prior art on this class of bug in the same module.deinit; caller destroys" for heap-allocated wrapper structs to keep destruction order unambiguous.