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

+16 -23
View File
@@ -29,10 +29,7 @@ import (
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
// billig) und von allen Tests geteilt.
var (
talkBin string
talkdBin string
)
var talkBin string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "talk-bins-*")
@@ -45,19 +42,14 @@ func TestMain(m *testing.M) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, b := range []struct{ out, pkg string }{
{"talk", "./cmd/talk"},
{"talkd", "./cmd/talkd"},
} {
cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out)
os.RemoveAll(dir)
os.Exit(1)
}
build := exec.Command("go", "build", "-o", filepath.Join(dir, "talk"), "./cmd/talk")
build.Dir = repo
if out, err := build.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "go build ./cmd/talk: %v\n%s", err, out)
os.RemoveAll(dir)
os.Exit(1)
}
talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
talkBin = filepath.Join(dir, "talk")
code := m.Run()
os.RemoveAll(dir)
@@ -254,19 +246,19 @@ type daemonProc struct {
baseURL string
}
// startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT,
// nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
// startDaemon startet `talk daemon run` als echten Prozess (freier Port per
// TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
// t.Cleanup: SIGINT, nach Frist Kill.
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
t.Helper()
port := freePort(t)
env := daemonEnv(root, port, fakeURL, extra)
stderr := &syncBuffer{}
cmd := exec.Command(talkdBin)
cmd := exec.Command(talkBin, "daemon", "run")
cmd.Env = env
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("talkd starten: %v", err)
t.Fatalf("talk daemon run starten: %v", err)
}
d := &daemonProc{
cmd: cmd,
@@ -288,7 +280,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
t.Fatalf("talk daemon antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
return nil
}
@@ -310,7 +302,7 @@ func (d *daemonProc) stop(t *testing.T) {
}
}
// daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse:
// daemonEnv baut die Prozess-Umgebung für talk-Prozesse (CLI und Daemon):
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
@@ -338,7 +330,8 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
"TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
"TALK_AUTOSTART": "false", // kein Test darf unbemerkt einen Daemon hinterlassen; autostart_test.go schaltet es bewusst an
}
for k, v := range extra {
set[k] = v