Skip to content

Commit 408c4fc

Browse files
authored
fix rare race condition in nvme disconnect subsystem
1 parent b28fc1b commit 408c4fc

4 files changed

Lines changed: 100 additions & 62 deletions

File tree

core/node/detach.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -660,15 +660,6 @@ func (c *Core) detachNVMeVolume(
660660
defer nvmeSelfHealingLock.RUnlock()
661661
nvmeNodeOperationWaitingCount.Add(-1)
662662

663-
lockContext := "detachNVMeVolume.RemovePublishedNVMeSession"
664-
if !attemptLock(ctx, lockContext, nvmeSelfHealingSessionLock, sharedLocksNodeLockTimeout) {
665-
locks.Unlock(ctx, lockContext, nvmeSelfHealingSessionLock)
666-
return errors.MaxWaitExceededError("request waited too long for the lock")
667-
}
668-
disconnect := c.nvme.RemovePublishedNVMeSession(&publishedNVMeSessions, publishInfo.NVMeSubsystemNQN,
669-
publishInfo.NVMeNamespaceUUID)
670-
locks.Unlock(ctx, lockContext, nvmeSelfHealingSessionLock)
671-
672663
nvmeSubsys := c.nvme.NewNVMeSubsystem(ctx, publishInfo.NVMeSubsystemNQN)
673664
// Get the device using 'nvme-cli' commands. Flush the device IOs.
674665
// Proceed further with detach flow, if device is not found.
@@ -749,8 +740,17 @@ func (c *Core) detachNVMeVolume(
749740
locks.Unlock(ctx, "detachNVMeVolume.FlushRetryDelete", nvmeFlushRetryMapLock)
750741
}
751742

743+
lockContext := "detachNVMeVolume.RemovePublishedNVMeSession"
744+
if !attemptLock(ctx, lockContext, nvmeSelfHealingSessionLock, sharedLocksNodeLockTimeout) {
745+
locks.Unlock(ctx, lockContext, nvmeSelfHealingSessionLock)
746+
return errors.MaxWaitExceededError("request waited too long for the lock")
747+
}
748+
c.nvme.RemovePublishedNVMeSession(&publishedNVMeSessions, publishInfo.NVMeSubsystemNQN,
749+
publishInfo.NVMeNamespaceUUID)
750+
locks.Unlock(ctx, lockContext, nvmeSelfHealingSessionLock)
751+
752752
// Disconnect the subsystem if needed (handled under lock to prevent race conditions).
753-
if err = c.disconnectNVMeSubsystemIfNeeded(ctx, nvmeSubsys, publishInfo, disconnect); err != nil {
753+
if err = c.disconnectNVMeSubsystemIfNeeded(ctx, nvmeSubsys, publishInfo); err != nil {
754754
Logc(ctx).WithError(err).Warn("Error during subsystem disconnect check.")
755755
// Continue with cleanup even if disconnect fails.
756756
}

core/node/detach_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,13 +751,22 @@ type fakeDetachNVMeSubsystem struct {
751751
getDeviceErr error
752752
disconnectErr error
753753
disconnectCalls int
754+
hostNsCount int
755+
hostNsCountErr error
754756
}
755757

756758
func (f *fakeDetachNVMeSubsystem) Disconnect(context.Context) error {
757759
f.disconnectCalls++
758760
return f.disconnectErr
759761
}
760762

763+
// GetNamespaceCount feeds disconnectNVMeSubsystemIfNeeded's host-level check, which only skips the
764+
// disconnect when the count exceeds one; the zero value therefore leaves the disconnect decision to
765+
// the published session count that each test below sets up.
766+
func (f *fakeDetachNVMeSubsystem) GetNamespaceCount(context.Context) (int, error) {
767+
return f.hostNsCount, f.hostNsCountErr
768+
}
769+
761770
// GetNVMeDevice/GetNVMeDeviceAt always report "no device found" (nil, getDeviceErr): every
762771
// detachNVMeVolume test below therefore exercises the nvmeDev==nil branches, which is sufficient
763772
// to cover subsystem lookup, LUKS teardown, and disconnect-decision logic without needing a real,
@@ -796,7 +805,8 @@ func TestDetachNVMeVolume_GetNVMeDeviceNonNotFoundError(t *testing.T) {
796805
publishInfo := samplePublishInfo(NVMe)
797806
subsystem := &fakeDetachNVMeSubsystem{getDeviceErr: errors.New("nvme-cli failed")}
798807

799-
mocks.NVMe.EXPECT().RemovePublishedNVMeSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(false)
808+
// No RemovePublishedNVMeSession expectation: the session is now removed only after the device is
809+
// torn down, so bailing out here leaves the published session in place for a later retry.
800810
mocks.NVMe.EXPECT().NewNVMeSubsystem(gomock.Any(), gomock.Any()).Return(subsystem)
801811

802812
err := core.detachNVMeVolume(context.Background(), "test-volume", publishInfo, false)
@@ -846,7 +856,8 @@ func TestDetachNVMeVolume_LUKSDevicePathLookupError(t *testing.T) {
846856
publishInfo.LUKSEncryption = "true"
847857
subsystem := &fakeDetachNVMeSubsystem{}
848858

849-
mocks.NVMe.EXPECT().RemovePublishedNVMeSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(false)
859+
// No RemovePublishedNVMeSession expectation: this failure precedes the session removal, which now
860+
// happens only after LUKS teardown completes.
850861
mocks.NVMe.EXPECT().NewNVMeSubsystem(gomock.Any(), gomock.Any()).Return(subsystem)
851862
mocks.Devices.EXPECT().GetLUKSDevicePathForDevicePath(gomock.Any(), publishInfo.DevicePath).
852863
Return("", errors.New("lookup failed"))

core/node/utils.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func (c *Core) readAllTrackingFiles(ctx context.Context) []models.VolumePublishI
185185
// This lock serializes GetNamespaceCount() and Disconnect() operations to ensure accurate namespace counting
186186
// and prevent race conditions where multiple threads might see the same count simultaneously.
187187
func (c *Core) disconnectNVMeSubsystemIfNeeded(
188-
ctx context.Context, nvmeSubsys nvme.NVMeSubsystemInterface, publishInfo *models.VolumePublishInfo, disconnect bool,
188+
ctx context.Context, nvmeSubsys nvme.NVMeSubsystemInterface, publishInfo *models.VolumePublishInfo,
189189
) error {
190190
lockContext := "disconnectNVMeSubsystemIfNeeded"
191191
if !attemptLock(ctx, lockContext, nvmeSubsystemDisconnectLock, sharedLocksNodeLockTimeout) {
@@ -194,20 +194,44 @@ func (c *Core) disconnectNVMeSubsystemIfNeeded(
194194
}
195195
defer locks.Unlock(ctx, lockContext, nvmeSubsystemDisconnectLock)
196196

197+
// publishedNVMeSessions is mutated (Add/Remove) under nvmeSelfHealingSessionLock so this read must
198+
// take that same lock to avoid a concurrent map read/write with NodeStage, NodeUnstage, or self-healing.
199+
sessionLockContext := "disconnectNVMeSubsystemIfNeeded.SessionRead"
200+
if !attemptLock(ctx, sessionLockContext, nvmeSelfHealingSessionLock, sharedLocksNodeLockTimeout) {
201+
locks.Unlock(ctx, sessionLockContext, nvmeSelfHealingSessionLock)
202+
return errors.MaxWaitExceededError("request waited too long for the lock")
203+
}
197204
numNs := publishedNVMeSessions.GetNamespaceCountForSession(publishInfo.NVMeSubsystemNQN)
205+
locks.Unlock(ctx, sessionLockContext, nvmeSelfHealingSessionLock)
198206
Logc(ctx).WithFields(LogFields{
199207
"subsystem": publishInfo.NVMeSubsystemNQN,
200208
"namespaceCount": numNs,
201-
"disconnectFlag": disconnect,
202209
}).Info("Checking if subsystem should be disconnected.")
203210

204-
if numNs == 0 || (c.nvmeSelfHealingInterval > 0 && disconnect) {
205-
if err := nvmeSubsys.Disconnect(ctx); err != nil {
206-
Logc(ctx).WithField(
207-
"subsystem", publishInfo.NVMeSubsystemNQN,
208-
).WithError(err).Debug("Error disconnecting subsystem.")
209-
return err
210-
}
211+
// Another pod still has a published session; we must not disconnect.
212+
if numNs > 0 {
213+
return nil
214+
}
215+
216+
// In-memory sessions show none left, but a concurrent NodeStage may have already attached a namespace
217+
// for a new pod without recording its session yet (recorded only after format/mount). Checking the
218+
// host's ground-truth namespace count; if it's >1, another namespace is active, we don't disconnect.
219+
if hostNsCount, err := nvmeSubsys.GetNamespaceCount(ctx); err != nil {
220+
Logc(ctx).WithField("subsystem", publishInfo.NVMeSubsystemNQN).WithError(err).Debug(
221+
"Could not determine host namespace count; proceeding with disconnect based on published sessions.")
222+
} else if hostNsCount > 1 {
223+
Logc(ctx).WithFields(LogFields{
224+
"subsystem": publishInfo.NVMeSubsystemNQN,
225+
"hostNamespaces": hostNsCount,
226+
}).Info("Subsystem still has namespace devices attached on host; skipping disconnect.")
227+
return nil
228+
}
229+
230+
if err := nvmeSubsys.Disconnect(ctx); err != nil {
231+
Logc(ctx).WithField(
232+
"subsystem", publishInfo.NVMeSubsystemNQN,
233+
).WithError(err).Debug("Error disconnecting subsystem.")
234+
return err
211235
}
212236
return nil
213237
}

core/node/utils_test.go

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -351,20 +351,26 @@ func TestReadAllTrackingFiles_NoTrackingDirReturnsEmpty(t *testing.T) {
351351

352352
// fakeNVMeSubsystem is a minimal hand-rolled fake for nvme.NVMeSubsystemInterface: no gomock
353353
// mock exists for this interface under mocks/mock_utils/nvme, only for NVMeInterface. Any method
354-
// besides Disconnect is intentionally left unimplemented (nil-embedded) since
354+
// besides Disconnect and GetNamespaceCount is intentionally left unimplemented (nil-embedded) since
355355
// disconnectNVMeSubsystemIfNeeded never calls them; using any of them would panic, which is the
356356
// desired failure mode if the code under test changes to call something unexpected.
357357
type fakeNVMeSubsystem struct {
358358
nvme.NVMeSubsystemInterface
359359
disconnectErr error
360360
disconnectCalls int
361+
hostNsCount int
362+
hostNsCountErr error
361363
}
362364

363365
func (f *fakeNVMeSubsystem) Disconnect(_ context.Context) error {
364366
f.disconnectCalls++
365367
return f.disconnectErr
366368
}
367369

370+
func (f *fakeNVMeSubsystem) GetNamespaceCount(_ context.Context) (int, error) {
371+
return f.hostNsCount, f.hostNsCountErr
372+
}
373+
368374
// withCleanPublishedNVMeSessions snapshots and restores the package-level publishedNVMeSessions
369375
// global so this test's seeding doesn't bleed into other tests in the package.
370376
func withCleanPublishedNVMeSessions(t *testing.T) {
@@ -373,59 +379,56 @@ func withCleanPublishedNVMeSessions(t *testing.T) {
373379
t.Cleanup(func() { publishedNVMeSessions = original })
374380
}
375381

376-
func TestDisconnectNVMeSubsystemIfNeeded_NoNamespaces_DisconnectsRegardlessOfFlag(t *testing.T) {
377-
withCleanPublishedNVMeSessions(t)
378-
core, _ := newTestCore(t)
379-
pi := samplePublishInfo(NVMe)
380-
fakeSubsys := &fakeNVMeSubsystem{}
381-
382-
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi, false)
383-
384-
require.NoError(t, err)
385-
assert.Equal(t, 1, fakeSubsys.disconnectCalls)
386-
}
387-
388-
func TestDisconnectNVMeSubsystemIfNeeded_NamespacesPresent_DisconnectFlagFalse_NoDisconnect(t *testing.T) {
382+
func TestDisconnectNVMeSubsystemIfNeeded_PublishedNamespacePresent_NoDisconnect(t *testing.T) {
389383
withCleanPublishedNVMeSessions(t)
390384
core, _ := newTestCore(t, WithNVMeSelfHealingInterval(5*time.Second))
391385
pi := samplePublishInfo(NVMe)
392386
publishedNVMeSessions.AddNVMeSession(nvme.NVMeSubsystem{NQN: pi.NVMeSubsystemNQN}, nil)
393387
publishedNVMeSessions.AddNamespaceToSession(pi.NVMeSubsystemNQN, "ns-1")
394-
fakeSubsys := &fakeNVMeSubsystem{}
388+
fakeSubsys := &fakeNVMeSubsystem{hostNsCount: 1}
395389

396-
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi, false)
390+
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi)
397391

398392
require.NoError(t, err)
399393
assert.Equal(t, 0, fakeSubsys.disconnectCalls)
400394
}
401395

402-
func TestDisconnectNVMeSubsystemIfNeeded_NamespacesPresent_SelfHealingDisabled_NoDisconnect(t *testing.T) {
403-
withCleanPublishedNVMeSessions(t)
404-
// nvmeSelfHealingInterval defaults to zero (disabled) when not set via WithNVMeSelfHealingInterval.
405-
core, _ := newTestCore(t)
406-
pi := samplePublishInfo(NVMe)
407-
publishedNVMeSessions.AddNVMeSession(nvme.NVMeSubsystem{NQN: pi.NVMeSubsystemNQN}, nil)
408-
publishedNVMeSessions.AddNamespaceToSession(pi.NVMeSubsystemNQN, "ns-1")
409-
fakeSubsys := &fakeNVMeSubsystem{}
410-
411-
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi, true)
412-
413-
require.NoError(t, err)
414-
assert.Equal(t, 0, fakeSubsys.disconnectCalls, "self-healing disabled must gate the disconnect hint even if disconnect=true")
415-
}
396+
// Once no published sessions remain, the host namespace count is the tie-breaker: a count above one
397+
// means a concurrent NodeStage already attached a namespace it hasn't recorded a session for yet, so
398+
// disconnecting would pull that device out from under the new pod. Any other outcome, including an
399+
// unreadable count, falls through to the disconnect.
400+
func TestDisconnectNVMeSubsystemIfNeeded_NoPublishedNamespaces_HostCountDecides(t *testing.T) {
401+
tests := map[string]struct {
402+
hostNsCount int
403+
hostNsCountErr error
404+
wantDisconnectCalls int
405+
}{
406+
"another namespace still attached on host": {hostNsCount: 2, wantDisconnectCalls: 0},
407+
"several namespaces still attached": {hostNsCount: 5, wantDisconnectCalls: 0},
408+
"only our own namespace attached": {hostNsCount: 1, wantDisconnectCalls: 1},
409+
"no namespaces attached": {hostNsCount: 0, wantDisconnectCalls: 1},
410+
"host count unreadable": {
411+
hostNsCountErr: errors.New("failed to read namespace count"),
412+
wantDisconnectCalls: 1,
413+
},
414+
}
416415

417-
func TestDisconnectNVMeSubsystemIfNeeded_NamespacesPresent_SelfHealingEnabledAndDisconnect_Disconnects(t *testing.T) {
418-
withCleanPublishedNVMeSessions(t)
419-
core, _ := newTestCore(t, WithNVMeSelfHealingInterval(5*time.Second))
420-
pi := samplePublishInfo(NVMe)
421-
publishedNVMeSessions.AddNVMeSession(nvme.NVMeSubsystem{NQN: pi.NVMeSubsystemNQN}, nil)
422-
publishedNVMeSessions.AddNamespaceToSession(pi.NVMeSubsystemNQN, "ns-1")
423-
fakeSubsys := &fakeNVMeSubsystem{}
416+
for name, test := range tests {
417+
t.Run(name, func(t *testing.T) {
418+
withCleanPublishedNVMeSessions(t)
419+
core, _ := newTestCore(t)
420+
pi := samplePublishInfo(NVMe)
421+
fakeSubsys := &fakeNVMeSubsystem{
422+
hostNsCount: test.hostNsCount,
423+
hostNsCountErr: test.hostNsCountErr,
424+
}
424425

425-
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi, true)
426+
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi)
426427

427-
require.NoError(t, err)
428-
assert.Equal(t, 1, fakeSubsys.disconnectCalls)
428+
require.NoError(t, err)
429+
assert.Equal(t, test.wantDisconnectCalls, fakeSubsys.disconnectCalls)
430+
})
431+
}
429432
}
430433

431434
func TestDisconnectNVMeSubsystemIfNeeded_DisconnectErrorPropagates(t *testing.T) {
@@ -434,7 +437,7 @@ func TestDisconnectNVMeSubsystemIfNeeded_DisconnectErrorPropagates(t *testing.T)
434437
pi := samplePublishInfo(NVMe)
435438
fakeSubsys := &fakeNVMeSubsystem{disconnectErr: errors.New("disconnect failed")}
436439

437-
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi, false)
440+
err := core.disconnectNVMeSubsystemIfNeeded(context.Background(), fakeSubsys, pi)
438441

439442
require.Error(t, err)
440443
assert.Contains(t, err.Error(), "disconnect failed")

0 commit comments

Comments
 (0)