Skip to content

macOS: fix popup over-releases (adopted tab close, native-window close) and free user-closed popup windows - #75

Merged
shannah merged 4 commits into
masterfrom
claude/eager-hawking-4osb18
Nov 2, 2026
Merged

shannah merged 4 commits into
masterfrom
claude/eager-hawking-4osb18

Conversation

@shannah

@shannah shannah commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

The crash

An app on agentic-app-framework v0.21.5 (webview 1.10.2) crashed on macOS after the user signed in to Okta and clicked the Jira tile, which opens Jira as an adopted popup tab. The crash report shows a message sent to a freed Objective-C object on the AppKit main thread:

  • EXC_BAD_ACCESS KERN_INVALID_ADDRESS at 0x003b9d8b7e0ddce8
  • x14 = 0xbdbb9d8b7e0ddcda, x15 = 0x003b9d8b7e0ddcd8, which is x14 with its pointer-authentication bits stripped. The fault address is x15 + 0x10, the method-cache read objc_msgSend does through a garbage isa.
  • webview-popup-adopt-grace was alive, so a popup had been adopted.

Fix 1: an adopted tab over-released its configuration (Canvas 18)

impl_create_web_view stored the WKWebViewConfiguration WebKit passes to webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures: in child_e->config without retaining it. WebKit only lends that object for the call (+0), and the child WKWebView keeps its own copy, so the passed object is freed soon after. cocoa_destroy_engine releases e->config for every engine, so destroying an adopted tab released a freed object.

  • impl_create_web_view retains the passed configuration for both ADOPT and NATIVE_WINDOW children.
  • cocoa_discard_popup and the engine-owned impl_web_view_did_close path release it. The java_owned path leaves it to cocoa_destroy_engine, which releases it once.
  • Canvas 18: new safeguard "An engine owns every object it releases"; Operation 8 items 2 and 4 updated.

Fix 2: a native-window popup over-released its window on window.close() (Canvas 15)

Found while testing fix 1 on a Mac. In Native window mode, a popup that calls window.close() stopped with zombies on: -[NSKVONotifying_NSWindow release]: message sent to deallocated instance. The popup NSWindow kept releasedWhenClosed at its default YES, so -close released it and the teardown released it again. It's now created with releasedWhenClosed = NO. This bug predates this PR. Canvas 15 safeguard: "The library owns the popup window (macOS)".

Fix 3: a native popup window closed with its close button is freed (Canvas 15)

Only a page's window.close() reached the teardown. A window the user closed with its own close button was never freed (before fix 2, AppKit freed the window object behind the library's back and everything else leaked), and Java never heard onPopupClosed.

  • The popup's UI-delegate object also adopts NSWindowDelegate and is the popup window's delegate. Its windowWillClose: schedules the webViewDidClose: teardown on the main queue, retaining the delegate and web view across the hop. It uses dispatch_async_f because a window must not be released inside its own windowWillClose:, and cocoa_run_on_main_async would run it inline.
  • webViewDidClose: clears the window's delegate before -close, so its own close doesn't re-enter. The teardown clears the "eng" association first, so whichever close path runs second does nothing.
  • Canvas 15 safeguard: "A native popup is torn down once, however it closes".

Demo (WebViewAdoptPopupDemo)

  • A Close tab button removes the selected adopted tab and calls dispose() on it, which is what reaches cocoa_destroy_engine. It leaves the opener tab alone.
  • A fourth opener case, window.open → self-closing popup, opens a popup with a window.close() button.
  • The README lists both steps. Canvas 18 Operation 10 item 3 describes them.

Testing

CI compiles the native library for macOS (arm64 and x86_64), Linux and Windows; all green on 18ddc01.

On a Mac (macOS 15.4.1, arm64), with NSZombieEnabled=YES ./run-mac-adopt-popup-demo.sh, on 18ddc01 — all passed, no crashes, no zombie messages:

  • Adopt into tab: opened popups and closed them with Close tab, repeatedly.
  • Adopt into tab: window.open → self-closing popup, click window.close(), then Close tab.
  • Native window: window.open → self-closing popup, click window.close(). Crashed before fix 2.
  • Native window: open a popup, close its window with the red close button (fix 3).
  • Native window: both close paths mixed.

