gastownhall/beads · error

workspacegate: acquiring %s: %w

Error message

workspacegate: acquiring %s: %w

What it means

Gate.Acquire checks ctx.Err() before attempting the lock; if the context is already canceled or its deadline has expired, acquisition is refused immediately with the context error wrapped and the gate path prefixed. This is fail-fast: a dead context can never hold a gate, so no lock attempt is made.

Source

Thrown at internal/workspacegate/gate.go:382

// Acquire takes the gate in the given mode, polling until Options.Wait is
// exhausted or ctx is done. The returned handle's file descriptor is not
// inherited by spawned children (Go opens files close-on-exec on Unix and
// non-inheritable on Windows), so a dolt child outliving its bd parent
// does not keep the gate held.
func (g Gate) Acquire(ctx context.Context, mode Mode, opts Options) (*Handle, error) {
	if g.path == "" {
		return nil, errors.New("workspacegate: zero Gate; use ForWorkspace/ForPhysicalRoot")
	}
	// Tolerate a nil context rather than panicking on ctx.Err below: gate
	// acquisition sits on CLI plumbing paths (cobra hooks, migrate helpers)
	// that tests and embedders invoke directly without the process-level
	// signal context, and a nil-deref here kills the whole test binary.
	if ctx == nil {
		ctx = context.Background()
	}
	if err := ctx.Err(); err != nil {
		return nil, fmt.Errorf("workspacegate: acquiring %s: %w", g.path, err)
	}
	poll := opts.PollInterval
	if poll <= 0 {
		poll = 100 * time.Millisecond
	}
	deadline := time.Now().Add(opts.Wait)

	f, err := os.OpenFile(g.path, os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return nil, fmt.Errorf("workspacegate: open gate %s: %w", g.path, err)
	}

	try := lockfile.FlockSharedNonBlock
	if mode == Exclusive {
		try = lockfile.FlockExclusiveNonBlock
	}

	notified := false

View on GitHub (pinned to 71377f2769)

Solutions

  1. Pass a live context — context.Background() (or a fresh signal context) for maintenance operations that must run to completion.
  2. Check ctx.Err() before calling Acquire and create a fresh context if the old one is spent.
  3. For background work, use context.WithoutCancel(ctx) (Go 1.21+) to drop cancellation while keeping values.
  4. If a timeout is the cause, extend the WithTimeout budget to cover gate waiting (Options.Wait) plus the operation itself.

Example fix

// before
ctx, cancel := context.WithTimeout(reqCtx, 5*time.Second)
defer cancel()
// later, after handler returns:
h, err := g.Acquire(ctx, workspacegate.Exclusive, opts) // ctx already done
// after
bg := context.WithoutCancel(reqCtx)
h, err := g.Acquire(bg, workspacegate.Exclusive, opts)
Defensive patterns

Strategy: validation

Validate before calling

if err := ctx.Err(); err != nil {
    return fmt.Errorf("cannot acquire gate: %w", err)
}
h, err := g.Acquire(ctx, workspacegate.Shared, workspacegate.Options{})

Try / catch

h, err := g.Acquire(ctx, mode, opts)
if err != nil {
    if errors.Is(err, context.Canceled) {
        return nil // caller canceled; clean exit
    }
    if errors.Is(err, context.DeadlineExceeded) {
        return fmt.Errorf("gate wait budget exhausted: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: Calling g.Acquire (or AcquireAll) with a context that was already canceled, or whose deadline expired before the call — e.g. reusing a per-request context after the request finished, or a signal-triggered cancel from cobra's signal context firing before acquisition.

Common situations: CLI commands whose signal context was canceled by Ctrl-C racing startup; passing an http.Request's context to a background maintenance task that runs after the handler returns; a WithTimeout whose budget was consumed by earlier setup; unit tests passing a canceled context.

Related errors


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