From f8d025d07e8a8cd712d7b7e152a9774919471dcd Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Thu, 14 May 2026 08:10:01 +1000 Subject: [PATCH] fix(installer): disable os-prober to prevent update-grub hang on multi-disk hardware On N100 mini-PCs with eMMC + NVMe, update-grub's os-prober probes all block devices via the bind-mounted /dev, hanging indefinitely on the eMMC. GRUB_DISABLE_OS_PROBER=true skips os-prober in the chroot. Also apply modernize lint fixes to vm test files (atomic.Int32). --- cmd/installer/install/install.go | 1 + spinifex/vm/device_map_test.go | 18 +++++++++--------- spinifex/vm/volumes_test.go | 24 ++++++++++++------------ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go index 86c518d11..00706661a 100644 --- a/cmd/installer/install/install.go +++ b/cmd/installer/install/install.go @@ -574,6 +574,7 @@ GRUB_TIMEOUT=5 GRUB_DISTRIBUTOR=Spinifex GRUB_CMDLINE_LINUX_DEFAULT="" GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200 systemd.show_status=1" +GRUB_DISABLE_OS_PROBER=true ` if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil { diff --git a/spinifex/vm/device_map_test.go b/spinifex/vm/device_map_test.go index 8f2577cb2..7ad4a463a 100644 --- a/spinifex/vm/device_map_test.go +++ b/spinifex/vm/device_map_test.go @@ -218,9 +218,9 @@ func TestQueryGuestDeviceMapWait(t *testing.T) { t.Run("device present on first attempt makes no extra calls", func(t *testing.T) { sleeps := stubDeviceMapRetrySleep(t) - var calls int32 + var calls atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - atomic.AddInt32(&calls, 1) + calls.Add(1) require.Equal(t, "query-block", cmd.Execute) return map[string]any{ "return": []qmp.BlockDevice{ @@ -235,15 +235,15 @@ func TestQueryGuestDeviceMapWait(t *testing.T) { require.NoError(t, err) assert.Contains(t, deviceMap, "vdisk-vol-new") - assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "exactly one query-block call expected") + assert.Equal(t, int32(1), calls.Load(), "exactly one query-block call expected") assert.Empty(t, *sleeps, "no sleep should occur on first-attempt success") }) t.Run("device missing all 5 attempts invokes final probe and returns last result", func(t *testing.T) { sleeps := stubDeviceMapRetrySleep(t) - var calls int32 + var calls atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - atomic.AddInt32(&calls, 1) + calls.Add(1) require.Equal(t, "query-block", cmd.Execute) return map[string]any{ "return": []qmp.BlockDevice{ @@ -256,7 +256,7 @@ func TestQueryGuestDeviceMapWait(t *testing.T) { deviceMap, err := queryGuestDeviceMapWait(qmpClient, "i-1", "vdisk-vol-missing") require.NoError(t, err) - assert.Equal(t, int32(6), atomic.LoadInt32(&calls), + assert.Equal(t, int32(6), calls.Load(), "5 retry attempts plus the final fall-through probe must all run") assert.Equal(t, []time.Duration{retryDelay, retryDelay, retryDelay, retryDelay}, *sleeps, "4 sleeps must occur between the 5 in-loop attempts (none after the final fall-through)") @@ -267,9 +267,9 @@ func TestQueryGuestDeviceMapWait(t *testing.T) { t.Run("device appears on third attempt skips remaining retries", func(t *testing.T) { sleeps := stubDeviceMapRetrySleep(t) - var calls int32 + var calls atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - n := atomic.AddInt32(&calls, 1) + n := calls.Add(1) require.Equal(t, "query-block", cmd.Execute) devices := []qmp.BlockDevice{ bootDevice("os", "/machine/peripheral-anon/device[0]/virtio-backend"), @@ -285,7 +285,7 @@ func TestQueryGuestDeviceMapWait(t *testing.T) { require.NoError(t, err) assert.Contains(t, deviceMap, "vdisk-vol-late") - assert.Equal(t, int32(3), atomic.LoadInt32(&calls), + assert.Equal(t, int32(3), calls.Load(), "third-attempt success must not trigger a 4th or 5th probe") assert.Equal(t, []time.Duration{retryDelay, retryDelay}, *sleeps, "exactly 2 sleeps must occur (after attempts 1 and 2); none after the 3rd success") diff --git a/spinifex/vm/volumes_test.go b/spinifex/vm/volumes_test.go index 5d9e520f2..6e6036da5 100644 --- a/spinifex/vm/volumes_test.go +++ b/spinifex/vm/volumes_test.go @@ -663,9 +663,9 @@ func TestTryBlockdevDel(t *testing.T) { t.Run("success on first attempt logs no retry message", func(t *testing.T) { buf := captureSlog(t) - var attempts int32 + var attempts atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - atomic.AddInt32(&attempts, 1) + attempts.Add(1) return nil }) defer cancel() @@ -673,7 +673,7 @@ func TestTryBlockdevDel(t *testing.T) { m := NewManagerWithDeps(Deps{}) err := m.tryBlockdevDel(&VM{ID: "i-1", QMPClient: qmpClient}, "nbd-vol-1") require.NoError(t, err) - assert.Equal(t, int32(1), atomic.LoadInt32(&attempts)) + assert.Equal(t, int32(1), attempts.Load()) assert.NotContains(t, buf.String(), "succeeded after retry", "first-attempt success must not log the retry-success line") }) @@ -681,9 +681,9 @@ func TestTryBlockdevDel(t *testing.T) { t.Run("success on third attempt logs succeeded after retry", func(t *testing.T) { buf := captureSlog(t) - var attempts int32 + var attempts atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - n := atomic.AddInt32(&attempts, 1) + n := attempts.Add(1) if n < 3 { return map[string]any{"error": map[string]any{"class": "GenericError", "desc": inUseDesc}} } @@ -694,16 +694,16 @@ func TestTryBlockdevDel(t *testing.T) { m := NewManagerWithDeps(Deps{}) err := m.tryBlockdevDel(&VM{ID: "i-1", QMPClient: qmpClient}, "nbd-vol-1") require.NoError(t, err) - assert.Equal(t, int32(3), atomic.LoadInt32(&attempts)) + assert.Equal(t, int32(3), attempts.Load()) logs := buf.String() assert.Contains(t, logs, "succeeded after retry") assert.Contains(t, logs, "attempts=3") }) t.Run("non-in-use error on second attempt returns immediately", func(t *testing.T) { - var attempts int32 + var attempts atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - n := atomic.AddInt32(&attempts, 1) + n := attempts.Add(1) switch n { case 1: return map[string]any{"error": map[string]any{"class": "GenericError", "desc": inUseDesc}} @@ -717,14 +717,14 @@ func TestTryBlockdevDel(t *testing.T) { err := m.tryBlockdevDel(&VM{ID: "i-1", QMPClient: qmpClient}, "nbd-vol-1") require.Error(t, err) assert.Contains(t, err.Error(), "permission denied") - assert.Equal(t, int32(2), atomic.LoadInt32(&attempts), + assert.Equal(t, int32(2), attempts.Load(), "non-retryable error must stop the loop on the attempt that produced it") }) t.Run("twenty in-use errors return the last error", func(t *testing.T) { - var attempts int32 + var attempts atomic.Int32 qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any { - atomic.AddInt32(&attempts, 1) + attempts.Add(1) return map[string]any{"error": map[string]any{"class": "GenericError", "desc": inUseDesc}} }) defer cancel() @@ -734,7 +734,7 @@ func TestTryBlockdevDel(t *testing.T) { require.Error(t, err) assert.True(t, isQMPNodeInUse(err), "exhaustion must return the last 'in use' error, not a wrapped or sentinel value") - assert.Equal(t, int32(blockdevDelMaxAttempts), atomic.LoadInt32(&attempts), + assert.Equal(t, int32(blockdevDelMaxAttempts), attempts.Load(), "retry must cap at blockdevDelMaxAttempts attempts") }) }