Skip to content

fix: Ensure non-destroyed window exists for authentication event handler - #86

Merged
dcalhoun merged 12 commits into
trunkfrom
fix/ensure-non-destroyed-window-exists-for-authentication-event-handler
May 7, 2024
Merged

fix: Ensure non-destroyed window exists for authentication event handler#86
dcalhoun merged 12 commits into
trunkfrom
fix/ensure-non-destroyed-window-exists-for-authentication-event-handler

Conversation

@dcalhoun

@dcalhoun dcalhoun commented May 3, 2024

Copy link
Copy Markdown
Member

Fixes https://github.com/Automattic/dotcom-forge/issues/6906.
Fixes #14.

Proposed Changes

Replace a cached main window reference with a more robust implementation of the
pre-existing withMainWindow utility used by the menu creation logic. The new
approach ensures a non-destroyed window is available whenever an event handler
attempts to reference it by accessing or creating the window within the event
handler, rather than using a main window reference that was passed when the
event handler was registered.

Testing Instructions

1. Authentication flow succeeds after closing and re-opening Studio's window

  1. Launch Studio.
  2. Close the Studio window.
  3. Open a new Studio window (e.g., click the dock icon).
  4. Complete the WPCOM authentication flow, allowing the browser to open the Studio app.
  5. Verify the Studio app becomes focused and the correct authenticated state is displayed.

2. Authentication flow succeeds after quitting Studio

Important

This test case requires uninstalling all other Studio app installs before testing and using a build from npm run make.

  1. Launch Studio.
  2. Begin the WPCOM authentication flow, then quit the Studio app before continuing.
  3. Complete the WPCOM authentication flow, allowing the browser to open the Studio app.
  4. Verify the Studio app launches, becomes focused, and the correct authenticated state is displayed.

3. Studio menu items have not regressed

Verify the various menu items — Add Site..., Settings..., Close Window, etc — continue to function as expected both with and without an active Studio window open.

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
@dcalhoun dcalhoun self-assigned this May 3, 2024
@dcalhoun
dcalhoun force-pushed the fix/ensure-non-destroyed-window-exists-for-authentication-event-handler branch 4 times, most recently from 988b450 to 44078d9 Compare May 5, 2024 12:03
dcalhoun added 11 commits May 6, 2024 07:06
Providing the cached main window reference at the time of event listener
creation could result in the callback attempting to emit events on a
window that had since been destroyed.
Currying is unnecessary as (1) we have no use for it currently and (2)
this higher-order function will most likely always want to be invoked
immediately to ensure the window reference provided is current and
available.
This same logic is run in an event handler for the window `close` event,
which makes this invocation unnecessary.
Registering the `open-url` event handler _before_ the app `ready` event,
which is explicitly directed in the Electron, results in the URL handler
correctly manages authentication on app start.

https://www.electronjs.org/docs/latest/api/app#event-open-url-macos
The final scenario of `did-finish-load` did not return the actual
window, so never returning the window is more consistent.
Use "set up" verb rather than "setup" noun.
There appears to be no reason to await the app `ready` event.
Intended to await the mock promise, not the mock function.
@dcalhoun
dcalhoun force-pushed the fix/ensure-non-destroyed-window-exists-for-authentication-event-handler branch from f00af0a to 3361577 Compare May 6, 2024 11:06
The CI server does not leverage the `darwin` platform, so we must mock
the platform to verify the deep link handler.
@dcalhoun
dcalhoun force-pushed the fix/ensure-non-destroyed-window-exists-for-authentication-event-handler branch from 31af4e0 to 13db766 Compare May 6, 2024 12:48
Comment thread jest.config.ts
Comment on lines +15 to +17
MAIN_WINDOW_WEBPACK_ENTRY: 'main-window-webpack-entry',
MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: 'main-window-preload-webpack-entry',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These additions mirror globals assigned by electron-forge's webpack build configuration.

Comment thread src/index.ts

Menu.setApplicationMenu( null );

setupCustomProtocolHandler();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was relocated out of the app ready event callback as it registers an event handler for the open-url event, which Electron's documentation explicitly directs not to register after the ready event.

Comment thread src/index.ts

mainWindow = createMainWindow();
setupAuthCallbackHandler( mainWindow );
handleAuthOnStartup();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This manual authentication handling become unnecessary now that the open-url event handler is registered before the app ready event.

