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:
1 parent
85fa64cfe6
commit
1b971bc3f6
7 files changed
+48
-18
No files matched your search
@@ -113,9 +113,10 @@ one announcement at a time, in the order `now` > `high` > `normal` > `low`
|
|||||||
`7800`, config `[server] port`, env `TALK_PORT`.
|
`7800`, config `[server] port`, env `TALK_PORT`.
|
||||||
|
|
||||||
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
|
**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`
|
the first speak command, detached from the terminal (on Windows also broken
|
||||||
with its PID in `daemon.pid` in the config directory; stderr says "daemon
|
away from the caller's job object, so a WinRM, SSH or CI session does not
|
||||||
started" once. The daemon then keeps running (idle costs next to nothing)
|
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
|
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
|
harmless: whoever loses the port race uses the other one's daemon. If you
|
||||||
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
|
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
|
||||||
|
|||||||
+3
-2
@@ -41,14 +41,15 @@ func setMute(muted bool, stdout, stderr io.Writer) int {
|
|||||||
client := api.NewClient(cfg.Server.Port)
|
client := api.NewClient(cfg.Server.Port)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := client.SetMute(ctx, muted); err == nil {
|
callErr := client.SetMute(ctx, muted)
|
||||||
|
if callErr == nil {
|
||||||
printMuteState(muted, stdout)
|
printMuteState(muted, stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon call failed (usually: not running): write the file directly;
|
// Daemon call failed (usually: not running): write the file directly;
|
||||||
// the daemon (and the direct path) re-read it on every announcement.
|
// 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)
|
dir, err := config.Dir(getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
|
|||||||
+12
-3
@@ -50,9 +50,11 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
|||||||
runErr := execChild(args, stdin, stdout, stderr)
|
runErr := execChild(args, stdin, stdout, stderr)
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
code := childExitCode(runErr)
|
code := childExitCode(runErr)
|
||||||
if runErr != nil {
|
if runErr != nil && !isPlainExit(runErr) {
|
||||||
// For example: binary not found or killed by a signal. The note goes
|
// Binary not found or killed by a signal: the command never produced
|
||||||
// to stderr; the announcement names the same code wrap exits with.
|
// 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)
|
fmt.Fprintf(stderr, "talk wrap: command did not exit cleanly: %v\n", runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +123,13 @@ func childExitCode(runErr error) int {
|
|||||||
return exitNotFound
|
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
|
// wrapLabel resolves the announcement label: config `wrap.label` (env
|
||||||
// override TALK_WRAP_LABEL) beats the command's base name
|
// override TALK_WRAP_LABEL) beats the command's base name
|
||||||
// (`/usr/bin/make` → "make").
|
// (`/usr/bin/make` → "make").
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
// port in use) without waiting for a timeout. If the caller exits first,
|
// port in use) without waiting for a timeout. If the caller exits first,
|
||||||
// the daemon keeps running (that is the point).
|
// the daemon keeps running (that is the point).
|
||||||
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
|
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
|
||||||
var logFile *os.File
|
|
||||||
if logPath == "" {
|
if logPath == "" {
|
||||||
logPath = os.DevNull
|
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
|
defer logFile.Close() // the child holds its own handle
|
||||||
|
|
||||||
cmd := exec.Command(exe, "daemon", "run")
|
// 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.Env = env
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
cmd.Stdout = logFile
|
cmd.Stdout = logFile
|
||||||
cmd.Stderr = logFile
|
cmd.Stderr = logFile
|
||||||
detach(cmd)
|
detach(cmd, breakaway)
|
||||||
if err := cmd.Start(); err != nil {
|
if err = cmd.Start(); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
|
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
|
||||||
}
|
}
|
||||||
exited := make(chan error, 1)
|
exited := make(chan error, 1)
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ package daemon
|
|||||||
import "os/exec"
|
import "os/exec"
|
||||||
|
|
||||||
// detach is a no-op on this platform (no Setsid, no console).
|
// detach is a no-op on this platform (no Setsid, no console).
|
||||||
func detach(*exec.Cmd) {}
|
func detach(*exec.Cmd, bool) {}
|
||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
"syscall"
|
"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).
|
// 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}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,18 @@ import (
|
|||||||
// binding to the caller's console.
|
// binding to the caller's console.
|
||||||
const detachedProcess = 0x00000008
|
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
|
// detach detaches the child from the console: own process group (no Ctrl-C
|
||||||
// from the shell) and no console (no flashing window).
|
// from the shell) and no console (no flashing window).
|
||||||
func detach(cmd *exec.Cmd) {
|
func detach(cmd *exec.Cmd, breakaway bool) {
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
flags := uint32(syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess)
|
||||||
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
|
if breakaway {
|
||||||
|
flags |= createBreakawayFromJob
|
||||||
}
|
}
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: flags}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user