Skip to content

Commit 6e7bce1

Browse files
committed
fix: panel won't crash — auto-port, error safety net, client error swallow
The dashboard was crashing on some users. Three defensive fixes: 1. EADDRINUSE → try next port automatically (up to +10). Previously the panel just died when port 3100 was in use. 2. Outer try/catch around every request handler, plus a 'clientError' listener so dropped sockets don't crash the process. 3. uncaughtException handler with a pointer to open an issue instead of dumping a stack trace.
1 parent 9ff0560 commit 6e7bce1

5 files changed

Lines changed: 238 additions & 153 deletions

File tree

dist/commands/panel.js

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,49 @@
44
import chalk from 'chalk';
55
import { createPanelServer } from '../panel/server.js';
66
export async function panelCommand(options) {
7-
const port = parseInt(options.port || '3100', 10);
8-
const server = createPanelServer(port);
9-
server.listen(port, () => {
10-
console.log('');
11-
console.log(chalk.bold(' Franklin Panel'));
12-
console.log(chalk.dim(` http://localhost:${port}`));
13-
console.log('');
14-
console.log(chalk.dim(' Press Ctrl+C to stop.'));
15-
console.log('');
16-
// Try to open browser
17-
const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
18-
import('node:child_process').then(({ exec }) => {
19-
exec(`${open} http://localhost:${port}`);
20-
}).catch(() => { });
21-
});
22-
// Graceful shutdown
23-
const shutdown = () => {
24-
server.close();
25-
process.exit(0);
7+
const requestedPort = parseInt(options.port || '3100', 10);
8+
// Handle port-in-use by trying up to 10 subsequent ports.
9+
const tryListen = (port, attempt) => {
10+
const server = createPanelServer(port);
11+
server.on('error', (err) => {
12+
if (err.code === 'EADDRINUSE' && attempt < 10) {
13+
console.log(chalk.yellow(` Port ${port} busy — trying ${port + 1}...`));
14+
tryListen(port + 1, attempt + 1);
15+
return;
16+
}
17+
console.error(chalk.red(`\n Panel failed to start: ${err.message}`));
18+
if (err.code === 'EADDRINUSE') {
19+
console.error(chalk.dim(` All ports from ${requestedPort} to ${requestedPort + 9} are busy.`));
20+
console.error(chalk.dim(` Try: franklin panel --port 4000`));
21+
}
22+
process.exit(1);
23+
});
24+
server.listen(port, () => {
25+
console.log('');
26+
console.log(chalk.bold(' Franklin Panel'));
27+
console.log(chalk.dim(` http://localhost:${port}`));
28+
console.log('');
29+
console.log(chalk.dim(' Press Ctrl+C to stop.'));
30+
console.log('');
31+
// Try to open browser
32+
const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
33+
import('node:child_process').then(({ exec }) => {
34+
exec(`${open} http://localhost:${port}`);
35+
}).catch(() => { });
36+
});
37+
// Graceful shutdown
38+
const shutdown = () => {
39+
server.close();
40+
process.exit(0);
41+
};
42+
process.on('SIGINT', shutdown);
43+
process.on('SIGTERM', shutdown);
2644
};
27-
process.on('SIGINT', shutdown);
28-
process.on('SIGTERM', shutdown);
45+
// Catch unexpected crashes with a useful message rather than a stack trace
46+
process.on('uncaughtException', (err) => {
47+
console.error(chalk.red(`\n Panel crashed: ${err.message}`));
48+
console.error(chalk.dim(' Open an issue: https://github.com/BlockRunAI/Franklin/issues'));
49+
process.exit(1);
50+
});
51+
tryListen(requestedPort, 0);
2952
}

dist/panel/server.js

Lines changed: 128 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -36,130 +36,151 @@ function broadcast(data) {
3636
export function createPanelServer(port) {
3737
const html = getHTML();
3838
const server = http.createServer(async (req, res) => {
39-
const url = new URL(req.url || '/', `http://localhost:${port}`);
40-
const p = url.pathname;
41-
// ─── HTML ──
42-
if (p === '/') {
43-
res.writeHead(200, {
44-
'Content-Type': 'text/html; charset=utf-8',
45-
'Cache-Control': 'no-store, no-cache, must-revalidate',
46-
'Pragma': 'no-cache',
47-
});
48-
res.end(html);
49-
return;
50-
}
51-
// ─── Static assets ──
52-
if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
53-
const filename = path.basename(p);
54-
const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
55-
const imgPath = path.join(assetsDir, filename);
56-
try {
57-
const img = fs.readFileSync(imgPath);
58-
res.writeHead(200, {
59-
'Content-Type': 'image/jpeg',
60-
'Cache-Control': 'public, max-age=86400',
61-
});
62-
res.end(img);
63-
}
64-
catch {
65-
res.writeHead(404);
66-
res.end('Not found');
67-
}
68-
return;
69-
}
70-
// ─── SSE ──
71-
if (p === '/api/events') {
72-
res.writeHead(200, {
73-
'Content-Type': 'text/event-stream',
74-
'Cache-Control': 'no-cache',
75-
'Connection': 'keep-alive',
76-
'Access-Control-Allow-Origin': '*',
77-
});
78-
res.write('data: {"type":"connected"}\n\n');
79-
sseClients.add(res);
80-
req.on('close', () => sseClients.delete(res));
81-
return;
82-
}
83-
// ─── API ──
8439
try {
85-
if (p === '/api/stats') {
86-
const summary = getStatsSummary();
87-
json(res, {
88-
totalRequests: summary.stats.totalRequests,
89-
totalCostUsd: summary.stats.totalCostUsd,
90-
opusCost: summary.opusCost,
91-
saved: summary.saved,
92-
savedPct: summary.savedPct,
93-
avgCostPerRequest: summary.avgCostPerRequest,
94-
period: summary.period,
95-
byModel: summary.stats.byModel,
40+
const url = new URL(req.url || '/', `http://localhost:${port}`);
41+
const p = url.pathname;
42+
// ─── HTML ──
43+
if (p === '/') {
44+
res.writeHead(200, {
45+
'Content-Type': 'text/html; charset=utf-8',
46+
'Cache-Control': 'no-store, no-cache, must-revalidate',
47+
'Pragma': 'no-cache',
9648
});
49+
res.end(html);
9750
return;
9851
}
99-
if (p === '/api/insights') {
100-
const days = parseInt(url.searchParams.get('days') || '30', 10);
101-
const report = generateInsights(days);
102-
json(res, report);
103-
return;
104-
}
105-
if (p === '/api/sessions') {
106-
const sessions = listSessions();
107-
json(res, sessions);
108-
return;
109-
}
110-
if (p.startsWith('/api/sessions/search')) {
111-
const q = url.searchParams.get('q') || '';
112-
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
113-
const results = searchSessions(q, { limit });
114-
json(res, results);
52+
// ─── Static assets ──
53+
if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
54+
const filename = path.basename(p);
55+
const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
56+
const imgPath = path.join(assetsDir, filename);
57+
try {
58+
const img = fs.readFileSync(imgPath);
59+
res.writeHead(200, {
60+
'Content-Type': 'image/jpeg',
61+
'Cache-Control': 'public, max-age=86400',
62+
});
63+
res.end(img);
64+
}
65+
catch {
66+
res.writeHead(404);
67+
res.end('Not found');
68+
}
11569
return;
11670
}
117-
if (p.startsWith('/api/sessions/')) {
118-
const id = decodeURIComponent(p.slice('/api/sessions/'.length));
119-
const history = loadSessionHistory(id);
120-
json(res, history);
71+
// ─── SSE ──
72+
if (p === '/api/events') {
73+
res.writeHead(200, {
74+
'Content-Type': 'text/event-stream',
75+
'Cache-Control': 'no-cache',
76+
'Connection': 'keep-alive',
77+
'Access-Control-Allow-Origin': '*',
78+
});
79+
res.write('data: {"type":"connected"}\n\n');
80+
sseClients.add(res);
81+
req.on('close', () => sseClients.delete(res));
12182
return;
12283
}
123-
if (p === '/api/wallet') {
124-
try {
125-
const chain = loadChain();
126-
let address = '', balance = 0;
127-
if (chain === 'solana') {
128-
const { setupAgentSolanaWallet } = await import('@blockrun/llm');
129-
const client = await setupAgentSolanaWallet({ silent: true });
130-
address = await client.getWalletAddress();
131-
balance = await client.getBalance();
84+
// ─── API ──
85+
try {
86+
if (p === '/api/stats') {
87+
const summary = getStatsSummary();
88+
json(res, {
89+
totalRequests: summary.stats.totalRequests,
90+
totalCostUsd: summary.stats.totalCostUsd,
91+
opusCost: summary.opusCost,
92+
saved: summary.saved,
93+
savedPct: summary.savedPct,
94+
avgCostPerRequest: summary.avgCostPerRequest,
95+
period: summary.period,
96+
byModel: summary.stats.byModel,
97+
});
98+
return;
99+
}
100+
if (p === '/api/insights') {
101+
const days = parseInt(url.searchParams.get('days') || '30', 10);
102+
const report = generateInsights(days);
103+
json(res, report);
104+
return;
105+
}
106+
if (p === '/api/sessions') {
107+
const sessions = listSessions();
108+
json(res, sessions);
109+
return;
110+
}
111+
if (p.startsWith('/api/sessions/search')) {
112+
const q = url.searchParams.get('q') || '';
113+
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
114+
const results = searchSessions(q, { limit });
115+
json(res, results);
116+
return;
117+
}
118+
if (p.startsWith('/api/sessions/')) {
119+
const id = decodeURIComponent(p.slice('/api/sessions/'.length));
120+
const history = loadSessionHistory(id);
121+
json(res, history);
122+
return;
123+
}
124+
if (p === '/api/wallet') {
125+
try {
126+
const chain = loadChain();
127+
let address = '', balance = 0;
128+
if (chain === 'solana') {
129+
const { setupAgentSolanaWallet } = await import('@blockrun/llm');
130+
const client = await setupAgentSolanaWallet({ silent: true });
131+
address = await client.getWalletAddress();
132+
balance = await client.getBalance();
133+
}
134+
else {
135+
const { setupAgentWallet } = await import('@blockrun/llm');
136+
const client = setupAgentWallet({ silent: true });
137+
address = client.getWalletAddress();
138+
balance = await client.getBalance();
139+
}
140+
json(res, { address, balance, chain });
132141
}
133-
else {
134-
const { setupAgentWallet } = await import('@blockrun/llm');
135-
const client = setupAgentWallet({ silent: true });
136-
address = client.getWalletAddress();
137-
balance = await client.getBalance();
142+
catch {
143+
json(res, { address: 'not set', balance: 0, chain: loadChain() });
138144
}
139-
json(res, { address, balance, chain });
145+
return;
140146
}
141-
catch {
142-
json(res, { address: 'not set', balance: 0, chain: loadChain() });
147+
if (p === '/api/social') {
148+
const stats = getSocialStats();
149+
json(res, stats);
150+
return;
143151
}
144-
return;
145-
}
146-
if (p === '/api/social') {
147-
const stats = getSocialStats();
148-
json(res, stats);
149-
return;
152+
if (p === '/api/learnings') {
153+
const learnings = loadLearnings();
154+
json(res, learnings);
155+
return;
156+
}
157+
// 404
158+
res.writeHead(404);
159+
res.end('Not found');
150160
}
151-
if (p === '/api/learnings') {
152-
const learnings = loadLearnings();
153-
json(res, learnings);
154-
return;
161+
catch (err) {
162+
json(res, { error: err.message }, 500);
155163
}
156-
// 404
157-
res.writeHead(404);
158-
res.end('Not found');
159164
}
160165
catch (err) {
161-
json(res, { error: err.message }, 500);
166+
// Outer safety net — logs but never crashes the server
167+
try {
168+
if (!res.headersSent)
169+
json(res, { error: err.message }, 500);
170+
else
171+
res.end();
172+
}
173+
catch { /* socket already gone */ }
174+
console.error('[panel] request error:', err.message);
175+
}
176+
});
177+
// Swallow socket errors (client disconnects, etc.) so they don't crash the process
178+
server.on('clientError', (err, socket) => {
179+
try {
180+
socket.destroy();
162181
}
182+
catch { /* already closed */ }
183+
console.error('[panel] client error:', err.message);
163184
});
164185
// Watch stats file for changes → push to SSE clients
165186
const statsFile = fs.existsSync(path.join(BLOCKRUN_DIR, 'franklin-stats.json'))

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blockrun/franklin",
3-
"version": "3.6.16",
3+
"version": "3.6.17",
44
"description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
55
"type": "module",
66
"exports": {

0 commit comments

Comments
 (0)