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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,13 @@ keys; components/; lib/ is pure and tested). One responsibility per file; aliase
## Build, test, release

- `npm run dev`, `npm test` (vitest), `npm run typecheck`, `npm run lint`.
- **THE DIAGNOSTICS LOG** (#140; owner, 2026-10-07: "robust logging and debugging ... especially to
catch stalls"). Both apps write `<userData>\logs\diag.jsonl` (PT `%APPDATA%\PrismTerminal\logs`,
the stable copy `%APPDATA%\PrismTerminalStable\logs`, Prism `%APPDATA%\Prism\logs`): stalls with
the scripts and the page stack, slow IPC, errors, crumbs. When the owner says something stalled or
failed, READ IT FIRST: `npm run diag -- --app stable --since 10m` (a `mark` line is their Mark
button). Schema, kinds and how to read them: [`docs/diagnostics.md`](docs/diagnostics.md). Local
only, never sent (`PRIVACY.md`); typed text, the clipboard and audio are never written.
- **A test run leaves nothing in %TEMP%** (2026-09-28): `vitest.global.ts` points the whole run's
TEMP at one folder and removes it afterwards (14 `pt-*` folders a run leaked before; Prism's suite
had left 40,000, which is what made its tree stall). A new test may mkdtemp freely.
Expand Down
14 changes: 12 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# Privacy

Prism Terminal has no accounts, no analytics, no telemetry and no crash reporting. It keeps your
settings and open tabs on your own PC (`%APPDATA%\PrismTerminal`), and nothing about you or what
you do in it is sent anywhere.
settings, open tabs and a diagnostics log on your own PC (`%APPDATA%\PrismTerminal`), and nothing
about you or what you do in it is sent anywhere.

## The diagnostics log

To find out why the app froze or failed, Prism Terminal keeps a log on your PC
(`%APPDATA%\PrismTerminal\logs`, at most 10 MB, the oldest part removed as it grows): when the
window or the app was slow and for how long, errors, and a timeline of what you did in the app
(a tab opened, closed or switched, a Settings page, an update, dictation starting and stopping, a
shell starting and ending). Folder paths are written in full. What you type, what you copy and
what you say are never written to it. The log is **never sent anywhere**; it leaves your PC only if
you send it yourself. Settings > Diagnostics opens its folder, and has a switch for more detail.

## What reaches the network

Expand Down
18 changes: 18 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ fourth, under the same bar. Spec and plan:
`dictation/useDictationState.ts`, `dictation/parts.tsx`), so every rule has
one copy and, for a few days, two layouts.

