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
+1144 -583

No files matched your search

-2
View File
@@ -1,8 +1,6 @@
# Go-Binaries # Go-Binaries
/talk /talk
/talkd
/talk.exe /talk.exe
/talkd.exe
*.test *.test
*.out *.out
+44 -20
View File
@@ -6,9 +6,10 @@
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` — **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
spricht eine kurze Nachricht über eine TTS-Provider-Kette spricht eine kurze Nachricht über eine TTS-Provider-Kette
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon (ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen
`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries, Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein
keine Runtime-Abhängigkeiten. statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine
Runtime-Abhängigkeiten.
## Befehle ## Befehle
@@ -20,6 +21,7 @@ keine Runtime-Abhängigkeiten.
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben | | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) | | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
| `talk cancel` | laufende und geplante Ansagen abbrechen | | `talk cancel` | laufende und geplante Ansagen abbrechen |
| `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) |
| `talk voices` | verfügbare Stimmen anzeigen | | `talk voices` | verfügbare Stimmen anzeigen |
| `talk test` | Test-Ansage ausgeben | | `talk test` | Test-Ansage ausgeben |
| `talk doctor` | Selbstdiagnose des Setups | | `talk doctor` | Selbstdiagnose des Setups |
@@ -29,17 +31,37 @@ Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`. `--voice`, `--lang`, `--provider`.
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`) **Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking,
`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`. Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon`
Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented
≠ 0). yet` (Exit-Code ≠ 0).
## Daemon talkd (Task 04–06) ## Daemon
`talkd` besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code,
gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (FIFO je im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau
Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default `7800`, eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low`
config `[server] port`, Env `TALK_PORT` (schlagen den Default). (FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default
`7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default).
**Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn
beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in
`daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet
einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet
nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart.
Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen,
benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet
(systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den
Autostart ab:
```toml
[daemon]
autostart = false # Env TALK_AUTOSTART=false
```
Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon
bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute`
schreibt die Mute-Datei direkt.
| Endpunkt | Wirkung | | Endpunkt | Wirkung |
| --- | --- | | --- | --- |
@@ -48,8 +70,9 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` | | `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` |
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` | | `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` | | `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` |
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) | | `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
| `GET /mcp` | `405` — talkd bietet keine server-initiierten Streams an | | `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an |
- **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via - **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via
`GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking, `GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking,
@@ -61,10 +84,11 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
`false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon `false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu, schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
der Zustand greift also auch offline. der Zustand greift also auch offline.
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und - **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im (Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk`
Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele
verfallen). Mute wird auch hier respektiert. Nichts bricht still. Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird
auch hier respektiert. Nichts bricht still.
### Rate-Limit + Ruhezeiten (Task 05) ### Rate-Limit + Ruhezeiten (Task 05)
@@ -129,7 +153,7 @@ allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
- **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein - **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb
lehnt talkd jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
nicht POST /speak von einer gelisteten Origin ist — also ungelistete nicht POST /speak von einer gelisteten Origin ist — also ungelistete
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`, Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
`/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der `/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der
@@ -261,8 +285,8 @@ talk wrap --wait -- make test
Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention), Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
Befehl, ungültige Flags) scheitern vor dem Start des Befehls. Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
- **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking; - **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking;
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei. `--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad.
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`, wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) und reicht sie an die Ansage durch. `--lang`, `--provider`) und reicht sie an die Ansage durch.
+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).", detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
handler: runStatus, 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", name: "cancel",
short: "Laufende und geplante Ansagen abbrechen", 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 // versionString rendert die Versionsausgabe: `talk version <version>` plus
// Commit-Hash in Klammern, falls per ldflags injiziert. // Commit-Hash in Klammern, falls per ldflags injiziert.
func versionString() string { func versionString() string {
if commit == "" { return "talk version " + buildVersion()
return fmt.Sprintf("talk version %s", version)
}
return fmt.Sprintf("talk version %s (%s)", version, commit)
} }
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr // 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 // runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe // Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der // 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 { func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(true, stdout, stderr) 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 — // runStatus implementiert `talk status`: plain Block aus GET /status —
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State, // Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon: // aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode // sagt status genau das (Exit 1) und startet ihn nicht — status soll die
// hat keinen Status). // Wirklichkeit zeigen, nicht verändern.
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv}) cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil { if err != nil {
@@ -86,7 +88,8 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
defer cancel() defer cancel()
st, err := client.Status(ctx) st, err := client.Status(ctx)
if err != nil { 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 return exitFailure
} }
+7 -5
View File
@@ -16,7 +16,9 @@ import (
// Prozessgrenzen-Tests in /test. // Prozessgrenzen-Tests in /test.
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler // 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) { func TestStatusOhneDaemon(t *testing.T) {
setGetenv(t, nil) setGetenv(t, nil)
var out, errBuf bytes.Buffer var out, errBuf bytes.Buffer
@@ -24,11 +26,11 @@ func TestStatusOhneDaemon(t *testing.T) {
if code != exitFailure { if code != exitFailure {
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", 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()) t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
} }
if out.Len() != 0 { if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
t.Errorf("stdout = %q, will leer", out.String()) 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 { if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err) 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()) t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
} }
} }
+98 -20
View File
@@ -5,10 +5,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath"
"time" "time"
"code.m3ta.dev/m3tam3re/talk/internal/api" "code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
"code.m3ta.dev/m3tam3re/talk/internal/queue" "code.m3ta.dev/m3tam3re/talk/internal/queue"
) )
@@ -19,31 +22,106 @@ import (
const ( const (
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status) daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe 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 waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
) )
// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status, // daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking), // mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder // mit anderem Status — zählt als „kein Daemon".
// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage — func daemonAlive(client *api.Client, timeout time.Duration) bool {
// info sagt „muted". info ist der Writer für Informations-Token (Item-ID, ctx, cancel := context.WithTimeout(context.Background(), timeout)
// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines defer cancel()
// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden. _, err := client.Status(ctx)
// return err == nil
// 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)
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) ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel() defer cancel()
resp, err := client.Speak(ctx, api.SpeakRequest{ 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 // say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur // ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded // vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/ // (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- <Befehl>` // Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code // betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die // mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den // `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
// Tasks 08–09. // übrigen Befehle sind Stubs.
package main package main
import "os" import (
"fmt"
"os"
)
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B. // Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
// //
@@ -23,6 +26,15 @@ var (
commit = "" 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() { func main() {
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
} }
+52 -33
View File
@@ -1,13 +1,16 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
"code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/queue" "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, // 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 // runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd // und validieren, dann über den Daemon sprechen — non-blocking als
// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking // Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
// als Default, --wait pollt bis zur Fertigstellung, --now springt vor, // kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein // nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal, // Warteschlange, aber nie still.
// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden
// Pfarden dieselbe (internal/provider, unverändert).
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
text := strings.Join(args, " ") text := strings.Join(args, " ")
if text == "" { 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) fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure return exitFailure
} }
return announce(cfg, text, opts, stdout, stderr)
if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled {
return code
} }
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)") // announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
return sayDegraded(cfg, text, opts, stdout, stderr) // 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)
}
// 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 | // 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 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 // isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
// stdin-Input warten, sondern meldet einen Benutzungsfehler). // stdin-Input warten, sondern meldet einen Benutzungsfehler).
func isCharDevice(r io.Reader) bool { 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 // 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 // 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 // von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd // den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt // 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. // den Key.
func setGetenv(t *testing.T, kv map[string]string) { func setGetenv(t *testing.T, kv map[string]string) {
t.Helper() t.Helper()
base := map[string]string{ base := map[string]string{
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1", "TALK_PORT": "1",
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
} }
for k, v := range kv { for k, v := range kv {
base[k] = v base[k] = v
+5 -16
View File
@@ -22,8 +22,8 @@ const exitNotFound = 127
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw. // sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der // „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels). // Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
// Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue, // Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
// non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt // blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
// die say-Flags und reicht sie an die Ansage durch. // die say-Flags und reicht sie an die Ansage durch.
// //
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk- // 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 return code
} }
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say: // announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
// Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon // (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
// Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie, // Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler). // gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap // 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 // Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
// werden. // werden.
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) { 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) 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. // 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
}
+5
View File
@@ -81,6 +81,11 @@ func (c *Client) SetMute(ctx context.Context, muted bool) error {
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil) return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
} }
// Shutdown ruft POST /shutdown (`talk daemon stop`).
func (c *Client) Shutdown(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
}
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen, // do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet // dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
// über errors.As(&HTTPError)). // über errors.As(&HTTPError)).
+20
View File
@@ -38,6 +38,11 @@ type HandlerConfig struct {
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer = // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
// keine CORS-Header, der Browser blockt; siehe cors.go). // keine CORS-Header, der Browser blockt; siehe cors.go).
AllowedOrigins []string AllowedOrigins []string
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
// nil → der Endpunkt antwortet 501.
Shutdown func()
} }
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
@@ -52,6 +57,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
known: make(map[string]bool, len(cfg.KnownProviders)), known: make(map[string]bool, len(cfg.KnownProviders)),
provider: cfg.Provider, provider: cfg.Provider,
allowedOrigins: cfg.AllowedOrigins, allowedOrigins: cfg.AllowedOrigins,
shutdown: cfg.Shutdown,
} }
for _, name := range cfg.KnownProviders { for _, name := range cfg.KnownProviders {
h.known[name] = true h.known[name] = true
@@ -63,6 +69,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
mux.HandleFunc("POST /cancel", h.cancel) mux.HandleFunc("POST /cancel", h.cancel)
mux.HandleFunc("POST /mute", h.muteEndpoint(true)) mux.HandleFunc("POST /mute", h.muteEndpoint(true))
mux.HandleFunc("POST /unmute", h.muteEndpoint(false)) mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06) mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
return h.originGuard(mux) return h.originGuard(mux)
@@ -76,6 +83,7 @@ type handler struct {
known map[string]bool known map[string]bool
provider func() string provider func() string
allowedOrigins []string allowedOrigins []string
shutdown func()
} }
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202, // speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
@@ -241,6 +249,18 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
} }
} }
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
// der Server die Verbindung schließt.
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
if h.shutdown == nil {
writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger")
return
}
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
h.shutdown()
}
// writeJSON schreibt eine JSON-Antwort mit Status-Code. // writeJSON schreibt eine JSON-Antwort mit Status-Code.
func writeJSON(w http.ResponseWriter, code int, body any) { func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+5
View File
@@ -131,3 +131,8 @@ type CancelResponse struct {
type MuteResponse struct { type MuteResponse struct {
Muted bool `json:"muted"` Muted bool `json:"muted"`
} }
// ShutdownResponse ist die Antwort von POST /shutdown.
type ShutdownResponse struct {
OK bool `json:"ok"`
}
+20
View File
@@ -44,6 +44,9 @@ type Config struct {
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05). // Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
Queue Queue `toml:"queue"` Queue Queue `toml:"queue"`
// Daemon steuert den Hintergrundprozess (Autostart).
Daemon Daemon `toml:"daemon"`
// Provider pinnt die Kette auf genau einen Provider (z. B. // Provider pinnt die Kette auf genau einen Provider (z. B.
// `provider = "elevenlabs"`); leer = komplette Default-Kette // `provider = "elevenlabs"`); leer = komplette Default-Kette
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider // (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
@@ -99,6 +102,15 @@ type Server struct {
AllowedOrigins []string `toml:"allowed_origins"` AllowedOrigins []string `toml:"allowed_origins"`
} }
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
type Daemon struct {
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
Autostart bool `toml:"autostart"`
}
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`). // Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
type Wrap struct { type Wrap struct {
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B. // Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
@@ -137,6 +149,7 @@ func Defaults() Config {
Audio: Audio{Sink: SinkOto}, Audio: Audio{Sink: SinkOto},
Server: Server{Port: DefaultServerPort}, Server: Server{Port: DefaultServerPort},
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true}, Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
Daemon: Daemon{Autostart: true},
QuietMode: queue.QuietModeDiscard, QuietMode: queue.QuietModeDiscard,
} }
} }
@@ -210,6 +223,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
cfg.Server.AllowedOrigins = origins cfg.Server.AllowedOrigins = origins
} }
} }
if v := getenv("TALK_AUTOSTART"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
}
cfg.Daemon.Autostart = b
}
if v := getenv("TALK_WRAP_LABEL"); v != "" { if v := getenv("TALK_WRAP_LABEL"); v != "" {
cfg.Wrap.Label = v cfg.Wrap.Label = v
} }
+45
View File
@@ -686,3 +686,48 @@ allow_high_burst = false
} }
}) })
} }
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
// klarer Fehler.
func TestLoadDaemonAutostart(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want Default true")
}
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = true, want false aus TOML")
}
cfg, err = Load(Options{
File: writeTOML(t, "[daemon]\nautostart = false\n"),
Getenv: env(map[string]string{"TALK_AUTOSTART": "true"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
}
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
}
}
+214
View File
@@ -0,0 +1,214 @@
// Package daemon ist der Hintergrundprozess von talk: HTTP-API auf
// 127.0.0.1, serialisierte Ansage-Queue mit Prioritäten und now-Sprung,
// persistenter Mute-State, die Anti-Spam-Gesetze (Rate-Limit, Ruhezeiten),
// CORS-Tür und MCP-Endpoint. Es gibt genau ein Binary: `talk daemon run`
// betreibt den Daemon im Vordergrund, die CLI startet ihn mit Spawn bei
// Bedarf selbst im Hintergrund (daemon.autostart).
package daemon
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/provider"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Dateien des Daemons im Config-Verzeichnis.
const (
// LogFileName ist das Protokoll eines im Hintergrund gestarteten
// Daemons (Spawn leitet stdout/stderr dorthin; pro Start neu).
LogFileName = "daemon.log"
// PIDFileName hält die Prozess-ID des laufenden Daemons (für
// Diagnose und als Notbremse, wenn die HTTP-API nicht antwortet).
PIDFileName = "daemon.pid"
)
// Options sind die Abhängigkeiten von Run.
type Options struct {
// Config ist die geladene Konfiguration (Port, Gesetze, Sink, …).
Config config.Config
// Getenv liefert Keys und Endpunkt-Overrides (nil = os.Getenv).
Getenv func(string) string
// Version ist die Daemon-Version für /status und MCP serverInfo.
Version string
// Log empfängt das Protokoll (Vordergrund: stderr; Hintergrund: die
// Log-Datei). nil = os.Stderr.
Log io.Writer
}
// Run betreibt den Daemon, bis ctx endet (Signal), POST /shutdown kommt
// oder der HTTP-Server scheitert. Eine laufende Ansage wird beim
// Herunterfahren abgebrochen — der Daemon soll prompt reagieren. Ein
// belegter Port ist ein klarer Fehler („läuft bereits ein Daemon?").
func Run(ctx context.Context, opts Options) error {
getenv := opts.Getenv
if getenv == nil {
getenv = os.Getenv
}
out := opts.Log
if out == nil {
out = os.Stderr
}
logger := log.New(out, "talk daemon: ", log.LstdFlags)
lw := lineWriter{logger}
cfg := opts.Config
engine, err := speak.New(cfg, getenv, lw)
if err != nil {
return err
}
// Anti-Spam-Gesetz: config.Load hat quiet_hours validiert — ein Fehler
// hier ist praktisch unmöglich, darf aber nie still werden.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
}
q := queue.New(queue.Laws{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
QuietMode: cfg.QuietMode,
}, func(ctx context.Context, item queue.Item) error {
return engine.Speak(ctx, speak.Request{
Text: item.Text,
Lang: item.Lang,
Provider: item.Provider,
Voice: item.Voice,
})
}, lw)
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
var mutePath, pidPath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
pidPath = filepath.Join(dir, PIDFileName)
} else {
logger.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State und PID-Datei deaktiviert (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
shutdown := make(chan struct{})
var once sync.Once
handler := api.NewHandler(api.HandlerConfig{
Queue: q,
Mute: mute,
Version: opts.Version,
KnownProviders: provider.Names(),
AllowedOrigins: cfg.Server.AllowedOrigins,
Provider: func() string {
if cur, ok := q.Current(); ok {
return engine.ActiveProvider(cur.Provider)
}
if last := engine.LastProvider(); last != "" {
return last
}
return engine.ActiveProvider("")
},
Shutdown: func() { once.Do(func() { close(shutdown) }) },
})
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
ln, err := net.Listen("tcp", addr)
if err != nil {
q.Close()
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talk daemon?", addr, err)
}
if pidPath != "" {
writePID(pidPath, logger)
// Die PID-Datei verschwindet als Letztes — `talk daemon stop`
// wartet darauf und weiß dann: Prozess ist fertig, nicht nur HTTP.
defer os.Remove(pidPath)
}
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)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog)
select {
case err := <-errCh:
q.Close()
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Printf("Signal empfangen — fahre herunter (laufende Ansage wird abgebrochen)")
case <-shutdown:
logger.Printf("Shutdown angefordert (talk daemon stop) — fahre herunter")
}
// 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 {
logger.Printf("http shutdown: %v", err)
}
q.Close()
return nil
}
// writePID schreibt die eigene Prozess-ID (best effort; ein Fehler ist nur
// eine Protokollzeile — der Daemon funktioniert auch ohne PID-Datei).
func writePID(path string, logger *log.Logger) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
return
}
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
}
}
// ReadPID liest die PID-Datei; 0, wenn keine (lesbare) Datei existiert.
func ReadPID(dir string) int {
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || pid <= 0 {
return 0
}
return pid
}
// lineWriter reicht Zeilen an den Logger weiter (Zeitstempel + Präfix),
// statt sie roh zu schreiben: Provider-Kette, Queue und null-Sink schreiben
// zeilenweise, so entsteht ein einheitliches Protokoll.
type lineWriter struct{ l *log.Logger }
func (w lineWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
if line != "" {
w.l.Print(line)
}
}
return len(p), nil
}
+41
View File
@@ -0,0 +1,41 @@
package daemon
import (
"fmt"
"os"
"os/exec"
)
// Spawn startet `exe daemon run` als eigenständigen Hintergrundprozess
// (eigene Session bzw. eigene Prozessgruppe, losgelöst vom Terminal), mit
// stdout/stderr in die Log-Datei logPath (pro Start neu angelegt; leer =
// verwerfen). env ist die Umgebung des Kindes (Keys, TALK_*-Overrides).
//
// Rückgabe ist ein Kanal, der genau einmal das Wait-Ergebnis liefert, wenn
// das Kind endet — der Aufrufer erkennt daran einen fehlgeschlagenen Start
// (z. B. Port belegt), ohne auf einen Timeout warten zu müssen. Endet der
// Aufrufer zuerst, läuft der Daemon weiter (das ist der Zweck).
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
var logFile *os.File
if logPath == "" {
logPath = os.DevNull
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("Daemon-Log %s: %w", logPath, err)
}
defer logFile.Close() // das Kind hält sein eigenes Handle
cmd := exec.Command(exe, "daemon", "run")
cmd.Env = env
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
detach(cmd)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("Daemon starten (%s daemon run): %w", exe, err)
}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
return exited, nil
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix && !windows
package daemon
import "os/exec"
// detach ist auf dieser Plattform ein No-op (kein Setsid, keine Konsole).
func detach(*exec.Cmd) {}
+14
View File
@@ -0,0 +1,14 @@
//go:build unix
package daemon
import (
"os/exec"
"syscall"
)
// detach löst das Kind vom Terminal: eigene Session (kein SIGHUP beim
// Schließen des Terminals, kein Ctrl-C der Shell).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+20
View File
@@ -0,0 +1,20 @@
//go:build windows
package daemon
import (
"os/exec"
"syscall"
)
// detachedProcess ist das Win32-Flag DETACHED_PROCESS: kein Konsolenfenster,
// keine Bindung an die Konsole des Aufrufers.
const detachedProcess = 0x00000008
// detach löst das Kind von der Konsole: eigene Prozessgruppe (kein Ctrl-C
// der Shell) und keine Konsole (kein aufblitzendes Fenster).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package speak ist der eine Sprech-Kern von talk: Er setzt Config, Audio-
// Sink und Provider-Kette zu einer Ansage zusammen. Der Daemon ruft ihn aus
// dem Queue-Worker, die CLI nur im Notfall ohne Daemon (direkt, ohne
// Warteschlange) — beide über genau diesen Weg, damit es keine zweite
// Zusammensetzung der Kette gibt, die auseinanderlaufen könnte.
package speak
import (
"context"
"io"
"sync"
"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"
)
// NoProvider ist der Provider-Name, den ActiveProvider meldet, wenn keine
// Stufe der Kette verfügbar ist (Status-Anzeige).
const NoProvider = "keine"
// Request ist eine Ansage: Text plus optionale Pins. Leere Pins heißen
// „Default": Sprache wird erkannt, die Kette läuft in Config-Reihenfolge,
// die Stimme kommt aus dem Voice-Mapping.
type Request struct {
Text string
Lang string // de | en; leer = langdetect
Provider string // Provider-Pin; leer = Config-Pin bzw. Default-Kette
Voice string // Voice-ID-Pin; leer = Mapping/Defaults
}
// Engine hält Sink und Konfiguration und baut pro Ansage die Kette (Pin und
// Stimme sind Eigenschaften der einzelnen Anfrage). Sie ist nebenläufig
// nutzbar; der Daemon spricht ohnehin seriell.
type Engine struct {
cfg config.Config
sink audio.Sink
getenv func(string) string
log io.Writer
base *provider.Chain // ungepinnte Kette: Verfügbarkeits-Auflösung für Status
mu sync.Mutex
last string // zuletzt verwendeter Provider (Status im Leerlauf)
}
// New baut den Sprech-Kern: Sink nach cfg.Audio.Sink, Basiskette nach
// Config. getenv liefert Keys und Endpunkt-Overrides (nil = leer), log
// empfängt Ketten- und Sink-Protokoll (nil = still).
func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) {
if getenv == nil {
getenv = func(string) string { return "" }
}
sink, err := audio.NewSink(cfg.Audio.Sink, log)
if err != nil {
return nil, err
}
base, err := provider.NewChain(chainConfig(cfg, "", "", getenv, nil, log))
if err != nil {
return nil, err
}
return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil
}
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird.
func (e *Engine) Speak(ctx context.Context, req Request) error {
lang := req.Lang
if lang == "" {
lang = langdetect.Detect(req.Text)
}
e.setLast(e.ActiveProvider(req.Provider))
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
if err != nil {
return err
}
return chain.Speak(ctx, req.Text, lang)
}
// ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin,
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
// Key-/Binary-Prüfung ohne Netz), sonst NoProvider.
func (e *Engine) ActiveProvider(pin string) string {
if pin != "" {
return pin
}
for _, p := range e.base.Tiers() {
if p.Available(context.Background()) {
return p.Name()
}
}
return NoProvider
}
// LastProvider liefert den Provider der zuletzt gestarteten Ansage; leer,
// wenn noch nichts gesprochen wurde.
func (e *Engine) LastProvider() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.last
}
// SinkName liefert den Namen des Audio-Sinks (oto | null).
func (e *Engine) SinkName() string { return e.sink.Name() }
func (e *Engine) setLast(name string) {
e.mu.Lock()
e.last = name
e.mu.Unlock()
}
// chainConfig überführt Config + Anfrage-Pins in die ChainConfig: Anfrage-
// Pin schlägt den Config-Pin `provider = "…"`; Endpunkte und das OpenAI-
// Modell kommen aus [providers.<name>].
func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig {
if pin == "" {
pin = cfg.Provider
}
endpoints := make(map[string]string, len(cfg.Providers))
for name, settings := range cfg.Providers {
if settings.Endpoint != "" {
endpoints[name] = settings.Endpoint
}
}
return provider.ChainConfig{
Pin: pin,
Voice: voice,
Voices: cfg.Voice,
Endpoints: endpoints,
Model: cfg.Providers["openai"].Model,
Getenv: getenv,
Player: player,
Log: log,
}
}
+215
View File
@@ -0,0 +1,215 @@
package test
// Prozessgrenzen-Tests für das Ein-Binary-Modell: talk startet den Daemon
// beim ersten Sprechbefehl selbst (daemon.autostart), parallele Aufrufe
// landen in EINER Queue, `talk daemon start|stop` steuern ihn explizit, und
// ein nicht startbarer Daemon (Port von einem fremden Dienst belegt) führt
// in den Direkt-Pfad statt ins Schweigen.
import (
"context"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
)
var itemID = regexp.MustCompile(`^[0-9a-f]{6}\n$`)
// autostartEnv ist daemonEnv mit eingeschaltetem Autostart auf einem
// freien Port — und einem Cleanup, das den gespawnten Daemon garantiert
// wieder einsammelt (POST /shutdown; Notbremse: PID-Datei + Kill), damit
// kein Testlauf Hintergrundprozesse hinterlässt.
func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) ([]string, *api.Client) {
t.Helper()
port := freePort(t)
set := map[string]string{"TALK_AUTOSTART": "true"}
for k, v := range extra {
set[k] = v
}
env := daemonEnv(root, port, fakeURL, set)
client := api.NewClient(port)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = client.Shutdown(ctx)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := client.Status(ctx); err != nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if pid := daemon.ReadPID(configDir(root)); pid > 0 {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}
})
return env, client
}
// TestAutostartStartetDaemon (Abnahmekriterium Ein-Binary): ohne laufenden
// Daemon startet `talk say` ihn selbst, meldet das auf stderr, liefert eine
// Item-ID auf stdout, und die Ansage läuft durch Queue und Fake-Provider.
// Danach ist der Daemon da (status), schreibt Log und PID, und `talk daemon
// stop` beendet ihn wieder.
func TestAutostartStartetDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, nil)
res := runTalk(t, env, "say", "erster aufruf startet")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("stdout = %q, want Item-ID (Daemon-Pfad, nicht direkt)", res.stdout)
}
if !strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q, will Autostart-Hinweis", res.stderr)
}
if hits := fake.waitCompleted(t, 1); hits[0].Text != "erster aufruf startet" {
t.Errorf("Treffer = %q", hits[0].Text)
}
// Zweiter Aufruf: Daemon läuft schon — kein zweiter Start.
res = runTalk(t, env, "say", "zweiter aufruf")
if res.code != 0 || strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("zweiter Aufruf: Exit %d, stderr %q — darf nicht erneut starten", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
// Der Daemon ist sichtbar: status, Log- und PID-Datei.
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: Exit %d, stdout %q", res.code, res.stdout)
}
if _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil {
t.Errorf("daemon.log fehlt: %v", err)
}
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
t.Errorf("daemon.pid fehlt oder leer")
}
// stop fährt ihn herunter; danach läuft keiner mehr, PID-Datei weg.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || res.stdout != "daemon: gestoppt\n" {
t.Fatalf("talk daemon stop: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Status(ctx); err == nil {
t.Errorf("Daemon antwortet nach stop noch")
}
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
t.Errorf("daemon.pid nach stop noch da (%d)", pid)
}
// Nochmal stop: idempotent.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("zweites stop: Exit %d, stdout %q", res.code, res.stdout)
}
}
// TestAutostartSerialisiertParallele: zwei gleichzeitige talk-Aufrufe ohne
// Daemon — einer startet ihn, der andere findet ihn (oder verliert das
// Port-Rennen und findet ihn trotzdem). Beide Ansagen laufen durch EINE
// Queue: nacheinander, nicht überlappend.
func TestAutostartSerialisiertParallele(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env, _ := autostartEnv(t, root, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"eins", "zwei"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
results[i] = runTalk(t, env, "say", "--wait", text)
}(i, text)
}
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (parallel, autostart): Exit %d, stderr: %s", i, res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("talk %d stdout = %q, want Item-ID (beide über den Daemon)", i, res.stdout)
}
}
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("Ansagen überlappten: Start(2)=%v < Ende(1)=%v — nicht EINE Queue", hits[1].Start, hits[0].End)
}
}
// TestDaemonStartExplizit: `talk daemon start` startet im Hintergrund und
// ist idempotent; der Port steht in der Meldung.
func TestDaemonStartExplizit(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "false"})
res := runTalk(t, env, "daemon", "start")
if res.code != 0 || res.stdout != "daemon: gestartet unter "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "daemon", "start")
if res.code != 0 || !strings.Contains(res.stdout, "läuft bereits") {
t.Errorf("zweites start: Exit %d, stdout %q", res.code, res.stdout)
}
// Autostart aus, aber Daemon da: say nutzt ihn.
res = runTalk(t, env, "say", "über explizit gestarteten daemon")
if res.code != 0 || !itemID.MatchString(res.stdout) {
t.Errorf("say: Exit %d, stdout %q", res.code, res.stdout)
}
fake.waitCompleted(t, 1)
}
// TestAutostartScheitertSprichtDirekt: Der Port gehört einem fremden Dienst
// (antwortet nicht wie talk) — der gespawnte Daemon kann nicht binden und
// endet sofort; talk erkennt das am Kind-Ende (kein Timeout-Warten) und
// spricht direkt. Der Benutzer sieht den Grund und den Log-Pfad.
func TestAutostartScheitertSprichtDirekt(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(fremd.Close)
port, _ := strconv.Atoi(fremd.URL[strings.LastIndex(fremd.URL, ":")+1:])
env := daemonEnv(root, port, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "true"})
start := time.Now()
res := runTalk(t, env, "say", "port ist fremd belegt")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
}
if d := time.Since(start); d > 4*time.Second {
t.Errorf("Dauer %v — Start-Fehlschlag soll am Kind-Ende erkannt werden, nicht per Timeout", d)
}
if !strings.Contains(res.stderr, "Daemon-Start fehlgeschlagen") || !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Fehlschlag-Grund und Direkt-Hinweis", res.stderr)
}
if !strings.Contains(res.stderr, daemon.LogFileName) {
t.Errorf("stderr = %q, will Log-Pfad-Hinweis", res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "port ist fremd belegt" {
t.Errorf("Treffer = %q", hits[0].Text)
}
logBytes, err := os.ReadFile(configDir(root) + "/" + daemon.LogFileName)
if err != nil || !strings.Contains(string(logBytes), "Port belegen") {
t.Errorf("daemon.log = %q (%v), will Port-belegt-Fehler", logBytes, err)
}
}
+14 -50
View File
@@ -64,68 +64,32 @@ func TestSayWait(t *testing.T) {
waitStatusIdle(t, d, 3*time.Second) waitStatusIdle(t, d, 3*time.Second)
} }
// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot → // TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt // spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
// gegen den Fake-Provider), Lock danach wieder freigegeben. // sagt das auf stderr — nichts bricht still.
func TestSayDegradedMode(t *testing.T) { func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := newFakeProvider(t, 0) fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
res := runTalk(t, env, "say", "offline ansage") res := runTalk(t, env, "say", "offline ansage")
if res.code != 0 { if res.code != 0 {
t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr) t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
} }
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" { if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
t.Errorf("Treffer = %q", hits[0].Text) t.Errorf("Treffer = %q", hits[0].Text)
} }
if !strings.Contains(res.stderr, "Degraded Mode") { if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr) t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
} }
if strings.Contains(res.stderr, "Daemon gestartet") {
// Lock-Datei wurde nach dem Abspielen freigegeben. t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
lockPath := filepath.Join(configDir(root), "talk.lock")
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
} }
} }
// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse // TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
// ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env := daemonEnv(root, 1, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"eins", "zwei"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
results[i] = runTalk(t, env, "say", text)
}(i, text)
}
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
}
}
// Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
// die zweite Synthese startet erst nach der Antwort der ersten.
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
}
}
// TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder). // Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
func TestSayDegradedRespektiertMute(t *testing.T) { func TestSayOhneDaemonRespektiertMute(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := newFakeProvider(t, 0) fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) env := daemonEnv(root, 1, fake.srv.URL, nil)
@@ -279,14 +243,14 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
} }
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare // TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
// Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode. // Fehler (kein Stillversagen) — say spricht dagegen direkt.
func TestCliSteuerbefehleOhneDaemon(t *testing.T) { func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := newFakeProvider(t, 0) fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) env := daemonEnv(root, 1, fake.srv.URL, nil)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") { if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr) t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
} }
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") { if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr) t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
+13 -20
View File
@@ -29,10 +29,7 @@ import (
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es // Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
// billig) und von allen Tests geteilt. // billig) und von allen Tests geteilt.
var ( var talkBin string
talkBin string
talkdBin string
)
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "talk-bins-*") dir, err := os.MkdirTemp("", "talk-bins-*")
@@ -45,19 +42,14 @@ func TestMain(m *testing.M) {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
for _, b := range []struct{ out, pkg string }{ build := exec.Command("go", "build", "-o", filepath.Join(dir, "talk"), "./cmd/talk")
{"talk", "./cmd/talk"}, build.Dir = repo
{"talkd", "./cmd/talkd"}, if out, err := build.CombinedOutput(); err != nil {
} { fmt.Fprintf(os.Stderr, "go build ./cmd/talk: %v\n%s", err, out)
cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out)
os.RemoveAll(dir) os.RemoveAll(dir)
os.Exit(1) os.Exit(1)
} }
} talkBin = filepath.Join(dir, "talk")
talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
code := m.Run() code := m.Run()
os.RemoveAll(dir) os.RemoveAll(dir)
@@ -254,19 +246,19 @@ type daemonProc struct {
baseURL string baseURL string
} }
// startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT, // startDaemon startet `talk daemon run` als echten Prozess (freier Port per
// nie der Default-Port) und wartet, bis GET /status antwortet. Stop per // TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
// t.Cleanup: SIGINT, nach Frist Kill. // t.Cleanup: SIGINT, nach Frist Kill.
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc { func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
t.Helper() t.Helper()
port := freePort(t) port := freePort(t)
env := daemonEnv(root, port, fakeURL, extra) env := daemonEnv(root, port, fakeURL, extra)
stderr := &syncBuffer{} stderr := &syncBuffer{}
cmd := exec.Command(talkdBin) cmd := exec.Command(talkBin, "daemon", "run")
cmd.Env = env cmd.Env = env
cmd.Stderr = stderr cmd.Stderr = stderr
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
t.Fatalf("talkd starten: %v", err) t.Fatalf("talk daemon run starten: %v", err)
} }
d := &daemonProc{ d := &daemonProc{
cmd: cmd, cmd: cmd,
@@ -288,7 +280,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
} }
time.Sleep(25 * time.Millisecond) time.Sleep(25 * time.Millisecond)
} }
t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String()) t.Fatalf("talk daemon antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
return nil return nil
} }
@@ -310,7 +302,7 @@ func (d *daemonProc) stop(t *testing.T) {
} }
} }
// daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse: // daemonEnv baut die Prozess-Umgebung für talk-Prozesse (CLI und Daemon):
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/ // geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt // Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als // damit unter <root>/talk), audio.sink=null und der Fake-Provider als
@@ -339,6 +331,7 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
"TALK_PORT": strconv.Itoa(port), "TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben) "TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
"TALK_AUTOSTART": "false", // kein Test darf unbemerkt einen Daemon hinterlassen; autostart_test.go schaltet es bewusst an
} }
for k, v := range extra { for k, v := range extra {
set[k] = v set[k] = v
+2 -2
View File
@@ -68,10 +68,10 @@ func TestMutePersistenzUeberRestart(t *testing.T) {
} }
} }
// TestDegradedMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne // TestOfflineMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen // Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
// dieselbe Quelle. // dieselbe Quelle.
func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) { func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := newFakeProvider(t, 0) fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) d := startDaemon(t, root, fake.srv.URL, nil)
+6 -6
View File
@@ -147,22 +147,22 @@ func TestWrapWait(t *testing.T) {
waitStatusIdle(t, d, 3*time.Second) waitStatusIdle(t, d, 3*time.Second)
} }
// TestWrapDegradedMode: Daemon tot → Ansage im Degraded Mode (Lock-Datei, // TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
// direkt gegen den Fake), und der Exit-Code des Befehls bleibt trotzdem. // Fake, und der Exit-Code des Befehls bleibt trotzdem.
func TestWrapDegradedMode(t *testing.T) { func TestWrapOhneDaemon(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := newFakeProvider(t, 0) fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3") res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
if res.code != 3 { if res.code != 3 {
t.Fatalf("talk wrap (Degraded): Exit %d, want 3 (Befehls-Code gewinnt)", res.code) t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
} }
hits := fake.waitCompleted(t, 1) hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 3." { if hits[0].Text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.") t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
} }
if !strings.Contains(res.stderr, "Degraded Mode") { if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr) t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
} }
} }