@evandu/super-pi 0.1.113 → 0.1.114
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.md +16 -0
- package/bin/super-pi.js +95 -3
- package/dist/client/.super-pi-client-sources.json +1 -1
- package/dist/extension.js +1 -1
- package/dist/server/index.js +14 -4
- package/dist/server/logging.d.ts +32 -0
- package/dist/server/logging.js +132 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -56,6 +56,22 @@ super-pi stop # Stop the background server
|
|
|
56
56
|
super-pi restart # Restart the server
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
If the panel suddenly stops working and the browser only says "connecting", the server may have
|
|
60
|
+
died. Its log says why:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
super-pi logs # The last 200 lines of the server log
|
|
64
|
+
super-pi logs 50 # The last 50 lines
|
|
65
|
+
super-pi logs --follow # Keep printing new lines (Ctrl+C to stop)
|
|
66
|
+
super-pi logs --path # Where the log file is
|
|
67
|
+
super-pi logs --clear # Empty the log file
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The log lives in `<data directory>/logs/server.log` (`super-pi logs --path` prints the exact path;
|
|
71
|
+
`SUPER_PI_DATA_DIR` moves the whole data directory). A separate `logs/heartbeat.txt` is touched
|
|
72
|
+
every minute while the server lives, so `super-pi logs` can also tell you when the server was last
|
|
73
|
+
alive - which is how a crash that left no message at all still gets a time on it.
|
|
74
|
+
|
|
59
75
|
To start Super Pi automatically when the current user signs in, run:
|
|
60
76
|
|
|
61
77
|
```bash
|
package/bin/super-pi.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, statSync, openSync, readSync, closeSync, truncateSync } from "node:fs";
|
|
4
4
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -38,7 +38,7 @@ function ownVersion() {
|
|
|
38
38
|
/** The line that names what is running, so two installations can be told apart at a glance. */
|
|
39
39
|
function runningBanner(url) {
|
|
40
40
|
const { version, location } = ownVersion();
|
|
41
|
-
return `Super Pi v${version} is running at ${url}\n installed at ${location}`;
|
|
41
|
+
return `Super Pi v${version} is running at ${url}\n installed at ${location}\n log file: ${logFile()}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function options(args) {
|
|
@@ -63,7 +63,75 @@ async function running(port) {
|
|
|
63
63
|
}
|
|
64
64
|
async function stop(port) { try { const headers = process.env.SUPER_PI_ACCESS_TOKEN ? { authorization: `Bearer ${process.env.SUPER_PI_ACCESS_TOKEN}` } : {}; const response = await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: "POST", headers, signal: AbortSignal.timeout(3000) }); return response.ok || response.status === 202; } catch { return false; } }
|
|
65
65
|
function openBrowser(url) { const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open"; const args = platform === "darwin" ? [url] : platform === "win32" ? ["/c", "start", "", url] : [url]; spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }).unref(); }
|
|
66
|
-
function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|diagnose|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" diagnose Measure where a message spends its time"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
|
|
66
|
+
function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|diagnose|logs|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" diagnose Measure where a message spends its time"); console.log(" logs [N] [--follow] Show the server log (last N lines, default 200)"); console.log(" logs --path Print the log file's path"); console.log(" logs --clear Empty the log file"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* WHERE THE SERVER KEEPS ITS LOG FILE.
|
|
70
|
+
*
|
|
71
|
+
* The same rule the server itself uses when it picks its data directory, mirrored here on purpose:
|
|
72
|
+
* the CLI has to find the file the server wrote, and the server may be a DIFFERENT installation of
|
|
73
|
+
* Super Pi than the CLI that is asking (the global install and the one the `/super_pi` extension
|
|
74
|
+
* loads are two separate installs by design). `SUPER_PI_DATA_DIR` overrides both, which is what a
|
|
75
|
+
* person with a custom data directory needs.
|
|
76
|
+
*/
|
|
77
|
+
function dataDir() {
|
|
78
|
+
if (process.env.SUPER_PI_DATA_DIR) return resolve(process.env.SUPER_PI_DATA_DIR);
|
|
79
|
+
const local = join(root, ".super-pi");
|
|
80
|
+
if (!root.match(/[\\/]node_modules[\\/]/i) && existsSync(local)) return local;
|
|
81
|
+
return join(home, ".super-pi");
|
|
82
|
+
}
|
|
83
|
+
function logFile() { return join(dataDir(), "logs", "server.log"); }
|
|
84
|
+
function heartbeatFile() { return join(dataDir(), "logs", "heartbeat.txt"); }
|
|
85
|
+
|
|
86
|
+
/** "How long ago was the server alive?" - the answer a silent death cannot give by itself. */
|
|
87
|
+
function lastAlive() {
|
|
88
|
+
const file = heartbeatFile();
|
|
89
|
+
if (!existsSync(file)) return null;
|
|
90
|
+
try {
|
|
91
|
+
const at = statSync(file).mtimeMs;
|
|
92
|
+
const minutes = Math.max(0, Math.round((Date.now() - at) / 60000));
|
|
93
|
+
const when = new Date(at).toLocaleString();
|
|
94
|
+
return minutes < 1 ? `${when} (just now)` : `${when} (${minutes} minute${minutes === 1 ? "" : "s"} ago)`;
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
/** The last `lines` lines of the log file, read from the end so a big file stays cheap to open. */
|
|
100
|
+
function readLogTail(file, lines) {
|
|
101
|
+
if (!existsSync(file)) return [];
|
|
102
|
+
const size = statSync(file).size;
|
|
103
|
+
if (size === 0) return [];
|
|
104
|
+
const length = Math.min(size, 512 * 1024);
|
|
105
|
+
const buffer = Buffer.alloc(length);
|
|
106
|
+
const fd = openSync(file, "r");
|
|
107
|
+
try { readSync(fd, buffer, 0, length, size - length); } finally { closeSync(fd); }
|
|
108
|
+
const all = buffer.toString("utf8").split(/\r?\n/);
|
|
109
|
+
if (all.length > 0 && all[all.length - 1] === "") all.pop();
|
|
110
|
+
return all.slice(Math.max(0, all.length - lines));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Print whatever the log file gains from now on, until Ctrl+C. */
|
|
114
|
+
function followLog(file) {
|
|
115
|
+
let offset = existsSync(file) ? statSync(file).size : 0;
|
|
116
|
+
const tick = () => {
|
|
117
|
+
try {
|
|
118
|
+
if (!existsSync(file)) return;
|
|
119
|
+
const size = statSync(file).size;
|
|
120
|
+
// A SMALLER FILE MEANS THE LOG WAS ROTATED (or cleared): start over from the top.
|
|
121
|
+
if (size < offset) offset = 0;
|
|
122
|
+
if (size <= offset) return;
|
|
123
|
+
const buffer = Buffer.alloc(size - offset);
|
|
124
|
+
const fd = openSync(file, "r");
|
|
125
|
+
try { readSync(fd, buffer, 0, buffer.length, offset); } finally { closeSync(fd); }
|
|
126
|
+
offset = size;
|
|
127
|
+
process.stdout.write(buffer.toString("utf8"));
|
|
128
|
+
} catch { /* the file is being rotated under us; the next tick picks it up */ }
|
|
129
|
+
};
|
|
130
|
+
tick();
|
|
131
|
+
const timer = setInterval(tick, 400);
|
|
132
|
+
process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
|
|
133
|
+
}
|
|
134
|
+
|
|
67
135
|
function paths() { if (platform === "win32") return { task: "Super Pi" }; if (platform === "darwin") return { file: join(home, "Library", "LaunchAgents", "com.evandu.super-pi.plist") }; return { file: join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "systemd", "user", "super-pi.service") }; }
|
|
68
136
|
async function system(command, args) { if (process.env.SUPER_PI_AUTOSTART_DRY_RUN === "1") { console.log(`[dry-run] ${command} ${args.join(" ")}`); return; } await execFileAsync(command, args, { windowsHide: true }); }
|
|
69
137
|
async function enable(value) {
|
|
@@ -104,6 +172,30 @@ try {
|
|
|
104
172
|
console.log(renderReport(result));
|
|
105
173
|
process.exit(result.error ? 1 : 0);
|
|
106
174
|
}
|
|
175
|
+
if (command === "logs" || command === "log") {
|
|
176
|
+
// HOW THE SERVER DIED, IN ONE COMMAND.
|
|
177
|
+
//
|
|
178
|
+
// The server runs detached with its console thrown away, so this file is the only record of it.
|
|
179
|
+
// It is written synchronously as the server speaks, so the last lines are always the crash.
|
|
180
|
+
const file = logFile();
|
|
181
|
+
const count = Number.parseInt(args.find((a) => /^\d+$/.test(a)) ?? "200", 10);
|
|
182
|
+
const follow = args.some((a) => a === "--follow" || a === "-f");
|
|
183
|
+
if (args.includes("--path")) { console.log(file); process.exit(0); }
|
|
184
|
+
if (args.includes("--clear")) { if (existsSync(file)) truncateSync(file, 0); console.log(`Cleared ${file}`); process.exit(0); }
|
|
185
|
+
if (!existsSync(file)) { console.log(`No log file yet at ${file}\nStart Super Pi with: super-pi start`); process.exit(0); }
|
|
186
|
+
console.log(`Log file: ${file}`);
|
|
187
|
+
const alive = lastAlive();
|
|
188
|
+
console.log(alive ? `Last alive: ${alive}` : "Last alive: unknown (no heartbeat file - the server may never have started)");
|
|
189
|
+
for (const line of readLogTail(file, Number.isInteger(count) && count > 0 ? count : 200)) console.log(line);
|
|
190
|
+
if (follow) {
|
|
191
|
+
// FOLLOWING MEANS THIS COMMAND DOES NOT END. The promise below never settles; `followLog` ends
|
|
192
|
+
// the process on Ctrl+C. Without it this branch would fall through to the "unknown command"
|
|
193
|
+
// check below and take the tail down with it.
|
|
194
|
+
followLog(file);
|
|
195
|
+
await new Promise(() => {});
|
|
196
|
+
}
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
107
199
|
if (!["start", "stop", "restart", "update"].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
108
200
|
if (command === "update") { const npm = platform === "win32" ? "npm.cmd" : "npm"; const args = prefix ? ["install", "--prefix", prefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"]; const child = spawn(npm, args, { stdio: "inherit", shell: platform === "win32", env: process.env }); const code = await new Promise((resolve) => child.on("close", resolve)); if (code !== 0) process.exit(code ?? 1); }
|
|
109
201
|
const value = options(args);
|
|
@@ -28,5 +28,5 @@
|
|
|
28
28
|
"src/client/types.ts": "fb2cf2889d695f102e0e7b02090ce0d96b351a4c6343e31fd8b88ac4b51a5781",
|
|
29
29
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
30
30
|
},
|
|
31
|
-
"recordedAt": "2026-10-23T03:
|
|
31
|
+
"recordedAt": "2026-10-23T03:47:58.951Z"
|
|
32
32
|
}
|
package/dist/extension.js
CHANGED
|
@@ -116,7 +116,7 @@ export default function (pi) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
if (!ready) {
|
|
119
|
-
ctx.ui.notify?.(`Warning: Super Pi server on port ${port} did not respond to health check in time
|
|
119
|
+
ctx.ui.notify?.(`Warning: Super Pi server on port ${port} did not respond to health check in time. Run "super-pi logs" to see why.`, "warning");
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
openBrowser(url);
|
package/dist/server/index.js
CHANGED
|
@@ -28,12 +28,25 @@ import { ExecService } from "./exec-service.js";
|
|
|
28
28
|
import { createHttpServer, getLanIps, isLocalOrPrivateAddress, parseServerArgs } from "./http-server.js";
|
|
29
29
|
import { WsBroadcaster, WsServer } from "./ws-server.js";
|
|
30
30
|
import { initRunningVersion } from "./version.js";
|
|
31
|
+
import { installServerLogging } from "./logging.js";
|
|
31
32
|
import { ToolRegistry } from "./tool-registry.js";
|
|
32
33
|
import { ToolRunner } from "./tool-runner.js";
|
|
33
34
|
import { ToolLlmService } from "./tool-llm.js";
|
|
34
35
|
// ------------------------------------------------------------- Environment & paths
|
|
35
36
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
36
37
|
initRunningVersion(rootDir);
|
|
38
|
+
// ------------------------------------------------------------- The log file
|
|
39
|
+
//
|
|
40
|
+
// THE SERVER IS STARTED DETACHED WITH ITS OUTPUT THROWN AWAY, so anything printed to the console
|
|
41
|
+
// is gone. A crash used to take the process down with no word at all, and the browser just sat there
|
|
42
|
+
// saying "connecting". The log file is installed BEFORE anything else can fail, so the first line in
|
|
43
|
+
// it is always "the server started" and the last line is always "the server stopped".
|
|
44
|
+
const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(rootDir, ".super-pi"))
|
|
45
|
+
? join(rootDir, ".super-pi")
|
|
46
|
+
: join(homedir(), ".super-pi");
|
|
47
|
+
const dataDir = resolve(process.env.SUPER_PI_DATA_DIR ?? defaultDataDir);
|
|
48
|
+
const logging = installServerLogging(dataDir);
|
|
49
|
+
logging.write("info", `server starting: pid ${process.pid}, node ${process.versions.node}, data dir ${dataDir}`);
|
|
37
50
|
// THE SECURITY PATCH, APPLIED WHERE IT CANNOT BREAK AN INSTALL.
|
|
38
51
|
//
|
|
39
52
|
// Pi's package pins a vulnerable `brace-expansion` in its own shrinkwrapped tree, and npm will not let
|
|
@@ -84,10 +97,6 @@ const envPort = Number.parseInt(process.env.SUPER_PI_PORT ?? "3080", 10);
|
|
|
84
97
|
const port = cliArgs.port ?? (Number.isNaN(envPort) ? 3080 : envPort);
|
|
85
98
|
const host = cliArgs.host ?? process.env.SUPER_PI_HOST ?? "0.0.0.0";
|
|
86
99
|
const internalHost = (host === "0.0.0.0" || host === "::" || !host) ? "127.0.0.1" : host;
|
|
87
|
-
const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(rootDir, ".super-pi"))
|
|
88
|
-
? join(rootDir, ".super-pi")
|
|
89
|
-
: join(homedir(), ".super-pi");
|
|
90
|
-
const dataDir = resolve(process.env.SUPER_PI_DATA_DIR ?? defaultDataDir);
|
|
91
100
|
const agentPromptDir = join(dataDir, "agent-prompts");
|
|
92
101
|
const clientDir = join(rootDir, "dist", "client");
|
|
93
102
|
const accessToken = process.env.SUPER_PI_ACCESS_TOKEN;
|
|
@@ -326,6 +335,7 @@ const wsServer = new WsServer({
|
|
|
326
335
|
httpServer.listen(port, host, () => {
|
|
327
336
|
const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
328
337
|
console.log(`Super Pi is running at http://${displayHost}:${port}`);
|
|
338
|
+
logging.write("info", `listening on ${host}:${port} (pid ${process.pid})`);
|
|
329
339
|
if (host === "0.0.0.0" || host === "::") {
|
|
330
340
|
const lanIps = getLanIps();
|
|
331
341
|
for (const ip of lanIps) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SERVER'S OWN LOG FILE.
|
|
3
|
+
*
|
|
4
|
+
* Super Pi is started DETACHED, with its standard streams thrown away (`stdio: "ignore"`), because
|
|
5
|
+
* the person who started it is usually gone by the time it matters - the panel runs in a browser tab
|
|
6
|
+
* and the server lives on behind it. The cost of that decision was that when the server DIED, it
|
|
7
|
+
* died silently: the browser sat there saying "connecting" and nobody could say why. A log file is
|
|
8
|
+
* the cheapest possible answer to "how did it die".
|
|
9
|
+
*
|
|
10
|
+
* Two properties matter more than volume here:
|
|
11
|
+
*
|
|
12
|
+
* - EVERY LINE IS WRITTEN SYNCHRONOUSLY. A buffered stream loses exactly the lines you want - the
|
|
13
|
+
* ones written a millisecond before the crash - because the buffer is still in memory when the
|
|
14
|
+
* process dies. `appendFileSync` costs a syscall per line, which is nothing at this volume, and
|
|
15
|
+
* it costs the line that matters.
|
|
16
|
+
* - THE CRASH IS LOGGED BEFORE THE PROCESS LEAVES. `uncaughtException` and `unhandledRejection` are
|
|
17
|
+
* the two ways a Node process dies without saying anything, and both are turned into a stack trace
|
|
18
|
+
* in the log file.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SERVER_LOG_NAME = "server.log";
|
|
21
|
+
export interface ServerLogging {
|
|
22
|
+
/** Absolute path of the log file this server writes to. */
|
|
23
|
+
readonly logFile: string;
|
|
24
|
+
/** Absolute path of the liveness file; its timestamp says when the server was last alive. */
|
|
25
|
+
readonly heartbeatFile: string;
|
|
26
|
+
/** Append one line to the log file, independently of the console. */
|
|
27
|
+
write(level: string, message: string): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Install the file logger. Returns the handle so the caller can write lines the console never sees.
|
|
31
|
+
*/
|
|
32
|
+
export declare function installServerLogging(dataDir: string): ServerLogging;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* THE SERVER'S OWN LOG FILE.
|
|
5
|
+
*
|
|
6
|
+
* Super Pi is started DETACHED, with its standard streams thrown away (`stdio: "ignore"`), because
|
|
7
|
+
* the person who started it is usually gone by the time it matters - the panel runs in a browser tab
|
|
8
|
+
* and the server lives on behind it. The cost of that decision was that when the server DIED, it
|
|
9
|
+
* died silently: the browser sat there saying "connecting" and nobody could say why. A log file is
|
|
10
|
+
* the cheapest possible answer to "how did it die".
|
|
11
|
+
*
|
|
12
|
+
* Two properties matter more than volume here:
|
|
13
|
+
*
|
|
14
|
+
* - EVERY LINE IS WRITTEN SYNCHRONOUSLY. A buffered stream loses exactly the lines you want - the
|
|
15
|
+
* ones written a millisecond before the crash - because the buffer is still in memory when the
|
|
16
|
+
* process dies. `appendFileSync` costs a syscall per line, which is nothing at this volume, and
|
|
17
|
+
* it costs the line that matters.
|
|
18
|
+
* - THE CRASH IS LOGGED BEFORE THE PROCESS LEAVES. `uncaughtException` and `unhandledRejection` are
|
|
19
|
+
* the two ways a Node process dies without saying anything, and both are turned into a stack trace
|
|
20
|
+
* in the log file.
|
|
21
|
+
*/
|
|
22
|
+
export const SERVER_LOG_NAME = "server.log";
|
|
23
|
+
/** Kept small enough to open by eye; the previous file is kept as `server.log.1`. */
|
|
24
|
+
const MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
function format(level, message) {
|
|
26
|
+
const time = new Date().toISOString();
|
|
27
|
+
// A multi-line message (a stack trace) stays one entry per line, each carrying the same stamp, so
|
|
28
|
+
// that a `tail` still shows where it started.
|
|
29
|
+
return message
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((line) => `[${time}] [${level}] ${line}`)
|
|
32
|
+
.join("\n");
|
|
33
|
+
}
|
|
34
|
+
function rotateIfLarge(logFile) {
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(logFile))
|
|
37
|
+
return;
|
|
38
|
+
if (statSync(logFile).size < MAX_LOG_BYTES)
|
|
39
|
+
return;
|
|
40
|
+
const previous = `${logFile}.1`;
|
|
41
|
+
try {
|
|
42
|
+
renameSync(previous, `${previous}.old`);
|
|
43
|
+
}
|
|
44
|
+
catch { /* no previous file: fine */ }
|
|
45
|
+
renameSync(logFile, previous);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Rotation is best-effort: a log file that cannot be rotated must not stop the server from
|
|
49
|
+
// starting, and must not stop it from logging.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Install the file logger. Returns the handle so the caller can write lines the console never sees.
|
|
54
|
+
*/
|
|
55
|
+
export function installServerLogging(dataDir) {
|
|
56
|
+
const logDir = join(dataDir, "logs");
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(logDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
catch { /* the write below will report the real problem */ }
|
|
61
|
+
const logFile = join(logDir, SERVER_LOG_NAME);
|
|
62
|
+
rotateIfLarge(logFile);
|
|
63
|
+
/**
|
|
64
|
+
* THE LIVENESS FILE.
|
|
65
|
+
*
|
|
66
|
+
* A log file says what the server said; it cannot say when the server STOPPED, because the lines
|
|
67
|
+
* it would have written to say that are exactly the ones a crash takes away. A native crash (the
|
|
68
|
+
* kind that comes from a terminal being killed) never runs a JavaScript handler at all, so the
|
|
69
|
+
* server cannot even write "I died". This file answers the other half of the question: it is
|
|
70
|
+
* touched every minute while the server lives, so its timestamp says "the server was alive until
|
|
71
|
+
* this moment" - which is how a silent death gets a time on it.
|
|
72
|
+
*/
|
|
73
|
+
const heartbeatFile = join(logDir, "heartbeat.txt");
|
|
74
|
+
const touch = () => {
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(heartbeatFile, `${Date.now()}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch { /* best effort */ }
|
|
79
|
+
};
|
|
80
|
+
touch();
|
|
81
|
+
const heartbeat = setInterval(touch, 60_000);
|
|
82
|
+
// The heartbeat must not be the reason the process stays alive.
|
|
83
|
+
heartbeat.unref?.();
|
|
84
|
+
const write = (level, message) => {
|
|
85
|
+
try {
|
|
86
|
+
appendFileSync(logFile, `${format(level, message)}\n`, "utf8");
|
|
87
|
+
}
|
|
88
|
+
catch { /* never let logging crash the server */ }
|
|
89
|
+
};
|
|
90
|
+
// CONSOLE OUTPUT IS TEED INTO THE FILE, NOT REPLACED BY IT.
|
|
91
|
+
//
|
|
92
|
+
// Everything the server already prints (the banner, warnings from the services, the shutdown
|
|
93
|
+
// notice) is worth keeping, and the cheapest way to keep it is to keep printing it and copy it.
|
|
94
|
+
// A service that logs through `console.warn` gets into the file for free.
|
|
95
|
+
const levels = ["log", "info", "warn", "error", "debug"];
|
|
96
|
+
const originals = new Map();
|
|
97
|
+
for (const level of levels) {
|
|
98
|
+
const original = console[level];
|
|
99
|
+
originals.set(level, original);
|
|
100
|
+
console[level] = (...args) => {
|
|
101
|
+
original.apply(console, args);
|
|
102
|
+
write(level === "log" ? "info" : level, args.map((arg) => (typeof arg === "string" ? arg : safeStringify(arg))).join(" "));
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* THE TWO WAYS A NODE PROCESS DIES WITHOUT A WORD.
|
|
107
|
+
*
|
|
108
|
+
* `uncaughtException` is a throw nobody caught; `unhandledRejection` is a promise nobody awaited.
|
|
109
|
+
* Both used to take the server down silently. Both now write the stack and then end the process -
|
|
110
|
+
* for `uncaughtException` continuing is not safe, because the process state after an uncaught
|
|
111
|
+
* exception is undefined.
|
|
112
|
+
*/
|
|
113
|
+
process.on("uncaughtException", (error) => {
|
|
114
|
+
write("fatal", `uncaughtException: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
117
|
+
process.on("unhandledRejection", (reason) => {
|
|
118
|
+
write("fatal", `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
|
|
119
|
+
});
|
|
120
|
+
process.on("exit", (code) => {
|
|
121
|
+
write("info", `server process exiting with code ${code}`);
|
|
122
|
+
});
|
|
123
|
+
return { logFile, heartbeatFile, write };
|
|
124
|
+
}
|
|
125
|
+
function safeStringify(value) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.stringify(value);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return String(value);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.114",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"scripts/pi-model-switch.test.mjs",
|
|
47
47
|
"scripts/pi-thinking-levels.test.mjs",
|
|
48
48
|
"scripts/diagnose-cli.test.mjs",
|
|
49
|
+
"scripts/cli-logs.test.mjs",
|
|
49
50
|
"scripts/lan-access.test.mjs",
|
|
50
51
|
"scripts/terminal-pty.test.mjs",
|
|
51
52
|
"scripts/persistence-repository.test.mjs",
|