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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:43:42 +02:00
1 parent 85fa64cfe6
commit 1b971bc3f6
7 files changed
+52 -22

No files matched your search

+4 -3
View File
@@ -113,9 +113,10 @@ one announcement at a time, in the order `now` > `high` > `normal` > `low`
`7800`, config `[server] port`, env `TALK_PORT`.
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
the first speak command, detached from the terminal, logging to `daemon.log`
with its PID in `daemon.pid` in the config directory; stderr says "daemon
started" once. The daemon then keeps running (idle costs next to nothing)
the first speak command, detached from the terminal (on Windows also broken
away from the caller's job object, so a WinRM, SSH or CI session does not
take it down), logging to `daemon.log` with its PID in `daemon.pid` in the
config directory; stderr says "daemon started" once. The daemon then keeps running (idle costs next to nothing)
until `talk daemon stop`, logout or reboot. Parallel first calls are
harmless: whoever loses the port race uses the other one's daemon. If you
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
+3 -2
View File
@@ -41,14 +41,15 @@ func setMute(muted bool, stdout, stderr io.Writer) int {
client := api.NewClient(cfg.Server.Port)
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
if err := client.SetMute(ctx, muted); err == nil {
callErr := client.SetMute(ctx, muted)
if callErr == nil {
printMuteState(muted, stdout)
return exitOK
}
// Daemon call failed (usually: not running): write the file directly;
// the daemon (and the direct path) re-read it on every announcement.
fmt.Fprintf(stderr, "talk: daemon call failed (%v); writing the mute file directly\n", err)
fmt.Fprintf(stderr, "talk: no daemon (%v); writing the mute file directly\n", callErr)
dir, err := config.Dir(getenv)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
+12 -3
View File
@@ -50,9 +50,11 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
runErr := execChild(args, stdin, stdout, stderr)
duration := time.Since(start)
code := childExitCode(runErr)
if runErr != nil {
// For example: binary not found or killed by a signal. The note goes
// to stderr; the announcement names the same code wrap exits with.
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)
}
@@ -121,6 +123,13 @@ func childExitCode(runErr error) int {
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").
+18 -8
View File
@@ -17,7 +17,6 @@ import (
// port in use) without waiting for a timeout. If the caller exits first,
// the daemon keeps running (that is the point).
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
var logFile *os.File
if logPath == "" {
logPath = os.DevNull
}
@@ -27,13 +26,24 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
}
defer logFile.Close() // the child holds its own handle
cmd := exec.Command(exe, "daemon", "run")
cmd.Env = env
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
detach(cmd)
if err := cmd.Start(); err != nil {
// First attempt: fully detached, including a breakaway from the parent's
// job object (Windows). Shells such as WinRM, SSH servers or CI runners
// put children into a job that is terminated with the session; the
// breakaway lets the daemon outlive it. If the job forbids breakaway,
// CreateProcess fails, and the second attempt starts without it.
var cmd *exec.Cmd
for _, breakaway := range []bool{true, false} {
cmd = exec.Command(exe, "daemon", "run")
cmd.Env = env
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
detach(cmd, breakaway)
if err = cmd.Start(); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
}
exited := make(chan error, 1)
+1 -1
View File
@@ -5,4 +5,4 @@ package daemon
import "os/exec"
// detach is a no-op on this platform (no Setsid, no console).
func detach(*exec.Cmd) {}
func detach(*exec.Cmd, bool) {}
+3 -2
View File
@@ -7,8 +7,9 @@ import (
"syscall"
)
// detach detaches the child from the terminal: own session (no SIGHUP when
// detach detaches the child from the terminal (the breakaway flag is a
// Windows concept and ignored here): own session (no SIGHUP when
// the terminal closes, no Ctrl-C from the shell).
func detach(cmd *exec.Cmd) {
func detach(cmd *exec.Cmd, _ bool) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+11 -3
View File
@@ -11,10 +11,18 @@ import (
// binding to the caller's console.
const detachedProcess = 0x00000008
// createBreakawayFromJob is the Win32 flag CREATE_BREAKAWAY_FROM_JOB: the
// child leaves the parent's job object, so a session-bound job (WinRM, SSH,
// CI) does not take the daemon down with it. Only works when the job allows
// it; Spawn falls back to a start without the flag otherwise.
const createBreakawayFromJob = 0x01000000
// detach detaches the child from the console: own process group (no Ctrl-C
// from the shell) and no console (no flashing window).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
func detach(cmd *exec.Cmd, breakaway bool) {
flags := uint32(syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess)
if breakaway {
flags |= createBreakawayFromJob
}
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: flags}
}