fix(tauri): prevent Windows event-loop refcount corruption (#996)

## Summary

Fix repeated Windows heap-corruption crashes in the installed Tauri
build. The pinned runtime exposes clonable, thread-safe handles whose
unsafe context graph contains Tao's non-atomic `Rc<EventLoopRunner>`.
IPC-worker clone/drop traffic races the event loop's reference count and
can free the runner prematurely.

- Use `Arc` for that internal Windows runner counter, retaining
main-thread confinement of runner state and the target's existing public
threading contract.
- Add an isolated regression using the real pinned Wry runtime's public
handle API, with a negative control that reverses only this counter in a
temporary crate copy.
- Add the Windows regression to CI and document the captured failures,
compatibility rationale and removal criteria.

This is a local compatibility correction for the runtime CodeNomad
ships. [tao#1290](https://github.com/tauri-apps/tao/pull/1290) proposed
the same counter change but was closed **without merging**, because the
underlying contract violation is Tauri moving/cloning the non-Send
target. That distinction is recorded explicitly. Related reports:
[tauri#15408](https://github.com/tauri-apps/tauri/issues/15408) and
[tauri#15793](https://github.com/tauri-apps/tauri/issues/15793).

## Validation

- `node scripts/test-tauri-runner-refcount.mjs --baseline`: **9 native
crashes / 10 negative-control processes; 5 / 5 corrected passes**,
800,000 handle clones plus teardown per process.
- `node scripts/test-tauri-input-deadlock.mjs --baseline`: keydown,
keyup, char, syschar, IME and instrumented IME-boundary checks pass; the
original/reverted negative controls reproduce their expected deadlocks.
- `cargo build --locked --release -p codenomad-tauri`: passes.
- `rustfmt --check` on the new fixture and `node --check` on its runner:
pass.
- Corrected release executable installed locally on the affected Windows
machine, retaining the existing resources and updater verification
public key; original executable and matching new symbols archived
locally.

The runtime reproduction needs no JavaScript or web page. The Virtua
0.53.5 upgrade is absent from the crashing `3394fc7f` build. Sustained
application use remains additional validation beyond the bounded native
regression.
This commit is contained in:
Pascal André 2026-10-18 02:07:17 +02:00 • committed by GitHub
parent fc16d250cf
commit 54958f4e83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 305 additions and 10 deletions

View file

@ -525,6 +525,9 @@ jobs:
- name: Reproduce and prevent Tao input deadlocks
run: node scripts/test-tauri-input-deadlock.mjs --baseline
- name: Reproduce and prevent Windows runtime handle corruption
run: node scripts/test-tauri-runner-refcount.mjs --baseline
# Exercise the selected-CLI contract on the reported macOS ARM64 platform as
# well as both other hosts, independently of unrelated server-suite failures.
tests-opencode-compatibility:

View file

@ -133,6 +133,8 @@
- Tauri's Tao Windows input backport lives in `packages/tauri-app/vendor/`; preserve upstream provenance and avoid message pumping under input mutexes. Run `node scripts/test-tauri-input-deadlock.mjs --baseline` on Windows when changing it. See `packages/tauri-app/vendor/README.md` for provenance and override removal criteria.
- The pinned Tauri runtime clones native handles on worker threads, including Tao's Windows event-loop target. Its internal runner counter must remain atomic; this does not authorize off-main-thread access to the runner state or make Tao's target Send/Sync. Run `node scripts/test-tauri-runner-refcount.mjs --baseline` after changes to this compatibility fix. The isolated regression uses the actual Wry runtime without an application profile or OpenCode; see `dev-docs/TAURI_WINDOWS_RUNNER_REFCOUNT.md` and the vendor provenance (the referenced upstream proposal was not merged).
- Tao's macOS zoom getter must remain read-only: synchronous temporary style-mask changes on borderless windows generate geometry-event feedback and can starve startup navigation. Keep the exact upstream tao#1182 backport beside the Windows fix, with its provenance in `packages/tauri-app/vendor/README.md`. Run `node scripts/test-tauri-macos-window.mjs --baseline` in a graphical macOS session and retain the ARM64 CI regression. Do not disable window persistence or change the application chrome as a workaround.
- Bundled runtime maintenance keeps `.node-version` exact and proposes only same-major LTS upgrades as regular, non-draft PRs without automatic merge. The scheduled npm CLI/dependency audit is warning-only: never independently replace bundled npm, auto-fix dependencies, merge or publish from that check. Audit failure is not a clean assessment; application runtime changes require reviewed builds/releases. See `dev-docs/RUNTIME_MAINTENANCE.md`.

View file

@ -0,0 +1,85 @@
# Windows Tauri runtime handle corruption
## Captured failure
The installed Windows build `0.20.1-dev-20261017-3394fc7f` crashed repeatedly
on 2026-10-18 at 00:19:50, 00:36:49 and 00:52:27 local time. Windows reported
`STATUS_HEAP_CORRUPTION` (`0xc0000374`) in `ntdll!RtlFreeHeap`.
The first two minidumps have the same application call chain. The leading
application offsets are `+0xbb9f28`, `+0x1043`, `+0x1165`, `+0x2e87b`,
`+0x2ec26`, `+0x611cf8`, followed by the native event callback and Win32
subclass/message dispatch. Disassembly shows the failing release reached via
a non-atomic reference-count decrement (`dec`, without `lock`). The installed
release's matching PDB was not published; these offsets are evidence, not
claimed symbol names.
The original dumps remain in the machine's Windows CrashDumps directory and
must not be committed or uploaded: they can contain application memory.
The installed build pins Virtua 0.51.0. The later Virtua 0.53.5 change (#991)
is absent from it. The native reproducer below does not load any JavaScript.
## Cause and bounded correction
`tauri-runtime-wry` 2.10.1's clonable context contains
`DispatcherMainThreadContext::window_target`. Its unsafe Send/Sync context
permits public runtime handles to move between threads, but cloning/dropping
the Windows target updates Tao's `Rc<EventLoopRunner>` non-atomically.
IPC-related handle traffic can therefore free the runner while the event loop
still owns it. A different ordering can leave a leaked reference instead.
References: tauri-apps/tauri#15408 and #15793. The same two-file counter change
was proposed in tauri-apps/tao#1290, **closed without merging** because Tao's
target is not intended to cross threads. The violation is in the runtime's
unsafe wrapper; CodeNomad's reproducer needs no unsafe Send wrapper of its own.
For the pinned runtime CodeNomad vendors a narrow compatibility correction:
the Windows runner's shared ownership uses `Arc`. Only reference counting
becomes thread-safe; runner state remains main-thread-only, and the window
target's public threading contract is not widened. Other platforms and existing
Windows input/macOS zoom backports retain their behavior.
## Regression
Run on Windows:
```sh
node scripts/test-tauri-runner-refcount.mjs --baseline
node scripts/test-tauri-input-deadlock.mjs --baseline
```
The new fixture creates the real Wry runtime and performs 800,000 public
runtime-handle clones/drops on eight workers, followed by teardown. It opens
no visible window and uses no web page, CodeNomad data, backend or daemon.
Every corrected run must exit successfully. The optional negative control
copies the same crate and lockfile, reverses only the counter correction,
and requires a native fault within ten isolated processes. A surviving
negative-control process is reported explicitly, not counted as a fault.
Initial diagnosis: the original build failed in all three stress runs;
the corrected build completed all five runs (four million clones total).
Debugger inspection of a failing native fixture reached `alloc::rc::Rc::drop_slow`
from a worker thread. The input regression is required alongside this test
because the existing Windows backport shares `event_loop.rs`.
The final scripted negative control reproduced nine native crashes out of ten
processes (one survived); all five corrected processes passed. The input suite
also passed keydown, keyup, char, syschar, IME and the instrumented IME-boundary
case, while each corresponding original/reverted negative control reproduced
its expected deadlock. The release application builds with the same vendor fix.
## Local installation
A release binary built from this worktree with the corrected counter was
installed on the affected machine after normal window shutdown. Existing
resources and the updater verification public key were retained. Its embedded
application version is `0.20.1-dev-20261018-1f132dbb-heapfix` (the Windows PE
version resource still reports the package version, `0.20.1`). SHA-256:
`0e8dce026bd3db3d48d77a4a82820e527e1425d8357214be8851e4ab8e15cea3`
The original executable and matching symbols for the corrected build were
archived locally before replacement. This is a local repair, not a published
release; sustained application use is additional validation beyond the bounded
native regression.

View file

@ -565,6 +565,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "codenomad-windows-runner-fixture"
version = "0.1.0"
dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.7"

View file

@ -1,5 +1,5 @@
[workspace]
members = ["src-tauri", "tests/windows-input", "tests/macos-window", "tests/panel-extension"]
members = ["src-tauri", "tests/windows-input", "tests/windows-runner-refcount", "tests/macos-window", "tests/panel-extension"]
default-members = ["src-tauri"]
resolver = "2"

View file

@ -0,0 +1,10 @@
[package]
name = "codenomad-windows-runner-fixture"
version = "0.1.0"
edition = "2021"
publish = false
[target.'cfg(windows)'.dependencies]
tauri-runtime = "=2.10.1"
tauri-runtime-wry = "=2.10.1"
windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug"] }

View file

@ -0,0 +1,14 @@
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
// Cargo can reuse this compiled build script between the temporary
// negative control and the checkout. Resolve the current path at run time.
let manifest = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR")
.expect("Cargo provides the current manifest directory"),
)
.join("../../src-tauri/examples/window_constraints_windows.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display());
}
}

View file

@ -0,0 +1,52 @@
//! Exercises the real Tauri runtime's safe Send + Clone handle across workers.
//! No CodeNomad profile, backend, shared daemon, web page or visible window.
#[cfg(not(windows))]
fn main() {
eprintln!("This regression requires Windows");
std::process::exit(1);
}
#[cfg(windows)]
fn main() {
use std::{
hint::black_box,
sync::{Arc, Barrier},
thread,
time::Duration,
};
use tauri_runtime::Runtime;
use tauri_runtime_wry::Wry;
use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_NOGPFAULTERRORBOX};
// A negative control must exit without opening a Windows error dialog.
unsafe {
SetErrorMode(SEM_NOGPFAULTERRORBOX);
}
thread::spawn(|| {
thread::sleep(Duration::from_secs(30));
eprintln!("FAIL: runtime handle regression watchdog");
std::process::exit(2);
});
let runtime = Wry::<()>::new(Default::default()).expect("create isolated Wry runtime");
let gate = Arc::new(Barrier::new(9));
let workers: Vec<_> = (0..8)
.map(|_| {
let handle = runtime.handle();
let gate = gate.clone();
thread::spawn(move || {
gate.wait();
for _ in 0..100_000 {
// IPC performs this same safe runtime-handle clone/drop traffic.
drop(black_box(handle.clone()));
}
})
})
.collect();
println!("START: concurrent Tauri runtime handle cloning");
gate.wait();
for worker in workers {
worker.join().expect("runtime worker failed");
}
drop(runtime);
println!("PASS: 800000 runtime handle clones and clean teardown");
}

View file

@ -1,8 +1,9 @@
# Tao native window backports
`tao-0.34.6/` is the published Apache-2.0 crate (license included), with only
the four Windows source files from upstream [tao#1215] and the macOS
`is_zoomed` function from [tao#1182] changed. It is selected
`tao-0.34.6/` is the published Apache-2.0 crate (license included), with
the Windows input backport from upstream [tao#1215], the macOS
`is_zoomed` function from [tao#1182], and the local Windows runner refcount
compatibility correction below. It is selected
by the Tauri workspace's `[patch.crates-io]`; there is no registry-cache mutation
or build-time patching. All platforms keep the same dependency/API version.
@ -49,12 +50,44 @@ events, including reads during geometry capture, and exercises maximize/restore,
fixed-size and decorated windows. Its original-crate negative control reproduces
the feedback loop.
Remove the override only after both native backports are available in the
resolved supported release and both native regressions pass.
Remove the override only after the resolved dependencies cover the input,
read-only zoom and runner lifetime fixes and their native regressions pass.
[tao#1215]: https://github.com/tauri-apps/tao/pull/1215
[tao#1182]: https://github.com/tauri-apps/tao/pull/1182
## Windows runtime handle lifetime
The pinned `tauri-runtime-wry` 2.10.1 exposes `Send + Sync + Clone` runtime
handles whose unsafe context graph contains Tao's non-atomic
`Rc<EventLoopRunner>`. Safe handle clones/drops on IPC workers race the main
thread's reference count, freeing the runner prematurely or leaking it.
The local correction changes **only this internal shared counter** to `Arc`
in `event_loop.rs` and `event_loop/runner.rs`. It neither makes
`EventLoopWindowTarget` Send/Sync nor permits worker access to the runner's
`Cell`/`RefCell` state. Tauri's existing main-thread access discipline remains.
Provenance: [tauri#15408](https://github.com/tauri-apps/tauri/issues/15408),
[tauri#15793](https://github.com/tauri-apps/tauri/issues/15793) and the proposal
[tao#1290](https://github.com/tauri-apps/tao/pull/1290). **That PR was closed
unmerged**: the maintainer correctly identified Tauri's cross-thread use of a
non-Send window target as the contract violation. This is a deliberate local
compatibility correction for the runtime we ship, not a claimed upstream merge
or a general guarantee that Tao window targets are safe to use across threads.
`node scripts/test-tauri-runner-refcount.mjs --baseline` calls the real pinned
Wry runtime's safe public handle API on eight workers. The negative control
reverts only the counter in a temporary crate copy and requires a native fault;
the corrected build must finish every run and tear down cleanly. It uses no
CodeNomad profile, page, backend or OpenCode service. Individual negative-control
processes can survive a race that leaks instead of freeing early.
Keep this correction until the resolved Tauri runtime no longer races Tao's
reference count (or the resolved Tao implementation safely handles that traffic).
Qualify removal with the same regression and the existing Windows input tests.
Captured crash evidence is recorded in `dev-docs/TAURI_WINDOWS_RUNNER_REFCOUNT.md`.
## Updater Windows launch handoff
`tauri-plugin-updater-2.12.0/` is the published MIT/Apache-2.0 crate (licenses

View file

@ -15,7 +15,6 @@ use std::{
ffi::c_void,
marker::PhantomData,
mem, panic,
rc::Rc,
sync::Arc,
thread,
time::{Duration, Instant},
@ -198,7 +197,7 @@ impl<T: 'static> EventLoop<T> {
thread::spawn(move || wait_thread(thread_id, HWND(send_thread_msg_target as _)));
let wait_thread_id = get_wait_thread_id();
let runner_shared = Rc::new(EventLoopRunner::new(thread_msg_target, wait_thread_id));
let runner_shared = Arc::new(EventLoopRunner::new(thread_msg_target, wait_thread_id));
let thread_msg_sender = subclass_event_target_window(thread_msg_target, runner_shared.clone());
raw_input::register_all_mice_and_keyboards_for_raw_input(thread_msg_target, Default::default());

View file

@ -7,7 +7,7 @@ use std::{
cell::{Cell, RefCell},
collections::{HashSet, VecDeque},
mem, panic,
rc::Rc,
sync::Arc,
time::Instant,
};
@ -24,7 +24,10 @@ use crate::{
window::WindowId,
};
pub(crate) type EventLoopRunnerShared<T> = Rc<EventLoopRunner<T>>;
// The pinned Tauri runtime clones/drops this handle through unsafe Send/Sync
// contexts on worker threads. Only the reference count may cross threads;
// the runner's interior remains confined to the event-loop thread.
pub(crate) type EventLoopRunnerShared<T> = Arc<EventLoopRunner<T>>;
pub(crate) struct EventLoopRunner<T: 'static> {
// The event loop's win32 handles
thread_msg_target: HWND,

View file

@ -0,0 +1,85 @@
// Real Wry handle cloning. No CodeNomad profile, backend, page or shared daemon.
import assert from "node:assert/strict"
import { spawnSync } from "node:child_process"
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
assert.equal(process.platform, "win32", "Run this native regression on Windows")
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const workspace = path.join(root, "packages/tauri-app")
const fixture = "tests/windows-runner-refcount"
const target = path.resolve(process.env.CARGO_TARGET_DIR || path.join(workspace, "target"))
const env = { ...process.env, CARGO_TARGET_DIR: target }
delete env.NODE_OPTIONS
delete env.CODENOMAD_NATIVE_PARENT
const binary = path.join(target, "release/codenomad-windows-runner-fixture.exe")
function cargo(args, cwd = workspace) {
const result = spawnSync("cargo", args, { cwd, env, encoding: "utf8", timeout: 600_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true })
assert.equal(result.error, undefined, String(result.error))
assert.equal(result.status, 0, result.stdout + result.stderr)
return result.stdout
}
function run(fixed, count) {
let crashes = 0
for (let index = 0; index < count; index++) {
const result = spawnSync(binary, [], { env, encoding: "utf8", timeout: 35_000, windowsHide: true })
assert.equal(result.error, undefined, String(result.error))
assert.match(result.stdout, /START: concurrent Tauri runtime handle cloning/)
if (fixed) {
assert.equal(result.status, 0, result.stdout + result.stderr)
assert.match(result.stdout, /PASS: 800000 runtime handle clones and clean teardown/)
} else if (result.status !== 0) {
// Native heap/access failures and Rust's abort on an invalid Rc refcount.
assert.ok([0xc000001d, 0xc0000005, 0xc0000374, 0xc0000409].includes(result.status >>> 0),
`Expected the native refcount failure, got ${result.status}: ${result.stdout}${result.stderr}`)
crashes++
} else {
// A refcount race can leak rather than free early. One surviving process
// is not a clean baseline; the bounded negative-control series must crash.
assert.match(result.stdout, /PASS: 800000 runtime handle clones and clean teardown/)
}
console.log(`${fixed ? "fixed PASS" : result.status === 0 ? "negative control survived" : "negative control crash reproduced"} ${index + 1}/${count}: 0x${(result.status >>> 0).toString(16)}`)
}
if (!fixed) assert.ok(crashes > 0, "Negative control did not reproduce the native race")
}
function replaceOnce(source, before, after) {
assert.equal(source.split(before).length, 2, `Expected one counter boundary: ${before}`)
return source.replace(before, after)
}
if (process.argv.includes("--baseline")) {
const temporaryRoot = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryRoot, { recursive: true })
const baseline = await mkdtemp(path.join(temporaryRoot, "tauri-runner-baseline-"))
try {
await cp(path.join(workspace, fixture), path.join(baseline, fixture), { recursive: true })
await cp(path.join(workspace, "vendor/tao-0.34.6"), path.join(baseline, "tao"), { recursive: true })
await cp(path.join(workspace, "Cargo.lock"), path.join(baseline, "Cargo.lock"))
const manifest = "src-tauri/examples/window_constraints_windows.manifest"
await mkdir(path.dirname(path.join(baseline, manifest)), { recursive: true })
await cp(path.join(workspace, manifest), path.join(baseline, manifest))
await writeFile(path.join(baseline, "Cargo.toml"), `[workspace]\nmembers = ["${fixture}"]\nresolver = "2"\n[patch.crates-io]\ntao = { path = "tao" }\n`)
// Reverse only this correction in a temporary copy. Existing input and macOS
// backports, dependencies and the public Wry API exercised remain identical.
const runner = path.join(baseline, "tao/src/platform_impl/windows/event_loop/runner.rs")
let source = await readFile(runner, "utf8")
source = replaceOnce(source, " sync::Arc,", " rc::Rc,")
source = replaceOnce(source, "= Arc<EventLoopRunner<T>>;", "= Rc<EventLoopRunner<T>>;")
await writeFile(runner, source)
const loopFile = path.join(baseline, "tao/src/platform_impl/windows/event_loop.rs")
await writeFile(loopFile, replaceOnce(await readFile(loopFile, "utf8"),
"let runner_shared = Arc::new(", "let runner_shared = std::rc::Rc::new("))
cargo(["build", "--release", "-p", "codenomad-windows-runner-fixture"], baseline)
run(false, 10)
} finally { await rm(baseline, { recursive: true, force: true }) }
}
const metadata = JSON.parse(cargo(["metadata", "--locked", "--format-version", "1"]))
const tao = metadata.packages.filter(item => item.name === "tao")
assert.equal(tao.length, 1)
assert.equal(path.resolve(tao[0].manifest_path), path.join(workspace, "vendor/tao-0.34.6/Cargo.toml"))
cargo(["build", "--locked", "--release", "-p", "codenomad-windows-runner-fixture"])
run(true, 5)