Embedded Database Process Manager

Systems Programming intermediate Building with AI ~30 min read

Overview

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.

Core Concepts

OS-Specific Binary Resolution

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.

Child Process Lifecycle

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.

Output Mirroring

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.

Key Concept: Health Polling for Readiness

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.

Key Concept: Graceful Shutdown with Escalation

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.

Key Concept: Process Group Management

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.

How It Works

Manager Structure and Binary Resolution

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)
}

Spawning and Health Polling

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)
        }
    }
}

Graceful Shutdown with Escalation

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
}
Key Concept: Platform-Specific Signal Handling

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.

Practical Applications

Desktop Search Application

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.

Development Tool Orchestration

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.

Embedded Database Engines

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.

Sidecar Process Pattern

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.

Implementation Patterns

Output Mirroring with Structured Logging

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,
        )
    }
}

Auto-Restart with Backoff

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")
        }
    }
}
Key Concept: Build Tags for Platform Code

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.

Performance Considerations

Common Pitfalls

Further Reading