diff --git a/README.md b/README.md index ca5d1bc..6aae326 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/talk/control.go b/cmd/talk/control.go index e14a2df..7ce6403 100644 --- a/cmd/talk/control.go +++ b/cmd/talk/control.go @@ -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) diff --git a/cmd/talk/wrap.go b/cmd/talk/wrap.go index f5ebec4..55d1a2b 100644 --- a/cmd/talk/wrap.go +++ b/cmd/talk/wrap.go @@ -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"). diff --git a/internal/daemon/spawn.go b/internal/daemon/spawn.go index 7c2b244..0c52826 100644 --- a/internal/daemon/spawn.go +++ b/internal/daemon/spawn.go @@ -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) diff --git a/internal/daemon/spawn_other.go b/internal/daemon/spawn_other.go index a5ef9f1..98852e9 100644 --- a/internal/daemon/spawn_other.go +++ b/internal/daemon/spawn_other.go @@ -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) {} diff --git a/internal/daemon/spawn_unix.go b/internal/daemon/spawn_unix.go index 47db6b7..488abab 100644 --- a/internal/daemon/spawn_unix.go +++ b/internal/daemon/spawn_unix.go @@ -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} } diff --git a/internal/daemon/spawn_windows.go b/internal/daemon/spawn_windows.go index 9bcd347..fec7115 100644 --- a/internal/daemon/spawn_windows.go +++ b/internal/daemon/spawn_windows.go @@ -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} }