ReviewOS

also looking at this

stacks/uplink

fix(app): report a crash instead of vanishing, and cap the log

#55
Merged glennmichael123 wants to merge fix/log-a-crash-and-cap-the-log into main
4 files +252 -5
app/Uplink/log.tsadded+92-0
Changes to app/Uplink/log.ts
@@ -0,0 +1,92 @@
1import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
2import { dirname } from 'node:path'
3import process from 'node:process'
4import { LOG_MAX_BYTES, LOG_PATH } from './settings'
5
6/**
7 * ~/Library/Logs/Uplink.log, which is the whole support story for a
8 * downloadable app: no crash reporting, no diagnostics bundle, and stdout that
9 * nobody is attached to.
10 *
11 * So this file has two jobs the previous inline version did not do. It stays
12 * within a size, because a watcher polling every two seconds appends forever.
13 * And it survives a crash, because the failure most worth explaining was the
14 * one the file could not describe: an uncaught throw went to a stderr the
15 * LaunchAgent does not capture, and the process vanished with the log's last
16 * line being whatever succeeded before it.
17 */
18
19// Checked on a counter rather than on every append: the watcher writes on a
20// two second poll, and a stat per line would be most of the work.
21const ROTATE_EVERY = 200
22let writes = 0
23
24/**
25 * One timestamped line. Never throws: `~/Library/Logs` can be missing or
26 * unwritable, and a crash reporter that crashes is worse than none.
27 */
28export function writeLog(line: string, path: string = LOG_PATH, maxBytes: number = LOG_MAX_BYTES): void {
29 try {
30 // Every write, not only on the rotation check: a directory that goes away
31 // mid-run should not cost 200 lines before anyone notices.
32 mkdirSync(dirname(path), { recursive: true })
33 if (writes++ % ROTATE_EVERY === 0)
34 rotate(path, maxBytes)
35 appendFileSync(path, `${new Date().toISOString()} ${line}\n`)
36 }
37 catch {
38 // Nothing useful left to do: the place errors are reported is the thing
39 // that failed.
40 }
41}
42
43/**
44 * One rotation, keeping `Uplink.log.1`. Kept rather than deleted because the
45 * lines leading up to a crash are the ones worth reading, and a rotation
46 * triggered by that crash's own output would otherwise throw them away.
47 */
48export function rotate(path: string = LOG_PATH, maxBytes: number = LOG_MAX_BYTES): void {
49 if ((statSync(path, { throwIfNoEntry: false })?.size ?? 0) > maxBytes)
50 renameSync(path, `${path}.1`)
51}
52
53/**
54 * Name, message and stack, and nothing else. A stack can carry a frame
55 * argument in some runtimes, and a text somebody sent is not ours to write to
56 * a file that outlives the run.
57 */
58export function describeFatal(error: unknown): string {
59 return error instanceof Error
60 ? `${error.name}: ${error.message}\n${error.stack ?? '(no stack)'}`
61 : String(error)
62}
63
64/**
65 * Report an uncaught throw or a rejected promise, then stop.
66 *
67 * Stopping is the point. A handler that swallows the error leaves the agent
68 * half alive: the watcher is gone and the loopback server is not, so the
69 * menubar reports a healthy app that answers nothing.
70 */
71export function installCrashHandlers(options: { path?: string, shutdown?: (code: number) => void } = {}): void {
72 const shutdown = options.shutdown ?? ((code: number) => process.exit(code))
73 let fatal = false
74
75 const report = (kind: string, error: unknown): void => {
76 // Running twice turns one crash into two, and the second is never the
77 // interesting one.
78 if (fatal)
79 return
80 fatal = true
81 const described = `[uplink] ${kind}: ${describeFatal(error)}`
82 // Whatever the TTY says. From a terminal the developer also gets it on
83 // stderr; the file gets the same entry either way.
84 writeLog(described, options.path)
85 if (process.stdout.isTTY)
86 console.error(described)
87 shutdown(1)
88 }
89
90 process.on('uncaughtException', error => report('uncaught exception', error))
91 process.on('unhandledRejection', reason => report('unhandled rejection', reason))
92}