src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/daemon/activate.go (about)

     1  package daemon
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"time"
    10  
    11  	"src.elv.sh/pkg/daemon/daemondefs"
    12  	"src.elv.sh/pkg/daemon/internal/api"
    13  	"src.elv.sh/pkg/fsutil"
    14  )
    15  
    16  var (
    17  	daemonSpawnTimeout     = time.Second
    18  	daemonSpawnWaitPerLoop = 10 * time.Millisecond
    19  
    20  	daemonKillTimeout     = time.Second
    21  	daemonKillWaitPerLoop = 10 * time.Millisecond
    22  )
    23  
    24  type daemonStatus int
    25  
    26  const (
    27  	daemonOK daemonStatus = iota
    28  	sockfileMissing
    29  	sockfileOtherError
    30  	connectionRefused
    31  	connectionOtherError
    32  	daemonOutdated
    33  )
    34  
    35  const connectionRefusedFmt = "Socket file %s exists but refuses requests. This is likely because the daemon was terminated abnormally. Going to remove socket file and re-spawn the daemon.\n"
    36  
    37  // Activate returns a daemon client, either by connecting to an existing daemon,
    38  // or spawning a new one. It always returns a non-nil client, even if there was an error.
    39  func Activate(stderr io.Writer, spawnCfg *daemondefs.SpawnConfig) (daemondefs.Client, error) {
    40  	sockpath := spawnCfg.SockPath
    41  	cl := NewClient(sockpath)
    42  	status, err := detectDaemon(sockpath, cl)
    43  	shouldSpawn := false
    44  
    45  	switch status {
    46  	case daemonOK:
    47  	case sockfileMissing:
    48  		shouldSpawn = true
    49  	case sockfileOtherError:
    50  		return cl, fmt.Errorf("socket file %s inaccessible: %w", sockpath, err)
    51  	case connectionRefused:
    52  		fmt.Fprintf(stderr, connectionRefusedFmt, sockpath)
    53  		err := os.Remove(sockpath)
    54  		if err != nil {
    55  			return cl, fmt.Errorf("failed to remove socket file: %w", err)
    56  		}
    57  		shouldSpawn = true
    58  	case connectionOtherError:
    59  		return cl, fmt.Errorf("unexpected RPC error on socket %s: %w", sockpath, err)
    60  	case daemonOutdated:
    61  		fmt.Fprintln(stderr, "Daemon is outdated; going to kill old daemon and re-spawn")
    62  		err := killDaemon(sockpath, cl)
    63  		if err != nil {
    64  			return cl, fmt.Errorf("failed to kill old daemon: %w", err)
    65  		}
    66  		shouldSpawn = true
    67  	default:
    68  		return cl, fmt.Errorf("code bug: unknown daemon status %d", status)
    69  	}
    70  
    71  	if !shouldSpawn {
    72  		return cl, nil
    73  	}
    74  
    75  	err = spawn(spawnCfg)
    76  	if err != nil {
    77  		return cl, fmt.Errorf("failed to spawn daemon: %w", err)
    78  	}
    79  
    80  	// Wait for daemon to come online
    81  	start := time.Now()
    82  	for time.Since(start) < daemonSpawnTimeout {
    83  		cl.ResetConn()
    84  		status, err := detectDaemon(sockpath, cl)
    85  
    86  		switch status {
    87  		case daemonOK:
    88  			return cl, nil
    89  		case sockfileMissing:
    90  			// Continue waiting
    91  		case sockfileOtherError:
    92  			return cl, fmt.Errorf("socket file %s inaccessible: %w", sockpath, err)
    93  		case connectionRefused:
    94  			// Continue waiting
    95  		case connectionOtherError:
    96  			return cl, fmt.Errorf("unexpected RPC error on socket %s: %w", sockpath, err)
    97  		case daemonOutdated:
    98  			return cl, fmt.Errorf("code bug: newly spawned daemon is outdated")
    99  		default:
   100  			return cl, fmt.Errorf("code bug: unknown daemon status %d", status)
   101  		}
   102  		time.Sleep(daemonSpawnWaitPerLoop)
   103  	}
   104  	return cl, fmt.Errorf("daemon did not come up within %v", daemonSpawnTimeout)
   105  }
   106  
   107  func detectDaemon(sockpath string, cl daemondefs.Client) (daemonStatus, error) {
   108  	_, err := os.Lstat(sockpath)
   109  	if err != nil {
   110  		if os.IsNotExist(err) {
   111  			return sockfileMissing, err
   112  		}
   113  		return sockfileOtherError, err
   114  	}
   115  
   116  	version, err := cl.Version()
   117  	if err != nil {
   118  		if errors.Is(err, errConnRefused) {
   119  			return connectionRefused, err
   120  		}
   121  		return connectionOtherError, err
   122  	}
   123  	if version < api.Version {
   124  		return daemonOutdated, nil
   125  	}
   126  	return daemonOK, nil
   127  }
   128  
   129  func killDaemon(sockpath string, cl daemondefs.Client) error {
   130  	pid, err := cl.Pid()
   131  	if err != nil {
   132  		return fmt.Errorf("kill daemon: %w", err)
   133  	}
   134  	process, err := os.FindProcess(pid)
   135  	if err != nil {
   136  		return fmt.Errorf("kill daemon: %w", err)
   137  	}
   138  	err = process.Signal(os.Interrupt)
   139  	if err != nil {
   140  		return fmt.Errorf("kill daemon: %w", err)
   141  	}
   142  	// Wait until the old daemon has removed the socket file, so that it doesn't
   143  	// inadvertently remove the socket file of the new daemon we will start.
   144  	start := time.Now()
   145  	for time.Since(start) < daemonKillTimeout {
   146  		_, err := os.Lstat(sockpath)
   147  		if err == nil {
   148  			time.Sleep(daemonKillWaitPerLoop)
   149  		} else if os.IsNotExist(err) {
   150  			return nil
   151  		} else {
   152  			return fmt.Errorf("kill daemon: %w", err)
   153  		}
   154  	}
   155  	return fmt.Errorf("kill daemon: daemon did not remove socket within %v", daemonKillTimeout)
   156  }
   157  
   158  // Can be overridden in tests to avoid actual forking.
   159  var startProcess = func(name string, argv []string, attr *os.ProcAttr) error {
   160  	_, err := os.StartProcess(name, argv, attr)
   161  	return err
   162  }
   163  
   164  // Spawns a daemon process in the background by invoking BinPath, passing
   165  // BinPath, DbPath and SockPath as command-line arguments after resolving them
   166  // to absolute paths. The daemon log file is created in RunDir, and the stdout
   167  // and stderr of the daemon is redirected to the log file.
   168  //
   169  // A suitable ProcAttr is chosen depending on the OS and makes sure that the
   170  // daemon is detached from the current terminal, so that it is not affected by
   171  // I/O or signals in the current terminal and keeps running after the current
   172  // process quits.
   173  func spawn(cfg *daemondefs.SpawnConfig) error {
   174  	binPath, err := os.Executable()
   175  	if err != nil {
   176  		return errors.New("cannot find elvish: " + err.Error())
   177  	}
   178  	dbPath, err := abs("DbPath", cfg.DbPath)
   179  	if err != nil {
   180  		return err
   181  	}
   182  	sockPath, err := abs("SockPath", cfg.SockPath)
   183  	if err != nil {
   184  		return err
   185  	}
   186  
   187  	args := []string{
   188  		binPath,
   189  		"-daemon",
   190  		"-db", dbPath,
   191  		"-sock", sockPath,
   192  	}
   193  
   194  	// The daemon does not read any input; open DevNull and use it for stdin. We
   195  	// could also just close the stdin, but on Unix that would make the first
   196  	// file opened by the daemon take FD 0.
   197  	in, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0)
   198  	if err != nil {
   199  		return err
   200  	}
   201  	defer in.Close()
   202  
   203  	out, err := fsutil.ClaimFile(cfg.RunDir, "daemon-*.log")
   204  	if err != nil {
   205  		return err
   206  	}
   207  	defer out.Close()
   208  
   209  	procattrs := procAttrForSpawn([]*os.File{in, out, out})
   210  
   211  	err = startProcess(binPath, args, procattrs)
   212  	return err
   213  }
   214  
   215  func abs(name, path string) (string, error) {
   216  	if path == "" {
   217  		return "", fmt.Errorf("%s is required for spawning daemon", name)
   218  	}
   219  	absPath, err := filepath.Abs(path)
   220  	if err != nil {
   221  		return "", fmt.Errorf("cannot resolve %s to absolute path: %s", name, err)
   222  	}
   223  	return absPath, nil
   224  }