fix: pending instance terminate (#80)

This commit is contained in:
Julian Sommer
2026-03-19 11:47:00 +10:00
committed by GitHub
parent fafc104c30
commit a4bc8bee74
4 changed files with 316 additions and 13 deletions
+114 -13
View File
@@ -851,11 +851,41 @@ func (d *Daemon) restoreInstances() {
// Check if QEMU process is still alive from before the restart
if d.isInstanceProcessRunning(instance) {
slog.Info("Instance QEMU process still alive, reconnecting", "instance", instance.ID)
if err := d.reconnectInstance(instance); err != nil {
slog.Error("Failed to reconnect to running instance", "instanceId", instance.ID, "err", err)
// Verify NBD sockets are still valid. After a viperblock restart,
// old sockets are gone and QEMU's block devices are broken. Kill
// the orphaned QEMU and relaunch from scratch instead of
// reconnecting to an instance with dead storage.
if !d.areVolumeSocketsValid(instance) {
slog.Warn("QEMU alive but NBD sockets are stale, killing orphaned process for relaunch",
"instance", instance.ID)
pid, pidErr := utils.ReadPidFile(instance.ID)
if pidErr != nil || pid <= 0 {
slog.Error("Cannot read PID for orphaned QEMU, skipping relaunch",
"instanceId", instance.ID, "err", pidErr)
continue
}
// SIGKILL directly — orphaned QEMU with dead storage has no
// state worth a graceful shutdown, and KillProcess's 120s
// SIGTERM timeout would block daemon startup.
if proc, err := os.FindProcess(pid); err == nil {
_ = proc.Signal(syscall.SIGKILL)
}
// Wait for the process to actually die, then remove the PID
// file ourselves. SIGKILL cannot be caught, so QEMU never
// runs its cleanup handler and the PID file stays on disk.
if err := utils.WaitForProcessExit(pid, 10*time.Second); err != nil {
slog.Error("Orphaned QEMU did not exit after SIGKILL, skipping relaunch",
"instanceId", instance.ID, "pid", pid, "err", err)
continue
}
_ = utils.RemovePidFile(instance.ID)
} else {
slog.Info("Instance QEMU process still alive, reconnecting", "instance", instance.ID)
if err := d.reconnectInstance(instance); err != nil {
slog.Error("Failed to reconnect to running instance", "instanceId", instance.ID, "err", err)
}
continue
}
continue
}
// QEMU is not running -- resolve transitional states from interrupted operations
@@ -887,21 +917,36 @@ func (d *Daemon) restoreInstances() {
case vm.StateRunning:
// Was running but QEMU died - reset to pending so LaunchInstance can transition to running
instance.Status = vm.StatePending
// Reset LaunchTime so the pending watchdog gives a fresh timeout window.
// Without this, the stale LaunchTime from the original launch causes the
// watchdog to immediately mark the instance as failed.
now := time.Now()
if instance.Instance != nil {
instance.Instance.LaunchTime = &now
}
slog.Info("Instance was running but QEMU exited, relaunching", "instance", instance.ID)
}
// Reset LaunchTime so the pending watchdog gives a fresh timeout window.
// Without this, the stale LaunchTime from the original launch causes the
// watchdog to immediately mark the instance as failed after a prolonged outage.
now := time.Now()
if instance.Instance != nil {
instance.Instance.LaunchTime = &now
}
toLaunch = append(toLaunch, instance)
}
// Phase 2: Relaunch crashed VMs with semaphore-based throttling
if len(toLaunch) > 0 {
// Subscribe to per-instance NATS topics before launching so that
// terminate/stop commands can reach this daemon while instances are
// still being relaunched. Without this, pending instances are
// unreachable via ec2.cmd.<id> and TerminateInstances fails.
d.mu.Lock()
for _, instance := range toLaunch {
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
if subErr != nil {
slog.Error("Failed to early-subscribe during recovery", "instanceId", instance.ID, "err", subErr)
} else {
d.natsSubscriptions[instance.ID] = sub
}
}
d.mu.Unlock()
slog.Info("Launching instances (recovery)", "count", len(toLaunch), "maxConcurrent", maxConcurrentRecovery)
sem := make(chan struct{}, maxConcurrentRecovery)
var wg sync.WaitGroup
@@ -917,9 +962,19 @@ func (d *Daemon) restoreInstances() {
slog.Error("Panic during instance recovery", "instanceId", inst.ID, "panic", r, "stack", string(debug.Stack()))
}
}()
// Skip if instance was terminated while waiting for semaphore
d.Instances.Mu.Lock()
status := inst.Status
d.Instances.Mu.Unlock()
if status != vm.StatePending && status != vm.StateProvisioning {
slog.Info("Instance state changed during recovery, skipping launch",
"instanceId", inst.ID, "status", string(status))
return
}
slog.Info("Launching instance (recovery)", "instance", inst.ID)
if err := d.LaunchInstance(inst); err != nil {
slog.Error("Failed to launch instance", "instanceId", inst.ID, "err", err)
slog.Error("Failed to launch instance during recovery", "instanceId", inst.ID, "err", err)
d.markInstanceFailed(inst, "recovery_launch_failed")
}
}(instance)
}
@@ -945,6 +1000,32 @@ func (d *Daemon) isInstanceProcessRunning(instance *vm.VM) bool {
return process.Signal(syscall.Signal(0)) == nil
}
// areVolumeSocketsValid checks whether the NBD Unix sockets backing an
// instance's volumes are reachable. A dial probe (not just os.Stat) is used
// because viperblock may restart with sockets at the same paths — the file
// exists but no process is listening on the old fd that QEMU holds.
func (d *Daemon) areVolumeSocketsValid(instance *vm.VM) bool {
instance.EBSRequests.Mu.Lock()
defer instance.EBSRequests.Mu.Unlock()
for _, req := range instance.EBSRequests.Requests {
if req.NBDURI == "" {
continue
}
serverType, sockPath, _, _, err := utils.ParseNBDURI(req.NBDURI)
if err != nil || serverType != "unix" {
continue // TCP or unparseable — can't validate locally
}
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
if err != nil {
slog.Debug("NBD socket unreachable", "volume", req.Name, "socket", sockPath, "err", err)
return false
}
_ = conn.Close()
}
return true
}
// reconnectInstance re-establishes QMP and NATS connections to a running QEMU instance
// after a daemon restart. This bypasses the state machine since recovery is not a
// normal state transition.
@@ -1634,6 +1715,15 @@ func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
func (d *Daemon) LaunchInstance(instance *vm.VM) (err error) {
// Abort if instance is no longer in a launchable state (e.g., terminated
// by a concurrent request while waiting in the launch queue).
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StatePending && status != vm.StateStopped && status != vm.StateProvisioning {
return fmt.Errorf("instance %s in %s state, not launchable", instance.ID, status)
}
// First, confirm if the instance is already running
pid, err := utils.ReadPidFile(instance.ID)
@@ -1728,8 +1818,19 @@ func (d *Daemon) LaunchInstance(instance *vm.VM) (err error) {
// then completes the termination lifecycle in the background so the instance
// reaches terminated state and doesn't get stuck in shutting-down.
func (d *Daemon) markInstanceFailed(instance *vm.VM, reason string) {
// Set state reason before transition (requires lock)
// If the instance is already being cleaned up (e.g., a concurrent terminate
// request transitioned it to shutting-down while LaunchInstance was running),
// don't spawn a second finalizeTermination goroutine — the existing cleanup
// handler owns the lifecycle from here.
d.Instances.Mu.Lock()
if instance.Status == vm.StateShuttingDown || instance.Status == vm.StateTerminated {
d.Instances.Mu.Unlock()
slog.Info("markInstanceFailed: instance already in cleanup state, skipping",
"instanceId", instance.ID, "status", string(instance.Status), "reason", reason)
return
}
// Set state reason before transition
if instance.Instance != nil {
instance.Instance.StateReason = &ec2.StateReason{}
instance.Instance.StateReason.SetCode("Server.InternalError")
@@ -262,9 +262,33 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
slog.Info("Instances added to state with pending status", "count", len(instances))
// Subscribe to per-instance NATS topics early so terminate/stop commands
// can reach this daemon while volumes are being prepared. LaunchInstance
// will replace these subscriptions when it completes.
d.mu.Lock()
for _, instance := range instances {
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
if subErr != nil {
slog.Error("Failed to early-subscribe to per-instance topic", "instanceId", instance.ID, "err", subErr)
} else {
d.natsSubscriptions[instance.ID] = sub
}
}
d.mu.Unlock()
// Launch all instances (volumes and VMs)
var successCount int
for _, instance := range instances {
// Skip if instance was terminated by a concurrent request
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StatePending && status != vm.StateProvisioning {
slog.Info("Instance state changed during provisioning, skipping launch",
"instanceId", instance.ID, "status", string(status))
continue
}
// Pre-compute dev MAC so cloud-init can generate per-interface netplan
// that suppresses the default route on the dev/hostfwd NIC.
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
+151
View File
@@ -2,7 +2,9 @@ package daemon
import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -1043,3 +1045,152 @@ func TestPendingWatchdog_MarksStuckInstanceFailed(t *testing.T) {
freshInst := daemon.Instances.VMS["i-fresh"]
assert.Equal(t, vm.StatePending, freshInst.Status)
}
func TestAreVolumeSocketsValid_ValidSocket(t *testing.T) {
daemon := createDaemonWithJetStream(t)
// Create a real Unix socket listener
sockPath := filepath.Join(t.TempDir(), "test.sock")
listener, err := net.Listen("unix", sockPath)
require.NoError(t, err)
defer listener.Close()
instance := &vm.VM{
ID: "i-valid-sock",
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: "vol-1", NBDURI: "nbd:unix:" + sockPath},
},
},
}
assert.True(t, daemon.areVolumeSocketsValid(instance))
}
func TestAreVolumeSocketsValid_MissingSocket(t *testing.T) {
daemon := createDaemonWithJetStream(t)
instance := &vm.VM{
ID: "i-missing-sock",
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: "vol-1", NBDURI: "nbd:unix:/nonexistent/path.sock"},
},
},
}
assert.False(t, daemon.areVolumeSocketsValid(instance))
}
func TestAreVolumeSocketsValid_StaleSocketFile(t *testing.T) {
daemon := createDaemonWithJetStream(t)
// Create a socket file with no listener — simulates viperblock crash
// leaving a stale socket on disk
sockPath := filepath.Join(t.TempDir(), "stale.sock")
listener, err := net.Listen("unix", sockPath)
require.NoError(t, err)
listener.Close() // close listener but leave file on disk
instance := &vm.VM{
ID: "i-stale-sock",
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: "vol-1", NBDURI: "nbd:unix:" + sockPath},
},
},
}
// os.Stat would return true here, but dial should fail
assert.False(t, daemon.areVolumeSocketsValid(instance))
}
func TestAreVolumeSocketsValid_EmptyRequests(t *testing.T) {
daemon := createDaemonWithJetStream(t)
instance := &vm.VM{
ID: "i-no-vols",
EBSRequests: types.EBSRequests{},
}
assert.True(t, daemon.areVolumeSocketsValid(instance))
}
func TestAreVolumeSocketsValid_TCPTransport(t *testing.T) {
daemon := createDaemonWithJetStream(t)
instance := &vm.VM{
ID: "i-tcp",
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: "vol-tcp", NBDURI: "nbd://192.168.1.1:10809"},
},
},
}
// TCP transport can't be validated locally — should return true
assert.True(t, daemon.areVolumeSocketsValid(instance))
}
func TestMarkInstanceFailed_AlreadyShuttingDown(t *testing.T) {
daemon := createDaemonWithJetStream(t)
instance := &vm.VM{
ID: "i-already-shutting",
Status: vm.StateShuttingDown,
Instance: &ec2.Instance{},
}
daemon.Instances.VMS[instance.ID] = instance
// Should be a no-op — instance is already being cleaned up
daemon.markInstanceFailed(instance, "test_reason")
// Status should not change
assert.Equal(t, vm.StateShuttingDown, instance.Status)
}
func TestMarkInstanceFailed_AlreadyTerminated(t *testing.T) {
daemon := createDaemonWithJetStream(t)
instance := &vm.VM{
ID: "i-already-term",
Status: vm.StateTerminated,
Instance: &ec2.Instance{},
}
daemon.Instances.VMS[instance.ID] = instance
daemon.markInstanceFailed(instance, "test_reason")
// Status should not change
assert.Equal(t, vm.StateTerminated, instance.Status)
}
func TestRestoreInstances_ProvisioningInstanceLaunchTimeReset(t *testing.T) {
daemon := createDaemonWithJetStream(t)
// Provisioning instances (daemon crashed during first launch) should get
// their LaunchTime reset so the pending watchdog doesn't immediately kill
// them after a prolonged outage.
staleTime := time.Now().Add(-30 * time.Minute)
daemon.Instances.VMS["i-provisioning"] = &vm.VM{
ID: "i-provisioning",
Status: vm.StateProvisioning,
Instance: &ec2.Instance{LaunchTime: &staleTime},
}
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
before := time.Now()
simulateCleanRestore(t, daemon)
// LoadState inside restoreInstances creates fresh objects, so look up
// from the map. The instance may have been removed by markInstanceFailed
// (launch fails in test env), so load directly from JetStream state.
require.NoError(t, daemon.LoadState())
instance := daemon.Instances.VMS["i-provisioning"]
if instance == nil {
// Instance was cleaned up by markInstanceFailed — that's fine.
// The important thing is that the LaunchTime WAS reset before
// the launch attempt. We can't verify it post-cleanup, so skip.
t.Skip("Instance was cleaned up during recovery — LaunchTime was reset before launch attempt")
}
require.NotNil(t, instance.Instance)
require.NotNil(t, instance.Instance.LaunchTime)
assert.True(t, instance.Instance.LaunchTime.After(before) || instance.Instance.LaunchTime.Equal(before),
"LaunchTime should be reset for provisioning instances, got %v", *instance.Instance.LaunchTime)
}
+27
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strconv"
"syscall"
"time"
)
@@ -159,6 +160,32 @@ func pidPath() string {
return os.TempDir()
}
// WaitForProcessExit polls until the given PID is no longer alive or the
// timeout expires. Unlike WaitForPidFileRemoval, this checks the process
// itself via kill(pid, 0), so it works after SIGKILL where the target
// cannot clean up its own PID file.
func WaitForProcessExit(pid int, timeout time.Duration) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timer.C:
return fmt.Errorf("timeout waiting for process %d to exit", pid)
case <-ticker.C:
proc, err := os.FindProcess(pid)
if err != nil {
return nil // process gone
}
if proc.Signal(syscall.Signal(0)) != nil {
return nil // process no longer alive
}
}
}
}
func WaitForPidFileRemoval(instanceID string, timeout time.Duration) error {
timeoutCh := time.After(timeout)
ticker := time.NewTicker(100 * time.Millisecond)