Files
lindyson-ai/apps/api/tests/workerLifecycle.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

204 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest';
import {
createTrackedSingleFlightTask,
createWorkerLifecycleCoordinator,
registerSafeWorkerErrorListener,
WorkerStartupStopRequestedError,
} from '../src/worker/workerLifecycle.js';
describe('worker lifecycle helpers', () => {
it('single-flight 复用当前 Promise 并保留失败状态', async () => {
let resolveTask: (() => void) | undefined;
const task = vi.fn(() => new Promise<void>(resolve => {
resolveTask = resolve;
}));
const onFailure = vi.fn();
const tracked = createTrackedSingleFlightTask(task, onFailure);
const first = tracked.run();
const second = tracked.run();
expect(first).toBe(second);
await Promise.resolve();
expect(task).toHaveBeenCalledTimes(1);
resolveTask?.();
await expect(first).resolves.toBe(false);
await expect(tracked.waitForCurrent()).resolves.toBe(false);
});
it('single-flight 失败只归属于当次 run,settled 后不会污染未来关闭', async () => {
let shouldFail = true;
const onFailure = vi.fn();
const tracked = createTrackedSingleFlightTask(async () => {
if (shouldFail) throw new Error('maintenance-secret');
}, onFailure);
await expect(tracked.run()).resolves.toBe(true);
expect(onFailure).toHaveBeenCalledTimes(1);
await expect(tracked.waitForCurrent()).resolves.toBe(false);
shouldFail = false;
await expect(tracked.run()).resolves.toBe(false);
await expect(tracked.waitForCurrent()).resolves.toBe(false);
});
it('安全 error listener 不记录动态 message 或 stack', () => {
let listener: ((error: unknown) => void) | undefined;
const worker = {
on: vi.fn((_event: 'error', handler: (error: unknown) => void) => {
listener = handler;
}),
};
const log = vi.fn();
registerSafeWorkerErrorListener(worker, 'generation-worker-error', log);
listener?.(new Error('dynamic-secret-must-not-leak'));
expect(worker.on).toHaveBeenCalledWith('error', expect.any(Function));
expect(log).toHaveBeenCalledWith(JSON.stringify({
event: 'generation-worker-error',
errorCode: 'BULLMQ_WORKER_ERROR',
}));
expect(log.mock.calls.flat().join('\n')).not.toContain('dynamic-secret-must-not-leak');
expect(log.mock.calls.flat().join('\n')).not.toContain('Error:');
});
it('early signal 等 acquisition 落地登记后逆序清理且不互等', async () => {
const lifecycle = createWorkerLifecycleCoordinator();
const events: string[] = [];
const acquireAfterSignal = vi.fn(() => 'resource-b');
let resolveAcquire: ((value: string) => void) | undefined;
let startupError: unknown;
const startup = (async () => {
try {
await lifecycle.acquire(
() => new Promise<string>(resolve => {
resolveAcquire = resolve;
}),
async resource => {
events.push(`close:${resource}`);
},
);
lifecycle.acquireSync(acquireAfterSignal, async resource => {
events.push(`close:${resource}`);
});
} catch (error) {
startupError = error;
lifecycle.finishStartup();
await lifecycle.requestSignalShutdown();
}
})();
await Promise.resolve();
const shutdown = lifecycle.requestSignalShutdown();
resolveAcquire?.('resource-a');
await startup;
await expect(shutdown).resolves.toBeUndefined();
expect(startupError).toBeInstanceOf(WorkerStartupStopRequestedError);
expect(acquireAfterSignal).not.toHaveBeenCalled();
expect(events).toEqual(['close:resource-a']);
});
it('startup 失败逆序尽力清理全部资源并固定 reject', async () => {
const lifecycle = createWorkerLifecycleCoordinator();
const events: string[] = [];
lifecycle.acquireSync(() => 'a', async value => {
events.push(`close:${value}`);
throw new Error('cleanup-secret');
});
lifecycle.acquireSync(() => 'b', async value => {
events.push(`close:${value}`);
});
lifecycle.registerStopScheduling(() => {
events.push('stop:timer');
});
await expect(lifecycle.abortStartup()).rejects.toMatchObject({
code: 'WORKER_LIFECYCLE_SHUTDOWN_FAILED',
});
expect(events).toEqual(['stop:timer', 'close:b', 'close:a']);
});
it('首个 admission stop pending 时仍同步触发后续 stop,全部完成后才 cleanup', async () => {
const lifecycle = createWorkerLifecycleCoordinator();
const events: string[] = [];
let resolveFirstStop: (() => void) | undefined;
lifecycle.registerCleanup(() => {
events.push('cleanup:queue');
});
lifecycle.registerStopScheduling(() => {
events.push('stop:later-registered-last-called');
});
lifecycle.registerStopScheduling(() => {
events.push('stop:first-deferred');
return new Promise<void>(resolve => {
resolveFirstStop = resolve;
});
});
lifecycle.finishStartup();
const shutdown = lifecycle.requestSignalShutdown();
expect(events).toEqual([
'stop:first-deferred',
'stop:later-registered-last-called',
]);
resolveFirstStop?.();
await shutdown;
expect(events).toEqual([
'stop:first-deferred',
'stop:later-registered-last-called',
'cleanup:queue',
]);
});
it('signal 请求后新登记的 stop 会同步执行且全程只执行一次', async () => {
const lifecycle = createWorkerLifecycleCoordinator();
const stop = vi.fn();
const shutdown = lifecycle.requestSignalShutdown();
lifecycle.registerStopScheduling(stop);
expect(stop).toHaveBeenCalledTimes(1);
lifecycle.finishStartup();
await shutdown;
lifecycle.requestSignalShutdown();
expect(stop).toHaveBeenCalledTimes(1);
});
it('stop gate 在所有 Worker/timer stopper 之前同步执行', async () => {
const lifecycle = createWorkerLifecycleCoordinator();
const events: string[] = [];
lifecycle.registerStopScheduling(() => {
events.push('stop:worker');
});
lifecycle.registerStopGate(() => {
events.push('gate:maintenance');
});
lifecycle.finishStartup();
const shutdown = lifecycle.requestSignalShutdown();
expect(events).toEqual(['gate:maintenance', 'stop:worker']);
await shutdown;
});
it('无 signal 的 startup rollback cleanup 永久 pending 时由 55 秒 deadline 固定 reject', async () => {
vi.useFakeTimers();
try {
const lifecycle = createWorkerLifecycleCoordinator();
const pendingCleanup = vi.fn(() => new Promise<void>(() => undefined));
lifecycle.registerCleanup(pendingCleanup);
const rollback = lifecycle.abortStartup();
const rejection = expect(rollback).rejects.toMatchObject({
code: 'WORKER_STARTUP_ROLLBACK_TIMEOUT',
});
await vi.advanceTimersByTimeAsync(54_999);
expect(pendingCleanup).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await rejection;
} finally {
vi.useRealTimers();
}
});
});