From 21fef1620d78f07af01a75b8bbbeeb15798e73ef Mon Sep 17 00:00:00 2001
From: Jacob Richman <jacob314@gmail.com>
Date: Fri, 25 Jul 2025 17:35:26 -0700
Subject: Handle unhandled rejections more gracefully. (#4417)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
---
 packages/cli/src/gemini.test.tsx | 66 ++++++++++++++++++++++++++++++++++++++--
 1 file changed, 64 insertions(+), 2 deletions(-)

(limited to 'packages/cli/src/gemini.test.tsx')

diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 20350815..505841c7 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -6,12 +6,13 @@
 
 import stripAnsi from 'strip-ansi';
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { main } from './gemini.js';
+import { main, setupUnhandledRejectionHandler } from './gemini.js';
 import {
   LoadedSettings,
   SettingsFile,
   loadSettings,
 } from './config/settings.js';
+import { appEvents, AppEvent } from './utils/events.js';
 
 // Custom error to identify mock process.exit calls
 class MockProcessExitError extends Error {
@@ -55,6 +56,16 @@ vi.mock('update-notifier', () => ({
   })),
 }));
 
+vi.mock('./utils/events.js', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('./utils/events.js')>();
+  return {
+    ...actual,
+    appEvents: {
+      emit: vi.fn(),
+    },
+  };
+});
+
 vi.mock('./utils/sandbox.js', () => ({
   sandbox_command: vi.fn(() => ''), // Default to no sandbox command
   start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
@@ -65,6 +76,8 @@ describe('gemini.tsx main function', () => {
   let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
   let originalEnvGeminiSandbox: string | undefined;
   let originalEnvSandbox: string | undefined;
+  let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
+    [];
 
   const processExitSpy = vi
     .spyOn(process, 'exit')
@@ -82,6 +95,8 @@ describe('gemini.tsx main function', () => {
     delete process.env.SANDBOX;
 
     consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+    initialUnhandledRejectionListeners =
+      process.listeners('unhandledRejection');
   });
 
   afterEach(() => {
@@ -96,6 +111,15 @@ describe('gemini.tsx main function', () => {
     } else {
       delete process.env.SANDBOX;
     }
+
+    const currentListeners = process.listeners('unhandledRejection');
+    const addedListener = currentListeners.find(
+      (listener) => !initialUnhandledRejectionListeners.includes(listener),
+    );
+
+    if (addedListener) {
+      process.removeListener('unhandledRejection', addedListener);
+    }
     vi.restoreAllMocks();
   });
 
@@ -145,7 +169,45 @@ describe('gemini.tsx main function', () => {
       'Please fix /test/settings.json and try again.',
     );
 
-    // Verify process.exit was called (indirectly, via the thrown error)
+    // Verify process.exit was called.
     expect(processExitSpy).toHaveBeenCalledWith(1);
   });
+
+  it('should log unhandled promise rejections and open debug console on first error', async () => {
+    const appEventsMock = vi.mocked(appEvents);
+    const rejectionError = new Error('Test unhandled rejection');
+
+    setupUnhandledRejectionHandler();
+    // Simulate an unhandled rejection.
+    // We are not using Promise.reject here as vitest will catch it.
+    // Instead we will dispatch the event manually.
+    process.emit('unhandledRejection', rejectionError, Promise.resolve());
+
+    // We need to wait for the rejection handler to be called.
+    await new Promise(process.nextTick);
+
+    expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
+    expect(appEventsMock.emit).toHaveBeenCalledWith(
+      AppEvent.LogError,
+      expect.stringContaining('Unhandled Promise Rejection'),
+    );
+    expect(appEventsMock.emit).toHaveBeenCalledWith(
+      AppEvent.LogError,
+      expect.stringContaining('Please file a bug report using the /bug tool.'),
+    );
+
+    // Simulate a second rejection
+    const secondRejectionError = new Error('Second test unhandled rejection');
+    process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
+    await new Promise(process.nextTick);
+
+    // Ensure emit was only called once for OpenDebugConsole
+    const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
+      (call) => call[0] === AppEvent.OpenDebugConsole,
+    );
+    expect(openDebugConsoleCalls.length).toBe(1);
+
+    // Avoid the process.exit error from being thrown.
+    processExitSpy.mockRestore();
+  });
 });
-- 
cgit v1.2.3

