12 KiB
Task 4 Report: talkd-Daemon + Queue + Mute
Status: DONE
Branch: feat/v1
Commits: 7 (716f6b2 → dc94f45, auf 3b44605)
Verifikation: go vet ./... ✓ · gofmt -l . leer ✓ · go test ./... -count=1 ✓ (inkl. -race für cmd/internal, count=5 für die Prozessgrenzen-Tests gegen Flakiness) · go build ./... ✓ · Cross-Compile-Smoke CGO_ENABLED=0 für darwin/windows/amd64+arm64 und linux/arm64 ✓
Geliefert
internal/queue (queue.go, mute.go, rate.go)
- Queue: Worker-Loop mit serialisierter Abarbeitung (genau eine Ansage gleichzeitig). Reihenfolge:
now>high>normal>low, FIFO je Level (Enqueue-Seq als Tiebrecher). IDs: 6 Hex-Zeilen (crypto/rand, Fallback praktisch unerreichbar). - MuteStore: persistenter Mute-State als Datei
muteim Config-Dir, Inhalttrue/false, atomar geschrieben (Temp-Datei + Rename, MkdirAll bei Bedarf). Fehlende/unlesbare Datei = nicht stumm (nie „stumm aus Versehen“). Da der Daemon die Datei bei jeder /speak-Anfrage neu liest, wirkt auch eine offline (CLI, Degraded Mode) geschriebene Datei sofort. - RateLimiter (Seam für Task 05): Struktur mit
Allow/Reserve/Nextsteht, ist getestet (internal/queue/queue_test.go) und bewusst NOCH NICHT in den Worker-Loop verdrahtet — Task-04-Default unbegrenzt, wie der Brief verlangt. Die Seam ist in rate.go wörtlich dokumentiert (Task 05: Parking/Discard/burst-Entscheidung + configqueue.rate_limit).
internal/api (types.go, server.go, client.go)
- Endpunkte exakt nach Brief, methodengebunden (ServeMux-Muster → 405/404 von selbst):
POST /speak {text, priority?, provider?, voice?, lang?, now?}→202 {"id":"…"}; bei Mute202 {"id":"","muted":true}(verworfen, dokumentiert). Validierung → 400 mit{"error":…}: kaputtes JSON, leerer Text, Müll-Priority, unbekannter Provider, Müll-Lang; Body-Limit 64 KiB.GET /status→{version, queue, queue_ids, current (null im Leerlauf), muted, provider}.queue_idsist die dokumentierte Ergänzung (wartende IDs in Abarbeitungs-Reihenfolge) — die CLI braucht sie für--wait.POST /cancel→200 {"cancelled":n}(n = laufende + wartende).POST /mute/POST /unmute→200 {"muted":bool}.
- Client (CLI-Seite):
Status/Speak/Cancel/SetMute.api.HTTPError(unerwarteter Status) ist von Netz-Fehlern unterscheidbar — die CLI degeneriert nur bei Netz-Fehlern und zeigt HTTP-Fehler ehrlich an.
cmd/talkd (main.go ersetzt den Platzhalter)
- HTTP nur auf
127.0.0.1:<port>, Port aus config[server] port/ EnvTALK_PORT/ Default 7800. Port belegt → klarer Fehler mit Hinweis. - Ansagen laufen über die unveränderte Provider-Kette (internal/provider wurde nicht angefasst): pro Item wird
provider.NewChaingebaut (Pin/Voice sind Anfrage-Properties),chain.Speak(ctx, …)spielt mit Fallback + Sink. Sprache: Item-Pin oder langdetect im Daemon. /status„aktiver Provider“: Pin des laufenden Items, sonst erste verfügbare Stufe einer Basiskette (Available ist billig, kein Netz-Call); im Leerlauf der zuletzt aktive. Die Kette meldet die erfolgreiche Stufe nicht selbst — deshalb Auflösung über Available (dokumentierte Entscheidung, s. „Offene Punkte“).- Signale (SIGINT/SIGTERM): erst
srv.Shutdown(keine neuen Anfragen), dannq.Close()(laufende Ansage abbrechen, Worker-Exit abwarten). - Mute ohne Config-Dir: Start-Warnung, Daemon läuft weiter (Mute dann deaktiviert,
/mute→ 500).
cmd/talk (daemon.go, degraded.go, control.go, say.go refaktoriert)
- say/Default: Daemon-Erkennung via
GET /status(1 s Budget). Läuft er: enqueue + sofort zurück (non-blocking Default; stdout = Item-ID, eine Zeile);--waitpollt /status (100 ms), bis die Ansage weder läuft noch wartet;--now→now:true; bei Mute → stdoutmuted, Exit 0. - Degraded Mode: Daemon nicht erreichbar → stderr-Hinweis, dann lokal: Mute-Datei respektieren, Lock-Datei
talk.lockim Config-Dir (O_CREATE|O_EXCL, reine stdlib; Kollision = warten, max 10 s, danach ungeschützt mit Warnung; verwaistes Lock nach 60 s entfernen), dann dieselbe Kette blockierend im CLI-Prozess. Ohne Config-Dir: ohne Lock/Mute weiterlaufen (Best-effort, konsequent zu config.Load). - mute/unmute: Daemon-Aufruf (
POST /mute//unmute), stdoutmuted/unmuted. Daemon tot: Mute-Datei direkt schreiben (dieselbe Quelle, die der Daemon liest — greift sofort und überlebt Restarts). - status: plain Block (
daemon: … (version …),queue: N,current: …,mute: an|aus,provider: …). Daemon tot → Exit 1 mit Hinweis. - cancel:
POST /cancel, stdoutcancelled: N. Daemon tot → Exit 1. - Handler-Signatur der Befehlstabelle um
stdouterweitert (say/Steuer-Befehle geben Ergebnisse aus);--prioritywird früh validiert;--lang-Validierung unverändert früh.
config
Server.Port([server] port), EnvTALK_PORT(validiert 1–65535, klarer Fehler bei Müll), Default 7800. Tischgesteuerte Tests ergänzt.
Tests
Prozessgrenze (primäre Seam), test/-Paket: talk + talkd werden einmal pro Lauf gebaut und als echte Prozesse gefahren; ElevenLabs-Fake per httptest (protokolliert Ankünfte separat und Antworten mit Zeiten + Completed-Flag für Abbrüche), TALK_AUDIO_SINK=null, TALK_EDGE_ENDPOINT auf toten Loopback (Kette fällt nie auf echte Fremd-Endpunkte), Config-Dir isoliert (HOME/XDG auf Temp), Ports: immer freie ephemere Ports per TALK_PORT — nie 7800, nie ein echter Daemon. Getestet:
- Serialisierung (2+1 schnelle Enqueues → nacheinander, nicht überlappend, beobachtbar an Fake-Ankunfts-/Antwort-Zeiten + null-Sink-Verwurf-Zeilen im Daemon-Log)
- Priorität (queue_ids in Abarbeitungs-Reihenfolge + echte Abspiel-Reihenfolge high→normal→low, FIFO je Level)
--nowüber die CLI springt vor wartende normal-Items (unterbricht nicht den Laufenden)- cancel: bricht die Synthese ab (Fake sieht den Abbruch), leert die Queue,
cancelled:2, Daemon danach gesund - non-blocking (say kehrt in <500 ms zurück, Ansage läuft weiter) +
--wait(kehrt erst nach Ende zurück) - Mute-Persistenz über Daemon-Restart (Abnahmekriterium) + offline geschriebene Mute-Datei wirkt im laufenden Daemon
- Degraded Mode: Daemon tot → Lock-Pfad (Lock danach freigegeben), Lock-Serialisierung zweier paralleler CLIs, mute respektiert
- Steuer-Befehle über CLI mit/ohne Daemon (plain Output, Exit-Codes)
- 3 parallele talk-Aufrufe → 3 Ansagen nacheinander (Abnahmekriterium, wörtlich)
- HTTP-Vertrag: 400-Validierung, 405, Status-Felder im Leerlauf (version dev, provider elevenlabs)
Unit (Neben-Seams): Queue-Abarbeitung/Current/Pending/Cancel/Close/IDs über steuerbaren Speaker (deterministisch, kein Timing), MuteStore-Roundtrip, RateLimiter-Semantik, config-Port-Auflösung, CLI-Steuerbefehle ohne Daemon (in-process über die run-Seam).
Test-Isolation der Bestands-Tests: setGetenv-Grundausstattung hat jetzt TALK_PORT=1 (analog zum toten TALK_EDGE_ENDPOINT) — die vorhandenen say-Tests laufen deterministisch durch den Degraded Mode und können nie einen zufällig laufenden echten talkd treffen.
Concurrency-Design (geforderte Notizen)
Serialisierung. Ein einziger Worker-Goroutine (loop) entnimmt Items; nur er ruft den Speaker. Enqueue (HTTP-Handler, beliebig parallel) hängt unter mu an die Liste und signalisiert notEmpty. Wait/Signal folgt dem klassischen Muster: Der Worker prüft die Predicate (len(items)==0 && !closed) unter demselben Mutex, unter dem Enqueue anhängt — verlorene Wakeups sind strukturell ausgeschlossen; das Signal ist bewusst ein sync.Cond (kein Kanal-Select nötig, kein Missed-Wake-Reasoning).
Cancel. Cancel() nimmt mu, verwirft die Warteschlange und ruft bei laufender Ansage cancelCur(). Pop und das Setzen von current/cancelCur geschehen im Worker unter EINEM Lock-Halten — es gibt kein Fenster, in dem ein Item weder „wartend“ noch „laufend“ ist und der Cancelisierung entwischen könnte. Der Abbruch wirkt kooperativ: Der Speaker (Provider-Kette) erhält den ctx in alle Synthese-Calls (HTTP via NewRequestWithContext, Prozesse via CommandContext) — die Kette bricht ab, ohne dass der Worker etwas tun müsste. Nach Rückkehr des Speakers räumt der Worker current unter mu ab. Zählt ein Item als abgebrochen, das in dem Moment just fertig wurde, ist das Punkt-in-Zeit-Semantik (kosmetisch, dokumentiert).
Shutdown. Close() setzt closed, verwirft wartende Items, bricht die laufende Ansage ab (cancelCur) und broadcastet; danach wartet Close auf done (geschlossen vom Worker-Loop nach dessen letztem Durchlauf). Enqueue nach Close → ErrClosed → API 503. Die Daemon-Shutdown-Reihenfolge ist: HTTP-Listener dicht (srv.Shutdown, keine neuen Anfragen) → q.Close() (Abbruch + Worker-Exit abwarten) → Prozessende. So kann nie eine Ansage den Daemon überleben, und ein Handler kann nie in eine geschlossene Queue enqueuen, ohne die 503 zu sehen. Close ist idempotent (idempotenter Zweitaufruf wartet nur noch auf done).
Bewusste Einfachheit: EIN Mutex für alles Queue-interne (Items, current, cancelCur, closed) statt feiner Locks; lineare Pop-Suche (n ist klein); logf nie unter dem Mutex (Aufrufer achten darauf). -race über cmd/internal grün, Queue-Tests zusätzlich 3× wiederholt.
Entscheidungen & Dokumentationspflichten
- Mute-Discard-Punkt: Mute wird bei
/speakgeprüft (202{muted:true}, Item verworfen) — wie im Brief. Bereits laufende Ansagen werden durchtalk muteNICHT abgebrochen (cancel ist der explizite Befehl dafür); das ist die schlichte, dokumentierte Lesart des Briefs. - „Aktiver Provider“ in /status: Die Kette (unveränderlich, Task-03-Bestand) meldet die erfolgreiche Stufe nicht zurück. Auflösung daher über Pin des Items bzw. erste
Available()-Stufe der Basiskette (billig, ohne Netz-Call); im Leerlauf der zuletzt aktive. Für doctor/status ausreichend; Task 09 (Status-Dashboard) kann das ggf. verfeinern. - CLI-stdout-Formate (plain, skriptbar): say → Item-ID (eine Zeile) bzw.
muted; mute/unmute →muted/unmuted; cancel →cancelled: N; status → Block mitdaemon:/queue:/current:/mute:/provider:. In README dokumentiert. - Degraded-Lock: Warten max 10 s, danach ungeschützt mit Warnung („best-effort“); verwaiste Locks (Crash) gelten nach 60 s als verfallen und werden entfernt. Bewusst stdlib-only (O_EXCL statt flock — Letzteres ist auf Windows nicht portabel).
- status/cancel ohne Daemon = Exit 1 (sie sind Daemon-Anfragen); say ohne Daemon = Degraded Mode Exit 0.
Selbst-Review (3b44605..HEAD, 26 Dateien, +3166/−102)
internal/providerundinternal/audio: null Zeilen geändert (Bindung „Kette unverändert“ eingehaltenn).- Bestehende Tests nur minimal angefasst:
setGetenv-Basis (TALK_PORT=1, Isolation),wantStubCommands(vier Befehle sind jetzt real). Kein Assert geschwächt. - Ein im Review gefundener Wortlaut-Fehler („nicht erreichbar“ bei HTTP-500 in setMute) ist gefixt (Commit
e1a3981amendiert). - Bekannte kosmetische Grenzen: Cancel-Zählung bei exakt gleichzeitigem Natürliche-Ende (s. o.);
--waitohne Timeout (bewusst — Escape ist Ctrl-C); Daemon-Probe-Budget 1 s kann bei pathologisch langsamen Loopbacks einen unnötigen Degraded-Lauf auslösen (ohne Doppl-Ansage, da kein enqueue stattfand).
Offene Punkte für Folge-Tasks
- Task 05: RateLimiter in den Worker-Loop verdrahten (
queue.rate_limit, Parking/Discard/allow_high_burst), Ruhezeiten;talk statusum deren State ergänzen. Struktur +Next()sind vorbereitet. - Task 06: CORS (nur /speak) und MCP — Handler-Konstruktion in
api.NewHandlerist dafür die eine Stelle. - .scratch/ unangetastet; kein Push erfolgt.