Skip to content

Commit 3da98c3

Browse files
committed
fix(policy): add EBUSY fallback and TOML parse recovery (#19919)
1 parent 919a95b commit 3da98c3

2 files changed

Lines changed: 105 additions & 17 deletions

File tree

packages/core/src/policy/config.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -639,9 +639,18 @@ export function createPolicyUpdater(
639639
} catch (error) {
640640
if (isNodeError(error) && error.code === 'ENOENT') {
641641
// File doesn't exist yet, start fresh
642+
} else if (!isNodeError(error)) {
643+
// TOML parse error — back up corrupted file and recover
644+
coreEvents.emitFeedback(
645+
'warning',
646+
`Syntax error found in policy file. Backing up corrupted file to ${policyFile}.bak and starting fresh.`,
647+
);
648+
await fs
649+
.copyFile(policyFile, `${policyFile}.bak`)
650+
.catch(() => {});
651+
existingData = {};
642652
} else {
643-
// Non-ENOENT read errors (e.g. EACCES) should abort persistence
644-
// to avoid silently overwriting the existing file with empty data
653+
// Real filesystem error (e.g. EACCES) — throw to prevent silent failure
645654
throw error;
646655
}
647656
}
@@ -704,7 +713,10 @@ export function createPolicyUpdater(
704713
} catch (renameError) {
705714
// Cross-device rename fails with EXDEV on some Linux mount configurations.
706715
// Fall back to copy + unlink which works across filesystems.
707-
if (isNodeError(renameError) && renameError.code === 'EXDEV') {
716+
if (
717+
isNodeError(renameError) &&
718+
(renameError.code === 'EXDEV' || renameError.code === 'EBUSY')
719+
) {
708720
await fs.copyFile(tmpFile, policyFile);
709721
await fs.unlink(tmpFile).catch(() => {});
710722
} else {

packages/core/src/policy/persistence.test.ts

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ decision = "deny"
241241
workspacePoliciesDir,
242242
);
243243
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
244-
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
244+
vi.spyOn(fs, 'mkdir').mockRejectedValue(new Error('Permission denied'));
245245

246246
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
247247

@@ -272,19 +272,19 @@ decision = "deny"
272272
workspacePoliciesDir,
273273
);
274274
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
275-
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
276-
vi.mocked(fs.readFile).mockRejectedValue(
275+
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
276+
vi.spyOn(fs, 'readFile').mockRejectedValue(
277277
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
278278
);
279279

280280
const mockFileHandle = {
281281
writeFile: vi.fn().mockRejectedValue(new Error('Disk full')),
282282
close: vi.fn().mockResolvedValue(undefined),
283283
};
284-
vi.mocked(fs.open).mockResolvedValue(
284+
vi.spyOn(fs, 'open').mockResolvedValue(
285285
mockFileHandle as unknown as fs.FileHandle,
286286
);
287-
vi.mocked(fs.unlink).mockResolvedValue(undefined);
287+
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
288288

289289
await messageBus.publish({
290290
type: MessageBusType.UPDATE_POLICY,
@@ -309,10 +309,11 @@ decision = "deny"
309309
workspacePoliciesDir,
310310
);
311311
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
312-
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
313-
vi.mocked(fs.readFile).mockRejectedValue(
312+
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
313+
vi.spyOn(fs, 'readFile').mockRejectedValue(
314314
makeNodeError('Permission denied', 'EACCES'),
315315
);
316+
vi.spyOn(fs, 'open');
316317

317318
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
318319

@@ -344,24 +345,24 @@ decision = "deny"
344345
workspacePoliciesDir,
345346
);
346347
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
347-
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
348-
vi.mocked(fs.readFile).mockRejectedValue(
348+
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
349+
vi.spyOn(fs, 'readFile').mockRejectedValue(
349350
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
350351
);
351352

352353
const mockFileHandle = {
353354
writeFile: vi.fn().mockResolvedValue(undefined),
354355
close: vi.fn().mockResolvedValue(undefined),
355356
};
356-
vi.mocked(fs.open).mockResolvedValue(
357+
vi.spyOn(fs, 'open').mockResolvedValue(
357358
mockFileHandle as unknown as fs.FileHandle,
358359
);
359360
// Simulate cross-device link error
360-
vi.mocked(fs.rename).mockRejectedValue(
361+
vi.spyOn(fs, 'rename').mockRejectedValue(
361362
makeNodeError('EXDEV: cross-device link not permitted', 'EXDEV'),
362363
);
363-
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
364-
vi.mocked(fs.unlink).mockResolvedValue(undefined);
364+
vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined);
365+
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
365366

366367
await messageBus.publish({
367368
type: MessageBusType.UPDATE_POLICY,
@@ -377,4 +378,79 @@ decision = "deny"
377378
expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/));
378379
});
379380
});
380-
});
381+
382+
it('should fall back to copy+unlink when rename fails with EBUSY', async () => {
383+
createPolicyUpdater(policyEngine, messageBus, mockStorage);
384+
385+
const workspacePoliciesDir = '/mock/project/.gemini/policies';
386+
const policyFile = path.join(
387+
workspacePoliciesDir,
388+
AUTO_SAVED_POLICY_FILENAME,
389+
);
390+
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
391+
workspacePoliciesDir,
392+
);
393+
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
394+
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
395+
vi.spyOn(fs, 'readFile').mockRejectedValue(
396+
makeNodeError('ENOENT: no such file or directory', 'ENOENT'),
397+
);
398+
399+
const mockFileHandle = {
400+
writeFile: vi.fn().mockResolvedValue(undefined),
401+
close: vi.fn().mockResolvedValue(undefined),
402+
};
403+
vi.spyOn(fs, 'open').mockResolvedValue(
404+
mockFileHandle as unknown as fs.FileHandle,
405+
);
406+
vi.spyOn(fs, 'rename').mockRejectedValue(
407+
makeNodeError('EBUSY: resource busy or locked', 'EBUSY'),
408+
);
409+
vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined);
410+
vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
411+
412+
await messageBus.publish({
413+
type: MessageBusType.UPDATE_POLICY,
414+
toolName: 'test_tool',
415+
persist: true,
416+
});
417+
418+
await vi.waitFor(() => {
419+
expect(fs.copyFile).toHaveBeenCalledWith(
420+
expect.stringMatching(/\.tmp$/),
421+
policyFile,
422+
);
423+
expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/));
424+
});
425+
});
426+
427+
it('should back up corrupted TOML file and recover', async () => {
428+
createPolicyUpdater(policyEngine, messageBus, mockStorage);
429+
430+
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
431+
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
432+
433+
const dir = path.dirname(policyFile);
434+
memfs.mkdirSync(dir, { recursive: true });
435+
memfs.writeFileSync(policyFile, 'this is not valid toml ][[[');
436+
437+
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
438+
439+
await messageBus.publish({
440+
type: MessageBusType.UPDATE_POLICY,
441+
toolName: 'test_tool',
442+
persist: true,
443+
});
444+
445+
await vi.advanceTimersByTimeAsync(100);
446+
447+
expect(feedbackSpy).toHaveBeenCalledWith(
448+
'warning',
449+
expect.stringContaining('.bak'),
450+
);
451+
452+
expect(memfs.existsSync(policyFile)).toBe(true);
453+
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
454+
expect(content).toContain('toolName = "test_tool"');
455+
});
456+
});

0 commit comments

Comments
 (0)