diff --git a/internal/init/run.go b/internal/init/run.go index 99a35c5..006d465 100644 --- a/internal/init/run.go +++ b/internal/init/run.go @@ -1016,7 +1016,21 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { // polls the local socket and redraws the node status frame. A console crash // is non-critical: superviseConsole restarts it and never returns an error // that would reboot a serving CA. - go superviseConsole(ctx) + // + // It runs under its own cancelable context rather than ctx directly, so a + // shutdown can stop it for good -- and confirm it has actually exited -- + // before the shutdown message is written to the same console (#370). + consoleCtx, cancelConsole := context.WithCancel(ctx) + defer cancelConsole() + consoleDone := make(chan struct{}) + go func() { + defer close(consoleDone) + superviseConsole(consoleCtx) + }() + shutdown.stopConsole = func() { + cancelConsole() + <-consoleDone + } // 14. Park until a shutdown is requested, then return so the deferred // teardown runs before PID 1 reboots or powers off. SIGINT must be handled diff --git a/internal/init/shutdown.go b/internal/init/shutdown.go index 255690d..9fad199 100644 --- a/internal/init/shutdown.go +++ b/internal/init/shutdown.go @@ -102,6 +102,11 @@ type shutdownRequests struct { // is happening instead of freezing on its last frame. announce func(ShutdownAction) + // stopConsole stops the console dashboard for good and blocks until it has + // actually exited. Wait calls it before announce, so the dashboard's own + // refresh loop can never redraw a frame over the shutdown message (#370). + stopConsole func() + mu sync.Mutex chosen ShutdownAction } @@ -145,6 +150,11 @@ func (r *shutdownRequests) Request(a ShutdownAction) { // for Action, and arms the teardown watchdog before returning, so the // teardown that follows cannot hang the node. An ended context counts as a // reboot. +// +// The console is stopped, and waited for, before the shutdown message is +// announced: announcing first and stopping the console after would leave a +// window where the dashboard's own refresh loop redraws its ordinary status +// frame over the message that was just written (#370). func (r *shutdownRequests) Wait(ctx context.Context) ShutdownAction { a := ShutdownReboot select { @@ -154,6 +164,9 @@ func (r *shutdownRequests) Wait(ctx context.Context) ShutdownAction { r.mu.Lock() r.chosen = a r.mu.Unlock() + if r.stopConsole != nil { + r.stopConsole() + } if r.announce != nil { r.announce(a) } diff --git a/internal/init/shutdown_test.go b/internal/init/shutdown_test.go index efc6b0d..b70f535 100644 --- a/internal/init/shutdown_test.go +++ b/internal/init/shutdown_test.go @@ -108,6 +108,32 @@ func TestShutdownRequests_WaitAnnouncesTheAction(t *testing.T) { } } +// Wait stops the console dashboard for good, and waits for it to have +// actually stopped, before announcing the shutdown message. Otherwise the +// dashboard's own refresh loop can redraw its ordinary status frame over the +// shutdown message after it is written (#370). +func TestShutdownRequests_WaitStopsTheConsoleBeforeAnnouncing(t *testing.T) { + sd := testShutdownRequests(nil) + var order []string + sd.stopConsole = func() { order = append(order, "stop") } + sd.announce = func(ShutdownAction) { order = append(order, "announce") } + sd.Request(ShutdownReboot) + + sd.Wait(context.Background()) + if !slices.Equal(order, []string{"stop", "announce"}) { + t.Fatalf("order = %v, want [stop announce]", order) + } +} + +// A nil stopConsole (the zero value, before run.go wires one up) must not +// panic Wait. +func TestShutdownRequests_WaitToleratesNoStopConsole(t *testing.T) { + sd := testShutdownRequests(nil) + sd.Request(ShutdownReboot) + + sd.Wait(context.Background()) +} + // The console message names the action in the same words an admin sees // elsewhere in the UI, not the log's terse "reboot"/"power-off". func TestShutdownAction_ConsoleMessage(t *testing.T) {