fix(daemon/windows): break out of parent shell Job Object so daemon survives

Approved and merged via Multica after CI passed.
This commit is contained in:
songlei
2026-04-27 17:47:30 +08:00
committed by GitHub
parent d63e7c1c45
commit 4c81fbed2b
3 changed files with 94 additions and 18 deletions

View File

@@ -174,11 +174,29 @@ func runDaemonBackground(cmd *cobra.Command) error {
child := exec.Command(exePath, args...)
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr()
// On Windows we want to break the child out of the parent shell's Job
// Object so the daemon survives parent-shell exit. If the parent's Job
// has not granted BREAKAWAY_OK, CreateProcess returns
// ERROR_ACCESS_DENIED — fall back to spawning without breakaway, which
// matches the pre-fix behaviour. On Unix the bool is a no-op.
child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
if isAccessDeniedSpawnErr(err) {
// Retry without breakaway. Reset the cmd state — exec.Cmd is
// not safe to Start() twice, so build a fresh one.
child = exec.Command(exePath, args...)
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr(false)
if err := child.Start(); err != nil {
logFile.Close()
return fmt.Errorf("start daemon (no breakaway): %w", err)
}
} else {
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
}
}
logFile.Close()
pid := child.Process.Pid
@@ -327,12 +345,26 @@ func runDaemonForeground(cmd *cobra.Command) error {
}
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr()
// Break out of the parent's Job Object on Windows; see the
// runDaemonBackground call site for rationale.
child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
logFile.Close()
logger.Error("failed to start new daemon", "error", err)
return nil
if isAccessDeniedSpawnErr(err) {
child = exec.Command(restartBin, args...)
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr(false)
if err := child.Start(); err != nil {
logFile.Close()
logger.Error("failed to start new daemon (no breakaway)", "error", err)
return nil
}
} else {
logFile.Close()
logger.Error("failed to start new daemon", "error", err)
return nil
}
}
logFile.Close()
child.Process.Release()

View File

@@ -11,10 +11,21 @@ import (
"syscall"
)
func daemonSysProcAttr() *syscall.SysProcAttr {
// daemonSysProcAttr returns the attributes used when spawning the background
// daemon. The withBreakaway argument exists only to share a signature with
// the Windows version (where it controls CREATE_BREAKAWAY_FROM_JOB); on
// Unix Setsid alone is sufficient to detach the child from its parent's
// session and process group.
func daemonSysProcAttr(_ bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}
// isAccessDeniedSpawnErr is always false on Unix. The Windows version
// looks for ERROR_ACCESS_DENIED to detect "parent Job Object disallowed
// breakaway" and trigger the breakaway-disabled retry; that retry is a
// no-op on Unix.
func isAccessDeniedSpawnErr(_ error) bool { return false }
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
}

View File

@@ -4,6 +4,7 @@ package main
import (
"context"
"errors"
"io"
"os"
"os/signal"
@@ -12,25 +13,57 @@ import (
)
const (
// detachedProcess severs the inherited console so closing the parent
// cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon.
detachedProcess = 0x00000008
sigBreak = syscall.Signal(0x15)
// createBreakawayFromJob lets the daemon escape its parent shell's Job
// Object. Modern Windows Terminal / cmd.exe / PowerShell host the
// processes they spawn inside a Job Object that has KILL_ON_JOB_CLOSE
// set, so when the parent shell exits the kernel kills every process
// inside that job — including a child we tried to "detach" with
// detachedProcess alone. detachedProcess only severs the console, not
// the Job Object inheritance. Adding createBreakawayFromJob makes
// CreateProcess place the new process outside the parent's Job, so
// the daemon survives parent-shell exit.
//
// If the parent's Job has not granted BREAKAWAY_OK, CreateProcess
// returns ERROR_ACCESS_DENIED. In that case the caller falls back to
// detachedProcess alone — the daemon is then at the mercy of the
// parent's Job lifecycle, which is the pre-fix behaviour.
createBreakawayFromJob = 0x01000000
sigBreak = syscall.Signal(0x15)
)
// daemonSysProcAttr returns the attributes used when spawning the background
// daemon. DETACHED_PROCESS severs the inherited console so closing the parent
// cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon.
// Because the detached daemon shares no console with the stop caller,
// `daemon stop` talks to it via the HTTP /shutdown endpoint rather than
// GenerateConsoleCtrlEvent. The daemon's stdout/stderr are already
// redirected to the log file before Start() is called, so losing the
// console is safe.
func daemonSysProcAttr() *syscall.SysProcAttr {
// daemon. The default is detachedProcess + createBreakawayFromJob so the
// daemon survives both the parent's console close and the parent's Job
// Object close. The daemon's stdout/stderr are already redirected to the
// log file before Start() is called, so losing the console is safe; and
// `daemon stop` talks to it via HTTP /shutdown rather than
// GenerateConsoleCtrlEvent, so losing the process group is also safe.
//
// The withBreakaway argument exists so the caller can retry with
// withBreakaway=false when CreateProcess fails with ERROR_ACCESS_DENIED
// (the parent Job does not allow breakaway).
func daemonSysProcAttr(withBreakaway bool) *syscall.SysProcAttr {
flags := uint32(detachedProcess)
if withBreakaway {
flags |= createBreakawayFromJob
}
return &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: detachedProcess,
CreationFlags: flags,
}
}
// isAccessDeniedSpawnErr reports whether the error returned from
// (*exec.Cmd).Start() is the Windows ERROR_ACCESS_DENIED, which is what
// CreateProcess returns when CREATE_BREAKAWAY_FROM_JOB is requested but
// the parent's Job Object has not set JOB_OBJECT_LIMIT_BREAKAWAY_OK.
func isAccessDeniedSpawnErr(err error) bool {
return errors.Is(err, syscall.ERROR_ACCESS_DENIED)
}
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, os.Interrupt, sigBreak)
}