Comment thread src/main-window.ts
Comment on lines +48 to +50
if ( mainWindow && ! mainWindow.isDestroyed() ) {
return mainWindow;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This guard prevents reassigning mainWindow when it is already populated with a non-destroyed window to avoid creating lingering windows. If that is confusing for a method named createMainWindow, we might instead destroy the existing window.

Comment thread src/main-window.ts
Comment on lines +143 to +149
/**
* Reset the main window reference. Exported for testing as resetting modules
* with Jest while preserving manual Electron mocks proved quite difficult.
*/
export function __resetMainWindow() {
mainWindow = null;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This internal, testing-only function is less than ideal, but I tried a few different approaches (e.g, jest.doMock, jest.resetModules, jest.isolatedModules) all of which resulted in a shortcoming  — mocking too late or breaking other manual package mocks.

Comment thread src/index.ts

setupCustomProtocolHandler();

setUpAuthCallbackHandler();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was relocated out of the app ready event callback as there is no longer a reason to await that event now that we do not reference mainWindow until within the event callback.

Comment thread src/lib/tests/oauth.test.ts
Comment thread src/tests/index.test.ts
( fs as MockedFs ).__setFileContents( '/path/to/app/temp/com.wordpress.studio/', '' );

it( 'should boot successfully', () => {
jest.isolateModules( () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The entry module includes side effects, so we must leverage isolateModules to reset/clear those side effects for each test case.

@dcalhoun
dcalhoun marked this pull request as ready for review May 6, 2024 12:55
@dcalhoun
dcalhoun requested a review from a team May 6, 2024 12:56

@wojtekn wojtekn 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.

All cases work correctly. I didn't spot any regression issues.

@dcalhoun
dcalhoun merged commit a342b0c into trunk May 7, 2024
@dcalhoun
dcalhoun deleted the fix/ensure-non-destroyed-window-exists-for-authentication-event-handler branch May 7, 2024 11:51
@sentry

sentry Bot commented May 14, 2024

Copy link
Copy Markdown

Suspect Issues

This pull request was deployed and Sentry observed the following issues:

  • ‼️ Error: Cannot create BrowserWindow before app is ready createMainWindow(main/studio/./src/main-window) View Issue

Did you find this useful? React with a 👍 or 👎

fredrikekelund pushed a commit that referenced this pull request May 29, 2026
## Related issues

- Fixes
[STU-1750](https://linear.app/a8c/issue/STU-1750/studio-reopens-multiple-times-when-quitting-during-site-startup)

## How AI was used in this PR

Claude Code investigated the bug, identified the root cause
(create-if-missing branch in `getMainWindow()` racing with async IPC
fan-out during the `will-quit` shutdown window), proposed the minimal
gate at `sendIpcEventToRenderer`, and applied the change. I reviewed the
diff and confirmed both the fix and its placement before committing.

## Proposed Changes

- Add an `isAppQuitting` flag in `apps/studio/src/ipc-utils.ts` with an
exported `markAppQuitting()` setter. While set, `sendIpcEventToRenderer`
returns early without calling `getMainWindow()`.
- Call `markAppQuitting()` at the start of the `will-quit` handler in
`apps/studio/src/index.ts`, before any other cleanup.

### Why this works

When the user confirms "Stop sites" on quit, `will-quit` calls
`event.preventDefault()` and waits up to 6 s for `stopAllServers()` to
drain. During that window, in-flight async callbacks (site-startup
events, snapshot events, sync progress, etc.) keep firing through
`sendIpcEventToRenderer()`. Each call routes through `getMainWindow()` —
and because the original window has typically already been closed by
Electron's shutdown sequence, `getMainWindow()` falls through to
`createMainWindow()` and spawns a fresh `BrowserWindow`. One pending
event = one ghost window. With ~6 sites starting simultaneously on a
Linux VM, you reliably see ~6 windows reopen.

Blocking the fan-out at the `sendIpcEventToRenderer` chokepoint is the
smallest fix: it catches every async-IPC path without changing
`getMainWindow`'s signature or touching its 15+ other call sites (menus,
deeplinks, update dialog, etc.), which are user-triggered and can't fire
post-`will-quit` anyway.

`will-quit` (not `before-quit`) is the right gate point: `before-quit`
can still be cancelled by the sync-in-progress dialog or the "Cancel"
button on the running-sites dialog. Once `will-quit` fires, the app is
committed.

### Regression origin

The mechanic was introduced in #86 (May 2024) for OAuth deep-link
handling. It became today's user-visible bug in #849 (Jan 2025), which
routed `sendIpcEventToRenderer` through `getMainWindow()`. The bug got
more reproducible recently due to Linux VM testing (slower shutdown
widens the race window) plus PR #3350 removing an accidental escape
hatch that bypassed the quit dialog when the CLI wasn't installed.

## Testing Instructions

Reproduce on `trunk` first, then verify the fix on this branch.

**Repro on trunk (Linux/Windows easier; macOS rarer):**

1. Have several Studio sites created (~6 makes it obvious).
2. Quit Studio so all sites stop.
3. Re-launch Studio.
4. Start all sites at once.
5. **Before** they finish booting, quit Studio.
6. Observe: Studio reopens 4–6 times in quick succession.

**Verify on this branch:**

1. Repeat steps 1–5 above.
2. Studio should quit cleanly with no ghost windows.

## Pre-merge Checklist

- [x] Have you checked for TypeScript, React or other console errors?
- [x] Manual repro on Linux (with several sites)
- [x] Manual repro on Windows (with several sites)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants