CodeNomad/scripts/test-electron-owner-close.mjs
Pascal André 0b348e864d
fix(electron): avoid destroyed window access on close (#785)
## Summary

Fix the main-process `TypeError: Object has been destroyed` dialog
observed when
closing the Windows Electron 0.20.0 build.

The local window's `closed` handler accessed `window.webContents` after
the native
BrowserWindow was destroyed. The exception surfaced while
`MultiwindowLifecycle.exit()` called `app.exit(0)`.

BrowserController now captures the owner WebContents while the window is
alive
and observes `closed` using that retained identity. Browser target
registrations
and pending-open claims are cleaned up after the registry's existing
removal
handler, without invoking getters on the destroyed BrowserWindow.

This is an Electron native-object lifetime correction; shared shutdown
semantics
and Tauri behavior remain equivalent.

## Validation

- Electron TypeScript check passed.
- 15 browser-controller regressions passed, including a
native-getter-throwing
  window fixture that verifies registration cleanup still occurs.
- 16 multiwindow lifecycle regressions passed.
- `node scripts/test-electron-owner-close.mjs` passed on Windows with
real
Electron 39.0.0 windows: forced destroy, ordinary close and final
lifecycle
exit. It also asserts that the former getter really throws after
`closed`.
- Native fixture uses its own temporary profile and no
CodeNomad/OpenCode backend.

Separate from the installation-progress UI PR. This fix is not yet
included in
the already-published 0.20.0 replacement archives.

File-size note: the existing Electron `main.ts` entry point is
approximately
625 lines; the change replaces one cleanup call with owner observation.
2026-09-28 13:12:07 +02:00

79 lines
3.9 KiB
JavaScript

import assert from "node:assert/strict"
import { spawn } from "node:child_process"
import { createRequire } from "node:module"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { build } from "esbuild"
// Real native destruction order, isolated from the desktop profile and backend.
const root = fileURLToPath(new URL("../", import.meta.url))
const require = createRequire(new URL("../packages/electron-app/package.json", import.meta.url))
const directory = await mkdtemp(join(tmpdir(), "codenomad-owner-close-"))
try {
const entry = join(directory, "fixture.mjs")
await build({
stdin: { resolveDir: root, sourcefile: "owner-close-fixture.ts", loader: "ts", contents: `
import assert from "node:assert/strict"
import { app, BrowserWindow } from "electron"
import { BrowserController } from "./packages/electron-app/electron/main/browser-controller"
import { MultiwindowLifecycle } from "./packages/electron-app/electron/main/multiwindow-lifecycle"
process.on("uncaughtException", error => { console.error(error); app.exit(1) })
process.on("unhandledRejection", error => { console.error(error); app.exit(1) })
void app.whenReady().then(async () => {
const controller = new BrowserController(() => {})
let closed = 0
const makeWindow = () => {
const window = new BrowserWindow({ show: false })
controller.observeOwner(window)
window.on("closed", () => {
assert.equal(window.isDestroyed(), true)
// Demonstrates why reading this getter inside the old callback failed.
assert.throws(() => window.webContents, /Object has been destroyed/)
closed++
})
return window
}
const last = makeWindow()
const destroyed = makeWindow()
destroyed.destroy()
const ordinary = makeWindow()
const ordinaryClosed = new Promise(resolve => ordinary.once("closed", resolve))
ordinary.close()
await ordinaryClosed
assert.equal(closed, 2)
const lifecycle = new MultiwindowLifecycle({
app, getLocalWindows: () => [], getAllWindows: () => BrowserWindow.getAllWindows(),
clientStateManager: { isPrimary: false, flush: async () => {}, drainAndReleasePrimary: async () => {} },
cliManager: { shutdown: async () => {} }, removeWindowState: async () => true,
getAllowedRendererOrigins: () => [], isTrustedRendererOrigin: () => false,
})
lifecycle.attachRemote(last)
lifecycle.registerAppEvents()
app.on("quit", () => {
assert.equal(closed, 3)
console.log("PASS: destroy, ordinary close and final lifecycle exit")
})
last.close()
})
` },
outfile: entry, bundle: true, platform: "node", format: "esm", external: ["electron"], logLevel: "silent",
})
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
// Some Linux test hosts disable unprivileged user namespaces. This opt-in
// only affects this empty-window fixture, which loads no page or backend.
const sandboxArgs = process.platform === "linux" && process.argv.includes("--no-sandbox") ? ["--no-sandbox"] : []
const child = spawn(require("electron"), [entry, `--user-data-dir=${join(directory, "profile")}`, ...sandboxArgs], { env, stdio: ["ignore", "pipe", "pipe"] })
let output = ""
child.stdout.on("data", data => { output += data })
child.stderr.on("data", data => { output += data })
const timeout = setTimeout(() => child.kill(), 30_000)
const code = await new Promise((resolve, reject) => { child.on("error", reject); child.on("exit", resolve) }).finally(() => clearTimeout(timeout))
assert.equal(code, 0, output)
assert.match(output, /PASS: destroy, ordinary close and final lifecycle exit/)
console.log("Real Electron owner-close regression passed.")
} finally {
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}