Files
talk/cmd/talk/wrap.go
T
m3ta-chironandClaude Fable 5.1 1b971bc3f6 fix: Windows daemon survives session jobs; quieter wrap note; correct mute error
Verified on a Windows 11 VM over WinRM: doctor, SAPI synthesis, Edge,
oto/WASAPI playback, autostart, mute file, wrap exit codes, doctor --probe
and toast mode all work. Three findings fixed:

- The autostarted daemon died with the WinRM shell: Windows session hosts
  (WinRM, SSH, CI runners) put children into a job object that is
  terminated with the session. Spawn now starts the daemon with
  CREATE_BREAKAWAY_FROM_JOB first and falls back to a start without it when
  the job forbids breakaway. Confirmed: the daemon now outlives the WinRM
  shell and `talk daemon stop` still ends it.
- `talk mute` without a daemon printed "daemon call failed (<nil>)": the
  outer err was reported instead of the call's. Now "no daemon (<reason>);
  writing the mute file directly".
- `talk wrap` printed "command did not exit cleanly" for every non-zero
  exit code. The note is now reserved for commands that produced no exit
  code of their own (not found, signal); a plain failure is carried by the
  announcement and the exit code alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:43:42 +02:00

190 lines
7.0 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
)
// exitNotFound is the shell convention for "command not found".
const exitNotFound = 127
// runWrap is the wrap entry point: `talk wrap -- <command> [args …]` runs
// the command (stdin/stdout/stderr passed through; stdout belongs to the
// command alone), measures wall time and announces the result afterwards:
// "<label> succeeded. Duration: X." or "<label> failed, code N." (label =
// config `wrap.label` or the command's base name; localized de/en by
// language detection on the label, falling back to the configured lang).
// The announcement takes the same path as say: daemon (enqueue, non-
// blocking; --wait honoured; autostart), without a daemon directly. wrap
// inherits the say flags and passes them on.
//
// Exit code policy: wrap ALWAYS exits with the command's code (talk errors
// are not command errors). If the announcement fails, there is a warning on
// stderr and the command's code stands. Usage errors of talk itself (no
// command, unknown flags, invalid --lang) fail BEFORE the command starts,
// so it does not run at all. Only configuration errors (broken config.toml
// or env) surface after the run: the command has run, the announcement is
// skipped with a warning.
//
// The positional arguments are the command line (command + args); the "--"
// itself was consumed by parseInterspersed, everything after it came
// through untouched.
func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "talk wrap: no command given; usage: talk wrap [flags] -- <command> [args …]")
return exitUsage
}
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
start := time.Now()
runErr := execChild(args, stdin, stdout, stderr)
duration := time.Since(start)
code := childExitCode(runErr)
if runErr != nil && !isPlainExit(runErr) {
// Binary not found or killed by a signal: the command never produced
// an exit code of its own, so say why wrap exits with the one it
// does. A plain non-zero exit code needs no note; the announcement
// and the exit code carry it.
fmt.Fprintf(stderr, "talk wrap: command did not exit cleanly: %v\n", runErr)
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk wrap: announcement skipped; config: %v (the command's exit code %d stands)\n", err, code)
return code
}
label := wrapLabel(cfg.Wrap.Label, args[0])
lang := opts.lang
if lang == "" {
lang = langdetect.Detect(label, cfg.Lang)
}
announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
return code
}
// announceWrap delivers the wrap announcement the same way say does
// (announce: daemon, autostart if needed, otherwise direct). The
// announcement machinery NEVER overrides the command's exit code: if it
// fails, there is a warning on stderr. Information lines of the
// announcement (item ID, muted, discarded) go to stderr for wrap: stdout
// belongs to the (already finished) command, so its pipes and redirects
// stay clean.
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
if announce(cfg, text, opts, stderr, stderr) != exitOK {
fmt.Fprintf(stderr, "talk wrap: announcement failed; the command's exit code %d stands\n", commandCode)
}
}
// execChild starts argv as a child process and waits for it. stdin/stdout/
// stderr are passed through unchanged: the command owns the console, talk
// does not interfere (the announcement goes out only AFTER the command has
// ended). Plain os/exec without platform-specific calls: runs unchanged on
// Windows, macOS and Linux.
func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
}
// childExitCode translates the child's Run result into the exit code wrap
// itself exits with (pass-through contract):
//
// - normal exit: the command's exit code (0 = success).
// - killed by a signal: normalized to 1, portably. The Unix convention
// 128+signal would need syscall.WaitStatus (Go reports signal deaths as
// ExitCode -1) and would no longer cross-compile freely; the
// announcement deliberately names the same normalized code.
// - start failed (binary not found/not executable): 127, the shell
// convention; the command failed without ever running.
func childExitCode(runErr error) int {
if runErr == nil {
return 0
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
if c := ee.ExitCode(); c >= 0 {
return c
}
return exitFailure
}
return exitNotFound
}
// isPlainExit reports whether runErr is just a non-zero exit code of a
// command that ran and exited normally.
func isPlainExit(runErr error) bool {
var ee *exec.ExitError
return errors.As(runErr, &ee) && ee.ExitCode() >= 0
}
// wrapLabel resolves the announcement label: config `wrap.label` (env
// override TALK_WRAP_LABEL) beats the command's base name
// (`/usr/bin/make` → "make").
func wrapLabel(cfgLabel, command string) string {
if cfgLabel != "" {
return cfgLabel
}
return filepath.Base(command)
}
// formatDuration renders wall time for humans, localized de/en: "3 Minuten
// 42 Sekunden" / "3 minutes 42 seconds". Rounded to whole seconds (half
// up); zero parts are dropped ("1 Minute"), only an overall zero keeps
// "0 Sekunden". Singulars are declined ("1 Sekunde", "1 minute").
// Anything over an hour names hours too.
func formatDuration(d time.Duration, lang string) string {
type unit struct{ singular, plural string }
units := []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
if lang == "de" {
units = []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
}
secs := int64(d.Round(time.Second) / time.Second)
n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
var parts []string
for i, u := range units {
if n[i] == 0 {
continue
}
word := u.plural
if n[i] == 1 {
word = u.singular
}
parts = append(parts, fmt.Sprintf("%d %s", n[i], word))
}
if len(parts) == 0 {
return fmt.Sprintf("0 %s", units[2].plural)
}
return strings.Join(parts, " ")
}
// announcementText builds the wrap announcement: success "<label>
// succeeded. Duration: X." / failure "<label> failed, code N.", in German
// "<label> erfolgreich. Dauer: X." / "<label> fehlgeschlagen, Code N.". The
// language comes from detection on the label (or --lang); anything but
// "de" speaks English.
func announcementText(label string, code int, d time.Duration, lang string) string {
if lang == "de" {
if code == 0 {
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
}
if code == 0 {
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s failed, code %d.", label, code)
}