gastownhall/beads · warning

workspacegate: waiting for

Error message

workspacegate: waiting for %s: %w

What it means

While Acquire was sleeping between lock retries, its context was cancelled or timed out; it wraps ctx.Err() with the gate path. The file descriptor is closed and no lock is held. This reports why the wait was abandoned, not a lock failure.

Solutions

  1. Check errors.Is(err, context.DeadlineExceeded) vs context.Canceled to decide whether to extend the deadline or treat it as user abort.
  2. Increase the context timeout/deadline to exceed the expected holder duration.
  3. Ensure the context stays alive for the whole Acquire (do not tie it to a short-lived request if the wait can be long).
  4. Use the OnWait callback to log contention early so operators can release the gate before the context dies.

Example fix

// before
h, err := g.Acquire(ctx, workspacegate.Exclusive, workspacegate.AcquireOpts{Wait: time.Minute})

// after
acqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
h, err := g.Acquire(acqCtx, workspacegate.Exclusive, workspacegate.AcquireOpts{Wait: time.Minute})
Defensive patterns

Strategy: try-catch

Validate before calling

if ctx.Err() != nil {
    return fmt.Errorf("context already done before acquire: %w", ctx.Err())
}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < expectedHolderTime {
    // widen the deadline before attempting
}

Try / catch

h, err := g.Acquire(ctx, mode, opts)
if err != nil {
    switch {
    case errors.Is(err, context.Canceled):
        return fmt.Errorf("acquire aborted by caller")
    case errors.Is(err, context.DeadlineExceeded):
        return fmt.Errorf("acquire wait budget exhausted: %w", err)
    default:
        return err
    }
}

Prevention

When it happens

Trigger: Calling Acquire with a ctx that is cancelled, or whose deadline/timeout expires, while the gate is busy and the function is inside the select on ctx.Done() between retry polls.

Common situations: HTTP request contexts timing out while waiting on a long-held gate; CLI processes interrupted (SIGINT) cancelling the context; a parent goroutine cancelling work because an earlier step failed; overly short context deadlines relative to opts.Wait.

Related errors


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

Appendix: source

Thrown at internal/workspacegate/gate.go:436

			}
		}
		remaining := time.Until(deadline)
		if opts.Wait <= 0 || remaining <= 0 {
			_ = f.Close()
			return nil, fmt.Errorf("workspacegate: %s (%s mode) held by %s: %w",
				g.path, mode, g.busyDetail(mode), ErrBusy)
		}
		// Never sleep past the wait budget: a Wait shorter than the poll
		// interval must still come back within (about) Wait, and the
		// deadline is re-checked above before any further attempt.
		sleep := poll
		if remaining < sleep {
			sleep = remaining
		}
		select {
		case <-ctx.Done():
			_ = f.Close()
			return nil, fmt.Errorf("workspacegate: waiting for %s: %w", g.path, ctx.Err())
		case <-time.After(sleep):
		}
	}
}

// writeInfo records the advisory exclusive-holder sidecar. Failures are
// deliberately swallowed: diagnostics must never block the operation that
// already holds the authoritative lock. The write goes to an O_EXCL temp
// file renamed into place, which (a) never follows a pre-planted symlink
// at either path — plain WriteFile would truncate the symlink's target —
// and (b) is atomic, so concurrent readers cannot see torn JSON.
func (g Gate) writeInfo(reason string) {
	host, _ := os.Hostname()
	data, err := json.Marshal(Info{
		PID:       os.Getpid(),
		Hostname:  host,
		Reason:    reason,
		StartedAt: time.Now().UTC(),

View on GitHub (pinned to 71377f2769)