fix(desktop): harden Windows crash recovery #1064 #1065 #1071

This commit is contained in:
程序员阿江(Relakkes) 2026-07-20 22:06:15 +08:00
parent ba8917f1db
commit 727bcea077
18 changed files with 759 additions and 67 deletions

View File

@ -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: |

View File

@ -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

View File

@ -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

View File

@ -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<void>((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)

View File

@ -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<boolean> {
const session = this.sessions.get(chatId)

View File

@ -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=="],

View File

@ -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({

View File

@ -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)
})
})

View File

@ -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<typeof setTimeout> | null = null
let rendererReloadTimer: ReturnType<typeof setTimeout> | 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()
})
}

View File

@ -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<http.Server | net.Server> = []
const bridges: SystemProxyBridge[] = []
const sockets = new Set<net.Socket>()
const sockets = new Set<Duplex>()
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<void>(resolve => { markResolverStarted = resolve })
let releaseResolver!: () => void
const resolverReleased = new Promise<void>(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<void>(resolve => { markResolverStarted = resolve })
let releaseResolver!: () => void
const resolverReleased = new Promise<void>(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<void>(resolve => { markProxyAccepted = resolve })
let releaseProxyResponse!: () => void
const proxyResponseReleased = new Promise<void>(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<void> {
return (bridge as unknown as {
handleConnect(
request: http.IncomingMessage,
clientSocket: Duplex,
head: Buffer,
): Promise<void>
}).handleConnect({ url } as http.IncomingMessage, clientSocket, Buffer.alloc(0))
}
async function createProxyServer(label: string): Promise<number> {
const server = http.createServer((_request, response) => response.end(label))
servers.push(server)

View File

@ -180,24 +180,52 @@ export class SystemProxyBridge implements SystemProxyBridgeLike {
clientSocket: Duplex,
head: Buffer,
): Promise<void> {
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'

View File

@ -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()
})
})

View File

@ -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<string>()
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),

View File

@ -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",

View File

@ -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))
})

View File

@ -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"
},

View File

@ -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', () => {

View File

@ -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',