Skip to content

Commit e289573

Browse files
committed
refactor share state management to introduce StateInvalid for failed attempts
Signed-off-by: Harsh Rawat <harshrawat@microsoft.com>
1 parent 9bd31b7 commit e289573

10 files changed

Lines changed: 315 additions & 65 deletions

File tree

internal/controller/device/plan9/controller.go

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
//
2424
// 1. Obtain a reservation using Reserve().
2525
//
26-
// 2. Use the reservation in MapToGuest() to mount the share unto guest.
26+
// 2. Use the reservation in MapToGuest() to mount the share into the guest.
2727
//
2828
// 3. Call UnmapFromGuest() to release the reservation and all resources.
2929
//
@@ -171,8 +171,8 @@ func (c *Controller) MapToGuest(ctx context.Context, id guid.GUID) (string, erro
171171

172172
// Validate if the host path has an associated share.
173173
// This should be reserved by the Reserve() call.
174-
existingShare := c.sharesByHostPath[res.hostPath]
175-
if existingShare == nil {
174+
existingShare, ok := c.sharesByHostPath[res.hostPath]
175+
if !ok {
176176
return "", fmt.Errorf("share for host path %s not found", res.hostPath)
177177
}
178178

@@ -210,14 +210,9 @@ func (c *Controller) UnmapFromGuest(ctx context.Context, id guid.GUID) error {
210210

211211
// Validate that the share exists before proceeding with teardown.
212212
// This should be reserved by the Reserve() call.
213-
existingShare := c.sharesByHostPath[res.hostPath]
214-
if existingShare == nil {
215-
// Share is gone — a sibling reservation already cleaned it up.
216-
// Example: A and B reserve the same path; A's AddToVM fails
217-
// (share→StateRemoved) and A's UnmapFromGuest deletes the map entry.
218-
// B has nothing left to clean up, so just drop the reservation.
219-
delete(c.reservations, id)
220-
return nil
213+
existingShare, ok := c.sharesByHostPath[res.hostPath]
214+
if !ok {
215+
return fmt.Errorf("share for host path %s not found", res.hostPath)
221216
}
222217

223218
log.G(ctx).WithField(logfields.HostPath, existingShare.HostPath()).Debug("unmapping Plan9 share from guest")

internal/controller/device/plan9/controller_test.go

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
}

internal/controller/device/plan9/mount/doc.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@
1616
// # Mount Lifecycle
1717
//
1818
// ┌──────────────────────┐
19-
// │ StateReserved │ ← mount failure → StateUnmounted (not retriable)
20-
// └──────────┬───────────┘
21-
// │ guest mount succeeds
22-
// ▼
23-
// ┌──────────────────────┐
24-
// │ StateMounted │
25-
// └──────────┬───────────┘
26-
// │ reference count → 0;
27-
// │ guest unmount succeeds
19+
// │ StateReserved │ ← mount failure → StateInvalid
20+
// └──────────┬───────────┘
21+
// │ guest mount succeeds │ all refs drained
22+
// ▼
23+
// ┌──────────────────────┐ ┌──────────────────────┐
24+
// │ StateMounted │ │ StateUnmounted │
25+
// └──────────┬───────────┘ └──────────────────────┘
26+
// │ reference count → 0; (terminal — entry
27+
// │ guest unmount succeeds removed from share)
2828
// ▼
2929
// ┌──────────────────────┐
3030
// │ StateUnmounted │

internal/controller/device/plan9/mount/mount.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ func (m *Mount) MountToGuest(ctx context.Context, guest LinuxGuestPlan9Mounter)
100100
Port: vmutils.Plan9Port,
101101
ReadOnly: m.config.ReadOnly,
102102
}); err != nil {
103-
// Move to unmounted since no guest state was established from Reserved.
104-
m.state = StateUnmounted
103+
// Move to invalid since no guest state was established from Reserved,
104+
// but outstanding reservations may still need to be drained.
105+
m.state = StateInvalid
105106
return "", fmt.Errorf("add LCOW mapped directory share=%s: %w", m.shareName, err)
106107
}
107108

@@ -117,10 +118,9 @@ func (m *Mount) MountToGuest(ctx context.Context, guest LinuxGuestPlan9Mounter)
117118
// existing guest path directly.
118119
return m.guestPath, nil
119120

120-
case StateUnmounted:
121+
default:
121122
return "", fmt.Errorf("cannot mount a share in state %s", m.state)
122123
}
123-
return "", nil
124124
}
125125

