From f63ad05766f508135e4dd7e14d12f8a246606306 Mon Sep 17 00:00:00 2001 From: Allen Vailliencourt Date: Sat, 24 Jan 2026 17:58:50 -0500 Subject: [PATCH] Add detach mode for running hyscale in the background This adds the ability to run hyscale as a background daemon process: Features: - New -detach/-d flag to start process in background - New -stop flag to gracefully stop a running detached instance - PID file management (.hyscale.pid) prevents duplicate instances - Logs redirect to hyscale.log when running in detached mode - Process survives terminal/SSH disconnects - Clean shutdown handling with automatic PID file cleanup - Graceful signal handling (SIGINT/SIGTERM) Usage: ./hyscale -detach # Start in background ./hyscale -stop # Stop running instance The detached process continues running independently and logs all output to hyscale.log in the same directory as the executable. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 16 ++++- cmd/hyscale/main.go | 168 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 64500d2..df884e6 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,24 @@ Download the latest release for your platform and run it: -``` +```bash ./hyscale ``` +**Run in background** (detached mode): + +```bash +./hyscale -detach +# or short form +./hyscale -d +``` + +When running in detached mode: + +- Logs are written to `hyscale.log` in the same directory +- Stop with: `./hyscale -stop` +- The process continues running even if you close the terminal + ### 2. Authenticate with Tailscale On first run, you'll see an authentication URL: diff --git a/cmd/hyscale/main.go b/cmd/hyscale/main.go index cbf7971..e5944a9 100644 --- a/cmd/hyscale/main.go +++ b/cmd/hyscale/main.go @@ -4,6 +4,7 @@ import ( "context" "embed" "flag" + "fmt" "io/fs" "log" "net" @@ -43,9 +44,24 @@ func main() { stateDir = flag.String("state-dir", "", "Tailscale state directory (default: tsnet default)") serverDir = flag.String("server-dir", "server", "Hytale server directory") noTLS = flag.Bool("notls", false, "Disable TLS (use HTTP on port 80 instead of HTTPS on 443)") + detach = flag.Bool("detach", false, "Run in background (detached mode)") + detachShort = flag.Bool("d", false, "Run in background (shortcut for -detach)") + stop = flag.Bool("stop", false, "Stop a running detached instance") ) flag.Parse() + // Handle -stop flag + if *stop { + handleStop(*configPath) + return + } + + // Handle -detach flag + if *detach || *detachShort { + handleDetach(*configPath) + return + } + // Resolve absolute path for config absConfigPath, err := filepath.Abs(*configPath) if err != nil { @@ -389,6 +405,10 @@ authenticated: // Close log manager logMgr.Close() + // Remove PID file if it exists + pidFile := getPidFilePath(absConfigPath) + os.Remove(pidFile) + // Shutdown HTTP server httpServer.Shutdown(context.Background()) }() @@ -401,3 +421,151 @@ authenticated: log.Println("Goodbye!") } + +// getPidFilePath returns the path to the PID file based on config path +func getPidFilePath(configPath string) string { + absPath, _ := filepath.Abs(configPath) + dir := filepath.Dir(absPath) + return filepath.Join(dir, ".hyscale.pid") +} + +// handleDetach forks the process to run in background +func handleDetach(configPath string) { + pidFile := getPidFilePath(configPath) + + // Check if already running + if pid, err := os.ReadFile(pidFile); err == nil { + log.Printf("hyscale appears to be already running (PID %s)", string(pid)) + log.Printf("If not, remove %s and try again", pidFile) + os.Exit(1) + } + + // Prepare to re-execute ourselves + executable, err := os.Executable() + if err != nil { + log.Fatalf("Failed to get executable path: %v", err) + } + + // Build args without -detach/-d flags + args := []string{} + skipNext := false + for i, arg := range os.Args[1:] { + if skipNext { + skipNext = false + continue + } + if arg == "-detach" || arg == "-d" || arg == "--detach" { + continue + } + if arg == "-config" || arg == "--config" { + args = append(args, arg) + skipNext = true + if i+1 < len(os.Args[1:]) { + args = append(args, os.Args[i+2]) + } + continue + } + args = append(args, arg) + } + + // Ensure config flag is present + hasConfig := false + for _, arg := range args { + if arg == "-config" || arg == "--config" { + hasConfig = true + break + } + } + if !hasConfig { + args = append([]string{"-config", configPath}, args...) + } + + // Set up log file + logDir := filepath.Dir(pidFile) + logFile := filepath.Join(logDir, "hyscale.log") + outFile, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + + // Start the process + attr := &os.ProcAttr{ + Files: []*os.File{nil, outFile, outFile}, // stdin=nil, stdout=logFile, stderr=logFile + } + + process, err := os.StartProcess(executable, append([]string{executable}, args...), attr) + if err != nil { + outFile.Close() + log.Fatalf("Failed to start background process: %v", err) + } + + // Write PID file + if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", process.Pid)), 0644); err != nil { + process.Kill() + outFile.Close() + log.Fatalf("Failed to write PID file: %v", err) + } + + outFile.Close() + + log.Printf("hyscale started in background (PID %d)", process.Pid) + log.Printf("Logs: %s", logFile) + log.Printf("Stop with: %s -stop", os.Args[0]) +} + +// handleStop stops a running detached instance +func handleStop(configPath string) { + pidFile := getPidFilePath(configPath) + + // Read PID file + pidData, err := os.ReadFile(pidFile) + if err != nil { + if os.IsNotExist(err) { + log.Printf("No running instance found (no PID file at %s)", pidFile) + } else { + log.Fatalf("Failed to read PID file: %v", err) + } + os.Exit(1) + } + + var pid int + if _, err := fmt.Sscanf(string(pidData), "%d", &pid); err != nil { + log.Fatalf("Invalid PID file content: %v", err) + } + + // Find the process + process, err := os.FindProcess(pid) + if err != nil { + log.Fatalf("Failed to find process: %v", err) + } + + // Send interrupt signal (SIGINT on Unix, similar on Windows) + log.Printf("Stopping hyscale (PID %d)...", pid) + if err := process.Signal(syscall.SIGINT); err != nil { + // Try SIGTERM if SIGINT fails + if err := process.Signal(syscall.SIGTERM); err != nil { + log.Fatalf("Failed to stop process: %v", err) + } + } + + // Wait a bit for graceful shutdown + time.Sleep(2 * time.Second) + + // Check if process is still running + if err := process.Signal(syscall.Signal(0)); err == nil { + log.Printf("Process still running, waiting...") + time.Sleep(3 * time.Second) + + // Check again + if err := process.Signal(syscall.Signal(0)); err == nil { + log.Printf("Warning: Process may still be running. You may need to kill it manually.") + } + } + + // Remove PID file + if err := os.Remove(pidFile); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: Failed to remove PID file: %v", err) + } + + log.Printf("hyscale stopped") +}