Skip to content

#3222 Fix macOS freeze when opening the Overview tab - #3226

Merged
mbastian merged 2 commits into
gephi:masterfrom
MattArtzAnthro:fix-3222-macos-overview-deadlock
Aug 27, 2026
Merged

mbastian merged 2 commits into
gephi:masterfrom
MattArtzAnthro:fix-3222-macos-overview-deadlock

Conversation

@MattArtzAnthro

@MattArtzAnthro MattArtzAnthro commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #3222.

NewtCanvasAWT.addNotify() takes an initial JAWT surface lock on the AWT EDT:

// enforce initial lock on AWT-EDT, allowing acquisition of pixel-scale
jawtWindow.lockSurface();

On macOS that call reaches OSXUtil.RunOnMainThread() and blocks the AWT EDT until AppKit's main thread has created the native window. If AppKit is concurrently waiting on the AWT EDT, both threads stop. The reported thread dumps show exactly that pair:

"AWT-EventQueue-0" ... in Object.wait()
   at jogamp.nativewindow.macosx.OSXUtil.RunOnMainThread(OSXUtil.java:338)
   at jogamp.nativewindow.macosx.OSXUtil.CreateNSWindow2(OSXUtil.java:174)
   at jogamp.nativewindow.jawt.macosx.MacOSXJAWTWindow.lockSurfaceImpl(MacOSXJAWTWindow.java:312)
   at com.jogamp.nativewindow.awt.JAWTWindow.lockSurface(JAWTWindow.java:708)
   at com.jogamp.newt.awt.NewtCanvasAWT.addNotify(NewtCanvasAWT.java:693)
   - locked <0x00000003026407c0>
   at java.awt.Container.addNotify(...)          <- holds the AWT tree lock
   ...
   at org.netbeans.core.windows.view.EditorView.updateAWTHierarchy(EditorView.java:142)

"AppKit Thread" ... RUNNABLE
   at sun.lwawt.macosx.LWCToolkit.doAWTRunLoopImpl(Native Method)
   at sun.lwawt.macosx.LWCToolkit.invokeAndWait(...)
   at sun.lwawt.macosx.CAccessibility.invokeAndWait(...)
   at sun.lwawt.macosx.CAccessibility.getFocusOwner(...)

LWCToolkit.invokeAndWait() runs the main run loop in a private mode that does not execute the blocks JOGL posts through performSelectorOnMainThread, so neither side can progress. Because the wait spans two runtime-managed threads rather than Java monitors, ThreadMXBean deadlock detection reports nothing, and the process never crashes. Line 693 in the dumps is the jawtWindow.lockSurface() call in the patched NewtCanvasAWT shipped in 0.11.2, so this is not a stale-JOGL artifact.

This is the same failure mode as JOGL Bug 1478, which 10cfb23 addressed for attachNewtChild(). That fix left addNotify() untouched, and addNotify() blocks the AWT EDT in the same way. The trigger is any macOS accessibility client polling the application (in my case Grammarly Desktop, and a screen reader would do the same), which explains why the freeze is intermittent and why the reports in #2546 were so hard to reproduce.

Change

addNotify() now performs its initial surface lock through the existing SecondaryLoop mechanism instead of blocking the AWT EDT:

  • Extracted the SecondaryLoop body of runOnNewtEDTAndWaitOnAWTEDT() into runOffAWTEDTAndPumpEvents(), which takes the dispatch strategy as a parameter. runOnNewtEDTAndWaitOnAWTEDT() keeps its behavior and now delegates to it.
  • Added lockSurfaceInitially(), which on macOS runs lockSurface()/unlockSurface() on a short-lived daemon thread while the AWT EDT keeps dispatching events. AppKit's pending invokeAndWait is therefore served, the main thread returns to its normal run loop, the native window is created, and the lock completes.
  • Added the initialSurfaceLockPending guard. Because the AWT EDT dispatches events during that wait, a queued paint can re-enter validateComponent() and attach the NEWT child while addNotify() is still in flight, which would lock the very surface the pending task holds. validateComponent() now returns false until addNotify() has returned, and the normal paint path attaches afterwards.

Every other platform, and any call not on the AWT EDT, keeps the original behavior of locking inline.

Reproduction

The freeze is a race, so I built a driver for it rather than clicking. Switching perspectives runs PerspectiveController.selectPerspective() on the EDT, which rearranges the NetBeans window system through EditorView.updateAWTHierarchy and reaches TopComponent.addNotify, the exact path in the dumps. Driving that switch in a loop, with a macOS accessibility client polling the focused application, reproduces the freeze on demand.

  • Unpatched 0.11.2: froze inside the first 50 Data Laboratory to Overview cycles, roughly a minute. The dump is the stack above, with AppKit in CAccessibility.getAccessibleIndexInParent rather than getFocusOwner. Worth noting, because it shows the trigger is not one particular accessibility call. Any AppKit query that reaches the EDT through LWCToolkit.invokeAndWait during the surface lock will do it.
  • Patched build: 650 cycles with no freeze, under the same conditions. 500 on an empty workspace and 150 with a 3672 node, 15554 edge graph loaded. A thread audit afterwards showed no leftover NewtCanvasAWT-InitialSurfaceLock workers, and a capture of the live canvas came back at 1962 by 1448 pixels on a roughly 981 by 724 point window, so the 2x pixel scale the initial lock exists to acquire is still being applied.

A dump taken during the surface lock on the patched build shows both halves of the fix at the same instant:

"AWT-EventQueue-0"   RUNNABLE          <- alive, dispatching an InvocationEvent
   at java.awt.EventDispatchThread.pumpEventsForFilter(...)
   at java.awt.WaitDispatchSupport.enter(...)
   at com.jogamp.newt.awt.NewtCanvasAWT.runOffAWTEDTAndPumpEvents(NewtCanvasAWT.java:1201)
   at com.jogamp.newt.awt.NewtCanvasAWT.lockSurfaceInitially(NewtCanvasAWT.java:1280)
   at com.jogamp.newt.awt.NewtCanvasAWT.addNotify(NewtCanvasAWT.java:707)
   at org.openide.windows.TopComponent.addNotify(TopComponent.java:259)

"NewtCanvasAWT-InitialSurfaceLock"   WAITING
   at jogamp.nativewindow.macosx.OSXUtil.RunOnMainThread(OSXUtil.java:338)
   at jogamp.nativewindow.macosx.OSXUtil.CreateNSWindow2(OSXUtil.java:174)
   at jogamp.nativewindow.jawt.macosx.MacOSXJAWTWindow.lockSurfaceImpl(MacOSXJAWTWindow.java:312)
   at com.jogamp.nativewindow.awt.JAWTWindow.lockSurface(JAWTWindow.java:708)

The frames that used to block the EDT are on the worker thread, and the EDT is dispatching at that moment, so AppKit's pending invokeAndWait is served and the native window creation completes. It also confirms the new path is the one running, rather than createSecondaryLoop() returning null and falling back to the original inline lock.

Scope and residual risk

The change is limited to macOS on the AWT EDT. Taking a JAWT surface lock off the EDT is ordinary JOGL practice, since the GL animator thread does it on every frame, and MacOSXJAWTWindow.lockSurfaceImpl() is written to be callable from any thread. The pattern itself is already in production in this file for attachNewtChild().

The one behavior that is genuinely new is that the AWT EDT dispatches events inside Container.addNotify(), while it holds the AWT tree lock. The initialSurfaceLockPending guard covers the re-entrant path that matters here, which is attaching the NEWT child early. I would welcome a second opinion on whether any other re-entrant path through this component deserves the same guard.

detachNewtChild() is untouched, for the reason already documented in the file header.

A separate pre-existing defect, noticed while testing

Not fixed here, and worth its own issue if you agree it is real.

java.lang.NullPointerException: Cannot invoke
  "com.jogamp.nativewindow.awt.JAWTWindow.isOffscreenLayerSurfaceEnabled()"
  because "this.jawtWindow" is null
   at com.jogamp.newt.awt.NewtCanvasAWT.attachNewtChild(NewtCanvasAWT.java:1233)
   at com.jogamp.newt.awt.NewtCanvasAWT.validateComponent(NewtCanvasAWT.java:1060)
   at com.jogamp.newt.awt.NewtCanvasAWT.reshape(NewtCanvasAWT.java:835)

Those line numbers are current master, not this branch. attachNewtChild() reads jawtWindow immediately after runOnNewtEDTAndWaitOnAWTEDT(), which pumps AWT events, so a removeNotify() dispatched during that pump nulls the field before the next statement dereferences it.

It appeared once in 500 cycles on master and once in 650 on this branch, with the same stack, so the rates are indistinguishable and this branch does not introduce or worsen it. I also instrumented the branch to report any destroy occurring during the new addNotify() wait, and the probe never fired, so the new pump is not a path to it. Happy to open the issue, or to fold a null re-check into this PR if you would rather have it in one place.

Checklist

  • Merged with master beforehand

Added tests?

  • 👍 yes
  • 🙅 no, because they aren't needed

The failure is a timing race between the AWT EDT, AppKit's main thread, and an external macOS accessibility client. It cannot be reproduced deterministically in a unit test, and it does not reproduce at all without an accessibility client polling the process. Verification is therefore manual, on macOS 26.5.1 (Apple Silicon), driving the perspective switch through a local automation API rather than clicking. Numbers are in the Reproduction section above. With an accessibility client running, master froze inside 50 cycles and this branch ran 650 without a freeze. A further 500 cycles were run with the accessibility client stopped, which says nothing about the deadlock and was there to measure the unrelated NPE rate.

Added to documentation?

  • 👍 Relevant code documentation

The Gephi patch header in NewtCanvasAWT.java records the second patched site, and lockSurfaceInitially() carries the full reasoning.


As with the original report, the fix and the testing above were worked out together with Claude, Anthropic's AI assistant. I built, ran, and verified everything on my own machine, and I am happy to re-run any of it or to gather more dumps if that would help.

NewtCanvasAWT.addNotify() locked the JAWT surface on the AWT EDT. On macOS that
call blocks in OSXUtil.RunOnMainThread until AppKit creates the native window,
which deadlocks whenever AppKit is itself waiting on the AWT EDT through
CAccessibility. Any accessibility client polling the process can trigger it, and
the mutual wait spans two runtime-managed threads, so deadlock detection reports
nothing.

Extend the existing Bug 1478 mitigation to that call site: extract the
SecondaryLoop body of runOnNewtEDTAndWaitOnAWTEDT() into
runOffAWTEDTAndPumpEvents(), and add lockSurfaceInitially(), which runs the lock
off the AWT EDT while the EDT keeps dispatching events. Guard
validateComponent() against attaching the NEWT child during that window.

Other platforms and non-EDT callers keep the original inline behavior.
@mbastian

mbastian commented Aug 7, 2026

Copy link
Copy Markdown
Member

Hi @MattArtzAnthro thanks a lot for the detailed report, and the fix. I still need to wrap my head around this as this is JOGL code but I wonder if you could also share the way you reproduce this? I would like to try on my own before concluding. Thanks in advance!

@MattArtzAnthro

Copy link
Copy Markdown
Contributor Author

Thanks Mathieu. Happy to give you a recipe you can run yourself.

First, one clarification that I should have made clearer in the PR, because I think my
write-up gave the wrong impression. I hit this as an ordinary Gephi user long before
gephi-ai existed.
It was the reason I started digging in the first place. The plugin was
never a precondition for the bug. It was only a convenient way to drive the window system
in a loop so that I could count cycles instead of waiting for an intermittent freeze to
happen on its own. #2546 has been collecting the same freeze from other macOS users since
2022, with no plugins and no automation involved.

So none of what follows needs gephi-ai, or any Gephi plugin at all. It runs against a
stock 0.11.2 install using nothing but osascript, which ships with macOS.

The short version

The freeze needs two things at the same time:

  1. Something re-creating the Graph window, which is what runs NewtCanvasAWT.addNotify()
    and takes the initial JAWT surface lock on the AWT EDT.
  2. A macOS accessibility client polling the Gephi process, which is what puts AppKit
    inside CAccessibility waiting on that same EDT.

Either one alone is harmless. Together they deadlock. That is why this has been so hard to
reproduce: nobody thinks to mention that they had a grammar checker or a screen reader
running.

By hand

If you want to feel it rather than script it, this is what I originally did:

  1. Start any macOS accessibility client. VoiceOver (Cmd-F5) is the one everybody already
    has. In my own case it was Grammarly Desktop.
  2. Open Gephi 0.11.2.
  3. Toggle between Data Laboratory and Overview repeatedly, or close and reopen the Graph
    window from the Window menu.
  4. Quit the accessibility client, and the freeze stops happening.

That last step is the part I would encourage you to try, because it is the control rather
than the reproduction. It is what convinced me the accessibility client was the trigger
and not a coincidence.

Scripted, zero dependencies

Two small AppleScript files. One polls accessibility, one drives the window. Both are in a
gist if you would rather download them than copy from here:
https://gist.github.com/MattArtzAnthro/10626f74c8b102e6d02d0e113dea620e

One gotcha that cost me some time: Gephi registers in the accessibility tree as java,
not gephi, so a script targeting the obvious name silently does nothing. Both scripts
below look the process up by its Unix pid instead.

Find the pid first:

PID=$(pgrep -f "Gephi.app/Contents/PlugIns.*java")

poller.applescript, which stands in for Grammarly or VoiceOver. Each
AXFocusedUIElement query arrives inside Gephi as CAccessibility.getFocusOwner, which
is the exact frame in the dumps.

on run argv
  tell application "System Events"
    set p to first process whose unix id is (item 1 of argv as integer)
    repeat 100000 times
      try
        get value of attribute "AXFocusedUIElement" of p
      end try
    end repeat
  end tell
end run

cycle.applescript, which closes and reopens the Graph window, which is the TopComponent
hosting the GL canvas. This drives TopComponent.addNotify into
NewtCanvasAWT.addNotify, the path in the dumps.

on run argv
  tell application "System Events"
    set p to first process whose unix id is (item 1 of argv as integer)
    set frontmost of p to true
    delay 0.2
    set wm to (first menu bar item of menu bar 1 of p whose name is "Window")
    click menu item "Graph" of menu 1 of wm
    delay 0.4
    click menu item "Close Window" of menu 1 of wm
    delay 0.4
    click menu item "Graph" of menu 1 of wm
    delay 0.4
  end tell
end run

Run them together:

osascript poller.applescript $PID &              # the trigger
for i in $(seq 1 200); do                        # the driver
  echo "cycle $i"
  osascript cycle.applescript $PID
done

When it deadlocks, the loop simply stops advancing: the current osascript call blocks
forever waiting on the frozen app, and the printed cycle number stops changing. Gephi
itself stays on screen, fully drawn and completely unresponsive, with no beachball at
first. Remember to kill the background poller afterwards.

In my runs it froze somewhere between cycle 100 and cycle 150, which is a couple of
minutes. If you want it faster, drop the delay values in cycle.applescript.

Terminal needs Accessibility permission in System Settings, Privacy and Security,
Accessibility. That is the only setup step.

When it freezes, take a dump with jstack $PID. Note that the runtime bundled in
Gephi.app is a JRE and has no jstack, so you will need one from any JDK you have around.
A JDK 21 jstack attaches to the bundled 17 process without complaint.

What I measured

Grammarly quit and fully unloaded for all of these, so the poller above was the only
accessibility client running. macOS 26.5.1 on Apple Silicon.

Build Accessibility poller Cycles Result
Stock 0.11.2 none 150 no freeze
Stock 0.11.2 running froze between 100 and 150 froze
This branch running 200 no freeze

The first row matters as much as the second. Driving the window system hard, on its own,
does not reproduce anything. The AppleScript clicking is itself accessibility traffic, so
it is not that any AX contact will do it. It specifically takes a client polling for the
focused element while addNotify() holds the EDT.

For the branch run I removed my own plugin from the user directory first, so both builds
were plain Gephi with nothing extra loaded. To confirm the canvas really was being rebuilt
on every cycle rather than quietly skipped, I watched the animator thread name, which
carries a counter:

cycle 40:   GraphTopComponent-VizExecutor-Display-.macosx_nil-1-EDT-41-FPSAWTAnimator#00-Timer40
cycle 200:  GraphTopComponent-VizExecutor-Display-.macosx_nil-1-EDT-201-FPSAWTAnimator#00-Timer200

So that is 200 real canvas creations and teardowns with no freeze, and afterwards zero
leftover NewtCanvasAWT-InitialSurfaceLock worker threads. In the frozen stock run there
is no animator thread at all, because it deadlocks during creation before the animator
ever starts.

The dump from the stock run is identical to the one in #3222:

"AWT-EventQueue-0" ... in Object.wait()
   at jogamp.nativewindow.macosx.OSXUtil.RunOnMainThread(OSXUtil.java:338)
   at jogamp.nativewindow.macosx.OSXUtil.CreateNSWindow2(OSXUtil.java:174)
   at jogamp.nativewindow.jawt.macosx.MacOSXJAWTWindow.lockSurfaceImpl(MacOSXJAWTWindow.java:312)
   at com.jogamp.nativewindow.awt.JAWTWindow.lockSurface(JAWTWindow.java:708)
   at com.jogamp.newt.awt.NewtCanvasAWT.addNotify(NewtCanvasAWT.java:693)
   at java.awt.Container.addNotify(...)
   - locked <0x...> (a java.awt.Component$AWTTreeLock)
   at org.openide.windows.TopComponent.addNotify(TopComponent.java:259)

"AppKit Thread" ... RUNNABLE
   at sun.lwawt.macosx.LWCToolkit.doAWTRunLoopImpl(Native Method)
   at sun.lwawt.macosx.LWCToolkit.invokeAndWait(...)
   at sun.lwawt.macosx.CAccessibility.invokeAndWait(...)
   at sun.lwawt.macosx.CAccessibility.getFocusOwner(...)

jstack reports no deadlock, because the wait spans two runtime-managed threads rather
than Java monitors. The process stays alive and simply never comes back.

I have the full thread dumps for every run above and am glad to post them, or to run any
variation you would like. If it does not reproduce on your machine I would genuinely like
to know, since the timing window may depend on machine speed or on how aggressively the
accessibility client polls.

One small thing still open from the PR description, whenever you get to it: there is a
pre-existing NPE in attachNewtChild() that I ran into while testing. It is on master as
well, at about one occurrence in 500 cycles, and this branch neither introduces nor worsens
it. I am glad to open a separate issue for it, or to fold a null re-check into this PR,
whichever you prefer.

As with the original report and the PR, this round of testing was worked out together with
Claude, Anthropic's AI assistant. Every run above happened on my own machine and I am happy
to repeat any of it.

@mbastian mbastian added this to the 0.11.3 milestone Aug 26, 2026
@mbastian

Copy link
Copy Markdown
Member

Thanks a lot @MattArtzAnthro, I verified the fixes and everything seems to check out.

@mbastian
mbastian merged commit 6683146 into gephi:master Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS freeze when opening the Overview tab: thread dump shows an EDT/AppKit mutual wait through an accessibility query during OpenGL canvas creation

2 participants