126126
// UnmountFromGuest decrements the reference count and, when it reaches zero,
@@ -160,10 +160,18 @@ func (m *Mount) UnmountFromGuest(ctx context.Context, guest LinuxGuestPlan9Unmou
160160
m.refCount--
161161
return nil
162162

163+
case StateInvalid:
164+
// The guest mount failed; no guest-side state to tear down.
165+
// Decrement the ref count and transition to the terminal state
166+
// once all reservations are drained.
167+
m.refCount--
168+
if m.refCount == 0 {
169+
m.state = StateUnmounted
170+
}
171+
return nil
172+
163173
case StateUnmounted:
164-
// Already in the terminal state — nothing to do. This can happen when
165-
// MountToGuest failed (it transitions StateReserved → StateUnmounted),
166-
// and the caller subsequently calls UnmapFromGuest to clean up.
174+
// Already in the terminal state — nothing to do.
167175
}
168176
return nil
169177
}

internal/controller/device/plan9/mount/mount_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,10 @@ func TestMountToGuest_HappyPath(t *testing.T) {
118118
}
119119
}
120120

121-
// TestMountToGuest_GuestFails_TransitionsToUnmounted verifies that when the
121+
// TestMountToGuest_GuestFails_TransitionsToInvalid verifies that when the
122122
// guest AddLCOWMappedDirectory call fails, the mount transitions directly from
123-
// StateReserved to StateUnmounted (the terminal state).
124-
func TestMountToGuest_GuestFails_TransitionsToUnmounted(t *testing.T) {
123+
// StateReserved to StateInvalid so outstanding reservations can be drained.
124+
func TestMountToGuest_GuestFails_TransitionsToInvalid(t *testing.T) {
125125
ctrl := gomock.NewController(t)
126126
guest := mocks.NewMockLinuxGuestPlan9Mounter(ctrl)
127127

@@ -133,8 +133,8 @@ func TestMountToGuest_GuestFails_TransitionsToUnmounted(t *testing.T) {
133133
if err == nil {
134134
t.Fatal("expected error")
135135
}
136-
if m.State() != StateUnmounted {
137-
t.Errorf("expected StateUnmounted after mount failure, got %v", m.State())
136+
if m.State() != StateInvalid {
137+
t.Errorf("expected StateInvalid after mount failure, got %v", m.State())
138138
}
139139
}
140140

@@ -165,20 +165,20 @@ func TestMountToGuest_AlreadyMounted_Idempotent(t *testing.T) {
165165
}
166166
}
167167

168-
// TestMountToGuest_OnUnmounted_Errors verifies that calling MountToGuest on a
169-
// mount in the terminal StateUnmounted returns an error.
170-
func TestMountToGuest_OnUnmounted_Errors(t *testing.T) {
168+
// TestMountToGuest_OnInvalid_Errors verifies that calling MountToGuest on a
169+
// mount in StateInvalid (from a prior mount failure) returns an error.
170+
func TestMountToGuest_OnInvalid_Errors(t *testing.T) {
171171
ctrl := gomock.NewController(t)
172172
guest := mocks.NewMockLinuxGuestPlan9Mounter(ctrl)
173173

174174
m := NewReserved("share0", Config{})
175175
guest.EXPECT().AddLCOWMappedDirectory(gomock.Any(), gomock.Any()).Return(errGuestMount)
176176
_, _ = m.MountToGuest(context.Background(), guest)
177177

178-
// Try to mount again from StateUnmounted.
178+
// Try to mount again from StateInvalid.
179179
_, err := m.MountToGuest(context.Background(), guest)
180180
if err == nil {
181-
t.Fatal("expected error when mounting from StateUnmounted")
181+
t.Fatal("expected error when mounting from StateInvalid")
182182
}
183183
}
184184

internal/controller/device/plan9/mount/state.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ package mount
99
//
1010
// StateReserved → StateMounted → StateUnmounted
1111
//
12-
// Mount failure from StateReserved transitions directly to the terminal
13-
// StateUnmounted state; the entry is then removed by the parent [share.Share].
12+
// Mount failure from StateReserved transitions to [StateInvalid].
13+
// In [StateInvalid] the guest mount was never established, but outstanding
14+
// reservations may still exist. The mount remains in [StateInvalid] until
15+
// all reservations have been drained via [Mount.UnmountFromGuest], at which
16+
// point it transitions to [StateUnmounted].
1417
// An unmount failure from StateMounted leaves the mount in StateMounted so
1518
// the caller can retry.
1619
//
@@ -19,12 +22,14 @@ package mount
1922
// Current State │ Trigger │ Next State
2023
// ────────────────┼────────────────────────────────────────────┼──────────────────────
2124
// StateReserved │ guest mount succeeds │ StateMounted
22-
// StateReserved │ guest mount fails │ StateUnmounted
25+
// StateReserved │ guest mount fails │ StateInvalid
2326
// StateReserved │ UnmountFromGuest (refCount > 1) │ StateReserved (ref--)
2427
// StateReserved │ UnmountFromGuest (refCount == 1) │ StateUnmounted
2528
// StateMounted │ UnmountFromGuest (refCount > 1) │ StateMounted (ref--)
2629
// StateMounted │ UnmountFromGuest (refCount == 1) succeeds │ StateUnmounted
2730
// StateMounted │ UnmountFromGuest (refCount == 1) fails │ StateMounted
31+
// StateInvalid │ UnmountFromGuest (refCount > 1) │ StateInvalid (ref--)
32+
// StateInvalid │ UnmountFromGuest (refCount == 1) │ StateUnmounted
2833
// StateUnmounted │ UnmountFromGuest │ StateUnmounted (no-op)
2934
// StateUnmounted │ (terminal — entry removed from share) │ —
3035
type State int
@@ -38,6 +43,13 @@ const (
3843
// the guest. The guest path is valid from this state onward.
3944
StateMounted
4045

46+
// StateInvalid means the guest mount operation failed. No guest-side
47+
// state was established, but outstanding reservations may still need
48+
// to be drained via [Mount.UnmountFromGuest]. Once all reservations
49+
// are released (refCount reaches zero), the mount transitions to
50+
// [StateUnmounted].
51+
StateInvalid
52+
4153
// StateUnmounted means the guest has unmounted the share. This is a
4254
// terminal state; the entry is removed from the parent share.
4355
StateUnmounted
@@ -50,6 +62,8 @@ func (s State) String() string {
5062
return "Reserved"
5163
case StateMounted:
5264
return "Mounted"
65+
case StateInvalid:
66+
return "Invalid"
5367
case StateUnmounted:
5468
return "Unmounted"
5569
default:

internal/controller/device/plan9/share/doc.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// # Share Lifecycle
1616
//
1717
// ┌──────────────────────┐
18-
// │ StateReserved │ ← add failure → StateRemoved (not retriable)
18+
// │ StateReserved │ ← add failure → StateInvalid
1919
// └──────────┬───────────┘
2020
// │ share added to VM
2121
// ▼
@@ -30,4 +30,15 @@
3030
// │ StateRemoved │
3131
// └──────────────────────┘
3232
// (terminal — entry removed from controller)
33+
//
34+
// ┌──────────────────────┐
35+
// │ StateInvalid │ ← add failed; share never on VM
36+
// └──────────┬───────────┘
37+
// │ all mount reservations drained
38+
// │ via UnmountFromGuest + RemoveFromVM
39+
// ▼
40+
// ┌──────────────────────┐
41+
// │ StateRemoved │
42+
// └──────────────────────┘
43+
// (terminal — entry removed from controller)
3344
package share

0 commit comments

Comments
 (0)