diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 3c5efe9a..00986976 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -77,7 +77,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: package.json - name: Setup Node.js uses: actions/setup-node@v4 @@ -113,6 +113,13 @@ jobs: SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }} run: bun run build:sidecars + - name: Verify compiled Windows sidecar startup + if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64' + working-directory: desktop + env: + CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS: '20' + run: bun run test:compiled-sidecar-smoke + - name: Build renderer and Electron bundles working-directory: desktop run: | diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index dd2e3dfa..3565d9b9 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -41,7 +41,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Collect changed files env: @@ -72,7 +72,7 @@ jobs: if: needs.scope-plan.outputs.policy_checks == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies if: needs.scope-plan.outputs.policy_checks == 'true' @@ -104,7 +104,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Install desktop dependencies @@ -124,7 +124,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Run root runtime checks @@ -141,7 +141,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Run offline provider contracts @@ -158,7 +158,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Install desktop dependencies @@ -178,7 +178,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install adapter dependencies working-directory: adapters run: bun install --frozen-lockfile @@ -203,7 +203,7 @@ jobs: libfuse2 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Install desktop dependencies @@ -223,7 +223,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Install desktop dependencies @@ -260,7 +260,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version-file: package.json - name: Install root dependencies run: bun install --frozen-lockfile - name: Install desktop dependencies diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 1d103164..f4149048 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -150,7 +150,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: package.json - name: Setup Node.js uses: actions/setup-node@v4 @@ -186,6 +186,13 @@ jobs: SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }} run: bun run build:sidecars + - name: Verify compiled Windows sidecar startup + if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64' + working-directory: desktop + env: + CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS: '20' + run: bun run test:compiled-sidecar-smoke + - name: Build renderer and Electron bundles working-directory: desktop run: | @@ -550,7 +557,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: package.json - name: Install root dependencies run: bun install diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts index 80411600..7d9d040d 100644 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -49,12 +49,20 @@ describe('WsBridge', () => { expect(bridge.sendStopGeneration('chat-stop')).toBe(false) }) - it('destroy cleans up all sessions', () => { + it('destroy cleans up all sessions without leaking connecting-socket errors', async () => { bridge.connectSession('a', 'uuid-a') bridge.connectSession('b', 'uuid-b') + const sockets = [...(bridge as any).sessions.values()] + .map((session: any) => session.ws) bridge.destroy() expect(bridge.hasSession('a')).toBe(false) expect(bridge.hasSession('b')).toBe(false) + + await new Promise((resolve) => setTimeout(resolve, 20)) + for (const ws of sockets) { + expect(ws.listenerCount('error')).toBe(0) + expect(ws.listenerCount('close')).toBe(0) + } }) }) @@ -225,6 +233,13 @@ describe('WsBridge: handler serialization', () => { bridge.resetSession('chat-reset') expect(bridge.hasSession('chat-reset')).toBe(false) expect(staleSession.ws.listenerCount('message')).toBe(0) + await new Promise((resolve) => { + if (staleSession.ws.readyState === staleSession.ws.CLOSED) { + resolve() + return + } + staleSession.ws.once('close', () => resolve()) + }) expect(staleSession.ws.listenerCount('close')).toBe(0) expect(staleSession.ws.listenerCount('error')).toBe(0) diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index f6e47251..b00122dd 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -135,8 +135,7 @@ export class WsBridge { const session = this.sessions.get(chatId) if (session) { if (session.reconnectTimer) clearTimeout(session.reconnectTimer) - session.ws.removeAllListeners() - session.ws.close(1000, 'session reset') + this.closeSocket(session.ws, 1000, 'session reset') this.sessions.delete(chatId) } this.handlers.delete(chatId) @@ -157,8 +156,7 @@ export class WsBridge { } for (const [, session] of this.sessions) { if (session.reconnectTimer) clearTimeout(session.reconnectTimer) - session.ws.removeAllListeners() - session.ws.close(1000, 'bridge destroyed') + this.closeSocket(session.ws, 1000, 'bridge destroyed') } this.sessions.clear() this.handlers.clear() @@ -178,7 +176,7 @@ export class WsBridge { const prev = this.sessions.get(chatId) if (prev) { if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer) - prev.ws.removeAllListeners() + this.closeSocket(prev.ws, 1000, 'session replaced') } const session: Session = { @@ -240,6 +238,21 @@ export class WsBridge { }) } + private closeSocket(ws: WebSocket, code: number, reason: string): void { + ws.removeAllListeners() + if (ws.readyState === WebSocket.CLOSED) return + + // `ws.close()` aborts an in-flight handshake by emitting an asynchronous + // error before close. Keep a temporary sink after detaching the session + // listeners so teardown cannot surface an unhandled EventEmitter error. + const swallowTeardownError = () => {} + ws.on('error', swallowTeardownError) + ws.once('close', () => { + ws.removeListener('error', swallowTeardownError) + }) + ws.close(code, reason) + } + /** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */ waitForOpen(chatId: string, timeoutMs = 10_000): Promise { const session = this.sessions.get(chatId) diff --git a/desktop/bun.lock b/desktop/bun.lock index 34e0162b..001bacb5 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -39,7 +39,7 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", - "electron": "^42.3.0", + "electron": "^42.4.0", "electron-builder": "^26.8.1", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", @@ -116,6 +116,8 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "https://registry.npmmirror.com/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], + "@electron/asar": ["@electron/asar@3.4.1", "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/fuses": ["@electron/fuses@1.8.0", "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], @@ -502,8 +504,6 @@ "@types/verror": ["@types/verror@1.10.11", "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -584,8 +584,6 @@ "buffer": ["buffer@5.7.1", "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@26.8.1", "https://registry.npmmirror.com/builder-util/-/builder-util-26.8.1.tgz", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], @@ -810,7 +808,7 @@ "ejs": ["ejs@3.1.10", "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@42.3.0", "https://registry.npmmirror.com/electron/-/electron-42.3.0.tgz", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g=="], + "electron": ["electron@42.7.0", "https://registry.npmmirror.com/electron/-/electron-42.7.0.tgz", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-Uu5p03M5o7aqulT8QxFYYg3eJVDfuNEUuasBU1qFl05ghVRRH7UHL7na8S3GhxVScZQ2D7j9DCMsc+qT5FuxtQ=="], "electron-builder": ["electron-builder@26.8.1", "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.8.1.tgz", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], @@ -862,16 +860,12 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "extract-zip": ["extract-zip@2.0.1", "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "extsprintf": ["extsprintf@1.4.1", "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fd-slicer": ["fd-slicer@1.1.0", "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], @@ -1248,8 +1242,6 @@ "pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], - "pend": ["pend@1.2.0", "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1568,8 +1560,6 @@ "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yauzl": ["yauzl@2.10.0", "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zustand": ["zustand@5.0.12", "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["immer", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 87f47311..608ab9b9 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -8,7 +8,7 @@ import { validateElectronIpcPayload, } from './ipc/capabilities' import { ElectronServerRuntime } from './services/serverRuntime' -import { electronHostDiagnosticsFile, sanitizeHostDiagnostic } from './services/sidecarManager' +import { appendHostDiagnostic, electronHostDiagnosticsFile, sanitizeHostDiagnostic } from './services/sidecarManager' import { openDialog, saveDialog } from './services/dialogs' import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell' import { @@ -41,6 +41,7 @@ import { installPreviewCleanupOnRendererNavigation } from './services/previewLif import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke' import { normalizeZoomFactor } from './services/zoom' import { resolveRendererEntry } from './services/rendererEntry' +import { installRendererLifecycle } from './services/rendererLifecycle' import { writeWindowSmokeSnapshot } from './services/windowSmoke' import { loadAndRevealMainWindow } from './services/windowStartup' import { @@ -542,17 +543,34 @@ async function createMainWindow() { shouldQuit: () => isQuitting, }) - mainWindow.on('resize', () => { - if (!mainWindow || mainWindow.isDestroyed()) return - mainWindow.webContents.send(ELECTRON_EVENT_CHANNELS.windowResized) - }) - mainWindow.webContents.on('did-finish-load', () => { - writeWindowSmokeSnapshot(mainWindow, 'did-finish-load') - }) - mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - writeWindowSmokeSnapshot(mainWindow, `did-fail-load:${errorCode}:${errorDescription}:${validatedURL}`) - }) + const window = mainWindow + const diagnosticsFile = electronHostDiagnosticsFile(process.env) + const recordRendererDiagnostic = (detail: string) => { + const sanitized = sanitizeHostDiagnostic(detail) + appendHostDiagnostic(diagnosticsFile, `[renderer] ${sanitized}`) + return sanitized + } + window.on('resize', () => { + if (window.isDestroyed()) return + window.webContents.send(ELECTRON_EVENT_CHANNELS.windowResized) + }) + installRendererLifecycle({ + window, + isQuitting: () => isQuitting, + recordDiagnostic: recordRendererDiagnostic, + writeSnapshot: reason => writeWindowSmokeSnapshot(window, reason), + onRendererProcessGone: detail => { + console.error(`[desktop] Electron renderer process exited: ${detail}`) + }, + onRecoveryExhausted: detail => { + console.error(`[desktop] Electron renderer recovery exhausted: ${detail}`) + dialog.showErrorBox( + '界面恢复失败 / Interface Recovery Failed', + `桌面界面意外退出或持续无响应,自动恢复未能解决问题。请重启应用;如果问题持续存在,请附上诊断日志反馈。\n\nThe desktop interface exited unexpectedly or remained unresponsive, and automatic recovery did not resolve it. Restart the app and include diagnostics when reporting the problem.\n\n${detail}`, + ) + }, + }) writeWindowSmokeSnapshot(mainWindow, 'after-create') await loadAndRevealMainWindow({ diff --git a/desktop/electron/services/rendererLifecycle.test.ts b/desktop/electron/services/rendererLifecycle.test.ts new file mode 100644 index 00000000..02bf388c --- /dev/null +++ b/desktop/electron/services/rendererLifecycle.test.ts @@ -0,0 +1,232 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS, + installRendererLifecycle, +} from './rendererLifecycle' + +class FakeWebContents extends EventEmitter { + destroyed = false + reload = vi.fn() + + isDestroyed() { + return this.destroyed + } +} + +class FakeWindow extends EventEmitter { + destroyed = false + webContents = new FakeWebContents() + + isDestroyed() { + return this.destroyed + } +} + +function createHarness(options: { quitting?: boolean } = {}) { + const window = new FakeWindow() + let quitting = options.quitting ?? false + const recordDiagnostic = vi.fn((detail: string) => `sanitized:${detail}`) + const writeSnapshot = vi.fn() + const onRendererProcessGone = vi.fn() + const onRecoveryExhausted = vi.fn() + + installRendererLifecycle({ + window: window as never, + isQuitting: () => quitting, + recordDiagnostic, + writeSnapshot, + onRendererProcessGone, + onRecoveryExhausted, + }) + + return { + window, + recordDiagnostic, + writeSnapshot, + onRendererProcessGone, + onRecoveryExhausted, + setQuitting(value: boolean) { + quitting = value + }, + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('Electron renderer lifecycle recovery', () => { + it('reloads after the first renderer exit and reports repeated failure only once', () => { + vi.useFakeTimers() + const harness = createHarness() + const details = { reason: 'crashed', exitCode: 139 } + + harness.window.webContents.emit('render-process-gone', {}, details) + + expect(harness.window.webContents.reload).not.toHaveBeenCalled() + expect(harness.recordDiagnostic).toHaveBeenCalledWith( + '[process-gone] reason=crashed exitCode=139', + ) + expect(harness.recordDiagnostic).toHaveBeenCalledWith( + '[recovery-started] trigger=process-gone:crashed', + ) + expect(harness.writeSnapshot).toHaveBeenCalledWith('render-process-gone:crashed:139') + expect(harness.onRendererProcessGone).toHaveBeenCalledWith( + 'sanitized:[process-gone] reason=crashed exitCode=139', + ) + + harness.window.webContents.emit('render-process-gone', {}, details) + expect(harness.recordDiagnostic).toHaveBeenCalledWith( + '[recovery-already-scheduled] trigger=process-gone:crashed', + ) + expect(harness.onRecoveryExhausted).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + + vi.runOnlyPendingTimers() + expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1) + + harness.window.webContents.emit('render-process-gone', {}, details) + harness.window.webContents.emit('render-process-gone', {}, details) + + expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1) + expect(harness.onRecoveryExhausted).toHaveBeenCalledTimes(1) + expect(harness.onRecoveryExhausted).toHaveBeenCalledWith( + 'sanitized:[recovery-exhausted] trigger=process-gone:crashed', + ) + }) + + it('reloads only after unresponsiveness persists for the full delay', () => { + vi.useFakeTimers() + const harness = createHarness() + + harness.window.webContents.emit('unresponsive') + vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS / 2) + harness.window.webContents.emit('unresponsive') + vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS / 2 - 1) + expect(harness.window.webContents.reload).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(harness.window.webContents.reload).not.toHaveBeenCalled() + vi.runOnlyPendingTimers() + expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1) + expect(harness.recordDiagnostic).toHaveBeenCalledWith( + '[recovery-started] trigger=unresponsive-timeout', + ) + }) + + it('cancels pending recovery when the renderer becomes responsive or the window closes', () => { + vi.useFakeTimers() + const harness = createHarness() + + harness.window.webContents.emit('unresponsive') + harness.window.webContents.emit('responsive') + vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS) + expect(harness.window.webContents.reload).not.toHaveBeenCalled() + expect(harness.writeSnapshot).toHaveBeenCalledWith('responsive') + + harness.window.webContents.emit('unresponsive') + harness.window.emit('closed') + vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS) + expect(harness.window.webContents.reload).not.toHaveBeenCalled() + }) + + it('does not reload a destroyed window or web contents, or while the app is quitting', () => { + vi.useFakeTimers() + const destroyedWindowHarness = createHarness() + destroyedWindowHarness.window.destroyed = true + destroyedWindowHarness.window.webContents.emit('unresponsive') + vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS) + expect(destroyedWindowHarness.window.webContents.reload).not.toHaveBeenCalled() + + const destroyedContentsHarness = createHarness() + destroyedContentsHarness.window.webContents.destroyed = true + destroyedContentsHarness.window.webContents.emit('render-process-gone', {}, { + reason: 'crashed', + exitCode: 1, + }) + expect(destroyedContentsHarness.window.webContents.reload).not.toHaveBeenCalled() + + const quittingHarness = createHarness({ quitting: true }) + quittingHarness.window.webContents.emit('render-process-gone', {}, { + reason: 'clean-exit', + exitCode: 0, + }) + expect(quittingHarness.window.webContents.reload).not.toHaveBeenCalled() + expect(quittingHarness.onRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('rechecks destruction and quitting before a deferred reload and cancels it on close', () => { + vi.useFakeTimers() + const details = { reason: 'crashed', exitCode: 1 } + + const destroyedWindowHarness = createHarness() + destroyedWindowHarness.window.webContents.emit('render-process-gone', {}, details) + destroyedWindowHarness.window.destroyed = true + vi.runOnlyPendingTimers() + expect(destroyedWindowHarness.window.webContents.reload).not.toHaveBeenCalled() + expect(destroyedWindowHarness.recordDiagnostic).toHaveBeenCalledWith( + '[recovery-skipped] trigger=process-gone:crashed quitting=false', + ) + + const destroyedContentsHarness = createHarness() + destroyedContentsHarness.window.webContents.emit('render-process-gone', {}, details) + destroyedContentsHarness.window.webContents.destroyed = true + vi.runOnlyPendingTimers() + expect(destroyedContentsHarness.window.webContents.reload).not.toHaveBeenCalled() + + const quittingHarness = createHarness() + quittingHarness.window.webContents.emit('render-process-gone', {}, details) + quittingHarness.setQuitting(true) + vi.runOnlyPendingTimers() + expect(quittingHarness.window.webContents.reload).not.toHaveBeenCalled() + + const closedHarness = createHarness() + closedHarness.window.webContents.emit('render-process-gone', {}, details) + expect(vi.getTimerCount()).toBe(1) + closedHarness.window.emit('closed') + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(closedHarness.window.webContents.reload).not.toHaveBeenCalled() + }) + + it('reports reload and recovered main-frame load failures once while ignoring aborts', () => { + vi.useFakeTimers() + const reloadErrorHarness = createHarness() + reloadErrorHarness.window.webContents.reload.mockImplementation(() => { + throw new Error('reload failed') + }) + reloadErrorHarness.window.webContents.emit('render-process-gone', {}, { + reason: 'crashed', + exitCode: 1, + }) + vi.runOnlyPendingTimers() + expect(reloadErrorHarness.onRecoveryExhausted).toHaveBeenCalledWith( + 'sanitized:[recovery-exhausted] trigger=process-gone:crashed reloadError=reload failed', + ) + + const loadFailureHarness = createHarness() + loadFailureHarness.window.webContents.emit('render-process-gone', {}, { + reason: 'crashed', + exitCode: 1, + }) + vi.runOnlyPendingTimers() + loadFailureHarness.window.webContents.emit( + 'did-fail-load', {}, -3, 'ERR_ABORTED', 'file:///app/index.html', true, + ) + expect(loadFailureHarness.onRecoveryExhausted).not.toHaveBeenCalled() + + loadFailureHarness.window.webContents.emit( + 'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', false, + ) + expect(loadFailureHarness.onRecoveryExhausted).not.toHaveBeenCalled() + + loadFailureHarness.window.webContents.emit( + 'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', true, + ) + loadFailureHarness.window.webContents.emit( + 'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', true, + ) + expect(loadFailureHarness.onRecoveryExhausted).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/electron/services/rendererLifecycle.ts b/desktop/electron/services/rendererLifecycle.ts new file mode 100644 index 00000000..3415d7b8 --- /dev/null +++ b/desktop/electron/services/rendererLifecycle.ts @@ -0,0 +1,121 @@ +import type { BrowserWindow } from 'electron' + +export const DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS = 10_000 + +export type RendererLifecycleOptions = { + window: BrowserWindow + isQuitting: () => boolean + recordDiagnostic: (detail: string) => string + writeSnapshot: (reason: string) => void + onRendererProcessGone?: (detail: string) => void + onRecoveryExhausted: (detail: string) => void + unresponsiveRecoveryDelayMs?: number +} + +export function installRendererLifecycle({ + window, + isQuitting, + recordDiagnostic, + writeSnapshot, + onRendererProcessGone, + onRecoveryExhausted, + unresponsiveRecoveryDelayMs = DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS, +}: RendererLifecycleOptions): void { + let recoveryAttempted = false + let failureReported = false + let unresponsiveRecoveryTimer: ReturnType | null = null + let rendererReloadTimer: ReturnType | null = null + + const clearUnresponsiveRecovery = () => { + if (!unresponsiveRecoveryTimer) return + clearTimeout(unresponsiveRecoveryTimer) + unresponsiveRecoveryTimer = null + } + const clearRendererReload = () => { + if (!rendererReloadTimer) return + clearTimeout(rendererReloadTimer) + rendererReloadTimer = null + } + const reportRecoveryFailure = (detail: string) => { + if (failureReported || isQuitting()) return + failureReported = true + onRecoveryExhausted(recordDiagnostic(`[recovery-exhausted] ${detail}`)) + } + const recoverRenderer = (trigger: string) => { + clearUnresponsiveRecovery() + if (isQuitting() || window.isDestroyed() || window.webContents.isDestroyed()) { + recordDiagnostic(`[recovery-skipped] trigger=${trigger} quitting=${isQuitting()}`) + return + } + if (rendererReloadTimer) { + recordDiagnostic(`[recovery-already-scheduled] trigger=${trigger}`) + return + } + if (recoveryAttempted) { + reportRecoveryFailure(`trigger=${trigger}`) + return + } + + recoveryAttempted = true + recordDiagnostic(`[recovery-started] trigger=${trigger}`) + rendererReloadTimer = setTimeout(() => { + rendererReloadTimer = null + if (isQuitting() || window.isDestroyed() || window.webContents.isDestroyed()) { + recordDiagnostic(`[recovery-skipped] trigger=${trigger} quitting=${isQuitting()}`) + return + } + try { + window.webContents.reload() + } catch (error) { + reportRecoveryFailure( + `trigger=${trigger} reloadError=${error instanceof Error ? error.message : String(error)}`, + ) + } + }, 0) + } + + window.webContents.on('did-finish-load', () => { + clearUnresponsiveRecovery() + if (recoveryAttempted) recordDiagnostic('[recovery-loaded]') + writeSnapshot('did-finish-load') + }) + window.webContents.on('did-fail-load', ( + _event, + errorCode, + errorDescription, + validatedURL, + isMainFrame, + ) => { + writeSnapshot(`did-fail-load:${errorCode}:${errorDescription}:${validatedURL}`) + if (recoveryAttempted && isMainFrame && errorCode !== -3) { + reportRecoveryFailure(`loadError=${errorCode}:${errorDescription}`) + } + }) + window.webContents.on('render-process-gone', (_event, details) => { + clearUnresponsiveRecovery() + const detail = recordDiagnostic( + `[process-gone] reason=${details.reason} exitCode=${details.exitCode}`, + ) + onRendererProcessGone?.(detail) + writeSnapshot(`render-process-gone:${details.reason}:${details.exitCode}`) + recoverRenderer(`process-gone:${details.reason}`) + }) + window.webContents.on('unresponsive', () => { + recordDiagnostic('[unresponsive]') + writeSnapshot('unresponsive') + if (unresponsiveRecoveryTimer) return + unresponsiveRecoveryTimer = setTimeout(() => { + unresponsiveRecoveryTimer = null + recoverRenderer('unresponsive-timeout') + }, unresponsiveRecoveryDelayMs) + }) + window.webContents.on('responsive', () => { + clearUnresponsiveRecovery() + recordDiagnostic('[responsive]') + writeSnapshot('responsive') + }) + window.on('closed', () => { + clearUnresponsiveRecovery() + clearRendererReload() + }) +} diff --git a/desktop/electron/services/systemProxyBridge.test.ts b/desktop/electron/services/systemProxyBridge.test.ts index dad82237..0eb482f1 100644 --- a/desktop/electron/services/systemProxyBridge.test.ts +++ b/desktop/electron/services/systemProxyBridge.test.ts @@ -1,5 +1,6 @@ import http from 'node:http' import net from 'node:net' +import { PassThrough, type Duplex } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { parseSystemProxyRules, @@ -9,7 +10,7 @@ import { const servers: Array = [] const bridges: SystemProxyBridge[] = [] -const sockets = new Set() +const sockets = new Set() afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.stop())) @@ -202,6 +203,101 @@ describe('SystemProxyBridge', () => { .rejects.toThrow('407 Proxy Authentication Required') }) + it('handles a client reset while CONNECT proxy resolution is pending', async () => { + let markResolverStarted!: () => void + const resolverStarted = new Promise(resolve => { markResolverStarted = resolve }) + let releaseResolver!: () => void + const resolverReleased = new Promise(resolve => { releaseResolver = resolve }) + const bridge = new SystemProxyBridge(async () => { + markResolverStarted() + await resolverReleased + return 'INVALID' + }) + bridges.push(bridge) + const clientSocket = new PassThrough() + + const handling = invokeConnect(bridge, 'foreign.example:443', clientSocket) + await resolverStarted + + expect(() => clientSocket.emit( + 'error', + Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }), + )).not.toThrow() + releaseResolver() + + await expect(handling).resolves.toBeUndefined() + expect(clientSocket.destroyed).toBe(true) + }) + + it('handles a graceful client end while CONNECT proxy resolution is pending', async () => { + let markResolverStarted!: () => void + const resolverStarted = new Promise(resolve => { markResolverStarted = resolve }) + let releaseResolver!: () => void + const resolverReleased = new Promise(resolve => { releaseResolver = resolve }) + const bridge = new SystemProxyBridge(async () => { + markResolverStarted() + await resolverReleased + return 'INVALID' + }) + bridges.push(bridge) + const clientSocket = new PassThrough() + const end = vi.spyOn(clientSocket, 'end') + + const handling = invokeConnect(bridge, 'foreign.example:443', clientSocket) + await resolverStarted + clientSocket.emit('end') + releaseResolver() + + await expect(handling).resolves.toBeUndefined() + expect(clientSocket.destroyed).toBe(true) + expect(end).not.toHaveBeenCalled() + }) + + it('does not send a CONNECT failure response after the client is closed', async () => { + const bridge = new SystemProxyBridge(async () => 'DIRECT') + bridges.push(bridge) + const clientSocket = new PassThrough() + const end = vi.spyOn(clientSocket, 'end') + clientSocket.destroy() + + await expect(invokeConnect(bridge, 'invalid target', clientSocket)).resolves.toBeUndefined() + + expect(end).not.toHaveBeenCalled() + }) + + it('destroys a CONNECT route that finishes after the client closes', async () => { + let markProxyAccepted!: () => void + const proxyAccepted = new Promise(resolve => { markProxyAccepted = resolve }) + let releaseProxyResponse!: () => void + const proxyResponseReleased = new Promise(resolve => { releaseProxyResponse = resolve }) + const tunnelProxy = http.createServer() + tunnelProxy.on('connect', (_request, socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + markProxyAccepted() + void proxyResponseReleased.then(() => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + }) + }) + servers.push(tunnelProxy) + const proxyPort = await listen(tunnelProxy) + const bridge = new SystemProxyBridge(async () => `PROXY 127.0.0.1:${proxyPort}`) + bridges.push(bridge) + const trackedRoutes = vi.spyOn(bridge as unknown as { + trackOutboundSocket(socket: Duplex): void + }, 'trackOutboundSocket') + const clientSocket = new PassThrough() + + const handling = invokeConnect(bridge, 'foreign.example:443', clientSocket) + await proxyAccepted + clientSocket.emit('close') + releaseProxyResponse() + + await expect(handling).resolves.toBeUndefined() + expect(trackedRoutes).toHaveBeenCalledOnce() + expect(trackedRoutes.mock.calls[0]?.[0].destroyed).toBe(true) + }) + it('always bypasses system proxy resolution for loopback targets', async () => { const target = http.createServer((_request, response) => response.end('loopback')) servers.push(target) @@ -269,6 +365,20 @@ async function startBridge( return await bridge.start() } +function invokeConnect( + bridge: SystemProxyBridge, + url: string, + clientSocket: Duplex, +): Promise { + return (bridge as unknown as { + handleConnect( + request: http.IncomingMessage, + clientSocket: Duplex, + head: Buffer, + ): Promise + }).handleConnect({ url } as http.IncomingMessage, clientSocket, Buffer.alloc(0)) +} + async function createProxyServer(label: string): Promise { const server = http.createServer((_request, response) => response.end(label)) servers.push(server) diff --git a/desktop/electron/services/systemProxyBridge.ts b/desktop/electron/services/systemProxyBridge.ts index b0491dfb..fead7468 100644 --- a/desktop/electron/services/systemProxyBridge.ts +++ b/desktop/electron/services/systemProxyBridge.ts @@ -180,24 +180,52 @@ export class SystemProxyBridge implements SystemProxyBridgeLike { clientSocket: Duplex, head: Buffer, ): Promise { + let routeSocket: Duplex | null = null + let clientUnavailable = clientSocket.destroyed + || !clientSocket.writable + || clientSocket.writableEnded + || clientSocket.writableFinished + const isClientUnavailable = () => clientUnavailable + || clientSocket.destroyed + || !clientSocket.writable + || clientSocket.writableEnded + || clientSocket.writableFinished + const closeRoute = () => { + clientUnavailable = true + routeSocket?.destroy() + clientSocket.destroy() + } + clientSocket.on('error', closeRoute) + clientSocket.once('end', closeRoute) + clientSocket.once('close', closeRoute) + try { const endpoint = parseEndpoint(request.url ?? '', 443) if (!endpoint) throw new Error('Invalid CONNECT target') const target = new URL(`https://${formatAuthority(endpoint.host, endpoint.port)}/`) const rules = await this.resolveRules(target) + if (isClientUnavailable()) return const route = await connectTunnelUsingRules(rules, endpoint.host, endpoint.port) + routeSocket = route.socket this.trackOutboundSocket(route.socket) - clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') - if (head.length > 0) route.socket.write(head) - route.socket.pipe(clientSocket) - clientSocket.pipe(route.socket) const closeBoth = () => { route.socket.destroy() clientSocket.destroy() } route.socket.on('error', closeBoth) - clientSocket.on('error', closeBoth) + if (isClientUnavailable()) { + route.socket.destroy() + return + } + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length > 0) route.socket.write(head) + route.socket.pipe(clientSocket) + clientSocket.pipe(route.socket) } catch (error) { + if (isClientUnavailable()) { + clientSocket.destroy() + return + } const authenticationRequired = error instanceof ProxyAuthenticationRequiredError clientSocket.end(`${authenticationRequired ? 'HTTP/1.1 407 Proxy Authentication Required' diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts index 1350a096..ba833b5e 100644 --- a/desktop/electron/services/terminal.test.ts +++ b/desktop/electron/services/terminal.test.ts @@ -283,4 +283,78 @@ describe('Electron terminal service', () => { expect(fakePty.killed).toBe(true) }) + + it('cleans up the PTY without sending events after the renderer is destroyed', async () => { + const dir = tempDir() + const fakePty = new FakePty() + const send = vi.fn() + const service = new ElectronTerminalService({ + env: { HOME: dir, SHELL: '/bin/test-shell' }, + platform: 'linux', + ptyFactory: { spawn: vi.fn(() => fakePty) }, + }) + + await service.spawn({ cols: 80, rows: 24, cwd: dir }, { + isDestroyed: () => true, + send, + }) + + expect(() => fakePty.emitData('late output')).not.toThrow() + expect(() => fakePty.emitExit({ exitCode: 0 })).not.toThrow() + expect(send).not.toHaveBeenCalled() + expect(() => service.write(1, 'after exit')).toThrow('terminal session is not running') + }) + + it('ignores a renderer destroyed during send but rethrows unrelated send errors', async () => { + const dir = tempDir() + const destroyedPty = new FakePty() + const destroyedService = new ElectronTerminalService({ + env: { HOME: dir, SHELL: '/bin/test-shell' }, + platform: 'linux', + ptyFactory: { spawn: vi.fn(() => destroyedPty) }, + }) + + await destroyedService.spawn({ cols: 80, rows: 24, cwd: dir }, { + isDestroyed: () => false, + send: () => { + throw new TypeError('Object has been destroyed') + }, + }) + + expect(() => destroyedPty.emitData('late output')).not.toThrow() + expect(() => destroyedPty.emitExit({ exitCode: 0 })).not.toThrow() + + const failingPty = new FakePty() + const failingService = new ElectronTerminalService({ + env: { HOME: dir, SHELL: '/bin/test-shell' }, + platform: 'linux', + ptyFactory: { spawn: vi.fn(() => failingPty) }, + }) + await failingService.spawn({ cols: 80, rows: 24, cwd: dir }, { + send: () => { + throw new Error('unexpected IPC failure') + }, + }) + + expect(() => failingPty.emitData('output')).toThrow('unexpected IPC failure') + }) + + it('ignores PTY events that arrive after killAll', async () => { + const dir = tempDir() + const fakePty = new FakePty() + const send = vi.fn() + const service = new ElectronTerminalService({ + env: { HOME: dir, SHELL: '/bin/test-shell' }, + platform: 'linux', + ptyFactory: { spawn: vi.fn(() => fakePty) }, + }) + + await service.spawn({ cols: 80, rows: 24, cwd: dir }, { send }) + service.killAll() + + expect(fakePty.killed).toBe(true) + expect(() => fakePty.emitData('late output')).not.toThrow() + expect(() => fakePty.emitExit({ exitCode: 0 })).not.toThrow() + expect(send).not.toHaveBeenCalled() + }) }) diff --git a/desktop/electron/services/terminal.ts b/desktop/electron/services/terminal.ts index d4cc3522..0a551c99 100644 --- a/desktop/electron/services/terminal.ts +++ b/desktop/electron/services/terminal.ts @@ -65,6 +65,7 @@ export type TerminalAppLike = { export type TerminalWebContentsLike = { send(channel: string, payload: unknown): void + isDestroyed?(): boolean } export type ElectronTerminalServiceOptions = { @@ -96,6 +97,22 @@ type TerminalSession = { pty: TerminalPtyProcess } +function sendTerminalEvent( + webContents: TerminalWebContentsLike, + channel: string, + payload: TerminalOutputPayload | TerminalExitPayload, +): void { + if (webContents.isDestroyed?.()) return + + try { + webContents.send(channel, payload) + } catch (error) { + // The renderer can be destroyed between the isDestroyed check and send. + if (error instanceof Error && error.message === 'Object has been destroyed') return + throw error + } +} + const preparedNodePtyDirs = new Set() export function terminalConfigPath(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv = process.env): string | null { @@ -563,15 +580,17 @@ export class ElectronTerminalService { this.sessions.set(sessionId, { pty }) pty.onData(data => { - webContents.send(ELECTRON_EVENT_CHANNELS.terminalOutput, { + if (this.sessions.get(sessionId)?.pty !== pty) return + sendTerminalEvent(webContents, ELECTRON_EVENT_CHANNELS.terminalOutput, { session_id: sessionId, data, } satisfies TerminalOutputPayload) }) pty.onExit(({ exitCode, signal }) => { + if (this.sessions.get(sessionId)?.pty !== pty) return this.sessions.delete(sessionId) - webContents.send(ELECTRON_EVENT_CHANNELS.terminalExit, { + sendTerminalEvent(webContents, ELECTRON_EVENT_CHANNELS.terminalExit, { session_id: sessionId, code: exitCode, signal: signal == null ? null : String(signal), diff --git a/desktop/package.json b/desktop/package.json index fb7c6348..306ae6f6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -136,7 +136,7 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", - "electron": "^42.3.0", + "electron": "^42.4.0", "electron-builder": "^26.8.1", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", diff --git a/desktop/scripts/build-sidecars.test.ts b/desktop/scripts/build-sidecars.test.ts index d32dd1d8..c3fd408e 100644 --- a/desktop/scripts/build-sidecars.test.ts +++ b/desktop/scripts/build-sidecars.test.ts @@ -522,6 +522,15 @@ describe('build-sidecars Windows x64 target mapping', () => { const compiledSidecarSmokeEnabled = process.env.CC_HAHA_RUN_COMPILED_SIDECAR_SMOKE === '1' +const configuredCompiledSidecarStarts = Number.parseInt( + process.env.CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS ?? '', + 10, +) +const compiledSidecarSmokeStarts = Number.isInteger(configuredCompiledSidecarStarts) + && configuredCompiledSidecarStarts >= 2 + && configuredCompiledSidecarStarts <= 50 + ? configuredCompiledSidecarStarts + : 2 describe.skipIf(!compiledSidecarSmokeEnabled)('compiled sidecar local-index smoke', () => { it('uses SQLite by default, serves one indexed session, and reopens the database', async () => { @@ -595,20 +604,17 @@ describe.skipIf(!compiledSidecarSmokeEnabled)('compiled sidecar local-index smok 'utf8', ) - await startAndVerify() - await startAndVerify() - expect(authenticationProofs).toEqual([ - { + for (let start = 0; start < compiledSidecarSmokeStarts; start += 1) { + await startAndVerify() + } + expect(authenticationProofs).toHaveLength(compiledSidecarSmokeStarts) + for (const proof of authenticationProofs) { + expect(proof).toEqual({ missingTokenStatus: 403, wrongTokenStatus: 403, correctTokenStatus: 200, - }, - { - missingTokenStatus: 403, - wrongTokenStatus: 403, - correctTokenStatus: 200, - }, - ]) + }) + } } finally { try { if (activeProcess) await terminateCompiledSidecar(activeProcess) @@ -618,5 +624,5 @@ describe.skipIf(!compiledSidecarSmokeEnabled)('compiled sidecar local-index smok } expect(await stat(rootDir).then(() => true, () => false)).toBe(false) - }, 90_000) + }, Math.max(90_000, compiledSidecarSmokeStarts * 10_000)) }) diff --git a/package.json b/package.json index 031d7b03..785ddb4b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "999.0.0-local", "private": true, "type": "module", - "packageManager": "bun@1.3.12", + "packageManager": "bun@1.3.14", "bin": { "claude-haha": "./bin/claude-haha" }, diff --git a/scripts/pr/pr-quality-workflow.test.ts b/scripts/pr/pr-quality-workflow.test.ts index 24460fd3..23a10b74 100644 --- a/scripts/pr/pr-quality-workflow.test.ts +++ b/scripts/pr/pr-quality-workflow.test.ts @@ -12,6 +12,22 @@ function workflowJobs(workflow: string) { } describe('PR quality workflow', () => { + test('uses packageManager as the single pinned Bun version source', () => { + const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8') + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { + packageManager?: string + } + const setupBunStepCount = workflow.match(/uses: oven-sh\/setup-bun@v2/g)?.length ?? 0 + const versionFileCount = workflow.match(/bun-version-file: package\.json/g)?.length ?? 0 + + expect(packageJson.packageManager).toBe('bun@1.3.14') + expect(setupBunStepCount).toBeGreaterThan(0) + expect(versionFileCount).toBe(setupBunStepCount) + expect(workflow).not.toContain('bun-version:') + expect(workflow).not.toContain('1.3.12') + expect(workflow).not.toContain('bun-version: latest') + }) + test('builds scope before routing independent quality jobs', () => { const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8') @@ -51,7 +67,6 @@ describe('PR quality workflow', () => { ]) { expect(jobs[jobId].needs).toBe('scope-plan') } - expect(workflow).toContain('bun-version: 1.3.12') }) test('keeps coverage artifacts observable in CI', () => { diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index 27971fcf..3af733b2 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -62,6 +62,43 @@ describe('release desktop workflow', () => { } }) + test('desktop build workflows install the Bun version declared by packageManager', () => { + for (const workflowPath of [ + '.github/workflows/build-desktop-dev.yml', + '.github/workflows/release-desktop.yml', + ]) { + const workflow = readFileSync(workflowPath, 'utf8') + const setupBunStepCount = workflow.match(/uses: oven-sh\/setup-bun@v2/g)?.length ?? 0 + const packageVersionCount = workflow.match(/bun-version-file: package\.json/g)?.length ?? 0 + + expect(setupBunStepCount, workflowPath).toBeGreaterThan(0) + expect(packageVersionCount, workflowPath).toBe(setupBunStepCount) + expect(workflow, workflowPath).not.toContain('bun-version: latest') + } + }) + + test('Windows x64 builds execute the compiled sidecar before packaging', () => { + for (const workflowPath of [ + '.github/workflows/build-desktop-dev.yml', + '.github/workflows/release-desktop.yml', + ]) { + const workflow = readFileSync(workflowPath, 'utf8') + const smokeStep = extractStep(workflow, 'Verify compiled Windows sidecar startup') + + expect(smokeStep, workflowPath).toContain( + "if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'", + ) + expect(smokeStep, workflowPath).toContain('working-directory: desktop') + expect(smokeStep, workflowPath).toContain("CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS: '20'") + expect(smokeStep, workflowPath).toContain('bun run test:compiled-sidecar-smoke') + expect(workflow.indexOf('Build sidecars'), workflowPath).toBeLessThan( + workflow.indexOf('Verify compiled Windows sidecar startup'), + ) + expect(workflow.indexOf('Verify compiled Windows sidecar startup'), workflowPath) + .toBeLessThan(workflow.indexOf('Build renderer and Electron bundles')) + } + }) + test('desktop build workflows prepare the pinned ripgrep asset before sidecars', () => { for (const workflowPath of [ '.github/workflows/build-desktop-dev.yml',