Skip to content

Bug: WM close destroys and disposes the BrowserWindow with no close event, breaking hide-to-tray (Linux/X11/GTK3) #53

Description

@abdedarghal111

Split off from #52, which is resolved — the freeze reported there is fixed in 0.4.5, the window now opens, paints and stays responsive on my setup. Thanks for that.

This is a separate problem that only appears once the window is closed. Repro script at the end.

Environment: @webviewjs/webview 0.4.5 · Arch Linux, kernel 7.2.3-arch1-3, X11/Xfce · gtk3 1:3.24.52-1 · webkit2gtk-4.1 2.52.6-1 · Node v26.8.1

Setup: typical tray app — ApplicationwhenReady() → invisible anchor BrowserWindow (keeps the tray alive with no visible windows) → createTrayIcon() → visible BrowserWindow + createWebview()app.run(), with win.on("close", () => win.hide()).

What happens

Pressing the window manager's close button destroys and disposes the window, with no notification:

  • no close event on the BrowserWindow (only blur);
  • no window-close-requested on the Application — and with the anchor window present, no application-close-requested either (without the anchor, that one does fire);
  • win.isDisposed() becomes true, so every method on the handle throws:
OK   win.isDisposed() -> true
FAIL win.isVisible(): BrowserWindow has been disposed
FAIL win.show(): BrowserWindow has been disposed

So hide-to-tray is impossible: the close can't be vetoed, and there's no event to react to — the app only finds out when the next call throws out of a native callback and takes the process down. The same code works as expected on Windows.

Secondary findings

  1. GTK criticals on every close, suggesting the webview teardown runs after the GdkWindow is gone:
    Gdk-WARNING **: GdkWindow 0x9a00003 unexpectedly destroyed
    GLib-GObject-CRITICAL **: invalid (NULL) pointer instance
    GLib-GObject-CRITICAL **: g_signal_handler_disconnect: assertion 'G_TYPE_CHECK_INSTANCE (instance)' failed
    Gdk-CRITICAL **: gdk_frame_clock_end_updating: assertion 'GDK_IS_FRAME_CLOCK (frame_clock)' failed
    
  2. Rebuilding the window with the same WebContext renders blank. The protocol handler is called and returns the HTML, but page-load-finished never fires and nothing paints. A fresh WebContext on the same dataDirectory works, so it isn't the profile dir — the context goes bad when its window dies.
  3. registerProtocol is per-context, not per-window: reusing a context and re-registering fails with Failed to create webview: Duplicate custom protocol 'app' registered on the WebViewBuilder.
  4. app.exit() returns without throwing but the process is still alive a second later.

Workaround we're running

Guard every window call with isDisposed(); rebuild the whole window (BrowserWindow + Webview) on each "show"; drop the WebContext along with the window and create a new one on the same dataDirectory; re-register app:// on the new context behind isCustomProtocolRegistered(); keep a process.exit(0) fallback after app.exit().

Expected

Either the window emits close and stays alive so hide() works (as on Windows), or — if disposal is intentional on Linux — an event saying the window is gone, plus a WebContext the replacement window can reuse.

Repro

Single file, no deps beyond the library (uses xdotool to press the close button; close by hand otherwise). Walks through all the points above and prints what happens:

repro-close-quit.mjs
// Repro for @webviewjs/webview 0.4.5 on Linux / X11 / GTK3.
//
// Tray app pattern: an invisible anchor window keeps the app alive, and the
// visible window is meant to hide on close and come back from the tray.
//
// Run: node repro-close-quit.mjs        (xdotool is used to press the WM close
//                                        button; close it by hand if missing)
import { Application } from "@webviewjs/webview"
import { execFileSync } from "node:child_process"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

const log = (...a) => console.log(`[${new Date().toISOString().slice(11, 23)}]`, ...a)
const step = (name, fn) => {
  try {
    const r = fn()
    log(`OK   ${name}`, r === undefined ? "" : `-> ${r}`)
    return r
  } catch (e) {
    log(`FAIL ${name}: ${e?.message ?? e}`)
  }
}

process.on("uncaughtException", (e) => log("uncaughtException:", e?.stack ?? e))

const PAGE = "<body style='background:#c33;font:700 40px sans-serif'>PAINTED</body>"
const PROFILE = mkdtempSync(join(tmpdir(), "webview-repro-"))

const app = new Application()
await app.whenReady()

// Invisible anchor window: keeps the tray alive with no visible windows.
app.createBrowserWindow({ visible: false })

// 16x16 PNG.
const icon = Buffer.from(
  "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGElEQVR4nGNgqLj+nyI8asCoAaMGDBcDAAwzTh8JA9THAAAAAElFTkSuQmCC",
  "base64"
)
app.createTrayIcon({ icon: { data: icon }, tooltip: "repro", menu: { items: [{ id: "quit", label: "Quit" }] } })

let win = null
let webContext = null
let reuseContext = true // second pass flips this to show the difference

function openWindow() {
  win = app.createBrowserWindow({ title: "repro-close-quit", width: 420, height: 300 })
  // Intended "hide instead of destroy" behaviour. Never fires on Linux.
  win.on("close", () => step("close event -> win.hide()", () => win.hide()))

  if (!webContext || !reuseContext) {
    webContext = app.createWebContext({ dataDirectory: PROFILE })
  }
  if (!webContext.isCustomProtocolRegistered("app")) {
    win.registerProtocol("app", async () => {
      log("protocol handler called")
      return new Response(PAGE, { headers: { "Content-Type": "text/html" } })
    })
  }
  const wv = win.createWebview({ url: "app://localhost/index.html", webContext, enableDevtools: false })
  wv.on("page-load-finished", (e) => log("page-load-finished", e.url))
  log("window created")
}

const wmClose = () => {
  const ids = execFileSync("xdotool", ["search", "--name", "^repro-close-quit$"]).toString().trim().split("\n")
  execFileSync("xdotool", ["windowclose", ids[ids.length - 1]])
}

openWindow()

setTimeout(() => {
  log("--- 1. pressing the WM close button ---")
  step("wm close", wmClose)
}, 3000)

setTimeout(() => {
  log("--- 2. the JS handle after the WM close ---")
  step("win.isDisposed()", () => win.isDisposed())
  step("win.isVisible()", () => win.isVisible())
  step("win.show()", () => win.show())
}, 5000)

setTimeout(() => {
  log("--- 3. rebuilding the window, REUSING the same WebContext ---")
  reuseContext = true
  openWindow()
}, 6000)

setTimeout(() => {
  log("(look at the window: it stays blank, and no page-load-finished above)")
  step("wm close", wmClose)
}, 11000)

setTimeout(() => {
  log("--- 4. rebuilding the window with a FRESH WebContext on the same dataDirectory ---")
  reuseContext = false
  openWindow()
}, 12000)

setTimeout(() => {
  log("(this one paints)")
  log("--- 5. quitting ---")
  step("app.exit()", () => app.exit())
  setTimeout(() => {
    log("still running 1s after app.exit(); falling back to process.exit(0)")
    process.exit(0)
  }, 1000)
}, 17000)

app.run()

Happy to test any patch on real X11/GTK3 hardware.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions