Skip to content

fix(w32): pass HMENU to DestroyMenu syscall#5228

Merged
leaanthony merged 22 commits into
wailsapp:masterfrom
johnmaguire:fix/windows-systray-setmenu-crashes
May 1, 2026
Merged

fix(w32): pass HMENU to DestroyMenu syscall#5228
leaanthony merged 22 commits into
wailsapp:masterfrom
johnmaguire:fix/windows-systray-setmenu-crashes

Conversation

@johnmaguire

@johnmaguire johnmaguire commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #5227

This fix was developed with Claude Code using empirical evidence. I included a systray-stress harness that was used to verify the fixes.

Fixes a family of Windows systray SetMenu crashes that manifested as
GDI/USER handle exhaustion in long-running apps. Root cause was a broken
DestroyMenu syscall — it was being called with four uintptr arguments
(1, hMenu, 0, 0), which placed the literal 1 in the handle slot and
dropped the real HMENU in the ignored second slot, so every call returned
FALSE without freeing anything. Go's (*Proc).Call(a ...uintptr) passes
its args verbatim to the Win32 function; there is no "arg count" prefix
convention.

Fixing the syscall surfaced several latent ownership bugs that had been
masked by DestroyMenu being a no-op:

  • Win32Menu.Update recreated the HMENU tree without destroying the
    previous one.
  • Win32Menu.Update also left checkboxItems / radioGroups maps
    populated with item IDs from the destroyed HMENU, so later
    CheckMenuItem / CheckRadio calls addressed freed menus.
  • HBITMAPs passed to SetMenuItemBitmaps are not freed by DestroyMenu
    (Microsoft docs are explicit). SetMenuIcons dropped the handle refs
    entirely, leaking a bitmap per icon per rebuild.
  • HBITMAPs allocated at runtime via MenuItem.SetBitmap were only
    tracked on the windowsMenuItem impl, not the menu-level slice, so
    they leaked on every rebuild even after the per-menu bitmap tracking
    was in place.
  • windowsSystemTray.updateMenu was calling Update() on the new
    Win32Menu right after NewPopupMenu (which already calls Update()
    in its constructor), doubling the allocations, and never destroyed
    the previous s.menu.

All Win32 API contracts were re-verified against Microsoft Learn:

  • DestroyMenu
    BOOL DestroyMenu([in] HMENU hMenu); and "DestroyMenu is recursive,
    that is, it will destroy the menu and all its submenus."
  • SetMenuItemBitmaps
    "When the menu is destroyed, these bitmaps are not destroyed; it is
    up to the application to destroy them."
  • Go syscall.Proc.Call
    (*Proc).Call(a ...uintptr), "Call executes procedure p with
    arguments a."

Type of change

Please select the option that is relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

  • Windows
  • macOS
  • Linux

Verified on Windows 11 Pro (build 26100). Test matrix:

  • go test ./v3/pkg/w32/... on Windows — new regression tests in
    menu_windows_test.go lock the DestroyMenu syscall signature and
    assert USER-object handle usage is bounded across
    Create+Append+Destroy cycles.
  • v3/examples/systray-stress — black-box harness with four workloads,
    handle-delta budgets, and iteration caps. Commands run:
    • systray-stress -mode=churn -iters=50000 -churn-gap=1ms — passes
      with flat handle deltas.
    • systray-stress -mode=churn -bitmaps=true -iters=10000 — exercises
      initial-build SetMenuIcons. Flat GDI delta.
    • systray-stress -mode=show -duration=30s — repeated OpenMenu + ESC
      dismiss. No crash.
    • systray-stress -mode=churn+show -iters=5000 -churn-gap=1ms -show-gap=20ms -dismiss-gap=10ms — concurrent churn and popup.
      Completed without crash; confirmed InvokeSync serialization
      already protects Destroy from racing TrackPopupMenuEx.
    • systray-stress -mode=mutate -iters=1000 -log-every=100
      exercises the runtime MenuItem.SetBitmap code path. Before the
      fix: gdi_delta=5007 over 1000 iterations. After the fix:
      gdi_delta=12 (flat).

Test Configuration

     # System

     ┌──────────────────────────────────────────────────────────────────────────────────────────────┐
     | Name              | Windows 10 Pro                                                           |
     | Version           | 2009 (Build: 26100)                                                      |
     | ID                | 24H2                                                                     |
     | Branding          | Windows 11 Pro                                                           |
     | Platform          | windows                                                                  |
     | Architecture      | amd64                                                                    |
     | Go WebView2Loader | true                                                                     |
     | WebView2 Version  | 147.0.3912.72                                                            |
     | CPU 1             | Apple Silicon                                                            |
     | CPU 2             | Apple Silicon                                                            |
     | CPU 3             | Apple Silicon                                                            |
     | CPU 4             | Apple Silicon                                                            |
     | GPU 1             | Parallels Display Adapter (WDDM) (Parallels) - Driver: 20.18.2631.57396  |
     | Memory            | 8GB                                                                      |
     └──────────────────────────────────────────────────────────────────────────────────────────────┘

     # Build Environment

     ┌──────────────────────────┐
     | Wails CLI   | v3.0.0-dev |
     | Go Version  | go1.25.0   |
     | -buildmode  | exe        |
     | -compiler   | gc         |
     | CGO_ENABLED | 0          |
     | GOAMD64     | v1         |
     | GOARCH      | amd64      |
     | GOOS        | windows    |
     └──────────────────────────┘

     # Dependencies

     ┌────────────────────────────────────────────────────────────────────────────────┐
     | npm                        | 11.4.2                                            |
     | NSIS                       | Not Installed                                     |
     | MakeAppx.exe (Windows SDK) | Not Installed                                     |
     | MSIX Packaging Tool        | Not Installed                                     |
     | SignTool.exe (Windows SDK) | Not Installed                                     |
     | docker                     | *Not installed (optional - for cross-compilation) |
     |                                                                                |
     └─────────────────────────── * - Optional Dependency ────────────────────────────┘

Checklist:

  • I have updated website/src/pages/changelog.mdx with details of this PR
  • My code follows the general coding style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Windows systray menu crashes and resource leaks during repeated menu rebuilds; menu icons and stale item state are now properly released and cleared, and redundant update calls removed.
  • Tests

    • Added Windows regression and stress tests to validate menu lifecycle and USER/GDI resource stability under heavy churn.
  • Documentation

    • Added a systray-stress README with workloads, thresholds, CLI flags, and reproduction steps.
  • Examples

    • Added a systray stress test executable to exercise menu rebuild and popup scenarios.

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Staged Windows systray menu rebuilds now allocate into a fresh HMENU/bitmap set, propagate Win32 failures as errors with rollback and cleanup, track and free HBITMAPs, fix a DestroyMenu syscall argument mismatch, clear stale menu state during updates, and add a stress test and README to exercise USER/GDI deltas.

Changes

Cohort / File(s) Summary
Low-level w32 changes
v3/pkg/w32/user32.go, v3/pkg/w32/icon.go, v3/pkg/w32/menu_windows_test.go
Fixed DestroyMenu call signature; SetMenuIcons now returns allocated HBITMAP handles plus error and cleans up on failure; added tests validating DestroyMenu semantics and USER/GDI leak stress cycles.
Menu bitmap tracking & item updates
v3/pkg/application/menuitem_windows.go
Menu items now retain returned HBITMAP handles, delete previously stored bitmap on update, and expose bitmap lifecycle for proper release.
Staged menu rebuild & error-rollback
v3/pkg/application/menu_windows.go, v3/pkg/application/popupmenu_windows.go
Menu rebuilds are staged into a new HMENU and bitmap list; processMenu/buildMenu return error; newly allocated submenus/bitmaps are freed on failure and successful builds swap state then free old resources.
SystemTray lifecycle change
v3/pkg/application/systemtray_windows.go
updateMenu now destroys the previous popup menu before creating a new one and removes a redundant post-assignment Update() call.
Stress test & docs
v3/examples/systray-stress/main.go, v3/examples/systray-stress/README.md, v3/UNRELEASED_CHANGELOG.md
Added a Windows systray stress test executable, README describing modes/flags and thresholds, and an unreleased changelog entry documenting fixed systray resource leak/crash issues.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant STray as SystemTray
    participant Win32M as Win32Menu
    participant W32 as W32 Wrapper
    participant WinAPI as Win32 API

    App->>STray: SetMenu(newMenu)
    STray->>Win32M: create staged HMENU & temp maps
    Win32M->>W32: AppendMenu / SetMenuIcons (may allocate HBITMAPs)
    alt success
        W32-->>Win32M: Return HBITMAP handles
        Win32M->>Win32M: store staged bitmaps
        Win32M->>STray: swap in staged HMENU/state
        Win32M->>W32: DeleteObject(old HBITMAPs)
        Win32M->>WinAPI: DestroyMenu(old HMENU)
    else failure
        Win32M->>W32: DeleteObject(any new HBITMAPs)
        Win32M->>WinAPI: DestroyMenu(staged HMENU)
        Win32M->>STray: restore prior HMENU/state
    end
    App->>STray: Show menu
    STray->>Win32M: TrackPopupMenuEx -> WinAPI shows popup
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

Windows, Bug, go, v3-alpha, size:L

Suggested reviewers

  • leaanthony

Poem

🐰 I hopped through menus, handles in paw,
Freed stray bitmaps and tamed the draw,
I staged a rebuild and rolled back with care,
No more leaks or crashes—menus fair,
Tray bells ring tidy in Windows air. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(w32): pass HMENU to DestroyMenu syscall' directly describes the primary bug fix—correcting the DestroyMenu syscall invocation. However, this fix addresses only the root cause; the PR also includes substantial follow-up fixes for related ownership and resource-leak bugs surfaced by fixing DestroyMenu. The title covers the main technical change but not the full scope of fixes.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes the linked issue, a detailed explanation of the root cause, lists all latent ownership bugs fixed, cites Microsoft Learn documentation, documents testing on Windows 11 Pro with specific test cases and stress-harness results, and completes all checklist items including test verification and changelog updates.
Linked Issues check ✅ Passed The PR fully addresses the objectives from issue #5227: it fixes the fatal crash in the Windows systray popup-menu build path, eliminates resource leaks (USER/GDI handle exhaustion) via proper HMENU/HBITMAP tracking and cleanup, handles repeated/concurrent SetMenu calls safely through staged rebuilds, and provides both unit regression tests and a comprehensive stress harness demonstrating bounded handle usage.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to Windows systray menu resource management and lifecycle fixes. The PR includes new test files, a stress harness example, and documentation updates—all directly supporting the core fixes. No unrelated refactoring, feature additions, or changes to unrelated systems were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
v3/pkg/application/menu_windows.go (1)

52-59: ⚠️ Potential issue | 🟡 Minor

update() doesn't reset menuMapping / currentMenuID, diverging from the parallel fix in popupmenu_windows.go.

Win32Menu.Update() at popupmenu_windows.go:193-211 recreates menuMapping, checkboxItems, and radioGroups on every rebuild so stale pointer-keyed entries don't accumulate. The equivalent windowsMenu.update() here only frees bitmaps and destroys the HMENU; w.menuMapping and w.currentMenuID keep growing across rebuilds. Across long-running churn this is a slow map-leak of *MenuItem references, and it also means freeBitmaps walks stale entries on each rebuild (currently harmless because each processMenu assigns a fresh newMenuItemImpl with bitmap==0, but fragile if that invariant ever changes).

🔧 Proposed fix
 func (w *windowsMenu) update() {
 	if w.hMenu != 0 {
 		w.freeBitmaps()
 		w32.DestroyMenu(w.hMenu)
 	}
 	w.hMenu = w32.NewPopupMenu()
+	w.menuMapping = make(map[int]*MenuItem)
+	w.currentMenuID = 0
 	w.processMenu(w.hMenu, w.menu)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/menu_windows.go` around lines 52 - 59, windowsMenu.update
currently rebuilds the HMENU but leaves w.menuMapping and w.currentMenuID
intact, causing stale pointer-keyed entries to accumulate; modify
windowsMenu.update so after freeing bitmaps and destroying/creating the HMENU
(i.e., keep freeBitmaps() then DestroyMenu/NewPopupMenu calls) you reinitialize
w.menuMapping to a fresh map and reset w.currentMenuID to its initial value
(e.g., 0) before calling w.processMenu so the mapping and ID counter don't grow
across rebuilds.
🧹 Nitpick comments (8)
v3/pkg/application/popupmenu_windows.go (2)

64-81: Duplicated between popupmenu_windows.go and menu_windows.go — consider a shared helper.

This freeBitmaps implementation is essentially identical to windowsMenu.freeBitmaps in v3/pkg/application/menu_windows.go:24-41 (same slice walk, same menuMapping walk, same impl.bitmap clearing). Non-blocking, but a small helper like freeMenuBitmaps(bitmaps []w32.HBITMAP, mapping map[int]*MenuItem) would keep the two in lockstep — the current duplication makes it easy to fix a bug in one place and miss the other.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/popupmenu_windows.go` around lines 64 - 81, Extract the
duplicated bitmap cleanup logic from Win32Menu.freeBitmaps and
windowsMenu.freeBitmaps into a shared helper (e.g., freeMenuBitmaps) that
accepts the slice of HBITMAPs and the menuMapping, then update both
Win32Menu.freeBitmaps and windowsMenu.freeBitmaps to call freeMenuBitmaps;
ensure the helper iterates the provided bitmaps slice and calls
w32.DeleteObject, walks the mapping entries, type-asserts to *windowsMenuItem,
deletes impl.bitmap via w32.DeleteObject and sets impl.bitmap = 0, and finally
clears the input slice/mapping references as the original functions did.

314-317: Destroy() is not idempotent — a second call will re-walk menuMapping and call DestroyMenu(0).

After Destroy(), p.menu is not zeroed and p.menuMapping isn't cleared. Practically this is safe today (impl.bitmap was set to 0 by the first freeBitmaps, and w32.DestroyMenu(0) returns FALSE without crashing — now actually correctly, thanks to the syscall fix in this PR), but it's fragile. updateMenu in systemtray_windows.go is already the primary caller and it replaces s.menu right after, so this doesn't bite in the current flow.

🔧 Optional hardening
 func (p *Win32Menu) Destroy() {
+	if p.menu == 0 {
+		return
+	}
 	p.freeBitmaps()
 	w32.DestroyMenu(p.menu)
+	p.menu = 0
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/popupmenu_windows.go` around lines 314 - 317, Destroy() on
Win32Menu is not idempotent because it leaves p.menu and p.menuMapping populated
so repeated calls re-walk and call w32.DestroyMenu(0); change Destroy to be safe
by early-returning if p.menu == 0 (or nil equivalent), and after calling
p.freeBitmaps() and w32.DestroyMenu(p.menu) set p.menu = 0 and clear/empty
p.menuMapping (and any other references to the destroyed menu) so subsequent
Destroy calls are no-ops; reference the Win32Menu.Destroy, p.menu,
p.menuMapping, freeBitmaps, and updateMenu (systemtray_windows.go) when making
the change.
v3/pkg/w32/menu_windows_test.go (1)

48-73: Tolerance of 50 over 2000 iterations is reasonable but consider sampling more points for a stronger signal.

The single-point start/end delta catches gross leakage but can miss gradual drift that stays under 50 across 2000 iterations yet would exceed it across 50,000. Not blocking, but for a genuinely airtight regression you could (a) sample mid-run and assert the delta is ~stable, or (b) raise iterations and keep tolerance small. The current form is sufficient to catch the pre-fix bug, which is the stated goal.

One minor thing: getUserObjectCount re-loads user32.dll and kernel32.dll on every call via NewLazyDLL. That's fine for a test that invokes it twice, but if someone later moves it into the hot loop, cache the *LazyProc values at package init.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/w32/menu_windows_test.go` around lines 48 - 73, The test
TestMenuHandleLifecycleDoesNotLeak currently only samples start/end and calls
getUserObjectCount which re-loads user32/kernel32 via NewLazyDLL on each call;
to improve robustness, either add a mid-run sampling/assertion (call
getUserObjectCount at one or more intermediate iterations and assert delta
remains small) or increase iterations while keeping tolerance small, and modify
getUserObjectCount to cache the DLL and Proc handles by creating the
LazyDLL/LazyProc values at package init (e.g., package-level vars initialized
once) instead of calling NewLazyDLL/NewProc on every getUserObjectCount
invocation.
v3/pkg/application/menu_windows.go (1)

24-41: Double-free hazard if the same HBITMAP ever ends up in both w.bitmaps and impl.bitmap.

Today the two collections are disjoint (processMenu only populates w.bitmaps; setBitmap only populates impl.bitmap), so this is safe. But the invariant is entirely implicit: a future change that e.g. appends the runtime SetBitmap handle into w.bitmaps, or replays processMenu without clearing w.bitmaps, would cause a double DeleteObject on the same GDI handle — which is undefined behaviour and hard to diagnose.

Consider either (a) a comment explicitly documenting the ownership split, or (b) unifying tracking in a single location (e.g. always store per-item bitmaps on impl.bitmap and iterate menuMapping only). Not blocking — just flagging the trap for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/menu_windows.go` around lines 24 - 41, In freeBitmaps,
avoid possible double-free between w.bitmaps and per-item windowsMenuItem.bitmap
by deduplicating and deleting each HBITMAP exactly once: in
windowsMenu.freeBitmaps build a set keyed by handle (from w.bitmaps and from
each item.impl.(*windowsMenuItem).bitmap via w.menuMapping), then iterate the
set and call w32.DeleteObject once per unique handle and clear the per-item
impl.bitmap and w.bitmaps; alternatively, if you prefer minimal change, add a
clear comment in freeBitmaps documenting the current ownership invariant
(w.bitmaps vs windowsMenuItem.bitmap) so future changes don’t accidentally cause
double DeleteObject.
v3/UNRELEASED_CHANGELOG.md (1)

26-26: Optional: reference the linked issue for traceability.

The changelog guidelines at the top of this file suggest referencing issue/PR numbers when applicable. Since this entry closes #5227, adding the reference would help readers trace the fix back to the report.

📝 Proposed addition
-- Fix a family of Windows systray `SetMenu` crashes caused by a broken `DestroyMenu` syscall that was passing four arguments instead of one, so every call returned FALSE and freed nothing. Also release HMENU and HBITMAP handles (including those allocated at runtime via `MenuItem.SetBitmap`) on menu rebuilds, reset stale checkbox/radio maps in `Win32Menu.Update`, and drop a redundant `Update()` call in `systemtray.updateMenu` that doubled allocations. Long-running systray apps no longer leak GDI/USER objects on each menu rebuild.
+- Fix a family of Windows systray `SetMenu` crashes caused by a broken `DestroyMenu` syscall that was passing four arguments instead of one, so every call returned FALSE and freed nothing. Also release HMENU and HBITMAP handles (including those allocated at runtime via `MenuItem.SetBitmap`) on menu rebuilds, reset stale checkbox/radio maps in `Win32Menu.Update`, and drop a redundant `Update()` call in `systemtray.updateMenu` that doubled allocations. Long-running systray apps no longer leak GDI/USER objects on each menu rebuild. (`#5227`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/UNRELEASED_CHANGELOG.md` at line 26, Update the changelog entry text to
reference the related issue/PR by appending a traceability note such as "
(closes `#5227`)" or "See `#5227`" to the existing sentence about the Windows
systray fix so readers can follow back to the report; edit the
UNRELEASED_CHANGELOG.md entry that begins "Fix a family of Windows systray
`SetMenu` crashes..." to include the issue/PR number for traceability.
v3/examples/systray-stress/main.go (3)

180-187: Nit: logJSON doesn't emit JSON.

The inline comment already acknowledges it's prefix=value. Renaming to logEvent (or similar) avoids grep confusion and a future reader assuming they can pipe the output into jq.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/systray-stress/main.go` around lines 180 - 187, The function
logJSON is misnamed because it emits space-separated key=value output rather
than JSON; rename the function (e.g., to logEvent or logKV) and update all call
sites to use the new name, adjust the inline comment to describe "prefix=value"
logging, and keep the same signature (func logEvent(event string, fields
map[string]any)) so behavior and interfaces remain unchanged.

211-215: Dead reference to icons.SystrayLight — either use it as a fallback or drop it.

The blank assignment only silences an unused-import error, and the comment suggests a fallback that never happens (when logo is empty nothing is set on the tray). Either wire it in as the fallback icon, or remove both the line and the icons import.

♻️ Suggested simplification
 	tray := app.SystemTray.New()
-	_ = icons.SystrayLight // ensure icons module is wired; tray uses defaults if we set nothing
 	if len(logo) > 0 {
 		tray.SetIcon(logo)
+	} else {
+		tray.SetIcon(icons.SystrayLight)
 	}

Or drop both the assignment and the "github.com/wailsapp/wails/v3/pkg/icons" import.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/systray-stress/main.go` around lines 211 - 215, The file contains
a dead reference to icons.SystrayLight that only silences an unused import;
either use it as the fallback icon when logo is empty or remove the line and the
icons import. Fix option A: change the logic around tray/SetIcon (symbols: tray,
logo, tray.SetIcon) so if len(logo) == 0 you call
tray.SetIcon(icons.SystrayLight) as the default; Fix option B: delete the blank
assignment to icons.SystrayLight and remove the
"github.com/wailsapp/wails/v3/pkg/icons" import so the unused reference is
eliminated.

60-89: INPUT struct padding assumes 64-bit architecture; build constraint should be more specific.

The hard-coded _padding [8]byte in keyboardInput and uint32 alignment word in input are sized for amd64/arm64 only. On Windows 386, these sizes don't match the real Win32 INPUT layout, and SendInput would silently fail. While the file has a //go:build windows constraint, clarify this further with //go:build windows && (amd64 || arm64) if 386 support is not intended. Alternatively, add a comment documenting that the padding is 64-bit specific.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/systray-stress/main.go` around lines 60 - 89, The INPUT struct
padding is hardcoded for 64-bit and will break on 32-bit Windows; update the
build constraint to restrict this file to 64-bit Windows by adding a top-level
build tag like "//go:build windows && (amd64 || arm64)" (affecting the
keyboardInput, input and sendEscapeKey definitions), or if 386 support is
required, adjust the `_padding` size and the alignment word in `input` to match
32-bit Win32 layout and add a clear comment; ensure the change is applied where
`keyboardInput`, `_padding`, `input`, and `sendEscapeKey` are defined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@v3/examples/systray-stress/main.go`:
- Around line 231-255: The exit helper always logs runtime_ms: 0 because start
time is only passed for the duration_reached branch; fix by capturing a single
start time variable (e.g., startTime := time.Now()) at the outer scope and
change exit (the exit func) to compute elapsed :=
time.Since(startTime).Milliseconds() and set fields["runtime_ms"]=elapsed before
logging; remove the redundant local start := time.Now() inside
OnApplicationEvent so all terminal branches (iter_target_reached,
handle_cap_exceeded, app_run_error, duration_reached) report the actual runtime.
- Around line 300-302: Update the stale comment to reflect that the SetBitmap
leak was fixed: state that freeBitmaps() now walks menuMapping and deletes
impl.bitmap for each windowsMenuItem (see freeBitmaps, menuMapping,
windowsMenuItem) so bitmaps are released on updateMenu/rebuild; note that the
mutate workload in this example verifies that this cleanup path still runs on
every rebuild rather than implying a leak remains.

In `@v3/pkg/application/menu_windows.go`:
- Around line 120-128: AppendMenu's return value is currently ignored in the
loop that builds the menu (w32.AppendMenu(parentMenu, flags, uintptr(itemID),
menuText)), which can hide append failures and lead to misleading SetMenuIcons
errors; update this block to check the AppendMenu success and on failure log via
globalApplication.error with context (including itemID/menuText) and abort the
rebuild (mirror popupmenu_windows.go buildMenu behavior) before attempting
SetMenuIcons; also consider changing the SetMenuIcons error handling in this
function to return (or at least log the append failure first) so w.bitmaps is
only appended when both AppendMenu and SetMenuIcons succeed.

In `@v3/pkg/application/popupmenu_windows.go`:
- Around line 170-182: The rebuild currently mutates p.menu and returns early on
AppendMenu/SetMenuIcons failures, leaving a partially-built menu; change
buildMenu to return an error and have Update() create the new HMENU into a local
variable (e.g., newMenu) and call buildMenu(newMenu, ...) so any
AppendMenu/SetMenuIcons failures cause buildMenu to return that error instead of
early-returning, then only swap p.menu and destroy the old menu when buildMenu
completes successfully; also propagate errors from recursive submenu calls in
buildMenu so an inner failure aborts the whole rebuild rather than appending a
partial submenu.

---

Outside diff comments:
In `@v3/pkg/application/menu_windows.go`:
- Around line 52-59: windowsMenu.update currently rebuilds the HMENU but leaves
w.menuMapping and w.currentMenuID intact, causing stale pointer-keyed entries to
accumulate; modify windowsMenu.update so after freeing bitmaps and
destroying/creating the HMENU (i.e., keep freeBitmaps() then
DestroyMenu/NewPopupMenu calls) you reinitialize w.menuMapping to a fresh map
and reset w.currentMenuID to its initial value (e.g., 0) before calling
w.processMenu so the mapping and ID counter don't grow across rebuilds.

---

Nitpick comments:
In `@v3/examples/systray-stress/main.go`:
- Around line 180-187: The function logJSON is misnamed because it emits
space-separated key=value output rather than JSON; rename the function (e.g., to
logEvent or logKV) and update all call sites to use the new name, adjust the
inline comment to describe "prefix=value" logging, and keep the same signature
(func logEvent(event string, fields map[string]any)) so behavior and interfaces
remain unchanged.
- Around line 211-215: The file contains a dead reference to icons.SystrayLight
that only silences an unused import; either use it as the fallback icon when
logo is empty or remove the line and the icons import. Fix option A: change the
logic around tray/SetIcon (symbols: tray, logo, tray.SetIcon) so if len(logo) ==
0 you call tray.SetIcon(icons.SystrayLight) as the default; Fix option B: delete
the blank assignment to icons.SystrayLight and remove the
"github.com/wailsapp/wails/v3/pkg/icons" import so the unused reference is
eliminated.
- Around line 60-89: The INPUT struct padding is hardcoded for 64-bit and will
break on 32-bit Windows; update the build constraint to restrict this file to
64-bit Windows by adding a top-level build tag like "//go:build windows &&
(amd64 || arm64)" (affecting the keyboardInput, input and sendEscapeKey
definitions), or if 386 support is required, adjust the `_padding` size and the
alignment word in `input` to match 32-bit Win32 layout and add a clear comment;
ensure the change is applied where `keyboardInput`, `_padding`, `input`, and
`sendEscapeKey` are defined.

In `@v3/pkg/application/menu_windows.go`:
- Around line 24-41: In freeBitmaps, avoid possible double-free between
w.bitmaps and per-item windowsMenuItem.bitmap by deduplicating and deleting each
HBITMAP exactly once: in windowsMenu.freeBitmaps build a set keyed by handle
(from w.bitmaps and from each item.impl.(*windowsMenuItem).bitmap via
w.menuMapping), then iterate the set and call w32.DeleteObject once per unique
handle and clear the per-item impl.bitmap and w.bitmaps; alternatively, if you
prefer minimal change, add a clear comment in freeBitmaps documenting the
current ownership invariant (w.bitmaps vs windowsMenuItem.bitmap) so future
changes don’t accidentally cause double DeleteObject.

In `@v3/pkg/application/popupmenu_windows.go`:
- Around line 64-81: Extract the duplicated bitmap cleanup logic from
Win32Menu.freeBitmaps and windowsMenu.freeBitmaps into a shared helper (e.g.,
freeMenuBitmaps) that accepts the slice of HBITMAPs and the menuMapping, then
update both Win32Menu.freeBitmaps and windowsMenu.freeBitmaps to call
freeMenuBitmaps; ensure the helper iterates the provided bitmaps slice and calls
w32.DeleteObject, walks the mapping entries, type-asserts to *windowsMenuItem,
deletes impl.bitmap via w32.DeleteObject and sets impl.bitmap = 0, and finally
clears the input slice/mapping references as the original functions did.
- Around line 314-317: Destroy() on Win32Menu is not idempotent because it
leaves p.menu and p.menuMapping populated so repeated calls re-walk and call
w32.DestroyMenu(0); change Destroy to be safe by early-returning if p.menu == 0
(or nil equivalent), and after calling p.freeBitmaps() and
w32.DestroyMenu(p.menu) set p.menu = 0 and clear/empty p.menuMapping (and any
other references to the destroyed menu) so subsequent Destroy calls are no-ops;
reference the Win32Menu.Destroy, p.menu, p.menuMapping, freeBitmaps, and
updateMenu (systemtray_windows.go) when making the change.

In `@v3/pkg/w32/menu_windows_test.go`:
- Around line 48-73: The test TestMenuHandleLifecycleDoesNotLeak currently only
samples start/end and calls getUserObjectCount which re-loads user32/kernel32
via NewLazyDLL on each call; to improve robustness, either add a mid-run
sampling/assertion (call getUserObjectCount at one or more intermediate
iterations and assert delta remains small) or increase iterations while keeping
tolerance small, and modify getUserObjectCount to cache the DLL and Proc handles
by creating the LazyDLL/LazyProc values at package init (e.g., package-level
vars initialized once) instead of calling NewLazyDLL/NewProc on every
getUserObjectCount invocation.

In `@v3/UNRELEASED_CHANGELOG.md`:
- Line 26: Update the changelog entry text to reference the related issue/PR by
appending a traceability note such as " (closes `#5227`)" or "See `#5227`" to the
existing sentence about the Windows systray fix so readers can follow back to
the report; edit the UNRELEASED_CHANGELOG.md entry that begins "Fix a family of
Windows systray `SetMenu` crashes..." to include the issue/PR number for
traceability.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f590aebf-5aed-4f2f-a7ff-85733ec6a470

📥 Commits

Reviewing files that changed from the base of the PR and between b1fd8e9 and 3914205.

⛔ Files ignored due to path filters (1)
  • v3/examples/systray-stress/logo.png is excluded by !**/*.png
📒 Files selected for processing (10)
  • v3/UNRELEASED_CHANGELOG.md
  • v3/examples/systray-stress/README.md
  • v3/examples/systray-stress/main.go
  • v3/pkg/application/menu_windows.go
  • v3/pkg/application/menuitem_windows.go
  • v3/pkg/application/popupmenu_windows.go
  • v3/pkg/application/systemtray_windows.go
  • v3/pkg/w32/icon.go
  • v3/pkg/w32/menu_windows_test.go
  • v3/pkg/w32/user32.go

Comment thread v3/examples/systray-stress/main.go
Comment thread v3/examples/systray-stress/main.go Outdated
Comment thread v3/pkg/application/menu_windows.go Outdated
Comment thread v3/pkg/application/popupmenu_windows.go
@johnmaguire

johnmaguire commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed:

  • windowsMenu.update() now resets menuMapping and currentMenuID on rebuild, matching Win32Menu.Update's parity fix — e0a7fa3 (outside-diff comment on menu_windows.go:52-59).
  • processMenu now checks the AppendMenu return and aborts with a proper error instead of silently continuing into SetMenuIcons — ac1b36a (menu_windows.go:120-128).
  • runtime_ms is now captured once at startup and computed inside exit(), so every terminal branch reports the real duration — 0303e93 (systray-stress/main.go:231-255).
  • Reframed the mutate-mode header and inline comments as a regression guard for the SetBitmap cleanup path that this PR introduced, rather than still implying the leak exists — abe8ab8 (systray-stress/main.go:60-89, 300-302).
  • buildMenu / processMenu now return error, recursive submenu builds propagate and clean up the orphaned HMENU, and Update()/update() stage the rebuild into a fresh HMENU + fresh maps — only destroying the old state on success. One known wart is called out inline: buildMenu replaces item.impl as it goes, so on the failure path any MenuItem touched before
    the failure point will hold a stale impl until the next successful rebuild. That felt like the right trade-off vs. a much larger rewrite for a path that only trips on Win32 resource exhaustion — eff737c (popupmenu_windows.go:170-182). Pulled the shared HBITMAP-release loop into a file-private releaseMenuBitmaps helper so both freeBitmaps methods delegate
    to the same implementation; its doc comment spells out the disjoint-ownership invariant between bitmaps and impl.bitmap, covering the double-free concern raised against menu_windows.go:24-41.
  • Renamed logJSON → logEvent — it emits event=… key=value lines, not JSON — 016901b (systray-stress/main.go:180).
  • Dropped the dead _ = icons.SystrayLight and the corresponding icons import — b763cd6 (systray-stress/main.go:211-215).

Consciously skipped:

  • Idempotent Win32Menu.Destroy(). Platform-specific destroy methods in this package (windowsWebviewWindow.destroy, windowsMenuItem.destroy) deliberately trust their single owner and call native APIs once — the idempotence lives one layer up in the Go-level Menu.Destroy / MenuItem.Destroy. Adding guards only to Win32Menu would make it a snowflake relative
    to its peers, and every current caller already nulls the pointer after destroy.
  • Test mid-run sampling / caching NewLazyDLL in menu_windows_test.go. The start/end delta is enough to catch the pre-fix regression, which was the stated goal. If the test ever moves into a hot loop, caching the procs is cheap to add at that point.
  • //go:build windows && (amd64 || arm64) on the stress harness. rg '^//go:build' v3/ returns zero hits for any arch constraint across v3 — Wails doesn't gate anything by CPU arch. The 64-bit INPUT padding is consistent with the rest of the codebase's posture. If 386 support ever becomes a goal, this harness won't be the first or last place that needs
    attention.

Let me know if any of the skipped items should be revisited.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
v3/pkg/application/menu_windows.go (1)

65-68: ⚠️ Potential issue | 🟠 Major

Same orphan-bitmap concern as Win32Menu.Update — more likely to trigger here.

windowsMenu.update() is called from ShowAt on every context-menu display, so the oldHMENU != 0 branch fires routinely. The releaseMenuBitmaps(oldBitmaps, oldMapping) call at line 66 runs after processMenu has swapped item.impl for every item (line 83), so any runtime SetBitmap handle previously held on the old impl's .bitmap is unreachable by the time the mapping walk inside releaseMenuBitmaps runs — it leaks on every successful rebuild. See the detailed comment and suggested fix on v3/pkg/application/popupmenu_windows.go (lines 233–251); the same pre-processMenu impl-bitmap harvesting fix applies here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/menu_windows.go` around lines 65 - 68, windowsMenu.update
currently calls releaseMenuBitmaps(oldBitmaps, oldMapping) after processMenu
swaps item.impl, which causes bitmap handles on the old impls to become
unreachable and leak; before calling processMenu (i.e., while oldHMENU != 0 and
before any item.impl is replaced) iterate the current items and collect any
bitmap handles held on the existing impls (the same handles releaseMenuBitmaps
expects) into oldBitmaps/oldMapping, then call releaseMenuBitmaps immediately
and only afterwards proceed to processMenu to replace item.impl and
DestroyMenu(oldHMENU); reference windowsMenu.update, ShowAt, processMenu,
item.impl, SetBitmap, releaseMenuBitmaps, oldHMENU, oldBitmaps and oldMapping
when locating where to perform this pre-swap harvest.
🧹 Nitpick comments (1)
v3/examples/systray-stress/main.go (1)

1-380: LGTM — stress harness is well-structured and the previously-flagged issues (runtime_ms=0, stale leak comment) are addressed.

The four-mode workload cleanly separates concerns: churn exercises staged rebuilds, mutate regression-guards the runtime-SetBitmap cleanup path described in the PR, and show/churn+show exercise the popup-display path. A couple of minor observations, none blocking:

  • show mode has no internal progress/handle-cap check; only churn/mutate emit progress lines and evaluate handle_cap_exceeded. That means -mode=show without -duration will run forever without surfacing leaks unless paired with churn. Worth a README note or a similar periodic check if you want show-only runs to self-diagnose.
  • The dismiss goroutine in show (lines 342–345) can fire after OpenMenu has already returned (e.g., if TrackPopupMenuEx fails fast), sending an ESC to whatever window is foreground. Fine for a local stress harness; less ideal if this ever runs unattended in CI.
  • keyboardInput._padding [8]byte is sized for amd64 INPUT union layout; given Wails v3 targets amd64/arm64 Windows this is fine, but a brief comment noting the 64-bit assumption would help future readers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/systray-stress/main.go` around lines 1 - 380, The show mode
currently lacks periodic progress/handle-cap checks and its dismiss goroutine
may send ESC after OpenMenu returns; update the show function to (1) emit the
same progress log and handle/GDI cap checks used in churn/mutate (use
getGuiResources, logEvent, cfg.logEvery, cfg.handleCap and call exit on exceed)
and (2) prevent late ESC by coordinating the dismiss goroutine with the OpenMenu
call (e.g., create a per-iteration done signal/channel that the goroutine
selects on before calling sendEscapeKey, and close/signal it immediately after
OpenMenu returns) so sendEscapeKey only runs while the menu is expected open.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@v3/pkg/application/popupmenu_windows.go`:
- Around line 233-251: Before calling buildMenu (in popupmenu_windows.go update
path) snapshot the HBITMAP handles from the current item implementations so they
aren't lost when buildMenu replaces item.impl; specifically iterate oldMapping
(p.menuMapping) and collect each impl.bitmap into oldBitmaps (or a new slice)
before calling p.buildMenu(newHMENU, p.menuData). After a successful build, call
releaseMenuBitmaps with that pre-build bitmap slice and the oldMapping to free
those handles, and keep the existing failure-path cleanup logic untouched. Apply
the same pre-build bitmap snapshot approach in the parallel windowsMenu.update()
code path to prevent leaks when item.impl is reassigned.

---

Duplicate comments:
In `@v3/pkg/application/menu_windows.go`:
- Around line 65-68: windowsMenu.update currently calls
releaseMenuBitmaps(oldBitmaps, oldMapping) after processMenu swaps item.impl,
which causes bitmap handles on the old impls to become unreachable and leak;
before calling processMenu (i.e., while oldHMENU != 0 and before any item.impl
is replaced) iterate the current items and collect any bitmap handles held on
the existing impls (the same handles releaseMenuBitmaps expects) into
oldBitmaps/oldMapping, then call releaseMenuBitmaps immediately and only
afterwards proceed to processMenu to replace item.impl and
DestroyMenu(oldHMENU); reference windowsMenu.update, ShowAt, processMenu,
item.impl, SetBitmap, releaseMenuBitmaps, oldHMENU, oldBitmaps and oldMapping
when locating where to perform this pre-swap harvest.

---

Nitpick comments:
In `@v3/examples/systray-stress/main.go`:
- Around line 1-380: The show mode currently lacks periodic progress/handle-cap
checks and its dismiss goroutine may send ESC after OpenMenu returns; update the
show function to (1) emit the same progress log and handle/GDI cap checks used
in churn/mutate (use getGuiResources, logEvent, cfg.logEvery, cfg.handleCap and
call exit on exceed) and (2) prevent late ESC by coordinating the dismiss
goroutine with the OpenMenu call (e.g., create a per-iteration done
signal/channel that the goroutine selects on before calling sendEscapeKey, and
close/signal it immediately after OpenMenu returns) so sendEscapeKey only runs
while the menu is expected open.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 50d861be-e758-46e8-bdfc-a93b65fffdd7

📥 Commits

Reviewing files that changed from the base of the PR and between 3914205 and b763cd6.

📒 Files selected for processing (3)
  • v3/examples/systray-stress/main.go
  • v3/pkg/application/menu_windows.go
  • v3/pkg/application/popupmenu_windows.go

Comment thread v3/pkg/application/popupmenu_windows.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
v3/pkg/application/popupmenu_windows.go (1)

210-263: Staged rebuild + rollback LGTM.

The flow correctly stages new state, hoists runtime SetBitmap handles into oldBitmaps before buildMenu reassigns item.impl, and only destroys the old HMENU tree once the build succeeds. The documented failure-path caveat (stale impl for items processed before the failure point) is a reasonable trade-off for a path that only triggers under Win32 resource exhaustion.

Minor observation: the transfer loop at lines 229-234 is duplicated verbatim in menu_windows.go update() (lines 53-58). Could be extracted to a helper like transferRuntimeBitmaps(mapping, bitmaps []HBITMAP) []HBITMAP alongside releaseMenuBitmaps, but only if you're already touching both files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/pkg/application/popupmenu_windows.go` around lines 210 - 263, The
duplicate runtime-bitmap transfer loop present in Win32Menu.Update (for _, item
:= range oldMapping { ... }) is repeated in menu_windows.go's update(); extract
that logic into a shared helper (e.g., transferRuntimeBitmaps) and call it from
both Win32Menu.Update and the update function in menu_windows.go, ensuring the
helper accepts the mapping and existing bitmap slice and returns the updated
[]HBITMAP; also add the helper near releaseMenuBitmaps to keep related bitmap
utilities together and replace both in-place loops with calls to
transferRuntimeBitmaps to remove duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@v3/pkg/application/popupmenu_windows.go`:
- Around line 210-263: The duplicate runtime-bitmap transfer loop present in
Win32Menu.Update (for _, item := range oldMapping { ... }) is repeated in
menu_windows.go's update(); extract that logic into a shared helper (e.g.,
transferRuntimeBitmaps) and call it from both Win32Menu.Update and the update
function in menu_windows.go, ensuring the helper accepts the mapping and
existing bitmap slice and returns the updated []HBITMAP; also add the helper
near releaseMenuBitmaps to keep related bitmap utilities together and replace
both in-place loops with calls to transferRuntimeBitmaps to remove duplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3d59149e-cfbc-48df-aea3-337f076037c3

📥 Commits

Reviewing files that changed from the base of the PR and between b763cd6 and c923747.

📒 Files selected for processing (2)
  • v3/pkg/application/menu_windows.go
  • v3/pkg/application/popupmenu_windows.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@v3/pkg/application/menu_bitmap_windows_test.go`:
- Around line 26-31: getGDIObjectCount currently ignores the syscall error and
doesn't validate the returned value, which can allow a zero (failed) sample to
silently pass; update getGDIObjectCount to capture and check the syscall
error/return values from testGetGuiResources (use the third return value or
lastErr equivalent), and ensure the returned n is non-zero (or log/fail the
test) so callers can detect a broken probe — modify the function to return an
error or assert/log when n==0 and surface that to the caller (references:
getGDIObjectCount, testGetGuiResources, testGR_GDIOBJECTS).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 59817c9c-b97e-4cbf-ada9-baeafe4c26a7

📥 Commits

Reviewing files that changed from the base of the PR and between c923747 and d60f7dc.

📒 Files selected for processing (1)
  • v3/pkg/application/menu_bitmap_windows_test.go

Comment thread v3/pkg/application/menu_bitmap_windows_test.go Outdated
@johnmaguire

johnmaguire commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

@leaanthony The CI is red due to changes unrelated to this PR. They are license compliance issues that were present in wails already, and require a maintainer to fix.

Has it been fixed on v3-alpha? Does this just need a rebase?

@johnmaguire

Copy link
Copy Markdown
Contributor Author

I see you changed the target branch. Is master the correct branch for this change?

johnmaguire and others added 19 commits May 1, 2026 11:31
The wrapper was calling procDestroyMenu.Call(1, uintptr(hMenu), 0, 0),
which on x86-64 Windows places the literal 1 in RCX (the single argument
slot DestroyMenu expects) and the real handle in RDX, where it is
ignored. Every call therefore returned FALSE (invalid handle) without
freeing anything.

As a result Win32Menu.Destroy() — invoked from windowsSystemTray.destroy
on app shutdown and from the SetMenu rebuild path once the surrounding
fixes land — has been a silent no-op. Fix the syscall to pass the handle
as the single argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cate Update

windowsSystemTray.updateMenu replaced s.menu without destroying the
previous *Win32Menu, leaking its entire HMENU tree (top-level menu +
every submenu created by buildMenu) on every SetMenu call. It also
followed NewPopupMenu (which already runs Update internally) with a
second explicit s.menu.Update, causing each rebuild to allocate the
entire tree twice and abandon the first copy.

Destroy the previous Win32Menu before replacing the pointer, and remove
the redundant Update call.

Paired with the DestroyMenu syscall fix that precedes this commit — if
that commit is absent, s.menu.Destroy is silently a no-op and this
change has no measurable effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Win32Menu.Update overwrote p.menu with a freshly allocated HMENU without
destroying the previous one, so every Update leaked the full HMENU tree
(the popup itself plus every submenu HMENU created inside buildMenu).
It also failed to reset p.checkboxItems and p.radioGroups, so repeated
Update calls accumulated entries referencing destroyed item IDs.

Destroy the previous HMENU — DestroyMenu recursively destroys attached
submenus — and reset the checkbox / radio maps before rebuilding.

Depends on the DestroyMenu syscall fix earlier in this series; without
it the destroy call is silently a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
buildMenu called globalApplication.fatal on any AppendMenu or
SetMenuIcons failure, tearing down the entire app for a single unlucky
menu item — including transient failures from USER / GDI handle
exhaustion that the companion fixes in this series aim to prevent from
ever happening.

Downgrade both to globalApplication.error and abort the current rebuild
so the previous (working) menu continues to be usable. Defense in depth
rather than masking a root cause: the leak fixes should make AppendMenu
failures vanishingly rare, but a production app should not crash if one
ever occurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… destroy

w32.SetMenuIcons allocated a fresh HBITMAP per call via
CreateHBITMAPFromImage and handed it to SetMenuItemBitmaps without
returning the handle to the caller. No code path ever called
DeleteObject on it. Per MSDN the application owns bitmaps set via
SetMenuItemBitmaps — DestroyMenu does not free them — so each SetMenu
rebuild leaked one HBITMAP per bitmap menu item (a fresh GDI object
from CreateDIBSection), filling the 10,000/process GDI quota in a
few thousand rebuilds for apps with bitmap items. Empirical repro:
~10 GDI handles/iter against a 10-bitmap menu.

Change SetMenuIcons to return the allocated handles and update all
three call sites to track and free them:

- popupmenu_windows.go: Win32Menu.bitmaps, freed in Update() and
  Destroy().
- menu_windows.go: windowsMenu.bitmaps, freed in update(). Also
  downgrades the SetMenuIcons fatal here to error+continue, matching
  the earlier commit in this series that softened the popupmenu path.
- menuitem_windows.go: windowsMenuItem.bitmap tracks the current
  handle so dynamic MenuItem.SetBitmap updates release the previous
  bitmap before installing the new one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stress harness that drives SystemTray.SetMenu under three workloads:
pure churn, popup-open-plus-dismiss, and both concurrent. Logs
GR_USEROBJECTS / GR_GDIOBJECTS deltas per N iterations and exits
cleanly when iteration, duration, or handle caps are reached.

Used to diagnose and verify the handle-leak + soft-fail fixes in the
preceding commits, and retained as a regression surface against future
changes to the Windows menu path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tests against the Windows-only w32 package:

- TestDestroyMenuReturnsTrueForValidHandle — calls CreatePopupMenu +
  DestroyMenu and asserts the latter returns true. Before the syscall
  signature fix this returned false because the handle argument was
  being placed in the wrong register slot.

- TestDestroyMenuReturnsFalseForZeroHandle — sanity check that passing
  handle 0 returns false.

- TestMenuHandleLifecycleDoesNotLeak — runs 2000 Create+Append+Destroy
  cycles and asserts the process's USER-object handle count does not
  grow unboundedly. On the pre-fix DestroyMenu this test fails at
  iteration 0 on the DestroyMenu assertion and would otherwise have
  leaked ~2000 handles.

Verified to fail against the pre-fix DestroyMenu (handles the first
commit in this series). Runs on Windows CI under the //go:build windows
constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing -bitmaps churn mode rebuilds a fresh Menu every iteration,
so MenuItems never have an impl at SetBitmap time and the Windows
setBitmap path is a no-op on the native side. Mutate mode keeps a single
Menu across iterations and calls SetBitmap on its items after the first
build — that is the path where windowsMenuItem.setBitmap allocates an
HBITMAP tracked only on impl.bitmap, which is orphaned when the next
SetMenu rebuild replaces the impl.

Pre-fix measurement (5 targets × 1000 iterations):
  gdi_delta=5007 after 1000 iters (≈5 leaks/iter, 1/target/iter)

Post-fix measurement with the same workload:
  gdi_delta=12 flat across 1000 iters

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stroy

HBITMAPs allocated by MenuItem.SetBitmap at runtime (after the initial
build) live on windowsMenuItem.bitmap, not in the menu-level bitmaps
slice. freeBitmaps only walked the slice, so every SetMenu rebuild
replaced the impl and orphaned its HBITMAP — one leaked GDI object per
item per rebuild. Extend freeBitmaps on both Win32Menu (popup) and
windowsMenu (application) to walk menuMapping and release each impl's
bitmap before DestroyMenu runs.

Verified via systray-stress mutate mode: gdi_delta=5007→12 over 1000
iterations with 5 targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….update

Parity with Win32Menu.Update in popupmenu_windows.go — windowsMenu.update
was leaving menuMapping and currentMenuID intact across rebuilds, so stale
*MenuItem entries accumulated keyed by IDs that referenced HMENUs that no
longer existed. Matches the fix already applied to the popup-menu twin.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n processMenu

Parity with Win32Menu.buildMenu in popupmenu_windows.go — processMenu was
discarding the AppendMenu return value and continuing on SetMenuIcons
errors, so an AppendMenu failure silently surfaced as a confusing
SetMenuIcons error on the next iteration. Match the popup-menu pattern:
log the real error with item context and abort the rebuild.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tress

The exit helper was seeding runtime_ms=0 and only the duration_reached
branch passed a real value via extra — so iter_target_reached,
handle_cap_exceeded, and app_run_error always reported 0ms, masking the
actual run length for any supervisor comparing runs. Hoist start :=
time.Now() to the outer scope and compute time.Since(start) inside exit
so every terminal branch reports correctly.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header and inline comments described the runtime-SetBitmap leak as if
it still occurred; the leak was fixed in this PR by having freeBitmaps
walk menuMapping. Clarify that the mutate workload now guards against a
regression of that cleanup path rather than reproducing an active bug.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d errors

buildMenu and processMenu now return errors instead of logging and
falling through. Errors from recursive submenu builds abort the whole
rebuild rather than attaching a half-built submenu via MF_POPUP — the
previous early-return path left the outer loop appending the partial
submenu.

Win32Menu.Update and windowsMenu.update stage the rebuild into a fresh
HMENU and fresh maps, only destroying the old HMENU and swapping state
once the build completes successfully. On failure the previous menu
stays displayed and the partial HMENU is cleanly destroyed. The
item.impl inconsistency across the failure boundary is documented in
Update — the rebuild replaces item.impl as it goes, so any MenuItem
touched before the failure point will hold a stale impl until the next
successful rebuild. Acceptable for a path that only triggers on Win32
resource exhaustion.

Pulled the common HBITMAP-release loop out into a file-private helper
(releaseMenuBitmaps) since the success path now frees bitmaps tracked
on a locally-held "old" state rather than on the receiver.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The function emits event=... key=value lines, not JSON — the name was
leftover from an earlier iteration. Clarifies intent for readers and
prevents anyone grepping for JSON output from being misled.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_ = icons.SystrayLight only existed to silence Go's unused-import check;
nothing else in the file uses pkg/icons. Remove the blank reference and
the import.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uild

buildMenu/processMenu reassign item.impl at the top of every loop
iteration, so by the time the staged Update's success path calls
releaseMenuBitmaps(oldBitmaps, oldMapping), every item in oldMapping
points at a freshly-created impl with bitmap == 0. The mapping walk
finds nothing to free and any handle attached via runtime
MenuItem.SetBitmap leaks — particularly bad for windowsMenu.update,
which runs on every ShowAt of a context menu.

Snapshot impl.bitmap handles into oldBitmaps before buildMenu runs, so
the success path can rely on the slice alone. The failure path already
restores oldBitmaps to the receiver, so the hoisted handles stay
tracked for the next rebuild.

Regression introduced by eff737c; caught by CodeRabbit on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allocate a fresh HBITMAP on every iteration via MenuItem.SetBitmap, call
Menu.Update to rebuild the HMENU, and measure the GR_GDIOBJECTS delta.
A fully-leaking path orphans one HBITMAP per item per iteration (1500
handles over the run) — the 50-handle tolerance flags a single-item
regression with comfortable noise headroom.

This sits one altitude above the existing pkg/w32 DestroyMenu regression
test: it exercises the full Menu.Update → processMenu → releaseMenuBitmaps
cycle that CodeRabbit caught mid-staging on PR #5228, where item.impl
replacement defeated the mapping-walk cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
johnmaguire and others added 2 commits May 1, 2026 11:31
getGDIObjectCount discarded the syscall error and did not validate the
return value. GetGuiResources returns 0 on failure — in a hardened
container or after a WinAPI change, both start and end samples would
read 0, the delta would stay 0, and the regression guard would silently
pass without measuring anything. Assert the reading is non-zero with
t.Fatalf so a broken probe surfaces with the Win32 error instead of
hiding behind a green check.

Also drop the unused testGR_USEROBJECTS constant — the test only
probes GDI.

Addresses CodeRabbit review feedback on PR #5228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the systray-stress churn workload at the unit level: allocate a
fresh Win32Menu per iteration with build-time bitmaps via SetMenuIcons,
then Destroy. Exercises the pkg/application-level cleanup path
(freeBitmaps releasing p.bitmaps + DestroyMenu tearing down the HMENU
tree) that the systray's tray.SetMenu flow hits. The existing pkg/w32
TestMenuHandleLifecycleDoesNotLeak covers only the raw DestroyMenu
syscall; the harness covers this path only via a live Windows session.

NewPopupMenu stores the parent HWND without dereferencing it unless
ShowAt runs, so w32.GetDesktopWindow is a safe stand-in for a real
window and keeps the test headless-CI-friendly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@johnmaguire johnmaguire force-pushed the fix/windows-systray-setmenu-crashes branch from 03d75ba to 74aa2f6 Compare May 1, 2026 15:34
The GetGuiResources probe in menu_bitmap_windows_test.go never produced
a usable signal from a Go test process. Console processes start with
zero GDI objects and the probe stays stuck at zero in both interactive
sessions and CI runners, so the delta-based leak check could not
discriminate between healthy cleanup and a fully-leaking path.

Reverts the three commits that added and tried to stabilize this file
(d60f7dc, 48c400f, 4724c0f). Coverage is not lost:

- pkg/w32/menu_windows_test.go guards the raw DestroyMenu syscall
  signature that started this bug family.
- examples/systray-stress/ exercises the Menu.Update / SetBitmap /
  SetMenu churn paths against a live Windows session where
  GetGuiResources actually reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@johnmaguire

Copy link
Copy Markdown
Contributor Author

I found #5272 so I believe master is correct, and I see that it is green now. Rebased, so this PR should go green soon. 🤞

@leaanthony

Copy link
Copy Markdown
Member

Thanks John! Sorry the move to master was confusing. Looking forward to simplifying a whole bunch of things now we have the one branch.

@leaanthony

Copy link
Copy Markdown
Member

Triaged by Wails PR Reviewer

This PR has been reviewed and accepted. A test sub-issue has been created for Windows.

Head Ref OID: 6358bfdfef2f7ee0b1bc43ad64f74de82eb7aed5


This comment serves as a signature that this PR has been triaged. Future runs will skip this PR based on the headRefOid.

@leaanthony leaanthony enabled auto-merge (squash) May 1, 2026 23:13
@leaanthony leaanthony merged commit 5c37df4 into wailsapp:master May 1, 2026
62 checks passed
@leaanthony

Copy link
Copy Markdown
Member

Thanks for taking the time on this 🙏

@leaanthony

Copy link
Copy Markdown
Member

Tested on Windows — verdict: ✓ pass

  • Commit: 5c37df447d6bd22ffd8ea6d4ced3dc705db42e06
  • OS: Windows 11 Pro Build 26100 · Go 1.26.2 · WebView2 147.0.3912.86
  • Build: 27,611 ms — successful
  • Unit tests (pkg/w32): 3/3 passing
  • Visual smoke: systray menu-only app builds and launches cleanly (no panic, clean exit)

pkg/w32 test detail:

=== RUN   TestDestroyMenuReturnsTrueForValidHandle
--- PASS: TestDestroyMenuReturnsTrueForValidHandle (0.00s)
=== RUN   TestDestroyMenuReturnsFalseForZeroHandle
--- PASS: TestDestroyMenuReturnsFalseForZeroHandle (0.00s)
=== RUN   TestMenuHandleLifecycleDoesNotLeak
    menu_windows_test.go:71: USER object delta over 2000 iterations: start=0 end=0 delta=0
--- PASS: TestMenuHandleLifecycleDoesNotLeak (0.01s)
PASS
ok  github.com/wailsapp/wails/v3/pkg/w32  1.929s

The handle-leak test creates and destroys 2000 menu objects and confirms a USER handle delta of exactly 0, directly validating the DestroyMenu fix.

Pre-existing failures (unrelated to this PR):

  • internal/generator TestGenerator: JS/TS codegen snapshot mismatch for generic type aliases
  • pkg/application/internal/tests/services/* (4 packages): GetCursorPos failed — headless VM limitation

@johnmaguire

Copy link
Copy Markdown
Contributor Author

@leaanthony Absolutely, thanks for all your work on Wails. 🙇

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.

[v3] SystemTray updates can fatal the process

2 participants