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
+1144
-583
No files matched your search
@@ -1,8 +1,6 @@
|
|||||||
# Go-Binaries
|
# Go-Binaries
|
||||||
/talk
|
/talk
|
||||||
/talkd
|
|
||||||
/talk.exe
|
/talk.exe
|
||||||
/talkd.exe
|
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
|
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
|
||||||
spricht eine kurze Nachricht über eine TTS-Provider-Kette
|
spricht eine kurze Nachricht über eine TTS-Provider-Kette
|
||||||
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
|
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen
|
||||||
`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
|
Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein
|
||||||
keine Runtime-Abhängigkeiten.
|
statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine
|
||||||
|
Runtime-Abhängigkeiten.
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ keine Runtime-Abhängigkeiten.
|
|||||||
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
|
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
|
||||||
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
||||||
| `talk cancel` | laufende und geplante Ansagen abbrechen |
|
| `talk cancel` | laufende und geplante Ansagen abbrechen |
|
||||||
|
| `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) |
|
||||||
| `talk voices` | verfügbare Stimmen anzeigen |
|
| `talk voices` | verfügbare Stimmen anzeigen |
|
||||||
| `talk test` | Test-Ansage ausgeben |
|
| `talk test` | Test-Ansage ausgeben |
|
||||||
| `talk doctor` | Selbstdiagnose des Setups |
|
| `talk doctor` | Selbstdiagnose des Setups |
|
||||||
@@ -29,17 +31,37 @@ Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
|||||||
`--voice`, `--lang`, `--provider`.
|
`--voice`, `--lang`, `--provider`.
|
||||||
|
|
||||||
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
|
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
|
||||||
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über
|
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking,
|
||||||
`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`.
|
Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon`
|
||||||
Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code
|
sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented
|
||||||
≠ 0).
|
yet` (Exit-Code ≠ 0).
|
||||||
|
|
||||||
## Daemon talkd (Task 04–06)
|
## Daemon
|
||||||
|
|
||||||
`talkd` besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage
|
Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code,
|
||||||
gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (FIFO je
|
im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau
|
||||||
Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default `7800`,
|
eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low`
|
||||||
config `[server] port`, Env `TALK_PORT` (schlagen den Default).
|
(FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default
|
||||||
|
`7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default).
|
||||||
|
|
||||||
|
**Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn
|
||||||
|
beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in
|
||||||
|
`daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet
|
||||||
|
einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet
|
||||||
|
nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart.
|
||||||
|
Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen,
|
||||||
|
benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet
|
||||||
|
(systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den
|
||||||
|
Autostart ab:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[daemon]
|
||||||
|
autostart = false # Env TALK_AUTOSTART=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon
|
||||||
|
bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute`
|
||||||
|
schreibt die Mute-Datei direkt.
|
||||||
|
|
||||||
| Endpunkt | Wirkung |
|
| Endpunkt | Wirkung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -48,8 +70,9 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
|
|||||||
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` |
|
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` |
|
||||||
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
|
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
|
||||||
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
|
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
|
||||||
|
| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` |
|
||||||
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
|
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
|
||||||
| `GET /mcp` | `405` — talkd bietet keine server-initiierten Streams an |
|
| `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an |
|
||||||
|
|
||||||
- **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via
|
- **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via
|
||||||
`GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking,
|
`GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking,
|
||||||
@@ -61,10 +84,11 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
|
|||||||
`false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon
|
`false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon
|
||||||
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
|
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
|
||||||
der Zustand greift also auch offline.
|
der Zustand greift also auch offline.
|
||||||
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und
|
- **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar
|
||||||
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im
|
(Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk`
|
||||||
Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als
|
direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele
|
||||||
verfallen). Mute wird auch hier respektiert. Nichts bricht still.
|
Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird
|
||||||
|
auch hier respektiert. Nichts bricht still.
|
||||||
|
|
||||||
### Rate-Limit + Ruhezeiten (Task 05)
|
### Rate-Limit + Ruhezeiten (Task 05)
|
||||||
|
|
||||||
@@ -129,7 +153,7 @@ allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
|
|||||||
- **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein
|
- **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein
|
||||||
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
|
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
|
||||||
oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb
|
oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb
|
||||||
lehnt talkd jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
|
lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
|
||||||
nicht POST /speak von einer gelisteten Origin ist — also ungelistete
|
nicht POST /speak von einer gelisteten Origin ist — also ungelistete
|
||||||
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
|
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
|
||||||
`/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der
|
`/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der
|
||||||
@@ -261,8 +285,8 @@ talk wrap --wait -- make test
|
|||||||
Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
|
Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
|
||||||
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
|
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
|
||||||
Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
|
Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
|
||||||
- **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking;
|
- **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking;
|
||||||
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
|
`--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad.
|
||||||
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
|
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
|
||||||
`--lang`, `--provider`) und reicht sie an die Ansage durch.
|
`--lang`, `--provider`) und reicht sie an die Ansage durch.
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -67,6 +67,13 @@ var commands = []command{
|
|||||||
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
|
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
|
||||||
handler: runStatus,
|
handler: runStatus,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "daemon",
|
||||||
|
short: "Hintergrundprozess steuern (run | start | stop)",
|
||||||
|
args: "[run|start|stop]",
|
||||||
|
detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).",
|
||||||
|
handler: runDaemon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "cancel",
|
name: "cancel",
|
||||||
short: "Laufende und geplante Ansagen abbrechen",
|
short: "Laufende und geplante Ansagen abbrechen",
|
||||||
@@ -286,10 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||||
// Commit-Hash in Klammern, falls per ldflags injiziert.
|
// Commit-Hash in Klammern, falls per ldflags injiziert.
|
||||||
func versionString() string {
|
func versionString() string {
|
||||||
if commit == "" {
|
return "talk version " + buildVersion()
|
||||||
return fmt.Sprintf("talk version %s", version)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("talk version %s (%s)", version, commit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
||||||
|
|||||||
+8
-5
@@ -19,7 +19,9 @@ import (
|
|||||||
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
|
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
|
||||||
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
|
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
|
||||||
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
|
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
|
||||||
// Zustand greift also auch offline und überlebt Daemon-Restarts.
|
// Zustand greift also auch offline und überlebt Daemon-Restarts. Die
|
||||||
|
// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das):
|
||||||
|
// Stummschalten braucht keinen laufenden Prozess.
|
||||||
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
return setMute(true, stdout, stderr)
|
return setMute(true, stdout, stderr)
|
||||||
}
|
}
|
||||||
@@ -72,9 +74,9 @@ func printMuteState(muted bool, stdout io.Writer) {
|
|||||||
|
|
||||||
// runStatus implementiert `talk status`: plain Block aus GET /status —
|
// runStatus implementiert `talk status`: plain Block aus GET /status —
|
||||||
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
|
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
|
||||||
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon:
|
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
|
||||||
// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode
|
// sagt status genau das (Exit 1) und startet ihn nicht — status soll die
|
||||||
// hat keinen Status).
|
// Wirklichkeit zeigen, nicht verändern.
|
||||||
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +88,8 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
st, err := client.Status(ctx)
|
st, err := client.Status(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL)
|
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
|
||||||
|
fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)")
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
// Prozessgrenzen-Tests in /test.
|
// Prozessgrenzen-Tests in /test.
|
||||||
|
|
||||||
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
|
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
|
||||||
// mit Hinweis (kein Stillversagen).
|
// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem
|
||||||
|
// als Status-Zeile, damit der Block nie leer bleibt. Status startet den
|
||||||
|
// Daemon nicht.
|
||||||
func TestStatusOhneDaemon(t *testing.T) {
|
func TestStatusOhneDaemon(t *testing.T) {
|
||||||
setGetenv(t, nil)
|
setGetenv(t, nil)
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
@@ -24,11 +26,11 @@ func TestStatusOhneDaemon(t *testing.T) {
|
|||||||
if code != exitFailure {
|
if code != exitFailure {
|
||||||
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||||||
}
|
}
|
||||||
if !strings.Contains(errBuf.String(), "nicht erreichbar") {
|
if !strings.Contains(errBuf.String(), "kein Daemon") {
|
||||||
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
|
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
|
||||||
}
|
}
|
||||||
if out.Len() != 0 {
|
if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
|
||||||
t.Errorf("stdout = %q, will leer", out.String())
|
t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ func TestSayOhneConfigDir(t *testing.T) {
|
|||||||
if _, err := os.Stat(seen); err != nil {
|
if _, err := os.Stat(seen); err != nil {
|
||||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(errBuf.String(), "Degraded Mode") {
|
if !strings.Contains(errBuf.String(), "spreche direkt") {
|
||||||
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
|
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+98
-20
@@ -5,10 +5,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,31 +22,106 @@ import (
|
|||||||
const (
|
const (
|
||||||
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
|
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
|
||||||
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
|
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
|
||||||
|
daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten
|
||||||
|
daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz
|
||||||
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
|
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
|
||||||
)
|
)
|
||||||
|
|
||||||
// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
|
// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
|
||||||
// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
|
// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
|
||||||
// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder
|
// mit anderem Status — zählt als „kein Daemon".
|
||||||
// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
|
func daemonAlive(client *api.Client, timeout time.Duration) bool {
|
||||||
// info sagt „muted". info ist der Writer für Informations-Token (Item-ID,
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines
|
defer cancel()
|
||||||
// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden.
|
_, err := client.Status(ctx)
|
||||||
//
|
return err == nil
|
||||||
// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
|
|
||||||
// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
|
|
||||||
// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
|
|
||||||
// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
|
|
||||||
// angekommen und wurde bewusst abgelehnt.
|
|
||||||
func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
|
||||||
client := api.NewClient(cfg.Server.Port)
|
|
||||||
|
|
||||||
probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
|
|
||||||
defer cancelProbe()
|
|
||||||
if _, err := client.Status(probeCtx); err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet
|
||||||
|
// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt:
|
||||||
|
// kein Daemon erreichbar und keiner startbar (Autostart aus, Start
|
||||||
|
// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf
|
||||||
|
// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir.
|
||||||
|
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
|
||||||
|
client = api.NewClient(cfg.Server.Port)
|
||||||
|
if daemonAlive(client, daemonProbeTimeout) {
|
||||||
|
return client, true
|
||||||
|
}
|
||||||
|
if !cfg.Daemon.Autostart {
|
||||||
|
return client, false
|
||||||
|
}
|
||||||
|
return client, startDaemon(client, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit
|
||||||
|
// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind
|
||||||
|
// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller
|
||||||
|
// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso.
|
||||||
|
func startDaemon(client *api.Client, stderr io.Writer) bool {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logPath := ""
|
||||||
|
if dir, err := config.Dir(getenv); err == nil {
|
||||||
|
logPath = filepath.Join(dir, daemon.LogFileName)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
logPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exited, err := daemon.Spawn(exe, logPath, os.Environ())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logHint := ""
|
||||||
|
if logPath != "" {
|
||||||
|
logHint = " (Log: " + logPath + ")"
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(daemonStartTimeout)
|
||||||
|
for {
|
||||||
|
if daemonAlive(client, daemonStartPoll*4) {
|
||||||
|
fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-exited:
|
||||||
|
if daemonAlive(client, daemonProbeTimeout) {
|
||||||
|
return true // ein anderer Aufruf hat den Daemon gestartet
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint)
|
||||||
|
return false
|
||||||
|
case <-time.After(daemonStartPoll):
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar.
|
||||||
|
func exitReason(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "Prozess hat sich sofort beendet"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default:
|
||||||
|
// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt
|
||||||
|
// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute
|
||||||
|
// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den
|
||||||
|
// Grund. info ist der Writer für diese Informations-Token: say reicht
|
||||||
|
// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um
|
||||||
|
// talk-Zeilen verunreinigt werden).
|
||||||
|
//
|
||||||
|
// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf
|
||||||
|
// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit
|
||||||
|
// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim
|
||||||
|
// Daemon angekommen und wurde bewusst abgelehnt.
|
||||||
|
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
resp, err := client.Speak(ctx, api.SpeakRequest{
|
resp, err := client.Speak(ctx, api.SpeakRequest{
|
||||||
|
|||||||
@@ -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
|
// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
|
||||||
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur
|
// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
|
||||||
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded
|
// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
|
||||||
// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/
|
// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
|
||||||
// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- <Befehl>`
|
// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
|
||||||
// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code
|
// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
|
||||||
// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die
|
// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
|
||||||
// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den
|
// `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
|
||||||
// Tasks 08–09.
|
// übrigen Befehle sind Stubs.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
||||||
//
|
//
|
||||||
@@ -23,6 +26,15 @@ var (
|
|||||||
commit = ""
|
commit = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert
|
||||||
|
// — der Wert, den der Daemon in /status und MCP serverInfo meldet.
|
||||||
|
func buildVersion() string {
|
||||||
|
if commit == "" {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", version, commit)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||||
}
|
}
|
||||||
+52
-33
@@ -1,13 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
|
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
|
||||||
@@ -27,13 +30,11 @@ type sayOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
||||||
// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd
|
// und validieren, dann über den Daemon sprechen — non-blocking als
|
||||||
// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking
|
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
|
||||||
// als Default, --wait pollt bis zur Fertigstellung, --now springt vor,
|
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
|
||||||
// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein
|
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
|
||||||
// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal,
|
// Warteschlange, aber nie still.
|
||||||
// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden
|
|
||||||
// Pfarden dieselbe (internal/provider, unverändert).
|
|
||||||
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
text := strings.Join(args, " ")
|
text := strings.Join(args, " ")
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@@ -64,13 +65,52 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
|
|||||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
return announce(cfg, text, opts, stdout, stderr)
|
||||||
if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled {
|
|
||||||
return code
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
|
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
|
||||||
return sayDegraded(cfg, text, opts, stdout, stderr)
|
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
|
||||||
|
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
|
||||||
|
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
||||||
|
if client, ok := ensureDaemon(cfg, stderr); ok {
|
||||||
|
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
|
||||||
|
return sayDirect(cfg, text, opts, info, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
|
||||||
|
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
|
||||||
|
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
|
||||||
|
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
|
||||||
|
// es ohne Mute weiter, statt zu scheitern.
|
||||||
|
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
||||||
|
if dir, err := config.Dir(getenv); err == nil {
|
||||||
|
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
|
||||||
|
if mute.Muted() {
|
||||||
|
fmt.Fprintln(info, "muted")
|
||||||
|
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine, err := speak.New(cfg, getenv, stderr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
|
return exitFailure
|
||||||
|
}
|
||||||
|
err = engine.Speak(context.Background(), speak.Request{
|
||||||
|
Text: text,
|
||||||
|
Lang: opts.lang,
|
||||||
|
Provider: opts.provider,
|
||||||
|
Voice: opts.voice,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
|
return exitFailure
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
|
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
|
||||||
@@ -88,27 +128,6 @@ func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
|
|
||||||
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
|
|
||||||
func pickProvider(flagValue, cfgValue string) string {
|
|
||||||
if flagValue != "" {
|
|
||||||
return flagValue
|
|
||||||
}
|
|
||||||
return cfgValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// endpointsOf überführt die Provider-Einstellungen der Config in die
|
|
||||||
// Endpunkt-Map der Kette.
|
|
||||||
func endpointsOf(cfg config.Config) map[string]string {
|
|
||||||
endpoints := make(map[string]string, len(cfg.Providers))
|
|
||||||
for name, settings := range cfg.Providers {
|
|
||||||
if settings.Endpoint != "" {
|
|
||||||
endpoints[name] = settings.Endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return endpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
||||||
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
|
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
|
||||||
func isCharDevice(r io.Reader) bool {
|
func isCharDevice(r io.Reader) bool {
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ import (
|
|||||||
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
||||||
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
|
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
|
||||||
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
|
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
|
||||||
// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd
|
// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
|
||||||
// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt
|
// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary
|
||||||
|
// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt
|
||||||
// den Key.
|
// den Key.
|
||||||
func setGetenv(t *testing.T, kv map[string]string) {
|
func setGetenv(t *testing.T, kv map[string]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
base := map[string]string{
|
base := map[string]string{
|
||||||
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
|
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
|
||||||
"TALK_PORT": "1",
|
"TALK_PORT": "1",
|
||||||
|
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
|
||||||
}
|
}
|
||||||
for k, v := range kv {
|
for k, v := range kv {
|
||||||
base[k] = v
|
base[k] = v
|
||||||
|
|||||||
+5
-16
@@ -22,8 +22,8 @@ const exitNotFound = 127
|
|||||||
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
|
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
|
||||||
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
|
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
|
||||||
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
|
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
|
||||||
// Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue,
|
// Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
|
||||||
// non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt
|
// blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
|
||||||
// die say-Flags und reicht sie an die Ansage durch.
|
// die say-Flags und reicht sie an die Ansage durch.
|
||||||
//
|
//
|
||||||
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
|
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
|
||||||
@@ -71,9 +71,8 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
|||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say:
|
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
|
||||||
// Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon
|
// (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
|
||||||
// Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
|
|
||||||
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
|
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
|
||||||
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
|
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
|
||||||
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
|
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
|
||||||
@@ -81,19 +80,9 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
|||||||
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
|
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
|
||||||
// werden.
|
// werden.
|
||||||
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
|
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
|
||||||
warn := func() {
|
if announce(cfg, text, opts, stderr, stderr) != exitOK {
|
||||||
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
|
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
|
||||||
}
|
}
|
||||||
if code, handled := sayViaDaemon(cfg, text, opts, stderr, stderr); handled {
|
|
||||||
if code != 0 {
|
|
||||||
warn()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
|
|
||||||
if sayDegraded(cfg, text, opts, stderr, stderr) != exitOK {
|
|
||||||
warn()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// execChild startet argv als Kindprozess und wartet auf sein Ende.
|
// execChild startet argv als Kindprozess und wartet auf sein Ende.
|
||||||
|
|||||||
@@ -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)
|
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown ruft POST /shutdown (`talk daemon stop`).
|
||||||
|
func (c *Client) Shutdown(ctx context.Context) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
|
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
|
||||||
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
||||||
// über errors.As(&HTTPError)).
|
// über errors.As(&HTTPError)).
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type HandlerConfig struct {
|
|||||||
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
||||||
// keine CORS-Header, der Browser blockt; siehe cors.go).
|
// keine CORS-Header, der Browser blockt; siehe cors.go).
|
||||||
AllowedOrigins []string
|
AllowedOrigins []string
|
||||||
|
|
||||||
|
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
|
||||||
|
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
|
||||||
|
// nil → der Endpunkt antwortet 501.
|
||||||
|
Shutdown func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||||||
@@ -52,6 +57,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
|||||||
known: make(map[string]bool, len(cfg.KnownProviders)),
|
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||||
provider: cfg.Provider,
|
provider: cfg.Provider,
|
||||||
allowedOrigins: cfg.AllowedOrigins,
|
allowedOrigins: cfg.AllowedOrigins,
|
||||||
|
shutdown: cfg.Shutdown,
|
||||||
}
|
}
|
||||||
for _, name := range cfg.KnownProviders {
|
for _, name := range cfg.KnownProviders {
|
||||||
h.known[name] = true
|
h.known[name] = true
|
||||||
@@ -63,6 +69,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
|||||||
mux.HandleFunc("POST /cancel", h.cancel)
|
mux.HandleFunc("POST /cancel", h.cancel)
|
||||||
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||||
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||||
|
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
|
||||||
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
||||||
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
||||||
return h.originGuard(mux)
|
return h.originGuard(mux)
|
||||||
@@ -76,6 +83,7 @@ type handler struct {
|
|||||||
known map[string]bool
|
known map[string]bool
|
||||||
provider func() string
|
provider func() string
|
||||||
allowedOrigins []string
|
allowedOrigins []string
|
||||||
|
shutdown func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
||||||
@@ -241,6 +249,18 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
|
||||||
|
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
|
||||||
|
// der Server die Verbindung schließt.
|
||||||
|
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.shutdown == nil {
|
||||||
|
writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
|
||||||
|
h.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
||||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -131,3 +131,8 @@ type CancelResponse struct {
|
|||||||
type MuteResponse struct {
|
type MuteResponse struct {
|
||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShutdownResponse ist die Antwort von POST /shutdown.
|
||||||
|
type ShutdownResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ type Config struct {
|
|||||||
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
|
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
|
||||||
Queue Queue `toml:"queue"`
|
Queue Queue `toml:"queue"`
|
||||||
|
|
||||||
|
// Daemon steuert den Hintergrundprozess (Autostart).
|
||||||
|
Daemon Daemon `toml:"daemon"`
|
||||||
|
|
||||||
// Provider pinnt die Kette auf genau einen Provider (z. B.
|
// Provider pinnt die Kette auf genau einen Provider (z. B.
|
||||||
// `provider = "elevenlabs"`); leer = komplette Default-Kette
|
// `provider = "elevenlabs"`); leer = komplette Default-Kette
|
||||||
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
|
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
|
||||||
@@ -99,6 +102,15 @@ type Server struct {
|
|||||||
AllowedOrigins []string `toml:"allowed_origins"`
|
AllowedOrigins []string `toml:"allowed_origins"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
|
||||||
|
type Daemon struct {
|
||||||
|
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
|
||||||
|
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
|
||||||
|
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
|
||||||
|
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
|
||||||
|
Autostart bool `toml:"autostart"`
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
|
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
|
||||||
type Wrap struct {
|
type Wrap struct {
|
||||||
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
|
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
|
||||||
@@ -137,6 +149,7 @@ func Defaults() Config {
|
|||||||
Audio: Audio{Sink: SinkOto},
|
Audio: Audio{Sink: SinkOto},
|
||||||
Server: Server{Port: DefaultServerPort},
|
Server: Server{Port: DefaultServerPort},
|
||||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
||||||
|
Daemon: Daemon{Autostart: true},
|
||||||
QuietMode: queue.QuietModeDiscard,
|
QuietMode: queue.QuietModeDiscard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +223,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
|||||||
cfg.Server.AllowedOrigins = origins
|
cfg.Server.AllowedOrigins = origins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v := getenv("TALK_AUTOSTART"); v != "" {
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
|
||||||
|
}
|
||||||
|
cfg.Daemon.Autostart = b
|
||||||
|
}
|
||||||
if v := getenv("TALK_WRAP_LABEL"); v != "" {
|
if v := getenv("TALK_WRAP_LABEL"); v != "" {
|
||||||
cfg.Wrap.Label = v
|
cfg.Wrap.Label = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
waitStatusIdle(t, d, 3*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
|
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
|
||||||
// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
|
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
|
||||||
// gegen den Fake-Provider), Lock danach wieder freigegeben.
|
// sagt das auf stderr — nichts bricht still.
|
||||||
func TestSayDegradedMode(t *testing.T) {
|
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
fake := newFakeProvider(t, 0)
|
fake := newFakeProvider(t, 0)
|
||||||
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||||||
|
|
||||||
res := runTalk(t, env, "say", "offline ansage")
|
res := runTalk(t, env, "say", "offline ansage")
|
||||||
if res.code != 0 {
|
if res.code != 0 {
|
||||||
t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr)
|
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
|
||||||
}
|
}
|
||||||
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
|
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
|
||||||
t.Errorf("Treffer = %q", hits[0].Text)
|
t.Errorf("Treffer = %q", hits[0].Text)
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.stderr, "Degraded Mode") {
|
if !strings.Contains(res.stderr, "spreche direkt") {
|
||||||
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(res.stderr, "Daemon gestartet") {
|
||||||
// Lock-Datei wurde nach dem Abspielen freigegeben.
|
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
|
||||||
lockPath := filepath.Join(configDir(root), "talk.lock")
|
|
||||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
|
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
|
||||||
// ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
|
|
||||||
func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
fake := newFakeProvider(t, 250*time.Millisecond)
|
|
||||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
results := make([]talkResult, 2)
|
|
||||||
for i, text := range []string{"eins", "zwei"} {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int, text string) {
|
|
||||||
defer wg.Done()
|
|
||||||
results[i] = runTalk(t, env, "say", text)
|
|
||||||
}(i, text)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for i, res := range results {
|
|
||||||
if res.code != 0 {
|
|
||||||
t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
|
|
||||||
// die zweite Synthese startet erst nach der Antwort der ersten.
|
|
||||||
hits := fake.waitHits(t, 2)
|
|
||||||
const slack = 25 * time.Millisecond
|
|
||||||
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
|
|
||||||
t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
|
|
||||||
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
||||||
func TestSayDegradedRespektiertMute(t *testing.T) {
|
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
fake := newFakeProvider(t, 0)
|
fake := newFakeProvider(t, 0)
|
||||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||||
@@ -279,14 +243,14 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
|
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
|
||||||
// Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode.
|
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
|
||||||
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
|
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
fake := newFakeProvider(t, 0)
|
fake := newFakeProvider(t, 0)
|
||||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||||
|
|
||||||
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
|
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
|
||||||
t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
|
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
|
||||||
}
|
}
|
||||||
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
|
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
|
||||||
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
|
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
|
||||||
|
|||||||
+13
-20
@@ -29,10 +29,7 @@ import (
|
|||||||
|
|
||||||
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
|
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
|
||||||
// billig) und von allen Tests geteilt.
|
// billig) und von allen Tests geteilt.
|
||||||
var (
|
var talkBin string
|
||||||
talkBin string
|
|
||||||
talkdBin string
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
dir, err := os.MkdirTemp("", "talk-bins-*")
|
dir, err := os.MkdirTemp("", "talk-bins-*")
|
||||||
@@ -45,19 +42,14 @@ func TestMain(m *testing.M) {
|
|||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
for _, b := range []struct{ out, pkg string }{
|
build := exec.Command("go", "build", "-o", filepath.Join(dir, "talk"), "./cmd/talk")
|
||||||
{"talk", "./cmd/talk"},
|
build.Dir = repo
|
||||||
{"talkd", "./cmd/talkd"},
|
if out, err := build.CombinedOutput(); err != nil {
|
||||||
} {
|
fmt.Fprintf(os.Stderr, "go build ./cmd/talk: %v\n%s", err, out)
|
||||||
cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg)
|
|
||||||
cmd.Dir = repo
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out)
|
|
||||||
os.RemoveAll(dir)
|
os.RemoveAll(dir)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
talkBin = filepath.Join(dir, "talk")
|
||||||
talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
|
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
os.RemoveAll(dir)
|
os.RemoveAll(dir)
|
||||||
@@ -254,19 +246,19 @@ type daemonProc struct {
|
|||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT,
|
// startDaemon startet `talk daemon run` als echten Prozess (freier Port per
|
||||||
// nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
|
// TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
|
||||||
// t.Cleanup: SIGINT, nach Frist Kill.
|
// t.Cleanup: SIGINT, nach Frist Kill.
|
||||||
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
|
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
port := freePort(t)
|
port := freePort(t)
|
||||||
env := daemonEnv(root, port, fakeURL, extra)
|
env := daemonEnv(root, port, fakeURL, extra)
|
||||||
stderr := &syncBuffer{}
|
stderr := &syncBuffer{}
|
||||||
cmd := exec.Command(talkdBin)
|
cmd := exec.Command(talkBin, "daemon", "run")
|
||||||
cmd.Env = env
|
cmd.Env = env
|
||||||
cmd.Stderr = stderr
|
cmd.Stderr = stderr
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
t.Fatalf("talkd starten: %v", err)
|
t.Fatalf("talk daemon run starten: %v", err)
|
||||||
}
|
}
|
||||||
d := &daemonProc{
|
d := &daemonProc{
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
@@ -288,7 +280,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
|
|||||||
}
|
}
|
||||||
time.Sleep(25 * time.Millisecond)
|
time.Sleep(25 * time.Millisecond)
|
||||||
}
|
}
|
||||||
t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
|
t.Fatalf("talk daemon antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +302,7 @@ func (d *daemonProc) stop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse:
|
// daemonEnv baut die Prozess-Umgebung für talk-Prozesse (CLI und Daemon):
|
||||||
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
|
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
|
||||||
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
|
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
|
||||||
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
|
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
|
||||||
@@ -339,6 +331,7 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
|
|||||||
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
|
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
|
||||||
"TALK_PORT": strconv.Itoa(port),
|
"TALK_PORT": strconv.Itoa(port),
|
||||||
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
|
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
|
||||||
|
"TALK_AUTOSTART": "false", // kein Test darf unbemerkt einen Daemon hinterlassen; autostart_test.go schaltet es bewusst an
|
||||||
}
|
}
|
||||||
for k, v := range extra {
|
for k, v := range extra {
|
||||||
set[k] = v
|
set[k] = v
|
||||||
|
|||||||
+2
-2
@@ -68,10 +68,10 @@ func TestMutePersistenzUeberRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDegradedMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
|
// TestOfflineMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
|
||||||
// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
|
// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
|
||||||
// dieselbe Quelle.
|
// dieselbe Quelle.
|
||||||
func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) {
|
func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
fake := newFakeProvider(t, 0)
|
fake := newFakeProvider(t, 0)
|
||||||
d := startDaemon(t, root, fake.srv.URL, nil)
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
||||||
|
|||||||
+6
-6
@@ -147,22 +147,22 @@ func TestWrapWait(t *testing.T) {
|
|||||||
waitStatusIdle(t, d, 3*time.Second)
|
waitStatusIdle(t, d, 3*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapDegradedMode: Daemon tot → Ansage im Degraded Mode (Lock-Datei,
|
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
|
||||||
// direkt gegen den Fake), und der Exit-Code des Befehls bleibt trotzdem.
|
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
|
||||||
func TestWrapDegradedMode(t *testing.T) {
|
func TestWrapOhneDaemon(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
fake := newFakeProvider(t, 0)
|
fake := newFakeProvider(t, 0)
|
||||||
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||||||
|
|
||||||
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
|
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
|
||||||
if res.code != 3 {
|
if res.code != 3 {
|
||||||
t.Fatalf("talk wrap (Degraded): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
|
t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
|
||||||
}
|
}
|
||||||
hits := fake.waitCompleted(t, 1)
|
hits := fake.waitCompleted(t, 1)
|
||||||
if hits[0].Text != "sh fehlgeschlagen, Code 3." {
|
if hits[0].Text != "sh fehlgeschlagen, Code 3." {
|
||||||
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
|
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.stderr, "Degraded Mode") {
|
if !strings.Contains(res.stderr, "spreche direkt") {
|
||||||
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user