From d2d5493136af4614798546c674d5707618429ca6 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sat, 18 Apr 2026 19:19:24 +0800 Subject: [PATCH] fix(stage-tamagotchi): concurrent apply and auto-heal will have conflicts --- .../main/libs/electron/persistence.test.ts | 116 ++++++++++++++++++ .../src/main/libs/electron/persistence.ts | 3 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts new file mode 100644 index 000000000..2115de1ff --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/electron/persistence.test.ts @@ -0,0 +1,116 @@ +import { number, object } from 'valibot' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * @example + * describe('createConfig', () => { + * it('persists configuration data', async () => { + * // assertions + * }) + * }) + */ +describe('createConfig', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + /** + * @example + * it('uses a unique temp file per save to avoid concurrent rename collisions', async () => { + * await vi.waitFor(() => { + * expect(renameMock).toHaveBeenCalledTimes(2) + * }) + * }) + * + * Failed to save config Error: ENOENT: no such file or directory, rename '/path/to/the/electron/app/data/app-config.json.tmp' -> '/path/to/the/electron/app/data/app-config.json' + * at async rename (node:internal/fs/promises:785:10) + * at async file://./airi/apps/stage-tamagotchi/out/main/index.js:3327:4 { + * errno: -2, + * code: 'ENOENT', + * syscall: 'rename', + * path: '/path/to/the/electron/app/data/app-config.json.tmp', + * dest: '/path/to/the/electron/app/data/app-config.json' + * } + * + * ROOT CAUSE: + * + * If concurrent save calls share one temporary file path, one rename removes the file first. + * This causes a second rename attempt to fail with ENOENT, and the save path logs an error. + * + * We fixed this by asserting each save operation writes and renames a distinct temp file path. + */ + it('uses a unique temp file per save to avoid concurrent rename collisions', async () => { + const appMock = { + getPath: vi.fn(() => '/tmp/airi-user-data'), + } + const mkdirMock = vi.fn(async () => {}) + const existingTempFiles = new Set() + const renameMock = vi.fn(async (from: string) => { + if (!existingTempFiles.has(from)) { + const error = new Error(`ENOENT: no such file or directory, rename '${from}'`) as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + } + existingTempFiles.delete(from) + }) + const writeCoordinator = { + calls: 0, + waitFor: Promise.resolve(), + release: () => {}, + } + const writeFileMock = vi.fn(async (path: string) => { + existingTempFiles.add(path) + writeCoordinator.calls += 1 + if (writeCoordinator.calls === 2) { + writeCoordinator.release() + } + await writeCoordinator.waitFor + }) + + writeCoordinator.waitFor = new Promise((resolve) => { + writeCoordinator.release = resolve + }) + + vi.doMock('electron', () => ({ + app: appMock, + })) + vi.doMock('es-toolkit', () => ({ + throttle: (handler: (...args: unknown[]) => unknown) => handler, + })) + vi.doMock('node:fs', () => ({ + existsSync: () => false, + readFileSync: () => '', + })) + vi.doMock('node:fs/promises', () => ({ + copyFile: vi.fn(async () => {}), + mkdir: mkdirMock, + rename: renameMock, + writeFile: writeFileMock, + })) + + const { createConfig } = await import('./persistence') + const schema = object({ value: number() }) + const config = createConfig('windows-widgets', 'config.json', schema, { default: { value: 0 } }) + const saveErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + config.setup() + config.update({ value: 1 }) + config.update({ value: 2 }) + + /** + * @example + * expect(renameMock).toHaveBeenCalledTimes(2) + * expect(saveErrorSpy).not.toHaveBeenCalledWith('Failed to save config', expect.anything()) + * expect(new Set(renameMock.mock.calls.map(([from]) => from)).size).toBe(2) + */ + await vi.waitFor(() => { + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + expect(saveErrorSpy).not.toHaveBeenCalledWith('Failed to save config', expect.anything()) + expect(new Set(renameMock.mock.calls.map(([from]) => from)).size).toBe(2) + saveErrorSpy.mockRestore() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts index 5aa8229cf..7837f18c9 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts @@ -1,5 +1,6 @@ import type { BaseIssue, BaseSchema, InferIssue, InferOutput } from 'valibot' +import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' @@ -80,7 +81,7 @@ export function createConfig( try { const path = configPath() await ensureConfigDirectory(path) - const tmpPath = `${path}.tmp` + const tmpPath = `${path}.${randomUUID()}.tmp` await writeFile(tmpPath, JSON.stringify(persistenceMap.get(key))) await rename(tmpPath, path) }