From 1a152575cc7c644d6d894308f1e0c4cd00baa457 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 17 Oct 2026 07:42:10 +0800 Subject: [PATCH] fix: preserve active system log files when clearing logs --- back/services/system.ts | 12 ++++- test/back/system-log-clear.test.cjs | 70 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/back/system-log-clear.test.cjs diff --git a/back/services/system.ts b/back/services/system.ts index 79c1544a..0cd1be79 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -606,7 +606,17 @@ export default class SystemService { const result = await readDirs(config.systemLogPath, config.systemLogPath); const logs = result.reverse().filter((x) => x.title.endsWith('.log')); for (const log of logs) { - await rmPath(path.join(config.systemLogPath, log.title)); + // All workers keep append handles open; unlinking strands future writes + // in a deleted inode until rotation. Preserve the file while clearing it. + const file = await fs.promises.open( + path.join(config.systemLogPath, log.title), + fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, + ); + try { + await file.truncate(0); + } finally { + await file.close(); + } } } diff --git a/test/back/system-log-clear.test.cjs b/test/back/system-log-clear.test.cjs new file mode 100644 index 00000000..296125f2 --- /dev/null +++ b/test/back/system-log-clear.test.cjs @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-system-log-clear-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const SystemService = load('back/services/system.ts', { + '../config': { systemLogPath: root }, + '../config/const': {}, + '../config/util': { + readDirs: async () => + (await fs.readdir(root)).map((title) => ({ title })), + rmPath: (file) => fs.rm(file), + }, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../shared/pLimit': {}, + '../schedule/client': {}, + '../shared/i18n': { t: (x) => x }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + }).default; + return { root, service: new SystemService({}, {}, {}) }; +} + +test('clearing system logs preserves append handles used by existing workers', async (t) => { + const { root, service } = await fixture(t); + const active = path.join(root, '2026-10-17.log'); + const rotated = path.join(root, '2026-10-16.log'); + await fs.writeFile(active, 'old active logs\n'); + await fs.writeFile(rotated, 'old rotated logs\n'); + await fs.writeFile(path.join(root, 'audit.json'), 'rotation metadata'); + const workers = await Promise.all([fs.open(active, 'a'), fs.open(active, 'a')]); + t.after(async () => { + await Promise.all(workers.map((worker) => worker.close())); + }); + const inode = (await fs.stat(active)).ino; + + for (let clear = 0; clear < 2; clear++) { + await service.deleteSystemLog(); + assert.equal(await fs.readFile(active, 'utf8'), ''); + assert.equal(await fs.readFile(rotated, 'utf8'), ''); + assert.equal((await fs.stat(active)).ino, inode); + await workers[0].write('new scheduler log\n'); + await workers[1].write('new queue log\n'); + assert.equal( + await fs.readFile(active, 'utf8'), + 'new scheduler log\nnew queue log\n', + ); + } + assert.equal( + await fs.readFile(path.join(root, 'audit.json'), 'utf8'), + 'rotation metadata', + ); +}); + +test('clearing system logs refuses symlinks without truncating their target', async (t) => { + const { root, service } = await fixture(t); + const target = path.join(root, 'keep.txt'); + await fs.writeFile(target, 'keep this content'); + await fs.symlink(target, path.join(root, 'linked.log')); + await assert.rejects(service.deleteSystemLog(), { code: 'ELOOP' }); + assert.equal(await fs.readFile(target, 'utf8'), 'keep this content'); +});