mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 19:19:53 +08:00
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon. Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility. Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
232 lines
6.8 KiB
JavaScript
232 lines
6.8 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const { spawn } = require('node:child_process');
|
|
const { EventEmitter } = require('node:events');
|
|
const { PassThrough } = require('node:stream');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const load = require('../helpers/load-security-module.cjs');
|
|
const { observeChildProcess } = require('../../back/shared/childProcess');
|
|
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const logger = { info() {}, error() {} };
|
|
test(
|
|
'spawn failure settles without exit, and the next scheduled run can proceed',
|
|
{ timeout: 3000 },
|
|
async () => {
|
|
let releases = 0;
|
|
let active = 0;
|
|
const { runCron } = load(path.resolve('back/shared/runCron.ts'), {
|
|
'cross-spawn': {
|
|
spawn: () => spawn('true', { shell: '/nonexistent-ql-test-shell' }),
|
|
},
|
|
'./pLimit': {
|
|
runWithCronLimit: async (_cron, fn) => {
|
|
active++;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
active--;
|
|
}
|
|
},
|
|
removeQueuedCron: () => releases++,
|
|
},
|
|
'../loaders/logger': logger,
|
|
'../data/cron': {
|
|
CrontabModel: {
|
|
findOne: async (where, read = {}) => {
|
|
return { isDisabled: 0 };
|
|
},
|
|
},
|
|
CrontabStatus: {},
|
|
},
|
|
'../data/runningInstance': {
|
|
RunningInstanceModel: {},
|
|
InstanceStatus: {},
|
|
},
|
|
'../config/util': { killTask: async () => {} },
|
|
});
|
|
await runCron('true', { id: '1' });
|
|
await runCron('true', { id: '2' });
|
|
assert.equal(active, 0);
|
|
assert.equal(releases, 2);
|
|
},
|
|
);
|
|
test(
|
|
'completion waits for slow log consumers and preserves the final output',
|
|
{ timeout: 5000 },
|
|
async () => {
|
|
const chunks = [];
|
|
const child = spawn(process.execPath, [
|
|
'-e',
|
|
'process.stdout.write("尾行\\n"); process.stderr.write("错误\\n")',
|
|
]);
|
|
const observed = observeChildProcess(child, {
|
|
onStart: () => delay(25),
|
|
onStdout: async (message) => {
|
|
await delay(40);
|
|
chunks.push(message);
|
|
},
|
|
onStderr: async (message) => {
|
|
await delay(30);
|
|
chunks.push(message);
|
|
},
|
|
});
|
|
const result = await observed.completed;
|
|
assert.equal(result.code, 0);
|
|
assert.equal(result.error, undefined);
|
|
assert.match(chunks.join(''), /尾行/);
|
|
assert.match(chunks.join(''), /错误/);
|
|
},
|
|
);
|
|
test('exit is not completion, and UTF-8 split across writes remains intact', async () => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.pid = 123;
|
|
const chunks = [];
|
|
const observed = observeChildProcess(child, {
|
|
onStdout: async (data) => chunks.push(data),
|
|
});
|
|
child.emit('spawn');
|
|
let done = false;
|
|
observed.completed.then(() => {
|
|
done = true;
|
|
});
|
|
child.emit('exit', 0, null);
|
|
const data = Buffer.from('末尾中文');
|
|
child.stdout.write(data.subarray(0, 2));
|
|
await delay(10);
|
|
assert.equal(done, false);
|
|
child.stdout.end(data.subarray(2));
|
|
child.stderr.end();
|
|
child.emit('close', 0, null);
|
|
await observed.completed;
|
|
assert.equal(chunks.join(''), '末尾中文');
|
|
});
|
|
test(
|
|
'failed log sink drains large output instead of blocking the child',
|
|
{ timeout: 5000 },
|
|
async () => {
|
|
const child = spawn(process.execPath, [
|
|
'-e',
|
|
'process.stdout.write("x".repeat(2 * 1024 * 1024))',
|
|
]);
|
|
const { completed } = observeChildProcess(child, {
|
|
onStdout: async () => {
|
|
throw new Error('ENOSPC');
|
|
},
|
|
});
|
|
const result = await completed;
|
|
assert.equal(result.code, 0);
|
|
assert.equal(result.error.message, 'ENOSPC');
|
|
},
|
|
);
|
|
function scheduleFixture() {
|
|
let active = 0;
|
|
const limit = async (_params, fn) => {
|
|
active++;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
active--;
|
|
}
|
|
};
|
|
const Schedule = load(path.resolve('back/services/schedule.ts'), {
|
|
'../shared/pLimit': {
|
|
runWithScriptLimit: limit,
|
|
runWithSystemLimit: limit,
|
|
},
|
|
'cross-spawn': {
|
|
spawn: () =>
|
|
spawn(process.execPath, [
|
|
'-e',
|
|
'setTimeout(() => process.stdout.write("done"), 150)',
|
|
]),
|
|
},
|
|
}).default;
|
|
return { service: new Schedule(logger), active: () => active };
|
|
}
|
|
test(
|
|
'PID response keeps queue capacity occupied until cleanup completes',
|
|
{ timeout: 4000 },
|
|
async () => {
|
|
const fixture = scheduleFixture();
|
|
let finish;
|
|
const finished = new Promise((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
const pid = await fixture.service.runTask(
|
|
'ignored',
|
|
{
|
|
onEnd: async () => {
|
|
await delay(20);
|
|
finish();
|
|
},
|
|
},
|
|
{ id: 'script', runOrigin: 'script' },
|
|
'start',
|
|
);
|
|
assert.ok(pid > 0);
|
|
assert.equal(fixture.active(), 1);
|
|
await finished;
|
|
await delay(0);
|
|
assert.equal(fixture.active(), 0);
|
|
},
|
|
);
|
|
test(
|
|
'before/error/end callback failures settle and release capacity',
|
|
{ timeout: 3000 },
|
|
async () => {
|
|
const fixture = scheduleFixture();
|
|
let ended = 0;
|
|
const result = await fixture.service.runTask(
|
|
'ignored',
|
|
{
|
|
onBefore: async () => {
|
|
throw new Error('setup failed');
|
|
},
|
|
onError: async () => {
|
|
throw new Error('sink failed');
|
|
},
|
|
onEnd: async (child) => {
|
|
assert.equal(child, undefined);
|
|
ended++;
|
|
throw new Error('cleanup failed');
|
|
},
|
|
},
|
|
{ id: 'system', runOrigin: 'system' },
|
|
);
|
|
assert.equal(result.error.message, 'setup failed');
|
|
assert.equal(ended, 1);
|
|
assert.equal(fixture.active(), 0);
|
|
},
|
|
);
|
|
test(
|
|
'concurrent writes and closes preserve all bytes, and failed files can be closed',
|
|
{ timeout: 5000 },
|
|
async (t) => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-lifecycle-'));
|
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
|
const manager = new LogStreamManager(dir);
|
|
const file = path.join(dir, 'out.log');
|
|
const lines = Array.from({ length: 500 }, (_, i) => `${i}:末尾\n`);
|
|
const writes = lines.map((line) => manager.write(file, line));
|
|
await Promise.all([
|
|
...writes,
|
|
manager.closeStream(file),
|
|
manager.closeStream(file),
|
|
]);
|
|
assert.equal(await fs.readFile(file, 'utf8'), lines.join(''));
|
|
assert.equal(manager.getOpenStreamCount(), 0);
|
|
const invalid = path.join(dir, 'missing', 'out.log');
|
|
await assert.rejects(manager.write(invalid, 'fail'), /ENOENT/);
|
|
await assert.rejects(manager.closeStream(invalid), /ENOENT/);
|
|
assert.equal(manager.getOpenStreamCount(), 0);
|
|
await manager.write(file, 'reopened');
|
|
await manager.closeAll();
|
|
assert.match(await fs.readFile(file, 'utf8'), /reopened$/);
|
|
},
|
|
);
|