**THE DIAGNOSTICS LOG (#140, 2026-10-07)** is the fifth: both apps catch their
stalls and errors the same way. `main/diagnostics.ts` `startDiagnostics` wires
it in one call; its main deps include **`diagLogDir`** (`<userData>\logs`), and
**a host without it logs nothing** (the page's bridge still answers, so Prism
is unchanged until it passes one). Call it before any IPC is registered (it
times every call by wrapping `ipcMain` itself), `watchWindow(win)` once the
window exists, `stop()` on the quit that goes ahead, and add `withStackPolicy`
in the session's `onHeadersReceived` for `mainFrame` responses (without that
Document-Policy the page stack never comes). Optional: `longWaitChannels`
(the host's channels that wait on the user or a download, never `ipc-slow`),
`powerMonitor` (a getter, so a sleep is not a lag), and `log.flushSync()` in
the window's `session-end`, where no quit comes. The bridge is `DGCH` in
`shared/channels.ts`, `preload/diagApi.ts` and `main/diagIpc.ts`; the page
calls `startDiag(bridge)` and anything may call `crumb()`. The Settings page
is `settings/sections/DiagnosticsPage.tsx` (props only), its rows
`diagnosticsOptions.ts`. Schema and how to read it: Prism Terminal's
`docs/diagnostics.md`.

## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`)

- **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a
Expand Down
116 changes: 116 additions & 0 deletions core/main/crashHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'events'
import type { DiagLog, DiagSource } from './diagLog'
import { hookCrashes, watchWindowHealth } from './crashHooks'

interface Line {
src: DiagSource
k: string
fields: Record<string, unknown>
}

function fakeLog(): DiagLog & { lines: Line[]; syncs: number } {
const lines: Line[] = []
const log = {
lines,
syncs: 0,
dir: '',
file: '',
write: (src: DiagSource, k: string, fields: Record<string, unknown> = {}) => lines.push({ src, k, fields }),
writeAt: (src: DiagSource, k: string, _at: number, fields: Record<string, unknown> = {}) => lines.push({ src, k, fields }),
verbose: () => false,
setVerbose: () => {},
flush: async () => {},
flushSync: () => {
log.syncs += 1
},
failed: () => false,
close: () => {}
}
return log
}

describe('hookCrashes', () => {
it('logs an uncaught exception by observing only, and writes it to disk at once', () => {
const proc = new EventEmitter()
const log = fakeLog()
hookCrashes({ log, process: proc, app: new EventEmitter() })
// The MONITOR event: observing it leaves Node's own handling untouched.
expect(proc.listenerCount('uncaughtExceptionMonitor')).toBe(1)
expect(proc.listenerCount('uncaughtException')).toBe(0)
proc.emit('uncaughtExceptionMonitor', new Error('kaput'), 'uncaughtException')
expect(log.lines[0]).toMatchObject({ src: 'main', k: 'main-error', fields: { msg: 'kaput', origin: 'uncaughtException' } })
expect(typeof log.lines[0].fields.stack).toBe('string')
expect(log.syncs).toBe(1)
})

it('logs an unhandled rejection', () => {
const proc = new EventEmitter()
const log = fakeLog()
hookCrashes({ log, process: proc, app: new EventEmitter() })
proc.emit('unhandledRejection', new Error('lost'), Promise.resolve())
expect(log.lines[0]).toMatchObject({ k: 'main-rejection', fields: { msg: 'lost' } })
})

it('writes a rejection in the batch, and the same one over and over once per 10 s with a count', () => {
vi.useFakeTimers()
try {
const proc = new EventEmitter()
const log = fakeLog()
let t = 0
const off = hookCrashes({ log, process: proc, app: new EventEmitter(), now: () => t })
// A poll that rejects every 250 ms for five seconds.
for (; t < 5000; t += 250) proc.emit('unhandledRejection', new Error('poll failed'), Promise.resolve())
expect(log.lines.map((l) => l.k)).toEqual(['main-rejection'])
expect(log.syncs).toBe(0)
t = 10_000
vi.advanceTimersByTime(10_000)
expect(log.lines.map((l) => l.k)).toEqual(['main-rejection', 'main-rejection'])
expect(log.lines[1].fields).toMatchObject({ msg: 'poll failed', repeats: 19 })
off()
} finally {
vi.useRealTimers()
}
})

it('logs a renderer or a child process that went', () => {
const app = new EventEmitter()
const log = fakeLog()
hookCrashes({ log, process: new EventEmitter(), app })
app.emit('render-process-gone', {}, {}, { reason: 'oom', exitCode: -536870904 })
app.emit('child-process-gone', {}, { type: 'GPU', reason: 'crashed', exitCode: 1, name: 'gpu' })
expect(log.lines.map((l) => l.fields)).toEqual([
{ type: 'renderer', reason: 'oom', exitCode: -536870904 },
{ type: 'GPU', reason: 'crashed', exitCode: 1, name: 'gpu' }
])
expect(log.lines.every((l) => l.k === 'gone')).toBe(true)
expect(log.syncs).toBe(2)
})

it('takes its listeners off again', () => {
const proc = new EventEmitter()
const app = new EventEmitter()
const off = hookCrashes({ log: fakeLog(), process: proc, app })
off()
expect(proc.listenerCount('uncaughtExceptionMonitor') + proc.listenerCount('unhandledRejection')).toBe(0)
expect(app.listenerCount('render-process-gone') + app.listenerCount('child-process-gone')).toBe(0)
})
})

describe('watchWindowHealth', () => {
it('logs unresponsive, then responsive with how long it was, and hands the hang to the watch', () => {
const win = new EventEmitter()
const log = fakeLog()
let t = 1000
const unresponsive = vi.fn()
watchWindowHealth(win, log, { unresponsive }, () => t)
win.emit('unresponsive')
t = 4500
win.emit('responsive')
expect(log.lines.map((l) => [l.k, l.fields])).toEqual([
['unresponsive', {}],
['responsive', { ms: 3500 }]
])
expect(unresponsive).toHaveBeenCalledTimes(1)
})
})
117 changes: 117 additions & 0 deletions core/main/crashHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { performance } from 'perf_hooks'
import type { DiagLog } from './diagLog'
import { errorFields } from './diagSummary'
import { createDiagGate } from '../shared/diagGate'

/**
* ERRORS AND CRASHES, OBSERVED (#140).
*
* Observe only: nothing here changes what the app does when something goes
* wrong. An uncaught exception is heard on `uncaughtExceptionMonitor`, which
* Node calls BEFORE its own handling and which does not count as handling it.
* `unhandledRejection` does count, but Electron's main runs Node in warn
* mode: MEASURED on Electron 43, an unhandled rejection only printed a
* warning and the app ran on, with or without a listener, so the listener
* costs the console warning and nothing else.
*
* An exception and a process that went are written to disk AT ONCE
* (`flushSync`): the process may be about to end, and the 250 ms batch would
* lose exactly the line that explains why. A rejection goes in the batch.
*/

/* eslint-disable @typescript-eslint/no-explicit-any */
export interface EmitterLike {
on(event: string, listener: (...args: any[]) => void): unknown
removeListener(event: string, listener: (...args: any[]) => void): unknown
}
/* eslint-enable @typescript-eslint/no-explicit-any */

export interface CrashHookDeps {
log: DiagLog
/** Node's `process`. */
process: EmitterLike
/** Electron's `app`. */
app: EmitterLike
/** Monotonic ms, for the repeat gate. */
now?: () => number
}

/** Hooks the process and the app; returns the function that unhooks them. */
export function hookCrashes({ log, process: proc, app, now = () => performance.now() }: CrashHookDeps): () => void {
const land = (k: string, fields: Record<string, unknown>): void => {
try {
log.write('main', k, fields)
log.flushSync()
} catch {
/* the log never adds a second failure to the first */
}
}
// The same error over and over (a rejection inside a poll) is one line per
// 10 s with a count, not a line each time (`shared/diagGate`).
const gate = createDiagGate<Record<string, unknown>>()
const gated = (k: string, fields: Record<string, unknown>, sync: boolean): void => {
try {
const line = gate.offer(k, `${k}|${String(fields.msg)}`, fields, now())
if (!line) return
if (sync) land(k, line)
else log.write('main', k, line)
} catch {
/* silent */
}
}
const sweep = setInterval(() => {
try {
// `k` rides in the held fields for this; the writer keeps its own `k`
// and drops a field of that name.
for (const line of gate.sweep(now())) log.write('main', String(line.k), line)
} catch {
/* silent */
}
}, 10_000)
sweep.unref?.()
const onError = (err: unknown, origin: unknown): void =>
gated('main-error', { ...errorFields(err), origin: typeof origin === 'string' ? origin : null, k: 'main-error' }, true)
// NOT written synchronously: a rejection does not end the process
// (MEASURED, above), and a promise that rejects inside a poll would pay a
// sync mkdir and append on main's thread every cycle. The batch takes it.
const onRejection = (reason: unknown): void => gated('main-rejection', { ...errorFields(reason), k: 'main-rejection' }, false)
const onRenderGone = (_e: unknown, _wc: unknown, d: { reason?: string; exitCode?: number } = {}): void =>
land('gone', { type: 'renderer', reason: d.reason ?? null, exitCode: d.exitCode ?? null })
const onChildGone = (_e: unknown, d: { type?: string; reason?: string; exitCode?: number; name?: string } = {}): void =>
land('gone', { type: d.type ?? null, reason: d.reason ?? null, exitCode: d.exitCode ?? null, name: d.name ?? null })

proc.on('uncaughtExceptionMonitor', onError)
proc.on('unhandledRejection', onRejection)
app.on('render-process-gone', onRenderGone)
app.on('child-process-gone', onChildGone)
return () => {
clearInterval(sweep)
proc.removeListener('uncaughtExceptionMonitor', onError)
proc.removeListener('unhandledRejection', onRejection)
app.removeListener('render-process-gone', onRenderGone)
app.removeListener('child-process-gone', onChildGone)
}
}

/**
* A window's own hang events: `unresponsive` (Chromium's hang monitor gave
* up waiting on the page), then `responsive` with how long it lasted. The
* hang is handed to the stall watch, which writes the page's stack for it.
*/
export function watchWindowHealth(
win: EmitterLike,
log: DiagLog,
watch?: { unresponsive(): void },
now: () => number = () => performance.now()
): void {
let since: number | null = null
win.on('unresponsive', () => {
since = now()
log.write('main', 'unresponsive', {})
watch?.unresponsive()
})
win.on('responsive', () => {
log.write('main', 'responsive', since === null ? {} : { ms: Math.round(now() - since) })
since = null
})
}
Loading
Loading