fix: five critical bugs surfaced by team review (targets v0.1.21)#6
Open
JustinMDotNet wants to merge 2 commits into
Open
fix: five critical bugs surfaced by team review (targets v0.1.21)#6JustinMDotNet wants to merge 2 commits into
JustinMDotNet wants to merge 2 commits into
Conversation
Five bug fixes. The Redscript ones (FxInstance IsDefined guards and the
empty-array OOB guard) close off the high-severity in-flight crashes.
The C++ ones (uint8/Int32 stack-type, singleton-shutdown UAF, unguarded
filesystem ops) close off the high/medium-severity ones flagged by
cpp-quality in the prior team review.
Fixes
1. Hooks/UpdateNavPath.cpp - widen uint8 targetType to int32_t before
passing its address to CStackType("Int32", ...). The old code would
have the Redscript VM read 3 bytes of stack garbage in the upper
bits; deterministic now.
2. InWorldNavigation.reds:UpdateNavPath - guard the dots-population
block with if i >= 0 so an empty points array (i = -1) no longer
OOBs at points[i]. Cleanup loops below still run and correctly kill
stale FX instances.
3. InWorldNavigation.reds - wrap every unguarded fx.BreakLoop() in
if IsDefined(fx) (five sites total - four in for-each loops in
Update and Stop, plus the cleanup loop in UpdateNavPath that gpt's
review flagged). Prevents crash when the engine has already
killed/expired an FxInstance from under us.
4. InWorldNavigation.{hpp,cpp} + Main.cpp - add a static
InWorldNavigation::Shutdown() called from RED4EXT_C_EXPORT Main's
Unload handler. Bypasses ~Handle()'s DecRef entirely by zeroing the
file-scope handle's pointers directly, so the eventual automatic
destruction at DLL unload is a no-op (SharedPtrBase::DecRef() is
guarded by if (refCount)). Removes the risk that DecRef would
Interlocked-decrement into a RefCnt block in possibly-already-freed
game memory - the suspected root cause of historical exit-crash
reports jackhumbert#27/jackhumbert#35/jackhumbert#43. Order in Main: detach hooks FIRST (via
ModModuleFactory::Unload), then Shutdown(), so no game thread can
re-arm the singleton via GetInstance() between the two. Trade-off:
leaks one strong + one weak ref (~16 bytes); OS reclaims at process
exit. CanBeDestructed() returns false anyway, so no destructor path
to leak meaningful resources.
5. Main.cpp - wrap both std::filesystem::remove_all() calls in
try/catch (std::exception and ...). Uncaught exceptions from an
extern "C" Main are std::terminate, which would kill the game over
a legacy-cleanup nicety. Catches widened from filesystem_error to
std::exception per claude-opus-4.7-high's review (covers std::bad_alloc
and other surprises from path-handling internals).
Triple review (per memory policy for memory-safety + cross-process
boundary changes)
- claude-opus-4.8: SHIP-with-fixes (timing uncertainty in original
Reset-based Shutdown; addressed by switching to pointer-zero approach
in this version)
- gpt-5.3-codex: SHIP-with-fixes (incomplete IsDefined coverage in
cleanup loop; addressed in fix #3)
- claude-opus-4.7-high: SHIP-with-fixes (Unload call order would have
re-introduced UAF via GetInstance re-arm; addressed by swapping
Shutdown to AFTER ModModuleFactory::Unload, and by also widening
the exception catch)
All three reviewers' blockers and concerns are addressed. Two of three
also independently flagged the Main.cpp:144 "the game's memory is
already freed" comment as contradicting the new Shutdown rationale;
that comment is reconciled in this PR.
Build: 73/73 ninja targets clean (MSVC 14.51 / Windows 11 SDK
10.0.28000.0). Runtime testing of the exit-crash fix still pending
- the static analysis is internally consistent but the only way to
confirm the exit crash is actually gone is to run a modded game and
close it repeatedly.
Out of scope (deliberately not in this PR)
- Pre-existing race in GetInstance() lazy init (no synchronization;
multiple threads can simultaneously observe handle.instance == nullptr
and both attempt to create-and-assign). Noted by claude-opus-4.7-high
as concern #6. Not introduced by this PR; would benefit from a
separate fix using std::call_once or an atomic CAS.
This PR does not tag a release. Tagging v0.1.21 is a separate step
after merge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Bundles five defensive bug fixes surfaced by a prior team code review: a stack-width fix in the C++ hook trampoline, an empty-array guard and IsDefined guards in the Redscript path, a safe DLL-unload teardown for the singleton handle to avoid an exit-time UAF, and exception guards around filesystem::remove_all in Main.
Changes:
- Widen
targetTypetoint32_tbefore passing its address toCStackType("Int32", …)so the Redscript VM no longer reads stack garbage. - Add
if i >= 0guard around the dots-population block inUpdateNavPathand wrap all fiveFxInstanceaccess sites (Update × 2, Stop × 2, and the cleanup loop) inIsDefined. - Introduce
InWorldNavigation::Shutdown()that bypasses~Handle()/DecRefby zeroing pointers, invoked fromMainUnload after hooks are detached; also wrapremove_allcalls in try/catch.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/red4ext/Hooks/UpdateNavPath.cpp | Widens 1-byte targetType enum into a local int32_t before passing its address to CStackType("Int32", …). |
| src/red4ext/InWorldNavigation.hpp | Declares static void Shutdown() with rationale for bypassing ~Handle() at DLL unload. |
| src/red4ext/InWorldNavigation.cpp | Implements Shutdown() by zeroing the file-scope handle's instance and refCount. |
| src/red4ext/Main.cpp | Wraps remove_all in try/catch; adds InWorldNavigation::Shutdown() after ModModuleFactory::Unload in the Unload handler. |
| src/redscript/in_world_navigation/InWorldNavigation.reds | Adds if i >= 0 guard around dots population and IsDefined(fx) guards around five BreakLoop/Kill sites. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let i = ArraySize(points) - 1; | ||
| let lastDrawnPoint: Vector4 = points[i]; | ||
| let i: Int32 = ArraySize(points) - 1; | ||
| // Guard against an empty points array. The original code derefenced |
Owner
Author
There was a problem hiding this comment.
Fixed in 1415f96. Pure comment-only correction; no code change.
Addresses the Copilot Reviewer comment on PR #6. Pure single-char comment correction, no code change. Skipping a fresh dual review on a follow-up that literally applies the on-PR reviewer's suggestion verbatim (Copilot Reviewer is the review here). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
What
Five bug fixes surfaced by the earlier 5-teammate code review. Does not tag a release. v0.1.21 tag is a separate step after merge.
The 5 fixes
Hooks/UpdateNavPath.cpp:33-37targetTypeisunsigned __int8(1 byte) passed via pointer toCStackType("Int32", ...)— Redscript VM read 3 bytes of stack garbage in the upper bitsint32_tfirst, pass its addressInWorldNavigation.reds:UpdateNavPathpointsarray →i = ArraySize - 1 = -1, thenpoints[i]OOBif i >= 0; cleanup loops below still run and correctly kill stale FXInWorldNavigation.reds(5 sites)fx.BreakLoop()called withoutIsDefined(fx)guard — crash when engine has already killed/expired the instanceif IsDefined(fx). Five sites (the original four + the cleanup loop inUpdateNavPaththat GPT's review flagged as also vulnerable)InWorldNavigation.{hpp,cpp}+Main.cppHandle<InWorldNavigation>'s~Handle()callsDecRef→InterlockedExchangeAddinto aRefCntblock in possibly-already-freed game memory at DLL unload. Suspected root cause of historical exit-crashes jackhumbert#27/jackhumbert#35/jackhumbert#43.static void Shutdown()called fromMainUnload. Bypasses DecRef entirely by zeroing the handle's pointers;SharedPtrBase::DecRef()short-circuits on null refCount at later automatic destruction. Order: detach hooks FIRST (ModModuleFactory::Unload), thenShutdown(), so no game thread can re-arm the singleton viaGetInstance()between the two. Trade-off: leaks ~16 bytes; OS reclaims at process exit.CanBeDestructed()returns false so no destructor path is bypassed in any meaningful way.Main.cpp:114-130std::filesystem::remove_allunguarded inextern "C" Main— throw →std::terminatekills the gametry/catch (const std::exception& e)/catch (...)per claude-opus-4.7-high's recommendation (coversstd::bad_allocand other surprises from path internals, not justfilesystem_error)Triple review (per memory policy for memory-safety + cross-process boundary)
claude-opus-4.8Main.cpp:144commentgpt-5.3-codexBreakLoop/SetBlackboardValue/KillwithoutIsDefined— same crash class as Fix #3if IsDefinedclaude-opus-4.7-highMain.cppUnload order was wrong —Shutdown()beforeModModuleFactory::Unload()left a re-init race window viaUpdateNavPathhook →GetInstance()→ fresh allocation that would then UAF at DLL unloadAll three reviewers' blockers + concerns are addressed.
Local build
73/73 ninja targets clean. MSVC 14.51.36231 / Windows 11 SDK 10.0.28000.0 / VS-bundled CMake 4.2.3 / bundled Ninja 1.11.1. Both
in_world_navigation.dllandmod_settings.dlllinked successfully.What still requires runtime verification
The static reasoning for Fix #4 is internally consistent (the new
Shutdownbypasses DecRef entirely, so we no longer touchRefCntregardless of whether it's alive). The only way to confirm the historical exit crashes are gone is to play with the modded DLL installed and exit the game repeatedly. The PR doesn't claim "this fixes jackhumbert#27/jackhumbert#35/jackhumbert#43"; it claims "this removes the documented UAF mechanism."Out of scope (deliberately not in this PR)
GetInstance()lazy init — no synchronization; multiple threads can simultaneously observehandle.instance == nullptrand both attempt to create-and-assign. Noted byclaude-opus-4.7-highas concern fix: five critical bugs surfaced by team review (targets v0.1.21) #6. Not introduced by this PR; would benefit from a separate fix usingstd::call_onceor an atomic CAS.After merge
Tagging
v0.1.21(separategit tag v0.1.21 && git push --tags) would firerelease.yamland publish the GitHub Release.