fix(tauri): prevent macOS startup window event feedback (#821)

Backport the exact read-only macOS maximize getter from Tao #1182 into the existing 0.34.6 vendor override, preserving the Windows input backport. The original borderless getter changed AppKit style masks and generated geometry events whose persistence capture called the getter again, starving startup independently of OpenCode readiness. Window chrome, persistence policy and shared OpenCode lifecycle remain unchanged.

Add a bounded native AppKit regression with isolated original-crate negative-control artifacts, repeated warm-cache runs, getter-phase capture evidence, maximize/restore and fixed-size/decorated cases. Reject unrelated watchdog timeouts and validate runner results with platform-independent tests. Run the native regression in macOS ARM64 CI.

Validation includes the macOS Intel VM reproduction and rebuilt Tauri startup, second-window quit/relaunch with isolated OpenCode 2.0.21, 181 macOS and 188 Windows Tauri tests, Windows input negative controls, 44 targeted Electron regressions, successful ARM64 CI tests and a published ARM64 test artifact. Both independent gatekeeper findings were fixed and re-reviewed to zero findings. All test jobs passed; remaining platform builds were still running at the explicitly requested admin merge.

Fixes #819.
This commit is contained in:
Pascal André 2026-10-01 14:58:32 +02:00 • committed by GitHub
parent fb2ae42aeb
commit 46a4ea0a85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 448 additions and 20 deletions

View file

@ -530,6 +530,13 @@ jobs:
working-directory: packages/tauri-app/src-tauri
run: CODENOMAD_TEST_ZSH=/bin/zsh CODENOMAD_TEST_NODE="$(command -v node)" cargo test --locked -- --test-threads=4
- name: Reproduce and prevent macOS window event feedback
timeout-minutes: 10
run: |
node --test scripts/test-tauri-macos-window-result.test.mjs
node scripts/test-tauri-macos-window.mjs --baseline
node scripts/test-tauri-macos-window.mjs --baseline
- name: Test owned Tauri zoom input script
run: node --test packages/tauri-app/tests/window-zoom-script.test.mjs

View file

@ -47,6 +47,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 `dev-docs/TAURI_WINDOWS_INPUT_DEADLOCK.md` for the captured failure and override removal criteria.
- 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. See `dev-docs/TAURI_MACOS_STARTUP.md`; do not disable window persistence or change the application chrome as a workaround.
- Verified shared npm OpenCode installations at 2.0.15+ delegate version changes to native `upgrade` through `opencode-update/native-upgrade.ts`, with bundled npm scoped to the verified prefix. Native Windows image retention replaces the write preflight only on that path; first install, older migration and same-version repair retain direct npm/preflight. Never retry a failed native mutation via npm or restart the daemon implicitly. Validate with the isolated `scripts/test-opencode-upgrade-native.mjs` fixture.
- Git is a full-functionality prerequisite, with directory-only degraded conversations when the backend cannot find Git. Only the explicitly opened physical folder is session authority in that mode; never infer sibling worktrees from native project IDs. Keep ancestor/descendant mutation identities covered by the deletion fence across Git availability changes. Inform agents through the owned native `codenomad.git-availability` instruction before prompts/custom commands, remove stale context after recovery, and keep this advisory separate from fail-closed environment synchronization. No blocking Git setup UI. Validate with `scripts/test-git-degraded-native.mjs` using an isolated CLI/database and provider.

View file

@ -0,0 +1,83 @@
# macOS startup: borderless maximize getter event feedback
## Captured failure
The published development release `v0.20.0-dev-20261001-77692a73` reproduces a
startup hang in a graphical macOS 15.8 (24H23) Intel Hyper-V guest. Its main
thread stays busy in this chain:
`setup_local_window` geometry event → `capture_window_in_memory` →
`read_window_geometry` → `Window::is_maximized` → Tao `is_zoomed` →
`set_style_mask_sync` → AppKit `-[NSWindow setStyleMask:]`.
The process remains near one full CPU core. The backend starts independently,
but the native event loop cannot complete loading-screen navigation. The hang
also occurs in a fresh scoped CodeNomad profile with an intentionally missing
OpenCode executable, before OpenCode can connect. The previously installed
0.20.1 application shows the same sampled path.
This is not the writer-lock/thread explosion fixed for #676. That fix remains
necessary: native getters still run outside client-state locks, and geometry
publication still uses the single debounced worker.
## Narrow fix and provenance
Backport the exact macOS source hunk from Tao upstream commit
`2fb512315899973e2c64d00c0e34ed382d93c893` (tao#1182) into the existing
`packages/tauri-app/vendor/tao-0.34.6/` override. The old getter temporarily
changed the borderless window's style mask twice, generating new geometry
events whose capture called the same getter again. The new getter reads the
window and screen frames instead. Titled/resizable windows and missing-screen
fallback retain AppKit's native `isZoomed` query.
No Tauri dependency/API upgrade, application chrome change, persistence
workaround or shared OpenCode lifecycle change is involved. The Windows input
backport remains intact; Electron uses its own native read-only getter and
requires no production change.
## Validation
- `node scripts/test-tauri-macos-window.mjs --baseline` requires a graphical
macOS session. It builds the original published Tao as a negative control in
a temporary workspace and the backport in the real workspace. Both use the
same native fixture, with a bounded watchdog and no application/backend data.
The baseline has its own target directory; the patched artifact is warmed
before it and rechecked afterwards. CI runs the command twice consecutively.
Only a normal getter/read-event assertion or a watchdog in that exact phase
with at least 100 capture callbacks counts as feedback reproduction. Setup,
later-phase and low-evidence timeouts fail the negative control.
- The fixture checks read-generated move/resize events, getters inside geometry
capture, normal bounds, maximize/restore, fixed-size and decorated windows.
- The PR macOS ARM64 job runs the regression in addition to the Tauri crate
tests. Packaging success alone does not qualify this event-loop behavior.
- Run the existing Windows input regression with `--baseline` and the Electron
window/persistence tests to protect cross-host behavior.
Local non-macOS checks passed: 188 Tauri crate tests on Windows, all six
Windows input cases with their negative controls, and 44 Electron
window/restore/navigation/startup tests. The upstream source patch also passes
`git apply --check --reverse` against the vendored file.
The native macOS fixture passed in the Intel 15.8 VM: the original published
crate reproduced geometry-event feedback, and the backport passed all
read-only getter and maximize/restore checks. The release Tauri binary was
rebuilt from the `fb2ae42a` development source plus the backport, using the
published release's bundled Node/server/UI resources in a separate test tree.
The rebuilt package-mode application reaches the interface in the VM (confirmed
in the Hyper-V console by the tester), with the native process at 0.0% CPU at
idle. A second native launch opens a separate window and a synthetic project
using OpenCode 2.0.21 in a separate HOME/XDG/database environment. After a native
AppleScript Quit and relaunch, two retained V3 window records are restored and
the isolated service remains on the same endpoint. No shared daemon or existing
application installation is modified. Guest `screencapture` returns white
frames in this Hyper-V display setup, so those captures are not visual evidence.
The available local VM is Intel, not Apple Silicon. Direct local ARM64
reproduction is unavailable; keep that distinction when reporting validation.
## Override removal
Remove the workspace override only when the supported resolved Tao release
includes both tao#1182 and tao#1215 and both native negative-control regressions
pass. Do not replace registry-cache sources or patch them at build time.

View file

@ -495,6 +495,13 @@ dependencies = [
"cc",
]
[[package]]
name = "codenomad-macos-window-fixture"
version = "0.1.0"
dependencies = [
"tao",
]
[[package]]
name = "codenomad-tauri"
version = "0.20.0"

View file

@ -1,9 +1,10 @@
[workspace]
members = ["src-tauri", "tests/windows-input"]
members = ["src-tauri", "tests/windows-input", "tests/macos-window"]
default-members = ["src-tauri"]
resolver = "2"
# Tao 0.34.x lacks the upstream Windows input reentrancy fix (tao#1215).
# Tao 0.34.x lacks Windows input reentrancy (tao#1215) and macOS
# read-only zoom detection (tao#1182) fixes.
# Keep the current Tauri API/dependency line; see vendor/README.md for provenance.
[patch.crates-io]
tao = { path = "vendor/tao-0.34.6" }

View file

@ -0,0 +1,8 @@
[package]
name = "codenomad-macos-window-fixture"
version = "0.1.0"
edition = "2021"
publish = false
[target.'cfg(target_os = "macos")'.dependencies]
tao = "=0.34.6"

View file

@ -0,0 +1,134 @@
// Real AppKit/Tao regression, with no CodeNomad profile, backend or OpenCode.
#[cfg(target_os = "macos")]
fn main() {
use std::{
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tao::{
dpi::LogicalSize,
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::WindowBuilder,
};
let progress = Arc::new((AtomicUsize::new(0), AtomicUsize::new(0)));
let watchdog = progress.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(20));
let phase = watchdog.0.load(Ordering::SeqCst);
let captures = watchdog.1.load(Ordering::SeqCst);
if phase == 1 && captures >= 100 {
eprintln!("FAIL: getter event feedback watchdog: phase={phase} captures={captures}");
} else {
eprintln!("FAIL: unrelated native window timeout: phase={phase} captures={captures}");
}
std::process::exit(2);
});
let event_loop = EventLoop::new();
let window = WindowBuilder::new()
.with_title("CodeNomad isolated macOS window regression")
.with_decorations(false)
.with_inner_size(LogicalSize::new(600.0, 500.0))
.build(&event_loop)
.unwrap();
let mut phase = 0;
let mut deadline = Instant::now() + Duration::from_secs(1);
let mut query_during_events = false;
let mut events = 0;
let mut before_reads = 0;
let mut normal_size = window.inner_size();
let mut normal_position = window.outer_position().unwrap();
event_loop.run(move |event, _, flow| {
*flow = ControlFlow::WaitUntil(deadline);
match event {
Event::WindowEvent {
event: WindowEvent::Resized(_) | WindowEvent::Moved(_),
..
} => {
events += 1;
if query_during_events {
progress.1.fetch_add(1, Ordering::SeqCst);
// Same feedback boundary as production geometry capture.
let _ = window.is_maximized();
}
}
Event::MainEventsCleared if Instant::now() >= deadline => {
match phase {
0 => {
println!("BEGIN: borderless getter feedback probe");
progress.0.store(1, Ordering::SeqCst);
normal_size = window.inner_size();
normal_position = window.outer_position().unwrap();
assert!(!window.is_maximized());
query_during_events = true;
before_reads = events;
for _ in 0..100 {
assert!(!window.is_maximized());
}
}
1 => {
progress.0.store(2, Ordering::SeqCst);
assert_eq!(events, before_reads, "normal reads generated geometry events");
assert_eq!(window.inner_size(), normal_size);
assert_eq!(window.outer_position().unwrap(), normal_position);
window.set_maximized(true);
}
2 => {
assert!(window.is_maximized(), "maximize must remain functional");
before_reads = events;
for _ in 0..100 {
assert!(window.is_maximized());
}
}
3 => {
assert_eq!(events, before_reads, "maximized reads generated geometry events");
window.set_maximized(false);
}
4 => {
assert!(!window.is_maximized());
assert_eq!(window.inner_size(), normal_size, "restore lost normal bounds");
assert_eq!(window.outer_position().unwrap(), normal_position);
window.set_resizable(false);
}
5 => {
before_reads = events;
for _ in 0..100 {
assert!(!window.is_maximized());
}
}
6 => {
assert_eq!(events, before_reads, "fixed-size reads generated geometry events");
window.set_resizable(true);
window.set_decorations(true);
}
7 => {
before_reads = events;
for _ in 0..100 {
assert!(!window.is_maximized());
}
}
8 => {
assert_eq!(events, before_reads, "decorated reads generated geometry events");
println!("PASS: read-only zoom checks, native event capture, maximize/restore and fixed-size/decorated windows");
*flow = ControlFlow::Exit;
return;
}
_ => unreachable!(),
}
phase += 1;
deadline = Instant::now() + Duration::from_secs(1);
}
_ => {}
}
});
}
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("Run this native regression on macOS with a graphical session.");
std::process::exit(1);
}