After merge

The fix reaches apps only after a swingwebview release, a webview-run release that shades it, and a matching bump of both pins in agentic-app-framework's pom.xml (they move in lockstep).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SZMcbkZ4JwWKt4vBrjZcLY

…b no longer crashes (Canvas 18)

impl_create_web_view stored the WKWebViewConfiguration WebKit passes to
webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:
in child_e->config without retaining it. WebKit only lends that object for
the call and the child WKWebView keeps its own copy, so it is freed soon
after. cocoa_destroy_engine releases e->config, so destroying an adopted
tab (closing it, or its session or window) released a freed object:
objc_msgSend on a garbage isa on the AppKit main thread, EXC_BAD_ACCESS at
isa + 0x10. Seen in the field after an Okta dashboard tile opened Jira as
an adopted tab.

- impl_create_web_view retains the passed configuration for both ADOPT and
  NATIVE_WINDOW children.
- cocoa_discard_popup and the engine-owned impl_web_view_did_close path
  release it, so the retain does not leak. The java_owned path leaves it to
  cocoa_destroy_engine, which releases it once.
- Canvas 18 gains the safeguard "An engine owns every object it releases"
  and Operation 8 items 2 and 4 say so.

Not compiled here (no macOS toolchain): build on macOS and exercise
adopt -> close tab, and adopt -> window.close(), ideally under Zombies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZMcbkZ4JwWKt4vBrjZcLY
…f (Canvas 18 Op 10.3)

The demo had no way to destroy an adopted tab, so the teardown fixed in the
previous commit could not be tested by hand. Removing a heavyweight
component from its parent does not destroy its native peer; dispose() does.

- A "Close tab" button removes the selected adopted tab and disposes it
  (never the opener).
- A fourth opener case opens a popup that calls window.close(), so the
  adopted child's webViewDidClose: path runs before its tab is closed.
- The demo README lists both steps and the NSZombieEnabled=YES hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZMcbkZ4JwWKt4vBrjZcLY
…-releases its window (Canvas 15)

impl_create_web_view allocs the popup NSWindow (+1, held in popup_window)
but left releasedWhenClosed at its default YES. impl_web_view_did_close
calls -close, which then releases the window, and later releases
popup_window again. Found on-device with the new self-closing popup demo
case in Native window mode, with NSZombieEnabled=YES:

  -[NSKVONotifying_NSWindow release]: message sent to deallocated instance

The window is now created with releasedWhenClosed = NO, so the teardown's
single release balances the alloc. Canvas 15 gains the safeguard "The
library owns the popup window (macOS)" and its createWebView step says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZMcbkZ4JwWKt4vBrjZcLY
@shannah shannah changed the title macOS: a popup child owns its configuration, so closing an adopted tab no longer crashes (Canvas 18) macOS: fix two popup over-releases (adopted tab close, native-window window.close()) Nov 2, 2026
…popup down (Canvas 15)

With releasedWhenClosed = NO (previous commit), a native-window popup
closed with its own close button was never freed: only a page's
window.close() reached the teardown, so the window, its web view, delegate,
configuration, Java global refs and Engine stayed alive, and Java never
heard onPopupClosed.

- The popup's UI-delegate object also adopts NSWindowDelegate and is the
  popup window's delegate. Its windowWillClose: schedules the
  webViewDidClose: teardown on the main queue (dispatch_async_f: a window
  must not be released inside its own windowWillClose:, and
  cocoa_run_on_main_async would run it inline), retaining the delegate and
  web view across the hop.
- webViewDidClose: clears the window's delegate before -close, so its own
  close does not re-enter. The teardown clears the "eng" association first,
  so whichever close path runs second does nothing.
- Canvas 15 gains the safeguard "A native popup is torn down once, however
  it closes" and the macOS operation describing the close-button path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZMcbkZ4JwWKt4vBrjZcLY
@shannah shannah changed the title macOS: fix two popup over-releases (adopted tab close, native-window window.close()) macOS: fix popup over-releases (adopted tab close, native-window close) and free user-closed popup windows Nov 2, 2026
@shannah
shannah marked this pull request as ready for review November 2, 2026 14:17
@shannah
shannah merged commit 3007878 into master Nov 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants