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
- Read the error's hook name (%q in the message) and the embedded cause to find the panicking plugin/hook.
- Fix the plugin: guard factory-time init (the code before `return func(...)` runs per invocation) so it cannot panic on bad config.
- Remember factory-captured closure state is recreated per command dispatch; hold long-lived state on the Plugin struct or a package-level variable instead.
- If the panic value is an error, use errors.Is/errors.As on the returned error to reach the original typed cause.
- 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
- Keep wrapper factories panic-free: return errors from the inner handler instead of calling must* helpers at composition time.
- Hold long-lived state (pools, caches) on the Plugin struct — factory-captured closures reset per dispatch.
- Add recover+error-return in your own wrapper internals during development so panics never reach production dispatch.
- Test plugins with missing/invalid config to verify the factory degrades to an error, not a panic.
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
- %v
- multiple plugins customized skills; only one plugin may own
- content safety panic: %v
- Hooks.NewArgs is required
- Hooks.NewArgs must return *%s
AI-assisted analysis of larksuite/cli@7fd6ef3c07 (2026-09-04).
Data as JSON: /api/errors/ee2d262728714aa4.
Report an issue: GitHub.