larksuite/cli · error

hook %q panic: %v

Error message

hook %q panic: %v

What it means

recoverWrap wraps a plugin Wrapper so any panic — including one from the wrapper's factory function running at invocation time — is converted into a typed errs validation error (SubtypeFailedPrecondition) with message "hook %q panicked: %v" and a hint to report or remove the plugin. The cause chain preserves error identity via %w when the panic value is an error, so errors.Is/As still work. The framework deliberately recovers here so a crashing plugin hook cannot take down the whole CLI process.

Source

Thrown at internal/hook/install.go:261

// dispatch) in exchange for total panic isolation.
//
// **Factory-local state lifetime contract**: any value the plugin's
// outer factory captures (`state` in the example above) is now created
// PER INVOCATION of the wrapped command -- it is NOT a one-shot init
// the way Plugin.Install is. Plugins that need long-lived state (a
// connection pool, an LRU cache, a metrics counter) MUST hold it on
// the Plugin struct or in a package-level variable; relying on
// closure-local memoisation inside the wrapper factory will silently
// reset on every command dispatch.
func recoverWrap(fullName string, w platform.Wrapper) platform.Wrapper {
	return func(next platform.Handler) platform.Handler {
		return func(ctx context.Context, inv platform.Invocation) (returned error) {
			defer func() {
				if r := recover(); r != nil {
					// Preserve the panic value's error identity in the cause
					// chain when it is an error, so errors.Is/As can still reach
					// it; fall back to %v formatting for non-error panics.
					cause := fmt.Errorf("hook %q panic: %v", fullName, r)
					if e, ok := r.(error); ok {
						cause = fmt.Errorf("hook %q panic: %w", fullName, e)
					}
					returned = errs.NewValidationError(errs.SubtypeFailedPrecondition,
						"hook %q panicked: %v", fullName, r).
						WithHint("plugin hook %q crashed while handling this command; report the panic to the plugin author or remove the plugin", fullName).
						WithCause(cause)
				}
			}()
			// Construct AFTER the recover is armed so a panicking
			// factory becomes a hook envelope instead of a process
			// crash.
			inner := w(next)
			return inner(ctx, inv)
		}
	}
}

View on GitHub (pinned to 7fd6ef3c07)

Solutions

  1. Read the error's hook name (%q in the message) and the embedded cause to find the panicking plugin/hook.
  2. Fix the plugin: guard factory-time init (the code before `return func(...)` runs per invocation) so it cannot panic on bad config.
  3. Remember factory-captured closure state is recreated per command dispatch; hold long-lived state on the Plugin struct or a package-level variable instead.
  4. If the panic value is an error, use errors.Is/errors.As on the returned error to reach the original typed cause.
  5. As a user (not plugin author), remove or update the offending plugin per the error hint.

Example fix

// before (panicking factory)
func(next platform.Handler) platform.Handler {
    cfg := mustLoadConfig() // panics when config missing
    return func(ctx context.Context, inv platform.Invocation) error { ... }
}
// after
func(next platform.Handler) platform.Handler {
    cfg, err := loadConfig()
    if err != nil {
        return func(ctx context.Context, inv platform.Invocation) error {
            return fmt.Errorf("policy-plugin: bad config: %w", err)
        }
    }
    return func(ctx context.Context, inv platform.Invocation) error { ... }
}
Defensive patterns

Strategy: try-catch

Validate before calling

// validate plugin config at install time so the wrapper factory never panics:
if err := plugin.ValidateConfig(cfg); err != nil {
	return fmt.Errorf("plugin %s: invalid config: %w", plugin.Name(), err)
}

Type guard

func isHookPanicValidationError(err error) (hookName string, ok bool) {
	var ve *errs.ValidationError
	if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
		return "", false
	}
	// hook name is the first %q in the message; cause keeps error identity
	return ve.HookName, true
}

Try / catch

if err := cmd.Execute(); err != nil {
	var ve *errs.ValidationError
	if errors.As(err, &ve) && strings.Contains(ve.Error(), "panicked") {
		var cause error
		if errors.As(err, &cause) && errors.Is(cause, ErrBadPluginConfig) {
			// recover original typed panic cause via errors.Is/As on the chain
		}
		fmt.Fprintln(os.Stderr, "plugin hook crashed; update or remove the plugin")
		os.Exit(1)
	}
}

Prevention

When it happens

Trigger: A plugin wrapper's factory panics during composition (e.g. mustInit() with bad config) or the wrapped handler panics mid-invocation of a cobra command; fullName is the namespaced hook name (e.g. "policy-plugin.policy").

Common situations: Plugin initialized with missing/invalid config at factory time; plugin code assuming optional state exists; a plugin written for an older CLI version hitting an incompatible API path at runtime.

Related errors


AI-assisted analysis of larksuite/cli@7fd6ef3c07 (2026-09-04). Data as JSON: /api/errors/ee2d262728714aa4. Report an issue: GitHub.