@@ -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 ( / \. t m p $ / ) ) ;
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 ( / \. t m p $ / ) ,
421+ policyFile ,
422+ ) ;
423+ expect ( fs . unlink ) . toHaveBeenCalledWith ( expect . stringMatching ( / \. t m p $ / ) ) ;
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