Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/desktop-rollout-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ The installation ID is a random UUID at `<userData>/desktop-service/installation

Harness launch attempts report startup-failure before readiness or harness-crash after readiness. Reporting awaits the actual log-stream write callback. Renderer/GPU loss is persisted synchronously before recovery can exit the app. Main JavaScript fatal exceptions use uncaughtExceptionMonitor and preserve the default crash behavior. A queued fatal event and the next unclean-start marker reuse the same event ID for deduplication.

Normal will-quit and intentional GPU relaunch clear the session marker. Native crashes, power loss or forced termination are reported as **unclean-exit on next launch**, not as a proven crash; they cannot reliably send HTTP from a dead process. If the app never reaches initialization again, there is no guarantee of delivery. Initialization/reporting failure does not prevent application startup.
Normal will-quit, intentional GPU relaunch, and in-app update install clear the session marker. Native crashes, power loss or forced termination are reported as **unclean-exit on next launch** only when the leftover marker version matches the current app version and the previous session log does not look healthy; they are not a proven crash and cannot reliably send HTTP from a dead process. A leftover marker from a different version is treated as an upgrade or overwrite install and is not reported. If the app never reaches initialization again, there is no guarantee of delivery. Initialization/reporting failure does not prevent application startup.

This does not yet track installer/download errors or correlate upgrade attempts with installation success. It also does not change signing or update-package verification settings. Real packaged macOS/Windows crash and upgrade acceptance remains separate from unit/build/CI validation.
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,16 @@
"!**/*.map",
"!**/*.d.ts",
"!**/*.tsbuildinfo",
"!**/*.md",
"!**/*.markdown",
"!**/README*.md",
"!**/readme*.md",
"!**/CHANGELOG*.md",
"!**/changelog*.md",
"!**/CONTRIBUTING*.md",
"!**/contributing*.md",
"!**/SECURITY*.md",
"!**/security*.md",
"!**/LICENSE*.md",
"!**/license*.md",
"!node_modules/typescript/**",
"!**/node_modules/**/test/**",
"!**/node_modules/**/tests/**",
Expand Down
Binary file modified packages/ppt-bundles/dsh-ppt-0.1.1-rc.2-desktop-20260906.tgz
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/ppt-runtime/artifacts.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"core": {
"file": "dsh-ppt-0.1.1-rc.2-desktop-20260906.tgz",
"sha256": "5d1c1451792474389f55f09db2e1bdfa70cf430384d21ec3ccd55eaea0d8c8ff",
"integrity": "sha512-SyZs2qWMNRY+XTHmz02cjgQzFKWG73escGM4frfgKhuJlb0/ErjiBoi6YcQOIlw4R4/M1qTVkd8vdNxWvDilQw=="
"sha256": "2f493c6aa0dbba384ee57dc875c8647fd45b7b1b79b1ecbbac3994d578f87221",
"integrity": "sha512-xFhBoyKRhgRudBtEVyxSbZvFVVkUGvOcEWVm1cjAjDR/vPaXgtA4h32uH3tI9oLDl8VefPTpdEui089B1XIVPQ=="
},
"adapter": {
"file": "dsh-ppt-composer-0.1.1-rc.2-desktop-20260906.tgz",
Expand Down
71 changes: 64 additions & 7 deletions packages/ppt-runtime/core/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3607,6 +3607,62 @@ const Config = z.object({
pptSkillRoot: z.string()
});
/** Compose storage, browser RPC, and bounded model tools. */
function registerPptRpcRoute(webCtx, channel, rpcHandler) {
webCtx.effect(() => webCtx.webServer.register({
kind: "prefix",
path: channel,
handler: async (req, res) => {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.writeHead(405);
res.end();
return;
}
const connection = webCtx.get("connection");
const rejection = connection?.requestRejection?.(req);
if (rejection !== void 0) {
res.writeHead(rejection);
res.end(rejection === 401 ? "unauthorized" : "forbidden");
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
res.writeHead(400);
res.end("body is not JSON");
return;
}
const rawPath = new URL(req.url ?? "/", "http://localhost").pathname;
const endpoint = rawPath.startsWith(`${channel}/`) ? rawPath.slice(channel.length + 1) : "";
try {
const result = await rpcHandler(endpoint, body?.payload);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
type: "server-response",
rpcId: body?.rpcId,
result
}));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({
type: "server-response",
rpcId: body?.rpcId,
result: {
ok: false,
error: {
code: "internal",
message: err instanceof Error ? err.message : String(err)
}
}
}));
}
}
}), `dsh-ppt: ${channel} rpc route`);
}

async function apply(ctx, config) {
const bundledPptSkillRoot = fileURLToPath(new URL("../skills/dsh-ppt", import.meta.url));
registerPreviewAssets(ctx, previewFiles, path.join(bundledPptSkillRoot, "references"));
Expand All @@ -3615,17 +3671,18 @@ async function apply(ctx, config) {
maxDecksPerSession: config.maxDecksPerSession ?? 50,
maxActivities: config.maxActivities ?? 200
}), { maxSlides: config.maxSlides ?? 40 });
// Harness 0.1.5 registers an RPC channel as a webServer route owned by the
// Context that read `connection`, and that Context must itself declare
// `webServer`. Registering from a scoped inject Context is upstream's own
// pattern; reading `ctx.connection` directly throws
// `cannot get property "webServer" without inject` and fails the whole tree.
const rpcHandler = pptRpc(service);
ctx.inject(["webServer"], (webCtx) => {
webCtx.connection.rpc.handle("/dsh-ppt", pptRpc(service), { authority: "trusted-host" });
registerPptRpcRoute(webCtx, "/dsh-ppt", rpcHandler);
// Older loaded clients can finish their in-flight requests after upgrade.
webCtx.connection.rpc.handle("/kimi-ppt", pptRpc(service), { authority: "trusted-host" });
registerPptRpcRoute(webCtx, "/kimi-ppt", rpcHandler);
try {
webCtx.connection?.rpc?.handle?.("/dsh-ppt", rpcHandler, { authority: "trusted-host" });
webCtx.connection?.rpc?.handle?.("/kimi-ppt", rpcHandler, { authority: "trusted-host" });
} catch {}
});
registerPptTools(ctx, service);
}
//#endregion
export { Config, apply, inject, name };

21 changes: 17 additions & 4 deletions src/main/desktop-service/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
}
let canSend = false
let pendingPluginFailureEventId: string | undefined
let suppressPluginFailure = false
const capture = (kind: FailureKind, message: string, eventId?: string) => {
safe(() => service.capture(kind, message, eventId))
if (canSend) flush()
if (canSend && eventId !== pendingPluginFailureEventId) flush()
}
safe(() => service.beginSession())
const fatal = (error: Error) => safe(() => service.captureFatal(error))
Expand Down Expand Up @@ -60,10 +61,18 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
flush()
},
runtimeChanged(snapshot: RuntimeSnapshot, flushLog: () => Promise<void>, attempt = 0) {
if (attempt !== previousAttempt) wasReady = false
if (snapshot.phase === 'starting') wasReady = false
if (attempt !== previousAttempt) {
wasReady = false
suppressPluginFailure = false
pendingPluginFailureEventId = undefined
}
if (snapshot.phase === 'starting') {
wasReady = false
suppressPluginFailure = false
}
if (snapshot.phase === 'ready') {
wasReady = true
suppressPluginFailure = false
if (pendingPluginFailureEventId) {
service.discard(pendingPluginFailureEventId)
pendingPluginFailureEventId = undefined
Expand All @@ -73,8 +82,11 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
const kind = wasReady ? 'harness-crash' : 'startup-failure'
// Wait for the actual Harness stream callback, not a guessed timer.
void flushLog().catch(error => options.onError?.(error)).then(() => {
const eventId = randomId()
const isPluginFailure = Boolean(snapshot.pluginFailures && snapshot.pluginFailures.length > 0)
if (!wasReady && isPluginFailure && suppressPluginFailure) {
return
}
const eventId = randomId()
if (!wasReady && isPluginFailure) {
pendingPluginFailureEventId = eventId
}
Expand All @@ -85,6 +97,7 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
previousAttempt = attempt
},
discardPendingPluginFailure() {
suppressPluginFailure = true
if (pendingPluginFailureEventId) {
service.discard(pendingPluginFailureEventId)
pendingPluginFailureEventId = undefined
Expand Down
2 changes: 1 addition & 1 deletion src/main/desktop-service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class DesktopService {
if (existsSync(this.marker)) {
try {
const old = JSON.parse(readFileSync(this.marker, 'utf8')) as { eventId: string; version: string }
if (uuid.test(old.eventId) && isVersion(old.version)) {
if (uuid.test(old.eventId) && isVersion(old.version) && old.version === this.options.version) {
const log = tailLog(this.options.logPath)
if (!isHealthySessionLog(log.lines)) {
this.capture('unclean-exit', 'Previous session ended without a clean shutdown (crash, power loss or forced termination).', old.eventId, old.version)
Expand Down
4 changes: 4 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2965,6 +2965,7 @@ async function bootstrap(): Promise<void> {
ipcMain.removeHandler('harness:open-recovery')
ipcMain.handle('harness:open-recovery', async (event, frontendErrorMessage?: unknown) => {
assertTrustedMainWindowEvent(event)
desktopDiagnostics?.discardPendingPluginFailure()
const message = typeof frontendErrorMessage === 'string' ? frontendErrorMessage : undefined
if (message) appendRendererPluginFailureLog(message)
const logs = [...rendererPluginFailureLogs]
Expand Down Expand Up @@ -3128,6 +3129,9 @@ async function bootstrap(): Promise<void> {
const dshHome = join(app.getPath('userData'), 'harness')
await quarantineInstalledLaunchAgentsForUpdate(dshHome)
quitting = true
// NSIS may force-kill before will-quit; clear the marker so the next
// launch does not treat this intentional update as an unclean-exit.
desktopDiagnostics?.markCleanExit()
stopUpdateManager()
}
})
Expand Down
56 changes: 53 additions & 3 deletions test/desktop-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,29 @@ describe('desktop service', () => {
request.mockImplementation(async (_url: string, init?: RequestInit) => new Response(JSON.stringify({ accepted: true, eventId: JSON.parse(String(init?.body)).eventId })))
await Promise.all([service.flush(), service.flush()]); expect(request).toHaveBeenCalledTimes(50); expect(service.pending()).toHaveLength(0)
})
it('attributes an unclean exit to the previous version and does not report clean shutdowns', () => {
it('reports an unclean exit only when the leftover marker belongs to this version', () => {
const { service, options, dir } = fixture()
service.beginSession()
const next = new DesktopService({ ...options, version: '0.9.0' }); next.beginSession()
expect(queued(next, dir)[0]).toMatchObject({ version: '0.8.0', kind: 'unclean-exit' })
next.markCleanExit(); new DesktopService(options).beginSession(); expect(next.pending()).toHaveLength(1)
expect(queued(next, dir)).toEqual([])
next.markCleanExit(); new DesktopService(options).beginSession(); expect(next.pending()).toHaveLength(0)
const same = new DesktopService(options)
same.beginSession()
expect(queued(same, dir)[0]).toMatchObject({ version: '0.8.0', kind: 'unclean-exit' })
same.markCleanExit(); new DesktopService(options).beginSession(); expect(same.pending()).toHaveLength(1)
})
it('does not replace a queued fatal report with a generic unclean-exit report', () => {
const { service, options, dir } = fixture()
service.beginSession(); service.captureFatal(new Error('fatal'))
new DesktopService(options).beginSession()
expect(queued(service, dir).map(r => r.kind)).toEqual(['main-crash'])
})
it('keeps a queued fatal report when a later version starts after an upgrade', () => {
const { service, options, dir } = fixture()
service.beginSession(); service.captureFatal(new Error('fatal'))
new DesktopService({ ...options, version: '0.9.0' }).beginSession()
expect(queued(service, dir).map(r => r.kind)).toEqual(['main-crash'])
})
it('queries with no channel/arch and rejects invalid or downgraded policy responses', async () => {
const { service, request } = fixture()
request.mockResolvedValue(new Response(JSON.stringify({ updateAvailable: false })))
Expand Down Expand Up @@ -205,6 +215,46 @@ describe('diagnostic event integration', () => {
diagnostics.runtimeChanged({ phase: 'ready', message: 'ready', logs: [] }, async () => {})
expect(service.pending()).toHaveLength(0)
})
it('discards pending plugin failure when frontend opens recovery, even with active sending', async () => {
const { service, request } = fixture()
request.mockResolvedValue(new Response(JSON.stringify({ accepted: true })))
const app = new EventEmitter()
const diagnostics = attachDiagnostics(app, service); disposers.push(() => diagnostics.dispose())
diagnostics.startSending()
const failedSnapshot = {
phase: 'failed' as const,
message: 'plugin error',
logs: [],
pluginFailures: [{ stage: 'import' as const, packageName: 'test-plugin', message: 'failed', chain: [] }]
}
diagnostics.runtimeChanged(failedSnapshot, async () => {})
await vi.waitFor(() => expect(service.pending()).toHaveLength(1))
expect(request).not.toHaveBeenCalled()
// Frontend identifies the incompatible plugin and invokes discard
diagnostics.discardPendingPluginFailure()
expect(service.pending()).toHaveLength(0)
expect(request).not.toHaveBeenCalled()
})
it('suppresses plugin failure report when discardPendingPluginFailure is called before log flush completes', async () => {
const { service, request } = fixture()
const app = new EventEmitter()
const diagnostics = attachDiagnostics(app, service); disposers.push(() => diagnostics.dispose())
diagnostics.startSending()
let finishLog!: () => void
const logPromise = new Promise<void>(resolve => { finishLog = resolve })
const failedSnapshot = {
phase: 'failed' as const,
message: 'plugin error',
logs: [],
pluginFailures: [{ stage: 'import' as const, packageName: 'test-plugin', message: 'failed', chain: [] }]
}
diagnostics.runtimeChanged(failedSnapshot, () => logPromise)
diagnostics.discardPendingPluginFailure()
finishLog()
await new Promise(resolve => setTimeout(resolve, 50))
expect(service.pending()).toHaveLength(0)
expect(request).not.toHaveBeenCalled()
})
})

it('waits for the actual Harness file stream before capturing its final error', async () => {
Expand Down
8 changes: 8 additions & 0 deletions test/plugin-error-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ describe('preload wiring for plugin error handling', () => {
expect(preload).toContain('findBootFailureText(document)')
expect(preload).not.toContain('document.body?.innerText')
})

it('discards pending diagnostics plugin failure when recovery is opened', async () => {
const main = await readFile('src/main/index.ts', 'utf8')
const recoveryHandler = main.slice(main.indexOf("ipcMain.handle('harness:open-recovery'"))
const handlerBody = recoveryHandler.slice(0, recoveryHandler.indexOf('})'))

expect(handlerBody).toContain('desktopDiagnostics?.discardPendingPluginFailure()')
})
})

describe('boot failure page detection', () => {
Expand Down
6 changes: 6 additions & 0 deletions test/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ describe('desktop update policy', () => {
expect(prepare.indexOf('await quarantineInstalledLaunchAgentsForUpdate(dshHome)')).toBeLessThan(
prepare.indexOf('quitting = true')
)
expect(prepare.indexOf('quitting = true')).toBeLessThan(
prepare.indexOf('desktopDiagnostics?.markCleanExit()')
)
expect(prepare.indexOf('desktopDiagnostics?.markCleanExit()')).toBeLessThan(
prepare.indexOf('stopUpdateManager()')
)
})
})

Expand Down
Loading