Skip to content

fix(bridge): survive Node worker_threads hosts (ESLint --concurrency)#24

Merged
johnsoncodehk merged 5 commits into
johnsoncodehk:masterfrom
christopher-buss:fix/worker-thread-safety
Jul 23, 2026
Merged

fix(bridge): survive Node worker_threads hosts (ESLint --concurrency)#24
johnsoncodehk merged 5 commits into
johnsoncodehk:masterfrom
christopher-buss:fix/worker-thread-safety

Conversation

@christopher-buss

@christopher-buss christopher-buss commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Running typescript-eslint on top of typescript-native-bridge under ESLint 10's --concurrency crashes routinely, in two distinct ways:

  1. Mid-lint, type-aware rules throw corrupted responses out of BridgeClient.apiRequest:
    SyntaxError: Unexpected non-whitespace character after JSON at position 125
        at JSON.parse (<anonymous>)
        at BridgeClient.apiRequest (.../typescript-native-bridge/lib/typescript.js)
    
  2. The eslint process exits with 3221225477 (0xC0000005), sometimes silently after all lint results were already produced.

ESLint's --concurrency N runs N worker threads in one process (new Worker in eslint/lib/eslint/eslint.js). Each worker gets its own napi env and its own bridge session — but they all share one Go runtime inside bridge.node. Two pieces of process-global state couldn't take that.

Root cause 1 — shared text-response buffer (the JSON corruption + mid-run AV)

returnText in bridge.go wrote every session's JSON response into one process-global resultBuf. The session-map mutex is released before HandleRequest/returnText, and the NAPI shim's napi_create_string_utf8 copy happens with no lock at all. With two threads in flight:

  • thread B's memcpy lands mid-copy of thread A's response → interleaved JSON → the Unexpected non-whitespace character errors;
  • thread B's grow path (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-rewritten C.CString. The binary error path (returnBinaryError) rides the same per-session buffer. The C shim's process-static methodCache had the same cross-thread exposure and moves into the per-env ShimInstance.

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:

(...): Access violation - code c0000005 (first chance)
<Unloaded_bridge.node>+0x12bc9
...
<Unloaded_bridge.node>+0x8d7c0
ntdll!RtlpCallVectoredHandlers+0xd6
ntdll!RtlDispatchException+0x206
ntdll!KiUserExceptionDispatch+0x2e
<Unloaded_bridge.node>+0x8d7c0      ← the handler itself is in unloaded pages
... (recurses until the stack is gone)

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. After FreeLibrary, the next fault dispatches into unmapped pages and recurses to death.

Fix: NAPI_MODULE_INIT pins 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 hammer BridgeCall with 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.

  • pre-fix binary: dies immediately with STATUS_HEAP_CORRUPTION;
  • post-fix: 30000 calls across 6 workers, 0 corrupted, exit 0.

End-to-end: a large consuming ts project that previously crashed on most --concurrency 6 type-aware lint runs now completes repeatedly — clean mid-run and clean at teardown, with only its genuine lint findings.

Notes for review

  • Memory delta: one result buffer per session instead of one per process (bounded by a session's largest text response, freed in BridgeDisposeSession), ~2 KB + interned method names per env. Single-session hosts (tsc, tsserver, test runners, Volar) see one buffer either way.
  • Sessions from dead workers keep their Go-side state until process exit — unchanged from before; the pin just makes teardown safe.
  • One known-unknown flagged during diagnosis: concurrent sessions still run tsgo package internals concurrently below the bridge. The probe plus repeated full-project concurrent lints came back clean, but that layer wasn't formally audited here.

🤖 Generated with Claude Code

christopher-buss and others added 5 commits July 23, 2026 16:53
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
@johnsoncodehk
johnsoncodehk merged commit fd1e907 into johnsoncodehk:master Jul 23, 2026
@christopher-buss
christopher-buss deleted the fix/worker-thread-safety branch July 23, 2026 23:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants