diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts index dad1f19afc..f43cdae495 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts @@ -30,6 +30,7 @@ import { err, ok, type Result } from '@emdash/shared'; import type { IDisposable } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; +import { isRecoverableSshTransportError } from '@main/core/ssh/transport-errors'; import { GIT_EXECUTABLE } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { @@ -56,6 +57,11 @@ const STATUS_FINGERPRINT_TIMEOUT_MS: Record = { normal: 10_000, }; +function emptyStdoutUnlessRecoverable(error: unknown): { stdout: string } { + if (isRecoverableSshTransportError(error)) throw error; + return { stdout: '' }; +} + function classifyFetchError(stderr: string): FetchError { if ( stderr.includes('Authentication failed') || @@ -204,10 +210,8 @@ export class GitService implements IDisposable { const parser = new StatusParser(); const [, stagedRes, unstagedRes, head] = await Promise.all([ this._runStatusZ(parser), - this.ctx.exec('git', ['diff', '--numstat', '--cached']).catch(() => ({ - stdout: '', - })), - this.ctx.exec('git', ['diff', '--numstat']).catch(() => ({ stdout: '' })), + this.ctx.exec('git', ['diff', '--numstat', '--cached']).catch(emptyStdoutUnlessRecoverable), + this.ctx.exec('git', ['diff', '--numstat']).catch(emptyStdoutUnlessRecoverable), this._getHeadInfo(), ]); @@ -221,6 +225,7 @@ export class GitService implements IDisposable { return await this._buildFullGitStatus(parser.status, stagedNumstat, unstagedNumstat, head); } catch (e) { if (e instanceof TooManyFilesChangedError) throw e; + if (isRecoverableSshTransportError(e)) throw e; return { staged: [], unstaged: [], @@ -997,7 +1002,8 @@ export class GitService implements IDisposable { try { const { stdout } = await this.ctx.exec('git', ['rev-parse', '--symbolic-full-name', 'HEAD']); ref = stdout.trim(); - } catch { + } catch (error) { + if (isRecoverableSshTransportError(error)) throw error; return this._getUnbornHeadInfo(); } @@ -1024,7 +1030,8 @@ export class GitService implements IDisposable { if (ref.startsWith('heads/')) return { kind: 'branch', name: ref.slice('heads/'.length), oid }; return { kind: 'branch', name: ref, oid }; - } catch { + } catch (error) { + if (isRecoverableSshTransportError(error)) throw error; return this._getUnbornHeadInfo(); } } @@ -1033,7 +1040,8 @@ export class GitService implements IDisposable { try { const { stdout: symOut } = await this.ctx.exec('git', ['symbolic-ref', '--short', 'HEAD']); return { kind: 'unborn', name: symOut.trim() }; - } catch { + } catch (error) { + if (isRecoverableSshTransportError(error)) throw error; return { kind: 'unborn', name: 'main' }; } } @@ -1162,7 +1170,8 @@ export class GitService implements IDisposable { } } return remotes; - } catch { + } catch (error) { + if (isRecoverableSshTransportError(error)) throw error; return []; } } diff --git a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts index 4459f993e9..d38621a959 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import type { GitPathInspection } from '@emdash/core/git'; import { err, ok } from '@emdash/shared'; import { sql } from 'drizzle-orm'; import { projectEvents } from '@main/core/projects/project-events'; @@ -101,7 +102,7 @@ export async function getLocalProjectPathStatus(path: string): Promise ({ spawn: vi.fn(), })); +vi.mock('@main/lib/logger', () => ({ + log: { + error: vi.fn(), + info: vi.fn(), + }, +})); + describe('LocalPtySession', () => { type MockPtyProcess = ConstructorParameters[1]; @@ -63,6 +71,26 @@ describe('LocalPtySession', () => { expect(mockProc.kill).toHaveBeenCalledTimes(1); }); + it('catches errors thrown by onExit handlers', () => { + let exitHandler: ((info: { exitCode: number; signal: number }) => void) | undefined; + vi.mocked(mockProc.onExit).mockImplementation((handler) => { + exitHandler = handler; + return { dispose: vi.fn() }; + }); + + pty.onExit(() => { + throw new Error('cleanup failed'); + }); + + expect(() => exitHandler?.({ exitCode: 1, signal: 15 })).not.toThrow(); + expect(log.error).toHaveBeenCalledWith('LocalPtySession:onExit handler failed', { + id: 'test-id', + exitCode: 1, + signal: 'SIGTERM', + error: 'cleanup failed', + }); + }); + it('kill() does not use POSIX termination on Windows', () => { setPlatform('win32'); diff --git a/apps/emdash-desktop/src/main/core/pty/local-pty.ts b/apps/emdash-desktop/src/main/core/pty/local-pty.ts index be4cf138e1..d1d81aa3db 100644 --- a/apps/emdash-desktop/src/main/core/pty/local-pty.ts +++ b/apps/emdash-desktop/src/main/core/pty/local-pty.ts @@ -103,8 +103,18 @@ export class LocalPtySession implements Pty { onExit(handler: (info: PtyExitInfo) => void): void { this.proc.onExit(({ exitCode, signal }) => { - this.posixTerminator.markExited(); - handler({ exitCode, signal: normalizeSignal(signal) }); + try { + this.posixTerminator.markExited(); + handler({ exitCode, signal: normalizeSignal(signal) }); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + log.error('LocalPtySession:onExit handler failed', { + id: this.id, + exitCode, + signal: normalizeSignal(signal), + error: message, + }); + } }); } diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts new file mode 100644 index 0000000000..4c52470f39 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts @@ -0,0 +1,133 @@ +import type { LocalBranch } from '@emdash/core/git'; +import { describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { FileSystemProvider } from '@main/core/fs/types'; +import { GitService } from '@main/core/git/legacy/git-service'; +import { log } from '@main/lib/logger'; +import { LegacySshGitRepository, LegacySshGitWorktree } from './ssh-git'; + +describe('GitService SSH refresh failures', () => { + function createRejectingGitService(error: Error): GitService { + const ctx = { + root: '/repo/worktree', + supportsLocalSpawn: false, + exec: vi.fn(async () => { + throw error; + }), + execStreaming: vi.fn(async () => { + throw error; + }), + dispose: vi.fn(), + } as unknown as IExecutionContext; + const fs = {} as FileSystemProvider; + return new GitService(ctx, fs); + } + + it('propagates recoverable SSH failures from status fallback paths', async () => { + const git = createRejectingGitService(new Error('SSH connection is not available')); + + await expect(git.getFullStatus()).rejects.toThrow('SSH connection is not available'); + }); + + it('propagates recoverable SSH failures from head fallback paths', async () => { + const git = createRejectingGitService( + Object.assign(new Error('read ETIMEDOUT'), { code: 'ETIMEDOUT' }) + ); + + await expect(git.getHeadInfo()).rejects.toThrow('read ETIMEDOUT'); + }); +}); + +describe('LegacySshGitRepository', () => { + it('treats unavailable SSH connections during background refs refresh as recoverable', async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const firstRefs: LocalBranch[] = [{ type: 'local', branch: 'main', oid: 'abc123' }]; + const getBranches = vi + .fn<() => Promise>() + .mockResolvedValueOnce(firstRefs) + .mockRejectedValueOnce(new Error('SSH connection is not available')); + const git = { + getBranches, + getRemotes: vi.fn(async () => []), + dispose: vi.fn(), + } as unknown as GitService; + const repository = new LegacySshGitRepository(git, '/repo/.git'); + + try { + await expect(repository.getRefs()).resolves.toEqual({ branches: firstRefs }); + await expect(repository.getRemotes()).resolves.toEqual({ remotes: [] }); + const updates: unknown[] = []; + const unsubscribe = repository.subscribe((update) => updates.push(update)); + + await vi.advanceTimersByTimeAsync(15_001); + + expect(getBranches).toHaveBeenCalledTimes(2); + expect(updates).toEqual([]); + + unsubscribe(); + } finally { + repository.dispose(); + warn.mockRestore(); + vi.useRealTimers(); + } + }); + + it('treats unavailable SSH connections during background status refresh as recoverable', async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const getFullStatus = vi + .fn<() => Promise>>>() + .mockResolvedValueOnce({ + staged: [], + unstaged: [], + currentBranch: 'main', + headKind: 'branch', + shortHash: 'abc123', + totalAdded: 0, + totalDeleted: 0, + }) + .mockRejectedValueOnce(new Error('SSH connection is not available')); + const git = { + getFullStatus, + getHeadInfo: vi.fn(async () => ({ kind: 'branch', name: 'main', oid: 'abc123' })), + getStatusFingerprint: vi + .fn<() => Promise<{ hash: string }>>() + .mockResolvedValueOnce({ hash: 'h1' }) + .mockResolvedValueOnce({ hash: 'h2' }), + dispose: vi.fn(), + } as unknown as GitService; + const worktree = new LegacySshGitWorktree(git, '/repo/worktree', {} as LegacySshGitRepository); + + try { + await expect(worktree.getStatus()).resolves.toEqual({ + kind: 'ok', + staged: [], + unstaged: [], + stagedAdded: 0, + stagedDeleted: 0, + }); + await expect(worktree.getHead()).resolves.toEqual({ + kind: 'branch', + name: 'main', + oid: 'abc123', + }); + const updates: unknown[] = []; + const unsubscribe = worktree.subscribe((update) => updates.push(update)); + + await vi.advanceTimersByTimeAsync(20_001); + + expect(getFullStatus).toHaveBeenCalledTimes(2); + expect(updates).toEqual([]); + expect(warn).toHaveBeenCalledWith('LegacySshGitWorktree: status refresh failed', { + error: expect.any(Error), + }); + + unsubscribe(); + } finally { + worktree.dispose(); + warn.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts index 1ed796a54a..05f51d4d13 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts @@ -51,6 +51,7 @@ import { SshExecutionContext } from '@main/core/execution-context/ssh-execution- import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { GitService } from '@main/core/git/legacy/git-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { isRecoverableSshTransportError } from '@main/core/ssh/transport-errors'; import { log } from '@main/lib/logger'; import type { ImageReadResult as LegacyImageReadResult } from '@shared/core/git/types'; @@ -228,7 +229,7 @@ export class LegacySshGitRuntime implements IGitRuntime { } } -class LegacySshGitRepository implements IGitRepository { +export class LegacySshGitRepository implements IGitRepository { readonly gitCommonDir: string; readonly objectStoreDir: string; @@ -243,11 +244,11 @@ class LegacySshGitRepository implements IGitRepository { this.gitCommonDir = gitCommonDir; this.objectStoreDir = `${gitCommonDir}/objects`; this.refsModel = new LiveModel({ - compute: async () => ok(await this.computeRefs()), + compute: () => this.computeRefs(), onError: (error) => log.warn('LegacySshGitRepository: refs refresh failed', { error }), }); this.remotesModel = new LiveModel({ - compute: async () => ok(await this.computeRemotes()), + compute: () => this.computeRemotes(), onError: (error) => log.warn('LegacySshGitRepository: remotes refresh failed', { error }), }); this.timers = [ @@ -392,16 +393,26 @@ class LegacySshGitRepository implements IGitRepository { return (await this.refsModel.refresh()).sequence; } - private async computeRefs(): Promise { - return { branches: await this.git.getBranches() }; + private async computeRefs(): Promise> { + try { + return ok({ branches: await this.git.getBranches() }); + } catch (error) { + if (isRecoverableSshTransportError(error)) return err(error); + throw error; + } } - private async computeRemotes(): Promise { - return { remotes: (await this.git.getRemotes()) as GitRemote[] }; + private async computeRemotes(): Promise> { + try { + return ok({ remotes: (await this.git.getRemotes()) as GitRemote[] }); + } catch (error) { + if (isRecoverableSshTransportError(error)) return err(error); + throw error; + } } } -class LegacySshGitWorktree implements IGitWorktree { +export class LegacySshGitWorktree implements IGitWorktree { readonly worktree: string; readonly repository: LegacySshGitRepository; @@ -418,11 +429,11 @@ class LegacySshGitWorktree implements IGitWorktree { this.worktree = worktreePath; this.repository = repository; this.statusModel = new LiveModel({ - compute: async () => ok(await this.computeStatus()), + compute: () => this.computeStatus(), onError: (error) => log.warn('LegacySshGitWorktree: status refresh failed', { error }), }); this.headModel = new LiveModel({ - compute: async () => ok(await this.computeHead()), + compute: () => this.computeHead(), onError: (error) => log.warn('LegacySshGitWorktree: head refresh failed', { error }), }); this.timers = [ @@ -597,26 +608,30 @@ class LegacySshGitWorktree implements IGitWorktree { this.git.dispose(); } - private async computeStatus(): Promise { + private async computeStatus(): Promise> { try { const status = await this.git.getFullStatus(); - return { + return ok({ kind: 'ok', staged: status.staged, unstaged: status.unstaged, stagedAdded: status.totalAdded, stagedDeleted: status.totalDeleted, - }; + }); } catch (error) { - if (error instanceof TooManyFilesChangedError) return { kind: 'too-many-files' }; - // Transient failures (e.g. dropped SSH connection) must not masquerade as a - // status; rethrowing keeps the last-good value and leaves the model dirty. + if (error instanceof TooManyFilesChangedError) return ok({ kind: 'too-many-files' }); + if (isRecoverableSshTransportError(error)) return err(error); throw error; } } - private async computeHead(): Promise { - return this.git.getHeadInfo(); + private async computeHead(): Promise> { + try { + return ok(await this.git.getHeadInfo()); + } catch (error) { + if (isRecoverableSshTransportError(error)) return err(error); + throw error; + } } private async refreshStatus(): Promise { diff --git a/apps/emdash-desktop/src/main/core/ssh/transport-errors.ts b/apps/emdash-desktop/src/main/core/ssh/transport-errors.ts new file mode 100644 index 0000000000..9e9f93e53e --- /dev/null +++ b/apps/emdash-desktop/src/main/core/ssh/transport-errors.ts @@ -0,0 +1,21 @@ +const RECOVERABLE_SSH_TRANSPORT_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ENOTCONN', + 'EPIPE', + 'ETIMEDOUT', +]); + +export function isRecoverableSshTransportError(error: unknown): boolean { + const code = + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : null; + if (typeof code === 'string' && RECOVERABLE_SSH_TRANSPORT_ERROR_CODES.has(code)) return true; + + const message = error instanceof Error ? error.message : String(error); + return /SSH connection is not available|read ETIMEDOUT|timed out|connection (?:reset|refused|closed)|not connected|socket hang up/i.test( + message + ); +}