From 04aca1c8cb64af93b7f86bc82899306a45495186 Mon Sep 17 00:00:00 2001 From: Waaiez Kinnear <43832467+Waaiez@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:57:29 +0200 Subject: [PATCH] fix: add SIGTERM fallback for Linux Claude usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, the ESC key doesn't exit the Claude CLI, causing a 30s timeout. This fix: 1. Adds SIGTERM fallback 2s after ESC fails 2. Returns captured data on timeout instead of failing Tested: ~19s on Linux instead of 30s timeout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/services/claude-usage-service.ts | 14 +++- .../services/claude-usage-service.test.ts | 68 ++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index d8c7d083..098ce29c 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -179,7 +179,12 @@ export class ClaudeUsageService { if (!settled) { settled = true; ptyProcess.kill(); - reject(new Error('Command timed out')); + // Don't fail if we have data - return it instead + if (output.includes('Current session')) { + resolve(output); + } else { + reject(new Error('Command timed out')); + } } }, this.timeout); @@ -193,6 +198,13 @@ export class ClaudeUsageService { setTimeout(() => { if (!settled) { ptyProcess.write('\x1b'); // Send escape key + + // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s + setTimeout(() => { + if (!settled) { + ptyProcess.kill('SIGTERM'); + } + }, 2000); } }, 2000); } diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 983e5806..d16802f6 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -485,7 +485,7 @@ Resets in 2h await expect(promise).rejects.toThrow('Authentication required'); }); - it('should handle timeout', async () => { + it('should handle timeout with no data', async () => { vi.useFakeTimers(); mockSpawnProcess.stdout = { @@ -619,7 +619,7 @@ Resets in 2h await expect(promise).rejects.toThrow('Authentication required'); }); - it('should handle timeout on Windows', async () => { + it('should handle timeout with no data on Windows', async () => { vi.useFakeTimers(); const windowsService = new ClaudeUsageService(); @@ -640,5 +640,69 @@ Resets in 2h vi.useRealTimers(); }); + + it('should return data on timeout if data was captured', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + // Simulate receiving usage data + dataCallback!('Current session\n65% left\nResets in 2h'); + + // Advance time past timeout (30 seconds) + vi.advanceTimersByTime(31000); + + // Should resolve with data instead of rejecting + const result = await promise; + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should send SIGTERM after ESC if process does not exit', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + windowsService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!('Current session\n65% left'); + + // Advance 2s to trigger ESC + vi.advanceTimersByTime(2100); + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + // Advance another 2s to trigger SIGTERM fallback + vi.advanceTimersByTime(2100); + expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM'); + + vi.useRealTimers(); + }); }); });