gastownhall/beads · warning

workspacegate: close %s: %w

Error message

workspacegate: close %s: %w

What it means

Release finishes by closing the gate file descriptor; a close failure is wrapped with the gate path and joined (via errors.Join) alongside any unlock error into the handle's result. Close failures on a local file are rare but signal possible I/O problems (disk metadata, NFS issues) or a double-close of the descriptor.

Source

Thrown at internal/workspacegate/gate.go:358

func (h *Handle) Release() error {
	if h == nil {
		return nil
	}
	h.once.Do(func() {
		var errs []error
		if h.mode == Exclusive {
			// Remove the sidecar BEFORE unlocking: after the unlock a new
			// exclusive holder may already have written its own sidecar,
			// and a late removal here would delete that holder's info.
			// Best effort; a leftover sidecar is ignored once the flock
			// is free.
			_ = os.Remove(h.gate.infoPath())
		}
		if err := lockfile.FlockUnlock(h.f); err != nil {
			errs = append(errs, fmt.Errorf("workspacegate: unlock %s: %w", h.gate.path, err))
		}
		if err := h.f.Close(); err != nil {
			errs = append(errs, fmt.Errorf("workspacegate: close %s: %w", h.gate.path, err))
		}
		h.err = errors.Join(errs...)
	})
	return h.err
}

// 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

View on GitHub (pinned to 71377f2769)

Solutions

  1. Never close the handle's file yourself — call Release once and let its sync.Once manage unlock+close.
  2. Check the joined error (errors.Is/As on the wrapped *fs.PathError) for EBADF, which means a double-close; remove the redundant close.
  3. If the handle came from MultiHandle.Release, inspect the joined errors for each gate individually.
  4. For repeated storage-level close errors, run filesystem checks or move the workspace to local storage.

Example fix

// before
defer f.Close()
h, _ := g.Acquire(ctx, mode, opts)
...
h.Release()
// after
h, _ := g.Acquire(ctx, mode, opts)
...
if err := h.Release(); err != nil {
    log.Printf("gate release: %v", err)
}
Defensive patterns

Strategy: try-catch

Try / catch

if err := h.Release(); err != nil {
    if errors.Is(err, os.ErrClosed) {
        return nil // already closed elsewhere; treat as done
    }
    return fmt.Errorf("gate close: %w", err)
}

Prevention

When it happens

Trigger: Calling Release when the descriptor was already closed elsewhere (EBADF); underlying storage errors during close (e.g. network filesystem); descriptor table pressure in extreme cases.

Common situations: Manual f.Close() calls on the handle's file; deferred closes duplicated with Release; NFS/network mounts with stale handles; long-running processes leaking fds until close misbehaves.

Related errors


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