diff --git a/src/server/__tests__/scheduled-tasks.test.ts b/src/server/__tests__/scheduled-tasks.test.ts index ac6daab5..8b031981 100644 --- a/src/server/__tests__/scheduled-tasks.test.ts +++ b/src/server/__tests__/scheduled-tasks.test.ts @@ -2,7 +2,7 @@ * Unit tests for CronService, SearchService, and Scheduled Tasks API */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' @@ -139,6 +139,41 @@ describe('CronService', () => { service.createTask({ cron: '* * * * *', prompt: '' }), ).rejects.toThrow() }) + + it('should retry the atomic write when rename returns ENOENT', async () => { + const originalRename = fs.rename + let renameCalls = 0 + + const renameSpy = spyOn(fs, 'rename') + renameSpy.mockImplementation(async (...args) => { + renameCalls += 1 + + if (renameCalls === 1) { + const error = new Error( + 'ENOENT: no such file or directory, rename tmp -> scheduled_tasks.json', + ) as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + } + + return originalRename(...args) + }) + + try { + const task = await service.createTask({ + cron: '0 9 * * *', + prompt: 'Retry rename once', + }) + + const tasks = await service.listTasks() + expect(task.id).toBeDefined() + expect(tasks).toHaveLength(1) + expect(tasks[0]?.prompt).toBe('Retry rename once') + expect(renameCalls).toBe(2) + } finally { + renameSpy.mockRestore() + } + }) }) // ─── SearchService tests ──────────────────────────────────────────────────── diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts index 09204409..b9fa8871 100644 --- a/src/server/services/cronService.ts +++ b/src/server/services/cronService.ts @@ -32,6 +32,8 @@ type TasksFile = { tasks: CronTask[] } +const TASKS_FILE_WRITE_ATTEMPTS = 2 + export class CronService { /** 任务文件路径 */ private getTasksFilePath(): string { @@ -134,21 +136,32 @@ export class CronService { private async writeTasksFile(data: TasksFile): Promise { const filePath = this.getTasksFilePath() const dir = path.dirname(filePath) - await fs.mkdir(dir, { recursive: true }) + const contents = JSON.stringify(data, null, 2) + '\n' + let lastError: Error | undefined - const tmpFile = `${filePath}.tmp.${Date.now()}` - try { - await fs.writeFile( - tmpFile, - JSON.stringify(data, null, 2) + '\n', - 'utf-8', - ) - await fs.rename(tmpFile, filePath) - } catch (err) { - await fs.unlink(tmpFile).catch(() => {}) - throw ApiError.internal( - `Failed to write scheduled tasks: ${(err as Error).message}`, - ) + for (let attempt = 0; attempt < TASKS_FILE_WRITE_ATTEMPTS; attempt++) { + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}` + + try { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(tmpFile, contents, 'utf-8') + await fs.rename(tmpFile, filePath) + return + } catch (err) { + lastError = err as Error + await fs.unlink(tmpFile).catch(() => {}) + + if ( + (err as NodeJS.ErrnoException).code !== 'ENOENT' || + attempt === TASKS_FILE_WRITE_ATTEMPTS - 1 + ) { + break + } + } } + + throw ApiError.internal( + `Failed to write scheduled tasks: ${lastError?.message ?? 'unknown error'}`, + ) } }