hashicorp/consul · error

semaphore: bad release

Error message

semaphore: bad release

What it means

Dynamic.Release (lib/semaphore/semaphore.go:87) panics when the semaphore has zero acquired slots (cur < 1), i.e. when Release is called more often than Acquire succeeded. This mirrors golang.org/x/sync/semaphore semantics: misuse is a programming error, so the library panics instead of silently corrupting the count. The most frequent cause is deferring Release before checking the Acquire error, so a canceled context leads to a release that was never acquired.

Source

Thrown at lib/semaphore/semaphore.go:92

		default:
			s.waiters.Remove(elem)
		}
		s.mu.Unlock()
		return err

	case <-ready:
		return nil
	}
}

// Release releases the semaphore. It will panic if release is called on an
// empty semphore.
func (s *Dynamic) Release() {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.cur < 1 {
		panic("semaphore: bad release")
	}

	next := s.waiters.Front()

	// If there are no waiters, just decrement and we're done
	if next == nil {
		s.cur--
		return
	}

	// Need to yield our slot to the next waiter.
	// Remove them from the list
	s.waiters.Remove(next)
	// And trigger it's chan before we release the lock
	close(next.Value.(chan struct{}))
	// Note we _don't_ decrement inflight since the slot was yielded directly.
}

View on GitHub (pinned to 2397ff0d76)

Solutions

  1. Acquire first and only defer Release after the error check: 'if err := sem.Acquire(ctx); err != nil { return err }; defer sem.Release()'
  2. Audit code for double-Release paths (a Release in a loop body plus one in a defer)
  3. If pairing is hard to see locally, wrap Dynamic in a small type that hands out a release token (func()) per successful Acquire

Example fix

// before
func work(ctx context.Context) error {
	defer sem.Release() // registered too early
	if err := sem.Acquire(ctx); err != nil {
		return err // Release still runs -> panic on empty semaphore
	}
	...
}

// after
func work(ctx context.Context) error {
	if err := sem.Acquire(ctx); err != nil {
		return err // nothing acquired, nothing to release
	}
	defer sem.Release() // paired with a successful Acquire
	...
}
Defensive patterns

Strategy: validation

Validate before calling

if err := sem.Acquire(ctx); err != nil {
	return err // no slot acquired; must NOT call Release
}
defer sem.Release() // only after a successful Acquire

Try / catch

// last-resort containment at a goroutine boundary; fix the pairing instead
defer func() {
	if r := recover(); r != nil {
		log.Error("semaphore misuse", "panic", r)
	}
}()

Prevention

When it happens

Trigger: 'defer sem.Release()' placed before sem.Acquire(ctx) returns, so Release runs even when Acquire failed with ctx.Err(); calling Release twice for one Acquire; calling Release on a fresh zero-value/newly created semaphore that nobody acquired.

Common situations: Refactors that hoist defers to the top of the function; early-return error paths added after the defer was written; multiple goroutines releasing on behalf of a single acquire; test teardown that releases unconditionally.

Related errors


AI-assisted analysis of hashicorp/consul@2397ff0d76 (2026-08-15). Data as JSON: /api/errors/038b4a2f63fd7510. Report an issue: GitHub.