Fix Lua allocator corruption from disposing a session mid-script - #1986
Merged
Conversation
Tiago Nápoli (tiagonapoli)
force-pushed
the
tiagonapoli/lua-dispose-race
branch
2 times, most recently
from
July 24, 2026 03:41
47e0d72 to
cb348c4
Compare
Tiago Nápoli (tiagonapoli)
marked this pull request as ready for review
July 24, 2026 04:00
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a shutdown-time (and disposal-time) race where a session’s Lua state can be closed (lua_close) from a different thread while a script is still allocating, corrupting the Lua allocator and causing flaky/unrelated failures. The change introduces a per-session “in-flight script” gate and updates EVAL/EVALSHA execution flow, plus adds a regression test to reproduce the race.
Changes:
- Add a per-session execution gate in
SessionScriptCacheso disposal can block until active script executions finish before disposing Lua runners/state. - Refactor EVAL/EVALSHA paths to execute scripts via a single guarded helper that also keeps the “remove runner on failure” path inside the guarded window.
- Add a scripting test that disposes the server mid-script to exercise the race (and make teardown resilient to
server = null).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| test/standalone/Garnet.test.scripting/LuaScriptTests.cs | Adds a regression test that disposes the server during a long-running allocation-heavy Lua script; makes teardown null-safe. |
| libs/server/Lua/SessionScriptCache.cs | Introduces a per-session in-flight execution monitor and uses it to drain executions before disposing cached runners/state. |
| libs/server/Lua/LuaCommands.cs | Refactors EVAL/EVALSHA to run scripts through a single guarded helper that pairs Start/Stop and removes cache entries on failure within the guarded region. |
Comments suppressed due to low confidence (1)
libs/server/Lua/SessionScriptCache.cs:260
- Calling ActiveWorkerMonitor.Dispose() here can race a late StartRunningScript/TryEnter and throw ObjectDisposedException (ManualResetEventSlim was disposed). Also, switching to TryClose() directly is not idempotent (repeated calls can overflow). Prefer closing/draining the monitor once (guarded by a flag) without disposing the underlying event while other threads may still attempt entry.
// Prevent new script executions and block until any in-flight script has exited before
// disposing runners (which frees the Lua state via lua_close). This avoids corrupting the
// single-threaded Lua allocator by freeing it while a script is still executing.
scriptRunMonitor.Dispose();
Tiago Nápoli (tiagonapoli)
force-pushed
the
tiagonapoli/lua-dispose-race
branch
2 times, most recently
from
July 24, 2026 05:47
01fd5e1 to
d9e30b5
Compare
The Lua allocators (managed/tracked/native) are single-thread-by-design: one script runs on one thread at a time. Disposing a session calls SessionScriptCache.Clear -> LuaRunner.Dispose -> lua_close, which frees the entire Lua heap. If a worker thread is concurrently inside LuaRunner.RunForSession allocating on that same allocator, the free-list is corrupted, surfacing as access violations / scrambled state. This races in production during graceful shutdown: GarnetServerBase .DisposeActiveHandlers force-disposes each handler inline on the shutdown thread without waiting for in-flight commands, so a client mid-EVAL hits the same corruption. Fix: gate script execution with an ActiveWorkerMonitor scoped to the Lua boundary (no cost on non-Lua commands). StartRunningScript registers an in-flight worker (and reports if the session is disposing so the caller skips execution); StopRunningScript exits. Clear() closes the monitor and blocks until the in-flight script exits before freeing any runner, so lua_close can never overlap RunForSession. The failure-path Remove is kept inside the guarded window via try/finally to avoid racing Clear on the runner dictionary. Adds DisposeDuringScriptExecutionAllocatorRace, which reliably reproduces the corruption (crashes 5/5 without the fix, passes with it) by disposing the server while a heavily-allocating script is executing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fc1aed1-13af-42b7-886a-d89ab9777404
Replace the fixed Thread.Sleep(250) with a store-write signal the script emits on entry, polled by a second connection, and shrink the alloc loop. The dispose still lands mid-script but the drain wait is much shorter, cutting the test from ~28s to ~4s across the six fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fc1aed1-13af-42b7-886a-d89ab9777404
…posal Document that RunScriptForSession's early return is only reached while the session is being torn down and the connection is closing, so there is no live client to reply to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fc1aed1-13af-42b7-886a-d89ab9777404
Tiago Nápoli (tiagonapoli)
force-pushed
the
tiagonapoli/lua-dispose-race
branch
from
July 24, 2026 05:49
d9e30b5 to
555260e
Compare
kevin-montrose
approved these changes
Jul 24, 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.
Problem
A session's Lua state is closed (lua_close, which frees the entire Lua heap) from SessionScriptCache.Clear() during RespServerSession / GarnetServer disposal. This can execute on a different thread than the one running a script for that session.
The clearest trigger is server shutdown: GarnetServer.Dispose() -> GarnetServerBase.DisposeActiveHandlers() -> RespServerSession.Dispose() -> SessionScriptCache.Clear() -> LuaRunner.Dispose() -> lua_close() runs on the disposing thread, while a .NET ThreadPool network worker may still be inside RunForSession for an in-flight
EVAL/EVALSHA.The Lua allocators (LuaManagedAllocator / LuaLimitedManagedAllocator) are single-threaded by design. Freeing the heap (
lua_close) while a script is still allocating on the same allocator corrupts its free list. The corruption then surfaces as unrelated, hard-to-diagnose failures (scrambled interned strings, bad ACL/RESP argument parsing,Bit/Structerrors, etc.).Root cause evidence
Reproduced deterministically under CPU-throttled containers with allocator-integrity instrumentation. Every capture showed the same two threads on the same allocator:
NUnit/ disposer thread inlua_close(SessionScriptCache.Clear), and.NET TP WorkerinLuaRunner.RunForSession(TryEVAL),racing on the free list.
LuaStateWrapper.Disposealready serializeslua_closewith hook-setting viastateUpdateLockand is idempotent, so the timeout thread and double-dispose are not the culprit - the only unguarded conflict is script execution vs. state close.This is the source of the flaky
LuaScriptTestsfailures, and is also a genuine (if lower-severity) production shutdown-time race.Fix
Add a small interlocked gate in
SessionScriptCache, scoped to script execution only (zero cost for non-Lua commands; one interlocked increment/decrement per script eval):StartRunningScriptincrements an in-flight counter and returnsfalse(refusing to start) once disposal has begun.StopRunningScriptdecrements it.Clearsets adisposingflag and drains in-flight executions (waits for the counter to reach 0) before disposing any runner.Remove-on-failure path is kept inside the guarded window (viatry/finally), so it also cannot raceClearon the runner dictionary.Full store-load fencing via
Interlockedon both sides guarantees a started execution and an in-progress disposal can never both proceed.Testing
dotnet build libs/server/Garnet.server.csproj -c Release— clean (0 warnings).