gastownhall/beads · info

ErrLockHeld

ErrLockHeld

Error message

proxy lock held by another proxy on this rootDir

What it means

ErrLockHeld is returned by ListenAndServe when another proxy process already holds proxy.lock for the same rootDir. It is an expected 'lost the race' outcome, not a failure: exactly one proxy per rootDir may serve. Child-spawned callers should map it to LockHeldExitCode (75) and exit cleanly.

Source

Thrown at internal/storage/dbproxy/proxy/server.go:80

	conns       errgroup.Group
}

const (
	PIDFileName  = "proxy.pid"
	LogFileName  = "proxy.log"
	LockFileName = "proxy.lock"
)

// LockHeldExitCode is the exit code a child proxy should use when
// ListenAndServe returns ErrLockHeld. The spawning parent treats this
// (EX_TEMPFAIL) as "lost the spawn race" and retries via readAndDial.
const LockHeldExitCode = 75

// ErrLockHeld is returned from ListenAndServe when another proxy already
// holds proxy.lock for the same rootDir. It is a normal "lost the race"
// outcome, not a failure: callers spawned as children should map it to
// LockHeldExitCode and exit cleanly.
var ErrLockHeld = errors.New("proxy lock held by another proxy on this rootDir")

const (
	serverReadyTimeout     = 30 * time.Second
	readyDialTimeout       = 2 * time.Second
	readyInitialBackoff    = 50 * time.Millisecond
	readyMaxBackoff        = 1 * time.Second
	idleWatcherMinInterval = 1 * time.Second
	backendStopTimeout     = 5 * time.Minute
	tcpKeepAlivePeriod     = 30 * time.Second
)

var errIdleTimeout = errors.New("idle timeout reached")

func NewProxyServer(opts ProxyOpts) *proxyServer {
	return &proxyServer{
		rootDir:     opts.RootDir,
		port:        opts.Port,
		idleTimeout: opts.IdleTimeout,

View on GitHub (pinned to 71377f2769)

Solutions

  1. Treat it as success: check errors.Is(err, proxy.ErrLockHeld), exit with LockHeldExitCode (75)
  2. Use the already-running proxy instead of starting a new one
  3. If truly stale (holder process is dead), clean up the stale proxy process/lock and retry

Example fix

// before
if err := srv.ListenAndServe(ctx); err != nil { os.Exit(1) }
// after
if err := srv.ListenAndServe(ctx); err != nil {
    if errors.Is(err, proxy.ErrLockHeld) {
        os.Exit(proxy.LockHeldExitCode) // 75, clean exit
    }
    os.Exit(1)
}
Defensive patterns

Strategy: try-catch

Validate before calling

// no pre-check lock API; probe by attempting to dial the existing proxy first
if _, err := proxy.DialExisting(ctx, root); err == nil {
    return nil // a proxy is already serving this root
}

Try / catch

if err := srv.ListenAndServe(ctx); err != nil {
    if errors.Is(err, proxy.ErrLockHeld) {
        os.Exit(proxy.LockHeldExitCode) // clean child exit
    }
    return err
}

Prevention

When it happens

Trigger: Starting a second proxy server (ListenAndServe) on a rootDir whose lock file is held by a live proxy; concurrent proxy instantiation tests where only one wins.

Common situations: Launching `bd` twice in the same workspace; a supervisor respawning the proxy while the old one still runs; leftover lock held by a healthy sibling process.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/8cd41c8bd7330d29. Report an issue: GitHub.