refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen

Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.

Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
  Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
  Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
  Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
  daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
  Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
  und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
  (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
  paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
  den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
  Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
  Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
  nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
  neu.

Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 09:49:16 +02:00
1 parent b86632006e
commit bc26a73aeb
29 files changed
+1145 -584

No files matched your search

+8 -4
View File
@@ -67,6 +67,13 @@ var commands = []command{
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
handler: runStatus,
},
{
name: "daemon",
short: "Hintergrundprozess steuern (run | start | stop)",
args: "[run|start|stop]",
detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).",
handler: runDaemon,
},
{
name: "cancel",
short: "Laufende und geplante Ansagen abbrechen",
@@ -286,10 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
// versionString rendert die Versionsausgabe: `talk version <version>` plus
// Commit-Hash in Klammern, falls per ldflags injiziert.
func versionString() string {
if commit == "" {
return fmt.Sprintf("talk version %s", version)
}
return fmt.Sprintf("talk version %s (%s)", version, commit)
return "talk version " + buildVersion()
}
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
+8 -5
View File
@@ -19,7 +19,9 @@ import (
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
// Zustand greift also auch offline und überlebt Daemon-Restarts.
// Zustand greift also auch offline und überlebt Daemon-Restarts. Die
// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das):
// Stummschalten braucht keinen laufenden Prozess.
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(true, stdout, stderr)
}
@@ -72,9 +74,9 @@ func printMuteState(muted bool, stdout io.Writer) {
// runStatus implementiert `talk status`: plain Block aus GET /status —
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon:
// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode
// hat keinen Status).
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
// sagt status genau das (Exit 1) und startet ihn nicht — status soll die
// Wirklichkeit zeigen, nicht verändern.
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -86,7 +88,8 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
defer cancel()
st, err := client.Status(ctx)
if err != nil {
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL)
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)")
return exitFailure
}
+7 -5
View File
@@ -16,7 +16,9 @@ import (
// Prozessgrenzen-Tests in /test.
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
// mit Hinweis (kein Stillversagen).
// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem
// als Status-Zeile, damit der Block nie leer bleibt. Status startet den
// Daemon nicht.
func TestStatusOhneDaemon(t *testing.T) {
setGetenv(t, nil)
var out, errBuf bytes.Buffer
@@ -24,11 +26,11 @@ func TestStatusOhneDaemon(t *testing.T) {
if code != exitFailure {
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nicht erreichbar") {
if !strings.Contains(errBuf.String(), "kein Daemon") {
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
}
if out.Len() != 0 {
t.Errorf("stdout = %q, will leer", out.String())
if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String())
}
}
@@ -112,7 +114,7 @@ func TestSayOhneConfigDir(t *testing.T) {
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err)
}
if !strings.Contains(errBuf.String(), "Degraded Mode") {
if !strings.Contains(errBuf.String(), "spreche direkt") {
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
}
}
+97 -19
View File
@@ -5,10 +5,13 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
@@ -19,31 +22,106 @@ import (
const (
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten
daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
)
// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder
// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
// info sagt „muted". info ist der Writer für Informations-Token (Item-ID,
// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines
// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden.
//
// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
// angekommen und wurde bewusst abgelehnt.
func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
client := api.NewClient(cfg.Server.Port)
// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
// mit anderem Status — zählt als „kein Daemon".
func daemonAlive(client *api.Client, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err := client.Status(ctx)
return err == nil
}
probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
defer cancelProbe()
if _, err := client.Status(probeCtx); err != nil {
return 0, false
// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet
// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt:
// kein Daemon erreichbar und keiner startbar (Autostart aus, Start
// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf
// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir.
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
client = api.NewClient(cfg.Server.Port)
if daemonAlive(client, daemonProbeTimeout) {
return client, true
}
if !cfg.Daemon.Autostart {
return client, false
}
return client, startDaemon(client, stderr)
}
// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit
// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind
// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller
// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso.
func startDaemon(client *api.Client, stderr io.Writer) bool {
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err)
return false
}
logPath := ""
if dir, err := config.Dir(getenv); err == nil {
logPath = filepath.Join(dir, daemon.LogFileName)
if err := os.MkdirAll(dir, 0o755); err != nil {
logPath = ""
}
}
exited, err := daemon.Spawn(exe, logPath, os.Environ())
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return false
}
logHint := ""
if logPath != "" {
logHint = " (Log: " + logPath + ")"
}
deadline := time.Now().Add(daemonStartTimeout)
for {
if daemonAlive(client, daemonStartPoll*4) {
fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint)
return true
}
select {
case err := <-exited:
if daemonAlive(client, daemonProbeTimeout) {
return true // ein anderer Aufruf hat den Daemon gestartet
}
fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint)
return false
case <-time.After(daemonStartPoll):
}
if time.Now().After(deadline) {
fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint)
return false
}
}
}
// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar.
func exitReason(err error) string {
if err == nil {
return "Prozess hat sich sofort beendet"
}
return err.Error()
}
// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default:
// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt
// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute
// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den
// Grund. info ist der Writer für diese Informations-Token: say reicht
// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um
// talk-Zeilen verunreinigt werden).
//
// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf
// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit
// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim
// Daemon angekommen und wurde bewusst abgelehnt.
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
resp, err := client.Speak(ctx, api.SpeakRequest{
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
)
// daemonStopTimeout ist die Frist, in der ein per /shutdown angestoßener
// Daemon verschwunden sein muss.
const daemonStopTimeout = 5 * time.Second
// runDaemon implementiert `talk daemon [run|start|stop]`:
//
// - run (Default): Daemon im Vordergrund bis SIGINT/SIGTERM — der Modus
// für systemd/launchd/Task Scheduler oder ein Terminal. Protokoll auf
// stderr.
// - start: Daemon im Hintergrund starten (dasselbe wie der Autostart von
// say, nur explizit und unabhängig von daemon.autostart) und melden,
// wo er läuft. Läuft schon einer: kein Fehler.
// - stop: laufenden Daemon per POST /shutdown herunterfahren und warten,
// bis er weg ist. Läuft keiner: Hinweis, Exit 0 (idempotent).
func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
sub := "run"
if len(args) > 0 {
sub = args[0]
}
if len(args) > 1 {
fmt.Fprintln(stderr, "talk daemon: zu viele Argumente — Verwendung: talk daemon [run|start|stop]")
return exitUsage
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk daemon: %v\n", err)
return exitFailure
}
switch sub {
case "run":
return daemonRun(cfg, stderr)
case "start":
return daemonStart(cfg, stdout, stderr)
case "stop":
return daemonStop(cfg, stdout, stderr)
default:
fmt.Fprintf(stderr, "talk daemon: unbekanntes Unterkommando %q (erlaubt: run, start, stop)\n", sub)
return exitUsage
}
}
func daemonRun(cfg config.Config, stderr io.Writer) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := daemon.Run(ctx, daemon.Options{
Config: cfg,
Getenv: getenv,
Version: buildVersion(),
Log: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "talk daemon: %v\n", err)
return exitFailure
}
return exitOK
}
func daemonStart(cfg config.Config, stdout, stderr io.Writer) int {
client := api.NewClient(cfg.Server.Port)
if daemonAlive(client, daemonProbeTimeout) {
fmt.Fprintf(stdout, "daemon: läuft bereits unter %s\n", client.BaseURL)
return exitOK
}
if !startDaemon(client, stderr) {
return exitFailure
}
fmt.Fprintf(stdout, "daemon: gestartet unter %s\n", client.BaseURL)
return exitOK
}
func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
client := api.NewClient(cfg.Server.Port)
if !daemonAlive(client, daemonProbeTimeout) {
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
return exitOK
}
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
if err := client.Shutdown(ctx); err != nil {
fmt.Fprintf(stderr, "talk daemon stop: %v\n", err)
return exitFailure
}
// Warten, bis der Daemon wirklich weg ist: erst verstummt HTTP, dann
// bricht er die laufende Ansage ab und löscht als Letztes die PID-Datei.
// Ohne Config-Dir (keine PID-Datei) genügt das HTTP-Ende.
pidDir := ""
if dir, err := config.Dir(getenv); err == nil {
pidDir = dir
}
deadline := time.Now().Add(daemonStopTimeout)
for daemonAlive(client, daemonStartPoll*4) || (pidDir != "" && daemon.ReadPID(pidDir) != 0) {
if time.Now().After(deadline) {
fmt.Fprintf(stderr, "talk daemon stop: Daemon ist nach %s noch nicht beendet — PID siehe %s im Config-Verzeichnis\n",
daemonStopTimeout, daemon.PIDFileName)
return exitFailure
}
time.Sleep(daemonStartPoll)
}
fmt.Fprintln(stdout, "daemon: gestoppt")
return exitOK
}
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/audio"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
"code.m3ta.dev/m3tam3re/talk/internal/provider"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen;
// nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir
// serialisiert konkurrierende talk-Prozesse; Kollision heißt warten.
const (
lockFileName = "talk.lock"
lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais
lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten
lockPollInterval = 50 * time.Millisecond
)
// sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe
// Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren,
// dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im
// Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das
// Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und
// Mute weiter, statt zu scheitern. info ist der Writer für Informations-
// Token („muted"): say reicht stdout, wrap stderr durch.
func sayDegraded(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if dir, err := config.Dir(getenv); err == nil {
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
if mute.Muted() {
fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK
}
release, err := acquireLock(filepath.Join(dir, lockFileName))
if err != nil {
fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err)
} else {
defer release()
}
}
lang := opts.lang
if lang == "" {
lang = langdetect.Detect(text)
}
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
chain, err := provider.NewChain(provider.ChainConfig{
Pin: pickProvider(opts.provider, cfg.Provider),
Voice: opts.voice,
Voices: cfg.Voice,
Endpoints: endpointsOf(cfg),
Model: cfg.Providers["openai"].Model,
Getenv: getenv,
Player: sink,
Log: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
if err := chain.Speak(context.Background(), text, lang); err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
// acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine
// stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt
// (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach
// lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion
// (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt —
// Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe
// nur bis zur Stale-Frist.
func acquireLock(path string) (release func(), err error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
deadline := time.Now().Add(lockWaitTimeout)
for {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
return func() {
f.Close()
_ = os.Remove(path)
}, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, err
}
if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
_ = os.Remove(path)
continue
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path)
}
time.Sleep(lockPollInterval)
}
}
+23 -11
View File
@@ -1,17 +1,20 @@
// talk ist die CLI für Sprach-Ausgaben (talk 2.0).
// talk ist das eine Binary von talk 2.0: CLI und Daemon.
//
// Dieser Stand (Task 07): say (und der Default-Aufruf `talk "Text"`) reicht
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded
// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/
// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- <Befehl>`
// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code
// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die
// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den
// Tasks 08–09.
// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
// `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
// übrigen Befehle sind Stubs.
package main
import "os"
import (
"fmt"
"os"
)
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
//
@@ -23,6 +26,15 @@ var (
commit = ""
)
// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert
// — der Wert, den der Daemon in /status und MCP serverInfo meldet.
func buildVersion() string {
if commit == "" {
return version
}
return fmt.Sprintf("%s (%s)", version, commit)
}
func main() {
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
+51 -32
View File
@@ -1,13 +1,16 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
@@ -27,13 +30,11 @@ type sayOptions struct {
}
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd
// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking
// als Default, --wait pollt bis zur Fertigstellung, --now springt vor,
// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein
// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal,
// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden
// Pfarden dieselbe (internal/provider, unverändert).
// und validieren, dann über den Daemon sprechen — non-blocking als
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
// Warteschlange, aber nie still.
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
text := strings.Join(args, " ")
if text == "" {
@@ -64,13 +65,52 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return announce(cfg, text, opts, stdout, stderr)
}
if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled {
return code
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if client, ok := ensureDaemon(cfg, stderr); ok {
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
return code
}
}
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
return sayDirect(cfg, text, opts, info, stderr)
}
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
return sayDegraded(cfg, text, opts, stdout, stderr)
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
// es ohne Mute weiter, statt zu scheitern.
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if dir, err := config.Dir(getenv); err == nil {
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
if mute.Muted() {
fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK
}
}
engine, err := speak.New(cfg, getenv, stderr)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
err = engine.Speak(context.Background(), speak.Request{
Text: text,
Lang: opts.lang,
Provider: opts.provider,
Voice: opts.voice,
})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
@@ -88,27 +128,6 @@ func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
return 0, false
}
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
func pickProvider(flagValue, cfgValue string) string {
if flagValue != "" {
return flagValue
}
return cfgValue
}
// endpointsOf überführt die Provider-Einstellungen der Config in die
// Endpunkt-Map der Kette.
func endpointsOf(cfg config.Config) map[string]string {
endpoints := make(map[string]string, len(cfg.Providers))
for name, settings := range cfg.Providers {
if settings.Endpoint != "" {
endpoints[name] = settings.Endpoint
}
}
return endpoints
}
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
func isCharDevice(r io.Reader) bool {
+4 -2
View File
@@ -16,14 +16,16 @@ import (
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd
// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt
// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary
// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt
// den Key.
func setGetenv(t *testing.T, kv map[string]string) {
t.Helper()
base := map[string]string{
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1",
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
}
for k, v := range kv {
base[k] = v
+5 -16
View File
@@ -22,8 +22,8 @@ const exitNotFound = 127
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
// Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue,
// non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt
// Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
// blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
// die say-Flags und reicht sie an die Ansage durch.
//
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
@@ -71,9 +71,8 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
return code
}
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say:
// Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon
// Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
// (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
@@ -81,19 +80,9 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
// werden.
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
warn := func() {
if announce(cfg, text, opts, stderr, stderr) != exitOK {
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
}
if code, handled := sayViaDaemon(cfg, text, opts, stderr, stderr); handled {
if code != 0 {
warn()
}
return
}
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
if sayDegraded(cfg, text, opts, stderr, stderr) != exitOK {
warn()
}
}
// execChild startet argv als Kindprozess und wartet auf sein Ende.
-270
View File
@@ -1,270 +0,0 @@
// talkd ist der Daemon für talk 2.0: HTTP-API auf 127.0.0.1 (Default-Port
// 7800, server.port/TALK_PORT), serialisierte Ansage-Queue mit Prioritäten
// und now-Sprung, persistenter Mute-State (Datei mute im Config-Dir) —
// und seit Task 05 das Anti-Spam-Gesetz: Rate-Limit (queue.rate_limit,
// Default 1 Ansage / 30 s; low über Budget wird verworfen, normal parkt,
// high darf mit queue.allow_high_burst verstoßen) plus Ruhezeiten
// (quiet_hours/quiet_mode; now:true bricht bewusst aus beiden aus).
// Task 06 ergänzt die CORS-Tür für POST /speak (server.allowed_origins /
// TALK_ALLOWED_ORIGINS; leer = kein CORS) und den MCP-Endpoint POST /mcp
// (Streamable HTTP, ein speak-Tool).
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/audio"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
"code.m3ta.dev/m3tam3re/talk/internal/provider"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Build-Informationen. Werden beim Release per -ldflags injiziert (gleicher
// Vertrag wie cmd/talk, siehe .goreleaser.yaml in Task 11). Default: dev.
var (
version = "dev"
commit = ""
)
func main() {
log.SetFlags(log.LstdFlags)
log.SetPrefix("talkd: ")
if err := run(); err != nil {
log.Fatal(err)
}
}
// run startet den Daemon und kehrt erst beim Herunterfahren (SIGINT/
// SIGTERM) zurück. Abbrüche der laufenden Ansage beim Shutdown sind
// beabsichtigt: talkd soll auf Signale prompt reagieren.
func run() error {
getenv := os.Getenv
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
return err
}
// Audio-Sink (audio.sink; "null" verwirft tonlos — CI/Server).
// Ketten-, Queue- und Sink-Protokoll laufen über denselben Logger.
lw := lineLogger{}
sink, err := audio.NewSink(cfg.Audio.Sink, lw)
if err != nil {
return err
}
// Basiskette nur für die /status-Auflösung „aktiver Provider" (erste
// verfügbare Stufe; Available ist billig — Key-/Endpunkt-Binary-Checks
// ohne Netz-Call). Das eigentliche Abspielen baut pro Item eine eigene
// Kette (Pin/Voice je Anfrage) — die Kette aus internal/provider wird
// unverändert wiederverwendet.
baseChain, err := provider.NewChain(chainConfigFor(cfg, "", "", getenv, nil))
if err != nil {
return err
}
state := &providerState{}
speak := func(ctx context.Context, item queue.Item) error {
state.set(resolveProviderName(baseChain, item))
lang := item.Lang
if lang == "" {
lang = langdetect.Detect(item.Text)
}
chain, err := provider.NewChain(chainConfigFor(cfg, item.Provider, item.Voice, getenv, sink))
if err != nil {
return err
}
return chain.Speak(ctx, item.Text, lang)
}
// Anti-Spam-Gesetz (Task 05): Rate-Limit + High-Burst + Ruhezeiten.
// config.Load hat quiet_hours bereits validiert — das Parsen hier kann
// praktisch nicht scheitern, aber ein Fehler darf nie still werden.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
}
laws := queue.Laws{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
QuietMode: cfg.QuietMode,
}
q := queue.New(laws, speak, lw)
// Persistenter Mute-State (Datei mute im Config-Dir). Ohne Config-Dir
// läuft der Daemon ohne Mute weiter (dokumentierte Warnung) statt zu
// scheitern — alles andere an talk darf ja auch ohne Config-Datei.
var mutePath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
} else {
log.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State deaktiviert (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
handler := api.NewHandler(api.HandlerConfig{
Queue: q,
Mute: mute,
Version: versionString(),
KnownProviders: provider.Names(),
AllowedOrigins: cfg.Server.AllowedOrigins,
Provider: func() string {
if cur, ok := q.Current(); ok {
return resolveProviderName(baseChain, cur)
}
if last := state.get(); last != "" {
return last
}
return resolveProviderName(baseChain, queue.Item{})
},
})
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talkd?", addr, err)
}
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
corsLog := "zu (kein CORS)"
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
addr, versionString(), sink.Name(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
q.Close()
return fmt.Errorf("http server: %w", err)
case s := <-sig:
log.Printf("Signal %v empfangen — fahre herunter (laufende Ansage wird abgebrochen)", s)
}
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
}
q.Close()
return nil
}
// versionString rendert die Daemon-Version für /status.
func versionString() string {
if commit == "" {
return version
}
return fmt.Sprintf("%s (%s)", version, commit)
}
// chainConfigFor überführt Config + Item-Pins in die ChainConfig — dieselbe
// Zusammensetzung wie im CLI-Pfad (cmd/talk/say.go), nur dass der Daemon
// sie pro Ansage baut (Pin/Voice sind Anfrage-Properties). player ist nur
// für die Abspiel-Kette gesetzt (die Basiskette braucht ihn nicht).
func chainConfigFor(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player) provider.ChainConfig {
return provider.ChainConfig{
Pin: pickProvider(pin, cfg.Provider),
Voice: voice,
Voices: cfg.Voice,
Endpoints: endpointsOf(cfg),
Model: cfg.Providers["openai"].Model,
Getenv: getenv,
Player: player,
Log: lineLogger{},
}
}
// pickProvider löst den Provider-Pin auf: Anfrage-/Flag-Pin schlägt
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
func pickProvider(pin, cfgValue string) string {
if pin != "" {
return pin
}
return cfgValue
}
// endpointsOf überführt die Provider-Einstellungen der Config in die
// Endpunkt-Map der Kette.
func endpointsOf(cfg config.Config) map[string]string {
endpoints := make(map[string]string, len(cfg.Providers))
for name, settings := range cfg.Providers {
if settings.Endpoint != "" {
endpoints[name] = settings.Endpoint
}
}
return endpoints
}
// resolveProviderName löst den „aktiven Provider" für /status auf: Pin des
// Items, sonst erste verfügbare Stufe der Basiskette. Available() ist
// bewusst billig (Key-/Binary-Prüfung ohne Netz-Call), deshalb darf das
// /status on demand aufrufen.
func resolveProviderName(chain *provider.Chain, item queue.Item) string {
if item.Provider != "" {
return item.Provider
}
for _, p := range chain.Tiers() {
if p.Available(context.Background()) {
return p.Name()
}
}
return "keine"
}
// providerState merkt sich den zuletzt aktiven Provider (für /status im
// Leerlauf). Geschrieben wird ausschließlich vom Queue-Worker, gelesen von
// HTTP-Handlern — daher der eigene kleine Mutex.
type providerState struct {
mu sync.Mutex
name string
}
func (s *providerState) set(name string) {
s.mu.Lock()
s.name = name
s.mu.Unlock()
}
func (s *providerState) get() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.name
}
// lineLogger reicht Zeilen an den Standard-Logger weiter (Zeitstempel +
// talkd-Präfix), statt sie roh zu schreiben: Provider-Kette, Queue und
// null-Sink schreiben zeilenweise, so entsteht ein einheitliches Log.
type lineLogger struct{}
func (lineLogger) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
if line != "" {
log.Print(line)
}
}
return len(p), nil
}