View file

@ -1,7 +1,8 @@
# Tao Windows input deadlock backport
# 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] changed. It is selected
the four Windows source files from upstream [tao#1215] and the macOS
`is_zoomed` function from [tao#1182] changed. 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.
@ -30,4 +31,27 @@ Remove this override when the supported Tauri dependency range can resolve a
published Tao release containing the fix; run the same regression first. Merely
bumping within the unfixed 0.34.x/0.35.x release lines is insufficient.
## macOS read-only zoom detection
The macOS backport is the exact `window.rs` hunk from upstream commit
`2fb512315899973e2c64d00c0e34ed382d93c893` ([tao#1182]). Its patch is
`patches/tao-1182.patch`. Windows and Linux behavior is unchanged.
On borderless windows, the old maximize getter temporarily changed the native
style mask twice. These writes generate resize/move events. CodeNomad's geometry
capture reads the getter again, sustaining a main-thread event feedback loop
before loading-screen navigation completes. The upstream getter uses read-only
AppKit frame/screen queries instead. It retains native `isZoomed` for ordinary
titled, resizable windows and when no screen is available.
Run `node scripts/test-tauri-macos-window.mjs --baseline` in a graphical macOS
session. The isolated native fixture checks that getters generate no geometry
events, including reads during geometry capture, and exercises maximize/restore,
fixed-size and decorated windows. Its original-crate negative control reproduces
the feedback loop. See `dev-docs/TAURI_MACOS_STARTUP.md` for real application tests.
Remove the override only after both native backports are available in the
resolved supported release and both native regressions pass.
[tao#1215]: https://github.com/tauri-apps/tao/pull/1215
[tao#1182]: https://github.com/tauri-apps/tao/pull/1182

View file

@ -0,0 +1,54 @@
From: https://github.com/tauri-apps/tao/commit/2fb512315899973e2c64d00c0e34ed382d93c893
Subject: fix(macos): remove synchronous styleMask mutations in is_zoomed (#1182)
Exact upstream source hunk; the upstream .changes entry is not vendored.
diff --git a/src/platform_impl/macos/window.rs b/src/platform_impl/macos/window.rs
--- a/src/platform_impl/macos/window.rs
+++ b/src/platform_impl/macos/window.rs
@@ -974,24 +974,30 @@ impl UnownedWindow {
}
pub(crate) fn is_zoomed(&self) -> bool {
- // because `isZoomed` doesn't work if the window's borderless,
- // we make it resizable temporalily.
- let curr_mask = unsafe { self.ns_window.styleMask() };
-
- let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable;
- let needs_temp_mask = !curr_mask.contains(required);
- if needs_temp_mask {
- self.set_style_mask_sync(required);
- }
+ // Previously, is_zoomed temporarily mutated the window's styleMask(or resizable)
+ // to force macOS to return a valid result for borderless windows.
+ // This synchronous mutation could trigger unnecessary layout passes or state inconsistencies.
+ // Related issue: https://github.com/rust-windowing/winit/issues/4071 and https://github.com/tauri-apps/plugins-workspace/issues/3240
- let is_zoomed: bool = unsafe { self.ns_window.isZoomed() };
+ unsafe {
+ let curr_mask = self.ns_window.styleMask();
- // Roll back temp styles
- if needs_temp_mask {
- self.set_style_mask_sync(curr_mask);
- }
+ let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable;
+ if curr_mask.contains(required) {
+ return self.ns_window.isZoomed();
+ }
- is_zoomed
+ if let Some(screen) = self.ns_window.screen() {
+ let frame = self.ns_window.frame();
+ let visible_frame = screen.visibleFrame();
+
+ return (frame.size.width - visible_frame.size.width).abs() < 1.0
+ && (frame.size.height - visible_frame.size.height).abs() < 1.0;
+ }
+
+ // Fallback to original `isZoomed` check
+ self.ns_window.isZoomed()
+ }
}
fn saved_style(&self, shared_state: &mut SharedState) -> NSWindowStyleMask {

View file

@ -974,24 +974,30 @@ impl UnownedWindow {
}
pub(crate) fn is_zoomed(&self) -> bool {
// because `isZoomed` doesn't work if the window's borderless,
// we make it resizable temporalily.
let curr_mask = unsafe { self.ns_window.styleMask() };
// Previously, is_zoomed temporarily mutated the window's styleMask(or resizable)
// to force macOS to return a valid result for borderless windows.
// This synchronous mutation could trigger unnecessary layout passes or state inconsistencies.
// Related issue: https://github.com/rust-windowing/winit/issues/4071 and https://github.com/tauri-apps/plugins-workspace/issues/3240
let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable;
let needs_temp_mask = !curr_mask.contains(required);
if needs_temp_mask {
self.set_style_mask_sync(required);
unsafe {
let curr_mask = self.ns_window.styleMask();
let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable;
if curr_mask.contains(required) {
return self.ns_window.isZoomed();
}
if let Some(screen) = self.ns_window.screen() {
let frame = self.ns_window.frame();
let visible_frame = screen.visibleFrame();
return (frame.size.width - visible_frame.size.width).abs() < 1.0
&& (frame.size.height - visible_frame.size.height).abs() < 1.0;
}
// Fallback to original `isZoomed` check
self.ns_window.isZoomed()
}
let is_zoomed: bool = unsafe { self.ns_window.isZoomed() };
// Roll back temp styles
if needs_temp_mask {
self.set_style_mask_sync(curr_mask);
}
is_zoomed
}
fn saved_style(&self, shared_state: &mut SharedState) -> NSWindowStyleMask {

View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict"
export function verifyNativeWindowResult(result, baseline = false) {
const output = result.stdout + result.stderr
assert.equal(result.error, undefined, String(result.error))
assert.equal(result.signal, null, output)
if (!baseline) {
assert.equal(result.status, 0, output)
assert.match(result.stdout, /PASS: read-only zoom checks/)
return
}
assert.ok(result.status === 2 || result.status === 101, output)
assert.match(result.stdout, /BEGIN: borderless getter feedback probe/)
if (result.status === 101) {
assert.match(result.stderr, /normal reads generated geometry events/)
} else {
const evidence = result.stderr.match(/FAIL: getter event feedback watchdog: phase=1 captures=(\d+)/)
assert.ok(evidence && Number(evidence[1]) >= 100, output)
}
}

View file

@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { verifyNativeWindowResult } from "./fixtures/tauri-macos-window-result.mjs"
const boundary = "BEGIN: borderless getter feedback probe\n"
const result = (overrides = {}) => ({ status: 2, signal: null, stdout: boundary,
stderr: "FAIL: getter event feedback watchdog: phase=1 captures=1000\n", ...overrides })
test("accepts only a getter-boundary feedback watchdog or read-generated event assertion", () => {
verifyNativeWindowResult(result(), true)
verifyNativeWindowResult(result({ status: 101, stderr: "normal reads generated geometry events" }), true)
})
test("rejects setup stalls, generic watchdogs, later-phase timeouts and insufficient capture evidence", () => {
for (const invalid of [
result({ stdout: "" }),
result({ stderr: "FAIL: native window event feedback watchdog" }),
result({ stderr: "FAIL: getter event feedback watchdog: phase=2 captures=1000" }),
result({ stderr: "FAIL: getter event feedback watchdog: phase=1 captures=2" }),
result({ status: 101, stderr: "maximize must remain functional" }),
result({ status: 0 }),
result({ status: null, signal: "SIGTERM" }),
result({ error: new Error("spawn failed") }),
]) assert.throws(() => verifyNativeWindowResult(invalid, true))
})
test("patched result requires successful exit and native completion evidence", () => {
verifyNativeWindowResult(result({ status: 0, stdout: "PASS: read-only zoom checks", stderr: "" }))
assert.throws(() => verifyNativeWindowResult(result({ status: 0, stdout: "" })))
assert.throws(() => verifyNativeWindowResult(result()))
})

View file

@ -0,0 +1,51 @@
// Real AppKit/Tao event feedback. Never opens user profiles or OpenCode.
import assert from "node:assert/strict"
import { spawnSync } from "node:child_process"
import { cp, mkdtemp, readFile, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { verifyNativeWindowResult } from "./fixtures/tauri-macos-window-result.mjs"
assert.equal(process.platform, "darwin", "Run this regression in a graphical macOS session")
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const workspace = path.join(root, "packages/tauri-app")
const fixture = path.join(workspace, "tests/macos-window")
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, targetDirectory = target) {
const result = spawnSync("cargo", args, { cwd, env: { ...env, CARGO_TARGET_DIR: targetDirectory }, encoding: "utf8", timeout: 300_000, maxBuffer: 4 * 1024 * 1024 })
assert.equal(result.error, undefined, String(result.error))
assert.equal(result.status, 0, result.stdout + result.stderr)
return result.stdout
}
function check(targetDirectory = target, baseline = false) {
const binary = path.join(targetDirectory, "debug/codenomad-macos-window-fixture")
const result = spawnSync(binary, [], { cwd: fixture, env, encoding: "utf8", timeout: 30_000 })
verifyNativeWindowResult(result, baseline)
if (baseline) {
console.log("PASS: original Tao getter reproduced geometry event feedback")
} else {
console.log(result.stdout.trim())
}
}
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"))
// Warm the patched artifact before the negative control to expose output collisions.
cargo(["build", "--locked", "-p", "codenomad-macos-window-fixture"])
check()
if (process.argv.includes("--baseline")) {
const baseline = await mkdtemp(path.join(os.tmpdir(), "codenomad-macos-zoom-baseline-"))
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")
const baselineTarget = path.join(baseline, "target")
cargo(["build", "--manifest-path", path.join(baseline, "Cargo.toml")], baseline, baselineTarget)
// The baseline may exit via the watchdog or the bounded event assertion.
check(baselineTarget, true)
cargo(["build", "--locked", "-p", "codenomad-macos-window-fixture"])
check()
}