siyuan-note/siyuan · error

encrypted notebook operation scope is closed

Error message

encrypted notebook operation scope is closed

What it means

`ErrEncryptedBoxOperationScopeClosed` is returned when code tries to acquire leases from a response-scoped operation scope that has already been closed (its close function was called at the end of the outer request). Scopes exist to tie all box leases to one request lifecycle, so any acquisition after close is a programming error.

Source

Thrown at kernel/model/crypto_lifecycle.go:205

		lifecycle.condition.Wait()
	}
	lifecycle.lock.Unlock()
}

type encryptedBoxOperationScope struct {
	lock     sync.Mutex
	boxIDs   []string
	boxIDSet map[string]struct{}
	closed   bool
}

type encryptedBoxOperationScopeKey struct{}

var (
	// ErrEncryptedBoxNotUnlocked 表示加密笔记本当前未解锁。
	ErrEncryptedBoxNotUnlocked = errors.New("encrypted notebook is not unlocked")
	// ErrEncryptedBoxOperationScopeClosed 表示响应级操作作用域已经关闭。
	ErrEncryptedBoxOperationScopeClosed = errors.New("encrypted notebook operation scope is closed")
)

// WithEncryptedBoxOperationScope 创建覆盖整个外层响应过程的租约作用域。
func WithEncryptedBoxOperationScope(ctx context.Context) (context.Context, func()) {
	scope := &encryptedBoxOperationScope{boxIDSet: map[string]struct{}{}}
	scopedContext := context.WithValue(ctx, encryptedBoxOperationScopeKey{}, scope)
	return scopedContext, scope.release
}

// AcquireEncryptedBoxOperations 按固定顺序取得多个笔记本的响应级租约。
func AcquireEncryptedBoxOperations(ctx context.Context, boxIDs []string) (release func(), err error) {
	unique := map[string]struct{}{}
	for _, boxID := range boxIDs {
		if boxID != "" && IsEncryptedBox(boxID) {
			unique[boxID] = struct{}{}
		}
	}
	sortedBoxIDs := make([]string, 0, len(unique))

View on GitHub (pinned to 8641553a1f)

Solutions

  1. Move lease acquisition into the synchronous part of the request handler, before the deferred scope close runs.
  2. If background work is needed, acquire leases before spawning the goroutine or use a context that is not tied to the closed scope.
  3. Structure the handler so the scope close func runs only after all lease-holding work completes.
  4. Check for double-invocation of the scope's close function in wrapper middleware.

Example fix

// before
scopeCtx, closeScope := model.WithEncryptedBoxOperationScope(ctx)
go func() { model.AcquireEncryptedBoxOperations(scopeCtx, ids) }() // scope may close first
closeScope()
// after
scopeCtx, closeScope := model.WithEncryptedBoxOperationScope(ctx)
_, err := model.AcquireEncryptedBoxOperations(scopeCtx, ids) // acquire synchronously
if err != nil { return err }
closeScope()
Defensive patterns

Strategy: try-catch

Try / catch

_, err := model.AcquireEncryptedBoxOperations(scopeCtx, boxIDs)
if errors.Is(err, model.ErrEncryptedBoxOperationScopeClosed) {
    // scope already closed: move this work into the request's synchronous path
    return fmt.Errorf("lease requested after request scope closed: %w", err)
}

Prevention

When it happens

Trigger: Calling AcquireEncryptedBoxOperations with a context whose scope was closed by the deferred close function from WithEncryptedBoxOperationScope; also asserted by the test TestAcquireEncryptedBoxOperationsReportsClosedScope.

Common situations: Launching a goroutine that outlives the HTTP request and tries to take leases after the request handler returned; forgetting that the scope close is deferred at the start of the response flow.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11). Data as JSON: /api/errors/f7ad36a6ccd69996. Report an issue: GitHub.