sharedserver 0.4.1

A lightweight CLI tool and library for managing shared servers with reference counting
Documentation

shareserver

A generic Neovim plugin for keeping server processes alive across multiple Neovim instances using reference counting with automatic grace period support.

Features

  • Multiple server management: Manage multiple servers simultaneously with named configurations
  • Shared server management: One server process shared across multiple Neovim instances
  • Reference counting: Servers stay alive as long as at least one Neovim instance is attached
  • Grace period support: Servers can stay alive for a configurable period after all clients disconnect
  • Lazy loading: Optionally only attach to servers if they're already running
  • Automatic lifecycle: Servers start on VimEnter, stop on VimLeave with grace period
  • Manual control: Start, stop, restart, and query status of named servers
  • Flexible configuration: Configure command, args, working directory, and idle timeout per server
  • Status monitoring: Check server status and get PID/refcount/grace period info
  • Built-in commands: User commands are automatically created for easy server management
  • Robust state management: Two-lockfile architecture with stale lock cleanup and atomic operations

Requirements

  • Neovim 0.5+
  • plenary.nvim
  • Rust toolchain (for building sharedserver) - install from rustup.rs
  • macOS: Built-in flock via Rust (no additional dependencies)
  • Linux: Built-in flock via Rust (no additional dependencies)

Building from Source

The plugin requires building the Rust-based sharedserver binary:

cd shareserver/rust
cargo build --release

The binary will be available at rust/target/release/sharedserver. The plugin will automatically find it in this location.

Installation via Cargo

The simplest way to install sharedserver from crates.io:

cargo install sharedserver

Or install from the repository:

# From repository root
cargo install --path rust

Both methods install the binary to ~/.cargo/bin/sharedserver, which should be in your PATH.

Optional: System-wide Installation

To install the binary system-wide for use outside Neovim:

# Linux
sudo cp rust/target/release/sharedserver /usr/local/bin/

# macOS with Homebrew
sudo cp rust/target/release/sharedserver /opt/homebrew/bin/

# User-local installation (no sudo)
cp rust/target/release/sharedserver ~/.local/bin/

The plugin searches for sharedserver in the following order:

  1. <plugin-dir>/rust/target/release/sharedserver (default after build)
  2. ~/.local/bin/sharedserver
  3. /usr/local/bin/sharedserver
  4. /opt/homebrew/bin/sharedserver

Shell Completions

Generate shell completion scripts for sharedserver:

# Bash
sharedserver completion bash > ~/.local/share/bash-completion/completions/sharedserver

# Zsh
sharedserver completion zsh > ~/.zsh/completions/_sharedserver
# Then add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)

# Fish
sharedserver completion fish > ~/.config/fish/completions/sharedserver.fish

Installation

Using lazy.nvim:

{
    "georgeharker/shareserver",
    dependencies = { "nvim-lua/plenary.nvim" },
    build = "cargo install sharedserver --force",
    config = function()
        require("sharedserver").setup({
            chroma = {
                command = "chroma",
                args = { "run", "--path", "~/.local/share/chromadb" },
                idle_timeout = "30m",  -- Keep alive 30 minutes after last client
            },
            redis = {
                command = "redis-server",
                lazy = true,  -- Only attach if already running
            }
        })
    end
}

Or for local development:

{
    dir = "~/Development/neovim-plugins/shareserver",
    dependencies = { "nvim-lua/plenary.nvim" },
    build = "cargo install --path rust --force",
    config = function()
        require("sharedserver").setup({
            -- your config
        })
    end
}

See EXAMPLES.md for more configuration examples and usage patterns.

Configuration

Setup Options

The setup() function accepts two parameters:

require("sharedserver").setup(servers, opts)
  • servers: A table of server configurations (see below)
  • opts: Optional configuration table with the following options:
    • commands (default: true): Whether to automatically create user commands
    • notify: Notification preferences (see below)

Notification Configuration

Control when the plugin shows notifications:

require("sharedserver").setup({
    -- servers...
}, {
    commands = true,
    notify = {
        on_start = true,   -- Notify when starting a new server (default: true)
        on_attach = false, -- Notify when attaching to existing (default: false)
        on_stop = false,   -- Notify when stopping a server (default: false)
        on_error = true,   -- Always notify on errors (default: true)
    }
})

By default, the plugin is quiet during normal operations (attach/detach) and only notifies when:

  • A new server is started for the first time
  • An error occurs
  • A server exits unexpectedly (non-zero exit code)

Multiple Servers (Recommended)

