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-levelView on GitHub (pinned to 71377f2769)
Solutions
- Never close the handle's file yourself — call Release once and let its sync.Once manage unlock+close.
- Check the joined error (errors.Is/As on the wrapped *fs.PathError) for EBADF, which means a double-close; remove the redundant close.
- If the handle came from MultiHandle.Release, inspect the joined errors for each gate individually.
- 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
- Never duplicate Close calls alongside Release.
- Use defer h.Release() once per handle at the acquisition site.
- Check the *fs.PathError errno (EBADF ⇒ double-close) to locate the offender.
- Monitor fd leaks in long-running processes; fix leaks rather than suppressing close errors.
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
- workspacegate: unlock %s: %w
- failed to close wisp root %s: %w
- close verified backend process handle for pid %d: %w
- workspacegate: cannot gate %q: the guarded path must be a na
- workspacegate: gate parent %s must exist: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/d97f15fb3ea7f58e.
Report an issue: GitHub.