fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)

* fix: harden task lifecycle and scheduler readiness

* fix: confine log writes to the configured log directory

* fix: verify complete build artifacts and untracked inputs

* fix: reconcile scheduler state and make stop win startup races

* fix: isolate cron generations and serialize scheduler recovery
This commit is contained in:
whyour
2026-09-13 00:32:41 +08:00
committed by GitHub
parent d62d8f3025
commit a94e665054
61 changed files with 4214 additions and 608 deletions
+150
View File
@@ -0,0 +1,150 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
test('build verification accepts matching source and rejects stale or dirty artifacts', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-source-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const git = (...args) =>
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
git('init');
git('config', 'user.name', 'Build test');
git('config', 'user.email', 'build-test@example.invalid');
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
git('add', '.');
git('commit', '-m', 'fixture');
fs.mkdirSync(path.join(dir, 'static/build'), { recursive: true });
fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true });
fs.writeFileSync(path.join(dir, 'static/build/app.js'), '');
fs.writeFileSync(path.join(dir, 'static/dist/index.html'), '');
const write = path.resolve('scripts/write-build-info.cjs');
const verify = path.resolve('docker/verify-build.cjs');
execFileSync(process.execPath, [write], { cwd: dir });
execFileSync(process.execPath, [verify], { cwd: dir });
const manifestPath = path.join(dir, 'static/build-info.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath));
for (const invalid of [
{ ...manifest, sourceCommit: '0'.repeat(40) },
{ ...manifest, dirty: true },
{ ...manifest, lockfileSha256: '0'.repeat(64) },
]) {
fs.writeFileSync(manifestPath, JSON.stringify(invalid));
assert.notEqual(
spawnSync(process.execPath, [verify], { cwd: dir }).status,
0,
);
}
});
test('release images receive the same-run artifact and verify it before use', () => {
const yaml = require('js-yaml');
const workflow = yaml.load(
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
);
assert.equal(workflow.jobs['build-static'].needs, 'validate');
const upload = workflow.jobs['build-static'].steps.find(step => step.uses?.startsWith('actions/upload-artifact@'));
assert.equal(upload.with['include-hidden-files'], true);
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
const job = workflow.jobs[name];
assert.equal(job.needs, 'build-static');
const download = job.steps.find((step) =>
step.uses?.startsWith('actions/download-artifact@'),
);
assert.equal(download.with.name, 'qinglong-static-${{ github.sha }}');
assert.equal(download.with.path, 'static/');
const build = job.steps.find((step) =>
step.uses?.startsWith('docker/build-push-action@'),
);
assert.match(
build.with['build-args'],
/SOURCE_COMMIT=\$\{\{ github.sha \}\}/,
);
const dockerfile = fs.readFileSync(build.with.file, 'utf8');
assert.doesNotMatch(dockerfile, /git clone.*qinglong-static/);
assert.match(
dockerfile,
/git fetch --depth=1 origin "\$\{SOURCE_COMMIT\}"/,
);
assert.match(dockerfile, /node \/tmp\/verify-build.cjs/);
}
});
test('complete artifact manifests reject changed, missing, extra files and untracked build inputs', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-manifest-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const git = (...args) =>
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
git('init');
git('config', 'user.name', 'Build test');
git('config', 'user.email', 'build-test@example.invalid');
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
git('add', '.');
git('commit', '-m', 'fixture');
const artifacts = [
'build/app.js',
'build/services/http.js',
'dist/index.html',
'dist/chunks/main.js',
'dist/assets/main.css',
'dist/.well-known/config',
];
for (const file of artifacts) {
fs.mkdirSync(path.dirname(path.join(dir, 'static', file)), {
recursive: true,
});
fs.writeFileSync(path.join(dir, 'static', file), file);
}
const write = path.resolve('scripts/write-build-info.cjs');
const verify = path.resolve('docker/verify-build.cjs');
const run = (script) =>
spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' });
assert.equal(run(write).status, 0);
const manifestFile = path.join(dir, 'static/build-info.json');
const original = fs.readFileSync(manifestFile, 'utf8');
assert.deepEqual(
Object.keys(JSON.parse(original).files).sort(),
artifacts.sort(),
);
assert.equal(run(write).status, 0);
assert.equal(fs.readFileSync(manifestFile, 'utf8'), original);
assert.equal(run(verify).status, 0);
for (const name of [
'build/services/http.js',
'dist/chunks/main.js',
'dist/assets/main.css',
'dist/.well-known/config',
]) {
const file = path.join(dir, 'static', name);
fs.writeFileSync(file, 'stale');
assert.notEqual(run(verify).status, 0);
fs.unlinkSync(file);
assert.notEqual(run(verify).status, 0);
fs.writeFileSync(file, name);
assert.equal(run(verify).status, 0);
}
const extra = path.join(dir, 'static/build/stale.js');
fs.writeFileSync(extra, 'extra');
assert.notEqual(run(verify).status, 0);
fs.unlinkSync(extra);
const source = path.join(dir, 'custom.config.js');
fs.writeFileSync(source, 'untracked build input');
assert.equal(run(write).status, 0);
assert.equal(JSON.parse(fs.readFileSync(manifestFile)).dirty, true);
assert.notEqual(run(verify).status, 0);
fs.unlinkSync(source);
assert.equal(run(write).status, 0);
assert.equal(run(verify).status, 0);
fs.symlinkSync(path.join(dir, 'pnpm-lock.yaml'), extra);
assert.notEqual(run(write).status, 0);
assert.notEqual(run(verify).status, 0);
});
+96
View File
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const source = fs.readFileSync('shell/share.sh', 'utf8');
function extract(name) {
const start = source.indexOf(name + '() {');
assert.ok(start >= 0);
return source.slice(start, source.indexOf('\n}', start) + 2);
}
function fixture(t, content) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-names-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'env file.sh');
fs.writeFileSync(file, content);
return {
root,
file,
run(script) {
const r = spawnSync(
'/bin/bash',
[
'-ec',
extract('get_env_array') +
'\n' +
extract('clear_env') +
'\n' +
script,
],
{
encoding: 'utf8',
env: { ...process.env, file_env: file, FIXTURE_ROOT: root },
},
);
assert.equal(r.status, 0, r.stderr);
return r.stdout;
},
};
}
test('matches legacy pipeline for whitespace, malformed lines, CRLF and final unterminated line', (t) => {
const f = fixture(
t,
'# comment\nexport A=one\nexport B="two words"\n export IGNORED=1\nexport\tIGNORED2=1\nexport C = spaced\nexport \nexport -x X=1\nexport D=a=b\nexport E=crlf\r\nexport F\r\nexport G=last',
);
assert.equal(
f.run('get_env_array; printf "%s\\0" "${exported_variables[@]}"'),
f.run(
'while IFS= read -r line; do printf "%s\\0" "$line"; done < <(grep "^export " "$file_env" | awk \'{print $2}\' | cut -d= -f1)',
),
);
});
test('resets prior names for an empty file', (t) => {
const f = fixture(t, '');
assert.equal(
f.run(
'exported_variables=(OLD); get_env_array; echo "${#exported_variables[@]}"',
),
'0\n',
);
});
test('extracting names never evaluates command substitutions or shell syntax in values', (t) => {
const f = fixture(
t,
'export A=$(touch "$FIXTURE_ROOT/unsafe")\nexport B=`touch "$FIXTURE_ROOT/unsafe2"`\n',
);
assert.equal(
f.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"'),
'A\nB\n',
);
assert.equal(fs.existsSync(path.join(f.root, 'unsafe')), false);
assert.equal(fs.existsSync(path.join(f.root, 'unsafe2')), false);
});
test('clear_env removes listed exports and preserves unrelated values', (t) => {
const f = fixture(t, 'export USER_ONE=1\nexport USER_TWO=2\n');
f.run(
'USER_ONE=old; USER_TWO=old; UNRELATED=kept; get_env_array; clear_env; [[ ! ${USER_ONE+x} && ! ${USER_TWO+x} && "$UNRELATED" == kept ]]',
);
});
test('large configuration preserves order and duplicate names', (t) => {
const text = Array.from(
{ length: 5000 },
(_, i) => 'export V' + (i % 1000) + '=value with spaces',
).join('\n');
const f = fixture(t, text);
const lines = f
.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"')
.trim()
.split('\n');
assert.equal(lines.length, 5000);
assert.deepEqual(
lines,
Array.from({ length: 5000 }, (_, i) => 'V' + (i % 1000)),
);
});
+236
View File
@@ -0,0 +1,236 @@
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 () => null },
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$/);
},
);
+31
View File
@@ -0,0 +1,31 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const load = require('../helpers/load-security-module.cjs');
const Http = load('back/services/http.ts', {
'../config': {bindHost: '127.0.0.1'},
'../loaders/logger': {debug(){},warn(){},error(){}},
'./metrics': {metricsService: {record(){}}},
typedi: {Service:()=>x=>x},
}).HttpServerService;
test('HTTP listener preserves private binding and releases its port after shutdown',async(t)=>{
const app=express();app.get('/',(_q,r)=>r.send('ok'));
const first=new Http();const server=await first.initialize(app,0);
t.after(()=>first.shutdown());
assert.equal(server.address().address,'127.0.0.1');
const port=server.address().port;
const response=await fetch(`http://127.0.0.1:${port}/`,{headers:{connection:'close'}});assert.equal(await response.text(),'ok');
await assert.rejects(new Http().initialize(app,port),e=>e.code==='EADDRINUSE');
await first.shutdown();const replacement=new Http();t.after(()=>replacement.shutdown());
const next=await replacement.initialize(app,port);assert.equal(next.address().port,port);
});
test('custom cluster deployments can restore shared listening',async(t)=>{
const previous=process.env.QL_HTTP_SHARED_LISTEN;
t.after(()=>{if(previous===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=previous;});
for(const value of [undefined,'true']){
if(value===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=value;
let options;const service=new Http();const app=express();const listen=app.listen.bind(app);
app.listen=(opts,cb)=>{options=opts;return listen(opts,cb);};
await service.initialize(app,0);assert.equal(options.exclusive,value!=='true');await service.shutdown();
}
});
+158
View File
@@ -0,0 +1,158 @@
const assert = require('node:assert/strict');
const test = require('node:test');
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 { LogStreamManager } = require('../../back/shared/logStreamManager');
async function fixture(t) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-boundary-'));
t.after(() => fs.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'log');
const outside = path.join(base, 'log-other');
await fs.mkdir(root);
await fs.mkdir(outside);
const victim = path.join(outside, 'victim.log');
await fs.writeFile(victim, 'unchanged');
await fs.symlink(outside, path.join(root, 'escape-dir'));
await fs.symlink(victim, path.join(root, 'escape-file'));
await fs.symlink(
path.join(outside, 'missing.log'),
path.join(root, 'dangling'),
);
const invalid = [
path.join(root, '..', 'log-other', 'new.log'),
path.join(root, 'escape-dir', 'new.log'),
path.join(root, 'escape-file'),
path.join(root, 'dangling'),
root,
path.join(root, 'bad\0name'),
];
return { root, outside, victim, invalid };
}
test('log streams reject traversal, sibling prefixes and escaping symlinks before writing', async (t) => {
const { root, outside, victim, invalid } = await fixture(t);
const manager = new LogStreamManager(root);
for (const target of invalid) {
await assert.rejects(
manager.write(target, 'overwrite'),
/outside the log directory/,
);
await assert.rejects(
manager.closeStream(target),
/outside the log directory/,
);
assert.equal(manager.getOpenStreamCount(), 0);
}
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
const folder = path.join(root, '中文 日志');
await fs.mkdir(folder);
const log = path.join(folder, 'task.log');
await Promise.all([
manager.write(log, '开始\n'),
manager.write(log, '结束\n'),
]);
await manager.closeAll();
assert.equal(await fs.readFile(log, 'utf8'), '开始\n结束\n');
});
test('log initialization rejects unsafe paths before mkdir or file writes', async (t) => {
const { root, outside, victim, invalid } = await fixture(t);
const { handleLogPath } = load(path.resolve('back/config/util.ts'), {
'./index': { logPath: root },
'./share': {},
'../loaders/logger': {},
'../shared/utils': {
writeFileWithLock: (file, data) => fs.writeFile(file, data),
},
'../data/dependence': { DependenceTypes: {} },
});
for (const target of invalid) {
await assert.rejects(
handleLogPath(target, 'overwrite'),
/outside the log directory/,
);
}
await assert.rejects(
handleLogPath('../log-other/new/sub/task.log', 'overwrite'),
/outside the log directory/,
);
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
const log = await handleLogPath('中文 日志/nested/task.log', 'initial');
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
assert.equal(await handleLogPath(log, 'ignored'), log);
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
});
test('manual execution rejects escaping log names before creating directories or spawning', async (t) => {
const { root, outside, victim } = await fixture(t);
let spawned = 0;
let releases = 0;
const errors = [];
const CronService = load(path.resolve('back/services/cron.ts'), {
'../config': { logPath: root },
'../data/cron': {
CrontabStatus: { queued: 3, idle: 1 },
CrontabModel: { update: async () => {} },
},
'../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} },
'../config/util': {},
'../config/const': {},
'../schedule/client': {},
'../shared/pLimit': {
manualRunWithCronLimit: async (fn) => {
try {
return await fn();
} finally {
releases++;
}
},
},
'../shared/utils': {},
'../shared/i18n': { t: (s) => s },
'../shared/logReader': {},
'../shared/logStreamManager': {
logStreamManager: { closeStream: async () => {} },
},
'cross-spawn': {
spawn: () => {
spawned++;
throw new Error('must not spawn');
},
},
}).default;
const service = new CronService({
info() {},
error: (...args) => errors.push(args),
});
for (const log_name of [
'../log-other/new',
outside,
'escape-dir/new',
'dangling',
'bad\0name',
]) {
service.getDb = async () => ({
id: 1,
status: 3,
command: 'ignored',
log_path: '',
log_name,
});
await service.runSingle(1);
}
assert.equal(spawned, 0);
assert.equal(releases, 5);
assert.equal(
errors.filter((args) =>
args.includes('Log path is outside the log directory'),
).length,
5,
);
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
});
+85
View File
@@ -0,0 +1,85 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { spawn } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
const { LogStreamManager } = require('../../back/shared/logStreamManager');
test(
'manual execution captures a short child before slow status storage and flushes before release',
{ timeout: 5000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const logs = new LogStreamManager(root);
const updates = [];
let active = 0;
let failSpawn = false;
const CronService = load(path.resolve('back/services/cron.ts'), {
'../config': { logPath: root },
'../data/cron': {
CrontabStatus: { queued: 3, running: 0, idle: 1 },
CrontabModel: {
update: async (data, options) => {
await new Promise((resolve) => setTimeout(resolve, 40));
updates.push({ data, options });
return [1];
},
},
},
'../data/runningInstance': {
RunningInstanceModel: {},
InstanceStatus: {},
},
'../config/util': { getUniqPath: async () => 'task' },
'../config/const': { TASK_PREFIX: 'task ', QL_PREFIX: 'ql ' },
'../schedule/client': {},
'../shared/pLimit': {
manualRunWithCronLimit: async (fn) => {
active++;
try {
return await fn();
} finally {
active--;
}
},
},
'../shared/utils': {},
'../shared/i18n': { t: (s) => s },
'../shared/logReader': {},
'../shared/logStreamManager': { logStreamManager: logs },
'cross-spawn': {
spawn: () =>
failSpawn
? spawn('true', { shell: '/nonexistent-ql-manual-shell' })
: spawn(process.execPath, [
'-e',
'process.stdout.write("末尾\\n")',
]),
},
}).default;
const service = new CronService({ info() {}, error() {} });
service.getDb = async () => ({
id: 1,
status: 3,
command: 'ignored',
log_path: '',
});
service.makeCommand = () => 'ignored';
await service.runSingle(1);
const [file] = await fs.readdir(path.join(root, 'task'));
assert.equal(
await fs.readFile(path.join(root, 'task', file), 'utf8'),
'末尾\n',
);
assert.equal(logs.getOpenStreamCount(), 0);
assert.equal(active, 0);
assert.equal(updates.at(-1).data.status, 1);
failSpawn = true;
await service.runSingle(1);
assert.equal(active, 0);
assert.equal(updates.at(-1).data.status, 1);
},
);
+215
View File
@@ -0,0 +1,215 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
const { killTask } = require('../../back/config/util');
const { LogStreamManager } = require('../../back/shared/logStreamManager');
for (const conflict of ['stop', 'newer-queue', 'stop-requeue']) {
test(
`manual startup loses its conditional claim after ${conflict} and terminates the late child`,
{ timeout: 10000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-claim-'));
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(async () => {
await db.close();
await fs.rm(root, { recursive: true, force: true });
});
const crons = db.define('Cron', {
status: DataTypes.INTEGER,
pid: DataTypes.INTEGER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
command: DataTypes.STRING,
});
const instances = db.define('Instance', {
cron_id: DataTypes.INTEGER,
status: DataTypes.INTEGER,
pid: DataTypes.INTEGER,
});
await db.sync();
await crons.create({
id: 1,
status: 3,
pid: null,
log_path: 'previous.log',
command: 'ignored',
});
let release,
entered,
child,
released = 0;
const pending = new Promise((resolve) => (entered = resolve));
const gate = new Promise((resolve) => (release = resolve));
const logs = new LogStreamManager(root);
t.after(() => logs.closeAll());
const CronService = load('back/services/cron.ts', {
'../config': { logPath: root },
'../data/cron': {
CrontabModel: crons,
CrontabStatus: { queued: 3, running: 0, idle: 1 },
},
'../data/runningInstance': {
RunningInstanceModel: instances,
InstanceStatus: { running: 0, stopped: 2 },
},
'../config/util': {
getUniqPath: async () => {
entered();
await gate;
return 'task';
},
killTask,
},
'../config/const': {},
'../schedule/client': {},
'../shared/pLimit': {
manualRunWithCronLimit: async (fn) => {
try {
return await fn();
} finally {
released++;
}
},
},
'../shared/utils': {},
'../shared/i18n': { t: (s) => s },
'../shared/logReader': {},
'../shared/logStreamManager': { logStreamManager: logs },
'cross-spawn': {
spawn: () => {
child = spawn(process.execPath, [
'-e',
'setInterval(() => {}, 1000)',
]);
t.after(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
});
return child;
},
},
}).default;
const service = new CronService({ info() {}, error() {} });
service.getDb = async () =>
(await crons.findByPk(1)).get({ plain: true });
service.makeCommand = () => 'ignored';
const running = service.runSingle(1);
await pending;
if (conflict === 'stop-requeue') {
await service.stop([1]);
// Queue again through the real API; leave the new runner pending.
const pendingRuns = [];
service.runSingle = (id, token) => pendingRuns.push({ id, token });
await service.run([1]);
assert.equal(pendingRuns.length, 1);
assert.equal(
pendingRuns[0].token,
(await crons.findByPk(1)).queued_token,
);
} else if (conflict === 'stop') await service.stop([1]);
else await crons.update({ log_path: 'newer.log' }, { where: { id: 1 } });
release();
const result = await running;
assert.match(result.error.message, /stopped or superseded/);
assert.ok(child.signalCode || child.exitCode !== null);
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
const row = await crons.findByPk(1);
assert.equal(row.status, conflict === 'stop' ? 1 : 3);
assert.equal(
row.log_path,
conflict === 'newer-queue' ? 'newer.log' : 'previous.log',
);
assert.equal(row.pid, null);
assert.equal(released, 1);
},
);
}
test(
'verified termination waits for a SIGTERM-resistant target to exit',
{ timeout: 5000 },
async (t) => {
const child = spawn(process.execPath, [
'-e',
'process.on("SIGTERM",()=>{}); process.stdout.write("ready"); setInterval(()=>{},1000)',
]);
t.after(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
});
await new Promise((resolve) => child.stdout.once('data', resolve));
await killTask(child.pid, true);
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
assert.equal(child.signalCode, 'SIGKILL');
},
);
test('a runner waiting for a concurrency slot cannot adopt a newer queued generation', async (t) => {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const crons = db.define('Cron', {
status: DataTypes.INTEGER,
pid: DataTypes.INTEGER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
});
await db.sync();
await crons.create({ id: 1, status: 1, log_path: 'same.log' });
const waiting = [];
const CronService = load('back/services/cron.ts', {
'../config': {},
'../data/cron': {
CrontabModel: crons,
CrontabStatus: { queued: 3, idle: 1, running: 0 },
},
'../data/runningInstance': {
RunningInstanceModel: { findAll: async () => [] },
InstanceStatus: { running: 0 },
},
'../config/util': {
getUniqPath: () => assert.fail('stale runner must not prepare a child'),
},
'../config/const': {},
'../schedule/client': {},
'../shared/pLimit': {
manualRunWithCronLimit: (fn) => {
waiting.push(fn);
return Promise.resolve();
},
},
'../shared/utils': {},
'../shared/i18n': {},
'../shared/logReader': {},
'../shared/logStreamManager': {},
'cross-spawn': { spawn: () => assert.fail('stale runner must not spawn') },
}).default;
const errors = [];
const service = new CronService({ error: (...args) => errors.push(args) });
await service.run([1]);
const first = (await crons.findByPk(1)).queued_token;
await service.stop([1]);
await service.run([1]);
const second = (await crons.findByPk(1)).queued_token;
assert.notEqual(first, second);
assert.equal(waiting.length, 2);
await waiting[0]();
const row = await crons.findByPk(1);
assert.equal(row.status, 3);
assert.equal(row.queued_token, second);
assert.equal(row.log_path, 'same.log');
assert.deepEqual(errors, []);
});
+173
View File
@@ -0,0 +1,173 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync, spawn } = require('node:child_process');
const helper = path.resolve('shell/node_path_cache.sh');
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-path-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
const calls = path.join(root, 'calls');
fs.writeFileSync(
path.join(bin, 'pnpm'),
'#!/bin/bash\necho call >> "$CALLS"\n[[ "${FAIL:-0}" == 1 ]] && exit 1\nprintf "%s\\n" "${ANSWER:-/test/global/node_modules}"\n',
{ mode: 0o755 },
);
const env = {
...process.env,
HOME: root,
PATH: `${bin}:${process.env.PATH}`,
dir_tmp: path.join(root, 'cache'),
CALLS: calls,
HELPER: helper,
};
const command = '. "$HELPER"; ql_get_node_global_path';
const run = (extra = {}, code = command) =>
spawnSync('/bin/bash', ['-uc', code], {
cwd: root,
env: { ...env, ...extra },
encoding: 'utf8',
});
const count = () =>
fs.existsSync(calls)
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
: 0;
return { root, bin, env, command, run, count };
}
test('warm cache avoids pnpm, preserves spaces, and never evaluates cached text', (t) => {
const f = fixture(t);
const answer = '/test/path with spaces/$(touch injected)';
assert.equal(f.run({ ANSWER: answer }).stdout.trim(), answer);
assert.equal(f.run({ ANSWER: '/different' }).stdout.trim(), answer);
assert.equal(f.count(), 1);
assert.equal(fs.existsSync(path.join(f.root, 'injected')), false);
});
test('config, environment, cwd, and executable changes invalidate discovery', (t) => {
const f = fixture(t);
f.run();
fs.writeFileSync(path.join(f.root, '.npmrc'), 'global-dir=/other\n');
f.run();
assert.equal(f.count(), 2);
f.run({ npm_config_global_dir: '/third' });
assert.equal(f.count(), 3);
f.run({ PNPM_HOME: '/fourth' });
assert.equal(f.count(), 4);
fs.mkdirSync(path.join(f.root, 'child'));
f.run({}, 'cd child; ' + f.command);
assert.equal(f.count(), 5);
f.run();
const before = f.count();
fs.appendFileSync(path.join(f.bin, 'pnpm'), '# upgrade\n');
f.run();
assert.equal(f.count(), before + 1);
});
test('expired or malformed records refresh and failed lookups are not cached', (t) => {
const f = fixture(t);
f.run();
const file = path.join(
f.env.dir_tmp,
fs.readdirSync(f.env.dir_tmp).find((name) => name.endsWith('.cache')),
);
let lines = fs.readFileSync(file, 'utf8').split('\n');
lines[1] = '0';
fs.writeFileSync(file, lines.join('\n'));
f.run();
assert.equal(f.count(), 2);
fs.writeFileSync(file, lines[0] + '\n08\n/incorrect\n');
assert.equal(f.run().status, 0);
assert.equal(f.count(), 3);
fs.writeFileSync(file, lines[0]);
assert.equal(f.run().status, 0);
assert.equal(f.count(), 4);
fs.rmSync(file);
assert.equal(f.run({ FAIL: '1' }).status, 1);
assert.equal(fs.existsSync(file), false);
assert.equal(f.run().status, 0);
assert.equal(f.count(), 6);
});
test('disabled or unavailable cache falls back without changing discovery output', (t) => {
const f = fixture(t);
for (let i = 0; i < 2; i++)
assert.equal(
f.run({ QL_NODE_PATH_CACHE: '0' }).stdout.trim(),
'/test/global/node_modules',
);
assert.equal(f.count(), 2);
assert.equal(fs.existsSync(f.env.dir_tmp), false);
const blocked = path.join(f.root, 'file');
fs.writeFileSync(blocked, '');
assert.equal(
f.run({ dir_tmp: blocked }).stdout.trim(),
'/test/global/node_modules',
);
assert.equal(f.count(), 3);
});
test('concurrent refreshes publish complete records and do not overwrite symlink targets', async (t) => {
const f = fixture(t);
await Promise.all(
Array.from(
{ length: 8 },
() =>
new Promise((resolve, reject) => {
const p = spawn('/bin/bash', ['-uc', f.command], {
cwd: f.root,
env: f.env,
});
let out = '';
p.stdout.on('data', (c) => (out += c));
p.once('error', reject);
p.once('close', (code) => {
try {
assert.equal(code, 0);
assert.equal(out.trim(), '/test/global/node_modules');
resolve();
} catch (e) {
reject(e);
}
});
}),
),
);
const before = f.count();
f.run();
assert.equal(f.count(), before);
const files = fs
.readdirSync(f.env.dir_tmp)
.filter((file) => file.endsWith('.cache'));
assert.equal(files.length, 1);
const file = path.join(f.env.dir_tmp, files[0]);
fs.rmSync(file);
const victim = path.join(f.root, 'victim');
fs.writeFileSync(victim, 'unchanged');
fs.symlinkSync(victim, file);
assert.equal(f.run().status, 0);
assert.equal(fs.readFileSync(victim, 'utf8'), 'unchanged');
assert.equal(fs.lstatSync(file).isSymbolicLink(), false);
});
test('task entry tolerates discovery failure with errexit enabled', (t) => {
const f = fixture(t);
const source = fs.readFileSync(path.resolve('shell/otask.sh'), 'utf8');
const start = source.indexOf('append_node_dependency_path() {');
const end = source.indexOf('\nenter_script_workdir()', start);
const script =
source.slice(start, end) +
'\nappend_node_dependency_path; printf "continued:%s" "$NODE_PATH"';
for (const dir of [path.dirname(helper), f.root]) {
const r = f.run(
{
FAIL: '1',
dir_shell: dir,
dir_dep: '/legacy/deps',
NODE_PATH: '/existing',
},
'set -e; ' + script,
);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, 'continued:/existing:/legacy/deps');
}
});
+178
View File
@@ -0,0 +1,178 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const helper = path.resolve('shell/node_path_cache.sh');
const hasFlock = spawnSync('/bin/bash', ['-c', 'type -P flock']).status === 0;
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-path-lock-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
fs.writeFileSync(
path.join(bin, 'pnpm'),
'#!/bin/bash\nprintf "call\\n" >> "$CALLS"\n[[ -n "${UMASK_FILE:-}" ]] && umask > "$UMASK_FILE"\nsleep "${LOOKUP_DELAY:-0.2}"\n[[ "${FAIL:-0}" == 1 ]] && exit 17\nprintf "%s\\n" "${npm_config_global_dir:-/test/global/node_modules}"\n',
{ mode: 0o755 },
);
const env = {
...process.env,
HOME: root,
PATH: bin + ':' + process.env.PATH,
dir_tmp: path.join(root, 'cache'),
CALLS: path.join(root, 'calls'),
HELPER: helper,
};
const command = '. "$HELPER"; ql_get_node_global_path';
const run = (extra = {}, code = command) =>
spawnSync('/bin/bash', ['-euc', code], {
cwd: root,
env: { ...env, ...extra },
encoding: 'utf8',
});
const asyncRun = (extra = {}) =>
new Promise((resolve, reject) => {
const child = spawn('/bin/bash', ['-euc', command], {
cwd: root,
env: { ...env, ...extra },
});
let out = '',
err = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (err += c));
child.on('error', reject);
child.on('close', (status) =>
resolve({ status, stdout: out, stderr: err }),
);
});
const count = () =>
fs.existsSync(env.CALLS)
? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length
: 0;
const cache = path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.cache`);
return {
root,
bin,
env,
command,
run,
asyncRun,
count,
cache,
lock: cache + '.lock',
};
}
function check(r, output = '/test/global/node_modules') {
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), output);
}
test(
'simultaneous cold and expired lookups share one successful refresh',
{ skip: !hasFlock },
async (t) => {
const f = fixture(t);
for (let batch = 0; batch < 2; batch++) {
if (batch) {
const lines = fs.readFileSync(f.cache, 'utf8').split('\n');
lines[1] = '0';
fs.writeFileSync(f.cache, lines.join('\n'));
}
const rows = await Promise.all(
Array.from({ length: 8 }, () => f.asyncRun({ LOOKUP_DELAY: '0.5' })),
);
rows.forEach((r) => check(r));
assert.equal(f.count(), batch + 1);
}
assert.equal(fs.statSync(f.lock).mode & 0o777, 0o600);
check(f.run());
assert.equal(f.count(), 2);
},
);
test(
'waiters with different configuration do not reuse another key',
{ skip: !hasFlock },
async (t) => {
const f = fixture(t);
const rows = await Promise.all(
['/first', '/second'].map((v) =>
f.asyncRun({ npm_config_global_dir: v }),
),
);
rows.forEach((r, i) => check(r, ['/first', '/second'][i]));
assert.equal(f.count(), 2);
},
);
test(
'lock timeout falls back while a holder is still alive',
{ skip: !hasFlock },
async (t) => {
const f = fixture(t);
fs.mkdirSync(f.env.dir_tmp);
const holder = spawn(
'/bin/bash',
['-c', 'exec 9>> "$LOCK"; flock 9; echo ready; sleep 30'],
{ env: { ...f.env, LOCK: f.lock }, detached: true },
);
t.after(() => {
try {
process.kill(-holder.pid, 'SIGKILL');
} catch {}
});
await new Promise((resolve, reject) => {
holder.stdout.once('data', resolve);
holder.once('error', reject);
});
const start = Date.now();
check(f.run());
assert.ok(Date.now() - start >= 1800);
assert.equal(holder.exitCode, null);
assert.equal(f.count(), 1);
},
);
test('absent or unsupported flock and unsafe lock paths preserve discovery', (t) => {
const f = fixture(t);
check(
f.run(
{},
'type(){ if [[ "$*" == "-P flock" ]]; then return 1; fi; builtin type "$@"; }; ' +
f.command,
),
);
fs.rmSync(f.cache);
fs.writeFileSync(path.join(f.bin, 'flock'), '#!/bin/bash\nexit 64\n', {
mode: 0o755,
});
check(f.run());
fs.rmSync(f.cache);
fs.rmSync(f.lock, { force: true });
const victim = path.join(f.root, 'victim');
fs.writeFileSync(victim, 'untouched');
fs.symlinkSync(victim, f.lock);
check(f.run());
assert.equal(fs.readFileSync(victim, 'utf8'), 'untouched');
assert.equal(f.count(), 3);
});
test('failed refresh releases lock and does not publish a success', (t) => {
const f = fixture(t);
assert.equal(f.run({ FAIL: '1' }).status, 17);
assert.equal(fs.existsSync(f.cache), false);
check(f.run());
assert.equal(f.count(), 2);
});
test('refresh leaves the caller file descriptor and umask unchanged', (t) => {
const f = fixture(t);
const sentinel = path.join(f.root, 'fd');
const mask = path.join(f.root, 'mask');
const r = f.run(
{ SENTINEL: sentinel, UMASK_FILE: mask },
'umask 022; exec 9> "$SENTINEL"; ' +
f.command +
' >/dev/null; printf preserved >&9; umask',
);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), '0022');
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserved');
assert.equal(fs.readFileSync(mask, 'utf8').trim(), '0022');
});
+24
View File
@@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
const appSource = fs.readFileSync('back/app.ts', 'utf8');
const ts = require('typescript');
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
const conf=config(env);env.pm_id='0';env.pmx=String(conf.pmx);
const calls=[],module={exports:{}};
vm.runInNewContext(compiled,{module,exports:module.exports,process:{env},require(name){if(name==='cluster')return {fork(options){calls.push({...env,...options});return {id:1,process:{pid:123}}}};if(name==='express')return ()=>({use(){}});return {};}});
new module.exports().forkWorker('http');
assert.equal(conf.pmx,primary==='true');
assert.equal(calls[0].pmx,primary==='true'&&workers==='true'?'true':'false');
assert.equal(conf.max_restarts,5);assert.equal(conf.script,'static/build/app.js');
}
});
test('standalone PM2 monitoring stays enabled', () => {
for(const QL_PRIMARY_APM of [undefined,'true','false']) assert.equal(config({QL_PRIMARY_APM}).pmx,true);
});
+274
View File
@@ -0,0 +1,274 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { setTimeout: delay } = require('node:timers/promises');
const { Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
function gate() {
let release;
const promise = new Promise((resolve) => {
release = resolve;
});
return { promise, release };
}
async function fixture(t) {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-scheduler-mutation-'),
);
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
const crons = db.define('Cron', {
name: DataTypes.STRING,
command: DataTypes.STRING,
schedule: DataTypes.STRING,
isDisabled: DataTypes.INTEGER,
saved: DataTypes.BOOLEAN,
queued_token: DataTypes.STRING,
});
await db.sync();
t.after(async () => {
await db.close();
await fs.rm(root, { recursive: true, force: true });
});
const jobs = new Map();
const client = {
addCron: async (items, replace) => {
if (replace) jobs.clear();
items.forEach((item) => jobs.set(item.id, item.command));
},
delCron: async (ids) => ids.forEach((id) => jobs.delete(id)),
};
const CronService = load('back/services/cron.ts', {
'../config': {
crontabFile: path.join(root, 'crontab.list'),
logPath: root,
},
'../data/cron': {
CrontabModel: crons,
Crontab: class {
constructor(options) {
Object.assign(this, { isDisabled: 0 }, options);
}
},
},
'../data/runningInstance': {},
'../config/util': { isDemoEnv: () => false },
'../config/const': {},
'../schedule/client': client,
'../shared/pLimit': {},
'../shared/utils': {},
'../shared/i18n': { t: (s) => s },
'../shared/logReader': {},
'../shared/logStreamManager': {},
}).default;
const service = new CronService({ error() {}, warn() {}, info() {} });
service.getLogName = async () => 'task';
service.shouldUseCronClient = () => true;
service.makeCommand = (doc) => doc.command;
service.crontabs = async () => ({ data: await crons.findAll({ raw: true }) });
service.setCrontab = async () => {};
return { service, crons, jobs, client, root };
}
for (const operation of ['create', 'update', 'remove', 'disabled', 'enabled']) {
test(`recovery snapshot cannot overwrite concurrent ${operation}`, async (t) => {
const { service, crons, jobs } = await fixture(t);
await crons.create({
id: 1,
command: 'old',
schedule: '* * * * *',
isDisabled: operation === 'enabled' ? 1 : 0,
});
const entered = gate(),
release = gate();
let first = true;
service.setCrontab = async () => {
if (first) {
first = false;
entered.release();
await release.promise;
}
};
const recovery = service.autosave_crontab(true);
await entered.promise;
const before = await crons.findAll({ raw: true });
const mutation =
operation === 'create'
? service.create({ command: 'new', schedule: '* * * * *' })
: operation === 'update'
? service.update({ id: 1, command: 'new' })
: service[operation]([1]);
await delay(50);
assert.deepEqual(
await crons.findAll({ raw: true }),
before,
'DB writes must also wait for recovery',
);
release.release();
await Promise.all([recovery, mutation]);
const rows = await crons.findAll({ raw: true });
assert.deepEqual(
[...jobs.entries()].sort(),
rows
.filter((row) => !row.isDisabled)
.map((row) => [String(row.id), row.command])
.sort(),
);
});
}
test('recovery waits for an admitted mutation to finish registration before reading the DB', async (t) => {
const { service, client, jobs } = await fixture(t);
const entered = gate(),
release = gate();
const add = client.addCron;
client.addCron = async (items, replace) => {
if (!replace) {
entered.release();
await release.promise;
}
return add(items, replace);
};
const mutation = service.create({ command: 'new', schedule: '* * * * *' });
await entered.promise;
let reads = 0;
const read = service.crontabs;
service.crontabs = async () => {
reads++;
return read();
};
const recovery = service.autosave_crontab(true);
await delay(50);
assert.equal(reads, 0);
release.release();
await Promise.all([mutation, recovery]);
assert.deepEqual([...jobs.values()], ['new']);
});
for (const operation of ['create', 'update', 'enabled']) {
test(`${operation} preserves scheduler 503 after rollback and releases its lock`, async (t) => {
const { service, crons, client } = await fixture(t);
if (operation !== 'create')
await crons.create({
id: 1,
command: 'old',
schedule: '* * * * *',
isDisabled: operation === 'enabled' ? 1 : 0,
});
const cause = Object.assign(Error('unavailable'), { status: 503 });
client.addCron = async () => {
throw cause;
};
const mutation =
operation === 'create'
? service.create({ command: 'new', schedule: '* * * * *' })
: operation === 'update'
? service.update({ id: 1, command: 'new' })
: service.enabled([1]);
await assert.rejects(
mutation,
(err) =>
err.status === 503 && err.cause === cause && /回滚/.test(err.message),
);
if (operation === 'create') assert.equal(await crons.count(), 0);
else {
const row = await crons.findByPk(1);
assert.equal(row.command, 'old');
assert.equal(row.isDisabled, operation === 'enabled' ? 1 : 0);
}
client.addCron = async () => {};
await service.autosave_crontab(true);
});
}
test(
'scheduler lock excludes a second OS process and leaves no lock after completion',
{ timeout: 10000 },
async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-scheduler-process-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = { crontabFile: path.join(root, 'crontab.list') };
const { withSchedulerMutation } = load(
'back/shared/schedulerMutationLock.ts',
{ '../config': config },
);
const entered = gate(),
release = gate();
const owner = withSchedulerMutation(async () => {
entered.release();
await release.promise;
});
await entered.promise;
const child = spawn(
process.execPath,
[
'-e',
`
const load = require('./test/helpers/load-security-module.cjs');
const { withSchedulerMutation } = load('back/shared/schedulerMutationLock.ts', {'../config': JSON.parse(process.argv[1])});
process.send('attempting');
withSchedulerMutation(async () => process.send('entered')).then(() => process.disconnect()).catch(err => { console.error(err); process.exit(1); });
`,
JSON.stringify(config),
],
{ stdio: ['ignore', 'pipe', 'pipe', 'ipc'] },
);
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
release.release();
});
const closed = once(child, 'close');
assert.equal((await once(child, 'message'))[0], 'attempting');
let acquired = false;
const acquisition = once(child, 'message').then(([message]) => {
acquired = true;
assert.equal(message, 'entered');
});
await delay(150);
assert.equal(acquired, false);
release.release();
await owner;
await acquisition;
assert.equal((await closed)[0], 0);
await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), {
code: 'ENOENT',
});
},
);
test('configuration rollback cannot restore an obsolete manual queue token', async (t) => {
const { service, crons, client } = await fixture(t);
await crons.create({
id: 1,
command: 'old',
schedule: '* * * * *',
isDisabled: 0,
queued_token: 'old-token',
});
let first = true;
client.addCron = async () => {
if (first) {
first = false;
await crons.update({ queued_token: 'new-token' }, { where: { id: 1 } });
throw Object.assign(Error('unavailable'), { status: 503 });
}
};
await assert.rejects(
service.update({ id: 1, command: 'new' }),
(err) => err.status === 503,
);
const row = await crons.findByPk(1);
assert.equal(row.command, 'old');
assert.equal(row.queued_token, 'new-token');
});
+85
View File
@@ -0,0 +1,85 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const {setTimeout:sleep}=require('node:timers/promises');
const {SchedulerReadiness}=require('../../back/shared/schedulerReadiness');
const load=require('../helpers/load-security-module.cjs');
const fs=require('node:fs');
const ts=require('typescript');
test('readiness stays false through failed restoration and retries without idle polling',async()=>{
let probes=0,restores=0,fail=true;
const state=new SchedulerReadiness(async()=>{probes++;},10);
state.configure(async()=>{restores++;if(fail)throw Error('registration failed');});
assert.equal(await state.recover(),false);assert.equal(await state.check(),false);
fail=false;await sleep(40);assert.equal(await state.check(),true);assert.ok(restores>=2);
const before=probes;await sleep(40);assert.equal(probes,before);
});
test('single-flight recovery cannot mark an invalidated generation ready',async()=>{
let release,restores=0;
const state=new SchedulerReadiness(async()=>{},10);
state.configure(async()=>{if(++restores===1)await new Promise(r=>release=r);});
const a=state.recover();assert.equal(state.recover(),a);await sleep(1);
state.invalidate();release();assert.equal(await a,false);assert.equal(await state.check(),false);
await sleep(30);assert.equal(await state.check(),true);assert.equal(restores,2);
});
test('mutation waiting is bounded, a later recovery can succeed',async()=>{
let release;
const state=new SchedulerReadiness(async()=>{},10);state.configure(()=>new Promise(r=>release=r));
await assert.rejects(state.ensureReady(15),e=>e.status===503);
release();await sleep(1);assert.equal(await state.check(),true);
});
test('probe failure immediately invalidates a previously ready scheduler',async()=>{
let fail=false;
const state=new SchedulerReadiness(async()=>{if(fail)throw Error('unavailable');},10);
state.configure(async()=>{});assert.equal(await state.recover(),true);
fail=true;assert.equal(await state.check(),false);
fail=false;await sleep(40);assert.equal(await state.check(),true);
});
test('health uses actual readiness and returns HTTP 503 until recovery',async(t)=>{
let ready=false;const express=require('express');
const {HealthService}=load('back/services/health.ts',{
typedi:{Service:()=>x=>x},'../loaders/logger':{error(){}},'./http':{},
'../schedule/client':{readiness:{check:async()=>ready}},
});
const service=new HealthService({getServer:()=>({})});
const app=express();load('back/api/health.ts',{
typedi:{get:()=>service},'../services/health':{HealthService},'../loaders/logger':{error(){}},
}).default(app);
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();}));
for(const expected of [503,200,503]){
ready=expected===200;const r=await fetch(`http://127.0.0.1:${server.address().port}/health`);const b=await r.json();
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
}
});
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
const module={exports:{}};let files=0;
new Function('module','isDemoEnv','cronClient','withSchedulerMutation',js)(module,()=>false,{addCron:async()=>{throw Error('registration unavailable');}},fn=>fn());
const fixture=new module.exports();fixture.crontabs=async()=>({data:[]});fixture.setCrontab=async()=>{files++;};fixture.logger={warn(){}};
await fixture.autosave_crontab();assert.equal(files,1);
await assert.rejects(fixture.autosave_crontab(true),/registration unavailable/);assert.equal(files,2);
});
test('scheduler probe uses the cron channel and failed writes are not replayed',async()=>{
const calls=[];const fake={
waitForReady(deadline,cb){calls.push(['wait',deadline]);cb();},
makeUnaryRequest(path,serialize,deserialize,request,options,cb){calls.push(['probe',path,request,options]);cb(null,{status:1});},
addCron(request,metadata,options,cb){calls.push(['add',request,options]);cb(Object.assign(Error('invalid'),{code:3}));},
};
const client=load('back/schedule/client.ts',{
'../protos/cron':{CronClient:class{constructor(){return fake;}}},
'../config':{grpcPort:5500},'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
'@grpc/grpc-js':{...require('@grpc/grpc-js'),credentials:{createSsl:()=>({})},status:{UNAVAILABLE:14},Metadata:class{}},
}).default;
client.readiness.configure(async()=>{});assert.equal(await client.readiness.recover(),true);
assert.ok(calls.filter(x=>x[0]==='probe').every(x=>x[1]==='/com.ql.health.Health/Check'&&x[2].service==='scheduler'&&x[3].deadline>Date.now()));
await assert.rejects(client.addCron([]),/invalid/);assert.equal(calls.filter(x=>x[0]==='add').length,1);
});
test('scheduler health probe never calls back into HTTP health',async()=>{
const {check}=load('back/schedule/health.ts',{
'../config':{},undici:{request:()=>{throw Error('recursive HTTP health call');}},
});
const result=await new Promise(resolve=>check({request:{service:'scheduler'}},(error,response)=>resolve({error,response})));
assert.equal(result.error,null);assert.deepEqual(result.response,{status:1});
});
+152
View File
@@ -0,0 +1,152 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const ts = require('typescript');
const load = require('../helpers/load-security-module.cjs');
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
const { AddCronRequest } = require('../../back/protos/cron');
function fixture(client) {
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
const a = source.indexOf(' public async autosave_crontab(');
const z = source.indexOf(' public async bootTask', a);
const js = ts.transpileModule(
'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;',
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
).outputText;
const module = { exports: {} };
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', js)(
module,
() => false,
client,
(fn) => fn(),
);
const service = new module.exports();
service.setCrontab = async () => {};
service.logger = { warn() {} };
service.shouldUseCronClient = () => true;
service.makeCommand = () => 'true';
return service;
}
test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => {
const cancelled = [];
const stacks = new Map(
['removed', 'disabled', 'kept'].map((id) => [
id,
[{ cancel: () => cancelled.push(id) }],
]),
);
const { addCron } = load('back/schedule/addCron.ts', {
'./data': { scheduleStacks: stacks },
'node-schedule': {
scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }),
},
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: (s) => s },
});
const client = {
addCron: async (crons, replace) => {
// Exercise the real protobuf field, not just a JavaScript-only flag.
const request = AddCronRequest.decode(
AddCronRequest.encode({ crons, replace }).finish(),
);
await new Promise((resolve, reject) =>
addCron({ request }, (err) => (err ? reject(err) : resolve())),
);
},
};
const service = fixture(client);
let rows = [
{ id: 'kept', schedule: '* * * * *', isDisabled: 0 },
{ id: 'disabled', schedule: '* * * * *', isDisabled: 1 },
];
service.crontabs = async () => ({ data: rows });
const state = new SchedulerReadiness(async () => {});
state.configure(() => service.autosave_crontab(true));
assert.equal(await state.recover(), true);
assert.equal(await state.check(), true);
assert.deepEqual([...stacks.keys()], ['kept']);
assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']);
rows = [];
state.invalidate();
assert.equal(await state.recover(), true);
assert.equal(stacks.size, 0);
assert.equal(await state.check(), true);
});
test('invalid replacement leaves the previous schedule intact', async () => {
const stacks = new Map([
[
'old',
[
{
cancel: () => {
throw Error('must not cancel');
},
},
],
],
]);
const { addCron } = load('back/schedule/addCron.ts', {
'./data': { scheduleStacks: stacks },
'../shared/runCron': {},
'../loaders/logger': {},
'../shared/i18n': { tf: (s) => s },
});
await assert.rejects(
new Promise((resolve, reject) =>
addCron(
{
request: {
replace: true,
crons: [{ id: 'bad', schedule: '?', extra_schedules: [] }],
},
},
(err) => (err ? reject(err) : resolve()),
),
),
);
assert.deepEqual([...stacks.keys()], ['old']);
});
test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => {
for (const method of ['addCron', 'delCron']) {
let invalidations = 0,
writes = 0;
const fake = {
waitForReady: (_deadline, cb) => cb(Error('channel unavailable')),
addCron: () => writes++,
delCron: () => writes++,
};
const client = load('back/schedule/client.ts', {
'../protos/cron': {
CronClient: class {
constructor() {
return fake;
}
},
},
'../config': { grpcPort: 5500 },
'../config/grpcCerts': {
getGrpcCerts: () => ({
caCert: 'ca',
clientKey: 'key',
clientCert: 'cert',
}),
},
'@grpc/grpc-js': {
...require('@grpc/grpc-js'),
credentials: { createSsl: () => ({}) },
},
}).default;
client.readiness.invalidate = () => invalidations++;
await assert.rejects(
client[method]([]),
(err) => err.status === 503 && /channel unavailable/.test(err.message),
);
assert.equal(invalidations, 1);
assert.equal(writes, 0);
}
});
+127
View File
@@ -0,0 +1,127 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { Sequelize, QueryTypes } = require('sequelize');
const { migrateSchema } = require('../../back/shared/schemaMigrations');
const load = require('../helpers/load-security-module.cjs');
async function createLegacy(storage = ':memory:', omitEnvs = false) {
const database = new Sequelize({
dialect: 'sqlite',
storage,
logging: false,
});
for (const table of [
'CrontabViews',
'Subscriptions',
'Crontabs',
...(omitEnvs ? [] : ['Envs']),
]) {
await database.query(
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`,
);
}
await database.query(
'INSERT INTO "Crontabs" (id, name) VALUES (1, \'keep-me\')',
);
return database;
}
test('legacy database upgrades without losing rows and repeated migration is idempotent', async (t) => {
const database = await createLegacy();
t.after(() => database.close());
await migrateSchema(database);
await migrateSchema(database);
const rows = await database.query('SELECT * FROM "Crontabs"', {
type: QueryTypes.SELECT,
});
assert.equal(rows[0].name, 'keep-me');
assert.equal(rows[0].queued_token, null);
assert.ok(Object.hasOwn(rows[0], 'allow_multiple_instances'));
const applied = await database.query('SELECT id FROM "SchemaMigrations"', {
type: QueryTypes.SELECT,
});
assert.equal(applied.length, 15);
});
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
const database = await createLegacy(':memory:', true);
t.after(() => database.close());
await assert.rejects(migrateSchema(database));
const columns = await database.getQueryInterface().describeTable('Crontabs');
assert.equal(Object.hasOwn(columns, 'work_dir'), false);
await database.query(
'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)',
);
await migrateSchema(database);
assert.ok(
Object.hasOwn(
await database.getQueryInterface().describeTable('Crontabs'),
'work_dir',
),
);
});
test('offline pre-upgrade backup restores the legacy schema and data', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const file = path.join(dir, 'database.sqlite');
const backup = path.join(dir, 'before.sqlite');
let database = await createLegacy(file);
await database.close();
await fs.copyFile(file, backup);
database = new Sequelize({
dialect: 'sqlite',
storage: file,
logging: false,
});
await migrateSchema(database);
await database.close();
await fs.copyFile(backup, file);
database = new Sequelize({
dialect: 'sqlite',
storage: file,
logging: false,
});
try {
assert.equal(
Object.hasOwn(
await database.getQueryInterface().describeTable('Crontabs'),
'work_dir',
),
false,
);
const rows = await database.query('SELECT name FROM "Crontabs"', {
type: QueryTypes.SELECT,
});
assert.equal(rows[0].name, 'keep-me');
} finally {
await database.close();
}
});
test('database loader rejects initialization failure rather than allowing workers to start', async () => {
const mocks = { './logger': { error() {}, info() {} } };
for (const [file, model] of [
['env', 'EnvModel'],
['cron', 'CrontabModel'],
['dependence', 'DependenceModel'],
['open', 'AppModel'],
['system', 'SystemModel'],
['subscription', 'SubscriptionModel'],
['cronView', 'CrontabViewModel'],
['cronStats', 'CrontabStatModel'],
['runningInstance', 'RunningInstanceModel'],
])
mocks[`../data/${file}`] = { [model]: { sync: async () => {} } };
mocks['../data'] = { sequelize: {} };
mocks['../shared/schemaMigrations'] = {
migrateSchema: async () => {
throw new Error('SQLITE_FULL');
},
};
const initialize = load(path.resolve('back/loaders/db.ts'), mocks).default;
await assert.rejects(initialize(), /SQLITE_FULL/);
});
+184
View File
@@ -0,0 +1,184 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const source = fs
.readFileSync(path.resolve('shell/api.sh'), 'utf8')
.replace(/\nget_token\s*$/, '\n');
const realJq = spawnSync('which', ['jq'], { encoding: 'utf8' }).stdout.trim();
function fixture(t) {
assert.ok(realJq, 'jq is required');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-parse-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
fs.writeFileSync(
path.join(bin, 'jq'),
'#!/bin/bash\nprintf "call\\n" >> "$JQ_CALLS"\nexec "$REAL_JQ" "$@"\n',
{ mode: 0o755 },
);
const token = path.join(root, 'token.json'),
response = path.join(root, 'response.json'),
calls = path.join(root, 'calls');
const run = (code) =>
spawnSync(
'/bin/bash',
[
'-euc',
source +
'\ncreate_token(){ __ql_token__=generated; generated=1; }; curl(){ cat "$RESPONSE_FILE"; };\n' +
code,
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: bin + ':' + process.env.PATH,
REAL_JQ: realJq,
JQ_CALLS: calls,
file_auth_token: token,
RESPONSE_FILE: response,
ql_port: '5700',
__ql_token__: 'test-token',
},
},
);
return {
token,
response,
run,
count: () =>
fs.existsSync(calls)
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
: 0,
};
}
test('valid token cache is read with one jq and reused without generation', (t) => {
const f = fixture(t);
fs.writeFileSync(
f.token,
JSON.stringify({
value: 'header.payload.signature',
expiration: 4102444800,
}),
);
const r = f.run(
'get_token; printf "%s:%s" "$__ql_token__" "${generated:-0}"',
);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, 'header.payload.signature:0');
assert.equal(f.count(), 1);
});
test('expired, corrupt, missing or header-unsafe token cache refreshes under errexit', (t) => {
const f = fixture(t);
for (const content of [
undefined,
'broken',
'{}',
JSON.stringify({ value: 'old', expiration: 1 }),
JSON.stringify({ value: 'old', expiration: '4102444800' }),
JSON.stringify({ value: '', expiration: 4102444800 }),
JSON.stringify({ value: 'bad\ninjected', expiration: 4102444800 }),
JSON.stringify({ value: 'bad\n', expiration: 4102444800 }),
JSON.stringify({ value: 'bad\rinjected', expiration: 4102444800 }),
]) {
if (content === undefined) fs.rmSync(f.token, { force: true });
else fs.writeFileSync(f.token, content);
const r = f.run('get_token; printf "%s:%s" "$__ql_token__" "$generated"');
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, 'generated:1');
}
});
test('success with a message still parses once and stays silent', (t) => {
const f = fixture(t);
fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' }));
const r = f.run('update_cron 1 0 123 log 1');
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, '');
assert.equal(f.count(), 1);
});
test('status and statistics errors preserve multiline Unicode messages', (t) => {
const f = fixture(t);
fs.writeFileSync(
f.response,
JSON.stringify({ code: 500, message: '写入失败\n请重试 "原任务"' }),
);
for (const command of [
'update_cron 1 1 123 log 1 2 7',
'record_cron_stat 1 7 2',
]) {
const r = f.run(command);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n');
}
assert.equal(f.count(), 2);
});
test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => {
const f = fixture(t);
fs.writeFileSync(f.response, 'upstream unavailable');
let r = f.run('update_cron 1 0 123 log 1');
assert.equal(r.status, 0);
assert.equal(r.stdout, 'upstream unavailable\n');
fs.writeFileSync(f.response, '{"code":500}');
r = f.run('record_cron_stat 1 7 2');
assert.equal(r.status, 0);
assert.equal(r.stdout, 'null\n');
});
test('statistics without a task id perform no JSON parsing', (t) => {
const f = fixture(t);
const r = f.run('record_cron_stat "" 0 1 || true');
assert.equal(r.status, 0, r.stderr);
assert.equal(f.count(), 0);
});
test('canonical success resets prior errors and matches jq missing-message semantics', (t) => {
const f = fixture(t);
fs.writeFileSync(f.response, '{"code":200}');
const r = f.run(
'code=500; message=old; ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"; record_cron_stat 1 0 1',
);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, '200|null');
assert.equal(f.count(), 0);
});
test('noncanonical JSON always uses jq, including whitespace, duplicate keys and embedded success text', (t) => {
const f = fixture(t);
const cases = [
[' {"code":200} ', '200|null'],
['{"code": 200}', '200|null'],
['{"code":200,"message":"完成\\n第二行"}', '200|完成\n第二行'],
['{"code":200,"data":{"code":500}}', '200|null'],
['{"code":"200"}', '200|null'],
['{"code":200,"code":500,"message":"error"}', '500|error'],
['{"code":500,"message":"{\\"code\\":200}"}', '500|{"code":200}'],
['{"code":500}', '500|null'],
];
for (const [body, expected] of cases) {
fs.writeFileSync(f.response, body);
const r = f.run(
'ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"',
);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, expected);
}
assert.equal(f.count(), cases.length);
});
test('success-shaped invalid documents retain the raw-response error fallback', (t) => {
const f = fixture(t);
const cases = [
'{"code":200}trailing',
'{"code":200',
'<html>{"code":200}</html>',
];
for (const body of cases) {
fs.writeFileSync(f.response, body);
const r = f.run('update_cron 1 0 123 log 1');
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, body + '\n');
}
assert.equal(f.count(), cases.length);
});
+167
View File
@@ -0,0 +1,167 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
async function fixture(t, onKill = async () => {}) {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const crons = db.define('Cron', {
command: DataTypes.STRING,
status: DataTypes.INTEGER,
pid: DataTypes.INTEGER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
last_execution_time: DataTypes.INTEGER,
last_running_time: DataTypes.INTEGER,
});
const instances = db.define('Instance', {
cron_id: DataTypes.INTEGER,
pid: DataTypes.INTEGER,
status: DataTypes.INTEGER,
exit_code: DataTypes.INTEGER,
finished_at: DataTypes.INTEGER,
});
await db.sync();
await crons.bulkCreate([
{ id: 1, command: 'task one.js', status: 0, pid: 101, log_path: 'one.log' },
{ id: 2, command: 'task two.js', status: 0, pid: 201, log_path: 'two.log' },
]);
await instances.bulkCreate([
{ id: 1, cron_id: 1, pid: 101, status: 0 },
{ id: 2, cron_id: 1, pid: 102, status: 0 },
{ id: 3, cron_id: 1, pid: 99, status: 1, exit_code: 0 },
{ id: 4, cron_id: 1, pid: 98, status: 3, exit_code: 7 },
{ id: 5, cron_id: 2, pid: 201, status: 0 },
]);
let service;
const killed = [];
const CronService = load(path.resolve('back/services/cron.ts'), {
'../config': {},
'../data/cron': {
CrontabModel: crons,
CrontabStatus: { queued: 3, running: 0, idle: 1 },
},
'../data/runningInstance': {
RunningInstanceModel: instances,
InstanceStatus: { running: 0, finished: 1, stopped: 2, error: 3 },
},
'../config/util': {
killTask: async (pid, wait) => {
assert.equal(wait, true);
killed.push(pid);
await onKill({ pid, service, instances, crons });
},
killAllTasks: async () => {
throw Error('command-wide scans must not run');
},
},
'../config/const': {},
'../schedule/client': {},
'../shared/pLimit': {},
'../shared/utils': {},
'../shared/i18n': { t: (s) => s },
'../shared/logReader': {},
'../shared/logStreamManager': {},
'../shared/childProcess': require('../../back/shared/childProcess'),
}).default;
service = new CronService({ info() {}, error() {} });
service.getDb = ({ id }) => crons.findByPk(id);
return { service, instances, crons, killed };
}
async function report(service, pid, code) {
await service.status({
ids: [1],
status: 1,
pid,
log_path: 'one.log',
last_running_time: 1,
last_execution_time: 100,
exit_code: code,
});
}
test('stop wins when shell exit writes error or success during termination, preserving exit codes', async (t) => {
const f = await fixture(t, async ({ service }) => {
await report(service, 101, 143);
await report(service, 102, 0);
});
await f.service.stop([1]);
const rows = await f.instances.findAll({ order: [['id', 'ASC']], raw: true });
assert.deepEqual(
rows.map((r) => [r.id, r.status, r.exit_code]),
[
[1, 2, 143],
[2, 2, 0],
[3, 1, 0],
[4, 3, 7],
[5, 0, null],
],
);
});
test('late shell completion cannot overwrite stopped instances', async (t) => {
const f = await fixture(t);
await f.service.stop([1]);
await report(f.service, 101, 143);
assert.equal((await f.instances.findByPk(1)).status, 2);
assert.equal((await f.instances.findByPk(2)).status, 2);
});
test('rows created after the stop snapshot are not relabelled', async (t) => {
const f = await fixture(t, async ({ instances }) => {
if (!(await instances.findByPk(6)))
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
});
await f.service.stop([1]);
assert.equal((await f.instances.findByPk(6)).status, 0);
assert.equal((await f.instances.findByPk(1)).status, 2);
});
test('repeated stop does not rewrite historical rows or their finished timestamps', async (t) => {
const f = await fixture(t);
await f.service.stop([1]);
const before = await f.instances.findAll({
raw: true,
order: [['id', 'ASC']],
});
await f.service.stop([1]);
assert.deepEqual(
await f.instances.findAll({ raw: true, order: [['id', 'ASC']] }),
before,
);
});
test('batch stop captures running instances from every requested cron', async (t) => {
const f = await fixture(t);
await f.service.stop([1, 2]);
assert.equal((await f.instances.findByPk(5)).status, 2);
assert.equal((await f.instances.findByPk(3)).status, 1);
});
test('stop signals every snapshotted PID exactly once and preserves a later running instance', async (t) => {
const f = await fixture(t, async ({ instances, crons }) => {
if (!(await instances.findByPk(6))) {
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
await crons.update(
{ pid: 103, log_path: 'later.log', status: 0 },
{ where: { id: 1 } },
);
}
});
await f.service.stop([1]);
assert.deepEqual(f.killed, [101, 102]);
assert.equal((await f.instances.findByPk(6)).status, 0);
assert.equal((await f.crons.findByPk(1)).pid, 103);
assert.equal((await f.crons.findByPk(1)).status, 0);
});
test('failed termination is not finalized as stopped', async (t) => {
const f = await fixture(t, async ({ pid }) => {
if (pid === 102) throw Error('still alive');
});
await f.service.stop([1]);
assert.deepEqual(f.killed, [101, 102]);
assert.equal((await f.instances.findByPk(1)).status, 2);
assert.equal((await f.instances.findByPk(2)).status, 0);
assert.equal((await f.crons.findByPk(1)).status, 0);
});
+53
View File
@@ -0,0 +1,53 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const dayjs = require('dayjs');
const load = require('../helpers/load-security-module.cjs');
test('subscription becomes idle and closes its log even when completion logging fails', async () => {
const updates = [];
let closed = 0;
let notified = 0;
const Subscription = load(path.resolve('back/services/subscription.ts'), {
'../config': {},
'../data/subscription': {
SubscriptionModel: { update: async (values) => updates.push(values) },
SubscriptionStatus: { idle: 1 },
},
'../data/cron': {},
'../config/util': { handleLogPath: async () => '/tmp/unused-log' },
'../config/const': { LOG_END_SYMBOL: 'end' },
'../config/subscription': {},
'../shared/i18n': { t: (s) => s, tf: (s) => s },
'../shared/pLimit': {},
'../shared/logReader': {},
'../shared/logStreamManager': {
logStreamManager: {
write: async () => {
throw new Error('ENOSPC');
},
closeStream: async () => {
closed++;
throw new Error('ENOSPC');
},
},
},
'./schedule': {},
'./sock': {},
'./sshKey': {},
'./cron': {},
}).default;
const service = new Subscription(
{},
{},
{ sendMessage: () => notified++ },
{},
{},
);
service.getDb = async () => ({ id: 1, log_path: 'log' });
const callbacks = service.taskCallbacks({ id: 1 });
await assert.rejects(callbacks.onEnd(undefined, dayjs(), 1), /ENOSPC/);
assert.equal(closed, 1);
assert.equal(updates.at(-1).status, 1);
assert.equal(notified, 1);
});
+116
View File
@@ -0,0 +1,116 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function extract(file, name) {
const text = fs.readFileSync(file, 'utf8');
const start = text.indexOf(name + '() {');
assert.ok(start >= 0);
return text.slice(start, text.indexOf('\n}', start) + 2);
}
const source =
['handle_log_path', 'init_begin_time']
.map((n) => extract('shell/task.sh', n))
.join('\n') +
'\n' +
['format_time', 'format_log_time', 'format_timestamp', 'handle_task_end']
.map((n) => extract('shell/share.sh', n))
.join('\n');
function run(t, body) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-time-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const result = spawnSync(
'/bin/bash',
[
'-ec',
source +
`
is_macos=0
mtime_format='%Y-%m-%d %H:%M:%S.%3N'
time_format='%Y-%m-%d %H:%M:%S'
ID=42; dir_log=/tmp; log_name=''; real_log_path=''; no_tee=''; real_time=''
make_dir() { :; }
t() { printf '%s\\n' "$*" >> "$TEST_DIR/messages"; }
update_cron() { printf '%s\\n' "$*" >> "$TEST_DIR/status"; }
record_cron_stat() { printf '%s\\n' "$*" >> "$TEST_DIR/stats"; }
date() {
printf '%s\\n' "$*" >> "$TEST_DIR/dates"
case "$1" in
'+%Y-%m-%d %H:%M:%S.%3N|%Y-%m-%d-%H-%M-%S-%3N') printf '%s\\n' '2026-09-12 23:59:59.999|2026-09-12-23-59-59-999';;
'+%Y-%m-%d %H:%M:%S|%s') printf '%s\\n' '2026-09-13 00:00:00|101';;
'-d') [[ "$3" == '+%s' ]] && echo 100;;
*) echo raw-time;;
esac
}
` +
body,
],
{ encoding: 'utf8', env: { ...process.env, TEST_DIR: dir } },
);
assert.equal(result.status, 0, result.stderr + result.stdout);
return {
dir,
text: (name) =>
fs.existsSync(path.join(dir, name))
? fs.readFileSync(path.join(dir, name), 'utf8')
: '',
};
}
test('Linux task timestamps share a snapshot and retain millisecond log names across midnight', (t) => {
const r = run(
t,
`handle_log_path 'folder/example.js'
init_begin_time
[[ "$log_path" == 'folder_example_42/2026-09-12-23-59-59-999.log' ]]
[[ "$time" == '2026-09-12 23:59:59.999' && "$begin_time" == '2026-09-12 23:59:59' && "$begin_timestamp" == 100 ]]
handle_task_end`,
);
assert.equal(r.text('dates').trim().split('\n').length, 3);
assert.match(r.text('stats'), /^42 0 1\n$/);
assert.match(r.text('messages'), /2026-09-13 00:00:00/);
});
test('explicit log paths and output mode remain intact', (t) => {
run(
t,
`real_log_path='custom/output.log'; real_time=true
handle_log_path 'folder/example.js'
[[ "$log_path" == 'custom/output.log' && "$cmd" == '' ]]`,
);
});
test('task failure keeps exit code and minimum one-second runtime', (t) => {
const r = run(
t,
`begin_timestamp=101; _task_exit_code=7; log_path=test.log
handle_task_end`,
);
assert.equal(r.text('stats'), '42 7 1\n');
assert.match(r.text('messages'), /失败/);
assert.match(r.text('status'), /101 1 7/);
});
test('manual stop retains stopped message and exit status reporting', (t) => {
const r = run(
t,
`begin_timestamp=99; _task_exit_code=1; log_path=test.log; MANUAL=true
handle_task_end`,
);
assert.equal(r.text('stats'), '42 1 2\n');
assert.match(r.text('messages'), /已停止/);
});
for (const mode of ['macos', 'custom'])
test(`${mode} keeps legacy time conversion helpers`, (t) => {
run(
t,
`
${mode === 'macos' ? 'is_macos=1' : 'mtime_format=custom; time_format=custom'}
format_log_time() { echo legacy-log; }
format_time() { echo legacy-time; }
format_timestamp() { echo 100; }
handle_log_path example.js
init_begin_time
[[ "$log_time" == legacy-log && "$begin_time" == legacy-time && "$begin_timestamp" == 100 ]]
handle_task_end
`,
);
});
+84
View File
@@ -0,0 +1,84 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const source = fs.readFileSync('back/app.ts', 'utf8');
const entry = source.indexOf('\nconst app = new Application();');
assert.ok(entry > 0, 'application entry point exists');
const compiled = ts.transpileModule(
source.slice(0, entry) + '\nmodule.exports = Application;',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
},
).outputText;
function create(env) {
const calls = [];
const cluster = {
fork(options) {
const worker = {
id: calls.length + 1,
process: { pid: 100 + calls.length },
};
calls.push({ options, inherited: { ...env, ...options }, worker });
return worker;
},
};
const module = { exports: {} };
const sandbox = {
module,
exports: module.exports,
process: { env },
require(name) {
if (name === 'cluster') return cluster;
if (name === 'express') return () => ({ use() {} });
return {};
},
};
vm.runInNewContext(compiled, sandbox);
return { app: new module.exports(), calls };
}
test('PM2-managed primary disables only inherited worker APM and keeps worker metadata', () => {
const env = { pm_id: '0', pmx: 'true', CUSTOM_VALUE: 'kept' };
const { app, calls } = create(env);
for (const role of ['grpc', 'http']) app.forkWorker(role);
assert.deepEqual(
calls.map((x) => ({ ...x.options })),
[
{ SERVICE_TYPE: 'grpc', pmx: 'false' },
{ SERVICE_TYPE: 'http', pmx: 'false' },
],
);
assert.equal(env.pmx, 'true');
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
for (const call of calls) {
const entry = app.workerMetadataMap.get(call.worker.id);
assert.equal(entry.pid, call.worker.process.pid);
assert.equal(entry.serviceType, call.options.SERVICE_TYPE);
}
});
test('worker APM opt-in restores inherited PM2 settings, including explicit disable', () => {
for (const pmx of ['true', 'false']) {
const { app, calls } = create({ pm_id: '0', pmx, QL_WORKER_APM: 'true' });
app.forkWorker('http');
assert.equal(Object.hasOwn(calls[0].options, 'pmx'), false);
assert.equal(calls[0].inherited.pmx, pmx);
}
});
test('standalone startup does not introduce a PM2-specific override', () => {
const { app, calls } = create({ CUSTOM_VALUE: 'kept' });
app.forkWorker('grpc');
assert.deepEqual({ ...calls[0].options }, { SERVICE_TYPE: 'grpc' });
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
});
test('replacement workers receive the same monitoring policy', () => {
const { app, calls } = create({ pm_id: '0', pmx: 'true' });
app.forkWorker('grpc');
app.forkWorker('grpc');
assert.equal(calls[1].inherited.pmx, 'false');
assert.notEqual(calls[0].worker.id, calls[1].worker.id);
});