maxpool 1.5.3 → 1.5.5
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/package.json +1 -1
- package/src/config.js +10 -0
- package/src/event-log.js +122 -0
- package/src/index.js +81 -14
- package/src/server.js +3 -0
- package/src/tui.js +4 -0
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -28,6 +28,16 @@ export function getStatePath() {
|
|
|
28
28
|
return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Path to the persistent event log (a sibling of the config). Default-on; holds a
|
|
33
|
+
* rotating record of routing / network errors / cooldowns / reloads so incidents
|
|
34
|
+
* are investigable after the in-memory TUI feed has scrolled away.
|
|
35
|
+
*/
|
|
36
|
+
export function getLogPath() {
|
|
37
|
+
const cfg = getConfigPath();
|
|
38
|
+
return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.log') : cfg + '.log';
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export function createDefaultConfig() {
|
|
32
42
|
return {
|
|
33
43
|
proxy: {
|
package/src/event-log.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Persistent, rotating, low-overhead event log so incidents are investigable
|
|
2
|
+
// after the fact (the TUI activity feed is in-memory only and scrolls away). It
|
|
3
|
+
// mirrors the existing `[Maxpool] …` console stream — routing, network errors,
|
|
4
|
+
// cooldowns, token refreshes, reloads, upstream-throttle — to a file that
|
|
5
|
+
// survives the terminal. Logging must NEVER throw into the proxy path, so every
|
|
6
|
+
// write is fire-and-forget and all errors are swallowed.
|
|
7
|
+
|
|
8
|
+
import { appendFile, stat, rename, chmod } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = 5 * 1024 * 1024; // rotate at ~5 MB (one .1 backup kept)
|
|
11
|
+
const ROTATE_CHECK_MS = 2000; // rotation-owner size-check cadence
|
|
12
|
+
// Cap each line below the platform PIPE_BUF (512 B on macOS) so concurrent
|
|
13
|
+
// O_APPEND writes from coexisting processes during a reload stay atomic (never
|
|
14
|
+
// interleave). 24-char ISO timestamp + space + body + newline must fit.
|
|
15
|
+
const MAX_BODY = 480;
|
|
16
|
+
const MAX_QUEUED = 10_000; // bound the in-flight queue if the disk stalls
|
|
17
|
+
|
|
18
|
+
let logPath = null;
|
|
19
|
+
let manageRotation = false; // SINGLE owner rotates (supervisor, or the lone unsupervised worker)
|
|
20
|
+
let rotationTimer = null;
|
|
21
|
+
let writeChain = Promise.resolve();
|
|
22
|
+
let queued = 0;
|
|
23
|
+
let dropped = 0;
|
|
24
|
+
let origConsole = null;
|
|
25
|
+
|
|
26
|
+
export function setEventLogPath(path, { manageRotation: owns = false } = {}) {
|
|
27
|
+
logPath = path || null;
|
|
28
|
+
manageRotation = Boolean(owns);
|
|
29
|
+
if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
|
|
30
|
+
if (logPath) {
|
|
31
|
+
// 0600 even on a pre-existing file (appendFile's mode only applies on create).
|
|
32
|
+
chmod(logPath, 0o600).catch(() => {});
|
|
33
|
+
if (manageRotation) {
|
|
34
|
+
rotationTimer = setInterval(() => { rotateIfNeeded().catch(() => {}); }, ROTATE_CHECK_MS);
|
|
35
|
+
rotationTimer.unref?.();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Redact obvious secrets BEFORE anything reaches disk. We only ever log summaries
|
|
42
|
+
* (account names, statuses, error classes) — never raw tokens/headers/bodies — but
|
|
43
|
+
* this is defense-in-depth so a future log line that happens to include a token
|
|
44
|
+
* can't leak through the persisted file.
|
|
45
|
+
*/
|
|
46
|
+
export function redactSecrets(s) {
|
|
47
|
+
return String(s)
|
|
48
|
+
.replace(/sk-ant-[A-Za-z0-9._-]+/g, 'sk-ant-[redacted]')
|
|
49
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]')
|
|
50
|
+
.replace(/(["']?(?:access|refresh)_?token["']?\s*[:=]\s*["']?)[A-Za-z0-9._-]{8,}/gi, '$1[redacted]')
|
|
51
|
+
.replace(/(["']?api[_-]?key["']?\s*[:=]\s*["']?)[A-Za-z0-9._-]{8,}/gi, '$1[redacted]');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function appendEventLog(msg) {
|
|
55
|
+
if (!logPath || msg == null) return;
|
|
56
|
+
if (queued >= MAX_QUEUED) { dropped++; return; } // disk stalled — drop, don't grow memory unbounded
|
|
57
|
+
queued++;
|
|
58
|
+
writeChain = writeChain.then(() => writeLine(msg)).catch(() => {}).finally(() => { queued--; });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Truncate to at most maxBytes UTF-8 bytes (not chars) without splitting a
|
|
62
|
+
// multibyte codepoint, so the final line stays under PIPE_BUF for atomic O_APPEND.
|
|
63
|
+
function capBytes(s, maxBytes) {
|
|
64
|
+
if (Buffer.byteLength(s) <= maxBytes) return s;
|
|
65
|
+
let str = Buffer.from(s).subarray(0, maxBytes - 3).toString('utf8'); // room for '…' (3 B)
|
|
66
|
+
if (str.endsWith('�')) str = str.slice(0, -1); // drop a partial trailing codepoint
|
|
67
|
+
return str + '…';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function writeLine(rawMsg) {
|
|
71
|
+
const path = logPath;
|
|
72
|
+
if (!path) return;
|
|
73
|
+
try {
|
|
74
|
+
// The drop sentinel is its OWN append so neither write exceeds PIPE_BUF.
|
|
75
|
+
if (dropped > 0) {
|
|
76
|
+
const n = dropped; dropped = 0;
|
|
77
|
+
await appendFile(path, `${new Date().toISOString()} [event-log] dropped ${n} line(s) (disk stalled)\n`, { mode: 0o600 });
|
|
78
|
+
}
|
|
79
|
+
// Redaction + formatting on the DRAINED async chain, off the request hot path.
|
|
80
|
+
let body = redactSecrets(String(rawMsg)).replace(/\s*\n\s*/g, ' ');
|
|
81
|
+
body = capBytes(body, MAX_BODY);
|
|
82
|
+
await appendFile(path, `${new Date().toISOString()} ${body}\n`, { mode: 0o600 });
|
|
83
|
+
} catch { /* logging must never throw into the proxy */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Rotation is run by a SINGLE owner's timer (never inside writeLine), so two
|
|
87
|
+
// coexisting processes during a reload can't both rename and clobber each other's
|
|
88
|
+
// archive. Appends are O_APPEND to the path, so a rename mid-stream just means the
|
|
89
|
+
// next append re-creates the live file — no lost current lines.
|
|
90
|
+
export async function rotateIfNeeded() {
|
|
91
|
+
const path = logPath;
|
|
92
|
+
if (!path) return;
|
|
93
|
+
const st = await stat(path).catch(() => null);
|
|
94
|
+
if (st && st.size > MAX_BYTES) await rename(path, `${path}.1`).catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Tee console.log/console.error to the event log while preserving their normal
|
|
99
|
+
* output (stdout in plain mode; the TUI separately re-points console at its ring
|
|
100
|
+
* buffer, whose _addLog also calls appendEventLog, so TUI-mode lines persist too).
|
|
101
|
+
*/
|
|
102
|
+
export function installConsoleMirror() {
|
|
103
|
+
if (origConsole) return; // idempotent
|
|
104
|
+
origConsole = { log: console.log.bind(console), error: console.error.bind(console) };
|
|
105
|
+
for (const method of ['log', 'error']) {
|
|
106
|
+
const orig = origConsole[method];
|
|
107
|
+
console[method] = (...args) => {
|
|
108
|
+
try { appendEventLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')); } catch { /* never */ }
|
|
109
|
+
orig(...args);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Test-only barrier: resolves once every queued append has settled. */
|
|
115
|
+
export const flushEventLog = () => writeChain;
|
|
116
|
+
|
|
117
|
+
/** Test-only: restore console + reset all module state so tests don't bleed. */
|
|
118
|
+
export function __resetEventLogForTest() {
|
|
119
|
+
if (origConsole) { console.log = origConsole.log; console.error = origConsole.error; origConsole = null; }
|
|
120
|
+
if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
|
|
121
|
+
logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0;
|
|
122
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
|
-
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
|
|
5
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
|
|
6
|
+
import { setEventLogPath, installConsoleMirror } from './event-log.js';
|
|
6
7
|
import { AccountManager } from './account-manager.js';
|
|
7
8
|
import { createProxyServer } from './server.js';
|
|
8
9
|
import { Prober } from './prober.js';
|
|
@@ -26,6 +27,34 @@ const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
|
|
|
26
27
|
// worker boots HEADLESS (plain logs, no writer lease) and waits for the baton.
|
|
27
28
|
const SERVER_RELOAD_WORKER_ENV = 'MAXPOOL_RELOAD_WORKER';
|
|
28
29
|
|
|
30
|
+
// Is maxpool attached to an interactive terminal? Governs SIGNAL semantics: in a
|
|
31
|
+
// terminal a SIGHUP means "the window hung up → shut down" (never reload into a
|
|
32
|
+
// headless orphan that outlives the terminal and squats the port); headless /
|
|
33
|
+
// service mode keeps SIGHUP as the conventional reload trigger. MAXPOOL_FORCE_TTY
|
|
34
|
+
// lets the (non-pty) test suite exercise the terminal-hangup path deterministically.
|
|
35
|
+
function isInteractiveTerminal() {
|
|
36
|
+
return Boolean(process.stdout.isTTY) || process.env.MAXPOOL_FORCE_TTY === '1';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Wire up the persistent event log (default-on; disable with `eventLog: false` in
|
|
40
|
+
// config). Sets the path + tees console.log/error to disk so routing, network
|
|
41
|
+
// errors, cooldowns and reloads survive the in-memory TUI feed for later triage.
|
|
42
|
+
function initEventLog(config, { manageRotation = false } = {}) {
|
|
43
|
+
if (config?.eventLog === false) return;
|
|
44
|
+
try {
|
|
45
|
+
setEventLogPath(getLogPath(), { manageRotation });
|
|
46
|
+
installConsoleMirror();
|
|
47
|
+
} catch { /* logging must never block startup */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Which reload path an in-process reload request takes. Interactive (a live TUI)
|
|
51
|
+
// reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
|
|
52
|
+
// seamless reload worker boots headless and would strand the user in raw logs.
|
|
53
|
+
// Headless/service keeps the zero-downtime baton (nothing visual to lose).
|
|
54
|
+
function reloadStrategy({ supervised, useTUI }) {
|
|
55
|
+
return supervised && !useTUI ? 'seamless' : 'cold-restart';
|
|
56
|
+
}
|
|
57
|
+
|
|
29
58
|
switch (command) {
|
|
30
59
|
case 'server':
|
|
31
60
|
await serverCommand();
|
|
@@ -110,6 +139,7 @@ async function serverCommand() {
|
|
|
110
139
|
async function supervisorCommand() {
|
|
111
140
|
const { createServer } = await import('node:net');
|
|
112
141
|
const config = await loadOrCreateConfig();
|
|
142
|
+
initEventLog(config, { manageRotation: true }); // supervisor is the single rotation owner
|
|
113
143
|
const port = config.proxy.port;
|
|
114
144
|
const host = config.proxy.host || '127.0.0.1';
|
|
115
145
|
|
|
@@ -149,9 +179,18 @@ async function supervisorCommand() {
|
|
|
149
179
|
const forwardSignal = sig => { try { activeWorker?.child.kill(sig); } catch { /* ignore */ } };
|
|
150
180
|
process.on('SIGINT', () => { /* delivered to the worker by the TTY group */ });
|
|
151
181
|
process.on('SIGTERM', () => { /* delivered to the worker by the TTY group */ });
|
|
152
|
-
// SIGHUP
|
|
153
|
-
//
|
|
154
|
-
|
|
182
|
+
// SIGHUP: in a terminal, a window hangup delivers SIGHUP to the whole foreground
|
|
183
|
+
// group, so the worker ALSO received it and (interactive) is already shutting
|
|
184
|
+
// itself down — the supervisor must NOT forward a second signal, or the worker
|
|
185
|
+
// gets a doubled signal that force-quits it mid-drain (non-zero exit → a fast
|
|
186
|
+
// exit is then misread as a crash → respawn into a headless orphan, the exact
|
|
187
|
+
// bug). Only forward in headless/service mode, where SIGHUP is the conventional
|
|
188
|
+
// reload trigger and reaches the supervisor alone.
|
|
189
|
+
process.on('SIGHUP', () => { if (!isInteractiveTerminal()) forwardSignal('SIGHUP'); });
|
|
190
|
+
// A dead controlling terminal (window closed) makes writes to stdout/stderr emit
|
|
191
|
+
// EPIPE/EIO — swallow them so a shutdown-time log can't crash the supervisor.
|
|
192
|
+
process.stdout.on('error', () => {});
|
|
193
|
+
process.stderr.on('error', () => {});
|
|
155
194
|
// A spawn failure / stray rejection must NOT kill the supervisor (it would
|
|
156
195
|
// wedge the port and drop the service). Log and let the supervision loop or
|
|
157
196
|
// the reload's own error handling recover.
|
|
@@ -407,6 +446,8 @@ function delay(ms) {
|
|
|
407
446
|
|
|
408
447
|
async function serverWorkerCommand() {
|
|
409
448
|
const config = await loadOrCreateConfig();
|
|
449
|
+
// The lone unsupervised worker owns rotation; under a supervisor, the supervisor does.
|
|
450
|
+
initEventLog(config, { manageRotation: typeof process.send !== 'function' });
|
|
410
451
|
|
|
411
452
|
// --log-to <dir>
|
|
412
453
|
const logTo = argValue('--log-to');
|
|
@@ -563,9 +604,15 @@ async function serverWorkerCommand() {
|
|
|
563
604
|
console.error(`[Maxpool] Worker uncaughtException: ${err?.stack || err}`);
|
|
564
605
|
restoreTerminal();
|
|
565
606
|
// A reload worker must NEVER exit(1) (escapes the supervisor exit-75 loop).
|
|
566
|
-
// Stay alive so the supervisor's baton timeouts roll us back cleanly.
|
|
567
|
-
|
|
607
|
+
// Stay alive so the supervisor's baton timeouts roll us back cleanly. Also
|
|
608
|
+
// skip the exit-75 restart while DRAINING: a shutdown-time write to a dead
|
|
609
|
+
// terminal can throw here, and restarting then would respawn the very orphan
|
|
610
|
+
// a terminal-close shutdown is trying to avoid — let the drain finish instead.
|
|
611
|
+
if (!isReloadWorker && !draining) process.exit(SERVER_RESTART_EXIT_CODE);
|
|
568
612
|
});
|
|
613
|
+
// Swallow EPIPE/EIO from writes to a hung-up terminal so they can't crash us.
|
|
614
|
+
process.stdout.on('error', () => {});
|
|
615
|
+
process.stderr.on('error', () => {});
|
|
569
616
|
process.on('unhandledRejection', reason => {
|
|
570
617
|
console.error(`[Maxpool] Worker unhandledRejection: ${reason}`);
|
|
571
618
|
});
|
|
@@ -636,7 +683,17 @@ async function serverWorkerCommand() {
|
|
|
636
683
|
// the respawned worker doesn't boot from a half-written state. Bounded so the
|
|
637
684
|
// abrupt path stays fast even if a write hangs.
|
|
638
685
|
Promise.race([
|
|
639
|
-
(async () => {
|
|
686
|
+
(async () => {
|
|
687
|
+
// Drain in-flight token refreshes before exit so the cold-respawned worker
|
|
688
|
+
// never boots mid-rotation of a single-use refresh token (→ invalid_grant
|
|
689
|
+
// → bricked account). Mirrors shutdownGracefully + the seamless baton's
|
|
690
|
+
// drainRefreshes — required now that interactive reload routes through here.
|
|
691
|
+
await releaseLease();
|
|
692
|
+
await accountManager.drainRefreshes();
|
|
693
|
+
await persistQuotaState(true);
|
|
694
|
+
await flushConfigWrites();
|
|
695
|
+
await flushStateWrites();
|
|
696
|
+
})(),
|
|
640
697
|
delay(2000),
|
|
641
698
|
]).finally(() => process.exit(SERVER_RESTART_EXIT_CODE));
|
|
642
699
|
};
|
|
@@ -646,7 +703,9 @@ async function serverWorkerCommand() {
|
|
|
646
703
|
// (not supervised, no IPC), fall back to the abrupt restart.
|
|
647
704
|
const requestReload = () => {
|
|
648
705
|
if (draining) return;
|
|
649
|
-
|
|
706
|
+
// Interactive (live TUI) → full cold restart so the fresh worker re-renders the
|
|
707
|
+
// TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
|
|
708
|
+
if (reloadStrategy({ supervised, useTUI }) === 'seamless') {
|
|
650
709
|
try {
|
|
651
710
|
process.send({ type: MSG_RELOAD_REQUEST });
|
|
652
711
|
return;
|
|
@@ -661,9 +720,15 @@ async function serverWorkerCommand() {
|
|
|
661
720
|
});
|
|
662
721
|
|
|
663
722
|
const shutdownGracefully = (reason, options = {}) => {
|
|
723
|
+
// A terminal-close shutdown must NEVER exit non-zero: the supervisor reads a
|
|
724
|
+
// fast non-zero exit as a crash and respawns a fresh worker — onto the now-dead
|
|
725
|
+
// terminal, re-creating the headless orphan. So even a drain-timeout or close
|
|
726
|
+
// error during a terminal close exits 0 (the terminal is gone; there's nothing
|
|
727
|
+
// to keep alive for).
|
|
728
|
+
const cleanExitCode = options.terminalClose ? 0 : 1;
|
|
664
729
|
if (draining) {
|
|
665
730
|
console.error(`\n[Maxpool] Force exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
666
|
-
process.exit(
|
|
731
|
+
process.exit(cleanExitCode);
|
|
667
732
|
}
|
|
668
733
|
|
|
669
734
|
draining = true;
|
|
@@ -704,14 +769,14 @@ async function serverWorkerCommand() {
|
|
|
704
769
|
|
|
705
770
|
timeoutTimer = setTimeout(() => {
|
|
706
771
|
console.error(`[Maxpool] Drain timeout after ${Math.ceil(drainTimeoutMs / 1000)}s; exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
707
|
-
finish(
|
|
772
|
+
finish(cleanExitCode);
|
|
708
773
|
}, drainTimeoutMs);
|
|
709
774
|
timeoutTimer.unref();
|
|
710
775
|
|
|
711
776
|
server.close(err => {
|
|
712
777
|
if (err) {
|
|
713
778
|
console.error(`[Maxpool] Shutdown error: ${err.message}`);
|
|
714
|
-
finish(
|
|
779
|
+
finish(cleanExitCode);
|
|
715
780
|
return;
|
|
716
781
|
}
|
|
717
782
|
console.log('[Maxpool] Shutdown complete.');
|
|
@@ -936,9 +1001,11 @@ async function serverWorkerCommand() {
|
|
|
936
1001
|
|
|
937
1002
|
process.on('SIGINT', () => shutdownGracefully('SIGINT'));
|
|
938
1003
|
process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));
|
|
939
|
-
// SIGHUP
|
|
940
|
-
//
|
|
941
|
-
|
|
1004
|
+
// SIGHUP: in a terminal this means the controlling window hung up (closed) →
|
|
1005
|
+
// drain + exit cleanly so we never reload into a headless orphan that outlives
|
|
1006
|
+
// the terminal and squats the port. Headless/service mode keeps SIGHUP as the
|
|
1007
|
+
// conventional reload signal (baton under the supervisor, else exit-75).
|
|
1008
|
+
process.on('SIGHUP', () => { if (isInteractiveTerminal()) shutdownGracefully('SIGHUP', { terminalClose: true }); else requestReload(); });
|
|
942
1009
|
}
|
|
943
1010
|
|
|
944
1011
|
function logPlainServerStart({ host, port, accounts, threshold, config }) {
|
package/src/server.js
CHANGED
|
@@ -874,6 +874,9 @@ async function forwardRequest(
|
|
|
874
874
|
);
|
|
875
875
|
if (queued) return;
|
|
876
876
|
ctx.status = 503;
|
|
877
|
+
// Record the user-facing failure explicitly so it's greppable in the event
|
|
878
|
+
// log (the in-memory TUI feed scrolls away). `err` is the last network error.
|
|
879
|
+
console.error(`[Maxpool] Returned connection_unavailable (503) after network errors on all routes (last: "${account.name}" — ${err.code || err.message})`);
|
|
877
880
|
sendErrorResponse(res, requestInfo, 503, {
|
|
878
881
|
type: 'error',
|
|
879
882
|
error: {
|
package/src/tui.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { fetchProfile, loginOAuth } from './oauth.js';
|
|
3
|
+
import { appendEventLog } from './event-log.js';
|
|
3
4
|
|
|
4
5
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -284,6 +285,9 @@ export class TUI {
|
|
|
284
285
|
|
|
285
286
|
_addLog(msg) {
|
|
286
287
|
msg = msg.replace(/^\[Maxpool\]\s*/, '');
|
|
288
|
+
// Persist too — in TUI mode console is re-pointed here, bypassing the console
|
|
289
|
+
// mirror, so this is where TUI-mode lines reach the on-disk event log.
|
|
290
|
+
appendEventLog(msg);
|
|
287
291
|
this.log.unshift({ t: timestamp(), msg });
|
|
288
292
|
if (this.log.length > 200) this.log.length = 200;
|
|
289
293
|
if (this.running) this.render();
|