fix(bridge): survive Node worker_threads hosts (ESLint --concurrency)#24
Merged
johnsoncodehk merged 5 commits intoJul 23, 2026
Merged
Conversation
ESLint 10 --concurrency runs N worker threads in one process; each gets
its own env and session, but two pieces of process-global state made the
bridge crash under that load:
- The shared text result buffer: returnText wrote every session's JSON
response into one Go-global resultBuf with no lock, while another
thread's napi_create_string_utf8 was still copying from it. Overwrite
mid-copy produced interleaved JSON ("Unexpected non-whitespace
character after JSON"); the grow path's free/realloc mid-copy was a
use-after-free (0xC0000005 / 0xC0000374). The buffer is now per
session (sessions are single-threaded by contract, so no lock), with
a static immutable string for the no-session error. The C shim's
static methodCache moves into the per-env ShimInstance for the same
reason.
- Unload with a live Go runtime: Node unloads an addon when its last
owning env dies, which happens whenever the addon was only ever
required from worker threads. A Go c-shared library can never be
unloaded (runtime threads and its vectored exception handler stay
registered), so the next fault dispatched into unmapped pages and
recursed to death inside RtlpCallVectoredHandlers. NAPI_MODULE_INIT
now pins the module (GetModuleHandleEx PIN / dlopen RTLD_NODELETE).
Witness: tools/triage-bridge-thread-race.mjs — 6 workers hammer
BridgeCall with thread-tagged method names of churning lengths; any
foreign tag in an error message is a torn buffer. Pre-fix it dies with
STATUS_HEAP_CORRUPTION; post-fix 30k calls run clean, and a full
consuming-repo lint at --concurrency 6 passes both mid-run and at
teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Name the invalid-session message once (invalidSessionMsg) and derive both the CString and the binary-path length from it — the literal was typed twice and the two copies had to stay byte-identical by hand. - Drop the no-lock sentence from the package comment (the field comment on sessionEntry.resultBuf is its home) and mend two sentences the edit had split mid-clause. - The probe now sets TNB_LIB_PATH like every other addon-loading triage script, so it runs standalone against a noembed build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…afety # Conflicts: # .github/workflows/ci.yml
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
Running typescript-eslint on top of typescript-native-bridge under ESLint 10's
--concurrencycrashes routinely, in two distinct ways:BridgeClient.apiRequest:3221225477(0xC0000005), sometimes silently after all lint results were already produced.ESLint's
--concurrency Nruns N worker threads in one process (new Workerineslint/lib/eslint/eslint.js). Each worker gets its own napi env and its own bridge session — but they all share one Go runtime insidebridge.node. Two pieces of process-global state couldn't take that.Root cause 1 — shared text-response buffer (the JSON corruption + mid-run AV)
returnTextinbridge.gowrote every session's JSON response into one process-globalresultBuf. The session-map mutex is released beforeHandleRequest/returnText, and the NAPI shim'snapi_create_string_utf8copy happens with no lock at all. With two threads in flight:memcpylands mid-copy of thread A's response → interleaved JSON → theUnexpected non-whitespace charactererrors;free+malloc) frees the buffer A is still reading → use-after-free →0xC0000005/0xC0000374(STATUS_HEAP_CORRUPTION).Fix: the buffer moves into
sessionEntry. A session is only ever called from its owning JS thread, so the per-session buffer needs no lock and the "pointer valid until the session's next call" contract is unchanged. The one text response with no session to own a buffer (invalid session handle) becomes a static, never-rewrittenC.CString. The binary error path (returnBinaryError) rides the same per-session buffer. The C shim's process-staticmethodCachehad the same cross-thread exposure and moves into the per-envShimInstance.Root cause 2 — addon unload with a live Go runtime (the teardown AV)
After fixing the buffer race, lint still died at the end of the run. cdb shows an infinite exception-dispatch recursion through the unloaded module:
Node unloads an addon when its last owning env dies — which happens whenever the addon was only ever
required from worker threads, exactly ESLint's layout (the main thread never loads the config). But a Go c-shared library can never be unloaded: the Go runtime keeps OS threads and a process-wide vectored exception handler registered for the life of the process. AfterFreeLibrary, the next fault dispatches into unmapped pages and recurses to death.Fix:
NAPI_MODULE_INITpins the module —GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN)on Windows,dlopen(RTLD_NODELETE)elsewhere. Idempotent per env init; a no-op for hosts where the addon already lived process-long.Witness
tools/triage-bridge-thread-race.mjs(exit-coded, listed in AGENTS.md's witnesses): 6 workers hammerBridgeCallwith thread-tagged method names of churning lengths; any foreign tag in an error message is a torn buffer, and the worker-only load + dispose exercises the teardown-unload path.30000 calls across 6 workers, 0 corrupted, exit 0.End-to-end: a large consuming ts project that previously crashed on most
--concurrency 6type-aware lint runs now completes repeatedly — clean mid-run and clean at teardown, with only its genuine lint findings.Notes for review
BridgeDisposeSession), ~2 KB + interned method names per env. Single-session hosts (tsc, tsserver, test runners, Volar) see one buffer either way.🤖 Generated with Claude Code