Desktop applications that embed server-side backends face a fundamental challenge: they must manage a child process’s entire lifecycle—discovery, spawning, health monitoring, output capture, and graceful shutdown—while presenting a seamless single-application experience to the user. The Embedded Process Manager pattern encapsulates this complexity into a reusable component that handles OS-specific binary resolution, child process supervision, health polling, and forced termination on timeout.
This pattern is common in desktop applications that bundle a local server: Electron apps wrapping Node.js services, desktop clients embedding database engines, and hybrid apps that pair a native GUI with a Go or Rust backend. The key challenge is reliability: the child process must start before the UI connects, its output must be captured for debugging, and it must be terminated cleanly when the parent exits—even if the parent crashes.
In the a typical desktop application, the GUI bundles the server engine as a native binary alongside it. The Manager component locates the correct server binary for the current OS (macOS, Windows, Linux), spawns swarm --metadata-api as a child process, mirrors stdout/stderr to the application log, polls /api/v1/health for readiness, and tears it down gracefully (SIGTERM followed by SIGKILL after a 5-second timeout) when the desktop app exits. This gives users a one-click search experience without requiring separate server installation.
Desktop apps bundle platform-specific binaries in their application package. On macOS, binaries live inside AppName.app/Contents/Resources/; on Windows, alongside the .exe in the installation directory; on Linux, in /usr/lib/appname/ or relative to the executable. The Manager resolves the correct binary path at runtime using runtime.GOOS and the executable’s location, handling path differences across platforms.
The lifecycle follows a state machine: Locating → Starting → Waiting for Ready → Running → Stopping → Stopped. Each transition has defined success and failure paths. The “Waiting for Ready” phase is critical: the child process may take seconds to initialize its data structures, open network ports, and become ready to serve requests. The Manager must not signal readiness to the GUI until the child’s health endpoint responds successfully.
The child process’s stdout and stderr streams are captured and forwarded to the application’s logging system. This serves multiple purposes: debugging startup failures, monitoring runtime behavior, and providing crash diagnostics. The mirroring must be non-blocking—a slow log consumer must not cause the child process to block on writes to its output pipes.
The Manager polls the child’s HTTP health endpoint at a fixed interval (typically 100–500 ms) until it receives a 200 response. This is more reliable than watching stdout for a “ready” message, because the health check confirms that the HTTP server is actually bound to the port and accepting connections. A maximum retry count or timeout prevents infinite waiting if the process fails to start.
When the desktop app exits, the Manager sends SIGTERM (Unix) or a console control event (Windows) to the child process, giving it a chance to flush data, close connections, and exit cleanly. If the child does not exit within the timeout (typically 5 seconds), the Manager escalates to SIGKILL (Unix) or TerminateProcess (Windows). This two-phase approach balances data safety with the need to avoid zombie processes.
On Unix systems, spawning a child process in its own process group (using Setpgid) ensures that sending a signal to the group kills all descendants, not just the direct child. This is essential when the child process spawns its own workers. Without process group management, orphaned grandchild processes can linger after the parent exits, holding ports and file locks.
The Manager encapsulates all process management state. Binary resolution uses the executable’s directory as the base path and applies OS-specific conventions:
package manager
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
)
type Manager struct {
binaryPath string
cmd *exec.Cmd
healthURL string
port int
mu sync.Mutex
state State
logger Logger
stopTimeout time.Duration
}
type State int
const (
StateStopped State = iota
StateStarting
StateReady
StateStopping
)
func New(port int, logger Logger) (*Manager, error) {
binPath, err := resolveBinary()
if err != nil {
return nil, fmt.Errorf("locating server binary: %w", err)
}
return &Manager{
binaryPath: binPath,
port: port,
healthURL: fmt.Sprintf("http://127.0.0.1:%d/api/v1/health", port),
logger: logger,
stopTimeout: 5 * time.Second,
}, nil
}
func resolveBinary() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
execDir := filepath.Dir(execPath)
var binaryName string
var searchPaths []string
switch runtime.GOOS {
case "darwin":
binaryName = "myapp"
// Inside .app bundle: Contents/MacOS/app -> Contents/Resources/myapp
searchPaths = []string{
filepath.Join(execDir, "..", "Resources", binaryName),
filepath.Join(execDir, binaryName),
}
case "windows":
binaryName = "myapp.exe"
searchPaths = []string{
filepath.Join(execDir, binaryName),
filepath.Join(execDir, "bin", binaryName),
}
default: // linux
binaryName = "myapp"
searchPaths = []string{
filepath.Join(execDir, binaryName),
filepath.Join(execDir, "..", "lib", binaryName),
filepath.Join("/usr/lib/myapp", binaryName),
}
}
for _, p := range searchPaths {
if info, err := os.Stat(p); err == nil && !info.IsDir() {
return p, nil
}
}
return "", fmt.Errorf("binary %s not found in: %v", binaryName, searchPaths)
}
The Start method spawns the child process, wires up output capture, and polls the health endpoint until the service is ready:
func (m *Manager) Start(ctx context.Context) error {
m.mu.Lock()
if m.state != StateStopped {
m.mu.Unlock()
return fmt.Errorf("manager in state %d, expected stopped", m.state)
}
m.state = StateStarting
m.mu.Unlock()
// Build the command with arguments
m.cmd = exec.CommandContext(ctx, m.binaryPath,
"swarm",
"--metadata-api",
"--port", fmt.Sprintf("%d", m.port),
)
// Set up process group for clean shutdown (Unix)
m.setProcAttr()
// Capture stdout and stderr
stdout, err := m.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("creating stdout pipe: %w", err)
}
stderr, err := m.cmd.StderrPipe()
if err != nil {
return fmt.Errorf("creating stderr pipe: %w", err)
}
// Start the process
if err := m.cmd.Start(); err != nil {
m.mu.Lock()
m.state = StateStopped
m.mu.Unlock()
return fmt.Errorf("starting server: %w", err)
}
m.logger.Info("server process started", "pid", m.cmd.Process.Pid)
// Mirror output in background goroutines
go m.mirrorOutput("stdout", stdout)
go m.mirrorOutput("stderr", stderr)
// Wait for the process to exit in background (detect crashes)
go m.watchProcess()
// Poll health endpoint until ready
if err := m.waitForReady(ctx); err != nil {
m.Stop() // Clean up the failed process
return fmt.Errorf("server failed to become ready: %w", err)
}
m.mu.Lock()
m.state = StateReady
m.mu.Unlock()
m.logger.Info("server is ready", "port", m.port)
return nil
}
func (m *Manager) waitForReady(ctx context.Context) error {
client := &http.Client{Timeout: 1 * time.Second}
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
deadline := time.After(30 * time.Second)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline:
return fmt.Errorf("health check timed out after 30s")
case <-ticker.C:
resp, err := client.Get(m.healthURL)
if err != nil {
m.logger.Debug("health check pending", "err", err)
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
m.logger.Debug("health check non-200", "status", resp.StatusCode)
}
}
}
The Stop method implements the two-phase shutdown: SIGTERM first, then SIGKILL after a timeout:
func (m *Manager) Stop() error {
m.mu.Lock()
if m.state == StateStopped || m.state == StateStopping {
m.mu.Unlock()
return nil
}
m.state = StateStopping
m.mu.Unlock()
defer func() {
m.mu.Lock()
m.state = StateStopped
m.mu.Unlock()
}()
if m.cmd == nil || m.cmd.Process == nil {
return nil
}
pid := m.cmd.Process.Pid
m.logger.Info("stopping server", "pid", pid)
// Phase 1: Send SIGTERM (or platform equivalent)
if err := m.signalStop(); err != nil {
m.logger.Warn("failed to send stop signal", "err", err)
}
// Wait for process to exit with timeout
done := make(chan error, 1)
go func() {
done <- m.cmd.Wait()
}()
select {
case err := <-done:
m.logger.Info("server exited gracefully", "pid", pid)
return err
case <-time.After(m.stopTimeout):
m.logger.Warn("server did not exit in time, force killing", "pid", pid)
}
// Phase 2: Force kill
if err := m.forceKill(); err != nil {
return fmt.Errorf("force killing server (pid %d): %w", pid, err)
}
// Wait for forced exit
select {
case <-done:
m.logger.Info("server force killed", "pid", pid)
case <-time.After(2 * time.Second):
m.logger.Error("server still alive after SIGKILL", "pid", pid)
}
return nil
}
Unix and Windows handle process signals differently. On Unix, syscall.Kill(-pid, syscall.SIGTERM) sends SIGTERM to the entire process group (note the negative PID). On Windows, there is no SIGTERM equivalent; you use GenerateConsoleCtrlEvent for graceful shutdown or TerminateProcess for forced kill. The Manager abstracts this behind signalStop() and forceKill() methods with build-tag-separated implementations.
A desktop app can bundle the server engine for offline, local-first operation. When the user launches the app, the Manager starts the backend, and the GUI connects to http://127.0.0.1:PORT for queries. The user never sees or manages the backend process. If the server crashes, the Manager detects it through the watchProcess goroutine and can restart it automatically or display an error in the GUI.
IDEs and development tools commonly embed language servers (LSP), linters, and debuggers as child processes. The same Manager pattern applies: locate the binary (potentially downloading it on first run), spawn it with appropriate flags, poll for readiness over a protocol-specific channel, and ensure clean shutdown. VS Code manages dozens of child processes this way.
Applications that bundle databases (SQLite via a separate process, embedded Postgres, DuckDB) use this pattern to manage the database lifecycle. The Manager ensures the database is ready before the application tries to connect, handles crash recovery, and ensures the database flushes its write-ahead log during shutdown.
In containerized environments, sidecars (log collectors, service mesh proxies, metric agents) follow the same lifecycle management pattern. While Kubernetes manages sidecar containers natively, bare-metal and VM deployments benefit from application-level process management with health checks and graceful shutdown.
Capture child process output and integrate it with the parent’s structured logging:
func (m *Manager) mirrorOutput(stream string, r io.Reader) {
scanner := bufio.NewScanner(r)
// Increase buffer for long log lines
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
// Try to parse structured JSON logs from the child
var logEntry map[string]interface{}
if err := json.Unmarshal([]byte(line), &logEntry); err == nil {
level, _ := logEntry["level"].(string)
msg, _ := logEntry["msg"].(string)
m.logger.Log(parseLevel(level), "[server] "+msg,
"stream", stream,
"raw", logEntry,
)
} else {
// Plain text fallback
m.logger.Info("[server] "+line, "stream", stream)
}
}
if err := scanner.Err(); err != nil {
m.logger.Warn("output scanner error", "stream", stream, "err", err)
}
}
func (m *Manager) watchProcess() {
err := m.cmd.Wait()
m.mu.Lock()
prevState := m.state
m.state = StateStopped
m.mu.Unlock()
if prevState == StateReady {
// Unexpected crash while running
exitCode := -1
if m.cmd.ProcessState != nil {
exitCode = m.cmd.ProcessState.ExitCode()
}
m.logger.Error("server crashed unexpectedly",
"exit_code", exitCode,
"err", err,
)
}
}
For critical child processes, implement automatic restart with exponential backoff to handle transient failures without spin-looping on persistent ones:
func (m *Manager) StartWithAutoRestart(ctx context.Context) error {
backoff := 1 * time.Second
maxBackoff := 30 * time.Second
consecutiveFailures := 0
for {
err := m.Start(ctx)
if err != nil {
consecutiveFailures++
m.logger.Error("server failed to start",
"err", err,
"attempt", consecutiveFailures,
"backoff", backoff,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
continue
}
// Reset backoff on successful start
backoff = 1 * time.Second
consecutiveFailures = 0
// Wait for the process to exit
<-m.waitForExit()
m.mu.Lock()
state := m.state
m.mu.Unlock()
if state == StateStopping {
// Intentional shutdown, don't restart
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
m.logger.Info("restarting server after unexpected exit")
}
}
}
Go build tags (//go:build darwin, //go:build windows) let you write platform-specific implementations in separate files. The Manager uses this for signal handling (signal_unix.go, signal_windows.go), binary path resolution, and process attribute configuration. Each file compiles only on its target platform, avoiding runtime OS checks and ensuring type safety for platform-specific syscalls.
GOGC or equivalent tuning on the child process to control its memory footprint.Stop(), the child process becomes an orphan. On Unix, set the child’s Pdeathsig to SIGTERM so it receives a signal when the parent dies. On macOS, Pdeathsig is not supported; use a heartbeat mechanism where the child exits if it cannot reach the parent.os.DevNull if you truly don’t need the output.Stop() is called before Start() finishes (e.g., the user closes the app during startup), you need careful state management. Use a mutex to protect state transitions and check the state at each phase of startup../bin/myapp) break in packaged applications. Always resolve paths relative to the executable’s location using os.Executable(), not the working directory (os.Getwd()).