From 297bb1f2b77af63e27db4af7ed141dcd45a85260 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:34:31 +0200 Subject: [PATCH 1/7] fix(ssh): recover refs refresh timeouts --- .../main/core/runtime/legacy/ssh-git.test.ts | 43 +++++++++++++++++ .../src/main/core/runtime/legacy/ssh-git.ts | 46 ++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts 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..ccd2a1edfd --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts @@ -0,0 +1,43 @@ +import type { LocalBranch } from '@emdash/core/git'; +import { describe, expect, it, vi } from 'vitest'; +import type { GitService } from '@main/core/git/legacy/git-service'; +import { log } from '@main/lib/logger'; +import { LegacySshGitRepository } from './ssh-git'; + +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 }); + const updates: unknown[] = []; + const unsubscribe = repository.subscribe((update) => updates.push(update)); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(getBranches).toHaveBeenCalledTimes(2); + expect(updates).toEqual([]); + expect(warn).toHaveBeenCalledWith('LegacySshGitRepository: refs refresh failed', { + error: expect.any(Error), + }); + + unsubscribe(); + } finally { + repository.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..d1a38379a8 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 @@ -60,6 +60,28 @@ const HEAD_POLL_MS = 10_000; const REFS_POLL_MS = 15_000; const REMOTES_POLL_MS = 60_000; +const RECOVERABLE_SSH_REFRESH_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ENOTCONN', + 'EPIPE', + 'ETIMEDOUT', +]); + +function isRecoverableSshRefreshError(error: unknown): boolean { + const code = + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : null; + if (typeof code === 'string' && RECOVERABLE_SSH_REFRESH_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 + ); +} + type LegacyRepositoryResource = { repository: LegacySshGitRepository; }; @@ -228,7 +250,7 @@ export class LegacySshGitRuntime implements IGitRuntime { } } -class LegacySshGitRepository implements IGitRepository { +export class LegacySshGitRepository implements IGitRepository { readonly gitCommonDir: string; readonly objectStoreDir: string; @@ -243,11 +265,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,12 +414,22 @@ 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 (isRecoverableSshRefreshError(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 (isRecoverableSshRefreshError(error)) return err(error); + throw error; + } } } From 156a15aa960ea1b8941a38c7b89831c13d9ec22c Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:37:05 +0200 Subject: [PATCH 2/7] fix(pty): catch local exit handler errors --- .../src/main/core/pty/local-pty.test.ts | 28 +++++++++++++++++++ .../src/main/core/pty/local-pty.ts | 18 +++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/pty/local-pty.test.ts b/apps/emdash-desktop/src/main/core/pty/local-pty.test.ts index a24495014f..6a2ae514ca 100644 --- a/apps/emdash-desktop/src/main/core/pty/local-pty.test.ts +++ b/apps/emdash-desktop/src/main/core/pty/local-pty.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '@main/lib/logger'; import { LocalPtySession } from './local-pty'; vi.mock('node-pty', () => ({ spawn: vi.fn(), })); +vi.mock('@main/lib/logger', () => ({ + log: { + error: vi.fn(), + info: vi.fn(), + }, +})); + describe('LocalPtySession', () => { type MockPtyProcess = ConstructorParameters[1]; @@ -80,6 +88,26 @@ describe('LocalPtySession', () => { expect(process.kill).not.toHaveBeenCalledWith(-1234, 'SIGKILL'); }); + 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 process groups 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 d8d3c174bd..1387a15fe9 100644 --- a/apps/emdash-desktop/src/main/core/pty/local-pty.ts +++ b/apps/emdash-desktop/src/main/core/pty/local-pty.ts @@ -102,11 +102,21 @@ export class LocalPtySession implements Pty { onExit(handler: (info: PtyExitInfo) => void): void { this.proc.onExit(({ exitCode, signal }) => { - if (this.killTimer) { - clearTimeout(this.killTimer); - this.killTimer = null; + try { + if (this.killTimer) { + clearTimeout(this.killTimer); + this.killTimer = null; + } + 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, + }); } - handler({ exitCode, signal: normalizeSignal(signal) }); }); } From 1f37fda75da49d3f62ebd2943b0491ade3847794 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:11:52 +0200 Subject: [PATCH 3/7] fix(ssh): keep git refresh failures recoverable --- .../main/core/runtime/legacy/ssh-git.test.ts | 103 +++++++++++++++++- .../src/main/core/runtime/legacy/ssh-git.ts | 26 +++-- 2 files changed, 117 insertions(+), 12 deletions(-) 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 index ccd2a1edfd..97afe84866 100644 --- 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 @@ -2,7 +2,7 @@ import type { LocalBranch } from '@emdash/core/git'; import { describe, expect, it, vi } from 'vitest'; import type { GitService } from '@main/core/git/legacy/git-service'; import { log } from '@main/lib/logger'; -import { LegacySshGitRepository } from './ssh-git'; +import { LegacySshGitRepository, LegacySshGitWorktree } from './ssh-git'; describe('LegacySshGitRepository', () => { it('treats unavailable SSH connections during background refs refresh as recoverable', async () => { @@ -40,4 +40,105 @@ describe('LegacySshGitRepository', () => { 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, + }); + const updates: unknown[] = []; + const unsubscribe = worktree.subscribe((update) => updates.push(update)); + + await vi.advanceTimersByTimeAsync(20_000); + + 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(); + } + }); + + it('treats unavailable SSH connections during background head refresh as recoverable', async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const getHeadInfo = vi + .fn<() => Promise>>>() + .mockResolvedValueOnce({ kind: 'branch', name: 'main', oid: 'abc123' }) + .mockRejectedValueOnce(Object.assign(new Error('read ETIMEDOUT'), { code: 'ETIMEDOUT' })); + const git = { + getFullStatus: vi.fn(async () => ({ + staged: [], + unstaged: [], + currentBranch: 'main', + headKind: 'branch', + shortHash: 'abc123', + totalAdded: 0, + totalDeleted: 0, + })), + getHeadInfo, + getStatusFingerprint: vi.fn(async () => ({ hash: 'h1' })), + dispose: vi.fn(), + } as unknown as GitService; + const worktree = new LegacySshGitWorktree(git, '/repo/worktree', {} as LegacySshGitRepository); + + try { + 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(10_000); + + expect(getHeadInfo).toHaveBeenCalledTimes(2); + expect(updates).toEqual([]); + expect(warn).toHaveBeenCalledWith('LegacySshGitWorktree: head 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 d1a38379a8..2b0765d9e5 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 @@ -433,7 +433,7 @@ export class LegacySshGitRepository implements IGitRepository { } } -class LegacySshGitWorktree implements IGitWorktree { +export class LegacySshGitWorktree implements IGitWorktree { readonly worktree: string; readonly repository: LegacySshGitRepository; @@ -450,11 +450,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 = [ @@ -629,26 +629,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 (isRecoverableSshRefreshError(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 (isRecoverableSshRefreshError(error)) return err(error); + throw error; + } } private async refreshStatus(): Promise { From 67de5d83ea5a81076e9da55f6b5367ac42932d8c Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:27:38 +0200 Subject: [PATCH 4/7] fix(ssh): preserve git state on disconnect --- .../src/main/core/git/legacy/git-service.ts | 46 +++++++++++--- .../main/core/runtime/legacy/ssh-git.test.ts | 61 +++++++++++++++++-- 2 files changed, 95 insertions(+), 12 deletions(-) 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..c52e352d19 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 @@ -56,6 +56,33 @@ const STATUS_FINGERPRINT_TIMEOUT_MS: Record = { normal: 10_000, }; +const RECOVERABLE_SSH_TRANSPORT_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ENOTCONN', + 'EPIPE', + 'ETIMEDOUT', +]); + +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 + ); +} + +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 +231,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 +246,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 +1023,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 +1051,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 +1061,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 +1191,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/runtime/legacy/ssh-git.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts index 97afe84866..ebce2d7f00 100644 --- 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 @@ -1,9 +1,49 @@ import type { LocalBranch } from '@emdash/core/git'; import { describe, expect, it, vi } from 'vitest'; -import type { GitService } from '@main/core/git/legacy/git-service'; +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'); + }); + + it('propagates recoverable SSH failures from remotes fallback paths', async () => { + const git = createRejectingGitService(new Error('socket hang up')); + + await expect(git.getRemotes()).rejects.toThrow('socket hang up'); + }); +}); + describe('LegacySshGitRepository', () => { it('treats unavailable SSH connections during background refs refresh as recoverable', async () => { vi.useFakeTimers(); @@ -22,10 +62,11 @@ describe('LegacySshGitRepository', () => { 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_000); + await vi.advanceTimersByTimeAsync(15_001); expect(getBranches).toHaveBeenCalledTimes(2); expect(updates).toEqual([]); @@ -75,10 +116,15 @@ describe('LegacySshGitRepository', () => { 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_000); + await vi.advanceTimersByTimeAsync(20_001); expect(getFullStatus).toHaveBeenCalledTimes(2); expect(updates).toEqual([]); @@ -123,10 +169,17 @@ describe('LegacySshGitRepository', () => { name: 'main', oid: 'abc123', }); + await expect(worktree.getStatus()).resolves.toEqual({ + kind: 'ok', + staged: [], + unstaged: [], + stagedAdded: 0, + stagedDeleted: 0, + }); const updates: unknown[] = []; const unsubscribe = worktree.subscribe((update) => updates.push(update)); - await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(10_001); expect(getHeadInfo).toHaveBeenCalledTimes(2); expect(updates).toEqual([]); From 5dcf4fc82fcd0f5fd5c7d2c5b2cf154a5c0aa428 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:38:27 +0200 Subject: [PATCH 5/7] test(ssh): trim refresh recovery coverage --- .../main/core/runtime/legacy/ssh-git.test.ts | 63 ------------------- 1 file changed, 63 deletions(-) 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 index ebce2d7f00..7ed13eb5c1 100644 --- 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 @@ -36,12 +36,6 @@ describe('GitService SSH refresh failures', () => { await expect(git.getHeadInfo()).rejects.toThrow('read ETIMEDOUT'); }); - - it('propagates recoverable SSH failures from remotes fallback paths', async () => { - const git = createRejectingGitService(new Error('socket hang up')); - - await expect(git.getRemotes()).rejects.toThrow('socket hang up'); - }); }); describe('LegacySshGitRepository', () => { @@ -70,9 +64,6 @@ describe('LegacySshGitRepository', () => { expect(getBranches).toHaveBeenCalledTimes(2); expect(updates).toEqual([]); - expect(warn).toHaveBeenCalledWith('LegacySshGitRepository: refs refresh failed', { - error: expect.any(Error), - }); unsubscribe(); } finally { @@ -140,58 +131,4 @@ describe('LegacySshGitRepository', () => { } }); - it('treats unavailable SSH connections during background head refresh as recoverable', async () => { - vi.useFakeTimers(); - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const getHeadInfo = vi - .fn<() => Promise>>>() - .mockResolvedValueOnce({ kind: 'branch', name: 'main', oid: 'abc123' }) - .mockRejectedValueOnce(Object.assign(new Error('read ETIMEDOUT'), { code: 'ETIMEDOUT' })); - const git = { - getFullStatus: vi.fn(async () => ({ - staged: [], - unstaged: [], - currentBranch: 'main', - headKind: 'branch', - shortHash: 'abc123', - totalAdded: 0, - totalDeleted: 0, - })), - getHeadInfo, - getStatusFingerprint: vi.fn(async () => ({ hash: 'h1' })), - dispose: vi.fn(), - } as unknown as GitService; - const worktree = new LegacySshGitWorktree(git, '/repo/worktree', {} as LegacySshGitRepository); - - try { - await expect(worktree.getHead()).resolves.toEqual({ - kind: 'branch', - name: 'main', - oid: 'abc123', - }); - await expect(worktree.getStatus()).resolves.toEqual({ - kind: 'ok', - staged: [], - unstaged: [], - stagedAdded: 0, - stagedDeleted: 0, - }); - const updates: unknown[] = []; - const unsubscribe = worktree.subscribe((update) => updates.push(update)); - - await vi.advanceTimersByTimeAsync(10_001); - - expect(getHeadInfo).toHaveBeenCalledTimes(2); - expect(updates).toEqual([]); - expect(warn).toHaveBeenCalledWith('LegacySshGitWorktree: head refresh failed', { - error: expect.any(Error), - }); - - unsubscribe(); - } finally { - worktree.dispose(); - warn.mockRestore(); - vi.useRealTimers(); - } - }); }); From 8003ba6bdcebbf0c69db98001c58e9925a73d4c3 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:55:24 +0200 Subject: [PATCH 6/7] style: fix format check --- apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 7ed13eb5c1..4c52470f39 100644 --- 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 @@ -130,5 +130,4 @@ describe('LegacySshGitRepository', () => { vi.useRealTimers(); } }); - }); From 8c41b105d120b4fe1ba071f4bed74dcd080bb359 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:59:05 +0200 Subject: [PATCH 7/7] refactor(ssh): share transport error recovery --- .../src/main/core/git/legacy/git-service.ts | 23 +------------- .../src/main/core/runtime/legacy/ssh-git.ts | 31 +++---------------- .../src/main/core/ssh/transport-errors.ts | 21 +++++++++++++ 3 files changed, 27 insertions(+), 48 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/ssh/transport-errors.ts 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 c52e352d19..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,28 +57,6 @@ const STATUS_FINGERPRINT_TIMEOUT_MS: Record = { normal: 10_000, }; -const RECOVERABLE_SSH_TRANSPORT_ERROR_CODES = new Set([ - 'ECONNRESET', - 'ECONNREFUSED', - 'EHOSTUNREACH', - 'ENETDOWN', - 'ENETUNREACH', - 'ENOTCONN', - 'EPIPE', - 'ETIMEDOUT', -]); - -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 - ); -} - function emptyStdoutUnlessRecoverable(error: unknown): { stdout: string } { if (isRecoverableSshTransportError(error)) throw error; return { stdout: '' }; 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 2b0765d9e5..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'; @@ -60,28 +61,6 @@ const HEAD_POLL_MS = 10_000; const REFS_POLL_MS = 15_000; const REMOTES_POLL_MS = 60_000; -const RECOVERABLE_SSH_REFRESH_ERROR_CODES = new Set([ - 'ECONNRESET', - 'ECONNREFUSED', - 'EHOSTUNREACH', - 'ENETDOWN', - 'ENETUNREACH', - 'ENOTCONN', - 'EPIPE', - 'ETIMEDOUT', -]); - -function isRecoverableSshRefreshError(error: unknown): boolean { - const code = - typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : null; - if (typeof code === 'string' && RECOVERABLE_SSH_REFRESH_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 - ); -} - type LegacyRepositoryResource = { repository: LegacySshGitRepository; }; @@ -418,7 +397,7 @@ export class LegacySshGitRepository implements IGitRepository { try { return ok({ branches: await this.git.getBranches() }); } catch (error) { - if (isRecoverableSshRefreshError(error)) return err(error); + if (isRecoverableSshTransportError(error)) return err(error); throw error; } } @@ -427,7 +406,7 @@ export class LegacySshGitRepository implements IGitRepository { try { return ok({ remotes: (await this.git.getRemotes()) as GitRemote[] }); } catch (error) { - if (isRecoverableSshRefreshError(error)) return err(error); + if (isRecoverableSshTransportError(error)) return err(error); throw error; } } @@ -641,7 +620,7 @@ export class LegacySshGitWorktree implements IGitWorktree { }); } catch (error) { if (error instanceof TooManyFilesChangedError) return ok({ kind: 'too-many-files' }); - if (isRecoverableSshRefreshError(error)) return err(error); + if (isRecoverableSshTransportError(error)) return err(error); throw error; } } @@ -650,7 +629,7 @@ export class LegacySshGitWorktree implements IGitWorktree { try { return ok(await this.git.getHeadInfo()); } catch (error) { - if (isRecoverableSshRefreshError(error)) return err(error); + if (isRecoverableSshTransportError(error)) return err(error); throw error; } } 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 + ); +}