gastownhall/beads · warning

workspacegate: waiting for %s: %w

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.

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)

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.