fix(desktop): 为 Electron 与后端日志增加按大小轮转 (#5029)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch
This commit is contained in:
エイカク
2026-02-11 20:17:57 +09:00
committed by GitHub
parent 331ada02fd
commit dc995af34b
7 changed files with 473 additions and 50 deletions
+9
View File
@@ -91,6 +91,15 @@ Runtime logs:
- Electron shell log: `~/.astrbot/logs/electron.log`
- Backend stdout/stderr log: `~/.astrbot/logs/backend.log`
- Both files rotate by size by default: `20MB` per file, keep `3` backups.
- Electron log rotation envs:
- `ASTRBOT_ELECTRON_LOG_MAX_MB`
- `ASTRBOT_ELECTRON_LOG_BACKUP_COUNT`
- Backend log rotation envs:
- `ASTRBOT_BACKEND_LOG_MAX_MB`
- `ASTRBOT_BACKEND_LOG_BACKUP_COUNT`
- Rotation debug logging:
- `ASTRBOT_LOG_ROTATION_DEBUG=1` (or `NODE_ENV=development`) to print filesystem errors from rotation operations.
- On backend startup failure, the app dialog also shows the backend reason and backend log path.
Timeout and loading controls:
+58 -42
View File
@@ -4,10 +4,20 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common');
const { BufferedRotatingLogger } = require('./buffered-rotating-logger');
const {
delay,
ensureDir,
normalizeUrl,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
} = require('./common');
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000;
const BACKEND_LOG_FLUSH_INTERVAL_MS = 120;
const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024;
function parseBackendTimeoutMs(app) {
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
@@ -34,10 +44,22 @@ class BackendManager {
);
this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
this.backendTimeoutMs = parseBackendTimeoutMs(app);
this.backendLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_BACKEND_LOG_MAX_MB,
);
this.backendLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT,
);
this.backendProcess = null;
this.backendConfig = null;
this.backendLogFd = null;
this.backendLogger = new BufferedRotatingLogger({
logPath: null,
maxBytes: this.backendLogMaxBytes,
backupCount: this.backendLogBackupCount,
flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS,
maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES,
});
this.backendLastExitReason = null;
this.backendStartupFailureReason = null;
this.backendSpawning = false;
@@ -195,14 +217,8 @@ class BackendManager {
return Boolean(this.getBackendConfig().cmd);
}
closeBackendLogFd() {
if (this.backendLogFd === null) {
return;
}
try {
fs.closeSync(this.backendLogFd);
} catch {}
this.backendLogFd = null;
async flushLogs() {
await this.backendLogger.flush();
}
async pingBackend(timeoutMs = 800) {
@@ -355,7 +371,7 @@ class BackendManager {
}
}
startBackend() {
async startBackend() {
if (this.shouldSkipStart()) {
this.log('Skip backend start because app is quitting.');
return;
@@ -379,61 +395,61 @@ class BackendManager {
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
}
let backendLogPath = null;
if (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
ensureDir(logsDir);
const logPath = path.join(logsDir, 'backend.log');
try {
this.backendLogFd = fs.openSync(logPath, 'a');
} catch {
this.backendLogFd = null;
}
backendLogPath = path.join(logsDir, 'backend.log');
}
await this.backendLogger.setLogPath(backendLogPath);
const usePipedLogging = Boolean(backendLogPath);
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
cwd: backendConfig.cwd,
env,
shell: backendConfig.shell,
stdio:
this.backendLogFd === null
? 'ignore'
: ['ignore', this.backendLogFd, this.backendLogFd],
stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore',
windowsHide: true,
});
if (this.backendLogFd !== null) {
if (usePipedLogging) {
if (this.backendProcess.stdout) {
this.backendProcess.stdout.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
if (this.backendProcess.stderr) {
this.backendProcess.stderr.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
}
if (usePipedLogging) {
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
.map((item) => JSON.stringify(item))
.join(' ');
try {
fs.writeSync(
this.backendLogFd,
`[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`,
);
} catch {}
this.backendLogger.log(
`[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`,
);
}
this.backendProcess.on('error', (error) => {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
if (this.backendLogFd !== null) {
try {
fs.writeSync(
this.backendLogFd,
`[${new Date().toISOString()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
} catch {}
}
this.closeBackendLogFd();
this.backendLogger.log(
`[${new Date().toISOString()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
void this.backendLogger.flush();
this.backendProcess = null;
});
this.backendProcess.on('exit', (code, signal) => {
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
this.closeBackendLogFd();
void this.backendLogger.flush();
this.backendProcess = null;
});
}
@@ -447,7 +463,7 @@ class BackendManager {
}
this.backendSpawning = true;
try {
this.startBackend();
await this.startBackend();
return await this.waitForBackend(maxWaitMs, true);
} finally {
this.backendSpawning = false;
@@ -506,7 +522,7 @@ class BackendManager {
await waitForProcessExit(processToStop, 1500);
}
}
this.closeBackendLogFd();
await this.backendLogger.flush();
}
findListeningPidsOnWindows(port) {
+162
View File
@@ -0,0 +1,162 @@
'use strict';
const { RotatingLogWriter } = require('./rotating-log-writer');
const { parseEnvInt } = require('./common');
const DEFAULT_FLUSH_INTERVAL_MS = 120;
const DEFAULT_MAX_BUFFER_BYTES = 128 * 1024;
const MIN_FLUSH_INTERVAL_MS = 10;
const MIN_MAX_BUFFER_BYTES = 4 * 1024;
const MAX_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
function clampIntOption(raw, { defaultValue, min, max }) {
const value = parseEnvInt(raw, defaultValue);
return Math.min(Math.max(value, min), max);
}
class BufferedRotatingLogger {
constructor({
logPath = null,
maxBytes,
backupCount,
flushIntervalMs,
maxBufferBytes,
label = 'buffered-log',
}) {
this.logPath = logPath || null;
this.flushIntervalMs = clampIntOption(flushIntervalMs, {
defaultValue: DEFAULT_FLUSH_INTERVAL_MS,
min: MIN_FLUSH_INTERVAL_MS,
max: 60 * 1000,
});
this.maxBufferBytes = clampIntOption(maxBufferBytes, {
defaultValue: DEFAULT_MAX_BUFFER_BYTES,
min: MIN_MAX_BUFFER_BYTES,
max: MAX_MAX_BUFFER_BYTES,
});
this.buffer = [];
this.bufferBytes = 0;
this.flushTimer = null;
this.pathSwitch = Promise.resolve();
this.writer = new RotatingLogWriter({
logPath: this.logPath,
maxBytes,
backupCount,
label,
});
}
setLogPath(logPath) {
const nextLogPath = logPath || null;
this.pathSwitch = this.pathSwitch.then(async () => {
if (nextLogPath === this.logPath) {
await this.flush();
return;
}
const previousLogPath = this.logPath;
if (previousLogPath) {
await this.flush();
}
this.logPath = null;
await this.writer.setLogPath(nextLogPath);
this.logPath = nextLogPath;
await this.flush();
});
return this.pathSwitch;
}
log(payload) {
if (payload === undefined || payload === null) {
return;
}
const chunk = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!chunk.length) {
return;
}
if (!this.logPath) {
const boundedChunk = this.clipChunkToBufferLimit(chunk);
this.dropOldestUntilWithinLimit(boundedChunk.length);
this.buffer.push(boundedChunk);
this.bufferBytes += boundedChunk.length;
return;
}
this.buffer.push(chunk);
this.bufferBytes += chunk.length;
if (this.bufferBytes >= this.maxBufferBytes) {
void this.flush();
return;
}
this.scheduleFlush();
}
flush() {
this.clearFlushTimer();
if (!this.buffer.length) {
return this.writer.flush();
}
if (!this.logPath) {
// Path is switching or temporarily unavailable; keep buffered data.
this.dropOldestUntilWithinLimit(0);
return this.writer.flush();
}
const chunks = this.buffer;
this.buffer = [];
this.bufferBytes = 0;
const payload = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
this.writer.append(payload);
return this.writer.flush();
}
dropOldestUntilWithinLimit(incomingBytes = 0) {
while (
this.buffer.length &&
this.bufferBytes + Math.max(0, incomingBytes) > this.maxBufferBytes
) {
const removed = this.buffer.shift();
if (removed) {
this.bufferBytes -= removed.length;
}
}
if (this.bufferBytes < 0) {
this.bufferBytes = 0;
}
}
clipChunkToBufferLimit(chunk) {
if (chunk.length <= this.maxBufferBytes) {
return chunk;
}
return chunk.subarray(chunk.length - this.maxBufferBytes);
}
scheduleFlush() {
if (this.flushTimer !== null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
clearFlushTimer() {
if (this.flushTimer === null) {
return;
}
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
module.exports = {
BufferedRotatingLogger,
};
+37
View File
@@ -2,6 +2,9 @@
const fs = require('fs');
const LOG_ROTATION_DEFAULT_MAX_MB = 20;
const LOG_ROTATION_DEFAULT_BACKUP_COUNT = 3;
function normalizeUrl(value) {
try {
const url = new URL(value);
@@ -24,6 +27,33 @@ function ensureDir(value) {
fs.mkdirSync(value, { recursive: true });
}
function parseEnvInt(raw, defaultValue) {
const parsed = Number.parseInt(`${raw ?? ''}`, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function isLogRotationDebugEnabled() {
return (
process.env.ASTRBOT_LOG_ROTATION_DEBUG === '1' ||
process.env.NODE_ENV === 'development'
);
}
function parseLogMaxBytes(envValue) {
const mb = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_MAX_MB);
const maxMb = mb > 0 ? mb : LOG_ROTATION_DEFAULT_MAX_MB;
return maxMb * 1024 * 1024;
}
function parseLogBackupCount(envValue) {
const count = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_BACKUP_COUNT);
return count >= 0 ? count : LOG_ROTATION_DEFAULT_BACKUP_COUNT;
}
function isIgnorableFsError(error) {
return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -52,8 +82,15 @@ function waitForProcessExit(child, timeoutMs = 5000) {
}
module.exports = {
LOG_ROTATION_DEFAULT_BACKUP_COUNT,
LOG_ROTATION_DEFAULT_MAX_MB,
delay,
ensureDir,
isIgnorableFsError,
isLogRotationDebugEnabled,
normalizeUrl,
parseEnvInt,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
};
+23 -6
View File
@@ -1,10 +1,23 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { ensureDir } = require('./common');
const { RotatingLogWriter } = require('./rotating-log-writer');
const { parseLogBackupCount, parseLogMaxBytes } = require('./common');
function createElectronLogger({ app, getRootDir }) {
const electronLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_ELECTRON_LOG_MAX_MB,
);
const electronLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_ELECTRON_LOG_BACKUP_COUNT,
);
const writer = new RotatingLogWriter({
logPath: null,
maxBytes: electronLogMaxBytes,
backupCount: electronLogBackupCount,
label: 'electron-log',
});
function getElectronLogPath() {
const rootDir =
process.env.ASTRBOT_ROOT ||
@@ -15,19 +28,23 @@ function createElectronLogger({ app, getRootDir }) {
function logElectron(message) {
const logPath = getElectronLogPath();
ensureDir(path.dirname(logPath));
const line = `[${new Date().toISOString()}] ${message}\n`;
try {
fs.appendFileSync(logPath, line, 'utf8');
} catch {}
void writer.setLogPath(logPath);
void writer.append(line);
}
async function flushElectron() {
await writer.flush();
}
return {
getElectronLogPath,
logElectron,
flushElectron,
};
}
module.exports = {
createElectronLogger,
};
+178
View File
@@ -0,0 +1,178 @@
'use strict';
const fs = require('fs/promises');
const path = require('path');
const { isIgnorableFsError, isLogRotationDebugEnabled } = require('./common');
class RotatingLogWriter {
constructor({ logPath = null, maxBytes = 0, backupCount = 0, label = 'log' }) {
this.logPath = logPath || null;
this.maxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 0;
this.backupCount = Number.isFinite(backupCount) && backupCount >= 0 ? backupCount : 0;
this.label = label;
this.cachedSize = null;
this.dirReadyForPath = null;
this.queue = Promise.resolve();
}
setLogPath(logPath) {
const nextPath = logPath || null;
if (nextPath === this.logPath) {
return this.queue;
}
return this.enqueue(async () => {
this.logPath = nextPath;
this.cachedSize = null;
this.dirReadyForPath = null;
});
}
append(payload) {
if (payload === undefined || payload === null) {
return this.queue;
}
const content = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!content.length) {
return this.queue;
}
return this.enqueue(async () => {
if (!this.logPath) {
return;
}
await this.ensureDirReady();
await this.ensureSizeLoaded();
await this.rotateIfNeeded(content.length);
await fs.appendFile(this.logPath, content);
if (!Number.isFinite(this.cachedSize)) {
this.cachedSize = await this.readSize();
} else {
this.cachedSize += content.length;
}
});
}
flush() {
return this.queue;
}
enqueue(task) {
const run = async () => {
try {
await task();
} catch (error) {
this.reportError('write', this.logPath || 'unknown', error);
}
};
this.queue = this.queue.then(run, run);
return this.queue;
}
async ensureSizeLoaded() {
if (Number.isFinite(this.cachedSize)) {
return;
}
this.cachedSize = await this.readSize();
}
async ensureDirReady() {
if (!this.logPath) {
return;
}
if (this.dirReadyForPath === this.logPath) {
return;
}
const dirPath = path.dirname(this.logPath);
try {
await fs.mkdir(dirPath, { recursive: true });
this.dirReadyForPath = this.logPath;
} catch (error) {
this.reportError('mkdir', dirPath, error);
}
}
async readSize() {
if (!this.logPath) {
return 0;
}
try {
const stat = await fs.stat(this.logPath);
return stat.size;
} catch (error) {
if (isIgnorableFsError(error)) {
return 0;
}
this.reportError('stat', this.logPath, error);
return 0;
}
}
async rotateIfNeeded(incomingBytes) {
if (!this.logPath || this.maxBytes <= 0) {
return;
}
const currentSize = Number.isFinite(this.cachedSize) ? this.cachedSize : 0;
if (currentSize + Math.max(0, incomingBytes) <= this.maxBytes) {
return;
}
if (this.backupCount <= 0) {
try {
await fs.truncate(this.logPath, 0);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('truncate', this.logPath, error);
}
}
this.cachedSize = await this.readSize();
return;
}
const oldestPath = `${this.logPath}.${this.backupCount}`;
try {
await fs.unlink(oldestPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('unlink', oldestPath, error);
}
}
for (let index = this.backupCount - 1; index >= 1; index -= 1) {
const sourcePath = `${this.logPath}.${index}`;
const targetPath = `${this.logPath}.${index + 1}`;
try {
await fs.rename(sourcePath, targetPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${sourcePath} -> ${targetPath}`, error);
}
}
}
try {
await fs.rename(this.logPath, `${this.logPath}.1`);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${this.logPath} -> ${this.logPath}.1`, error);
}
}
this.cachedSize = await this.readSize();
}
reportError(action, targetPath, error) {
if (!isLogRotationDebugEnabled()) {
return;
}
const details = error instanceof Error ? error.message : String(error);
console.error(
`[astrbot][${this.label}] ${action} failed for ${targetPath}: ${details}`,
);
}
}
module.exports = {
RotatingLogWriter,
};
+6 -2
View File
@@ -36,7 +36,7 @@ let backendManager = null;
app.commandLine.appendSwitch('disable-http-cache');
const { logElectron } = createElectronLogger({
const { logElectron, flushElectron } = createElectronLogger({
app,
getRootDir: () => (backendManager ? backendManager.getRootDir() : null),
});
@@ -387,8 +387,12 @@ app.on('before-quit', (event) => {
}
}),
)
.finally(() => {
.finally(async () => {
logElectron('Backend stop finished, exiting app.');
await Promise.allSettled([
flushElectron(),
backendManager ? backendManager.flushLogs() : Promise.resolve(),
]);
app.exit(0);
});
});