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:
1 parent
b86632006e
commit
bc26a73aeb
29 files changed
+1145
-584
No files matched your search
@@ -1,8 +1,6 @@
|
||||
# Go-Binaries
|
||||
/talk
|
||||
/talkd
|
||||
/talk.exe
|
||||
/talkd.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
|
||||
spricht eine kurze Nachricht über eine TTS-Provider-Kette
|
||||
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
|
||||
`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
|
||||
keine Runtime-Abhängigkeiten.
|
||||
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen
|
||||
Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein
|
||||
statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine
|
||||
Runtime-Abhängigkeiten.
|
||||
|
||||
## Befehle
|
||||
|
||||
@@ -20,6 +21,7 @@ keine Runtime-Abhängigkeiten.
|
||||
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
|
||||
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
||||
| `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 test` | Test-Ansage ausgeben |
|
||||
| `talk doctor` | Selbstdiagnose des Setups |
|
||||
@@ -29,17 +31,37 @@ Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
||||
`--voice`, `--lang`, `--provider`.
|
||||
|
||||
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
|
||||
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über
|
||||
`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`.
|
||||
Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code
|
||||
≠ 0).
|
||||
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking,
|
||||
Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon`
|
||||
sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented
|
||||
yet` (Exit-Code ≠ 0).
|
||||
|
||||
## Daemon talkd (Task 04–06)
|
||||
## Daemon
|
||||
|
||||
`talkd` besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage
|
||||
gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (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).
|
||||
Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code,
|
||||
im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau
|
||||
eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low`
|
||||
(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 |
|
||||
| --- | --- |
|
||||
@@ -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]}` |
|
||||
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
|
||||
| `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) |
|
||||
| `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
|
||||
`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
|
||||
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
|
||||
der Zustand greift also auch offline.
|
||||
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und
|
||||
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im
|
||||
Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als
|
||||
verfallen). Mute wird auch hier respektiert. Nichts bricht still.
|
||||
- **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar
|
||||
(Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk`
|
||||
direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele
|
||||
Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird
|
||||
auch hier respektiert. Nichts bricht still.
|
||||
|
||||
### 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
|
||||
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
|
||||
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
|
||||
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
|
||||
`/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),
|
||||
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
|
||||
Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
|
||||
- **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking;
|
||||
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
|
||||
- **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking;
|
||||
`--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad.
|
||||
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
|
||||
`--lang`, `--provider`) und reicht sie an die Ansage durch.
|
||||
|
||||
|
||||
+8
-4
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
||||
// über errors.As(&HTTPError)).
|
||||
|
||||
@@ -38,6 +38,11 @@ type HandlerConfig struct {
|
||||
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
||||
// keine CORS-Header, der Browser blockt; siehe cors.go).
|
||||
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
|
||||
@@ -52,6 +57,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||
provider: cfg.Provider,
|
||||
allowedOrigins: cfg.AllowedOrigins,
|
||||
shutdown: cfg.Shutdown,
|
||||
}
|
||||
for _, name := range cfg.KnownProviders {
|
||||
h.known[name] = true
|
||||
@@ -63,6 +69,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
mux.HandleFunc("POST /cancel", h.cancel)
|
||||
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||
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("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
||||
return h.originGuard(mux)
|
||||
@@ -76,6 +83,7 @@ type handler struct {
|
||||
known map[string]bool
|
||||
provider func() string
|
||||
allowedOrigins []string
|
||||
shutdown func()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -131,3 +131,8 @@ type CancelResponse struct {
|
||||
type MuteResponse struct {
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
||||
// ShutdownResponse ist die Antwort von POST /shutdown.
|
||||
type ShutdownResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
@@ -44,6 +44,9 @@ type Config struct {
|
||||
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
|
||||
Queue Queue `toml:"queue"`
|
||||
|
||||
// Daemon steuert den Hintergrundprozess (Autostart).
|
||||
Daemon Daemon `toml:"daemon"`
|
||||
|
||||
// Provider pinnt die Kette auf genau einen Provider (z. B.
|
||||
// `provider = "elevenlabs"`); leer = komplette Default-Kette
|
||||
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
|
||||
@@ -99,6 +102,15 @@ type Server struct {
|
||||
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]`).
|
||||
type Wrap struct {
|
||||
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
|
||||
@@ -137,6 +149,7 @@ func Defaults() Config {
|
||||
Audio: Audio{Sink: SinkOto},
|
||||
Server: Server{Port: DefaultServerPort},
|
||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
||||
Daemon: Daemon{Autostart: true},
|
||||
QuietMode: queue.QuietModeDiscard,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +223,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||
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 != "" {
|
||||
cfg.Wrap.Label = v
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -64,68 +64,32 @@ func TestSayWait(t *testing.T) {
|
||||
waitStatusIdle(t, d, 3*time.Second)
|
||||
}
|
||||
|
||||
// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
|
||||
// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
|
||||
// gegen den Fake-Provider), Lock danach wieder freigegeben.
|
||||
func TestSayDegradedMode(t *testing.T) {
|
||||
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
|
||||
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
|
||||
// sagt das auf stderr — nichts bricht still.
|
||||
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||||
|
||||
res := runTalk(t, env, "say", "offline ansage")
|
||||
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" {
|
||||
t.Errorf("Treffer = %q", hits[0].Text)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "Degraded Mode") {
|
||||
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
||||
if !strings.Contains(res.stderr, "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
||||
}
|
||||
|
||||
// Lock-Datei wurde nach dem Abspielen freigegeben.
|
||||
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)
|
||||
if strings.Contains(res.stderr, "Daemon gestartet") {
|
||||
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
|
||||
// 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
|
||||
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
|
||||
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
||||
func TestSayDegradedRespektiertMute(t *testing.T) {
|
||||
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||
@@ -279,14 +243,14 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||
|
||||
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
|
||||
t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
|
||||
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 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") {
|
||||
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
|
||||
|
||||
+16
-23
@@ -29,10 +29,7 @@ import (
|
||||
|
||||
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
|
||||
// billig) und von allen Tests geteilt.
|
||||
var (
|
||||
talkBin string
|
||||
talkdBin string
|
||||
)
|
||||
var talkBin string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "talk-bins-*")
|
||||
@@ -45,19 +42,14 @@ func TestMain(m *testing.M) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, b := range []struct{ out, pkg string }{
|
||||
{"talk", "./cmd/talk"},
|
||||
{"talkd", "./cmd/talkd"},
|
||||
} {
|
||||
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.Exit(1)
|
||||
}
|
||||
build := exec.Command("go", "build", "-o", filepath.Join(dir, "talk"), "./cmd/talk")
|
||||
build.Dir = repo
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "go build ./cmd/talk: %v\n%s", err, out)
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
|
||||
talkBin = filepath.Join(dir, "talk")
|
||||
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
@@ -254,19 +246,19 @@ type daemonProc struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT,
|
||||
// nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
|
||||
// startDaemon startet `talk daemon run` als echten Prozess (freier Port per
|
||||
// TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
|
||||
// t.Cleanup: SIGINT, nach Frist Kill.
|
||||
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
|
||||
t.Helper()
|
||||
port := freePort(t)
|
||||
env := daemonEnv(root, port, fakeURL, extra)
|
||||
stderr := &syncBuffer{}
|
||||
cmd := exec.Command(talkdBin)
|
||||
cmd := exec.Command(talkBin, "daemon", "run")
|
||||
cmd.Env = env
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("talkd starten: %v", err)
|
||||
t.Fatalf("talk daemon run starten: %v", err)
|
||||
}
|
||||
d := &daemonProc{
|
||||
cmd: cmd,
|
||||
@@ -288,7 +280,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
|
||||
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
|
||||
@@ -338,7 +330,8 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
|
||||
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
|
||||
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
|
||||
"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 {
|
||||
set[k] = v
|
||||
|
||||
+2
-2
@@ -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
|
||||
// dieselbe Quelle.
|
||||
func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) {
|
||||
func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
d := startDaemon(t, root, fake.srv.URL, nil)
|
||||
|
||||
+6
-6
@@ -147,22 +147,22 @@ func TestWrapWait(t *testing.T) {
|
||||
waitStatusIdle(t, d, 3*time.Second)
|
||||
}
|
||||
|
||||
// TestWrapDegradedMode: Daemon tot → Ansage im Degraded Mode (Lock-Datei,
|
||||
// direkt gegen den Fake), und der Exit-Code des Befehls bleibt trotzdem.
|
||||
func TestWrapDegradedMode(t *testing.T) {
|
||||
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
|
||||
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
|
||||
func TestWrapOhneDaemon(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||||
|
||||
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 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)
|
||||
if 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") {
|
||||
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
||||
if !strings.Contains(res.stderr, "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user