require("sharedserver").setup({
    -- Server name as key
    chroma = {
        command = "chroma",
        args = { "run", "--path", "~/.local/share/chromadb" },
        lazy = false,  -- Start immediately (default)
        idle_timeout = "1h",  -- Keep alive 1 hour after last client
    },
    redis = {
        command = "redis-server",
        args = { "--port", "6379" },
        lazy = true,  -- Only attach if already running, don't start
    },
    webserver = {
        command = "python",
        args = { "-m", "http.server", "8080" },
        working_dir = vim.fn.getcwd(),
        idle_timeout = "30m",
        on_start = function(pid)
            vim.notify("Web server started: http://localhost:8080")
        end,
    },
})

Disable Commands

To disable automatic command creation:

require("sharedserver").setup({
    chroma = {
        command = "chroma",
        args = { "run", "--path", "~/.local/share/chromadb" },
    },
}, { commands = false })

Single Server (Backward Compatible)

require("sharedserver").setup({
    name = "myserver",  -- Optional, defaults to "default"
    command = "chroma",
    args = { "run", "--path", "~/.local/share/chromadb" },
})

Server Configuration Options

For each server:

  • command (required): Command to execute (can be full path or command in PATH)
  • args (optional, default: {}): Arguments to pass to the command
  • lazy (optional, default: false): If true, only attach to server if already running, don't start a new one
  • working_dir (optional, default: nil): Working directory for the server
  • idle_timeout (optional, default: nil): Grace period duration after last client disconnects (e.g., "30m", "1h", "2h30m")
  • on_start (optional): Callback function called with (pid) when server starts
  • on_exit (optional): Callback function called with (exit_code) when server exits

Lazy Loading

The lazy option is useful for servers that:

  • You want to share between projects but not start automatically
  • Might be started by external tools
  • Are expensive to start and should only run when needed
require("sharedserver").setup({
    expensive_db = {
        command = "heavy-database-server",
        lazy = true,  -- Only attach if already running
    },
})

-- Later, manually start when needed:
vim.keymap.set("n", "<leader>sd", function()
    require("sharedserver").start("expensive_db")
end, { desc = "Start expensive database" })

API

All API functions are available through require("sharedserver"):

local sharedserver = require("sharedserver")

-- Setup servers and optionally enable commands
sharedserver.setup(servers, opts)

-- Register a server after initial setup
sharedserver.register(name, config)

-- Manually control servers
sharedserver.start(name)
sharedserver.stop(name)
sharedserver.restart(name)
sharedserver.stop_all()

-- Query server status
local status = sharedserver.status(name)
local all_statuses = sharedserver.status_all()
local server_names = sharedserver.list()

Commands

When commands are enabled (default), the following user commands are automatically created:

  • :ServerStart <name> - Start a named server
  • :ServerStop <name> - Stop a named server
  • :ServerRestart <name> - Restart a named server
  • :ServerStatus [name] - Show server status in a floating window (all servers if no name given)
  • :ServerList - List all registered servers (same as :ServerStatus with no args)
  • :ServerStopAll - Stop all servers

The :ServerStatus command displays a rich floating window with:

  • Server name and running status (●/○)
  • PID, refcount, and uptime for running servers
  • Attached/detached state
  • Lazy mode indicator
  • Full command details when viewing a specific server

Press q or <Esc> to close the status window.

Example output of :ServerStatus:

╭──────────────────────────── Shared Servers ─────────────────────────────╮
│ NAME                 STATUS       PID      REFS     UPTIME               │
│ ────────────────────────────────────────────────────────────────────────│
│ ● chroma             running      1234     2        2h 15m               │
│ ● redis              running      5678     1        45m 32s              │
│ ⏳ postgres           GRACE        9012     0        3h 22m               │
│ ○ myserver           stopped      -        -        - [lazy]             │
│                                                                           │
│ Press q or <Esc> to close                                                │
╰───────────────────────────────────────────────────────────────────────────╯

Status indicators:

  • Running with active clients (refcount > 0)
  • Grace period (refcount = 0, waiting for timeout)
  • Stopped

To disable command creation, pass { commands = false } to setup().

Detailed API Reference

setup(servers, opts)

setup(servers, opts)

Initialize and register servers. Accepts:

  • servers: A table of named server configurations, or a single server configuration (backward compatible)
  • opts: Optional table with options:
    • commands (default: true): Whether to create user commands

Example:

require("sharedserver").setup({
    chroma = { command = "chroma", args = { "run" } },
}, { commands = true })

