feat: add per-log-file size limit with truncation

This commit is contained in:
zjdndjf
2026-04-14 17:29:41 +08:00
parent 96933d9ea0
commit 2a04cafee5
13 changed files with 208 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { appConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { deepMerge } from '../utils/merge'
import { defaultConfig } from '../utils/template'
import { normalizeMaxLogFileSizeMB, setGlobalMaxLogFileSizeMB } from '../utils/logFile'
let appConfig: IAppConfig // config.yaml
let appConfigWriteQueue: Promise<void> = Promise.resolve()
@@ -13,9 +14,11 @@ export async function getAppConfig(force = false): Promise<IAppConfig> {
const data = await readFile(appConfigPath(), 'utf-8')
const parsedConfig = parse(data)
const mergedConfig = deepMerge({ ...defaultConfig }, parsedConfig || {})
mergedConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(mergedConfig.maxLogFileSize)
if (JSON.stringify(mergedConfig) !== JSON.stringify(parsedConfig)) {
await writeFile(appConfigPath(), stringify(mergedConfig))
}
setGlobalMaxLogFileSizeMB(mergedConfig.maxLogFileSize)
appConfig = mergedConfig
})
await appConfigWriteQueue
@@ -30,6 +33,8 @@ export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void>
appConfig.nameserverPolicy = patch.nameserverPolicy
}
appConfig = deepMerge(appConfig, patch)
appConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(appConfig.maxLogFileSize)
setGlobalMaxLogFileSizeMB(appConfig.maxLogFileSize)
await writeFile(appConfigPath(), stringify(appConfig))
})
await appConfigWriteQueue

View File

