// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "context" "errors" "log/slog" "time" "git.stormux.org/storm/cthulhu/internal/lifecycle" ) // runtimeShutdown owns the ordered teardown after the event loop returns. // The worker channels must close before preferences, braille, and speech stop. type runtimeShutdown struct { tentacles *tentacleRuntime ocr *ocrSession logger *slog.Logger cancel context.CancelFunc workers *runtimeWorkers preferences *preferencesWorker braille *brailleWorker speech *speechWorker focusGate *focusGate services *lifecycle.Runner } func stopRuntime(runtime runtimeShutdown) error { runtime.cancel() var tentacleError error if runtime.tentacles != nil { ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) tentacleError = runtime.tentacles.Stop(ctx) cancel() } <-runtime.workers.commandDone if runtime.workers.internalUIDone != nil { <-runtime.workers.internalUIDone } <-runtime.workers.clipboardDone <-runtime.workers.inputDone <-runtime.workers.copyDone <-runtime.workers.autoClipboardDone if runtime.ocr != nil { ocrContext, cancelOCR := context.WithTimeout(context.Background(), 5*time.Second) if err := runtime.ocr.Stop(ocrContext); err != nil { runtime.logger.Warn("stop OCR", "error", err) } cancelOCR() } // Join settings work before farewell and backend shutdown. The preferences // service also remains in the runner so failed cleanup is retried there. if runtime.preferences != nil { preferencesContext, cancelPreferences := context.WithTimeout(context.Background(), 7*time.Second) if err := runtime.preferences.Stop(preferencesContext); err != nil { runtime.logger.Warn("stop preferences", "error", err) } cancelPreferences() } brailleContext, cancelBraille := context.WithTimeout(context.Background(), shutdownTimeout) if err := runtime.braille.Stop(brailleContext); err != nil { runtime.logger.Warn("stop braille", "error", err) } cancelBraille() if runtime.speech != nil && !runtime.focusGate.Locked() { farewellContext, cancelFarewell := context.WithTimeout(context.Background(), farewellTimeout) runtime.speech.InterruptForUser() farewellError := runtime.speech.EnqueueAndWait(farewellContext, speechRequest{ Text: random_string(shutdownMessages), Role: "system", Cause: "shutdown", }) cancelFarewell() if farewellError != nil { runtime.logger.Warn("deliver shutdown message", "error", farewellError) } else { runtime.logger.Debug("shutdown message accepted") } } shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout) defer cancelShutdown() runtime.logger.Debug("stopping runtime services") stopError := runtime.services.Stop(shutdownContext) if stopError != nil { runtime.logger.Warn("runtime services stopped with an error", "error", stopError) } else { runtime.logger.Debug("runtime services stopped") } return errors.Join(tentacleError, stopError) }