Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ type ClickHandler interface {
// is found it falls back to the event target's HTML id, so it is empty only
// when the target has no id either.
//
// [ErrEventUnhandled] tries the next handler. Other non-nil errors are logged
// and queued as danger alerts when possible during Request event processing.
// [ErrEventUnhandled] tries the next handler. Request event processing logs
// other errors and, unless they match [ErrEventLogOnly], queues them as danger
// alerts when possible.
JawsClick(elem *Element, click Click) (err error)
}

Expand All @@ -158,8 +159,9 @@ type ContextMenuHandler interface {
// Events that occur while the bundled client's WebSocket is not open are not
// forwarded or replayed.
//
// [ErrEventUnhandled] tries the next handler. Other non-nil errors are logged
// and queued as danger alerts when possible during Request event processing.
// [ErrEventUnhandled] tries the next handler. Request event processing logs
// other errors and, unless they match [ErrEventLogOnly], queues them as danger
// alerts when possible.
JawsContextMenu(elem *Element, click Click) (err error)
}

Expand Down
10 changes: 6 additions & 4 deletions doc/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ page dot does not install them. See `ConnectFn` in

Register event handlers during rendering, before the Element is frozen.
Dispatch tries attached handlers in reverse registration order, then the
Element's UI. Return `jaws.ErrEventUnhandled` to try the next handler. Other
errors are logged and, when possible, displayed as browser danger alerts.
Recovered panics show a generic alert while logs retain details.
If no handler accepts an event, it is ignored without an error or alert.
Element's UI. Return `jaws.ErrEventUnhandled` to try the next handler; an event
with no accepting handler is ignored. Other errors are logged and, when possible,
displayed as browser danger alerts unless they match `jaws.ErrEventLogOnly`.
Recovered panics and JsVar store rejections match this sentinel. During browser
events, use `Request.Alert` for explicit feedback, including inside
[`ClientCheck`](ui/jsvar.md).

Browser event handlers run serially within each Request, concurrently with update
processing and handlers in other Requests. Synchronize shared application state
Expand Down
3 changes: 2 additions & 1 deletion doc/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ known to the Request are removed from server bookkeeping.