@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'fs/promises'
import { promisify } from 'util'
import path from 'path'
import os from 'os'
import { createWriteStream, existsSync } from 'fs'
import { existsSync } from 'fs'
import chokidar, { FSWatcher } from 'chokidar'
import { app, ipcMain } from 'electron'
import { mainWindow } from '../window'
@@ -28,6 +28,7 @@ import { startMonitor } from '../resolve/trafficMonitor'
import { safeShowErrorBox } from '../utils/init'
import i18next from '../../shared/i18n'
import { managerLogger } from '../utils/logger'
import { createCappedLogWritableStream } from '../utils/logFile'
import {
startMihomoTraffic,
startMihomoConnections,
@@ -210,9 +211,6 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
function spawnCoreProcess(config: CoreConfig): ChildProcess {
const { corePath, workDir, ipcPath, cpuPriority, detached } = config
const stdout = createWriteStream(coreLogPath(), { flags: 'a' })
const stderr = createWriteStream(coreLogPath(), { flags: 'a' })
const proc = spawn(corePath, ['-d', workDir, ctlParam, ipcPath], {
detached,
stdio: detached ? 'ignore' : undefined
@@ -226,6 +224,8 @@ function spawnCoreProcess(config: CoreConfig): ChildProcess {
}
if (!detached) {
const stdout = createCappedLogWritableStream(coreLogPath())
const stderr = createCappedLogWritableStream(coreLogPath())
proc.stdout?.pipe(stdout)
proc.stderr?.pipe(stderr)
}

View File

@@ -1,5 +1,5 @@
import { Worker } from 'worker_threads'
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises'
import http from 'http'
import net from 'net'
@@ -12,6 +12,7 @@ import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { systemLogger } from '../utils/logger'
import { createCappedLogWritableStream } from '../utils/logFile'
export let pacPort: number
export let subStorePort: number
@@ -109,8 +110,8 @@ export async function startSubStoreBackendServer(): Promise<void> {
subStorePort = await findAvailablePort(38324)
const icon = nativeImage.createFromPath(subStoreIcon)
icon.toDataURL()
const stdout = createWriteStream(substoreLogPath(), { flags: 'a' })
const stderr = createWriteStream(substoreLogPath(), { flags: 'a' })
const stdout = createCappedLogWritableStream(substoreLogPath())
const stderr = createCappedLogWritableStream(substoreLogPath())
const env = {
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
SUB_STORE_BACKEND_API_HOST: subStoreHost,

165
src/main/utils/logFile.ts Normal file
View File

@@ -0,0 +1,165 @@
import { appendFile, open, stat, writeFile } from 'fs/promises'
import { Writable } from 'stream'
const MB = 1024 * 1024
const DEFAULT_MAX_LOG_FILE_SIZE_MB = 10
const MIN_MAX_LOG_FILE_SIZE_MB = 1
const TRUNCATE_MARKER = Buffer.from('\n[LOG] File truncated because size limit reached.\n')
interface LogFileState {
queue: Promise<void>
size: number | null
}
const logFileStates = new Map<string, LogFileState>()
let globalMaxLogFileSizeBytes = DEFAULT_MAX_LOG_FILE_SIZE_MB * MB
function getLogFileState(filePath: string): LogFileState {
const existing = logFileStates.get(filePath)
if (existing) return existing
const created: LogFileState = {
queue: Promise.resolve(),
size: null
}
logFileStates.set(filePath, created)
return created
}
export function normalizeMaxLogFileSizeMB(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return DEFAULT_MAX_LOG_FILE_SIZE_MB
return Math.max(MIN_MAX_LOG_FILE_SIZE_MB, Math.floor(num))
}
export function setGlobalMaxLogFileSizeMB(value: unknown): void {
const normalized = normalizeMaxLogFileSizeMB(value)
globalMaxLogFileSizeBytes = normalized * MB
}
export function getGlobalMaxLogFileSizeBytes(): number {
return globalMaxLogFileSizeBytes
}
async function readTail(filePath: string, bytes: number): Promise<Buffer> {
if (bytes <= 0) return Buffer.alloc(0)
try {
const file = await open(filePath, 'r')
try {
const fileStat = await file.stat()
const readSize = Math.min(bytes, fileStat.size)
if (readSize <= 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(readSize)
await file.read(buffer, 0, readSize, fileStat.size - readSize)
return buffer
} finally {
await file.close()
}
} catch {
return Buffer.alloc(0)
}
}
async function getCurrentSize(filePath: string, state: LogFileState): Promise<number> {
if (state.size !== null) return state.size
try {
const fileStat = await stat(filePath)
state.size = fileStat.size
} catch {
state.size = 0
}
return state.size
}
async function appendToFileWithLimitInternal(
filePath: string,
data: Buffer,
state: LogFileState,
maxBytes: number
): Promise<void> {
if (data.length === 0) return
if (maxBytes <= 0) {
await appendFile(filePath, data)
const size = await getCurrentSize(filePath, state)
state.size = size + data.length
return
}
if (data.length >= maxBytes) {
const sliced = data.subarray(data.length - maxBytes)
await writeFile(filePath, sliced)
state.size = sliced.length
return
}
const size = await getCurrentSize(filePath, state)
if (size + data.length <= maxBytes) {
await appendFile(filePath, data)
state.size = size + data.length
return
}
const keepBytes = Math.max(0, maxBytes - data.length - TRUNCATE_MARKER.length)
const tail = await readTail(filePath, keepBytes)
let rewritten = Buffer.concat([tail, TRUNCATE_MARKER, data])
if (rewritten.length > maxBytes) {
rewritten = rewritten.subarray(rewritten.length - maxBytes)
}
await writeFile(filePath, rewritten)
state.size = rewritten.length
}
export async function appendToFileWithLimit(
filePath: string,
data: string | Buffer,
maxBytes = getGlobalMaxLogFileSizeBytes()
): Promise<void> {
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
const state = getLogFileState(filePath)
state.queue = state.queue
.catch(() => {
// Keep queue alive after previous failures.
})
.then(() => appendToFileWithLimitInternal(filePath, buffer, state, maxBytes))
await state.queue
}
class CappedLogWritable extends Writable {
private readonly filePath: string
private readonly maxBytes: number
constructor(filePath: string, maxBytes: number) {
super()
this.filePath = filePath
this.maxBytes = maxBytes
}
_write(
chunk: Buffer | string,
encoding: BufferEncoding,
callback: (error?: Error | null) => void
): void {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
appendToFileWithLimit(this.filePath, buffer, this.maxBytes).then(
() => callback(),
(error) => callback(error as Error)
)
}
}
export function createCappedLogWritableStream(
filePath: string,
maxBytes = getGlobalMaxLogFileSizeBytes()
): Writable {
return new CappedLogWritable(filePath, maxBytes)
}

View File

@@ -1,5 +1,5 @@
import { writeFile } from 'fs/promises'
import { logPath } from './dirs'
import { appendToFileWithLimit } from './logFile'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@@ -24,7 +24,7 @@ class Logger {
try {
const appLogPath = logPath()
const logMessage = this.formatLogMessage(level, message, error)
await writeFile(appLogPath, logMessage, { flag: 'a' })
await appendToFileWithLimit(appLogPath, logMessage)
} catch (logError) {
// 如果写入日志文件失败,仍然输出到控制台
console.error(`[Logger] Failed to write to log file:`, logError)

View File

@@ -13,6 +13,7 @@ export const defaultConfig: IAppConfig = {
trayProxyGroupStyle: 'default',
disableTrayIconColor: false,
maxLogDays: 7,
maxLogFileSize: 10,
proxyCols: 'auto',
connectionDirection: 'asc',
connectionOrderBy: 'time',

View File

@@ -222,6 +222,7 @@
"mihomo.disableEmbedCA": "Disable Embed CA",
"mihomo.disableSystemCA": "Disable System CA",
"mihomo.logRetentionDays": "Log Retention Days",
"mihomo.logFileSizeLimit": "Single Log File Limit (MB)",
"mihomo.logLevel": "Log Level",
"mihomo.selectLogLevel": "Select Log Level",
"mihomo.silent": "Silent",

View File

@@ -197,6 +197,7 @@
"mihomo.disableEmbedCA": "عدم استفاده از گواهی CA داخلی",
"mihomo.disableSystemCA": "عدم استفاده از گواهی CA سیستم",
"mihomo.logRetentionDays": "روزهای نگهداری لاگ",
"mihomo.logFileSizeLimit": "حداکثر حجم هر فایل لاگ (MB)",
"mihomo.logLevel": "سطح لاگ",
"mihomo.selectLogLevel": "انتخاب سطح لاگ",
"mihomo.silent": "بی‌صدا",

View File

@@ -199,6 +199,7 @@
"mihomo.disableEmbedCA": "Отключить встроенный CA",
"mihomo.disableSystemCA": "Отключить системный CA",
"mihomo.logRetentionDays": "Дни хранения логов",
"mihomo.logFileSizeLimit": "Лимит одного лог-файла (MB)",
"mihomo.logLevel": "Уровень логирования",
"mihomo.selectLogLevel": "Выберите уровень логирования",
"mihomo.silent": "Тихий",

View File

@@ -222,6 +222,7 @@
"mihomo.disableEmbedCA": "不使用内置 CA 证书",
"mihomo.disableSystemCA": "不使用系统 CA 证书",
"mihomo.logRetentionDays": "日志保留天数",
"mihomo.logFileSizeLimit": "日志上限MB",
"mihomo.logLevel": "日志等级",
"mihomo.selectLogLevel": "选择日志等级",
"mihomo.silent": "静默",

View File

@@ -222,6 +222,7 @@
"mihomo.disableEmbedCA": "不使用內置 CA 證書",
"mihomo.disableSystemCA": "不使用系統 CA 證書",
"mihomo.logRetentionDays": "日誌保留天數",
"mihomo.logFileSizeLimit": "日誌上限MB",
"mihomo.logLevel": "日誌等級",
"mihomo.selectLogLevel": "選擇日誌等級",
"mihomo.silent": "靜默",

View File

@@ -96,6 +96,7 @@ const Mihomo: React.FC = () => {
smartCoreStrategy = 'sticky-sessions',
smartCollectorSize = 100,
maxLogDays = 7,
maxLogFileSize = 10,
sysProxy,
showMixedPort,
enableMixedPort = true,
@@ -1431,6 +1432,26 @@ const Mihomo: React.FC = () => {
}}
/>
</SettingItem>
<SettingItem title={t('mihomo.logFileSizeLimit')} divider>
<Input
size="sm"
type="number"
className="w-[100px]"
value={maxLogFileSize.toString()}
onValueChange={(v) => {
const num = parseInt(v)
if (!isNaN(num)) {
patchAppConfig({ maxLogFileSize: num })
}
}}
onBlur={(e) => {
const num = parseInt(e.target.value)
if (isNaN(num) || num < 1) {
patchAppConfig({ maxLogFileSize: 1 })
}
}}
/>
</SettingItem>
<SettingItem title={t('mihomo.logLevel')} divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}

View File

@@ -312,6 +312,7 @@ interface IAppConfig {
autoCloseConnection: boolean
sysProxy: ISysProxyConfig
maxLogDays: number
maxLogFileSize: number
userAgent?: string
delayTestConcurrency?: number
delayTestUrl?: string