refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der Degraded Mode kannte weder Rate-Limit noch Ruhezeiten. Jetzt: - internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage); Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite Zusammensetzung der Kette mehr. - internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei daemon.pid, Shutdown-Trigger. - `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg und die PID-Datei gelöscht ist (Prozess wirklich beendet). - Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt den Daemon des anderen — kein Timeout-Warten. - Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis + Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst nicht; `talk status` zeigt „läuft nicht" (Exit 1). - cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt neu. Tests: Harness baut nur noch talk und startet `talk daemon run`; TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile aller sechs Targets mit CGO_ENABLED=0 verifiziert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b86632006e
commit
bc26a73aeb
29 files changed
+1145
-584
No files matched your search
+14
-50
@@ -64,68 +64,32 @@ func TestSayWait(t *testing.T) {
|
||||
waitStatusIdle(t, d, 3*time.Second)
|
||||
}
|
||||
|
||||
// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
|
||||
// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
|
||||
// gegen den Fake-Provider), Lock danach wieder freigegeben.
|
||||
func TestSayDegradedMode(t *testing.T) {
|
||||
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
|
||||
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
|
||||
// sagt das auf stderr — nichts bricht still.
|
||||
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||||
|
||||
res := runTalk(t, env, "say", "offline ansage")
|
||||
if res.code != 0 {
|
||||
t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr)
|
||||
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
|
||||
}
|
||||
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
|
||||
t.Errorf("Treffer = %q", hits[0].Text)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "Degraded Mode") {
|
||||
t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
||||
if !strings.Contains(res.stderr, "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
||||
}
|
||||
|
||||
// Lock-Datei wurde nach dem Abspielen freigegeben.
|
||||
lockPath := filepath.Join(configDir(root), "talk.lock")
|
||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
|
||||
if strings.Contains(res.stderr, "Daemon gestartet") {
|
||||
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
|
||||
// ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
|
||||
func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 250*time.Millisecond)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]talkResult, 2)
|
||||
for i, text := range []string{"eins", "zwei"} {
|
||||
wg.Add(1)
|
||||
go func(i int, text string) {
|
||||
defer wg.Done()
|
||||
results[i] = runTalk(t, env, "say", text)
|
||||
}(i, text)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, res := range results {
|
||||
if res.code != 0 {
|
||||
t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
|
||||
// die zweite Synthese startet erst nach der Antwort der ersten.
|
||||
hits := fake.waitHits(t, 2)
|
||||
const slack = 25 * time.Millisecond
|
||||
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
|
||||
t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
|
||||
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
|
||||
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
||||
func TestSayDegradedRespektiertMute(t *testing.T) {
|
||||
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||
@@ -279,14 +243,14 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
|
||||
// Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode.
|
||||
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
|
||||
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||||
|
||||
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
|
||||
t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
|
||||
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
|
||||
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
|
||||
}
|
||||
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
|
||||
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
|
||||
|
||||
Reference in new issue
Block a user