dsh-notch 0.3.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +58 -47
- package/README.md +60 -55
- package/cordis.patch.yml +3 -0
- package/desktop/README.md +29 -0
- package/desktop/notch-lifecycle.mjs +120 -0
- package/desktop/restart-probe.mjs +61 -0
- package/docs/desktop-017rc2.md +23 -0
- package/docs/elastic-edge-dock.md +127 -0
- package/docs/motion-audit.md +1 -1
- package/docs/releases/v0.3.2.md +38 -0
- package/docs/releases/v0.3.3.md +9 -0
- package/docs/subagent-compatibility.md +51 -0
- package/docs/verification/elastic-algorithm-timing.json +337 -0
- package/docs/verification/elastic-attention.json +44 -0
- package/docs/verification/elastic-frame-timing.json +240 -0
- package/docs/verification/elastic-tension.json +59 -0
- package/docs/verification/elastic-tip-taper.json +181 -0
- package/dshx.yml +2 -0
- package/lib/client.js +112 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +990 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +58 -10
- package/src/board.ts +178 -35
- package/src/client/index.ts +104 -0
- package/src/dsh-notch.ts +19 -2
- package/src/http.ts +17 -2
- package/src/session-state.ts +15 -1
- package/src/store.ts +3 -3
- package/src/types.ts +3 -0
- package/BASELINE.md +0 -13
- package/DESIGN.md +0 -34
- package/cordis.yml +0 -5
- package/macos/Package.swift +0 -17
- package/macos/STATUS-MOTION.md +0 -49
- package/macos/Sources/Client.swift +0 -139
- package/macos/Sources/IdleRobot.swift +0 -423
- package/macos/Sources/NotchMarkdown.swift +0 -266
- package/macos/Sources/Panel.swift +0 -153
- package/macos/Sources/Resources/Idle/balance.json +0 -1
- package/macos/Sources/Resources/Idle/blink.json +0 -1
- package/macos/Sources/Resources/Idle/cube-in.json +0 -1
- package/macos/Sources/Resources/Idle/dance.json +0 -1
- package/macos/Sources/Resources/Idle/hop.json +0 -1
- package/macos/Sources/Resources/Idle/nod.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
- package/macos/Sources/Resources/Idle/scan.json +0 -1
- package/macos/Sources/Resources/Idle/sleep.json +0 -1
- package/macos/Sources/Resources/Idle/sneeze.json +0 -1
- package/macos/Sources/Resources/Idle/stretch.json +0 -1
- package/macos/Sources/Resources/Idle/tilt.json +0 -1
- package/macos/Sources/ReviewStudio.swift +0 -337
- package/macos/Sources/RootView.swift +0 -1093
- package/macos/Sources/StatusOrbit.swift +0 -642
- package/macos/Sources/main.swift +0 -153
- package/macos/Tests/ExpandedHeightProbe.swift +0 -108
- package/macos/Tests/GeometryProbe.swift +0 -98
- package/macos/Tests/IdleProbe.swift +0 -294
- package/macos/Tests/MotionProbe.swift +0 -106
- package/macos/Tests/OutcomeProbe.swift +0 -133
- package/macos/Tests/ScrollbarProbe.swift +0 -91
- package/macos/Tests/expanded-height.sh +0 -13
- package/macos/Tests/geometry.sh +0 -14
- package/macos/Tests/idle.sh +0 -14
- package/macos/Tests/motion.sh +0 -14
- package/macos/Tests/outcome.sh +0 -14
- package/macos/Tests/scrollbar.sh +0 -13
- package/tests/answer-lifecycle.test.mjs +0 -23
- package/tests/board.test.mjs +0 -7
- package/tests/browse-sync.test.mjs +0 -20
- package/tests/mirror.test.mjs +0 -4
- package/tests/peer-range.test.mjs +0 -21
- package/tests/session-state.test.mjs +0 -54
- package/tests/unread-hold.test.mjs +0 -90
- package/tools/desktop-shell/README.md +0 -56
- package/tools/desktop-shell/child-log.mjs +0 -6
- package/tools/desktop-shell/child-log.test.mjs +0 -14
- package/tools/desktop-shell/package-recovery.py +0 -78
- package/tools/desktop-shell/recovery-probe.cjs +0 -50
- package/tools/desktop-shell/renderer-recovery.mjs +0 -95
- package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
- package/tools/diagnostics/completion-watch.py +0 -101
- package/tools/diagnostics/completion-watch.test.py +0 -17
- package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
- package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
- package/tools/idle/LICENSE.open-bot-motion +0 -21
- package/tools/idle/export.mjs +0 -40
- package/tools/idle/index.html +0 -4
- package/tools/idle/motions.js +0 -83
- package/tools/idle/original.js +0 -1673
- package/tools/idle/renderer.js +0 -1673
- package/tools/recording/Client.swift +0 -76
- package/tools/recording/DemoCatalog.swift +0 -174
- package/tools/recording/README.md +0 -23
- package/tools/recording/build.sh +0 -41
- package/tools/recording/main.swift +0 -100
- package/tools/review/ReviewStudio.swift +0 -236
- package/tools/review/audit.sh +0 -19
- package/tools/review/build.sh +0 -7
- package/tools/review/main.swift +0 -158
- package/tools/review/render-audit.swift +0 -32
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { test, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
-
import { syncBuiltinESMExports } from 'node:module'
|
|
6
|
-
|
|
7
|
-
const testDir = mkdtempSync(os.tmpdir() + '/notch-unread-')
|
|
8
|
-
const realHomedir = os.homedir
|
|
9
|
-
os.homedir = () => testDir
|
|
10
|
-
syncBuiltinESMExports()
|
|
11
|
-
const { Board } = await import('../src/board.ts')
|
|
12
|
-
os.homedir = realHomedir
|
|
13
|
-
syncBuiltinESMExports()
|
|
14
|
-
after(() => rmSync(testDir, { recursive: true, force: true }))
|
|
15
|
-
|
|
16
|
-
test('a single running task becomes a green unread lamp instead of vanishing', () => {
|
|
17
|
-
let running = true
|
|
18
|
-
const events = [{ type: 'turn/start', time: 1 }]
|
|
19
|
-
const session = {
|
|
20
|
-
id: 'session-one',
|
|
21
|
-
header: {},
|
|
22
|
-
snapshotEvents: () => events,
|
|
23
|
-
}
|
|
24
|
-
const ctx = {
|
|
25
|
-
sessions: { list: () => [session] },
|
|
26
|
-
agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
|
|
27
|
-
get: () => undefined,
|
|
28
|
-
logger: { warn() {} },
|
|
29
|
-
}
|
|
30
|
-
const board = new Board(ctx)
|
|
31
|
-
assert.equal(board.snapshot('').rows[0].busy, true)
|
|
32
|
-
|
|
33
|
-
running = false
|
|
34
|
-
events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
|
|
35
|
-
const done = board.snapshot('').rows[0]
|
|
36
|
-
assert.equal(done.busy, false)
|
|
37
|
-
assert.equal(done.unread, true)
|
|
38
|
-
assert.equal(board.snapshot('').rows[0].unread, true)
|
|
39
|
-
|
|
40
|
-
board.markSeen(session.id)
|
|
41
|
-
assert.equal(board.snapshot('').rows.length, 0)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('clicking seen clears green even if the sidebar still says completed', () => {
|
|
45
|
-
let running = true
|
|
46
|
-
const events = [{ type: 'turn/start', time: 1 }]
|
|
47
|
-
const session = {
|
|
48
|
-
id: 'session-sidebar',
|
|
49
|
-
header: {},
|
|
50
|
-
snapshotEvents: () => events,
|
|
51
|
-
}
|
|
52
|
-
const ctx = {
|
|
53
|
-
sessions: { list: () => [session] },
|
|
54
|
-
agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
|
|
55
|
-
get: () => undefined,
|
|
56
|
-
logger: { warn() {} },
|
|
57
|
-
}
|
|
58
|
-
const board = new Board(ctx)
|
|
59
|
-
board.snapshot('')
|
|
60
|
-
running = false
|
|
61
|
-
events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
|
|
62
|
-
board.snapshot('')
|
|
63
|
-
board.syncSidebar({
|
|
64
|
-
clientId: 'app',
|
|
65
|
-
focused: true,
|
|
66
|
-
rows: [{ id: session.id, title: 'Done', running: false, completed: true }],
|
|
67
|
-
})
|
|
68
|
-
assert.equal(board.snapshot('').rows[0].unread, true)
|
|
69
|
-
board.markSeen(session.id)
|
|
70
|
-
assert.equal(board.snapshot('').rows.length, 0)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('cold completed history is not replayed as green', () => {
|
|
74
|
-
const session = {
|
|
75
|
-
id: 'session-old',
|
|
76
|
-
header: {},
|
|
77
|
-
snapshotEvents: () => [
|
|
78
|
-
{ type: 'turn/start', time: 1 },
|
|
79
|
-
{ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } },
|
|
80
|
-
],
|
|
81
|
-
}
|
|
82
|
-
const ctx = {
|
|
83
|
-
sessions: { list: () => [session] },
|
|
84
|
-
agents: { get: () => ({ status: 'idle' }) },
|
|
85
|
-
get: () => undefined,
|
|
86
|
-
logger: { warn() {} },
|
|
87
|
-
}
|
|
88
|
-
const board = new Board(ctx)
|
|
89
|
-
assert.equal(board.snapshot('').rows.length, 0)
|
|
90
|
-
})
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# Desktop renderer recovery
|
|
2
|
-
|
|
3
|
-
This optional thin-shell patch prevents an unexpected renderer exit from leaving
|
|
4
|
-
an existing DSH window permanently blank. It does **not** identify or fix the
|
|
5
|
-
underlying long-idle renderer termination.
|
|
6
|
-
|
|
7
|
-
The recovery controller listens to `render-process-gone`, including `clean-exit`.
|
|
8
|
-
While the app/window remains open it reloads the original WebContents, preserves
|
|
9
|
-
its navigation/auth context, requests a repaint after load, and records the result.
|
|
10
|
-
It never starts, stops or restarts a Host. Two automatic attempts are allowed per
|
|
11
|
-
minute; further exits and a 15-second load timeout go to a native error prompt.
|
|
12
|
-
App quit/window close cancels pending work. The shell integration adds window and
|
|
13
|
-
system idle metadata to bounded local diagnostic logs.
|
|
14
|
-
|
|
15
|
-
`child-log.mjs` fixes a separate shutdown race: each child stdout/stderr stream
|
|
16
|
-
pipes with `end: false` so EOF cannot close the shared desktop log before an exit
|
|
17
|
-
callback writes to it. Logging errors cannot raise an uncaught main-process error.
|
|
18
|
-
|
|
19
|
-
## Checks
|
|
20
|
-
|
|
21
|
-
```sh
|
|
22
|
-
node --test tools/desktop-shell/*.test.mjs tools/diagnostics/renderer-diagnostics.test.mjs
|
|
23
|
-
"$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs --baseline
|
|
24
|
-
"$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Use the target app's matching Electron executable. The baseline is expected to
|
|
28
|
-
fail: it has no recovery handler. The repaired native integration fixture uses a
|
|
29
|
-
temporary profile/static page, crashes only its own renderer, checks both visible
|
|
30
|
-
and hidden windows twice, verifies an actual painted frame against its healthy
|
|
31
|
-
baseline (including display color management), and verifies retry limiting.
|
|
32
|
-
It never reads DSH conversations or invokes models. This is an Electron lifecycle
|
|
33
|
-
test, not an alternative browser automation interface.
|
|
34
|
-
|
|
35
|
-
## Package and activate
|
|
36
|
-
|
|
37
|
-
`package-recovery.py <original-app.asar> <staging-directory>` produces a patched
|
|
38
|
-
ASAR, readable main module, and input/output hash manifest. It requires the known
|
|
39
|
-
thin-shell source shape with the earlier diagnostics hook and refuses an already
|
|
40
|
-
patched or unfamiliar archive. Every unrelated archive entry is preserved and
|
|
41
|
-
all output entry bytes are verified. To test packaged recovery, set
|
|
42
|
-
`DSH_RECOVERY_MODULE` to the staged ASAR's `src/renderer-recovery.mjs` before the
|
|
43
|
-
native probe. The script never installs or controls a process.
|
|
44
|
-
|
|
45
|
-
Keep a verified copy of the original ASAR. Installation is a separate, authorized,
|
|
46
|
-
atomic resource replacement after checking that the current archive still matches
|
|
47
|
-
the reviewed input. Shell-main changes require a controlled App reopen. The current
|
|
48
|
-
App owns its Host: do this only with the applicable task authorization and an idle
|
|
49
|
-
runtime, and prove that the old Host exited before launching the original App.
|
|
50
|
-
Once active, routine renderer recovery does not require another App/Host restart.
|
|
51
|
-
Verify diagnostic attachment plus the actual conversation/composer through the
|
|
52
|
-
normal app. A package checksum or a recovery log is not visual acceptance.
|
|
53
|
-
|
|
54
|
-
For rollback, restore the exact preserved original archive and perform the same
|
|
55
|
-
controlled App reopen. Never replace the user's checkout, history or Notch assets.
|
|
56
|
-
Do not commit local logs, screenshots, session data or installed-artifact backups.
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// stdout and stderr (and sometimes several children) share one desktop log.
|
|
2
|
-
// EOF belongs to an input stream; it must not end the shared destination.
|
|
3
|
-
export function pipeChildLog(child, log) {
|
|
4
|
-
child.stdout?.pipe(log, {end: false});
|
|
5
|
-
child.stderr?.pipe(log, {end: false});
|
|
6
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test';import assert from 'node:assert/strict';import {PassThrough} from 'node:stream';
|
|
2
|
-
import {pipeChildLog} from './child-log.mjs';
|
|
3
|
-
test('child EOF leaves shared log open for exit callback and other child',async()=>{
|
|
4
|
-
const log=new PassThrough(),chunks=[],errors=[];log.on('data',c=>chunks.push(c.toString()));log.on('error',e=>errors.push(e));
|
|
5
|
-
const first={stdout:new PassThrough(),stderr:new PassThrough()},second={stdout:new PassThrough(),stderr:new PassThrough()};
|
|
6
|
-
pipeChildLog(first,log);pipeChildLog(second,log);
|
|
7
|
-
first.stdout.end('first-out\n');first.stderr.end('first-err\n');
|
|
8
|
-
await new Promise(setImmediate);
|
|
9
|
-
assert.equal(log.writableEnded,false);
|
|
10
|
-
log.write('first-exit\n');second.stdout.end('second-out\n');second.stderr.end('second-err\n');
|
|
11
|
-
await new Promise(setImmediate);
|
|
12
|
-
log.write('second-exit\n');await new Promise(setImmediate);
|
|
13
|
-
assert.equal(errors.length,0);assert.match(chunks.join(''),/first-exit/);assert.match(chunks.join(''),/second-exit/);log.end();
|
|
14
|
-
});
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Stage a reviewed thin-shell patch. Never installs or restarts DSH."""
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
import argparse, hashlib, json, struct
|
|
5
|
-
p=argparse.ArgumentParser();p.add_argument('archive',type=Path);p.add_argument('output',type=Path);args=p.parse_args()
|
|
6
|
-
raw=args.archive.read_bytes();hs=struct.unpack_from('<I',raw,4)[0];js=struct.unpack_from('<I',raw,12)[0]
|
|
7
|
-
tree=json.loads(raw[16:16+js]);files={}
|
|
8
|
-
def walk(entries,prefix=''):
|
|
9
|
-
for name,e in entries.items():
|
|
10
|
-
key=prefix+name
|
|
11
|
-
if 'files' in e:walk(e['files'],key+'/')
|
|
12
|
-
else:
|
|
13
|
-
assert not e.get('unpacked') and 'link' not in e, 'unsupported archive entry'
|
|
14
|
-
start=8+hs+int(e['offset']);files[key]=raw[start:start+e['size']]
|
|
15
|
-
walk(tree['files'])
|
|
16
|
-
original=dict(files);source=files['src/main.mjs'].decode()
|
|
17
|
-
assert 'installRendererRecovery' not in source, 'recovery already installed; use the preserved base archive'
|
|
18
|
-
def replace(old,new):
|
|
19
|
-
global source
|
|
20
|
-
assert source.count(old)==1, 'unexpected shell source: '+old[:70]
|
|
21
|
-
source=source.replace(old,new,1)
|
|
22
|
-
replace("import { createDiagnosticLog, installRendererDiagnostics }", "import { pipeChildLog } from './child-log.mjs'\nimport { installRendererRecovery } from './renderer-recovery.mjs'\nimport { createDiagnosticLog, installRendererDiagnostics }")
|
|
23
|
-
replace('const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences } = electron','const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences, powerMonitor, dialog } = electron')
|
|
24
|
-
replace(''' const attachDiagnostics = contents => installRendererDiagnostics({
|
|
25
|
-
app, contents, write: diagnosticWrite,
|
|
26
|
-
related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
|
|
27
|
-
})''',''' const attachDiagnostics = contents => {
|
|
28
|
-
const owner = BrowserWindow.fromWebContents(contents)
|
|
29
|
-
installRendererDiagnostics({
|
|
30
|
-
app, contents, window: owner, powerMonitor, write: diagnosticWrite,
|
|
31
|
-
related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
|
|
32
|
-
})
|
|
33
|
-
let promptOpen = false
|
|
34
|
-
installRendererRecovery({
|
|
35
|
-
app, window: owner, write: diagnosticWrite,
|
|
36
|
-
onUnavailable: async () => {
|
|
37
|
-
if (promptOpen || owner.isDestroyed()) return
|
|
38
|
-
promptOpen = true
|
|
39
|
-
try {
|
|
40
|
-
const result = await dialog.showMessageBox(owner, {
|
|
41
|
-
type: 'error', title: 'DSH 页面未能恢复',
|
|
42
|
-
message: '页面连续异常退出或重新加载超时。',
|
|
43
|
-
detail: '后台服务没有因页面恢复而重启。可以重新加载页面,或稍后从 View → Reload 重试。',
|
|
44
|
-
buttons: ['重新加载页面', '稍后'], defaultId: 0, cancelId: 1, noLink: true,
|
|
45
|
-
})
|
|
46
|
-
if (result.response === 0 && !owner.isDestroyed()) contents.reload()
|
|
47
|
-
} finally { promptOpen = false }
|
|
48
|
-
},
|
|
49
|
-
})
|
|
50
|
-
}''')
|
|
51
|
-
pair=" child.stdout?.pipe(log)\n child.stderr?.pipe(log)"
|
|
52
|
-
assert source.count(pair)==2
|
|
53
|
-
source=source.replace(pair, ' pipeChildLog(child, log)')
|
|
54
|
-
replace(" return log\n}", " log.on('error', () => {}) // Logging failure must not crash the desktop shell.\n return log\n}")
|
|
55
|
-
root=Path(__file__).parent
|
|
56
|
-
files['src/child-log.mjs']=(root/'child-log.mjs').read_bytes()
|
|
57
|
-
files['src/main.mjs']=source.encode()
|
|
58
|
-
files['src/renderer-recovery.mjs']=(root/'renderer-recovery.mjs').read_bytes()
|
|
59
|
-
files['src/renderer-diagnostics.mjs']=(root.parent/'diagnostics/renderer-diagnostics.mjs').read_bytes()
|
|
60
|
-
header={'files':{}};payload=bytearray()
|
|
61
|
-
for name,body in files.items():
|
|
62
|
-
node=header['files'];parts=name.split('/')
|
|
63
|
-
for part in parts[:-1]:node=node.setdefault(part,{'files':{}})['files']
|
|
64
|
-
digest=hashlib.sha256(body).hexdigest()
|
|
65
|
-
node[parts[-1]]={'size':len(body),'offset':str(len(payload)),'integrity':{'algorithm':'SHA256','hash':digest,'blockSize':4194304,'blocks':[hashlib.sha256(body[i:i+4194304]).hexdigest() for i in range(0,len(body),4194304)]}}
|
|
66
|
-
payload.extend(body)
|
|
67
|
-
encoded=json.dumps(header,separators=(',',':')).encode();body=struct.pack('<I',len(encoded))+encoded;body+=b'\0'*((-len(body))%4);pickle=struct.pack('<I',len(body))+body
|
|
68
|
-
archive=struct.pack('<II',4,len(pickle))+pickle+payload
|
|
69
|
-
args.output.mkdir(parents=True,exist_ok=True)
|
|
70
|
-
(args.output/'app.asar').write_bytes(archive)
|
|
71
|
-
(args.output/'main.mjs').write_text(source)
|
|
72
|
-
new_hs=struct.unpack_from('<I',archive,4)[0]
|
|
73
|
-
for name,expected in files.items():
|
|
74
|
-
node=header['files']
|
|
75
|
-
for part in name.split('/')[:-1]:node=node[part]['files']
|
|
76
|
-
e=node[name.split('/')[-1]];start=8+new_hs+int(e['offset']);assert archive[start:start+e['size']]==expected
|
|
77
|
-
manifest={'inputSHA256':hashlib.sha256(raw).hexdigest(),'outputSHA256':hashlib.sha256(archive).hexdigest(),'changed':[n for n,b in files.items() if original.get(n)!=b]}
|
|
78
|
-
(args.output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');print(json.dumps(manifest))
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// Native integration test: isolated profile/static fixture, never the user's DSH.
|
|
2
|
-
const {app,BrowserWindow}=require('electron');
|
|
3
|
-
const assert=require('node:assert/strict');
|
|
4
|
-
const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
|
|
5
|
-
const {pathToFileURL}=require('node:url');
|
|
6
|
-
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'dsh-recovery-test-'));
|
|
7
|
-
app.setPath('userData',profile);app.setPath('sessionData',profile);
|
|
8
|
-
app.on('window-all-closed',()=>{});
|
|
9
|
-
const nativePid=process.pid;
|
|
10
|
-
const waitFor=(emitter,event)=>new Promise(resolve=>emitter.once(event,resolve));
|
|
11
|
-
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
12
|
-
const deadline=setTimeout(()=>{console.error('native recovery timeout');app.exit(2)},20000);
|
|
13
|
-
app.whenReady().then(async()=>{
|
|
14
|
-
const {installRendererRecovery}=await import(pathToFileURL(process.env.DSH_RECOVERY_MODULE||path.join(__dirname,'renderer-recovery.mjs')));
|
|
15
|
-
const results=[];
|
|
16
|
-
for(const hidden of [false,true]){
|
|
17
|
-
const events=[];let unavailable=0;
|
|
18
|
-
const window=new BrowserWindow({width:460,height:220,show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}});
|
|
19
|
-
const wc=window.webContents;
|
|
20
|
-
let complete;
|
|
21
|
-
if(!process.argv.includes('--baseline'))installRendererRecovery({app,window,write:(event,data)=>{events.push({event,...data});if(event==='renderer-recovery-complete')complete?.()},onUnavailable:()=>unavailable++});
|
|
22
|
-
await window.loadURL('data:text/html,<title>DSH recovery fixture</title><body style="margin:0;background:%23004c2e;color:white;font:24px sans-serif;padding:32px"><h1>Recovery fixture</h1><p>Local static page. No model calls.</p></body>');
|
|
23
|
-
if(!hidden)window.showInactive();
|
|
24
|
-
const originalContents=wc.id;
|
|
25
|
-
const baselinePixels=(await wc.capturePage()).toBitmap();
|
|
26
|
-
const reference=Array.from(baselinePixels.subarray(0,4));
|
|
27
|
-
assert.ok(reference[1]>reference[0]&&reference[1]>reference[2]&&reference[3]>0,'baseline fixture must be green');
|
|
28
|
-
for(let cycle=1;cycle<=2;cycle++){
|
|
29
|
-
const oldPid=wc.getOSProcessId();
|
|
30
|
-
const recovered=new Promise(resolve=>{complete=resolve});
|
|
31
|
-
const gone=waitFor(wc,'render-process-gone');
|
|
32
|
-
wc.forcefullyCrashRenderer();
|
|
33
|
-
await gone;
|
|
34
|
-
if(process.argv.includes('--baseline')){await delay(700);assert.ok(wc.getOSProcessId()>0,'renderer remains dead: persistent blank window after exit');}
|
|
35
|
-
await recovered;
|
|
36
|
-
assert.equal(process.pid,nativePid);assert.equal(wc.id,originalContents);
|
|
37
|
-
assert.ok(wc.getOSProcessId()>0);assert.notEqual(wc.getOSProcessId(),oldPid);
|
|
38
|
-
assert.equal(await wc.executeJavaScript('document.querySelector("h1").textContent'),'Recovery fixture');
|
|
39
|
-
const frame=await wc.capturePage();const pixels=frame.toBitmap();
|
|
40
|
-
let green=0;for(let i=0;i<pixels.length;i+=4)if(reference.every((v,j)=>Math.abs(pixels[i+j]-v)<=3))green++;
|
|
41
|
-
assert.ok(green>pixels.length/4*.5,'recovered page must paint fixture background instead of white');
|
|
42
|
-
results.push({hidden,cycle,recovered:true,sameContents:true,painted:true});
|
|
43
|
-
}
|
|
44
|
-
const gone=waitFor(wc,'render-process-gone');wc.forcefullyCrashRenderer();await gone;await delay(400);
|
|
45
|
-
assert.equal(unavailable,1,'third rapid exit must stop auto reload');
|
|
46
|
-
window.destroy();
|
|
47
|
-
}
|
|
48
|
-
console.log(JSON.stringify({electron:process.versions.electron,results,boundedRetry:true}));
|
|
49
|
-
clearTimeout(deadline);app.exit(0);
|
|
50
|
-
}).catch(error=>{console.error(error.stack);clearTimeout(deadline);app.exit(1)});
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// Recovery belongs to the desktop window, never to the Host or a plugin session.
|
|
2
|
-
// A zero exit code is still unexpected while the user's window remains open.
|
|
3
|
-
export function installRendererRecovery({
|
|
4
|
-
app, window, write = () => {}, onUnavailable = () => {},
|
|
5
|
-
repaint = () => window.webContents.invalidate(),
|
|
6
|
-
now = Date.now, scheduler = {setTimeout, clearTimeout},
|
|
7
|
-
retryDelayMs = 250, loadTimeoutMs = 15000, maxAttempts = 2, retryWindowMs = 60000,
|
|
8
|
-
}) {
|
|
9
|
-
const contents = window.webContents;
|
|
10
|
-
const listeners = [];
|
|
11
|
-
let disposed = false, quitting = false, closing = false;
|
|
12
|
-
let pending = false, reloadTimer = null, deadlineTimer = null, attempts = [];
|
|
13
|
-
const on = (emitter, event, fn) => {
|
|
14
|
-
emitter.on(event, fn);
|
|
15
|
-
listeners.push(() => emitter.removeListener(event, fn));
|
|
16
|
-
};
|
|
17
|
-
const live = () => !disposed && !quitting && !closing && !window.isDestroyed() && !contents.isDestroyed();
|
|
18
|
-
const record = (event, data = {}) => {
|
|
19
|
-
try { write(event, {contentsId: contents.id, ...data}); } catch {}
|
|
20
|
-
};
|
|
21
|
-
const clearTimers = () => {
|
|
22
|
-
if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
|
|
23
|
-
if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
|
|
24
|
-
reloadTimer = deadlineTimer = null;
|
|
25
|
-
};
|
|
26
|
-
const unavailable = reason => {
|
|
27
|
-
record('renderer-recovery-unavailable', {reason});
|
|
28
|
-
try { Promise.resolve(onUnavailable(reason)).catch(() => {}); } catch {}
|
|
29
|
-
};
|
|
30
|
-
const watchLoad = () => {
|
|
31
|
-
if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
|
|
32
|
-
deadlineTimer = scheduler.setTimeout(() => {
|
|
33
|
-
deadlineTimer = null;
|
|
34
|
-
if (live() && pending) unavailable('load-timeout');
|
|
35
|
-
}, loadTimeoutMs);
|
|
36
|
-
};
|
|
37
|
-
on(contents, 'render-process-gone', (_event, details) => {
|
|
38
|
-
if (!live()) return;
|
|
39
|
-
if (reloadTimer !== null) return;
|
|
40
|
-
clearTimers();
|
|
41
|
-
pending = false;
|
|
42
|
-
const time = now();
|
|
43
|
-
attempts = attempts.filter(at => time - at < retryWindowMs);
|
|
44
|
-
if (attempts.length >= maxAttempts) { unavailable('repeated-exit'); return; }
|
|
45
|
-
attempts.push(time);
|
|
46
|
-
pending = true;
|
|
47
|
-
record('renderer-recovery-scheduled', {reason: details.reason, exitCode: details.exitCode, attempt: attempts.length});
|
|
48
|
-
reloadTimer = scheduler.setTimeout(() => {
|
|
49
|
-
reloadTimer = null;
|
|
50
|
-
if (!live() || !pending) return;
|
|
51
|
-
watchLoad();
|
|
52
|
-
try {
|
|
53
|
-
// Preserve the existing navigation controller, auth session and conversation.
|
|
54
|
-
if (!contents.isLoading()) contents.reload();
|
|
55
|
-
} catch {
|
|
56
|
-
clearTimers();
|
|
57
|
-
pending = false;
|
|
58
|
-
unavailable('reload-failed');
|
|
59
|
-
}
|
|
60
|
-
}, retryDelayMs);
|
|
61
|
-
});
|
|
62
|
-
on(contents, 'did-start-loading', () => {
|
|
63
|
-
if (!live() || !pending) return;
|
|
64
|
-
// A user's manual Reload can win the race with our queued recovery.
|
|
65
|
-
if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
|
|
66
|
-
reloadTimer = null;
|
|
67
|
-
watchLoad();
|
|
68
|
-
});
|
|
69
|
-
on(contents, 'did-finish-load', () => {
|
|
70
|
-
if (!live() || !pending || contents.getOSProcessId() <= 0) return;
|
|
71
|
-
clearTimers();
|
|
72
|
-
pending = false;
|
|
73
|
-
try { repaint(); } catch { record('renderer-repaint-failed'); }
|
|
74
|
-
record('renderer-recovery-complete', {rendererPid: contents.getOSProcessId()});
|
|
75
|
-
});
|
|
76
|
-
on(app, 'before-quit', () => { quitting = true; pending = false; clearTimers(); });
|
|
77
|
-
on(window, 'close', () => {
|
|
78
|
-
closing = true;
|
|
79
|
-
pending = false;
|
|
80
|
-
clearTimers();
|
|
81
|
-
// A cancelled close must not disable recovery for the remaining window.
|
|
82
|
-
queueMicrotask(() => { if (!disposed && !quitting && !window.isDestroyed()) closing = false; });
|
|
83
|
-
});
|
|
84
|
-
on(contents, 'will-prevent-unload', () => { quitting = false; closing = false; });
|
|
85
|
-
function dispose() {
|
|
86
|
-
if (disposed) return;
|
|
87
|
-
disposed = true;
|
|
88
|
-
clearTimers();
|
|
89
|
-
for (const remove of listeners) remove();
|
|
90
|
-
}
|
|
91
|
-
on(window, 'closed', dispose);
|
|
92
|
-
on(contents, 'destroyed', dispose);
|
|
93
|
-
record('renderer-recovery-attached');
|
|
94
|
-
return dispose;
|
|
95
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {EventEmitter} from 'node:events'
|
|
4
|
-
import {installRendererRecovery} from './renderer-recovery.mjs'
|
|
5
|
-
|
|
6
|
-
function fixture(options={}) {
|
|
7
|
-
const app=new EventEmitter(),window=new EventEmitter(),contents=new EventEmitter(),rows=[],failures=[];
|
|
8
|
-
let reloaded=0,destroyed=false,pid=17,loading=false,clock=0,seq=0;
|
|
9
|
-
const jobs=new Map();
|
|
10
|
-
window.webContents=contents;window.isDestroyed=()=>destroyed;
|
|
11
|
-
contents.isDestroyed=()=>destroyed;contents.getOSProcessId=()=>pid;
|
|
12
|
-
contents.isLoading=()=>loading;contents.reload=()=>{reloaded++;loading=true;contents.emit('did-start-loading')};
|
|
13
|
-
const scheduler={setTimeout:(fn,ms)=>{const id=++seq;jobs.set(id,{fn,at:clock+ms});return id},clearTimeout:id=>jobs.delete(id)};
|
|
14
|
-
const dispose=installRendererRecovery({app,window,write:(event,data)=>rows.push({event,...data}),onUnavailable:reason=>failures.push(reason),repaint:()=>rows.push({event:'repaint'}),now:()=>clock,scheduler,...options});
|
|
15
|
-
const tick=ms=>{const end=clock+ms;while(true){const ready=[...jobs].filter(([,j])=>j.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!ready)break;clock=ready[1].at;jobs.delete(ready[0]);ready[1].fn()}clock=end};
|
|
16
|
-
return {app,window,contents,rows,failures,dispose,tick,jobs,get reloads(){return reloaded},gone(reason='clean-exit'){pid=0;loading=false;contents.emit('render-process-gone',{}, {reason,exitCode:0,url:'private-token'})},loaded(){pid=18;loading=false;contents.emit('did-finish-load')},destroy(){destroyed=true;window.emit('closed')}}
|
|
17
|
-
}
|
|
18
|
-
test('unexpected clean exit reloads original contents and completes recovery',()=>{const f=fixture();f.gone();f.tick(300);assert.equal(f.reloads,1);f.loaded();assert.equal(f.rows.at(-1).event,'renderer-recovery-complete');assert.ok(f.rows.some(r=>r.event==='repaint'));assert.ok(!JSON.stringify(f.rows).includes('private-token'));f.dispose()})
|
|
19
|
-
test('duplicate exit before scheduled reload does not consume another attempt',()=>{const f=fixture();f.gone();f.gone();f.tick(300);assert.equal(f.reloads,1);f.loaded();f.dispose()})
|
|
20
|
-
test('normal quit and closed windows never resurrect a renderer',()=>{for(const stop of [f=>f.app.emit('before-quit'),f=>f.destroy()]){const f=fixture();f.gone();stop(f);f.tick(20000);assert.equal(f.reloads,0);f.dispose()}})
|
|
21
|
-
test('crash loop is bounded, including repeated clean exits',()=>{const f=fixture();for(let i=0;i<3;i++){f.gone();f.tick(300);f.loaded()}assert.equal(f.reloads,2);assert.equal(f.failures.length,1);f.tick(60000);f.gone();f.tick(300);assert.equal(f.reloads,3);f.dispose()})
|
|
22
|
-
test('manual reload wins over queued automatic recovery',()=>{const f=fixture();f.gone();f.contents.reload();f.loaded();f.tick(20000);assert.equal(f.reloads,1);assert.equal(f.failures.length,0);f.dispose()})
|
|
23
|
-
test('load timeout is reported once; late load may still recover',()=>{const f=fixture();f.gone();f.tick(20000);assert.equal(f.reloads,1);assert.deepEqual(f.failures,['load-timeout']);f.loaded();assert.equal(f.rows.at(-1).event,'renderer-recovery-complete');f.dispose()})
|
|
24
|
-
test('disposal cancels work and removes all owned listeners',()=>{const f=fixture();f.gone();f.dispose();f.tick(20000);assert.equal(f.reloads,0);assert.equal(f.jobs.size,0);assert.equal(f.contents.listenerCount('render-process-gone'),0);assert.equal(f.app.listenerCount('before-quit'),0)})
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Read-only process/status correlation. Stops with the selected DSH app."""
|
|
3
|
-
import datetime
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
import time
|
|
10
|
-
import urllib.parse
|
|
11
|
-
import urllib.request
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def counts(snapshot):
|
|
15
|
-
rows = snapshot.get('rows', [])
|
|
16
|
-
result = {'doing': 0, 'done': 0, 'error': 0, 'decision': 0}
|
|
17
|
-
for row in rows:
|
|
18
|
-
if row.get('ask') or row.get('approval'):
|
|
19
|
-
result['decision'] += 1
|
|
20
|
-
elif row.get('busy'):
|
|
21
|
-
result['doing'] += 1
|
|
22
|
-
elif (row.get('lastTurn') or {}).get('failed'):
|
|
23
|
-
result['error'] += 1
|
|
24
|
-
elif row.get('unread'):
|
|
25
|
-
result['done'] += 1
|
|
26
|
-
result['sidebarFresh'] = isinstance(snapshot.get('sidebarSyncedAt'), (int, float))
|
|
27
|
-
return result
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def status(runtime):
|
|
31
|
-
file = json.loads(runtime.read_text())
|
|
32
|
-
origin = urllib.parse.urlsplit(file['origin'])
|
|
33
|
-
if origin.scheme != 'http' or origin.hostname not in ('127.0.0.1', 'localhost', '::1'):
|
|
34
|
-
raise ValueError('not loopback')
|
|
35
|
-
request = urllib.request.Request(file['origin'] + '/dsh-notch/status', headers={'Authorization': 'Bearer ' + file['token']})
|
|
36
|
-
# Never send local credentials through a machine proxy or follow redirects.
|
|
37
|
-
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
38
|
-
def redirect_request(self, *args, **kwargs):
|
|
39
|
-
return None
|
|
40
|
-
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
41
|
-
with opener.open(request, timeout=1) as response:
|
|
42
|
-
snapshot = json.load(response)
|
|
43
|
-
if snapshot.get('ok') is not True:
|
|
44
|
-
raise ValueError('invalid snapshot')
|
|
45
|
-
return counts(snapshot)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def processes(app_pid, base):
|
|
49
|
-
result = []
|
|
50
|
-
output = subprocess.check_output(['ps', '-axo', 'pid=,ppid=,rss=,comm='], text=True, timeout=2)
|
|
51
|
-
for line in output.splitlines():
|
|
52
|
-
fields = line.strip().split(None, 3)
|
|
53
|
-
if len(fields) != 4:
|
|
54
|
-
continue
|
|
55
|
-
pid, parent, rss, command = fields
|
|
56
|
-
if command.startswith(base) or (int(parent) == app_pid and command.endswith('/node')):
|
|
57
|
-
role = ('renderer' if 'DSH Helper (Renderer)' in command else
|
|
58
|
-
'notch' if command.endswith('/dsh-notch') else
|
|
59
|
-
'app' if int(pid) == app_pid else
|
|
60
|
-
'host' if command.endswith('/node') else 'helper')
|
|
61
|
-
result.append({'pid': int(pid), 'role': role, 'rssKB': int(rss)})
|
|
62
|
-
return result
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def main():
|
|
66
|
-
app_pid, app_path, output = int(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
|
|
67
|
-
runtime = Path.home() / '.dsh/dsh-notch/runtime.json'
|
|
68
|
-
output.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
-
previous = None
|
|
70
|
-
heartbeat = 0
|
|
71
|
-
while True:
|
|
72
|
-
began = time.monotonic()
|
|
73
|
-
try:
|
|
74
|
-
rows = processes(app_pid, str(app_path / 'Contents') + '/')
|
|
75
|
-
alive = any(row['pid'] == app_pid and row['role'] == 'app' for row in rows)
|
|
76
|
-
try:
|
|
77
|
-
board = status(runtime) if alive else {'unavailable': 'app-ended'}
|
|
78
|
-
except Exception as error:
|
|
79
|
-
board = {'unavailable': type(error).__name__}
|
|
80
|
-
signature = ([(r['pid'], r['role']) for r in rows], board)
|
|
81
|
-
if signature != previous or began - heartbeat >= 30:
|
|
82
|
-
event = {'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
83
|
-
'event': 'state-change' if signature != previous else 'heartbeat',
|
|
84
|
-
'appPid': app_pid, 'processes': rows, 'notch': board,
|
|
85
|
-
'sampleDurationMs': round((time.monotonic()-began)*1000),
|
|
86
|
-
'exitReason': 'unavailable-to-external-observer'}
|
|
87
|
-
if output.exists() and output.stat().st_size >= 1024 * 1024:
|
|
88
|
-
os.replace(output, str(output) + '.1')
|
|
89
|
-
with output.open('a') as file:
|
|
90
|
-
file.write(json.dumps(event) + '\n')
|
|
91
|
-
os.chmod(output, 0o600)
|
|
92
|
-
previous, heartbeat = signature, began
|
|
93
|
-
if not alive:
|
|
94
|
-
return
|
|
95
|
-
except (OSError, subprocess.SubprocessError):
|
|
96
|
-
pass
|
|
97
|
-
time.sleep(max(.1, 1 - (time.monotonic() - began)))
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == '__main__':
|
|
101
|
-
main()
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import importlib.util
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
import json
|
|
4
|
-
import unittest
|
|
5
|
-
spec=importlib.util.spec_from_file_location('watch',Path(__file__).with_name('completion-watch.py'))
|
|
6
|
-
watch=importlib.util.module_from_spec(spec);spec.loader.exec_module(watch)
|
|
7
|
-
class Tests(unittest.TestCase):
|
|
8
|
-
def test_counts_match_native_precedence_and_redact(self):
|
|
9
|
-
snapshot={'rows':[{'title':'secret','id':'secret','unread':True} for _ in range(5)]+[
|
|
10
|
-
{'busy':True,'unread':True}, {'lastTurn':{'failed':True},'unread':True},
|
|
11
|
-
{'ask':{'id':'secret'},'busy':True}], 'sidebarSyncedAt':123,'token':'secret'}
|
|
12
|
-
result=watch.counts(snapshot)
|
|
13
|
-
self.assertEqual(result,{'doing':1,'done':5,'error':1,'decision':1,'sidebarFresh':True})
|
|
14
|
-
self.assertNotIn('secret',json.dumps(result))
|
|
15
|
-
def test_expired_mirror_recorded_separately(self):
|
|
16
|
-
self.assertEqual(watch.counts({'rows':[]})['sidebarFresh'],False)
|
|
17
|
-
if __name__=='__main__':unittest.main()
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import {appendFileSync, mkdirSync, renameSync, statSync} from 'node:fs'
|
|
2
|
-
import {dirname} from 'node:path'
|
|
3
|
-
import {freemem,totalmem} from 'node:os'
|
|
4
|
-
|
|
5
|
-
// Deliberately excludes URLs, page text, tokens and arbitrary error messages.
|
|
6
|
-
export function createDiagnosticLog(path, {maxBytes=1024*1024}={}) {
|
|
7
|
-
return (event, data={}) => {
|
|
8
|
-
try {
|
|
9
|
-
mkdirSync(dirname(path),{recursive:true,mode:0o700})
|
|
10
|
-
try {if(statSync(path).size>=maxBytes)renameSync(path,path+'.1')} catch {}
|
|
11
|
-
appendFileSync(path,JSON.stringify({at:new Date().toISOString(),event,...data})+'\n',{mode:0o600})
|
|
12
|
-
} catch {} // Diagnostic failure must never break the shell.
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
export function installRendererDiagnostics({app,contents,window,powerMonitor,write,related=()=>({}),intervalMs=30000}) {
|
|
16
|
-
const disposers=[]
|
|
17
|
-
const on=(emitter,event,fn)=>{emitter.on(event,fn);disposers.push(()=>emitter.removeListener(event,fn))}
|
|
18
|
-
let lastRendererPid=null, loadedAt=null
|
|
19
|
-
const read=(object,method)=>{try{return object?.[method]?.()}catch{return undefined}}
|
|
20
|
-
const snapshot=()=>{
|
|
21
|
-
let processes=[]
|
|
22
|
-
try {processes=app.getAppMetrics().map(p=>({pid:p.pid,type:p.type,cpu:p.cpu?.percentCPUUsage,rssKB:p.memory?.workingSetSize,peakKB:p.memory?.peakWorkingSetSize}))}catch{}
|
|
23
|
-
let rendererPid=null
|
|
24
|
-
try{rendererPid=contents.getOSProcessId()}catch{}
|
|
25
|
-
if(rendererPid>0)lastRendererPid=rendererPid
|
|
26
|
-
return {appPid:process.pid,contentsId:contents.id,rendererPid,lastRendererPid,
|
|
27
|
-
pageAgeMs:loadedAt===null?null:Date.now()-loadedAt,
|
|
28
|
-
systemIdleSeconds:read(powerMonitor,'getSystemIdleTime'),
|
|
29
|
-
windowVisible:read(window,'isVisible'),windowMinimized:read(window,'isMinimized'),
|
|
30
|
-
windowFocused:read(window,'isFocused'),appHidden:read(app,'isHidden'),
|
|
31
|
-
loading:read(contents,'isLoading'),backgroundThrottling:read(contents,'getBackgroundThrottling'),
|
|
32
|
-
processes,freeMemoryBytes:freemem(),totalMemoryBytes:totalmem(),...related()}
|
|
33
|
-
}
|
|
34
|
-
const record=(event,data={})=>{try{write(event,{...snapshot(),...data})}catch{}}
|
|
35
|
-
on(contents,'render-process-gone',(_event,details)=>record('renderer-exited',{reason:details.reason,exitCode:details.exitCode}))
|
|
36
|
-
on(contents,'unresponsive',()=>record('renderer-unresponsive'))
|
|
37
|
-
on(contents,'responsive',()=>record('renderer-responsive'))
|
|
38
|
-
on(contents,'did-start-loading',()=>record('page-loading'))
|
|
39
|
-
on(contents,'did-finish-load',()=>{loadedAt=Date.now();record('page-loaded')})
|
|
40
|
-
on(contents,'did-fail-load',(_event,errorCode,_description,_url,isMainFrame)=>record('page-load-failed',{errorCode,isMainFrame}))
|
|
41
|
-
on(app,'child-process-gone',(_event,details)=>record('child-exited',{type:details.type,reason:details.reason,exitCode:details.exitCode}))
|
|
42
|
-
// No URLs, navigation arguments, input text or session content are recorded.
|
|
43
|
-
for(const event of ['render-view-deleted','render-view-ready','dom-ready','will-prevent-unload'])
|
|
44
|
-
on(contents,event,()=>record(event))
|
|
45
|
-
if(window)for(const event of ['show','hide','minimize','restore','focus','blur','resize','close','closed'])
|
|
46
|
-
on(window,event,()=>record('window-'+event))
|
|
47
|
-
if(powerMonitor)for(const event of ['suspend','resume','lock-screen','unlock-screen'])
|
|
48
|
-
on(powerMonitor,event,()=>record('system-'+event))
|
|
49
|
-
on(app,'before-quit',()=>record('app-before-quit'))
|
|
50
|
-
const timer=setInterval(()=>record('heartbeat'),intervalMs);timer.unref?.()
|
|
51
|
-
let disposed=false
|
|
52
|
-
function dispose(){if(disposed)return;disposed=true;clearInterval(timer);for(const fn of disposers)fn()}
|
|
53
|
-
on(contents,'destroyed',()=>{record('contents-destroyed');dispose()})
|
|
54
|
-
record('diagnostics-attached')
|
|
55
|
-
return dispose
|
|
56
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test';import assert from 'node:assert/strict';import {EventEmitter} from 'node:events';
|
|
2
|
-
import {mkdtempSync,readFileSync,rmSync,existsSync} from 'node:fs';import {tmpdir} from 'node:os';
|
|
3
|
-
import {createDiagnosticLog,installRendererDiagnostics} from './renderer-diagnostics.mjs';
|
|
4
|
-
function fixture(){const app=new EventEmitter(),contents=new EventEmitter();app.getAppMetrics=()=>[{pid:7,type:'Tab',memory:{workingSetSize:123}}];contents.id=3;contents.getOSProcessId=()=>7;return {app,contents}}
|
|
5
|
-
test('renderer termination retains reason and exit code, strips secrets',()=>{const f=fixture(),rows=[];const dispose=installRendererDiagnostics({...f,write:(event,data)=>rows.push({event,...data})});f.contents.emit('render-process-gone',{}, {reason:'oom',exitCode:137,url:'secret',message:'secret'});const row=rows.at(-1);assert.equal(row.event,'renderer-exited');assert.equal(row.reason,'oom');assert.equal(row.exitCode,137);assert.equal(row.rendererPid,7);assert.ok(!JSON.stringify(rows).includes('secret'));dispose();assert.equal(f.contents.listenerCount('render-process-gone'),0)})
|
|
6
|
-
test('secondary process and loading events exclude URLs and messages',()=>{const f=fixture(),rows=[];const dispose=installRendererDiagnostics({...f,write:(e,d)=>rows.push({e,...d})});f.contents.emit('did-fail-load',{},-2,'secret','token-url',true);f.app.emit('child-process-gone',{}, {type:'GPU',reason:'crashed',exitCode:1,name:'secret'});assert.equal(rows.at(-1).type,'GPU');assert.ok(!JSON.stringify(rows).includes('secret'));assert.ok(!JSON.stringify(rows).includes('token-url'));dispose()})
|
|
7
|
-
test('diagnostic errors cannot break shell, destruction cleans up',()=>{const f=fixture();installRendererDiagnostics({...f,write:()=>{throw Error('disk full')}});assert.doesNotThrow(()=>f.contents.emit('render-process-gone',{}, {reason:'crashed',exitCode:1}));f.contents.emit('destroyed');assert.equal(f.app.listenerCount('child-process-gone'),0)})
|
|
8
|
-
test('rotates log at limit',()=>{const dir=mkdtempSync(tmpdir()+'/notch-diag-');try{const path=dir+'/events.jsonl',write=createDiagnosticLog(path,{maxBytes:1});write('one');write('two');assert.ok(existsSync(path+'.1'));assert.equal(JSON.parse(readFileSync(path,'utf8')).event,'two')}finally{rmSync(dir,{recursive:true,force:true})}})
|
|
9
|
-
test('idle renderer loss preserves departed PID and window state without private event arguments',()=>{
|
|
10
|
-
const f=fixture(),window=new EventEmitter(),powerMonitor=new EventEmitter(),rows=[];
|
|
11
|
-
window.isVisible=()=>true;window.isMinimized=()=>false;window.isFocused=()=>false;
|
|
12
|
-
powerMonitor.getSystemIdleTime=()=>7200;
|
|
13
|
-
const dispose=installRendererDiagnostics({...f,window,powerMonitor,write:(event,data)=>rows.push({event,...data})});
|
|
14
|
-
f.contents.emit('did-finish-load');
|
|
15
|
-
f.contents.getOSProcessId=()=>0;
|
|
16
|
-
f.contents.emit('render-process-gone',{}, {reason:'clean-exit',exitCode:0});
|
|
17
|
-
const row=rows.at(-1);
|
|
18
|
-
assert.equal(row.rendererPid,0);assert.equal(row.lastRendererPid,7);
|
|
19
|
-
assert.equal(row.systemIdleSeconds,7200);assert.equal(row.windowFocused,false);
|
|
20
|
-
assert.ok(row.pageAgeMs>=0);
|
|
21
|
-
powerMonitor.emit('suspend','private');window.emit('hide','private');
|
|
22
|
-
assert.equal(rows.at(-2).event,'system-suspend');assert.equal(rows.at(-1).event,'window-hide');
|
|
23
|
-
assert.ok(!JSON.stringify(rows).includes('private'));
|
|
24
|
-
dispose();assert.equal(window.listenerCount('hide'),0);assert.equal(powerMonitor.listenerCount('suspend'),0);
|
|
25
|
-
});
|
|
26
|
-
test('unsupported or destroyed window and power APIs do not break remaining evidence',()=>{
|
|
27
|
-
const f=fixture(),window=new EventEmitter(),powerMonitor=new EventEmitter(),rows=[];
|
|
28
|
-
window.isVisible=()=>{throw Error('destroyed')};powerMonitor.getSystemIdleTime=()=>{throw Error('unsupported')};
|
|
29
|
-
const dispose=installRendererDiagnostics({...f,window,powerMonitor,write:(event,data)=>rows.push({event,...data})});
|
|
30
|
-
f.contents.emit('render-process-gone',{}, {reason:'killed',exitCode:15});
|
|
31
|
-
assert.equal(rows.at(-1).reason,'killed');assert.equal(rows.at(-1).systemIdleSeconds,undefined);dispose();
|
|
32
|
-
});
|