CodeNomad/scripts/test-tauri-input-deadlock.mjs
Pascal André 89db55d340
fix(tauri): prevent Windows input reentrancy deadlocks (#773)
Backport upstream tao#1215 unchanged onto Tao 0.34.6 to correct the same-thread native input mutex deadlock identified in a symbolized Windows hang dump. Preserve the existing Tauri dependency/API line, all non-Windows sources, licensing and exact published-crate/patch provenance through a workspace Cargo override.

Add real isolated Win32 reentry regressions for keydown, keyup, character, system-character and IME input. Original-crate controls reproduce bounded deadlocks; the backport passes all five cases. Independently cover the IME lock boundary with temporary-source instrumentation and require an IME-only lock-order mutation to fail, preventing keyboard pre-peeking from masking an IME regression.

Independent gatekeeper pass 1 identified the IME coverage gap; pass 2 on 462f9eac reports zero actionable findings and independently reran all controls. Full local Tauri tests passed 181/181. Administrator squash merge requested by the user; remaining CI jobs were still running at merge.
2026-09-24 21:02:20 +02:00

93 lines
5.3 KiB
JavaScript

// Real Win32/Tao input reentrancy. No CodeNomad profile, backend or shared daemon.
import assert from "node:assert/strict"
import { spawnSync } from "node:child_process"
import { mkdtemp, mkdir, readFile, writeFile, cp, rm } 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 = path.join(workspace, "tests/windows-input")
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
function cargo(args, cwd = workspace) {
const result = spawnSync("cargo", args, { cwd, env, encoding: "utf8", timeout: 300_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true })
assert.equal(result.error, undefined, String(result.error))
assert.equal(result.status, 0, result.stdout + result.stderr)
return result.stdout
}
const binary = path.join(target, "debug/codenomad-windows-input-fixture.exe")
const cases = ["keydown", "keyup", "char", "syschar", "ime"]
function check(expected, scenarios = cases) {
for (const scenario of scenarios) {
const result = spawnSync(binary, [scenario], { cwd: fixture, env, encoding: "utf8", timeout: 15_000, windowsHide: true })
assert.equal(result.error, undefined, `${scenario}: ${result.error}`)
assert.match(result.stdout, /nested focus queued before Tao input processing/, `${scenario}: ${result.stdout}${result.stderr}`)
assert.equal(result.status, expected, `${scenario}: ${result.stdout}${result.stderr}`)
if (expected === 0) assert.match(result.stdout, /PASS:/)
else assert.match(result.stderr, /input callback watchdog/)
console.log(`${expected === 0 ? "fixed PASS" : "negative control deadlock reproduced"}: ${scenario}`)
}
}
// Optional negative control downloads the original published crate through Cargo.
// Build it in a separate temporary workspace so the production patch is untouched.
if (process.argv.includes("--baseline")) {
const temporaryRoot = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryRoot, { recursive: true })
const baseline = await mkdtemp(path.join(temporaryRoot, "tao-input-baseline-"))
try {
await cp(path.join(fixture, "src"), path.join(baseline, "src"), { recursive: true })
await writeFile(path.join(baseline, "Cargo.toml"), (await readFile(path.join(fixture, "Cargo.toml"), "utf8")) + "\n[workspace]\n")
cargo(["build", "--manifest-path", path.join(baseline, "Cargo.toml")], baseline)
check(2)
} 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, "Tauri and the regression must share one Tao implementation")
assert.equal(path.resolve(tao[0].manifest_path), path.join(workspace, "vendor/tao-0.34.6/Cargo.toml"))
cargo(["build", "--locked", "-p", "codenomad-windows-input-fixture"])
check(0)
// Queue at the IME boundary itself: the keyboard pre-peek must not consume the
// sent focus message first. Instrument ONLY a temporary copy, preserving every
// production statement and the checked-in upstream source byte-for-byte.
const temporaryRoot = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryRoot, { recursive: true })
const imeRoot = await mkdtemp(path.join(temporaryRoot, "tao-ime-boundary-"))
function replaceOnce(source, before, after) {
assert.equal(source.split(before).length, 2, `Expected one instrumentation boundary: ${before}`)
return source.replace(before, after)
}
try {
await cp(path.join(fixture, "src"), path.join(imeRoot, "src"), { recursive: true })
await cp(path.join(workspace, "vendor/tao-0.34.6"), path.join(imeRoot, "tao"), { recursive: true })
await writeFile(path.join(imeRoot, "Cargo.toml"),
(await readFile(path.join(fixture, "Cargo.toml"), "utf8")) + '\n[workspace]\n[patch.crates-io]\ntao = { path = "tao" }\n')
const eventLoop = path.join(imeRoot, "tao/src/platform_impl/windows/event_loop.rs")
const original = (await readFile(eventLoop, "utf8")).replaceAll("\r\n", "\n")
const instrumented = replaceOnce(original, " let ime_callback = || {", ` let ime_callback = || {
if matches!(msg, WM_CHAR | WM_SYSCHAR) {
unsafe { SendMessageW(window, WM_APP + 0x434, None, None); }
}`)
await writeFile(eventLoop, instrumented)
cargo(["build", "--manifest-path", path.join(imeRoot, "Cargo.toml")], imeRoot)
check(0, ["ime-boundary"])
// Mutation control: only move the real IME peek back under window_state.
// This must hang even though keyboard input still uses the upstream fix.
const peek = " let more_char_coming = more_ime_char_coming(window, msg);\n"
const lock = " let text = {\n let mut window_state = subclass_input.window_state.lock();\n"
const mutated = replaceOnce(replaceOnce(instrumented, peek, ""), lock, lock + peek)
await writeFile(eventLoop, mutated)
cargo(["build", "--locked", "--manifest-path", path.join(imeRoot, "Cargo.toml")], imeRoot)
check(2, ["ime-boundary"])
} finally {
await rm(imeRoot, { recursive: true, force: true })
}