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
tests/unit/uplink/log.test.tsadded+127-0
Changes to tests/unit/uplink/log.test.ts
@@ -0,0 +1,127 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import process from 'node:process'
6import { describeFatal, rotate, writeLog } from '../../../app/Uplink/log'
7
8const dirs: string[] = []
9function tempLog(): string {
10 const dir = mkdtempSync(join(tmpdir(), 'uplink-log-'))
11 dirs.push(dir)
12 return join(dir, 'Uplink.log')
13}
14afterEach(() => {
15 for (const dir of dirs.splice(0))
16 rmSync(dir, { recursive: true, force: true })
17})
18
19describe('the log file', () => {
20 it('writes a timestamped line', () => {
21 const path = tempLog()
22 writeLog('hello', path)
23 expect(readFileSync(path, 'utf8')).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z hello\n$/)
24 })
25
26 it('makes the directory rather than failing on a first run', () => {
27 const path = join(tempLog(), '..', 'nested', 'Uplink.log')
28 writeLog('first', path)
29 expect(readFileSync(path, 'utf8')).toContain('first')
30 })
31
32 /**
33 * The lines leading up to a crash are the ones worth reading, and a rotation
34 * triggered by that crash's own output would otherwise throw them away. So
35 * one generation is kept rather than truncated.
36 */
37 it('rotates once past the limit and keeps the previous contents', () => {
38 const path = tempLog()
39 appendFileSync(path, 'x'.repeat(2048))
40 rotate(path, 1024)
41
42 expect(existsSync(path)).toBe(false)
43 expect(readFileSync(`${path}.1`, 'utf8')).toHaveLength(2048)
44
45 // And it keeps logging afterwards.
46 writeLog('after the rotation', path, 1024)
47 expect(readFileSync(path, 'utf8')).toContain('after the rotation')
48 expect(statSync(path).size).toBeLessThan(1024)
49 })
50
51 it('leaves a file that is under the limit alone', () => {
52 const path = tempLog()
53 appendFileSync(path, 'small')
54 rotate(path, 1024)
55 expect(readFileSync(path, 'utf8')).toBe('small')
56 expect(existsSync(`${path}.1`)).toBe(false)
57 })
58
59 it('replaces an older rotation rather than accumulating them', () => {
60 const path = tempLog()
61 appendFileSync(`${path}.1`, 'the generation before last')
62 appendFileSync(path, 'y'.repeat(2048))
63 rotate(path, 1024)
64 expect(readFileSync(`${path}.1`, 'utf8')).toHaveLength(2048)
65 expect(existsSync(`${path}.2`)).toBe(false)
66 })
67
68 it('does not throw when the log cannot be written', () => {
69 // A path whose parent is a file, so both mkdir and append fail.
70 const blocked = tempLog()
71 appendFileSync(blocked, 'i am a file')
72 expect(() => writeLog('into the void', join(blocked, 'Uplink.log'))).not.toThrow()
73 })
74})
75
76describe('describeFatal', () => {
77 it('gives the name, message and stack', () => {
78 const described = describeFatal(new TypeError('null is not an object'))
79 expect(described).toContain('TypeError: null is not an object')
80 expect(described).toContain('log.test.ts')
81 })
82
83 it('handles something thrown that is not an Error', () => {
84 expect(describeFatal('just a string')).toBe('just a string')
85 expect(describeFatal(undefined)).toBe('undefined')
86 })
87})
88
89/**
90 * The failure this exists for. An uncaught throw used to go to a stderr the
91 * LaunchAgent does not capture, and the process vanished with the log's last
92 * line being whatever succeeded before it.
93 */
94describe('crash handlers', () => {
95 function crash(how: string): { exitCode: number, log: string } {
96 const path = tempLog()
97 const root = join(import.meta.dir, '..', '..', '..')
98 const result = Bun.spawnSync(['bun', '-e', `
99 import { installCrashHandlers } from ${JSON.stringify(join(root, 'app/Uplink/log.ts'))}
100 installCrashHandlers({ path: ${JSON.stringify(path)} })
101 ${how}
102 `], { stderr: 'pipe', stdout: 'pipe', env: { ...process.env } })
103 return { exitCode: result.exitCode, log: existsSync(path) ? readFileSync(path, 'utf8') : '' }
104 }
105
106 it('records an uncaught throw and exits non-zero', () => {
107 const { exitCode, log } = crash(`setTimeout(() => { throw new Error('boom') }, 0)`)
108 expect(exitCode).toBe(1)
109 expect(log).toContain('uncaught exception')
110 expect(log).toContain('Error: boom')
111 // The stack, which is the part worth having.
112 expect(log).toContain('at ')
113 })
114
115 it('records a rejected promise with no catch', () => {
116 const { exitCode, log } = crash(`Promise.reject(new Error('nobody caught me'))`)
117 expect(exitCode).toBe(1)
118 expect(log).toContain('unhandled rejection')
119 expect(log).toContain('nobody caught me')
120 })
121
122 it('reports one crash, not a cascade', () => {
123 const { log } = crash(`setTimeout(() => { throw new Error('first') }, 0); setTimeout(() => { throw new Error('second') }, 1)`)
124 expect(log).toContain('first')
125 expect(log).not.toContain('second')
126 })
127})