@@ -274,7 +274,7 @@ func TestMapToGuest_GuestMountFails_RetryMapToGuest_Fails(t *testing.T) {
274274
275275 // Forward retry: MapToGuest again with the same reservation.
276276 // AddToVM is idempotent (share already in StateAdded), but MountToGuest
277- // fails because the mount is in the terminal StateUnmounted .
277+ // fails because the mount is in StateInvalid .
278278 _ , err = tc .c .MapToGuest (tc .ctx , id )
279279 if err == nil {
280280 t .Fatal ("expected error on retry MapToGuest after terminal mount failure" )
@@ -525,3 +525,134 @@ func TestFullLifecycle_ReuseAfterRelease(t *testing.T) {
525525 t .Error ("expected a new reservation ID after re-reserving a released path" )
526526 }
527527}
528+
529+ // TestUnmapFromGuest_AddToVMFails_MultipleReservations_AllDrain verifies that
530+ // when two callers reserve the same host path and AddToVM fails, the share
531+ // stays in the controller's map (in StateInvalid) until both callers call
532+ // UnmapFromGuest to drain their mount reservations. Only after the last ref
533+ // is drained does the share transition to StateRemoved and get cleaned up.
534+ func TestUnmapFromGuest_AddToVMFails_MultipleReservations_AllDrain (t * testing.T ) {
535+ t .Parallel ()
536+ tc := newTestController (t , false )
537+
538+ // Two callers reserve the same host path.
539+ id1 , _ , _ := tc .c .Reserve (tc .ctx , share.Config {HostPath : "/host/path" }, mount.Config {})
540+ id2 , _ , _ := tc .c .Reserve (tc .ctx , share.Config {HostPath : "/host/path" }, mount.Config {})
541+
542+ // First caller attempts MapToGuest — AddToVM fails.
543+ tc .vmAdd .EXPECT ().AddPlan9 (gomock .Any (), gomock .Any ()).Return (errVMAdd )
544+ _ , err := tc .c .MapToGuest (tc .ctx , id1 )
545+ if err == nil {
546+ t .Fatal ("expected error when VM add fails" )
547+ }
548+
549+ // First caller's UnmapFromGuest: decrements mount ref but share stays
550+ // (mount ref > 0 → share stays in StateInvalid).
551+ if err := tc .c .UnmapFromGuest (tc .ctx , id1 ); err != nil {
552+ t .Fatalf ("first UnmapFromGuest: %v" , err )
553+ }
554+
555+ // Share must still be in the map — second caller hasn't drained yet.
556+ if len (tc .c .sharesByHostPath ) != 1 {
557+ t .Errorf ("expected share to remain in map after first unmap, got %d shares" , len (tc .c .sharesByHostPath ))
558+ }
559+ if len (tc .c .reservations ) != 1 {
560+ t .Errorf ("expected 1 remaining reservation, got %d" , len (tc .c .reservations ))
561+ }
562+
563+ // Second caller's UnmapFromGuest: drains last mount ref → share transitions
564+ // to StateRemoved → controller cleans up the share entry.
565+ if err := tc .c .UnmapFromGuest (tc .ctx , id2 ); err != nil {
566+ t .Fatalf ("second UnmapFromGuest: %v" , err )
567+ }
568+
569+ // Everything should be cleaned up.
570+ if len (tc .c .reservations ) != 0 {
571+ t .Errorf ("expected 0 reservations after all unmaps, got %d" , len (tc .c .reservations ))
572+ }
573+ if len (tc .c .sharesByHostPath ) != 0 {
574+ t .Errorf ("expected 0 shares after all unmaps, got %d" , len (tc .c .sharesByHostPath ))
575+ }
576+ }
577+
578+ // TestUnmapFromGuest_GuestMountFails_MultipleReservations_AllDrain verifies
579+ // that when two callers reserve the same host path and MapToGuest fails at the
580+ // guest mount stage (AddToVM succeeds, AddLCOWMappedDirectory fails), the share
581+ // and mount stay in the controller's maps until all callers have called
582+ // UnmapFromGuest to drain their mount reservations.
583+ func TestUnmapFromGuest_GuestMountFails_MultipleReservations_AllDrain (t * testing.T ) {
584+ t .Parallel ()
585+ tc := newTestController (t , false )
586+
587+ // Two callers reserve the same host path.
588+ id1 , _ , _ := tc .c .Reserve (tc .ctx , share.Config {HostPath : "/host/path" }, mount.Config {})
589+ id2 , _ , _ := tc .c .Reserve (tc .ctx , share.Config {HostPath : "/host/path" }, mount.Config {})
590+
591+ // First caller attempts MapToGuest — AddToVM succeeds, guest mount fails.
592+ tc .vmAdd .EXPECT ().AddPlan9 (gomock .Any (), gomock .Any ()).Return (nil )
593+ tc .guestMount .EXPECT ().AddLCOWMappedDirectory (gomock .Any (), gomock .Any ()).Return (errMount )
594+ _ , err := tc .c .MapToGuest (tc .ctx , id1 )
595+ if err == nil {
596+ t .Fatal ("expected error when guest mount fails" )
597+ }
598+
599+ // First caller's UnmapFromGuest: decrements mount ref but share stays
600+ // because second caller still holds a reservation.
601+ // VM remove should NOT be called yet — second caller hasn't drained.
602+ tc .vmRemove .EXPECT ().RemovePlan9 (gomock .Any (), gomock .Any ()).Return (nil ).MaxTimes (0 )
603+ if err := tc .c .UnmapFromGuest (tc .ctx , id1 ); err != nil {
604+ t .Fatalf ("first UnmapFromGuest: %v" , err )
605+ }
606+
607+ // Share must still be in the map — second caller hasn't drained yet.
608+ if len (tc .c .sharesByHostPath ) != 1 {
609+ t .Errorf ("expected share to remain in map after first unmap, got %d shares" , len (tc .c .sharesByHostPath ))
610+ }
611+ if len (tc .c .reservations ) != 1 {
612+ t .Errorf ("expected 1 remaining reservation, got %d" , len (tc .c .reservations ))
613+ }
614+
615+ // Second caller's UnmapFromGuest: drains last mount ref → share transitions
616+ // to StateRemoved → controller cleans up the share entry.
617+ tc .vmRemove .EXPECT ().RemovePlan9 (gomock .Any (), gomock .Any ()).Return (nil )
618+ if err := tc .c .UnmapFromGuest (tc .ctx , id2 ); err != nil {
619+ t .Fatalf ("second UnmapFromGuest: %v" , err )
620+ }
621+
622+ // Everything should be cleaned up.
623+ if len (tc .c .reservations ) != 0 {
624+ t .Errorf ("expected 0 reservations after all unmaps, got %d" , len (tc .c .reservations ))
625+ }
626+ if len (tc .c .sharesByHostPath ) != 0 {
627+ t .Errorf ("expected 0 shares after all unmaps, got %d" , len (tc .c .sharesByHostPath ))
628+ }
629+ }
630+
631+ // TestUnmapFromGuest_AddToVMFails_SingleReservation_Drains verifies that when
632+ // a single caller reserves a host path and AddToVM fails, UnmapFromGuest
633+ // correctly drains the mount and transitions the share to StateRemoved.
634+ func TestUnmapFromGuest_AddToVMFails_SingleReservation_Drains (t * testing.T ) {
635+ t .Parallel ()
636+ tc := newTestController (t , false )
637+
638+ id , _ , _ := tc .c .Reserve (tc .ctx , share.Config {HostPath : "/host/path" }, mount.Config {})
639+
640+ // MapToGuest fails at AddToVM.
641+ tc .vmAdd .EXPECT ().AddPlan9 (gomock .Any (), gomock .Any ()).Return (errVMAdd )
642+ _ , err := tc .c .MapToGuest (tc .ctx , id )
643+ if err == nil {
644+ t .Fatal ("expected error when VM add fails" )
645+ }
646+
647+ // UnmapFromGuest drains the mount and removes the share.
648+ if err := tc .c .UnmapFromGuest (tc .ctx , id ); err != nil {
649+ t .Fatalf ("UnmapFromGuest: %v" , err )
650+ }
651+
652+ if len (tc .c .reservations ) != 0 {
653+ t .Errorf ("expected 0 reservations, got %d" , len (tc .c .reservations ))
654+ }
655+ if len (tc .c .sharesByHostPath ) != 0 {
656+ t .Errorf ("expected 0 shares, got %d" , len (tc .c .sharesByHostPath ))
657+ }
658+ }
0 commit comments