Unverified Commit 1c89a5ee authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(operator): wake up for idle auto-stop — the timer had no driver

EmptySince was stamped and then never revisited: player joins/leaves do not
touch the CRD, RCON is only probed inside Reconcile, and a steady Running
server produces no watch events (its status update goes out unchanged and is a
no-op). Live, an empty server with a 30s grace sat Running for minutes with
zero reconciles in the log — the auto-stop existed only on paper.

reconcileRunning now returns a RequeueAfter for idle-enabled servers: exactly
at the deadline while empty, or a 30s probe cadence while occupied so the
moment the last player leaves is noticed. New unit tests pin all three:
deadline requeue, occupied cadence, and no requeue when disabled.
parent c04a3f08
Loading
Loading
Loading
Loading
+20 −1
Changes for internal/operator/reconciler.go: 20 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ const (
	requeueStarting            = 2 * time.Second
	requeueStopping            = 5 * time.Second
	requeueSecret              = 10 * time.Second
	requeueIdleProbe           = 30 * time.Second
	defaultTimeoutSeconds      = 300
	defaultReadinessTimeoutSec = 300
)
@@ -197,7 +198,25 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
	}

	r.markRunningReady(server, players, endpointAddress)
	return ctrl.Result{}, r.patchStatus(ctx, server)
	if err := r.patchStatus(ctx, server); err != nil {
		return ctrl.Result{}, err
	}
	// Idle auto-stop has no natural wake-up: player joins/leaves never touch
	// this CRD and RCON is only probed here, so without a requeue the
	// empty-duration counter would be stamped once and then never revisited
	// (observed live: the stamp sat unexamined for minutes). Wake at the exact
	// deadline while the tally says empty, or on a slow cadence while players
	// are online, to notice the moment the last one leaves.
	if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
		if server.Status.EmptySince != nil {
			deadline := server.Status.EmptySince.Time.Add(time.Duration(server.Spec.Idle.EmptySecondsBeforeStop) * time.Second)
			if wait := deadline.Sub(r.now().Time); wait > 0 {
				return ctrl.Result{RequeueAfter: wait}, nil
			}
		}
		return ctrl.Result{RequeueAfter: requeueIdleProbe}, nil
	}
	return ctrl.Result{}, nil
}

func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
+60 −0
Changes for internal/operator/reconciler_test.go: 60 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -427,6 +427,66 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
	}
}

// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an
// empty Running server must wake the controller at the auto-stop deadline with
// no external event to lean on. Live, the EmptySince stamp sat unexamined for
// minutes because nothing re-triggered the reconcile loop — this test fails if
// the requeue is ever dropped again.
func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) {
	r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())
	base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
	r.Now = func() metav1.Time { return metav1.NewTime(base) }

	s := getServer(t, c, "survival")
	s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 30}
	if err := c.Update(context.Background(), s); err != nil {
		t.Fatalf("enable idle: %v", err)
	}

	reconcile(t, r, "survival")
	markPodReady(t, c, "survival")
	res := reconcile(t, r, "survival")

	if res.RequeueAfter != 30*time.Second {
		t.Fatalf("RequeueAfter = %v, want exactly 30s (wake at the auto-stop deadline)", res.RequeueAfter)
	}
}

// TestIdleAutoStop_RequeuesWhileOccupied verifies the slow probe cadence that
// notices the last player leaving: with players online there is no deadline to
// aim at, but the tally must still be re-sampled.
func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) {
	r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret())

	s := getServer(t, c, "survival")
	s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900}
	if err := c.Update(context.Background(), s); err != nil {
		t.Fatalf("enable idle: %v", err)
	}

	reconcile(t, r, "survival")
	markPodReady(t, c, "survival")
	res := reconcile(t, r, "survival")

	if res.RequeueAfter <= 0 {
		t.Fatalf("RequeueAfter = %v, want a positive probe cadence while occupied", res.RequeueAfter)
	}
}

// TestNoIdleRequeueWhenDisabled guards against a blanket requeue: servers
// without idle auto-stop keep the old quiescent behaviour.
func TestNoIdleRequeueWhenDisabled(t *testing.T) {
	r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret())

	reconcile(t, r, "survival")
	markPodReady(t, c, "survival")
	res := reconcile(t, r, "survival")

	if res.RequeueAfter != 0 {
		t.Fatalf("RequeueAfter = %v, want 0 when idle auto-stop is disabled", res.RequeueAfter)
	}
}

// TestIdleAutoStop_SkipsWhenDisabled verifies that a Running empty server does
// NOT get an EmptySince timestamp when AutoStopEnabled is false.
func TestIdleAutoStop_SkipsWhenDisabled(t *testing.T) {