`Hook` is a synchronous test event. The browser never sends it, and inbound
messages do not dispatch it. A broadcast Hook invokes a matching handler; that
handler must not send its own messages. Its returned error becomes an alert.
handler must not send its own messages. Returned errors follow the
[event alert policy](runtime.md#connect-and-handle-events).

## Limits and connection loops

Expand Down
8 changes: 6 additions & 2 deletions doc/ui/jsvar.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ complete tentative Go value under the store's write lock. Validate authorization
through the originating Element and validate the full value, including fields
omitted from JSON and changes through parent or root paths. A `null` proposal for
a struct zeroes all its fields, including unexported and `json:"-"` fields. An
error or panic rolls the proposal back. The callback must only inspect: do not
mutate or retain tentative data, reacquire the lock, or call a store setter.
error or panic rolls the proposal back. The callback must only inspect tentative
data: do not mutate or retain it, reacquire the lock, or call a store setter.

Rejected proposals, including panicking checks, are logged and corrected without
automatic browser alerts. When handling browser input, `ClientCheck` can call
`source.Request.Alert("warning", "Choose a valid value.")` for user feedback.

Every binding receives the same JSON value; use separate stores for data with
different visibility. Proposal outcomes can depend on Go fields omitted from
Expand Down
24 changes: 12 additions & 12 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,11 @@ func newErrNoWebSocketRequest(rq *Request) error {
return errNoWebSocketRequest{Addr: rq.remoteIP}
}

// ErrEventHandlerPanic is returned by [CallEventHandlers] when a user event handler
// panics.
// ErrEventHandlerPanic reports a recovered event-handler panic.
//
// Match it with [errors.Is]. When the recovered panic value is itself an error it is
// available via Unwrap (and thus [errors.As] / [errors.Is]); a non-error panic value
// appears only in the formatted message. Request event processing logs the full
// error and queues a generic browser alert.
// [CallEventHandlers] returns an error matching this sentinel and [ErrEventLogOnly].
// It unwraps to the recovered value if that value is an error; other panic values
// appear only in the error text.
var ErrEventHandlerPanic errEventHandlerPanic

type errEventHandlerPanic struct {
Expand All @@ -247,13 +245,8 @@ func (e errEventHandlerPanic) Error() string {
return fmt.Sprintf("jaws: %v panic: %v", e.Type, e.Value)
}

// JawsClientAlert returns the browser message for a recovered handler panic.
func (errEventHandlerPanic) JawsClientAlert() string {
return "event handler failed"
}

func (errEventHandlerPanic) Is(target error) bool {
return target == ErrEventHandlerPanic
return target == ErrEventHandlerPanic || target == ErrEventLogOnly
}

func (e errEventHandlerPanic) Unwrap() error {
Expand All @@ -263,6 +256,13 @@ func (e errEventHandlerPanic) Unwrap() error {
return nil
}

// ErrEventLogOnly marks event errors that are logged without automatic browser alerts.
//
// Request event processing suppresses an error's alert when [errors.Is] matches
// this sentinel. Recovered handler panics and JsVar rejections match it.
// Explicit [Request.Alert] calls are unaffected.
var ErrEventLogOnly = errors.New("event error is log-only")

type errEventUnhandled struct{}

func (errEventUnhandled) Error() string {
Expand Down
5 changes: 3 additions & 2 deletions event.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ type InputHandler interface {
// its WebSocket is open and does not queue them for later delivery. Native
// changes that emit neither input nor change do not invoke JawsInput.
//
// [ErrEventUnhandled] tries the next handler. Other non-nil errors are logged
// and queued as danger alerts when possible during Request event processing.
// [ErrEventUnhandled] tries the next handler. Request event processing logs
// other errors and, unless they match [ErrEventLogOnly], queues them as danger
// alerts when possible.
JawsInput(elem *Element, value string) (err error)
}

Expand Down
6 changes: 4 additions & 2 deletions jaws.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ package jaws
// must not block or call back into the same Request.
//
// Bound-value locks in lib/bind, lib/ui, and lib/named are released before dirtying or
// broadcasting. InitialHTMLAttrHandler callbacks run without caller-held widget or
// bound-value locks; bind.Binder acquires its own lock before calling its narrower
// broadcasting, except that a JsVar ClientCheck may send an explicit Request.Alert.
// Broadcast delivery does not acquire bound-value locks or wait for Requests.
// InitialHTMLAttrHandler callbacks run without caller-held widget or bound-value
// locks; bind.Binder acquires its own lock before calling its narrower
// InitialHTMLAttrHook. Code holding a core lock must not invoke UI value methods.
// Container reconciliation has one deliberate reverse edge: containerState.mu may be
// held while Request.NewElement takes Request.mu. Provider callbacks and validation
Expand Down
5 changes: 1 addition & 4 deletions lib/ui/errjsvar.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,5 @@ func (e errJsVarClientWrite) Error() string { return e.cause.Error() }
func (e errJsVarClientWrite) Is(target error) bool {
// Event dispatch treats ErrEventUnhandled as fallthrough. A rejected proposal
// is always handled, even when an application check returns that sentinel.
return target != jaws.ErrEventUnhandled && errors.Is(e.cause, target)
return target == jaws.ErrEventLogOnly || (target != jaws.ErrEventUnhandled && errors.Is(e.cause, target))
}

// JawsClientAlert returns a generic browser message for a rejected proposal.
func (errJsVarClientWrite) JawsClientAlert() string { return "invalid JsVar update" }
16 changes: 11 additions & 5 deletions lib/ui/jsvar.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,18 @@ func validateJsVarPath(path string) error {
// JsVarCheck validates the complete tentative state of a browser proposal.
//
// A nil check denies browser writes. The check runs under the store's write lock
// after jq tentatively applies a changed Go value. It must only inspect next;
// an error or panic rolls the proposal back. The source can authorize a user
// or session. Validate the complete value, including fields omitted from JSON
// after jq tentatively applies a changed Go value. It must not mutate or retain
// next, reacquire the lock, or call a store setter. An error or panic rolls the
// proposal back. The source can authorize a user or session.
// Validate the complete value, including fields omitted from JSON
// and changes through parent or root paths. A null proposal for a struct zeroes
// all its fields, including unexported and json:"-" fields. The check sees
// converted Go values, not raw browser JSON; see [JsVarBinding.JawsInput].
//
// Rejection errors are logged during event processing without automatic browser
// alerts. When handling browser input, the check may call [jaws.Request.Alert]
// on source.Request to provide user-facing feedback. An error matching
// [ErrJsVarTooLarge] cancels the Request.
type JsVarCheck[T any] func(source *jaws.Element, next *T, path string) error

// JSONSizeCheck limits the encoded size of a tentative JsVar store value.
Expand Down Expand Up @@ -586,8 +592,9 @@ func (store *JsVarStore[T]) projectVisiblePatch(path, root string, visible any)
// A rejected, invalid, or unchanged proposal schedules a canonical correction
// for its source binding. A changed accepted proposal invalidates every binding.
// An unchanged proposal to a complex Go shape is rejected.
// Returned errors match [jaws.ErrEventLogOnly].
// A panicking check rolls back and schedules a root correction before the panic
// continues. [ErrJsVarTooLarge] cancels the source Request for reload recovery.
// continues. [ErrJsVarTooLarge] cancels the source Request.
//
// Browser JSON numbers are decoded as float64 and converted by jq to the Go
// destination before ClientCheck runs. Conversion may lose information: 300
Expand Down Expand Up @@ -629,7 +636,6 @@ func (binding *JsVarBinding[T]) JawsInput(elem *jaws.Element, input string) (err
if err != nil {
if errors.Is(err, ErrJsVarTooLarge) {
elem.Request.Cancel(err)
return ErrJsVarTooLarge
}
return errJsVarClientWrite{err}
}
Expand Down
52 changes: 47 additions & 5 deletions lib/ui/jsvar_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ func TestJsVarRejectedBrowserInput(t *testing.T) {
name string
input string
checkError error
checkPanic bool
alert string
hook bool
readOnly bool
wantPatch string
wantChecks int
}{
{name: "missing equals", input: "value", wantPatch: `={"value":1}`},
{name: "invalid path", input: "value..field=2", wantPatch: `={"value":1}`},
{name: "missing path", input: "missing=2", wantPatch: "missing="},
{name: "invalid JSON", input: "value={", wantPatch: `={"value":1}`},
{name: "read-only store", input: "value=2", checkError: ErrJsVarReadOnly, readOnly: true, wantPatch: "value=1"},
{name: "rejected check", input: "value=2", checkError: errors.New("private check detail"), wantPatch: "value=1", wantChecks: 1},
{name: "unhandled check", input: "value=2", checkError: fmt.Errorf("private check detail: %w", jaws.ErrEventUnhandled), wantPatch: "value=1", wantChecks: 1},
{name: "explicit alert", input: "value=2", checkError: errors.New("private check detail"), alert: "Choose a value no greater than 1.", wantPatch: "value=1", wantChecks: 1},
{name: "hook rejection", input: "value=2", checkError: errors.New("private check detail"), hook: true, wantPatch: "value=1", wantChecks: 1},
{name: "panicking check", input: "value=2", checkError: errors.New("private panic detail"), checkPanic: true, wantPatch: `={"value":1}`, wantChecks: 1},
{name: "hook panic", input: "value=2", checkError: errors.New("private panic detail"), checkPanic: true, hook: true, wantPatch: `={"value":1}`, wantChecks: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -40,6 +49,8 @@ func TestJsVarRejectedBrowserInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
logger := new(templateLogger)
jw.Logger = logger
go jw.Serve()
tr := jawstest.NewTestRequest(jw, nil)
<-tr.ReadyCh
Expand All @@ -55,20 +66,51 @@ func TestJsVarRejectedBrowserInput(t *testing.T) {
}{Value: 1}
store := newTestJsVarStore(t, jw, "client", &mu, &state)
checks := 0
store.ClientCheck = func(*jaws.Element, *struct {
store.ClientCheck = func(source *jaws.Element, _ *struct {
Value int `json:"value"`
}, string,
}, _ string,
) error {
checks++
if tt.alert != "" {
source.Request.Alert("warning", tt.alert)
}
if tt.checkPanic {
panic(tt.checkError)
}
return tt.checkError
}
if tt.readOnly {
store.ClientCheck = nil
}
_, elem, _ := renderTestJsVar(t, tr.Request, store)
tr.InCh <- wire.WsMsg{Jid: elem.Jid(), What: what.JsVar, Data: tt.input}
if tt.hook {
tr.BcastCh <- wire.Message{Dest: store, What: what.Hook, Data: tt.input}
} else {
tr.InCh <- wire.WsMsg{Jid: elem.Jid(), What: what.JsVar, Data: tt.input}
}
synctest.Wait()
time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond)
synctest.Wait()
logged := logger.sync(t, jw)
if len(logged) != 1 {
t.Fatalf("logged errors = %v, want one rejection", logged)
}
if !errors.Is(logged[0], jaws.ErrEventLogOnly) {
t.Fatalf("logged error does not match ErrEventLogOnly: %v", logged[0])
}
if tt.checkPanic {
if !errors.Is(logged[0], jaws.ErrEventHandlerPanic) || !errors.Is(logged[0], tt.checkError) {
t.Fatalf("logged panic = %v, want %v", logged[0], tt.checkError)
}
} else if tt.checkError != nil && logged[0].Error() != tt.checkError.Error() {
t.Fatalf("logged error = %q, want %q", logged[0], tt.checkError)
}

var alerts, patches int
var wantAlerts int
if tt.alert != "" {
wantAlerts = 1
}
for {
select {
case msg, ok := <-tr.OutCh:
Expand All @@ -78,7 +120,7 @@ func TestJsVarRejectedBrowserInput(t *testing.T) {
switch msg.What {
case what.Alert:
alerts++
if msg.Jid != 0 || msg.Data != "danger\ninvalid JsVar update" {
if tt.alert == "" || msg.Jid != 0 || msg.Data != "warning\n"+tt.alert {
t.Fatalf("browser alert = %#v", msg)
}
case what.JsVar:
Expand All @@ -90,7 +132,7 @@ func TestJsVarRejectedBrowserInput(t *testing.T) {
t.Fatalf("unexpected browser message: %#v", msg)
}
default:
if alerts != 1 || patches != 1 || checks != tt.wantChecks || state.Value != 1 {
if alerts != wantAlerts || patches != 1 || checks != tt.wantChecks || state.Value != 1 {
t.Fatalf("alerts=%d patches=%d checks=%d value=%d", alerts, patches, checks, state.Value)
}
return
Expand Down
3 changes: 3 additions & 0 deletions lib/ui/jsvar_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ func TestJsVarStoreJSONSizeRollback(t *testing.T) {
if !errors.Is(err, ErrJsVarTooLarge) || state.Text != "ok" {
t.Fatalf("oversize proposal: state=%q err=%v", state.Text, err)
}
if !errors.Is(err, jaws.ErrEventLogOnly) {
t.Fatalf("oversize proposal error permits an automatic alert: %v", err)
}
if rq.Context().Err() == nil {
t.Fatal("oversize proposal did not cancel its Request")
}
Expand Down
10 changes: 4 additions & 6 deletions lib/what/what.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ const (

// Hook synchronously invokes the matching event handler.
//
// Hook is a testing facility rather than part of the browser wire protocol:
// the JaWS client never sends it, and inbound client messages are never
// dispatched as Hook. Broadcasting a Hook message lets a test drive an
// element's event handler synchronously, without round-tripping through the
// client. The handler must not send messages of its own; any error it
// returns is delivered to the client as an [Alert].
// Hook is a test-only broadcast event; inbound client messages never dispatch
// it. Handlers must not send messages of their own. Errors are logged and
// normally sent as [Alert] messages; recovered panics and JsVar store rejections
// omit alerts.
Hook
)

Expand Down
Loading
Loading