register(name, config)

Register a new server after initial setup.

require("sharedserver").register("newserver", {
    command = "myserver",
    args = { "--port", "9000" },
})

start(name)

Manually start a named server. Returns true on success, false on failure.

local success = require("sharedserver").start("chroma")

stop(name)

Manually stop a named server.

require("sharedserver").stop("chroma")

stop_all()

Stop all registered servers.

require("sharedserver").stop_all()

restart(name)

Restart a named server.

require("sharedserver").restart("chroma")

status(name)

Get the status of a named server.

local status = require("sharedserver").status("chroma")
if status.running then
    print("Server running with PID: " .. status.pid)
    print("Reference count: " .. status.refcount)
    print("Attached: " .. tostring(status.attached))
    print("Lazy: " .. tostring(status.lazy))
else
    print("Server not running")
end

status_all()

Get status of all registered servers.

local statuses = require("sharedserver").status_all()
for name, status in pairs(statuses) do
    print(name, status.running)
end

list()

Get a list of all registered server names.

local servers = require("sharedserver").list()
for _, name in ipairs(servers) do
    print("Registered server: " .. name)
end

How It Works

The plugin uses sharedserver (Rust-based, bundled in rust/target/release/) for robust server lifecycle management with a two-lockfile architecture and automatic dead client detection.

Architecture

Two lockfiles per server (default location: $XDG_RUNTIME_DIR/sharedserver/ or /tmp/sharedserver/):

  • <name>.server.json: Persistent server state (PID, command, start time, grace period settings)
  • <name>.clients.json: Active client tracking (refcount, client PIDs with metadata)

Three states:

  • ACTIVE: clients.json exists (refcount > 0), server running normally
  • GRACE: clients.json deleted (refcount = 0) but server still alive, waiting for timeout or new client
  • STOPPED: Both files deleted, server terminated

Lifecycle Flow

  1. Neovim starts (VimEnter):

    • Plugin checks if server exists using sharedserver check <name>
    • For non-lazy servers:
      • If exists: Increments refcount via sharedserver incref (attaches to existing)
      • If not: Starts via sharedserver start <name> -- <command> <args>
    • For lazy servers:
      • If exists: Attaches (increments refcount)
      • If not: Does nothing (waits for manual start)
  2. Multiple Neovim instances:

    • Each instance calls sharedserver incref <name> on startup
    • Refcount tracked in clients.json (e.g., 3 instances = refcount 3)
    • Server process itself is registered as a client
  3. Neovim exits (VimLeave):

    • Plugin calls sharedserver decref <name> automatically
    • Refcount decremented
    • If refcount reaches 0:
      • clients.json deleted → Server enters GRACE period
      • Watcher starts countdown timer (e.g., 30 minutes)
      • If new client attaches: Grace cancelled, back to ACTIVE
      • If grace expires: Server receives TERM signal, both lockfiles deleted
  4. Dead client detection (automatic):

    • Watcher polls every 5 seconds
    • Checks each client PID with health checks (Linux: /proc, macOS: proc_pidinfo())
    • Automatically removes dead clients and recalculates refcount
    • Prevents refcount leaks from crashed Neovim instances
    • If all clients die: Triggers grace period automatically

Grace Period Example

require("sharedserver").setup({
    myserver = {
        command = "myserver",
        idle_timeout = "30m",  -- Stay alive 30 min after last client
    }
})

Timeline:

  • T+0: First nvim starts → Server launched, refcount=1
  • T+5: Second nvim starts → refcount=2
  • T+10: First nvim exits → refcount=1
  • T+15: Second nvim exits → refcount=0, enter GRACE period (30min countdown)
  • T+20: Third nvim starts → refcount=1, grace cancelled, back to ACTIVE
  • T+25: Third nvim exits → refcount=0, enter GRACE again
  • T+55: Grace expires (30min after T+25) → Server terminated

Use Cases

  • Database servers: ChromaDB, Redis, PostgreSQL for development
  • Language servers: Custom LSP servers, code analysis tools
  • Development servers: HTTP servers, WebSocket servers
  • Background processes: File watchers, sync daemons
  • Expensive services: Large ML models, heavy databases (use lazy = true)

For detailed configuration examples and usage patterns, see EXAMPLES.md.

Shell Integration

The rust/target/release/sharedserver binary allows shell scripts and other programs to participate in the shared server lifecycle.

sharedserver Commands

The sharedserver binary provides a clean command-line interface for managing shared servers:

