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

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

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

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

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

No files matched your search

+14 -50
View File
@@ -64,68 +64,32 @@ func TestSayWait(t *testing.T) {
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
// gegen den Fake-Provider), Lock danach wieder freigegeben.
func TestSayDegradedMode(t *testing.T) {
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
// sagt das auf stderr — nichts bricht still.
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
res := runTalk(t, env, "say", "offline ansage")
if res.code != 0 {
t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
}
if !strings.Contains(res.stderr, "Degraded Mode") {
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
}
// Lock-Datei wurde nach dem Abspielen freigegeben.
lockPath := filepath.Join(configDir(root), "talk.lock")
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
if strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
}
}
// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
// ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env := daemonEnv(root, 1, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"eins", "zwei"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
results[i] = runTalk(t, env, "say", text)
}(i, text)
}
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
}
}
// Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
// die zweite Synthese startet erst nach der Antwort der ersten.
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
}
}
// TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
func TestSayDegradedRespektiertMute(t *testing.T) {
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
@@ -279,14 +243,14 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
}
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
// Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode.
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
}
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)