ai-or-die 0.1.45 → 0.1.46
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/.github/workflows/ci.yml +36 -0
- package/bin/supervisor.js +121 -0
- package/e2e/playwright.config.js +6 -0
- package/package.json +6 -3
- package/src/public/app.js +103 -4
- package/src/restart-manager.js +132 -0
- package/src/server.js +83 -21
- package/src/utils/session-store.js +31 -4
package/.github/workflows/ci.yml
CHANGED
|
@@ -507,6 +507,42 @@ jobs:
|
|
|
507
507
|
playwright-report/
|
|
508
508
|
retention-days: 14
|
|
509
509
|
|
|
510
|
+
test-browser-restart:
|
|
511
|
+
runs-on: ${{ matrix.os }}
|
|
512
|
+
needs: test
|
|
513
|
+
timeout-minutes: 12
|
|
514
|
+
strategy:
|
|
515
|
+
fail-fast: false
|
|
516
|
+
matrix:
|
|
517
|
+
os: [ubuntu-latest, windows-latest]
|
|
518
|
+
steps:
|
|
519
|
+
- uses: actions/checkout@v4
|
|
520
|
+
- uses: actions/setup-node@v4
|
|
521
|
+
with:
|
|
522
|
+
node-version: '22'
|
|
523
|
+
cache: 'npm'
|
|
524
|
+
- run: npm ci
|
|
525
|
+
- name: Cache Playwright browsers
|
|
526
|
+
uses: actions/cache@v4
|
|
527
|
+
with:
|
|
528
|
+
path: |
|
|
529
|
+
~/.cache/ms-playwright
|
|
530
|
+
~/AppData/Local/ms-playwright
|
|
531
|
+
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
|
532
|
+
- name: Install Playwright browsers
|
|
533
|
+
run: npx playwright install chromium --with-deps
|
|
534
|
+
- name: Run restart tests
|
|
535
|
+
run: npx playwright test --config e2e/playwright.config.js --project restart
|
|
536
|
+
- name: Upload Playwright report
|
|
537
|
+
uses: actions/upload-artifact@v4
|
|
538
|
+
if: ${{ !cancelled() }}
|
|
539
|
+
with:
|
|
540
|
+
name: playwright-restart-${{ matrix.os }}
|
|
541
|
+
path: |
|
|
542
|
+
e2e/test-results/
|
|
543
|
+
playwright-report/
|
|
544
|
+
retention-days: 14
|
|
545
|
+
|
|
510
546
|
build-binary:
|
|
511
547
|
runs-on: ${{ matrix.os }}
|
|
512
548
|
timeout-minutes: 12
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { RESTART_EXIT_CODE } = require('../src/restart-manager');
|
|
8
|
+
|
|
9
|
+
const RESTART_DELAY_MS = 1000;
|
|
10
|
+
const CRASH_RESTART_DELAY_MS = 3000;
|
|
11
|
+
const CIRCUIT_BREAKER_WINDOW_MS = 30000;
|
|
12
|
+
const CIRCUIT_BREAKER_MAX_CRASHES = 3;
|
|
13
|
+
const SHUTDOWN_TIMEOUT_MS = 10000;
|
|
14
|
+
|
|
15
|
+
const serverScript = process.env.SUPERVISOR_CHILD_SCRIPT
|
|
16
|
+
|| path.join(__dirname, 'ai-or-die.js');
|
|
17
|
+
const forwardedArgs = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
let child = null;
|
|
20
|
+
let shuttingDown = false;
|
|
21
|
+
let crashTimestamps = [];
|
|
22
|
+
let pendingRestartTimer = null;
|
|
23
|
+
|
|
24
|
+
function startServer() {
|
|
25
|
+
pendingRestartTimer = null;
|
|
26
|
+
const nodeArgs = ['--expose-gc', serverScript, ...forwardedArgs];
|
|
27
|
+
|
|
28
|
+
child = spawn(process.execPath, nodeArgs, {
|
|
29
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
30
|
+
env: { ...process.env, SUPERVISED: '1' }
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
child.on('exit', (code, signal) => {
|
|
34
|
+
child = null;
|
|
35
|
+
|
|
36
|
+
if (shuttingDown) {
|
|
37
|
+
process.exit(0);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (code === 0) {
|
|
42
|
+
// Clean shutdown — don't restart
|
|
43
|
+
console.log('[supervisor] Server exited cleanly');
|
|
44
|
+
process.exit(0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (code === RESTART_EXIT_CODE) {
|
|
49
|
+
// Restart requested — quick restart, don't count as crash
|
|
50
|
+
console.log(`[supervisor] Restart requested, respawning in ${RESTART_DELAY_MS}ms...`);
|
|
51
|
+
pendingRestartTimer = setTimeout(startServer, RESTART_DELAY_MS);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Unexpected exit — check circuit breaker
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
crashTimestamps.push(now);
|
|
58
|
+
// Remove timestamps outside the window
|
|
59
|
+
crashTimestamps = crashTimestamps.filter(t => now - t < CIRCUIT_BREAKER_WINDOW_MS);
|
|
60
|
+
|
|
61
|
+
if (crashTimestamps.length >= CIRCUIT_BREAKER_MAX_CRASHES) {
|
|
62
|
+
console.error(`[supervisor] Circuit breaker: ${CIRCUIT_BREAKER_MAX_CRASHES} crashes within ${CIRCUIT_BREAKER_WINDOW_MS / 1000}s. Stopping.`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
|
|
68
|
+
console.warn(`[supervisor] Server exited unexpectedly (${exitInfo}), restarting in ${CRASH_RESTART_DELAY_MS}ms... (crash ${crashTimestamps.length}/${CIRCUIT_BREAKER_MAX_CRASHES})`);
|
|
69
|
+
pendingRestartTimer = setTimeout(startServer, CRASH_RESTART_DELAY_MS);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
child.on('error', (err) => {
|
|
73
|
+
// Spawn errors (ENOENT, EACCES) — the child process failed to launch.
|
|
74
|
+
// Port-in-use (EADDRINUSE) errors surface as child exit codes, not here.
|
|
75
|
+
console.error('[supervisor] Failed to spawn server:', err.message);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function shutdownGracefully() {
|
|
80
|
+
if (shuttingDown) return;
|
|
81
|
+
shuttingDown = true;
|
|
82
|
+
|
|
83
|
+
// Cancel any pending restart timer to prevent spawning a new child during shutdown
|
|
84
|
+
if (pendingRestartTimer) {
|
|
85
|
+
clearTimeout(pendingRestartTimer);
|
|
86
|
+
pendingRestartTimer = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!child) {
|
|
90
|
+
process.exit(0);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Send shutdown via IPC (works on Windows, unlike SIGINT)
|
|
95
|
+
try {
|
|
96
|
+
child.send({ type: 'shutdown' });
|
|
97
|
+
} catch (_) {
|
|
98
|
+
// IPC channel may be closed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fallback: force kill after timeout
|
|
102
|
+
const killTimer = setTimeout(() => {
|
|
103
|
+
console.warn('[supervisor] Server did not exit within timeout, force killing');
|
|
104
|
+
// child may be null if exit handler fired between IPC send and this timer
|
|
105
|
+
if (child) {
|
|
106
|
+
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
setTimeout(() => process.exit(1), 1000);
|
|
109
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
110
|
+
killTimer.unref();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.on('SIGINT', shutdownGracefully);
|
|
114
|
+
// SIGTERM is not available on Windows; IPC message (below) is the Windows shutdown path
|
|
115
|
+
process.on('SIGTERM', shutdownGracefully);
|
|
116
|
+
// Allow test harness to trigger shutdown via IPC
|
|
117
|
+
process.on('message', (msg) => {
|
|
118
|
+
if (msg && msg.type === 'shutdown') shutdownGracefully();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
startServer();
|
package/e2e/playwright.config.js
CHANGED
|
@@ -127,5 +127,11 @@ module.exports = defineConfig({
|
|
|
127
127
|
name: 'mobile-journeys',
|
|
128
128
|
testMatch: '50-mobile-user-journeys.spec.js',
|
|
129
129
|
},
|
|
130
|
+
// Server restart feature tests
|
|
131
|
+
{
|
|
132
|
+
name: 'restart',
|
|
133
|
+
testMatch: '20-server-restart.spec.js',
|
|
134
|
+
timeout: 120000,
|
|
135
|
+
},
|
|
130
136
|
],
|
|
131
137
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.46",
|
|
4
4
|
"description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
"aiordie": "./bin/ai-or-die.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node bin/
|
|
12
|
-
"
|
|
11
|
+
"start": "node bin/supervisor.js",
|
|
12
|
+
"start:direct": "node bin/ai-or-die.js",
|
|
13
|
+
"dev": "node bin/supervisor.js --dev",
|
|
14
|
+
"dev:direct": "node bin/ai-or-die.js --dev",
|
|
13
15
|
"test": "mocha --exit test/*.test.js",
|
|
16
|
+
"test:integration": "mocha --exit --timeout 20000 test/supervisor-integration.test.js",
|
|
14
17
|
"test:browser": "npx playwright test --config e2e/playwright.config.js",
|
|
15
18
|
"build:bundle": "node scripts/build-sea.js bundle",
|
|
16
19
|
"build:sea": "node scripts/build-sea.js",
|
package/src/public/app.js
CHANGED
|
@@ -1151,6 +1151,64 @@ class ClaudeCodeWebInterface {
|
|
|
1151
1151
|
}
|
|
1152
1152
|
}
|
|
1153
1153
|
|
|
1154
|
+
_showMemoryWarning(message) {
|
|
1155
|
+
// Don't show duplicate warnings if banner already visible
|
|
1156
|
+
if (document.getElementById('memoryWarningBanner')) return;
|
|
1157
|
+
|
|
1158
|
+
const banner = document.createElement('div');
|
|
1159
|
+
banner.id = 'memoryWarningBanner';
|
|
1160
|
+
banner.setAttribute('role', 'alert');
|
|
1161
|
+
banner.setAttribute('aria-atomic', 'true');
|
|
1162
|
+
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:10000;background:#92400e;color:#fef3c7;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;font-family:Inter,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
|
|
1163
|
+
|
|
1164
|
+
const text = document.createElement('span');
|
|
1165
|
+
const icon = document.createElement('span');
|
|
1166
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
1167
|
+
icon.textContent = '\u26a0\ufe0f ';
|
|
1168
|
+
text.appendChild(icon);
|
|
1169
|
+
if (message.supervised) {
|
|
1170
|
+
text.appendChild(document.createTextNode(`Memory usage is high (${message.rss}). Save your work \u2014 you can restart now to keep things running smoothly.`));
|
|
1171
|
+
} else {
|
|
1172
|
+
text.appendChild(document.createTextNode(`Memory usage is high (${message.rss}). Save your work, then stop the server (Ctrl+C) and run \u201cnpm start\u201d again to free memory.`));
|
|
1173
|
+
}
|
|
1174
|
+
banner.appendChild(text);
|
|
1175
|
+
|
|
1176
|
+
const btnGroup = document.createElement('div');
|
|
1177
|
+
btnGroup.style.cssText = 'display:flex;gap:8px;flex-shrink:0;';
|
|
1178
|
+
|
|
1179
|
+
if (message.supervised) {
|
|
1180
|
+
const restartBtn = document.createElement('button');
|
|
1181
|
+
restartBtn.textContent = 'Restart Now';
|
|
1182
|
+
restartBtn.setAttribute('aria-label', 'Restart the server now to free memory');
|
|
1183
|
+
restartBtn.style.cssText = 'background:#b91c1c;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;min-height:32px;';
|
|
1184
|
+
restartBtn.addEventListener('click', () => {
|
|
1185
|
+
this.send({ type: 'restart_server' });
|
|
1186
|
+
banner.remove();
|
|
1187
|
+
});
|
|
1188
|
+
btnGroup.appendChild(restartBtn);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const dismissBtn = document.createElement('button');
|
|
1192
|
+
dismissBtn.textContent = 'Dismiss';
|
|
1193
|
+
dismissBtn.setAttribute('aria-label', 'Dismiss memory warning');
|
|
1194
|
+
dismissBtn.style.cssText = 'background:transparent;color:#fef3c7;border:1px solid #fef3c7;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:12px;min-height:32px;';
|
|
1195
|
+
dismissBtn.addEventListener('click', () => banner.remove());
|
|
1196
|
+
btnGroup.appendChild(dismissBtn);
|
|
1197
|
+
|
|
1198
|
+
banner.appendChild(btnGroup);
|
|
1199
|
+
document.body.appendChild(banner);
|
|
1200
|
+
|
|
1201
|
+
// Focus dismiss button unless user is actively typing
|
|
1202
|
+
const active = document.activeElement;
|
|
1203
|
+
const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
|
|
1204
|
+
if (!isTyping) dismissBtn.focus();
|
|
1205
|
+
|
|
1206
|
+
// Auto-dismiss after 90 seconds (longer to account for users stepping away)
|
|
1207
|
+
setTimeout(() => {
|
|
1208
|
+
if (banner.parentNode) banner.remove();
|
|
1209
|
+
}, 90000);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1154
1212
|
_handleVoiceMessage(message) {
|
|
1155
1213
|
var btn = document.getElementById('voiceInputBtn');
|
|
1156
1214
|
switch (message.type) {
|
|
@@ -1409,6 +1467,15 @@ class ClaudeCodeWebInterface {
|
|
|
1409
1467
|
|
|
1410
1468
|
this.socket.onopen = () => {
|
|
1411
1469
|
this.reconnectAttempts = 0;
|
|
1470
|
+
// Clear server restart state if we were reconnecting after a restart
|
|
1471
|
+
if (this._serverRestarting) {
|
|
1472
|
+
this._serverRestarting = false;
|
|
1473
|
+
this._restartReconnectAttempts = 0;
|
|
1474
|
+
if (this._restartTimeout) {
|
|
1475
|
+
clearTimeout(this._restartTimeout);
|
|
1476
|
+
this._restartTimeout = null;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1412
1479
|
this.updateStatus('Connected');
|
|
1413
1480
|
console.log('Connected to server');
|
|
1414
1481
|
|
|
@@ -1443,7 +1510,14 @@ class ClaudeCodeWebInterface {
|
|
|
1443
1510
|
};
|
|
1444
1511
|
|
|
1445
1512
|
this.socket.onclose = (event) => {
|
|
1446
|
-
|
|
1513
|
+
// During server restart, don't count failures against reconnect budget
|
|
1514
|
+
// but still use backoff to avoid thundering herd
|
|
1515
|
+
if (this._serverRestarting) {
|
|
1516
|
+
this.updateStatus('Restarting \u2014 reconnecting\u2026');
|
|
1517
|
+
const restartBackoff = Math.min(2000 * Math.pow(1.5, this._restartReconnectAttempts || 0), 15000);
|
|
1518
|
+
this._restartReconnectAttempts = (this._restartReconnectAttempts || 0) + 1;
|
|
1519
|
+
setTimeout(() => this.reconnect(), restartBackoff);
|
|
1520
|
+
} else if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
1447
1521
|
this.updateStatus('Reconnecting...');
|
|
1448
1522
|
setTimeout(() => this.reconnect(), Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000));
|
|
1449
1523
|
this.reconnectAttempts++;
|
|
@@ -1702,10 +1776,13 @@ class ClaudeCodeWebInterface {
|
|
|
1702
1776
|
if (isNewSession) {
|
|
1703
1777
|
console.log('[session_joined] New session detected, showing start prompt');
|
|
1704
1778
|
this.showOverlay('startPrompt');
|
|
1779
|
+
} else if (message.wasActive && message.agent) {
|
|
1780
|
+
console.log('[session_joined] Session was active before server restart, agent:', message.agent);
|
|
1781
|
+
const toolAlias = this.getAlias(message.agent) || message.agent;
|
|
1782
|
+
this.terminal.writeln(`\r\n\x1b[33mThe server was restarted and ${toolAlias} was stopped. Your session history is preserved \u2014 click \u201cStart ${toolAlias}\u201d below to pick up where you left off.\x1b[0m`);
|
|
1783
|
+
this.showOverlay('startPrompt');
|
|
1705
1784
|
} else {
|
|
1706
|
-
console.log('[session_joined] Existing session with stopped
|
|
1707
|
-
// For existing sessions where Claude has stopped, show start prompt
|
|
1708
|
-
// This allows the user to restart Claude in the same session
|
|
1785
|
+
console.log('[session_joined] Existing session with stopped tool, showing restart prompt');
|
|
1709
1786
|
this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('claude')} has stopped in this session. Click "Start ${this.getAlias('claude')}" to restart.\x1b[0m`);
|
|
1710
1787
|
this.showOverlay('startPrompt');
|
|
1711
1788
|
}
|
|
@@ -1971,6 +2048,28 @@ class ClaudeCodeWebInterface {
|
|
|
1971
2048
|
this._handleVoiceMessage(message);
|
|
1972
2049
|
break;
|
|
1973
2050
|
|
|
2051
|
+
// Server memory and restart events
|
|
2052
|
+
case 'memory_warning':
|
|
2053
|
+
this._showMemoryWarning(message);
|
|
2054
|
+
break;
|
|
2055
|
+
|
|
2056
|
+
case 'server_restarting':
|
|
2057
|
+
console.log('[server_restarting] Server restart imminent');
|
|
2058
|
+
this._serverRestarting = true;
|
|
2059
|
+
this._restartReconnectAttempts = 0;
|
|
2060
|
+
this.reconnectAttempts = 0;
|
|
2061
|
+
this.updateStatus('Restarting \u2014 please wait\u2026');
|
|
2062
|
+
// Start a 60-second timeout — if server doesn't come back, show error
|
|
2063
|
+
if (this._restartTimeout) clearTimeout(this._restartTimeout);
|
|
2064
|
+
this._restartTimeout = setTimeout(() => {
|
|
2065
|
+
if (this._serverRestarting) {
|
|
2066
|
+
this._serverRestarting = false;
|
|
2067
|
+
this.updateStatus('Disconnected');
|
|
2068
|
+
this.showError('The server did not come back after restarting.\n\n\u2022 Refresh this page to try reconnecting\n\u2022 If the problem persists, restart the server manually with \u201cnpm start\u201d');
|
|
2069
|
+
}
|
|
2070
|
+
}, 60000);
|
|
2071
|
+
break;
|
|
2072
|
+
|
|
1974
2073
|
default:
|
|
1975
2074
|
console.log('Unknown message type:', message.type);
|
|
1976
2075
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const RESTART_EXIT_CODE = 75;
|
|
4
|
+
const MEMORY_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
5
|
+
const NOTIFICATION_THROTTLE_MS = 30 * 60 * 1000; // 30 minutes
|
|
6
|
+
const RESTART_BROADCAST_DELAY_MS = 500;
|
|
7
|
+
const MIN_RESTART_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes between restarts
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Memory monitoring and restart trigger.
|
|
11
|
+
*
|
|
12
|
+
* This is NOT a supervisor — process lifecycle is managed by bin/supervisor.js.
|
|
13
|
+
* This module only:
|
|
14
|
+
* 1. Monitors memory and triggers GC when RSS exceeds a threshold
|
|
15
|
+
* 2. Notifies clients when memory is critically high
|
|
16
|
+
* 3. Initiates a restart by broadcasting to clients, then delegating to
|
|
17
|
+
* the server's existing handleShutdown() with exit code 75
|
|
18
|
+
*/
|
|
19
|
+
class RestartManager {
|
|
20
|
+
constructor(server) {
|
|
21
|
+
this.server = server;
|
|
22
|
+
this.gcThresholdBytes = (parseInt(process.env.MEMORY_GC_THRESHOLD_MB, 10) || 1024) * 1024 * 1024;
|
|
23
|
+
this.warnThresholdBytes = (parseInt(process.env.MEMORY_WARN_THRESHOLD_MB, 10) || 2048) * 1024 * 1024;
|
|
24
|
+
this._lastWarningTime = 0;
|
|
25
|
+
this._lastRestartTime = 0;
|
|
26
|
+
this._monitorInterval = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
startMemoryMonitoring() {
|
|
30
|
+
this._monitorInterval = setInterval(() => this._checkMemory(), MEMORY_CHECK_INTERVAL_MS);
|
|
31
|
+
// Don't block process exit
|
|
32
|
+
if (this._monitorInterval.unref) this._monitorInterval.unref();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stopMemoryMonitoring() {
|
|
36
|
+
if (this._monitorInterval) {
|
|
37
|
+
clearInterval(this._monitorInterval);
|
|
38
|
+
this._monitorInterval = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_checkMemory() {
|
|
43
|
+
const mem = process.memoryUsage();
|
|
44
|
+
const rssMB = (mem.rss / (1024 * 1024)).toFixed(1);
|
|
45
|
+
const heapMB = (mem.heapUsed / (1024 * 1024)).toFixed(1);
|
|
46
|
+
|
|
47
|
+
// Schedule GC via setImmediate so in-flight I/O (WebSocket frames, HTTP responses)
|
|
48
|
+
// drains first. global.gc() is synchronous and can stall the event loop 100-300ms.
|
|
49
|
+
if (mem.rss > this.gcThresholdBytes && typeof global.gc === 'function') {
|
|
50
|
+
console.log(`[memory] RSS ${rssMB} MB exceeds GC threshold, scheduling garbage collection...`);
|
|
51
|
+
setImmediate(() => this._runGc());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Notify user when RSS exceeds warning threshold (throttled)
|
|
55
|
+
if (mem.rss > this.warnThresholdBytes) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (now - this._lastWarningTime >= NOTIFICATION_THROTTLE_MS) {
|
|
58
|
+
this._lastWarningTime = now;
|
|
59
|
+
console.warn(`[memory] RSS ${rssMB} MB exceeds warning threshold (${(this.warnThresholdBytes / (1024 * 1024)).toFixed(0)} MB). Notifying clients.`);
|
|
60
|
+
this.server.broadcastToAll({
|
|
61
|
+
type: 'memory_warning',
|
|
62
|
+
rss: `${rssMB} MB`,
|
|
63
|
+
rssBytes: mem.rss,
|
|
64
|
+
heapUsed: `${heapMB} MB`,
|
|
65
|
+
heapUsedBytes: mem.heapUsed,
|
|
66
|
+
threshold: `${(this.warnThresholdBytes / (1024 * 1024)).toFixed(0)} MB`,
|
|
67
|
+
supervised: this.server.supervised
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_runGc() {
|
|
74
|
+
const before = process.memoryUsage().rss;
|
|
75
|
+
// Try minor GC first (young generation, ~5ms)
|
|
76
|
+
try { global.gc({ type: 'minor' }); } catch (_) { /* ignore */ }
|
|
77
|
+
|
|
78
|
+
const afterMinor = process.memoryUsage().rss;
|
|
79
|
+
if (afterMinor > this.gcThresholdBytes) {
|
|
80
|
+
// Minor GC wasn't enough, do full GC (~100-300ms)
|
|
81
|
+
try { global.gc(); } catch (_) { /* ignore */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const after = process.memoryUsage().rss;
|
|
85
|
+
const reclaimedMB = ((before - after) / (1024 * 1024)).toFixed(1);
|
|
86
|
+
console.log(`[memory] GC complete. Reclaimed ${reclaimedMB} MB. RSS: ${(after / (1024 * 1024)).toFixed(1)} MB`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async initiateRestart(reason = 'manual') {
|
|
90
|
+
// Guard: reuse the server's existing shutdown guard
|
|
91
|
+
if (this.server.isShuttingDown) {
|
|
92
|
+
console.log('[restart] Already shutting down, ignoring restart request');
|
|
93
|
+
return 'already_shutting_down';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Rate limit: prevent restart-loop DoS
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (now - this._lastRestartTime < MIN_RESTART_INTERVAL_MS) {
|
|
99
|
+
const waitSec = Math.ceil((MIN_RESTART_INTERVAL_MS - (now - this._lastRestartTime)) / 1000);
|
|
100
|
+
console.log(`[restart] Rate limited, ${waitSec}s remaining before next restart allowed`);
|
|
101
|
+
return 'rate_limited';
|
|
102
|
+
}
|
|
103
|
+
this._lastRestartTime = now;
|
|
104
|
+
|
|
105
|
+
console.log(`[restart] Initiating restart (reason: ${reason})`);
|
|
106
|
+
|
|
107
|
+
// Broadcast restart notification to all clients before shutdown begins.
|
|
108
|
+
// Wrapped in try/catch: a throw here (e.g., half-closed socket) must not
|
|
109
|
+
// abort the restart — the broadcast is best-effort, the shutdown is mandatory.
|
|
110
|
+
try {
|
|
111
|
+
this.server.broadcastToAll({
|
|
112
|
+
type: 'server_restarting',
|
|
113
|
+
reason
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.warn('[restart] Failed to broadcast restart notification:', e.message);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Brief wait for WebSocket frames to be delivered
|
|
120
|
+
await new Promise(r => setTimeout(r, RESTART_BROADCAST_DELAY_MS));
|
|
121
|
+
|
|
122
|
+
// Delegate to the server's single shutdown path with restart exit code
|
|
123
|
+
await this.server.handleShutdown(RESTART_EXIT_CODE);
|
|
124
|
+
return 'restarting';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = RestartManager;
|
|
129
|
+
module.exports.RESTART_EXIT_CODE = RESTART_EXIT_CODE;
|
|
130
|
+
module.exports.MEMORY_CHECK_INTERVAL_MS = MEMORY_CHECK_INTERVAL_MS;
|
|
131
|
+
module.exports.NOTIFICATION_THROTTLE_MS = NOTIFICATION_THROTTLE_MS;
|
|
132
|
+
module.exports.MIN_RESTART_INTERVAL_MS = MIN_RESTART_INTERVAL_MS;
|
package/src/server.js
CHANGED
|
@@ -21,6 +21,7 @@ const { VSCodeTunnelManager } = require('./vscode-tunnel');
|
|
|
21
21
|
const InstallAdvisor = require('./install-advisor');
|
|
22
22
|
const SttEngine = require('./stt-engine');
|
|
23
23
|
const CircularBuffer = require('./utils/circular-buffer');
|
|
24
|
+
const RestartManager = require('./restart-manager');
|
|
24
25
|
|
|
25
26
|
/** Foreground/background session priority constants */
|
|
26
27
|
const COALESCE_MS_FG = 16; // 60 flushes/sec for active session
|
|
@@ -77,6 +78,9 @@ class ClaudeCodeWebServer {
|
|
|
77
78
|
this.activityBroadcastTimestamps = new Map(); // sessionId -> last broadcast timestamp
|
|
78
79
|
this.startTime = Date.now(); // Track server start time
|
|
79
80
|
this.isShuttingDown = false; // Flag to prevent duplicate shutdown
|
|
81
|
+
this.supervised = typeof process.send === 'function'; // Running under supervisor with IPC
|
|
82
|
+
this.restartManager = new RestartManager(this);
|
|
83
|
+
this.restartManager.startMemoryMonitoring();
|
|
80
84
|
// Commands dropdown removed
|
|
81
85
|
// Assistant aliases (for UI display only)
|
|
82
86
|
this.aliases = {
|
|
@@ -90,6 +94,7 @@ class ClaudeCodeWebServer {
|
|
|
90
94
|
this.setupExpress();
|
|
91
95
|
this._sessionsLoaded = this.loadPersistedSessions();
|
|
92
96
|
this.setupAutoSave();
|
|
97
|
+
this.setupIpcListener();
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
async loadPersistedSessions() {
|
|
@@ -144,7 +149,7 @@ class ClaudeCodeWebServer {
|
|
|
144
149
|
? this.sessionStore.serializeForSave(this.claudeSessions)
|
|
145
150
|
: JSON.stringify([...this.claudeSessions.entries()]);
|
|
146
151
|
const fs = require('fs');
|
|
147
|
-
fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data);
|
|
152
|
+
fs.writeFileSync(this.sessionStore.sessionsFile + '.crash', data, { mode: 0o600 });
|
|
148
153
|
} catch (saveErr) {
|
|
149
154
|
console.error('Failed to save sessions on crash:', saveErr);
|
|
150
155
|
}
|
|
@@ -155,6 +160,22 @@ class ClaudeCodeWebServer {
|
|
|
155
160
|
// Don't swallow — let it propagate to uncaughtException on Node 15+
|
|
156
161
|
});
|
|
157
162
|
}
|
|
163
|
+
|
|
164
|
+
setupIpcListener() {
|
|
165
|
+
if (!this.supervised) return;
|
|
166
|
+
// When running under the supervisor, listen for graceful shutdown via IPC
|
|
167
|
+
process.on('message', (msg) => {
|
|
168
|
+
if (msg && msg.type === 'shutdown') {
|
|
169
|
+
console.log('Received shutdown request via IPC');
|
|
170
|
+
this.handleShutdown();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// If the supervisor crashes, continue running standalone
|
|
174
|
+
process.on('disconnect', () => {
|
|
175
|
+
console.warn('IPC channel disconnected (supervisor may have crashed). Continuing standalone.');
|
|
176
|
+
this.supervised = false;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
158
179
|
|
|
159
180
|
setTunnelManager(tm) {
|
|
160
181
|
this.tunnelManager = tm;
|
|
@@ -167,29 +188,24 @@ class ClaudeCodeWebServer {
|
|
|
167
188
|
await this.sessionStore.saveSessions(this.claudeSessions);
|
|
168
189
|
}
|
|
169
190
|
|
|
170
|
-
async handleShutdown() {
|
|
191
|
+
async handleShutdown(exitCode = 0) {
|
|
171
192
|
// Prevent multiple shutdown attempts
|
|
172
193
|
if (this.isShuttingDown) {
|
|
173
194
|
return;
|
|
174
195
|
}
|
|
175
196
|
this.isShuttingDown = true;
|
|
176
197
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
// Stop all VS Code tunnels
|
|
186
|
-
await this.vscodeTunnel.stopAll();
|
|
187
|
-
// Clean up temp images for all sessions
|
|
188
|
-
for (const [, session] of this.claudeSessions) {
|
|
189
|
-
this.cleanupSessionImages(session);
|
|
190
|
-
}
|
|
198
|
+
// Hard timeout: if close() hangs, force exit (protects unsupervised mode)
|
|
199
|
+
const forceExitTimer = setTimeout(() => {
|
|
200
|
+
console.error(`Shutdown timed out after 15s, forcing exit (code ${exitCode})`);
|
|
201
|
+
process.exit(exitCode);
|
|
202
|
+
}, 15000);
|
|
203
|
+
forceExitTimer.unref();
|
|
204
|
+
|
|
205
|
+
console.log(`\nGracefully shutting down (exit code: ${exitCode})...`);
|
|
191
206
|
await this.close();
|
|
192
|
-
|
|
207
|
+
clearTimeout(forceExitTimer);
|
|
208
|
+
process.exit(exitCode);
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
isPathWithinBase(targetPath) {
|
|
@@ -1483,6 +1499,23 @@ class ClaudeCodeWebServer {
|
|
|
1483
1499
|
this.sendToWebSocket(wsInfo.ws, { type: 'pong' });
|
|
1484
1500
|
break;
|
|
1485
1501
|
|
|
1502
|
+
case 'restart_server':
|
|
1503
|
+
if (!this.supervised) {
|
|
1504
|
+
this.sendToWebSocket(wsInfo.ws, {
|
|
1505
|
+
type: 'error',
|
|
1506
|
+
message: 'Server is not running in supervised mode. Restart manually.'
|
|
1507
|
+
});
|
|
1508
|
+
} else if (this.restartManager) {
|
|
1509
|
+
const result = await this.restartManager.initiateRestart('user_requested');
|
|
1510
|
+
if (result === 'rate_limited') {
|
|
1511
|
+
this.sendToWebSocket(wsInfo.ws, {
|
|
1512
|
+
type: 'error',
|
|
1513
|
+
message: 'Restart was requested too recently. Please wait a few minutes.'
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
break;
|
|
1518
|
+
|
|
1486
1519
|
case 'get_usage':
|
|
1487
1520
|
this.handleGetUsage(wsInfo);
|
|
1488
1521
|
break;
|
|
@@ -1650,6 +1683,8 @@ class ClaudeCodeWebServer {
|
|
|
1650
1683
|
sessionName: session.name,
|
|
1651
1684
|
workingDir: session.workingDir,
|
|
1652
1685
|
active: session.active,
|
|
1686
|
+
wasActive: session.wasActive || false,
|
|
1687
|
+
agent: session.agent || null,
|
|
1653
1688
|
outputBuffer: session.outputBuffer.slice(-200) // Send last 200 lines
|
|
1654
1689
|
});
|
|
1655
1690
|
|
|
@@ -1873,6 +1908,14 @@ class ClaudeCodeWebServer {
|
|
|
1873
1908
|
});
|
|
1874
1909
|
}
|
|
1875
1910
|
|
|
1911
|
+
broadcastToAll(data) {
|
|
1912
|
+
for (const [, wsInfo] of this.webSocketConnections) {
|
|
1913
|
+
if (wsInfo.ws.readyState === WebSocket.OPEN) {
|
|
1914
|
+
this.sendToWebSocket(wsInfo.ws, data);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1876
1919
|
// Sends a lightweight event to all WebSocket connections that are NOT joined
|
|
1877
1920
|
// to the specified session. This enables clients to track activity in background
|
|
1878
1921
|
// sessions for notification purposes without receiving full terminal output.
|
|
@@ -2066,7 +2109,7 @@ class ClaudeCodeWebServer {
|
|
|
2066
2109
|
// Save sessions before closing
|
|
2067
2110
|
await this.saveSessionsToDisk(true);
|
|
2068
2111
|
|
|
2069
|
-
// Clear
|
|
2112
|
+
// Clear all intervals
|
|
2070
2113
|
if (this.autoSaveInterval) {
|
|
2071
2114
|
clearInterval(this.autoSaveInterval);
|
|
2072
2115
|
}
|
|
@@ -2077,14 +2120,32 @@ class ClaudeCodeWebServer {
|
|
|
2077
2120
|
clearInterval(this.sessionEvictionInterval);
|
|
2078
2121
|
}
|
|
2079
2122
|
|
|
2123
|
+
// Stop memory monitoring to release the interval timer
|
|
2124
|
+
if (this.restartManager) {
|
|
2125
|
+
this.restartManager.stopMemoryMonitoring();
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Stop all VS Code tunnels
|
|
2129
|
+
try { await this.vscodeTunnel.stopAll(); } catch (_) { /* ignore */ }
|
|
2130
|
+
|
|
2131
|
+
// Clean up temp images for all sessions
|
|
2132
|
+
for (const [, session] of this.claudeSessions) {
|
|
2133
|
+
this.cleanupSessionImages(session);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2080
2136
|
if (this.wss) {
|
|
2137
|
+
// Terminate existing WebSocket clients so server.close() callback fires promptly
|
|
2138
|
+
// (wss.close() alone only stops new connections; open clients keep the HTTP server alive)
|
|
2139
|
+
for (const client of this.wss.clients) {
|
|
2140
|
+
try { client.terminate(); } catch (_) { /* ignore */ }
|
|
2141
|
+
}
|
|
2081
2142
|
this.wss.close();
|
|
2082
2143
|
}
|
|
2083
2144
|
if (this.server) {
|
|
2084
2145
|
this.server.close();
|
|
2085
2146
|
}
|
|
2086
|
-
|
|
2087
|
-
// Flush pending output and stop all sessions
|
|
2147
|
+
|
|
2148
|
+
// Flush pending output and stop all sessions with a 5-second timeout
|
|
2088
2149
|
const stopPromises = [];
|
|
2089
2150
|
for (const [sessionId, session] of this.claudeSessions.entries()) {
|
|
2090
2151
|
this._flushAndClearOutputTimer(session, sessionId);
|
|
@@ -2095,7 +2156,8 @@ class ClaudeCodeWebServer {
|
|
|
2095
2156
|
}
|
|
2096
2157
|
}
|
|
2097
2158
|
}
|
|
2098
|
-
|
|
2159
|
+
const timeout = new Promise(resolve => setTimeout(resolve, 5000));
|
|
2160
|
+
await Promise.race([Promise.allSettled(stopPromises), timeout]);
|
|
2099
2161
|
|
|
2100
2162
|
// Clear all data
|
|
2101
2163
|
this.claudeSessions.clear();
|
|
@@ -3,6 +3,8 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const CircularBuffer = require('./circular-buffer');
|
|
5
5
|
|
|
6
|
+
const MAX_BUFFER_BYTES_PER_SESSION = 512 * 1024; // 512KB per-session byte cap
|
|
7
|
+
|
|
6
8
|
class SessionStore {
|
|
7
9
|
constructor() {
|
|
8
10
|
// Store sessions in user's home directory
|
|
@@ -16,6 +18,23 @@ class SessionStore {
|
|
|
16
18
|
this._dirty = true;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Trim an array of output lines to fit within MAX_BUFFER_BYTES_PER_SESSION.
|
|
23
|
+
* Keeps the most recent lines (end of array) and drops the oldest.
|
|
24
|
+
*/
|
|
25
|
+
_capBufferByBytes(lines) {
|
|
26
|
+
let totalBytes = 0;
|
|
27
|
+
// Walk backwards from the end (newest lines) summing byte lengths
|
|
28
|
+
let startIndex = lines.length;
|
|
29
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
30
|
+
const lineBytes = Buffer.byteLength(lines[i] || '', 'utf8');
|
|
31
|
+
if (totalBytes + lineBytes > MAX_BUFFER_BYTES_PER_SESSION) break;
|
|
32
|
+
totalBytes += lineBytes;
|
|
33
|
+
startIndex = i;
|
|
34
|
+
}
|
|
35
|
+
return startIndex === 0 ? lines : lines.slice(startIndex);
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
async initializeStorage() {
|
|
20
39
|
try {
|
|
21
40
|
// Create storage directory if it doesn't exist
|
|
@@ -40,8 +59,10 @@ class SessionStore {
|
|
|
40
59
|
lastActivity: session.lastActivity || new Date(),
|
|
41
60
|
workingDir: session.workingDir || process.cwd(),
|
|
42
61
|
active: false, // Always set to false when saving (processes won't persist)
|
|
62
|
+
wasActive: session.active || false, // Preserve active state for restart awareness
|
|
63
|
+
agent: session.agent || null, // Which tool was running (claude, codex, etc.)
|
|
43
64
|
outputBuffer: (session.outputBuffer && typeof session.outputBuffer.slice === 'function')
|
|
44
|
-
? session.outputBuffer.slice(-
|
|
65
|
+
? this._capBufferByBytes(session.outputBuffer.slice(-1000)) : [], // Keep last 1000 lines, capped at 512KB
|
|
45
66
|
connections: [], // Clear connections (they won't persist)
|
|
46
67
|
lastAccessed: session.lastAccessed || Date.now(),
|
|
47
68
|
// Session-specific usage tracking
|
|
@@ -64,8 +85,14 @@ class SessionStore {
|
|
|
64
85
|
};
|
|
65
86
|
|
|
66
87
|
// Write to a temporary file first, then rename (atomic operation)
|
|
88
|
+
// Use restrictive permissions (owner-only) since output may contain secrets.
|
|
89
|
+
// Note: mode 0o600 is silently ignored on Windows (which uses ACLs instead
|
|
90
|
+
// of Unix permissions). The file inherits the user's default ACL, which is
|
|
91
|
+
// acceptable but not explicitly enforced.
|
|
67
92
|
const tempFile = `${this.sessionsFile}.tmp`;
|
|
68
|
-
|
|
93
|
+
// JSON.stringify is CPU-bound; yield to pending I/O before serializing
|
|
94
|
+
const jsonStr = await new Promise(resolve => setImmediate(() => resolve(JSON.stringify(data))));
|
|
95
|
+
await fs.writeFile(tempFile, jsonStr, { mode: 0o600 });
|
|
69
96
|
// Ensure directory still exists before rename (handles race conditions)
|
|
70
97
|
await fs.mkdir(this.storageDir, { recursive: true });
|
|
71
98
|
await fs.rename(tempFile, this.sessionsFile);
|
|
@@ -137,8 +164,8 @@ class SessionStore {
|
|
|
137
164
|
connections: new Set(),
|
|
138
165
|
outputBuffer: CircularBuffer.fromArray(session.outputBuffer || [], 1000),
|
|
139
166
|
maxBufferSize: 1000,
|
|
140
|
-
// Restore usage data if available
|
|
141
|
-
|
|
167
|
+
// Restore usage data if available (saved under sessionUsage key)
|
|
168
|
+
sessionUsage: session.sessionUsage || null
|
|
142
169
|
});
|
|
143
170
|
}
|
|
144
171
|
|