Skip to content

View / lifecycle panics escape the framework — recover should be built in, not opt-in via mw.Recover #48

Description

@joaomdsg

Severity: medium

Summary

Action handlers have built-in panic recovery (action.go:197, routed through via.WithActionErrorHandler). View renders do not — a panic in viewFn at render.go:63 propagates to the embedding http.Server, which logs a stack trace to stderr and returns a bare 500 Internal Server Error body. The user only gets structured logging + a controlled response if they remember to wire mw.Recover (or mw.Defaults).

Where

  • render.go:62-65 — synchronous viewFn call with no recover.
  • Compare runtime.go:244 (defer recoverLog(ctx, "OnDispose")) and the OnInit guard at render.go:46-52, which already do this for the lifecycle callbacks. View is the odd one out.

Why it matters

The asymmetry is the surprise: identical-looking programmer mistakes get wildly different treatment depending on which callback they sit in.

  • A panic inside Inc(ctx) → recovered, logged through app.Logger() with structured kv, and the user-configured ActionErrorHandler decides what the client sees (toast by default).
  • The same panic inside View(ctx) (e.g. on.Click(nil), an on.Click(closure), a nil-deref in a state read) → falls off the framework, no via log line, naked stderr stack on the server, naked "500 Internal Server Error" on the wire.

Registration-time mistakes (the on.* panics that #36 just split into nil/closure/top-level/non-func) almost always surface at View time, not action time — so the newly-precise messages are most useful exactly where the framework doesn't structure them today. The fix to #36 makes the panic text better; this issue is about making sure the user actually sees it.

Suggested fix

Wrap the viewFn call in render.go:62-65 with a recover that:

  1. Logs via a.Logger() at LogError with the route + recovered value (mirroring mw.Recover at mw/mw.go:106-119).
  2. Writes a 500 response so the surrounding http.Server doesn't have to.
  3. Optionally routes through the existing ActionErrorHandler hook (or a sibling ViewErrorHandler) so apps that want a custom error UI can opt into one.

This is a few lines in core (no mw dependency — app.Logger() is right there) and matches the pattern already used for OnInit / OnDispose / OnConnect.

mw.Recover stays — it's still the right backstop for non-via handlers wired through HandleFunc, plugin endpoints, custom middleware. mw.Defaults becomes additive (log format, request-id stamping) rather than load-bearing for basic safety.

SSE event-loop callbacks deserve the same treatment if they don't already have it — worth auditing the broadcast path while we're in there.

Open question

Should this be unconditional, or gated by a via.WithBuiltinRecover(false) for users who genuinely want raw panic propagation (e.g. in tests, or to defer to a custom outer middleware)? I'd lean unconditional — mw.Recover already exists for users who want a different shape — but the option keeps the door open.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions