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>
169 lines
6.2 KiB
Go
169 lines
6.2 KiB
Go
// wrap-Prozessgrenzen-Tests (Task 07): talk läuft als echter Prozess,
|
|
// talkd als echter Daemon, Fake-Provider + sink null wie im Harness —
|
|
// beobachtet wird nur extern: Exit-Codes, Befehls-stdout, Ansage-Text am
|
|
// Fake. Die gewrappten Befehle sind echte Binaries (sleep/sh/true), die
|
|
// Dauern bewusst klein.
|
|
package test
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestWrapErfolgMitDauer (Abnahmekriterium): `talk wrap -- sleep 1`
|
|
// annonciert Erfolg inklusive Dauer und exitet 0.
|
|
func TestWrapErfolgMitDauer(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 0)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
res := runTalk(t, env, "wrap", "--", "sleep", "1")
|
|
if res.code != 0 {
|
|
t.Fatalf("talk wrap -- sleep 1: Exit %d, stderr: %s", res.code, res.stderr)
|
|
}
|
|
hits := fake.waitCompleted(t, 1)
|
|
// sleep 1 misst ~1 s (unter Last gern mehr) — Zahl offen, Form nicht.
|
|
if !regexp.MustCompile(`^sleep erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(hits[0].Text) {
|
|
t.Errorf("Ansage = %q, will „sleep erfolgreich. Dauer: N Sekunde(n).“", hits[0].Text)
|
|
}
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapFehlschlagCodeDurchgereicht (Abnahmekriterium): scheitert der
|
|
// Befehl mit Code 2, exitet wrap mit 2 und sagt den Misserfolg mit Code —
|
|
// ohne Dauer, wortgenau nach Brief.
|
|
func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 0)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
|
|
if res.code != 2 {
|
|
t.Fatalf("talk wrap -- sh -c 'exit 2': Exit %d, want 2 (Durchreichung)", res.code)
|
|
}
|
|
hits := fake.waitCompleted(t, 1)
|
|
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
|
|
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
|
|
}
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapFlagShielding (Regression, Task-07-Kern): `talk wrap -- true
|
|
// --bar` reicht --bar an den Befehl weiter — talk darf --bar NICHT als
|
|
// eigenes Flag parsen (wäre Exit 2 mit Flag-Fehler). GNU true ignoriert
|
|
// Operanden, exitet also 0, und die Erfolg-Ansage läuft.
|
|
func TestWrapFlagShielding(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 0)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
res := runTalk(t, env, "wrap", "--", "true", "--bar")
|
|
if res.code != 0 {
|
|
t.Fatalf("talk wrap -- true --bar: Exit %d, want 0 (--bar gehört dem Befehl) — stderr: %s", res.code, res.stderr)
|
|
}
|
|
if strings.Contains(res.stderr, "flag provided but not defined") {
|
|
t.Errorf("talk hat --bar als eigenes Flag geparsed — Shielding kaputt: %s", res.stderr)
|
|
}
|
|
hits := fake.waitCompleted(t, 1)
|
|
if !strings.HasPrefix(hits[0].Text, "true erfolgreich.") {
|
|
t.Errorf("Ansage = %q, will Erfolg für true", hits[0].Text)
|
|
}
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapLabelOverride: TALK_WRAP_LABEL ersetzt den Basisnamen in der
|
|
// Ansage (Fleet-Muster: Env statt Config-Datei).
|
|
func TestWrapLabelOverride(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 0)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
|
|
|
|
res := runTalk(t, env, "wrap", "--", "false")
|
|
if res.code != 1 {
|
|
t.Fatalf("talk wrap -- false: Exit %d, want 1", res.code)
|
|
}
|
|
hits := fake.waitCompleted(t, 1)
|
|
if hits[0].Text != "Deployment fehlgeschlagen, Code 1." {
|
|
t.Errorf("Ansage = %q, want %q", hits[0].Text, "Deployment fehlgeschlagen, Code 1.")
|
|
}
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapNonBlocking: wrap kehrt nach dem enqueue zurück, bevor die
|
|
// Ansage zu Ende gespielt ist (Ansage dauert 600 ms am Fake) — stdout
|
|
// gehört dabei allein dem Befehl, die Item-ID geht auf stderr.
|
|
func TestWrapNonBlocking(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 600*time.Millisecond)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
start := time.Now()
|
|
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo bauausgabe")
|
|
elapsed := time.Since(start)
|
|
|
|
if res.code != 0 {
|
|
t.Fatalf("talk wrap: Exit %d, stderr: %s", res.code, res.stderr)
|
|
}
|
|
if elapsed >= 500*time.Millisecond {
|
|
t.Fatalf("talk wrap war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
|
|
}
|
|
if res.stdout != "bauausgabe\n" {
|
|
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe (Item-ID gehört auf stderr)", res.stdout)
|
|
}
|
|
if strings.TrimSpace(res.stderr) == "" {
|
|
t.Errorf("stderr = %q, will mindestens die Item-ID der Ansage", res.stderr)
|
|
}
|
|
|
|
// Die Ansage läuft im Daemon trotzdem zu Ende.
|
|
fake.waitCompleted(t, 1)
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapWait: `talk wrap --wait -- …` kehrt erst nach Ende der Ansage
|
|
// zurück (geerbtes say-Flag, hier: 400 ms Synthese).
|
|
func TestWrapWait(t *testing.T) {
|
|
root := t.TempDir()
|
|
fake := newFakeProvider(t, 400*time.Millisecond)
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
start := time.Now()
|
|
res := runTalk(t, env, "wrap", "--wait", "--", "true")
|
|
elapsed := time.Since(start)
|
|
|
|
if res.code != 0 {
|
|
t.Fatalf("talk wrap --wait: Exit %d, stderr: %s", res.code, res.stderr)
|
|
}
|
|
if elapsed < 350*time.Millisecond {
|
|
t.Fatalf("talk wrap --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
|
|
}
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
}
|
|
|
|
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
|
|
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
|
|
func TestWrapOhneDaemon(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, "wrap", "--", "sh", "-c", "exit 3")
|
|
if res.code != 3 {
|
|
t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
|
|
}
|
|
hits := fake.waitCompleted(t, 1)
|
|
if 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, "spreche direkt") {
|
|
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
|
}
|
|
}
|