claude-code-session-manager 0.17.0 → 0.17.1
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/dist/assets/{TiptapBody-CQUVYNDX.js → TiptapBody-B16U7NOC.js} +1 -1
- package/dist/assets/{cssMode-8ncTkitH.js → cssMode-BlRHn_L6.js} +1 -1
- package/dist/assets/{freemarker2-DiWr5tWQ.js → freemarker2-Bk2fXLx4.js} +1 -1
- package/dist/assets/{handlebars-BdG5bj_X.js → handlebars-eLqjBxBa.js} +1 -1
- package/dist/assets/{html-BjT3cHU8.js → html-BzvHaJnM.js} +1 -1
- package/dist/assets/{htmlMode-B6lopeCZ.js → htmlMode-C9rcOKw8.js} +1 -1
- package/dist/assets/{index-CSotRGGc.css → index-BqYM-JWd.css} +1 -1
- package/dist/assets/{index-DCaakMzv.js → index-DqO1hmJF.js} +1199 -1208
- package/dist/assets/{javascript-BCMuo5_4.js → javascript-BAOAmGHx.js} +1 -1
- package/dist/assets/{jsonMode-1KRBrXXK.js → jsonMode-CM0KALCa.js} +1 -1
- package/dist/assets/{liquid-ls0rkUL4.js → liquid-Bi-cszJ-.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BA-sGQJB.js → lspLanguageFeatures-BxkipFwP.js} +1 -1
- package/dist/assets/{mdx-T7BxS13S.js → mdx-DwbRtWl-.js} +1 -1
- package/dist/assets/{python-CYW9xWwh.js → python-D-IprHOk.js} +1 -1
- package/dist/assets/{razor-D06Ehp7u.js → razor-CC6ogdu3.js} +1 -1
- package/dist/assets/{tsMode-yKkshrv7.js → tsMode-BJR_Ezbp.js} +1 -1
- package/dist/assets/{typescript-Dlkgf9PI.js → typescript-FkJpv2Nj.js} +1 -1
- package/dist/assets/{xml-BSp0bNTm.js → xml-BVuV92it.js} +1 -1
- package/dist/assets/{yaml-C8041Bt_.js → yaml-CKj9MriX.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/main/crashDiagnostics.cjs +217 -0
- package/src/main/index.cjs +15 -1
- package/src/main/lib/sendToRenderer.cjs +16 -3
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Crash & OOM diagnostics for the main process.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: the app has been dying "silently" — its own logs end
|
|
4
|
+
// mid-stream with no error line. That signature is a *native* termination
|
|
5
|
+
// (kernel OOM-kill / SIGKILL / renderer segfault), which `process.on
|
|
6
|
+
// ('uncaughtException')` can NEVER catch because no JS exception is thrown;
|
|
7
|
+
// the process just disappears. System journal confirmed repeated
|
|
8
|
+
// `oom-kill` events against the Electron renderer (Chromium tags renderers
|
|
9
|
+
// with oom_score_adj:300, making them the kernel's first victim under
|
|
10
|
+
// memory pressure). Because the app holds a `powerSaveBlocker` suspend
|
|
11
|
+
// inhibitor, its death releases the org.freedesktop.login1 inhibitor and
|
|
12
|
+
// Pop!_OS idle-suspends — hence "app crashes -> machine falls asleep".
|
|
13
|
+
//
|
|
14
|
+
// This module adds the four things needed to catch the next occurrence:
|
|
15
|
+
// 1. Chromium crash-reason hooks — render-process-gone / child-process-gone
|
|
16
|
+
// surface `reason: 'oom' | 'crashed' | 'killed'` and the dead process type.
|
|
17
|
+
// 2. A memory heartbeat — per-process working-set sampled on an interval and
|
|
18
|
+
// persisted to a sentinel file, so the LAST sample before a SIGKILL
|
|
19
|
+
// survives the crash and is logged at next boot.
|
|
20
|
+
// 3. An unclean-shutdown sentinel — if the previous run never marked a clean
|
|
21
|
+
// exit, we log a WARN at boot with that last memory sample (the OOM
|
|
22
|
+
// postmortem the app could not write while dying).
|
|
23
|
+
// 4. powerMonitor + blocker-state logging — ties suspend/resume events and
|
|
24
|
+
// the inhibitor's health to the crash timeline.
|
|
25
|
+
|
|
26
|
+
const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
let logs = null;
|
|
31
|
+
let getPowerBlockerId = () => -1;
|
|
32
|
+
let heartbeatTimer = null;
|
|
33
|
+
let sentinelPath = null;
|
|
34
|
+
let startedAtIso = null;
|
|
35
|
+
let lastSample = null;
|
|
36
|
+
|
|
37
|
+
// Warn threshold for total working set across all Electron processes. The OOM
|
|
38
|
+
// journal showed a single renderer hitting ~900 MB anon-rss before the kernel
|
|
39
|
+
// stepped in; warning at 1.5 GB total gives an early signal in the app's own
|
|
40
|
+
// log BEFORE the kill, turning a silent death into a visible ramp. Override
|
|
41
|
+
// with SM_MEM_WARN_MB.
|
|
42
|
+
const MEM_WARN_MB = Number(process.env.SM_MEM_WARN_MB) || 1500;
|
|
43
|
+
// Heartbeat cadence. 30s is fine-grained enough to catch the last sample
|
|
44
|
+
// before an OOM yet cheap (getAppMetrics is O(numProcesses), ~5-10 procs).
|
|
45
|
+
const HEARTBEAT_MS = Number(process.env.SM_MEM_HEARTBEAT_MS) || 30_000;
|
|
46
|
+
|
|
47
|
+
function sentinelFile() {
|
|
48
|
+
return path.join(app.getPath('userData'), 'logs', 'last-run.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// crashReporter collects local minidumps for renderer/GPU SIGSEGV/SIGABRT
|
|
52
|
+
// crashes (NOT OOM — a SIGKILL leaves no dump). Local-only, never uploaded.
|
|
53
|
+
// Must run before the app is ready, so call this at module require time from
|
|
54
|
+
// index.cjs's top level.
|
|
55
|
+
function startCrashReporter() {
|
|
56
|
+
try {
|
|
57
|
+
crashReporter.start({
|
|
58
|
+
productName: 'claude-code-session-manager',
|
|
59
|
+
companyName: 'session-manager',
|
|
60
|
+
uploadToServer: false,
|
|
61
|
+
compress: true,
|
|
62
|
+
});
|
|
63
|
+
} catch { /* best-effort: diagnostics must never break boot */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Snapshot per-process memory. app.getAppMetrics() returns one entry per
|
|
67
|
+
// Electron process (Browser/GPU/Tab/Utility) with workingSetSize in KB.
|
|
68
|
+
function sampleMemory() {
|
|
69
|
+
let metrics = [];
|
|
70
|
+
try { metrics = app.getAppMetrics(); } catch { /* not ready yet */ }
|
|
71
|
+
const procs = metrics.map((m) => ({
|
|
72
|
+
pid: m.pid,
|
|
73
|
+
type: m.type,
|
|
74
|
+
name: m.name || m.serviceName || undefined,
|
|
75
|
+
// workingSetSize is in KB; normalize to MB for readability.
|
|
76
|
+
mb: Math.round((m.memory?.workingSetSize || 0) / 1024),
|
|
77
|
+
}));
|
|
78
|
+
const totalMb = procs.reduce((s, p) => s + p.mb, 0);
|
|
79
|
+
const mu = process.memoryUsage();
|
|
80
|
+
return {
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
totalMb,
|
|
83
|
+
main: {
|
|
84
|
+
rssMb: Math.round(mu.rss / 1048576),
|
|
85
|
+
heapUsedMb: Math.round(mu.heapUsed / 1048576),
|
|
86
|
+
externalMb: Math.round(mu.external / 1048576),
|
|
87
|
+
},
|
|
88
|
+
powerBlockerHeld: (() => {
|
|
89
|
+
try { return powerSaveBlocker.isStarted(getPowerBlockerId()); } catch { return null; }
|
|
90
|
+
})(),
|
|
91
|
+
procs,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function persistSentinel(clean) {
|
|
96
|
+
if (!sentinelPath) return;
|
|
97
|
+
const payload = {
|
|
98
|
+
open: !clean, // true while running; flipped false on clean quit
|
|
99
|
+
pid: process.pid,
|
|
100
|
+
startedAt: startedAtIso,
|
|
101
|
+
closedAt: clean ? new Date().toISOString() : undefined,
|
|
102
|
+
lastSample,
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(sentinelPath, JSON.stringify(payload), { mode: 0o600 });
|
|
106
|
+
} catch { /* best-effort */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function heartbeat() {
|
|
110
|
+
lastSample = sampleMemory();
|
|
111
|
+
// Persist every tick so the final pre-crash sample survives a SIGKILL.
|
|
112
|
+
persistSentinel(false);
|
|
113
|
+
const top = [...lastSample.procs].sort((a, b) => b.mb - a.mb).slice(0, 4);
|
|
114
|
+
const level = lastSample.totalMb >= MEM_WARN_MB ? 'warn' : 'debug';
|
|
115
|
+
logs?.writeLine({
|
|
116
|
+
scope: 'crash-diag',
|
|
117
|
+
level,
|
|
118
|
+
message: level === 'warn' ? 'memory high' : 'memory heartbeat',
|
|
119
|
+
meta: {
|
|
120
|
+
totalMb: lastSample.totalMb,
|
|
121
|
+
mainRssMb: lastSample.main.rssMb,
|
|
122
|
+
powerBlockerHeld: lastSample.powerBlockerHeld,
|
|
123
|
+
top: top.map((p) => `${p.type}:${p.mb}MB`),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Read the previous run's sentinel. If it was still `open`, the app died
|
|
129
|
+
// without a clean quit — almost certainly the OOM-kill we're hunting. Log it
|
|
130
|
+
// as the postmortem the dying process couldn't write, including its last
|
|
131
|
+
// memory sample so we can see the footprint at time of death.
|
|
132
|
+
function checkPreviousRun() {
|
|
133
|
+
let prev = null;
|
|
134
|
+
try { prev = JSON.parse(fs.readFileSync(sentinelPath, 'utf8')); } catch { return; }
|
|
135
|
+
if (!prev || prev.open !== true) return;
|
|
136
|
+
const s = prev.lastSample;
|
|
137
|
+
logs?.writeLine({
|
|
138
|
+
scope: 'crash-diag',
|
|
139
|
+
level: 'error',
|
|
140
|
+
message: 'previous session ended UNCLEANLY (likely OOM-kill / native crash — no graceful quit)',
|
|
141
|
+
meta: {
|
|
142
|
+
prevPid: prev.pid,
|
|
143
|
+
startedAt: prev.startedAt,
|
|
144
|
+
lastSampleTs: s?.ts,
|
|
145
|
+
lastTotalMb: s?.totalMb,
|
|
146
|
+
lastMainRssMb: s?.main?.rssMb,
|
|
147
|
+
lastTop: s ? [...s.procs].sort((a, b) => b.mb - a.mb).slice(0, 4).map((p) => `${p.type}:${p.mb}MB`) : undefined,
|
|
148
|
+
powerBlockerHeldAtDeath: s?.powerBlockerHeld,
|
|
149
|
+
hint: 'Check `journalctl -k | grep -i oom` around lastSampleTs to confirm the kernel OOM-killer.',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function registerCrashHooks() {
|
|
155
|
+
// Renderer death — reason is the prize: 'oom' | 'crashed' | 'killed' |
|
|
156
|
+
// 'launch-failed' | 'integrity-failure'. This fires even when the kernel
|
|
157
|
+
// OOM-kills only the renderer (main process survives to log it).
|
|
158
|
+
app.on('render-process-gone', (_e, _wc, details) => {
|
|
159
|
+
logs?.writeLine({
|
|
160
|
+
scope: 'crash-diag',
|
|
161
|
+
level: 'error',
|
|
162
|
+
message: 'render-process-gone',
|
|
163
|
+
meta: { reason: details?.reason, exitCode: details?.exitCode, lastTotalMb: lastSample?.totalMb },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// GPU / Utility / Pepper / network-service child death.
|
|
168
|
+
app.on('child-process-gone', (_e, details) => {
|
|
169
|
+
logs?.writeLine({
|
|
170
|
+
scope: 'crash-diag',
|
|
171
|
+
level: 'error',
|
|
172
|
+
message: 'child-process-gone',
|
|
173
|
+
meta: {
|
|
174
|
+
type: details?.type,
|
|
175
|
+
name: details?.name || details?.serviceName,
|
|
176
|
+
reason: details?.reason,
|
|
177
|
+
exitCode: details?.exitCode,
|
|
178
|
+
lastTotalMb: lastSample?.totalMb,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// powerMonitor ties the suspend story to the crash timeline. If we ever log
|
|
184
|
+
// a 'suspend' while the app is alive, the inhibitor failed; if the app dies
|
|
185
|
+
// first, the unclean-shutdown postmortem at next boot is the trail instead.
|
|
186
|
+
try {
|
|
187
|
+
powerMonitor.on('suspend', () => {
|
|
188
|
+
logs?.writeLine({ scope: 'crash-diag', level: 'warn', message: 'system suspend (powerMonitor) — inhibitor did NOT hold while app alive', meta: { powerBlockerHeld: lastSample?.powerBlockerHeld } });
|
|
189
|
+
});
|
|
190
|
+
powerMonitor.on('resume', () => {
|
|
191
|
+
logs?.writeLine({ scope: 'crash-diag', level: 'info', message: 'system resume (powerMonitor)' });
|
|
192
|
+
});
|
|
193
|
+
} catch { /* powerMonitor needs a ready app on some platforms */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function init(opts) {
|
|
197
|
+
logs = opts.logs;
|
|
198
|
+
if (typeof opts.getPowerBlockerId === 'function') getPowerBlockerId = opts.getPowerBlockerId;
|
|
199
|
+
sentinelPath = sentinelFile();
|
|
200
|
+
startedAtIso = new Date().toISOString();
|
|
201
|
+
|
|
202
|
+
checkPreviousRun(); // postmortem for the previous (possibly OOM'd) run
|
|
203
|
+
persistSentinel(false); // mark this run as open
|
|
204
|
+
registerCrashHooks();
|
|
205
|
+
|
|
206
|
+
heartbeat(); // immediate baseline sample
|
|
207
|
+
heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS);
|
|
208
|
+
if (heartbeatTimer.unref) heartbeatTimer.unref(); // don't keep the loop alive
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Call from will-quit so a graceful exit is distinguishable from an OOM-kill.
|
|
212
|
+
function markCleanShutdown() {
|
|
213
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
214
|
+
persistSentinel(true);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { startCrashReporter, init, markCleanShutdown };
|
package/src/main/index.cjs
CHANGED
|
@@ -13,6 +13,11 @@ const usageMatrix = require('./usageMatrix.cjs');
|
|
|
13
13
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
14
14
|
const billing = require('./usage.cjs');
|
|
15
15
|
const logs = require('./logs.cjs');
|
|
16
|
+
const crashDiagnostics = require('./crashDiagnostics.cjs');
|
|
17
|
+
// Start the local minidump collector before app-ready (required by Electron).
|
|
18
|
+
// Catches renderer/GPU SIGSEGV/SIGABRT; OOM-kills leave no dump but are caught
|
|
19
|
+
// by the sentinel + render-process-gone hooks in crashDiagnostics.init().
|
|
20
|
+
crashDiagnostics.startCrashReporter();
|
|
16
21
|
const voiceHotkey = require('./voiceHotkey.cjs');
|
|
17
22
|
const voiceWizard = require('./voiceWizard.cjs');
|
|
18
23
|
const scheduler = require('./scheduler.cjs');
|
|
@@ -617,7 +622,7 @@ protocol.registerSchemesAsPrivileged([
|
|
|
617
622
|
const SMFILE_MIME = {
|
|
618
623
|
'.html': 'text/html', '.htm': 'text/html',
|
|
619
624
|
'.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
620
|
-
'.json': 'application/json', '.svg': 'image/svg+xml',
|
|
625
|
+
'.json': 'application/json', '.jsonl': 'application/jsonl', '.ndjson': 'application/x-ndjson', '.svg': 'image/svg+xml',
|
|
621
626
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
622
627
|
'.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
|
|
623
628
|
'.avif': 'image/avif', '.bmp': 'image/bmp',
|
|
@@ -677,6 +682,12 @@ app.whenReady().then(async () => {
|
|
|
677
682
|
logs.pruneOld();
|
|
678
683
|
logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
|
|
679
684
|
|
|
685
|
+
// Crash/OOM diagnostics: logs a postmortem if the PREVIOUS run died
|
|
686
|
+
// uncleanly (the silent OOM-kill we've been chasing), then starts the memory
|
|
687
|
+
// heartbeat + render/child-process-gone hooks for this run. See
|
|
688
|
+
// crashDiagnostics.cjs for the full rationale.
|
|
689
|
+
crashDiagnostics.init({ logs, getPowerBlockerId: () => powerBlockerId });
|
|
690
|
+
|
|
680
691
|
// Boot-time detection 1: surface `claude` binary resolution so a missing
|
|
681
692
|
// install becomes visible to the renderer instead of failing silently on
|
|
682
693
|
// first spawn attempt.
|
|
@@ -883,6 +894,9 @@ app.whenReady().then(async () => {
|
|
|
883
894
|
});
|
|
884
895
|
|
|
885
896
|
app.on('will-quit', () => {
|
|
897
|
+
// Mark a clean exit so the next boot can distinguish a graceful quit from an
|
|
898
|
+
// OOM-kill / native crash (which leaves the sentinel `open`).
|
|
899
|
+
crashDiagnostics.markCleanShutdown();
|
|
886
900
|
// PRD F1 v2 §IPC plumbing: must unregisterAll on will-quit.
|
|
887
901
|
try { globalShortcut.unregisterAll(); } catch { /* */ }
|
|
888
902
|
voiceHotkey.disposeOnQuit();
|
|
@@ -9,12 +9,25 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Send a payload on a channel iff the BrowserWindow
|
|
12
|
+
* Send a payload on a channel iff the BrowserWindow AND its render frame are alive.
|
|
13
13
|
* No-ops on null/destroyed windows so callers don't need their own guards.
|
|
14
|
+
*
|
|
15
|
+
* `window.isDestroyed()` alone is insufficient: during a reload/navigation the
|
|
16
|
+
* BrowserWindow stays alive while its underlying render frame (WebFrameMain) is
|
|
17
|
+
* disposed and recreated. In that window, `webContents.send` throws
|
|
18
|
+
* "Render frame was disposed before WebFrameMain could be accessed". The frame
|
|
19
|
+
* can also vanish between our check and the send (TOCTOU), so we both probe the
|
|
20
|
+
* webContents state and wrap the send in try/catch.
|
|
14
21
|
*/
|
|
15
22
|
function sendIfAlive(window, channel, payload) {
|
|
16
|
-
if (window
|
|
17
|
-
|
|
23
|
+
if (!window || window.isDestroyed()) return;
|
|
24
|
+
const wc = window.webContents;
|
|
25
|
+
if (!wc || wc.isDestroyed() || wc.isCrashed()) return;
|
|
26
|
+
try {
|
|
27
|
+
wc.send(channel, payload);
|
|
28
|
+
} catch {
|
|
29
|
+
// Render frame disposed mid-send (reload/teardown race). Drop the message —
|
|
30
|
+
// a stale frame has no listener anyway.
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
|