Files
cthulhu/internal/app/runtime_shutdown.go

107 lines
3.7 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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)
}