Everyday Commands:

  • use <name> [-- <command> [args...]] - Attach to server (starts if needed)
  • unuse <name> - Detach from server
  • list - Show all managed servers
  • info <name> [--json] - Get server details (formatted or JSON)
  • check <name> - Test if server exists (exit codes: 0=active, 1=grace, 2=stopped)
  • completion <shell> - Generate shell completions (bash/zsh/fish)

Admin Commands (for troubleshooting):

  • admin start <name> --pid <pid> -- <command> [args...] - Manually start server
  • admin stop <name> [--force] - Emergency stop (sends SIGTERM)
  • admin incref <name> --pid <pid> - Manually increment refcount
  • admin decref <name> --pid <pid> - Manually decrement refcount
  • admin debug <name> - Show invocation logs

PID Behavior:

  • User commands (use, unuse): --pid defaults to parent process (the caller)
  • Admin commands: --pid defaults to current process (must be specified)

Examples

# Start or attach to a server
sharedserver use myserver -- python -m http.server 8000

# Detach from server
sharedserver unuse myserver

# Check server status
sharedserver check myserver
sharedserver info myserver

# List all servers
sharedserver list

# Emergency stop (admin)
sharedserver admin stop myserver --force

Two-Lockfile Architecture

Serverctl uses a two-lockfile design for robust lifecycle management:

  • <name>.server.json: Persistent while server is running

    • Contains: PID, command, start time, grace period settings, watcher PID
    • Created when server starts
    • Deleted only when server truly dies (after grace period)
  • <name>.clients.json: Exists only while clients are connected (refcount > 0)

    • Contains: refcount, map of client PIDs to metadata (attached timestamp, custom metadata)
    • Created when first client attaches (or when server starts with itself as first client)
    • Deleted when last client dies or decref's (triggers grace period)
    • Watcher automatically removes dead client PIDs every 5 seconds

Grace Period

When the last client decrements refcount to 0, the server enters a grace period instead of immediately shutting down:

# Start server with 30-minute grace period
sharedserver start --grace-period 30m opencode -- opencode serve --port 4097

# Or 1 hour grace period
sharedserver start --grace-period 1h myserver -- ./server.sh

Grace period flow:

  1. Last client decrefs or dies → clients.json deleted
  2. Watcher enters GRACE mode, starts countdown timer (e.g., 30 minutes)
  3. If new client increfs during grace: clients.json recreated, timer cancelled, back to ACTIVE
  4. If grace expires: Watcher sends SIGTERM, waits 5s, sends SIGKILL if needed, server.json deleted

Shell Script Usage

Requirements:

  • Rust toolchain (see Building from Source section)

Basic usage:

# Check if server exists
if ! sharedserver check opencode; then
    # Start server with 30-minute grace period
    sharedserver start --grace-period 30m opencode -- opencode serve --port 4097 &
    sleep 1  # Wait for startup
fi

# Register as client (optional - for tracking only)
sharedserver incref opencode --metadata "shell-script-$$"

# Use server...
curl http://localhost:4097/health

# Unregister when done
sharedserver decref opencode

Neovim Integration

The plugin automatically uses sharedserver - no manual configuration needed:

require("sharedserver").setup({
    opencode = {
        command = "opencode",
        args = { "serve", "--port", "4097" },
        idle_timeout = "30m",  -- Grace period after all clients disconnect
    }
})

What happens internally:

  • Plugin starts server with: sharedserver start --grace-period 30m opencode -- opencode serve --port 4097
  • Server survives 30 minutes after all Neovim instances close
  • Auto-decref when Neovim exits
  • Grace period can be cancelled if a new client attaches
  • Dead clients are automatically cleaned up by the watcher

Lockfile location:

  • Default: $XDG_RUNTIME_DIR/sharedserver/ or /tmp/sharedserver/
  • <name>.server.json - Server state
  • <name>.clients.json - Client refcount
  • Set SHAREDSERVER_LOCKDIR environment variable to override

Advanced: Manual sharedserver usage from Lua

If you need to call sharedserver directly (e.g., for custom workflows):

local sharedserver = vim.fn.stdpath("config") .. "/../path/to/rust/target/release/sharedserver"

-- Check if server exists
local result = vim.fn.system({sharedserver, "check", "myserver"})
if vim.v.shell_error == 0 then
    print("Server is running")
end

-- Get server info as JSON
local json = vim.fn.system({sharedserver, "info", "myserver"})
local info = vim.fn.json_decode(json)
print("PID:", info.pid, "Status:", info.status, "Refcount:", info.refcount)

License

MIT