Add new "daemon" package to handle detaching on Linux and Unix systems.
Supports writing pid files, socket pair based startup messaging, and
systemd notification on Linux.
--- /dev/null
+//go:build linux && !arm64
+
+package daemon
+
+import "syscall"
+
+const DefaultPidFileDirectory = "/run"
+const DefaultLogDirectory = "/var/log"
+
+func fork() (uintptr, uintptr, error) {
+ return syscall.Syscall(syscall.SYS_FORK, 0, 0, 0)
+}
+
+func dup2(oldfd, newfd int) error {
+ return syscall.Dup2(oldfd, newfd)
+}
--- /dev/null
+//go:build linux && arm64
+
+package daemon
+
+import (
+ "fmt"
+ "syscall"
+)
+
+const DefaultPidFileDirectory = "/run"
+const DefaultLogDirectory = "/var/log"
+
+const SYS_FORK = 2
+const SYS_DUP2 = 63
+
+func fork() (uintptr, uintptr, error) {
+ return syscall.Syscall(SYS_FORK, 0, 0, 0)
+}
+
+func dup2(oldfd int, newfd int) (err error) {
+ _, _, e1 := syscall.Syscall(SYS_DUP2, uintptr(oldfd), uintptr(newfd), 0)
+ if e1 != 0 {
+ err = fmt.Errorf("dup2: errno %d", e1)
+ }
+ return
+}
--- /dev/null
+//go:build !linux && !win32
+
+package daemon
+
+import "syscall"
+
+const DefaultPidFileDirectory = "/var/run"
+const DefaultLogDirectory = "/var/log"
+
+func fork() (uintptr, uintptr, error) {
+ return syscall.Syscall(syscall.SYS_FORK, 0, 0, 0)
+}
+
+func dup2(oldfd, newfd int) error {
+ return syscall.Dup2(oldfd, newfd)
+}
--- /dev/null
+package daemon
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "go.fuhry.dev/runtime/constants"
+ "go.fuhry.dev/runtime/utils/hashset"
+ "go.fuhry.dev/runtime/utils/log"
+)
+
+const logDirDefaultMode os.FileMode = 0700
+const logFileDefaultMode os.FileMode = 0600
+
+var logger *log.Logger
+
+var stdoutReopenPath, stderrReopenPath string
+var detach bool
+
+func init() {
+ logger = log.WithPrefix("daemon")
+
+ execName, err := os.Executable()
+ if err != nil {
+ execName = "main"
+ }
+
+ defaultLogDirectory := path.Join(DefaultLogDirectory, constants.OrgSlug)
+ if strings.HasPrefix(execName, "/home/") {
+ defaultLogDirectory = path.Dir(execName)
+ }
+ defaultStdoutFile := path.Join(defaultLogDirectory, path.Base(execName)+".log")
+ defaultStderrFile := path.Join(defaultLogDirectory, path.Base(execName)+".err")
+
+ flag.BoolVar(&detach, "detach", true, "Set to false to run in foreground (don't fork and detach)")
+ flag.StringVar(&stdoutReopenPath, "log.out", defaultStdoutFile, "File to reopen as standard output after daemonizing")
+ flag.StringVar(&stderrReopenPath, "log.err", defaultStderrFile, "File to reopen as standard error after daemonizing")
+}
+
+func createLogDirs() {
+ // create log directories, if they don't exist
+ logDirs := hashset.NewHashSet[string]()
+ logDirs.Add(path.Dir(stdoutReopenPath))
+ logDirs.Add(path.Dir(stderrReopenPath))
+ for _, dir := range logDirs.AsSlice() {
+ stat, err := os.Stat(dir)
+ if err == nil {
+ // log directory exists, is it a directory?
+ if !stat.IsDir() {
+ logger.Warnf("cannot open log: %q exists but is not a directory", dir)
+ }
+ } else if errors.Is(err, os.ErrNotExist) {
+ // log directory doesn't exist
+ err = os.MkdirAll(dir, logDirDefaultMode)
+ if err != nil {
+ logger.Warnf("failed to create log directory %q: %v", dir, err)
+ }
+ } else {
+ logger.Warnf("failed to stat log directory %q: %v", dir, err)
+ }
+ }
+}
+
+// ReopenStdio redirects the standard output and standard error file descriptors to the
+// log files set on the command line.
+func ReopenStdio() {
+ createLogDirs()
+
+ newStdout, err := os.OpenFile(stdoutReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode)
+ if err != nil {
+ logger.Warningf("failed to open output logfile: %v", err)
+ goto skipStdout
+ }
+
+ err = dup2(int(newStdout.Fd()), int(os.Stdout.Fd()))
+ if err != nil {
+ logger.Warningf("failed to dup2() for stdout: %v", err)
+ }
+skipStdout:
+
+ newStderr, err := os.OpenFile(stderrReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode)
+ if err != nil {
+ logger.Warningf("failed to open error logfile: %v", err)
+ goto skipStderr
+ }
+
+ err = dup2(int(newStderr.Fd()), int(os.Stderr.Fd()))
+ if err != nil {
+ logger.Warningf("failed to dup2() for stderr: %v", err)
+ }
+skipStderr:
+
+ return
+}
+
+// Detach re-executes the program that is currently running with standard output and standard error
+// redirected to log files.
+//
+// We don't directly use fork, because fork only copies the current thread and that messes up the
+// golang runtime quite a bit. Instead, we create a new process and use a socket pair for IPC to
+// have the child process notify us that it's alive.
+//
+// This is probably *quite* unix-specific; it was tested on Linux and OpenBSD, and nowhere else.
+func Detach() error {
+ if !detach {
+ return nil
+ }
+
+ if socketfdStr := os.Getenv("GODAEMON_SOCKETFD"); socketfdStr != "" {
+ socketfd, err := strconv.Atoi(socketfdStr)
+ if err != nil {
+ return err
+ }
+
+ parentConn, err := fdToFileConn(socketfd)
+ if err != nil {
+ return err
+ }
+
+ parentConn.Write([]byte(strconv.Itoa(os.Getpid()) + "\n"))
+ parentConn.Close()
+
+ return nil
+ }
+
+ logger.V(1).Infof("stdout will be redirected to: %s", stdoutReopenPath)
+ logger.V(1).Infof("stderr will be redirected to: %s", stderrReopenPath)
+
+ createLogDirs()
+ stdout, err := os.OpenFile(stdoutReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode)
+ if err != nil {
+ return err
+ }
+ defer stdout.Close()
+
+ stderr, err := os.OpenFile(stderrReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode)
+ if err != nil {
+ return err
+ }
+ defer stderr.Close()
+
+ executable, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get calling executable: %v", err)
+ }
+
+ left, right, err := socketpair()
+ if err != nil {
+ return fmt.Errorf("failed to get socket pair: %v", err)
+ }
+
+ leftConn, err := fdToFileConn(left)
+ if err != nil {
+ return err
+ }
+ defer leftConn.Close()
+ defer syscall.Close(right)
+
+ env := append(os.Environ(), "GODAEMON_SOCKETFD=3")
+
+ attrs := &syscall.ProcAttr{
+ Dir: "/",
+ Env: env,
+ Files: []uintptr{
+ uintptr(syscall.Stdin),
+ uintptr(stdout.Fd()),
+ uintptr(stderr.Fd()),
+ uintptr(right),
+ },
+ Sys: &syscall.SysProcAttr{
+ Setsid: true,
+ },
+ }
+
+ pid, err := syscall.ForkExec(executable, os.Args, attrs)
+ if err != nil {
+ return err
+ }
+
+ buf := make([]byte, 32)
+ n, err := leftConn.Read(buf)
+ if err != nil {
+ return err
+ }
+ buf = buf[0 : n-1]
+
+ if checkPid, err := strconv.Atoi(string(buf)); err == nil {
+ if checkPid == pid {
+ logger.Infof("successfully daemonized with pid %d", pid)
+ os.Exit(0)
+ }
+
+ return fmt.Errorf("got unexpected child pid: %d != %d", checkPid, pid)
+ }
+
+ return fmt.Errorf("got unexpected message through daemon socket: %s", string(buf))
+}
--- /dev/null
+package daemon
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+var pidFilePath string
+var pidFileMode os.FileMode = 0644
+
+var enforceSingleInstance bool
+var replaceInstance bool
+
+func WritePidFile() error {
+ pid, err := ReadPidFile()
+ if err == nil && processIsAlive(pid) {
+ return fmt.Errorf("refusing to overwrite existing pid file with alive process %d", pid)
+ }
+
+ pid = os.Getpid()
+ pidBytes := []byte(strconv.Itoa(pid))
+
+ return os.WriteFile(pidFilePath, pidBytes, pidFileMode)
+}
+
+func PidFilePath() string {
+ return pidFilePath
+}
+
+func ReadPidFile() (int, error) {
+ fp, err := os.OpenFile(pidFilePath, os.O_RDONLY|os.O_EXCL, pidFileMode)
+ if err != nil {
+ return -1, err
+ }
+ defer fp.Close()
+
+ pidBytes, err := io.ReadAll(fp)
+ if err != nil && err != io.EOF {
+ return -1, err
+ }
+
+ pid, err := strconv.Atoi(string(pidBytes))
+ if err != nil {
+ return -1, err
+ }
+
+ return pid, nil
+}
+
+func CleanupPidFile() error {
+ pid, err := ReadPidFile()
+ if err != nil {
+ return err
+ }
+
+ if pid != os.Getpid() {
+ return fmt.Errorf(
+ "refusing to remove pid file %q: PID mismatch: %d (our PID) != %d (recorded PID)",
+ pidFilePath, os.Getpid(), pid)
+ }
+
+ return os.Remove(pidFilePath)
+}
+
+func Singleton() {
+ pid, err := ReadPidFile()
+ if err != nil || !processIsAlive(pid) {
+ return
+ }
+
+}
+
+func processIsAlive(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ defer proc.Release()
+
+ return proc.Signal(syscall.Signal(0)) == nil
+}
+
+func init() {
+ execName, err := os.Executable()
+ if err != nil {
+ execName = "main"
+ }
+ pidFileDirectory := DefaultPidFileDirectory
+ if strings.HasPrefix(execName, "/home/") {
+ pidFileDirectory = path.Dir(execName)
+ }
+ defaultPidFilePath := path.Join(pidFileDirectory, path.Base(execName)+".pid")
+ flag.StringVar(&pidFilePath, "pid.file", defaultPidFilePath, "Path to PID file to write after startup")
+ flag.BoolVar(&enforceSingleInstance, "pid.singleton", true,
+ "if true, enforce that only a single instance of this program runs (based on pid file)")
+ flag.BoolVar(&replaceInstance, "pid.replace", false,
+ "if true, when another running instance is found, kill it instead of exiting with an error")
+}
--- /dev/null
+//go:build linux
+
+package daemon
+
+import (
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+)
+
+func processIsSameExecutable(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ defer proc.Release()
+
+ execPath, err := os.Executable()
+ if err != nil {
+ return false
+ }
+
+ // linux: use /proc/<pid>/exe
+ if target, err := os.Readlink(path.Join("/proc", strconv.Itoa(pid), "exe")); err == nil {
+ abspath, err := filepath.Abs(target)
+ return err == nil && abspath == execPath
+ }
+
+ return false
+}
--- /dev/null
+//go:build opsnbsd || freebsd || netbsd
+
+package daemon
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/mitchellh/go-ps"
+)
+
+func processIsSameExecutable(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ defer proc.Release()
+
+ execPath, err := os.Executable()
+ if err != nil {
+ return false
+ }
+
+ // other platforms: use mitchellh/go-ps
+ if proc, err := ps.FindProcess(pid); err == nil {
+ abspath, err := filepath.Abs(proc.Executable())
+ return err == nil && abspath == execPath
+ }
+
+ return false
+}
--- /dev/null
+package daemon
+
+import (
+ "net"
+ "os"
+ "syscall"
+)
+
+func socketpair() (int, int, error) {
+ fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0)
+ if err != nil {
+ return -1, -1, err
+ }
+
+ return fds[0], fds[1], nil
+}
+
+func fdToFileConn(fd int) (net.Conn, error) {
+ f := os.NewFile(uintptr(fd), "")
+ defer f.Close()
+ return net.FileConn(f)
+}