feat(desktop): enrich startup crash diagnostics and reveal reports
Refs #4540. Capture startup state before disposal and make crash reports retrievable from native recovery.
This commit is contained in:
1 parent
1a33461f2b
commit
c6d01dcac0
25 files changed
+482
-42
No files matched your search
@@ -0,0 +1,53 @@
|
||||
/** Bounded Host failure details sent to the Desktop crash reporter. */
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import { StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/** Maximum characters carried by one fatal diagnostic, including its truncation notice. */
|
||||
export const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024
|
||||
|
||||
/**
|
||||
* Render the original error and explicitly selected startup metadata.
|
||||
* Selected metadata excludes configuration values and arbitrary startup logger arguments.
|
||||
* @param error - Failure received by the Host entry point.
|
||||
* @returns Bounded text with an explicit notice when the report is shortened.
|
||||
*/
|
||||
export function formatFatalDiagnostic(error: unknown): string {
|
||||
const original = inspect(error, { depth: 4, maxArrayLength: 50 })
|
||||
const sections: string[] = []
|
||||
const seen = new Set<Error>()
|
||||
function visit(value: unknown): void {
|
||||
if (!(value instanceof Error) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
if (value instanceof StartupError) {
|
||||
if (value.composition !== undefined) {
|
||||
const { plugins, services, omittedPlugins, omittedServices } = value.composition
|
||||
const sources = [...new Set(plugins.map(plugin => plugin.source))]
|
||||
sections.push('--- startup composition ---',
|
||||
'Provider lists contain observed registrations only; an empty list means the provider is unknown.',
|
||||
'Source identifies the Loader tree, not necessarily the overriding patch.',
|
||||
`Omitted plugin rows: ${omittedPlugins}; omitted missing-service observations: ${omittedServices}.`,
|
||||
'--- missing services ---', ...services.map(service => JSON.stringify(service)),
|
||||
'--- plugin sources (sourceIndex) ---', ...sources.map((source, index) => JSON.stringify({ index, source })),
|
||||
'--- plugins (inactive first) ---', ...plugins.map(({ source, ...plugin }) => JSON.stringify({ ...plugin, sourceIndex: sources.indexOf(source) })))
|
||||
} else {
|
||||
sections.push('Startup composition diagnostic unavailable.')
|
||||
}
|
||||
sections.push('--- startup entries ---', inspect(value.entries, {
|
||||
depth: 6, maxArrayLength: null, maxStringLength: 4096,
|
||||
}))
|
||||
if (value.startup !== undefined) {
|
||||
sections.push(`Configuration file: ${value.startup.configurationPath}`)
|
||||
}
|
||||
}
|
||||
visit(value.cause)
|
||||
if (value instanceof AggregateError) value.errors.forEach(visit)
|
||||
}
|
||||
visit(error)
|
||||
sections.unshift(sections.length === 0 || original.length <= 8192 ? original
|
||||
: `${original.slice(0, 8192).replace(/[\uD800-\uDBFF]$/u, '')}\n… (original error truncated at 8192 characters)`)
|
||||
const text = sections.join('\n')
|
||||
if (text.length <= MAX_FATAL_DIAGNOSTIC_CHARS) return text
|
||||
const notice = '\n… (Host diagnostic truncated at 65536 characters)'
|
||||
return text.slice(0, MAX_FATAL_DIAGNOSTIC_CHARS - notice.length).replace(/[\uD800-\uDBFF]$/u, '') + notice
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Launch the Desktop profile through the Web application and report its URL to Electron. */
|
||||
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { inspect } from 'node:util'
|
||||
import { formatFatalDiagnostic } from './fatal-diagnostic.ts'
|
||||
import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection'
|
||||
@@ -104,16 +104,13 @@ async function main(): Promise<void> {
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
|
||||
}
|
||||
|
||||
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
|
||||
const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// The shell receives the complete inspected error here, not through stderr:
|
||||
// stderr bytes and this IPC message race, and the shell reports the first
|
||||
// failure it sees.
|
||||
const diagnostic = inspect(error, { depth: 4, maxArrayLength: 50 }).slice(0, MAX_FATAL_DIAGNOSTIC_CHARS)
|
||||
const diagnostic = formatFatalDiagnostic(error)
|
||||
if (process.connected) process.send?.({ type: 'fatal', message, diagnostic }, (error) => { if (error !== null) console.error(error) })
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
import { formatFatalDiagnostic, MAX_FATAL_DIAGNOSTIC_CHARS } from '../src/fatal-diagnostic.ts'
|
||||
|
||||
it('includes non-enumerable startup entries without copying arbitrary startup logger arguments', () => {
|
||||
const error = new StartupError('required plugin did not activate', [{
|
||||
id: 'agent-loop', module: '@deepseek-ai/dsh-agent-loop', required: true, fiberState: undefined,
|
||||
outcome: { kind: 'pending', missing: ['agents', 'sessions'] },
|
||||
}])
|
||||
error.startup = { configurationPath: '/profile/cordis.yml', messages: [{
|
||||
ts: 1, name: 'plugin', type: 'warn', args: ['private-plugin-output'],
|
||||
}] }
|
||||
const report = formatFatalDiagnostic(new Error('Host startup failed', { cause: error }))
|
||||
expect(report).toContain('--- startup entries ---')
|
||||
expect(report).toContain('@deepseek-ai/dsh-agent-loop')
|
||||
expect(report).toContain("missing: [ 'agents', 'sessions' ]")
|
||||
expect(report).toContain('Configuration file: /profile/cordis.yml')
|
||||
expect(report).not.toContain('private-plugin-output')
|
||||
})
|
||||
|
||||
it('marks truncation within the complete IPC budget and preserves Unicode', () => {
|
||||
const error = new Error('😀'.repeat(MAX_FATAL_DIAGNOSTIC_CHARS))
|
||||
const report = formatFatalDiagnostic(error)
|
||||
expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS)
|
||||
expect(report).toContain('Host diagnostic truncated')
|
||||
expect(report.isWellFormed()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps missing-service evidence ahead of a truncated plugin inventory', () => {
|
||||
const error = new StartupError('failure '.repeat(2000), [])
|
||||
error.composition = {
|
||||
services: [{ consumer: 'agent-loop', service: 'agents', providers: [] }],
|
||||
plugins: Array.from({ length: 256 }, (_, index) => ({
|
||||
id: `plugin-${index}`, module: 'long-package-name'.repeat(30), version: '1.0.0',
|
||||
state: 'disabled', source: '/profile/cordis.yml', missing: [],
|
||||
})),
|
||||
omittedPlugins: 3, omittedServices: 2,
|
||||
}
|
||||
const report = formatFatalDiagnostic(error)
|
||||
expect(report).toContain('original error truncated')
|
||||
expect(report).toContain('"service":"agents","providers":[]')
|
||||
expect(report).toContain('Omitted plugin rows: 3; omitted missing-service observations: 2.')
|
||||
expect(report).toContain('"source":"/profile/cordis.yml"')
|
||||
expect(report).toContain('Host diagnostic truncated')
|
||||
expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS)
|
||||
})
|
||||
|
||||
it('terminates traversal through cyclic and aggregate causes', () => {
|
||||
const error = new AggregateError([], 'cycle')
|
||||
error.errors.push(error)
|
||||
error.cause = error
|
||||
expect(formatFatalDiagnostic(error)).toContain('cycle')
|
||||
})
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 22153a298a6662e1
|
||||
zh: 3600803c8c9d0274
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: a1a47b09f4a72ba4
|
||||
zh: 7b1cf27f0b72cf31
|
||||
en: 3ee09d4d3ef08fc0
|
||||
zh: 60d7f05f57dea305
|
||||
/deepseek-harness-desktop/installation-ownership/host-environment:
|
||||
en: 98cf848762482d48
|
||||
zh: 74f9100a39ec8c2f
|
||||
|
||||
@@ -170,11 +170,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
|
||||
|
||||
The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files.
|
||||
|
||||
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart.
|
||||
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, Disable third-party plugins, back up profile patch, and restart, and Get diagnostic report while report writing is pending or after it succeeds. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers Exit, Restart, and Get diagnostic report while report writing is pending or after it succeeds.
|
||||
|
||||
Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines, plus the path of the crash report described below when one was written. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
|
||||
|
||||
Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog without a path. The file `crash-<UTC time>-<source>.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own inspected error (at most 64 KiB) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory.
|
||||
Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog with an explicit pending or unavailable status. Get diagnostic report checks the same write and reveals a saved file in the system file manager without restarting or stopping the Host; a write that completes after the initial deadline remains available. The user attaches that file to a support ticket. If writing fails, the dialog hides Get diagnostic report and suggests providing a screenshot. The file `crash-<UTC time>-<source>.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own error and explicitly selected startup metadata (at most 64 Ki characters, including a truncation notice; startup errors reserve space for metadata by limiting the original error to 8 Ki characters) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory.
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
|
||||
@@ -170,11 +170,11 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏
|
||||
|
||||
[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。
|
||||
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件并备份 profile patch 后重启,以及报告仍在保存或已保存时的获取诊断报告。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,提供退出、重启,以及报告仍在保存或已保存时的获取诊断报告。
|
||||
|
||||
原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,若已写入下述崩溃报告则附上其路径。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
|
||||
|
||||
首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时弹窗不带路径。文件 `crash-<UTC 时间>-<source>.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时自己的 inspect 错误(最多 64 KiB),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。
|
||||
首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件;写入失败时隐藏获取报告按钮,并提示提供窗口截图。文件 `crash-<UTC 时间>-<source>.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本限制为 8 Ki 个字符,为元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@ interface RecoveryOperations {
|
||||
disablePlugins(): Promise<void>
|
||||
exit(): void
|
||||
restart(): void
|
||||
/** Reveal an existing report in the system file manager; reject if it is no longer available. */
|
||||
revealReport(path: string): Promise<void>
|
||||
/** Persist the complete diagnostic; resolves with the file path, or `undefined` when nothing was written. */
|
||||
writeReport(error: unknown, source: CrashReportSource): Promise<string | undefined>
|
||||
}
|
||||
|
||||
type ReportState = { kind: 'pending' } | { kind: 'failed' } | { kind: 'ready'; path: string }
|
||||
|
||||
/** Upper bound on waiting for the crash report before the dialog opens. */
|
||||
export const CRASH_REPORT_WAIT_MS = 1_000
|
||||
|
||||
@@ -31,22 +35,25 @@ const DETAIL_TAIL_LINES = 8
|
||||
* when one was written, then the reinstall advice. The report line does not
|
||||
* depend on shortening: a short error that was persisted names its file too.
|
||||
*/
|
||||
function dialogDetail(error: string, messages: DesktopMessages, reportPath: string | undefined): string {
|
||||
function dialogDetail(error: string, messages: DesktopMessages, reportState: ReportState): string {
|
||||
const advice = `\n\n${messages.startupReinstallAdvice}`
|
||||
const report = reportLine(messages, reportPath)
|
||||
const report = reportLine(messages, reportState)
|
||||
const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-DETAIL_TAIL_LINES).join('\n')
|
||||
const budget = DETAIL_BUDGET - advice.length - report.length - messages.diagnosticTruncated.length - 1
|
||||
const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '')
|
||||
return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${report}${advice}`
|
||||
}
|
||||
|
||||
function reportLine(messages: DesktopMessages, reportPath: string | undefined): string {
|
||||
return reportPath === undefined ? '' : `\n${formatDesktopMessage(messages.reportWrittenTo, { path: reportPath })}`
|
||||
function reportLine(messages: DesktopMessages, state: ReportState): string {
|
||||
if (state.kind === 'pending') return `\n${messages.reportPending}`
|
||||
if (state.kind === 'failed') return `\n${messages.reportUnavailable}`
|
||||
return `\n${formatDesktopMessage(messages.reportWrittenTo, { path: state.path })}`
|
||||
}
|
||||
|
||||
/** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */
|
||||
export class DesktopFatalRecovery {
|
||||
private reported = false
|
||||
private reportState: ReportState = { kind: 'pending' }
|
||||
|
||||
/** @param operations - Native presentation, report persistence, and application-owned shutdown operations. */
|
||||
constructor(private readonly operations: RecoveryOperations) {}
|
||||
@@ -57,7 +64,7 @@ export class DesktopFatalRecovery {
|
||||
/**
|
||||
* Show the first fatal error; later reports cannot replace it or open another dialog.
|
||||
* The crash report is written first, bounded by {@link CRASH_REPORT_WAIT_MS}, so the dialog can name it;
|
||||
* a slow or failed write shows the dialog without a path.
|
||||
* a slow or failed write shows its status. Retrieving a report rechecks the same write.
|
||||
* @param error - Fatal failure, including nested diagnostic causes.
|
||||
* @param source - Where the failure surfaced, recorded in the report.
|
||||
* @returns Completion of the user's recovery action; duplicate reports resolve immediately.
|
||||
@@ -66,25 +73,44 @@ export class DesktopFatalRecovery {
|
||||
if (this.reported) return
|
||||
this.reported = true
|
||||
const messages = this.operations.messages()
|
||||
const reportPath = await this.persist(error, source)
|
||||
const report = this.persist(error, source)
|
||||
await this.waitForReport(report)
|
||||
let detail = desktopErrorState(error).message
|
||||
let message = messages.fatalSummary
|
||||
for (;;) {
|
||||
const addressInUse = /\blisten EADDRINUSE\b/u.test(detail)
|
||||
const buttons = addressInUse
|
||||
? [messages.exitApplication, messages.restartApplication]
|
||||
: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins]
|
||||
const reportButton = this.reportState.kind === 'failed' ? undefined : buttons.length
|
||||
if (reportButton !== undefined) buttons.push(messages.getDiagnosticReport)
|
||||
const { response } = await this.operations.show({
|
||||
type: 'error',
|
||||
title: messages.startupFailed,
|
||||
message,
|
||||
detail: addressInUse
|
||||
? `${messages.startupAddressInUse}${reportLine(messages, reportPath)}`
|
||||
: dialogDetail(detail, messages, reportPath),
|
||||
buttons: addressInUse
|
||||
? [messages.exitApplication, messages.restartApplication]
|
||||
: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
|
||||
? `${messages.startupAddressInUse}${reportLine(messages, this.reportState)}`
|
||||
: dialogDetail(detail, messages, this.reportState),
|
||||
buttons,
|
||||
defaultId: 1,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
})
|
||||
if (response === reportButton) {
|
||||
await this.waitForReport(report)
|
||||
if (this.reportState.kind === 'ready') {
|
||||
try {
|
||||
await this.operations.revealReport(this.reportState.path)
|
||||
message = messages.reportShareHint
|
||||
} catch (failure) {
|
||||
console.error('dsh desktop: could not reveal crash report', failure)
|
||||
message = messages.reportRevealFailed
|
||||
}
|
||||
} else {
|
||||
message = messages.fatalSummary
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (response === 0) {
|
||||
try { await this.operations.stop() } catch (failure) { console.error(failure) }
|
||||
this.operations.exit()
|
||||
@@ -103,16 +129,23 @@ export class DesktopFatalRecovery {
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(error: unknown, source: CrashReportSource): Promise<string | undefined> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
private async persist(error: unknown, source: CrashReportSource): Promise<void> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.operations.writeReport(error, source),
|
||||
new Promise<undefined>((resolve) => { timer = setTimeout(() => { resolve(undefined) }, CRASH_REPORT_WAIT_MS) }),
|
||||
])
|
||||
const path = await this.operations.writeReport(error, source)
|
||||
this.reportState = path === undefined ? { kind: 'failed' } : { kind: 'ready', path }
|
||||
} catch (failure) {
|
||||
console.error('dsh desktop: crash report failed', failure)
|
||||
return undefined
|
||||
this.reportState = { kind: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForReport(report: Promise<void>): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
report,
|
||||
new Promise<void>((resolve) => { timer = setTimeout(resolve, CRASH_REPORT_WAIT_MS) }),
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ export const en = {
|
||||
startupAddressInUse: 'Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.',
|
||||
diagnosticTruncated: '… Error details shortened.',
|
||||
reportWrittenTo: 'Diagnostic report: {path}',
|
||||
getDiagnosticReport: 'Get diagnostic report',
|
||||
reportPending: 'The diagnostic report is still being saved. Choose Get diagnostic report to check again.',
|
||||
reportUnavailable: 'The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support.',
|
||||
reportShareHint: 'The report location has been opened. Attach the selected file to your support ticket.',
|
||||
reportRevealFailed: 'The report location could not be opened. Use the path below to find the file.',
|
||||
startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
|
||||
exitApplication: 'Exit',
|
||||
restartApplication: 'Restart',
|
||||
@@ -232,6 +237,11 @@ export const zh = {
|
||||
startupAddressInUse: '有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。',
|
||||
diagnosticTruncated: '… 错误详情已截短。',
|
||||
reportWrittenTo: '诊断报告:{path}',
|
||||
getDiagnosticReport: '获取诊断报告',
|
||||
reportPending: '诊断报告仍在保存,可点击“获取诊断报告”再次检查',
|
||||
reportUnavailable: '诊断报告未能保存,反馈问题时可提供此窗口的截图',
|
||||
reportShareHint: '已打开报告位置,请将选中的文件作为附件提供给工单',
|
||||
reportRevealFailed: '无法打开报告位置,请通过下方路径查找文件',
|
||||
startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
|
||||
exitApplication: '退出',
|
||||
restartApplication: '重启',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-prod
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { access, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
@@ -104,6 +104,7 @@ const recovery = new DesktopFatalRecovery({
|
||||
exit: () => { quitWithoutConfirmation() },
|
||||
restart: () => { app.relaunch(); quitWithoutConfirmation() },
|
||||
writeReport: (error, source) => persistCrashReport(error, source),
|
||||
revealReport: async (path) => { await access(path); shell.showItemInFolder(path) },
|
||||
})
|
||||
|
||||
function persistCrashReport(error: unknown, source: CrashReportSource): Promise<string | undefined> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
DeepSeek Harness is unavailable
|
||||
The application could not start or stopped unexpectedly.
|
||||
Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.
|
||||
The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support.
|
||||
Exit
|
||||
Restart
|
||||
@@ -1,5 +1,6 @@
|
||||
DeepSeek Harness 无法使用
|
||||
应用无法启动或已意外停止。
|
||||
有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。
|
||||
诊断报告未能保存,反馈问题时可提供此窗口的截图
|
||||
退出
|
||||
重启
|
||||
@@ -2,6 +2,7 @@ DeepSeek Harness is unavailable
|
||||
The application could not start or stopped unexpectedly.
|
||||
Desktop Host failed
|
||||
Plugin initialization failed
|
||||
The diagnostic report could not be saved. You can provide a screenshot of this dialog when requesting support.
|
||||
|
||||
If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
|
||||
Exit
|
||||
|
||||
@@ -8,3 +8,4 @@ If application files are missing or damaged, close the application and reinstall
|
||||
Exit
|
||||
Restart
|
||||
Disable third-party plugins, back up profile patch, and restart
|
||||
Get diagnostic report
|
||||
@@ -8,3 +8,4 @@ Plugin initialization failed
|
||||
退出
|
||||
重启
|
||||
禁用第三方插件、备份 profile patch 并重启
|
||||
获取诊断报告
|
||||
@@ -2,6 +2,7 @@ DeepSeek Harness 无法使用
|
||||
应用无法启动或已意外停止。
|
||||
Desktop Host failed
|
||||
Plugin initialization failed
|
||||
诊断报告未能保存,反馈问题时可提供此窗口的截图
|
||||
|
||||
如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
|
||||
退出
|
||||
|
||||
@@ -17,6 +17,7 @@ function fixture(
|
||||
disablePlugins: vi.fn(async () => {}),
|
||||
exit: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
revealReport: vi.fn(async (_path: string) => {}),
|
||||
writeReport: vi.fn(writeReport),
|
||||
}
|
||||
return { operations, choice, stopped, recovery: new DesktopFatalRecovery(operations) }
|
||||
@@ -31,14 +32,16 @@ const REPORT_PATH = 'C:\\Users\\someone\\AppData\\Roaming\\DeepSeek Harness\\log
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict in %s', async (locale) => {
|
||||
it.each(['en', 'zh-CN'])('offers exit and restart for a listener conflict without a saved report in %s', async (locale) => {
|
||||
const { operations, choice, stopped, recovery } = fixture(locale)
|
||||
const pending = recovery.report(new AggregateError([
|
||||
new Error('webserver (@deepseek-ai/dsh-host-webserver): Error: listen EADDRINUSE: address already in use 127.0.0.1:19387'),
|
||||
], 'required startup failure'), 'host')
|
||||
await shown(operations)
|
||||
const options = operations.show.mock.calls[0]![0]
|
||||
expect(options.buttons).toEqual([operations.messages().exitApplication, operations.messages().restartApplication])
|
||||
expect(options.buttons).toEqual([
|
||||
operations.messages().exitApplication, operations.messages().restartApplication,
|
||||
])
|
||||
await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n')
|
||||
.toMatchFileSnapshot(`expected/fatal-address-in-use-${locale}.txt`)
|
||||
choice.resolve({ response: 1, checkboxChecked: false })
|
||||
@@ -56,7 +59,7 @@ it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflic
|
||||
expect(operations.show.mock.calls[0]![0].buttons).toEqual([
|
||||
operations.messages().exitApplication, operations.messages().restartApplication,
|
||||
])
|
||||
expect(operations.show.mock.calls[0]![0].detail).toBe(operations.messages().startupAddressInUse)
|
||||
expect(operations.show.mock.calls[0]![0].detail).toBe(`${operations.messages().startupAddressInUse}\n${operations.messages().reportUnavailable}`)
|
||||
choice.resolve({ response: 0, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
await pending
|
||||
@@ -188,7 +191,7 @@ it('names the report file in the listener-conflict dialog too', async () => {
|
||||
await shown(operations)
|
||||
await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() })
|
||||
const options = operations.show.mock.calls[0]![0]
|
||||
expect(options.buttons).toHaveLength(2)
|
||||
expect(options.buttons).toHaveLength(3)
|
||||
expect(options.detail).toBe(`${operations.messages().startupAddressInUse}\n${operations.messages().reportWrittenTo.replace('{path}', REPORT_PATH)}`)
|
||||
choice.resolve({ response: 0, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
@@ -202,7 +205,7 @@ it('shows the dialog without a path when the report write fails or returns nothi
|
||||
const pending = recovery.report(new Error('fatal'), 'main')
|
||||
await shown(operations)
|
||||
await vi.waitFor(() => { expect(operations.show).toHaveBeenCalledOnce() })
|
||||
expect(operations.show.mock.calls[0]![0].detail).toBe(`fatal\n\n${operations.messages().startupReinstallAdvice}`)
|
||||
expect(operations.show.mock.calls[0]![0].detail).toBe(`fatal\n${operations.messages().reportUnavailable}\n\n${operations.messages().startupReinstallAdvice}`)
|
||||
choice.resolve({ response: 0, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
await pending
|
||||
@@ -239,3 +242,61 @@ it('writes the report once for the first fatal failure and not for the recovery-
|
||||
expect(operations.writeReport).toHaveBeenCalledOnce()
|
||||
expect(operations.show.mock.calls[1]![0].detail).toContain(REPORT_PATH)
|
||||
})
|
||||
|
||||
it('reveals the saved report without stopping the Host or restarting', async () => {
|
||||
const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH)
|
||||
operations.show.mockResolvedValueOnce({ response: 3, checkboxChecked: false })
|
||||
.mockImplementationOnce(async () => {
|
||||
expect(operations.revealReport).toHaveBeenCalledWith(REPORT_PATH)
|
||||
expect(operations.stop).not.toHaveBeenCalled()
|
||||
expect(operations.restart).not.toHaveBeenCalled()
|
||||
return { response: 0, checkboxChecked: false }
|
||||
})
|
||||
stopped.resolve(undefined)
|
||||
await recovery.report(new Error('fatal'), 'host')
|
||||
expect(operations.writeReport).toHaveBeenCalledOnce()
|
||||
expect(operations.show.mock.calls[1]![0].message).toBe(operations.messages().reportShareHint)
|
||||
})
|
||||
|
||||
it('keeps the original failure and report path when revealing the file fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH)
|
||||
operations.revealReport.mockRejectedValueOnce(new Error('file removed'))
|
||||
operations.show.mockResolvedValueOnce({ response: 3, checkboxChecked: false })
|
||||
.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
await recovery.report(new Error('original startup failure'), 'host')
|
||||
expect(operations.show.mock.calls[1]![0]).toMatchObject({ message: operations.messages().reportRevealFailed })
|
||||
expect(operations.show.mock.calls[1]![0].detail).toContain('original startup failure')
|
||||
expect(operations.show.mock.calls[1]![0].detail).toContain(REPORT_PATH)
|
||||
})
|
||||
|
||||
it('makes a report that finishes after the initial deadline available without rewriting it', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const write = Promise.withResolvers<string>()
|
||||
const { operations, choice, stopped, recovery } = fixture('en', () => write.promise)
|
||||
const pending = recovery.report(new Error('fatal'), 'host')
|
||||
await vi.advanceTimersByTimeAsync(CRASH_REPORT_WAIT_MS)
|
||||
expect(operations.show.mock.calls[0]![0].detail).toContain(operations.messages().reportPending)
|
||||
operations.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
write.resolve(REPORT_PATH)
|
||||
choice.resolve({ response: 3, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
await pending
|
||||
expect(operations.revealReport).toHaveBeenCalledWith(REPORT_PATH)
|
||||
expect(operations.writeReport).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not attempt to reveal an unavailable report', async () => {
|
||||
const { operations, stopped, recovery } = fixture()
|
||||
operations.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
stopped.resolve(undefined)
|
||||
await recovery.report(new Error('fatal'), 'host')
|
||||
expect(operations.revealReport).not.toHaveBeenCalled()
|
||||
expect(operations.show.mock.calls[0]![0].buttons).not.toContain(operations.messages().getDiagnosticReport)
|
||||
expect(operations.show.mock.calls[0]![0].detail).toContain(operations.messages().reportUnavailable)
|
||||
})
|
||||
@@ -2068,7 +2068,7 @@ describe('desktop main startup', () => {
|
||||
harness.prepared.reject(new Error('runtime resources missing'))
|
||||
await harness.dialogShown.promise
|
||||
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('runtime resources missing')
|
||||
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
|
||||
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart', 'Get diagnostic report'])
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
en: f4a2695cb4c8a570
|
||||
zh: 89837235d2f376cf
|
||||
/deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes:
|
||||
en: 9ecb744556705fd1
|
||||
zh: d83aaf2ebb91d0fc
|
||||
en: b198a3847ca42079
|
||||
zh: c3f17e6837f751ff
|
||||
/deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior:
|
||||
en: 6fa850d1bb000d0e
|
||||
zh: aa83a40a9d3db295
|
||||
|
||||
@@ -143,7 +143,9 @@ This section explains how the outcomes above are realized and points at the code
|
||||
- **One rejection checkpoint.** `inactiveEntries` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
|
||||
- **Two-stage failure labels.** Outside startup audit failures, `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
|
||||
|
||||
The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its `entries` and `startup` fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no `cause`; failures with recorded errors retain their original values in an `AggregateError`. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.
|
||||
The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its `entries`, `startup`, and `composition` fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no `cause`; failures with recorded errors retain their original values in an `AggregateError`. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.
|
||||
|
||||
Before disposal, `composition` captures up to 256 configured plugin rows and 256 missing-service observations, with omission counts. Rows include module, available package version, state and Loader source URL; configuration values are excluded. Registered providers include their state and whether their scope matches the consumer. An empty provider list means ownership is unknown: disabled or failed plugins may never have registered. The Loader source does not identify which overlay last changed a row. Metadata lookup failures leave the version unknown; snapshot failure preserves the original startup error.
|
||||
|
||||
### Helper behavior
|
||||
|
||||
|
||||
@@ -143,7 +143,9 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
|
||||
- **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
|
||||
- **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
|
||||
|
||||
启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。其 `entries` 和 `startup` 字段不可枚举:直接访问和完整诊断报告保留这些字段,常规错误检查输出则省略它们。只有等待条目时没有 `cause`;存在已记录错误时,`AggregateError` 保留其原始值。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。
|
||||
启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。其 `entries`、`startup` 和 `composition` 字段不可枚举:直接访问和完整诊断报告保留这些字段,常规错误检查输出则省略它们。只有等待条目时没有 `cause`;存在已记录错误时,`AggregateError` 保留其原始值。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。
|
||||
|
||||
释放前,`composition` 捕获最多 256 个配置插件条目和 256 个缺失服务观测,并记录省略数量。条目包含模块、可取得的包版本、状态和 Loader 来源 URL,不含配置值。已注册提供者包含其状态及作用域是否与消费者一致。提供者列表为空表示归属未知:被禁用或失败的插件可能从未注册服务。Loader 来源不代表最后修改该条目的 overlay。元数据查询失败时版本标为未知;快照失败保留原始启动错误。
|
||||
|
||||
### Helper 行为
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { inspect, parseEnv } from 'node:util'
|
||||
import { captureStartupComposition, type StartupCompositionDiagnostic } from './startup-diagnostics.ts'
|
||||
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis'
|
||||
@@ -1005,6 +1006,8 @@ interface StartupLogRecord {
|
||||
|
||||
/** Startup audit failure with non-enumerable metadata and original failures as its cause. */
|
||||
export class StartupError extends Error {
|
||||
/** Plugin states captured before disposal, excluding configuration values. */
|
||||
composition?: StartupCompositionDiagnostic
|
||||
/** Root configuration and startup logs, attached by boot after disposal. */
|
||||
startup?: { configurationPath: string; messages: readonly StartupLogRecord[] }
|
||||
|
||||
@@ -1018,6 +1021,7 @@ export class StartupError extends Error {
|
||||
Object.defineProperties(this, {
|
||||
entries: { enumerable: false },
|
||||
startup: { enumerable: false },
|
||||
composition: { enumerable: false },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1240,6 +1244,11 @@ export async function boot(
|
||||
await auditStartupEntries(ctx, binName)
|
||||
return ctx
|
||||
} catch (cause) {
|
||||
if (cause instanceof StartupError) {
|
||||
try { cause.composition = captureStartupComposition(ctx) } catch (error) {
|
||||
ctx.logger.warn('Startup composition diagnostic unavailable', error)
|
||||
}
|
||||
}
|
||||
// Root-fiber disposal contains cleanup failures per observer (Cordis
|
||||
// fiber.ts hardening) and a repeated call returns the settled single-shot
|
||||
// result, so this await cannot reject and replace `cause`.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Configuration-free snapshots captured before a failed startup disposes its plugins. */
|
||||
|
||||
import { Context, type FiberState } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from './profile-resolution/service.ts'
|
||||
|
||||
/** One configured entry at the time startup failed. */
|
||||
export interface StartupPluginState {
|
||||
readonly id: string
|
||||
readonly module: string
|
||||
readonly version: string
|
||||
readonly state: 'disabled' | 'active' | 'pending' | 'failed' | 'disposed' | 'loading' | 'unloading'
|
||||
/** Loader source URL; individual overlay ownership may be unavailable. */
|
||||
readonly source: string
|
||||
readonly missing: readonly string[]
|
||||
}
|
||||
|
||||
/** Observed registrations for one service missing from a consumer. */
|
||||
export interface StartupServiceState {
|
||||
readonly consumer: string
|
||||
readonly service: string
|
||||
/** Empty means no registration was observed, not that no configured plugin can provide it. */
|
||||
readonly providers: readonly {
|
||||
readonly entry: string
|
||||
readonly state: string
|
||||
readonly visibility: 'same-scope' | 'other-scope'
|
||||
}[]
|
||||
}
|
||||
|
||||
/** Bounded startup state without plugin config, service values, or logger arguments. */
|
||||
export interface StartupCompositionDiagnostic {
|
||||
readonly plugins: readonly StartupPluginState[]
|
||||
readonly services: readonly StartupServiceState[]
|
||||
readonly omittedPlugins: number
|
||||
readonly omittedServices: number
|
||||
}
|
||||
|
||||
function stateName(state: FiberState): StartupPluginState['state'] {
|
||||
const names: Record<FiberState, StartupPluginState['state']> = {
|
||||
0: 'pending', 1: 'loading', 2: 'active', 3: 'failed', 4: 'disposed', 5: 'unloading',
|
||||
}
|
||||
return names[state]
|
||||
}
|
||||
|
||||
function pluginState(ctx: Context, entry: Entry): StartupPluginState {
|
||||
let state: StartupPluginState['state']
|
||||
try {
|
||||
state = entry.disabled ? 'disabled' : entry.fiber === undefined ? 'failed' : stateName(entry.fiber.state)
|
||||
} catch (_error) {
|
||||
// The activation diagnostic owns the disabled-expression error.
|
||||
state = 'failed'
|
||||
}
|
||||
let version = 'unknown'
|
||||
const source = entry.parent.tree.ctx.baseUrl
|
||||
try {
|
||||
if (source !== undefined) version = ctx.get('pluginPackages')?.packageOf(entry.options.name, source)?.version ?? 'unknown'
|
||||
} catch (_error) {
|
||||
// Missing or damaged metadata must not replace the startup failure.
|
||||
}
|
||||
const fiber = entry.fiber
|
||||
return {
|
||||
id: entry.id, module: entry.options.name, version, state, source: source ?? 'unknown',
|
||||
missing: state === 'pending' && fiber !== undefined
|
||||
? Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture Loader state and registered service ownership before teardown.
|
||||
* Providers that never registered cannot be inferred from their package names.
|
||||
* @param ctx - Live application context with its Loader still mounted.
|
||||
* @returns At most 256 plugin rows and 256 missing-service observations, with omission counts.
|
||||
*/
|
||||
export function captureStartupComposition(ctx: Context): StartupCompositionDiagnostic {
|
||||
const entries = [...ctx.loader.entries()]
|
||||
const rows = entries.map(entry => ({ entry, state: pluginState(ctx, entry) }))
|
||||
// Inactive rows precede healthy rows so bounded reports retain the failure context.
|
||||
rows.sort((a, b) => Number(a.state.state === 'active') - Number(b.state.state === 'active'))
|
||||
const services: StartupServiceState[] = []
|
||||
let omittedServices = 0
|
||||
const store = ctx.reflect.store
|
||||
for (const { entry, state } of rows) {
|
||||
const fiber = entry.fiber
|
||||
if (fiber === undefined) continue
|
||||
for (const service of state.missing) {
|
||||
if (services.length === 256) { omittedServices++; continue }
|
||||
const scope = fiber.ctx[Context.isolate][service]
|
||||
const providers: StartupServiceState['providers'][number][] = []
|
||||
for (const key of Object.getOwnPropertySymbols(store)) {
|
||||
const impl = store[key]
|
||||
if (impl?.name !== service) continue
|
||||
providers.push({
|
||||
entry: impl.fiber.entry?.id ?? 'unknown', state: stateName(impl.fiber.state),
|
||||
visibility: key === scope ? 'same-scope' : 'other-scope',
|
||||
})
|
||||
}
|
||||
services.push({ consumer: state.id, service, providers })
|
||||
}
|
||||
}
|
||||
return {
|
||||
plugins: rows.slice(0, 256).map(row => row.state), services,
|
||||
omittedPlugins: Math.max(0, rows.length - 256), omittedServices,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { boot, StartupError } from '../src/index.ts'
|
||||
import { captureStartupComposition } from '../src/startup-diagnostics.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function configuration(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostic-'))
|
||||
roots.push(root)
|
||||
await writeFile(join(root, 'consumer.mjs'), 'export const inject = ["agents"]; export function apply() {}\n')
|
||||
await writeFile(join(root, 'provider.mjs'), 'export function apply(ctx) { ctx.provide("agents", {}) }\n')
|
||||
await writeFile(join(root, 'cordis.yml'), [
|
||||
'- id: agent', ' name: ./provider.mjs', ' disabled: true',
|
||||
' config:', ' privateValue: do-not-export-this',
|
||||
'- id: agent-loop', ' name: ./consumer.mjs', '',
|
||||
].join('\n'))
|
||||
return join(root, 'cordis.yml')
|
||||
}
|
||||
|
||||
it('retains disabled and waiting entries after the real Loader has been disposed', async () => {
|
||||
const error: unknown = await boot('diagnostic-test', await configuration()).catch((failure: unknown) => failure)
|
||||
expect(error).toBeInstanceOf(StartupError)
|
||||
if (!(error instanceof StartupError)) throw error
|
||||
expect(error.composition?.plugins).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ module: './provider.mjs', state: 'disabled', version: 'unknown' }),
|
||||
expect.objectContaining({ module: './consumer.mjs', state: 'pending', missing: ['agents'] }),
|
||||
]))
|
||||
expect(error.composition?.services).toEqual([
|
||||
expect.objectContaining({ service: 'agents', providers: [] }),
|
||||
])
|
||||
expect(JSON.stringify(error.composition)).not.toContain('do-not-export-this')
|
||||
})
|
||||
|
||||
it('preserves the startup failure when the Loader is already unavailable to diagnostic capture', async () => {
|
||||
const failure = new StartupError('startup was interrupted', [])
|
||||
const result: unknown = await boot('diagnostic-test', await configuration(), undefined, async (ctx) => {
|
||||
await ctx.fiber.dispose()
|
||||
throw failure
|
||||
}).catch((error: unknown) => error)
|
||||
expect(result).toBe(failure)
|
||||
expect(failure.composition).toBeUndefined()
|
||||
expect(failure.startup?.messages.some(message => message.args[0] === 'Startup composition diagnostic unavailable')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies a registered provider that is isolated from the waiting consumer', async () => {
|
||||
const error: unknown = await boot('diagnostic-test', await configuration(), undefined, async (ctx) => {
|
||||
await ctx.isolate('agents').plugin({
|
||||
name: 'isolated-agent-provider',
|
||||
apply(provider) { provider.reflect.provide('agents', {}) },
|
||||
})
|
||||
}).catch((failure: unknown) => failure)
|
||||
expect(error).toBeInstanceOf(StartupError)
|
||||
if (!(error instanceof StartupError)) throw error
|
||||
expect(error.composition?.services).toEqual([
|
||||
expect.objectContaining({ service: 'agents', providers: [{ entry: 'unknown', state: 'active', visibility: 'other-scope' }] }),
|
||||
])
|
||||
})
|
||||
|
||||
it('reports omitted plugin and missing-service counts instead of silently dropping rows', async () => {
|
||||
const config = await configuration()
|
||||
const root = roots.at(-1)!
|
||||
const services = Array.from({ length: 257 }, (_, index) => `missing${index}`)
|
||||
await writeFile(join(root, 'consumer.mjs'), `export const inject = ${JSON.stringify(services)}; export function apply() {}\n`)
|
||||
await writeFile(config, [
|
||||
'- id: agent-loop\n name: ./consumer.mjs',
|
||||
...Array.from({ length: 257 }, (_, index) => `- id: disabled-${index}\n name: ./provider.mjs\n disabled: true`), '',
|
||||
].join('\n'))
|
||||
const error: unknown = await boot('diagnostic-test', config).catch((failure: unknown) => failure)
|
||||
if (!(error instanceof StartupError)) throw error
|
||||
expect(error.composition?.plugins).toHaveLength(256)
|
||||
expect(error.composition?.omittedPlugins).toBeGreaterThanOrEqual(2)
|
||||
expect(error.composition?.services).toHaveLength(256)
|
||||
expect(error.composition?.omittedServices).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a registered same-scope provider whose value is unavailable', async () => {
|
||||
const config = await configuration()
|
||||
await writeFile(join(roots.at(-1)!, 'provider.mjs'), 'export function apply(ctx) { ctx.provide("agents", undefined) }\n')
|
||||
await writeFile(join(roots.at(-1)!, 'consumer.mjs'), 'export const inject = ["agents", "unprovided"]; export function apply() {}\n')
|
||||
await writeFile(config, '- id: agent\n name: ./provider.mjs\n- id: agent-loop\n name: ./consumer.mjs\n')
|
||||
const error: unknown = await boot('diagnostic-test', config)
|
||||
.catch((failure: unknown) => failure)
|
||||
if (!(error instanceof StartupError)) throw error
|
||||
expect(error.composition?.services[0]?.providers).toMatchObject([
|
||||
{ state: 'active', visibility: 'same-scope' },
|
||||
])
|
||||
expect(error.composition?.services[0]?.providers[0]?.entry).toContain('agent')
|
||||
})
|
||||
|
||||
it('retains the Loader disablement after disposal and reports missing source metadata', async () => {
|
||||
const config = await configuration()
|
||||
await writeFile(config, '- id: agent\n name: ./provider.mjs\n')
|
||||
const ctx = await boot('diagnostic-test', config)
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(row => row.options.id === 'agent')!
|
||||
await entry.fiber!.dispose()
|
||||
Object.defineProperty(entry.parent.tree.ctx, 'baseUrl', { value: undefined, configurable: true })
|
||||
const snapshot = captureStartupComposition(ctx)
|
||||
expect(snapshot.plugins).toContainEqual(expect.objectContaining({ module: './provider.mjs', state: 'disabled', source: 'unknown' }))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user