fix(stage-tamagotchi): concurrent apply and auto-heal will have conflicts

This commit is contained in:
Neko Ayaka
2026-04-18 19:26:22 +08:00
parent 2ab21879dd
commit d2d5493136
2 changed files with 118 additions and 1 deletions
@@ -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<string>()
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<void>((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()
})
})
@@ -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<TSchema extends PersistedSchema>(
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)
}