Skip to content

fix: ensure WebSocket notifications are sent before application quits#4381

Open
cjuriartec wants to merge 2 commits intopear-devs:masterfrom
cjuriartec:fix/reliable-ws-shutdown
Open

fix: ensure WebSocket notifications are sent before application quits#4381
cjuriartec wants to merge 2 commits intopear-devs:masterfrom
cjuriartec:fix/reliable-ws-shutdown

Conversation

@cjuriartec
Copy link
Copy Markdown

@cjuriartec cjuriartec commented Mar 23, 2026

Problem

When closing the application (e.g. via Cmd+Q on macOS), the WebSocket clients wouldn't consistently receive a final "isPlaying: false" state because the application was terminating before the network buffers could be flushed.

Solution

  • Expose a wsNotifyClose function in the api-server plugin.
  • Added a before-quit event listener in the main backend process that triggers this notification.
  • Ensured structural compatibility for WebSocket context types with as unknown as WSContext<WebSocket> to prevent TypeScript errors.

Testing

Verified with Boring Notch connection. The notification is now consistently received before Pear Desktop fully exits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved graceful shutdown of WebSocket connections to properly notify connected clients when the application closes or the backend stops, preventing abrupt disconnections.

@JellyBrick JellyBrick added the bug Something isn't working label Mar 23, 2026

export const backend = createBackend<BackendType, APIServerConfig>({
async start(ctx) {
const { app } = await import('electron');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a reason you used await import?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe they were not aware that vite does tree-shaking

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

The changes add WebSocket notification functionality to gracefully inform connected clients when the backend server or Electron app is shutting down. A new wsNotifyClose callback broadcasts a PLAYER_STATE_CHANGED message to all connected WebSocket clients before server termination.

Changes

Cohort / File(s) Summary
WebSocket Close Notification
src/plugins/api-server/backend/routes/websocket.ts
Added wsNotifyClose callback function that broadcasts a PLAYER_STATE_CHANGED message with isPlaying: false to all connected WebSocket clients. Exported as a module-level variable for external invocation.
Routes Export
src/plugins/api-server/backend/routes/index.ts
Re-exported wsNotifyClose from the websocket module to make it publicly accessible to other backend modules.
Backend Lifecycle Integration
src/plugins/api-server/backend/main.ts
Imported wsNotifyClose and integrated WebSocket close notifications into the backend lifecycle: registered Electron before-quit event handler and added notification calls in backend.start and backend.end methods.

Sequence Diagram(s)

sequenceDiagram
    participant App as Electron App
    participant Backend as Backend Server
    participant WS as WebSocket Handler
    participant Client as Connected Clients
    
    App->>Backend: trigger before-quit event
    Backend->>WS: call wsNotifyClose()
    WS->>WS: prepare PLAYER_STATE_CHANGED message
    WS->>Client: broadcast isPlaying: false, position: lastElapsed
    Client->>Client: receive shutdown notification
    Client->>Client: disconnect gracefully
    Backend->>Backend: close server connection
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A whisper through the WebSocket threads,
As apps and servers lay down their heads,
"Farewell," they say to clients near,
With graceful close, no sudden fear,
The player pauses, all is well.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding WebSocket notifications before app quit.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 `@src/plugins/api-server/backend/routes/websocket.ts`:
- Around line 63-69: wsNotifyClose currently fires the PLAYER_STATE_CHANGED send
synchronously and returns nothing; change wsNotifyClose to return a Promise that
resolves when send has flushed to all clients (or on a short timeout) by
tracking per-socket send completion (use the same sockets collection and the
send function that sends DataTypes.PlayerStateChanged with { isPlaying:false,
position: lastSongInfo?.elapsedSeconds ?? 0 }) and resolving when all sends
complete; then update the main.ts before-quit handler to call
event.preventDefault(), await wsNotifyClose(), and only then call app.quit() so
shutdown waits for the notification to be sent.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab073512-da5d-4d2e-b2e0-7c2273b770e8

📥 Commits

Reviewing files that changed from the base of the PR and between f2e6126 and 18c7f32.

📒 Files selected for processing (3)
  • src/plugins/api-server/backend/main.ts
  • src/plugins/api-server/backend/routes/index.ts
  • src/plugins/api-server/backend/routes/websocket.ts

Comment on lines +63 to +69
wsNotifyClose = () => {
console.log(`[API Server WebSocket] NotifyClose called. Sending isPlaying:false to ${sockets.size} clients.`);
send(DataTypes.PlayerStateChanged, {
isPlaying: false,
position: lastSongInfo?.elapsedSeconds ?? 0,
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Electron, does the before-quit event wait for asynchronous work to finish, and what is the recommended pattern for delaying quit until a WebSocket notification has been flushed (or timed out)?

💡 Result:

No, the before-quit event does not wait for asynchronous work to finish. The event handler is treated as synchronous; any returned Promise is ignored, and Electron proceeds with quitting regardless. The recommended pattern for delaying quit until a WebSocket notification is flushed (or timed out) is to use a state flag in the main process: 1. Listen for the before-quit event. 2. If cleanup (WebSocket flush) is not complete, call event.preventDefault to cancel the quit. 3. Perform the async WebSocket operation (e.g., send notification and await acknowledgment or use a timeout). 4. Set the flag to indicate completion. 5. Call app.quit manually, which will trigger before-quit again but skip prevention due to the flag. Example code: let hasFlushed = false; async function flushWebSocket { // Send notification over WebSocket // Await ack or use Promise.race with timeout, e.g.: return Promise.race([ websocket.sendAndAwaitAck('notification'), new Promise(resolve => setTimeout(resolve, 5000)) // 5s timeout ]); } app.on('before-quit', (event) => { if (!hasFlushed) { event.preventDefault; flushWebSocket.then( => { hasFlushed = true; app.quit; }).catch( => { hasFlushed = true; app.quit; // Timeout or error: still quit }); } }); This pattern is consistently recommended across sources and works for network operations like WebSocket flushes. Use IPC if the WebSocket is in a renderer process.

Citations:


🏁 Script executed:

# Find the websocket.ts file
fd -type f -name "websocket.ts"

Repository: pear-devs/pear-desktop

Length of output: 236


🏁 Script executed:

# Also check the main.ts file that was mentioned
fd -type f -name "main.ts" | grep -i "api-server\|backend"

Repository: pear-devs/pear-desktop

Length of output: 236


🏁 Script executed:

# Find the websocket.ts file with correct syntax
fd -t f "websocket.ts"

Repository: pear-devs/pear-desktop

Length of output: 116


🏁 Script executed:

# Find main.ts files in the codebase
fd -t f "main.ts" | head -20

Repository: pear-devs/pear-desktop

Length of output: 316


🏁 Script executed:

# Read the websocket.ts file to see the implementation
cat -n "src/plugins/api-server/backend/routes/websocket.ts" | head -180

Repository: pear-devs/pear-desktop

Length of output: 5877


🏁 Script executed:

# Read the main.ts file for the api-server to see how wsNotifyClose is used
cat -n "src/plugins/api-server/backend/main.ts"

Repository: pear-devs/pear-desktop

Length of output: 7381


Return a Promise from wsNotifyClose so the caller can delay shutdown until the notification is flushed.

wsNotifyClose is synchronous and fire-and-forget. Electron's before-quit event ignores Promises from handlers, but the handler can use event.preventDefault() to cancel quit, await async work, then call app.quit() manually. Without a Promise return value, there's no way for the caller to hold process teardown until the WebSocket notification has been written, so this PR still risks missing the final PLAYER_STATE_CHANGED message under the same race condition.

Make wsNotifyClose return a Promise that resolves when the notification has been sent to all clients (or after a reasonable timeout), then update the before-quit handler in main.ts to prevent default, await completion, and manually call app.quit().

Affects lines 63–69 and line 169.

🧰 Tools
🪛 ESLint

[error] 64-64: Replace [API·Server·WebSocket]·NotifyClose·called.·Sending·isPlaying:false·to·${sockets.size}·clients. with ⏎······[API·Server·WebSocket]·NotifyClose·called.·Sending·isPlaying:false·to·${sockets.size}·clients.,⏎····

(prettier/prettier)

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

In `@src/plugins/api-server/backend/routes/websocket.ts` around lines 63 - 69,
wsNotifyClose currently fires the PLAYER_STATE_CHANGED send synchronously and
returns nothing; change wsNotifyClose to return a Promise that resolves when
send has flushed to all clients (or on a short timeout) by tracking per-socket
send completion (use the same sockets collection and the send function that
sends DataTypes.PlayerStateChanged with { isPlaying:false, position:
lastSongInfo?.elapsedSeconds ?? 0 }) and resolving when all sends complete; then
update the main.ts before-quit handler to call event.preventDefault(), await
wsNotifyClose(), and only then call app.quit() so shutdown waits for the
notification to be sent.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants