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(); + }); }); });