The API example below matches the current draft. The audit findings at the end describe the earlier edd11fe implementation and remain as historical evidence.
Goal
A Go value shared by several Requests backs one browser JSON variable per page. Browser writes and server mutations can change individual paths. Accepted state reaches every connected client using partial updates at the normal update rate; rejected writes, connection setup, and overload do not leave a healthy client permanently stale. ClientCheck remains available. This is follow-up design work to #392; PR #402 handles only its immediate overload failure.
Current gaps
- JsVar bindings share a Go pointer but have separate ClientCheck settings. A weaker binding can mutate state that a stronger binding would reject.
- Server code needs a rendered Element to publish a path change; a nil Element changes Go state without broadcasting.
- Accepted broadcasts carry the requested value. For example, setting a Go int from JSON 1.5 stores 1 but sends 1.5. A map-to-struct overlay may retain fields in Go that JavaScript replaces. A rejected optimistic browser write has no correction.
- The browser sends a write even for a jawsVar(name) read. Same-name bindings fan one proposal out to every Element, so a non-idempotent PathSetter can run more than once.
- A dropped final Set or a change between HTML rendering and WebSocket subscription has no repair path. Ordinary UI tags can also route one store's patch to an unrelated JsVar with the same tag.
- A Go field is not automatically a JSON patch: json:",string", omitempty, custom marshaling, and deletion can change the root JSON representation without a matching leaf assignment.
- SetPather.JawsPathSet runs after the JsVar lock is released, but for a browser write its
value is the object jq just stored in Ptr by reference (a decoded map[string]any, []any or any). When Requests share one locker and Ptr, plain browser Set frames from another Request race with any hook that reads value, and the Go runtime can abort the whole process. See Audit findings.
Proposed ownership and API
One store owns one backing value, locker, validation policy, browser name, and private routing tag for one Jaws instance. JaWS carries bounded path invalidations to each Request; the store does not retain Request or Element pointers. Each render obtains a fresh request-scoped binding on that Jaws instance. Every active same-store binding handles proposals and receives canonical patches. The browser retains a value per live Jid, patches each matching Jid, and uses the last attached live binding for reads and writes. Removing it exposes the previous live binding. A parent render rollback leaves the previous visible binding usable. A browser proposal reaches the store once.
Current draft API (illustrative call site):
var mu sync.RWMutex
store, err := ui.NewJsVarStore(jw, "game", &mu, &game)
if err != nil { return err }
store.ClientCheck = func(source *jaws.Element, next *Game, path string) error {
// Validate the complete tentative state and source.Request permissions.
return nil
}
binding := store.Bind() // fresh binding for one Request/Element
_, err = store.SetPath("players.alice.x", 12) // no Element needed
store.ReadLocked(func(value *Game) {
// Inspect value while the store's read lock is held.
})
err = store.WriteLocked(func(
get func(string) (any, error),
set func(string, any) (bool, error),
deletePath func(string) (bool, error),
) error {
if _, err := set("players.alice.x", 13); err != nil { return err }
_, err := set("players.alice.y", 34)
return err
})
The constructor accepts a sync.Locker and preserves bind.RWLocker behavior through bind.AsRWLocker. ReadLocked uses RLock; SetPath and WriteLocked use Lock. Passing a sync.RWMutex therefore permits concurrent reader callbacks; a plain sync.Mutex remains valid but serializes readers.
Configure ClientCheck before first use. It runs once for every changed browser write supported by the generic JSON setter, against tentative complete Go state under the application lock; rejection rolls the mutation back. The check is read-only. It receives the source Element and a canonical path. Authorization must inspect the complete tentative value: a root or parent proposal can change many descendants, so a path-only denylist cannot make a field immutable. JSONSizeCheck remains composable, but its full-value marshal costs O(state size) on every checked write; large game states should use domain-specific checks. Server writes are not client proposals and do not invoke ClientCheck.
Do not carry PathSetter into the store or auto-detect it on the backing value. Today it replaces generic mutation and bypasses ClientCheck, so keeping both leaves validation ambiguous. Use JSON-native, client-facing fields for generic browser writes. For custom decoding or commands, use an explicit application event handler that validates its input and calls SetPath or WriteLocked; a non-JSON-native field can be represented in a client-facing DTO. No custom mutation callback is part of the store's browser-write transaction in the first version.
SetPath is a one-call wrapper around WriteLocked's setter. Both apply the value and record the canonical path in the same operation; no separate path list can drift from the mutation. ReadLocked passes the borrowed *T under one read lock. WriteLocked passes locked get, set, and delete functions so read-modify-write and several path changes share one write lock. A no-op set returns changed=false and records no dirty path. A root replacement uses the empty path. The supplied functions are valid only during their callback and must not be retained or called from another goroutine. Callbacks must not call lock-taking store methods. Mutable values reached through ReadLocked or returned by the locked getter are borrowed under the lock and must not be retained or mutated through aliases.
WriteLocked is a lock scope, not a rollback transaction: successful setters remain applied and dirty if a later step returns an error or panics. Direct edits to the captured backing pointer are outside the store contract because they bypass path tracking. No full-value snapshot is needed for ordinary grouped path writes.
All server mutations that need publication must use SetPath, DeletePath, or WriteLocked's supplied setters. If a path cannot provide a safe partial JSON projection, publish a root snapshot.
JSON and browser contract
Use strict canonical dotted paths; reject empty components, protocol delimiters, prototype-sensitive names, and components that cannot represent the encoded JSON path. Arbitrary map keys need a future structured-path API. Every bound JSON object must have unique member names after encoding, including nested objects; reject ambiguous encodings before render or patch. A non-root browser proposal must address an existing visible path and may change only that visible subtree. Compare the tentative encoded result for complex Go shapes so encoder aliases, promoted fields, and custom methods cannot redirect a proposal. Reject unchanged proposals to complex shapes too: Go equality can otherwise reveal fields hidden by their JSON encoding. ClientCheck validates each changed tentative value.
For each invalidation snapshot, encode the current root once under the store lock. Release that lock, decode the frozen bytes once with exact JSON numbers and duplicate-name detection, then project all requested paths from the decoded tree. Deduplicate patches that widen to the same ancestor; a root patch supersedes all others. Plain JSON trees can use partial patches. Omitted or newly visible fields, custom JSON methods, pointers, slices, dynamic interfaces, and uncertain projection use a safe ancestor or root Patch. Deleting a map key needs an explicit delete operation; assigning null is not deletion. Never publish a raw Go field that the root marshaler redacts.
Keep optimistic browser writes for immediate local response. Make jawsVar(name) a pure read and jawsVar(name, value) an explicit write; do not send a proposal for a read. A successful send updates the browser value locally; a failed or unavailable send leaves it unchanged. The server checks each proposal and publishes the canonical stored result to the source, including for rejection, no-op, or an accepted value identical to the proposal; coalesce source responses by path at the update tick. An earlier server patch can still be in flight when the browser sends a newer value, so skipping the identical response could let that older patch leave the browser stale. A server frame may briefly replace a newer optimistic local edit; the later proposal response restores the final server value. Server commit order resolves concurrent writes to the same path. No proposal sequence, browser revision, or acknowledgement is required. ErrJsVarTooLarge may disconnect the source, whose next render supplies a snapshot.
Inbound coalescing boundary
Keep applying and validating each browser proposal by default, then coalesce the accepted canonical results for outbound delivery. Dropping inbound Proposal messages first changes ClientCheck policies that inspect each proposed transition. Browser proposals replace existing paths; they cannot append to slices. Inbound coalescing also cannot reduce server-originated write traffic. A future explicitly replaceable path type could allow inbound last-value-wins coalescing, but would need a bounded per-client queue and a defined validation and ordering contract. Client-side throttling is only a convenience; a custom WebSocket client can bypass it.
Delivery
Register the store's private tag and render initial JSON under the store lock. A mutation before rendering is in that JSON; one afterward records a canonical path-only invalidation. Extend JaWS's existing 100 ms dirty pass to coalesce paths per selector and copy them into every registered Request, including one waiting for its WebSocket. A Request retains a bounded union of pending paths until its loop handles them; excess paths collapse to a root invalidation. This closes the render-to-connect gap without Ready, a connect-time snapshot, a revision counter, or a delivery cursor. The supported bundled client opens its WebSocket after parsing the page; a custom client that connects while HTML renders is outside this design's supported flow (see #409).
When a Request handles an invalidation, each active matching binding encodes the current canonical Go state under the store lock. Decode and project the frozen snapshot outside that lock, then queue safe partial patches or a root replacement on that Request's ordered outbound queue. An old invalidation handled after later writes or a binding replacement therefore sends current values, never an old frozen payload. The browser applies patches by Jid, including to unselected live bindings; retained binding Jids keep their current browser value through Replace. An accepted write invalidates every binding; rejection or no-op invalidates only the source so its optimistic value is corrected. Keep path invalidations bounded; a blocked Request eventually times out or closes and reloads. A client that cannot apply a patch reloads rather than logging and continuing. No store-side batch ring, replay, generation count, or application-level acknowledgement is needed.
This keeps validation and JSON projection in lib/ui; JaWS uses path-bearing dirty dispatch in place of the old Set batching. Store ExtraTags use ordinary Jaws.Dirty so a tag registered later in a pending page render still receives the update. Pending Requests deduplicate those ordinary selectors across dirty passes.
Acceptance criteria
- Two live Requests over one game struct exchange server and browser updates to different paths; ordinary coordinate traffic transfers only those paths, and both browsers converge to the Go value.
- Tests cover conversion (JSON 1.5 into Go int), map-to-struct overlay, validation rejection, per-user permissions, size rejection, rapid optimistic writes with an older server patch arriving after an accepted identical proposal, and a same-name duplicate that sends only one proposal to the store.
- Tests cover concurrent ReadLocked calls with an RWLocker, atomic read-modify-write and grouped SetPath calls under WriteLocked, no-op writes, and publication of earlier successful writes after a later error or panic.
- Tests cover render-to-connect writes, reconnect by reload, a Request delayed several ticks, bounded path collapse to root, a slow peer, selected-binding removal and retained-ID replacement, patch application failure, and an unrelated JsVarStore binding sharing an ordinary UI tag. No dead Request or Element stays retained by the store.
- Security regression tests cover omitted parents and fields, encoded-name aliases and collisions (including escaped and invalid UTF-8 keys), hidden-value equality guesses, duplicate members at any depth, nonfinite conversions, exact large integers, array bounds, and protocol-invalid paths. Render rollback restores the surviving binding; rendering on another Jaws instance fails early. Unsafe leaf projection uses a safe ancestor/root or an explicit delete patch; rejected proposals leave state unchanged.
- No store API hands an unlocked caller a reference into the backing value. A
-race test has two Requests writing a map-valued path while the other side reads the written value, and reports no race.
- Race tests use real concurrent writers and two Requests. Commit benchmarks for a large state with one changed leaf and for multiple active bindings, with transferred bytes and allocations. Run before/after at least six times and report benchstat. Full snapshots must not be the normal path for supported JSON-native fields.
Local six-run benchmarks for the current audit fixes: projecting 64 paths fell from 14.41 ms to 0.55 ms per snapshot, and concurrent writer wait from 83.5 ms to 1.99 ms at -cpu=8. Changed leaf proposals cost about 25–30% more for JSON validation. A route-selection scan measured about 10% (50–70 ns) more and was removed in favor of patches to every active binding. A final six-run rendered-binding comparison shows +0.2% geomean proposal cost with unchanged allocations; the -cpu=8 parallel case was +1.7%. Dirty-tag fan-out for 100 Requests fell from 10.43 ms to 335 µs for 300 tags, and from 17.29 ms to 470 µs with 100 tags and 1,000 pending selectors. Keep these scenarios as regression benchmarks; the pending-selector case uses more memory.
Breaking JsVar API changes are acceptable. Start with the plain JSON-native path and correctness fallbacks; add custom transactional mutation only if tests justify it.
Audit findings (2026-09-25)
A security audit of commit edd11fe independently verified the finding below twice. The hardening notes are unverified observations that bear on this design.
SetPather receives a value that aliases lock-protected shared state
- The trigger is a browser write:
Set<TAB>Jid.N<TAB>path=json.
JsVar.JawsInput (lib/ui/jsvar.go:609) decodes the JSON into any.
setPathLocked (jsvar.go:361-367) calls jq.Set/jq.SetChecked. For an assignable composite, jq v0.6.0 stageValue keeps the value itself as the candidate (jq.go:122-123). Ptr and value therefore share one map or slice.
setPathAndMarshal unlocks (jsvar.go:383). setPath then calls sp.JawsPathSet(elem, jsPath, value) without the lock (jsvar.go:448).
- The public SetPather doc (jsvar.go:77-84) does not mention this aliasing. Only the internal comment at jsvar.go:378-380 does. Commit 30e3f59 moved the broadcast
json.Marshal(value) under the lock but left this call outside it.
This matters because distinct JsVar values sharing one locker and Ptr across Requests is a documented pattern (jsvar.go:174, lib/ui/AI.md JsVarMaker example). A SetPather that logs, marshals or iterates value then reads a map that another Request's event goroutine writes under the lock. One client with two page loads can trigger it with ordinary Set frames. Two legitimate users writing concurrently can trigger it by accident. The event-handler recover() does not catch a runtime concurrent-map fatal error.
Reproduction (sandboxed, no network; real Request loops, raw frames through wire.Parse)
Setup:
type board struct {
Shapes map[string]any `json:"shapes"`
audit *slog.Logger // slog.NewJSONHandler(io.Discard, nil)
}
func (b *board) JawsPathSet(elem *jaws.Element, jsPath string, value any) {
b.audit.Info("jsvar set", "path", jsPath, "value", value) // reads value unlocked
}
- Two
jawstest.NewTestRequest Requests share one sync.Mutex and one *board. Each renders its own ui.NewJsVar over them.
- Request A loops
Set<TAB>Jid.a<TAB>shapes={"k":0,"r":<i>,"x0":0,...,"x1999":1999}, which is about 23.8 KB, under the 32 KiB read limit.
- Request B loops
Set<TAB>Jid.b<TAB>shapes.k=<i+1>.
Observed:
- After one write, the
value passed to JawsPathSet is pointer-identical to Ptr.Shapes.
- With
go test -race, 100 rounds gave 73 WARNING: DATA RACE reports. The write side is jq SetMapIndex via JsVar.setPathLocked (jsvar.go:367). The read side is slog/json in JawsPathSet (jsvar.go:448). A second harness with a json.Marshal persistence hook gave 49.
- Without
-race, the slog hook run aborted after 0.33 s with fatal error: concurrent map iteration and map write. The hunter's harness aborted in 4 of 4 runs. Two 20000-round runs with json.Marshal hooks did not abort. The abort depends on timing and on the hook; the race does not.
- With a private copy passed to the callback, the alias and all races disappear, and the existing JsVar tests pass under
-race.
Fix for current code: for a browser write, pass JawsPathSet a private value decoded from the broadcast payload that setPathAndMarshal already marshals under the lock. Document that a programmatic JawsSetPath value may be retained by reference and should only be read under the locker. Add the -race regression test above.
if err == nil && broadcasted {
if sp, ok := any(jsvar.Ptr).(SetPather); ok {
if clientWrite {
// The setter may have stored value by reference in Ptr and this
// callback runs unlocked, so pass a private copy decoded from the
// payload marshaled under the lock.
var fresh any
if json.Unmarshal(data, &fresh) == nil {
value = fresh
} else {
value = nil
}
}
sp.JawsPathSet(elem, jsPath, value)
}
}
For this design, the finding supports dropping PathSetter/SetPather from the store. It also means no store callback or getPathLocked value may escape the lock by reference.
Set batching coalesces on the raw path, not the canonical path (from #402)
Introduced with the per-tick Set coalescing in #402. The audit verified it twice.
setBatch.add (setbatch.go:62-91) keys entries on the raw path cut from the Set data (lines 63, 71, 81).
- jq and
jawsVar both ignore empty dot components (lib/ui/jsvar.go:222, lib/ui/AI.md:353, jaws.js:506). So value, .value, value. and ..value name one field, but each spelling becomes its own batch entry.
JsVar.setPath rejects only TAB/LF/CR/= (jsvar.go:429) and broadcasts the raw client path (jsvar.go:412-415).
Observed in the sandboxed harness with the real Request/Serve loops:
- The unit-level
setBatch.add → take() returns one entry per spelling for both single-tag and []any destinations.
- N accepted writes to one field reach a peer sharing the binding as 1 frame with one spelling, but as N frames with distinct spellings (N=20 and N=200).
- A mirror of
TestJsVarClientBurstFanoutKeepsSlowPeerCurrent (lib/ui/jsvar_batch_test.go:149) delivered 160 frames instead of 1. A peer that easily keeps up with the coalesced rate was eventually cancelled with ErrRequestOverloaded.
Scope: severity low. This matters only for bindings with a small fixed canonical path set. Distinct canonical paths on growable slices or maps already allow the same fan-out by design, and inbound rate limiting is an accepted non-feature (SECURITY.md I6).
Fix for current code: normalize the coalescing key by dropping empty dot components before keying. Keep the last message's Data verbatim, and keep passing the raw path to ClientCheck, PathSetter and SetPather as documented. Add a regression test that runs the client-burst scenario with distinct spellings and expects one frame per tick. For this design, the store's canonical-path rule, applied before ClientCheck and before batching, subsumes the fix.
Hardening notes (unverified)
setGroup.forRequest (setbatch.go:25-40) takes rq.mu.RLock and does a tag lookup for every batch entry, for every subscriber, even subscribers that hold none of the batch's tags. Request.process calls sendQueue after each Set in a group, and getSendMsgs rebuilds its Jid set from all rq.elems on each call (1000 Sets took 0.29 ms at 1 Element and 4.86 ms at 500 Elements). Grouping by destination and building the set once per group would bound both. The store's per-binding delivery should not inherit this.
- With multi-tag (
[]any) destinations, setBatch.add rescans all entries for each add, so a batch costs O(entries²). Measured 0.75 ms at 1000 entries and 20 ms at 8000. Index those entries by path if they stay.
- The server accepts inherited-name path components such as
toString, hasOwnProperty and constructor.prototype.x for generic map JsVars once those keys exist, and broadcasts them verbatim. Only the browser's own-property traversal and function-value check neutralize them. A regression test that replays such frames and asserts the Object/Function/Array prototypes are unchanged would pin that invariant.
- In
jawsVar, jawsShouldSet's JSON-equality shortcut also applies when obj[lastkey] is not an own property. With an empty {} snapshot, a same-named Window named property, such as an element id, therefore stays at window[name] until the first root write, and peer path writes then throw and silently desync that binding. Require Object.hasOwn(obj, lastkey) before the shortcut.
- A binding to an existing application global keeps that object, prototype accessors included, whenever its JSON equals the snapshot, and replaces it otherwise. The docs don't say this.
JsVar.JawsGet and JsVar.JawsGetPath release the read lock and return maps, slices and pointers that alias Ptr, and the doc does not say so. Any future unlocked store read API should either copy or state the borrow rule.
jawsVar rejects only an exact __proto__ component (jaws.js:507-509), and the server name check excludes only __proto__. Rejecting constructor and prototype components on both sides would add defense in depth for bindings to existing application globals.
Goal
A Go value shared by several Requests backs one browser JSON variable per page. Browser writes and server mutations can change individual paths. Accepted state reaches every connected client using partial updates at the normal update rate; rejected writes, connection setup, and overload do not leave a healthy client permanently stale. ClientCheck remains available. This is follow-up design work to #392; PR #402 handles only its immediate overload failure.
Current gaps
valueis the object jq just stored in Ptr by reference (a decodedmap[string]any,[]anyorany). When Requests share one locker and Ptr, plain browser Set frames from another Request race with any hook that readsvalue, and the Go runtime can abort the whole process. See Audit findings.Proposed ownership and API
One store owns one backing value, locker, validation policy, browser name, and private routing tag for one Jaws instance. JaWS carries bounded path invalidations to each Request; the store does not retain Request or Element pointers. Each render obtains a fresh request-scoped binding on that Jaws instance. Every active same-store binding handles proposals and receives canonical patches. The browser retains a value per live Jid, patches each matching Jid, and uses the last attached live binding for reads and writes. Removing it exposes the previous live binding. A parent render rollback leaves the previous visible binding usable. A browser proposal reaches the store once.
Current draft API (illustrative call site):
The constructor accepts a
sync.Lockerand preservesbind.RWLockerbehavior throughbind.AsRWLocker.ReadLockedusesRLock;SetPathandWriteLockeduseLock. Passing async.RWMutextherefore permits concurrent reader callbacks; a plainsync.Mutexremains valid but serializes readers.Configure ClientCheck before first use. It runs once for every changed browser write supported by the generic JSON setter, against tentative complete Go state under the application lock; rejection rolls the mutation back. The check is read-only. It receives the source Element and a canonical path. Authorization must inspect the complete tentative value: a root or parent proposal can change many descendants, so a path-only denylist cannot make a field immutable. JSONSizeCheck remains composable, but its full-value marshal costs O(state size) on every checked write; large game states should use domain-specific checks. Server writes are not client proposals and do not invoke ClientCheck.
Do not carry PathSetter into the store or auto-detect it on the backing value. Today it replaces generic mutation and bypasses ClientCheck, so keeping both leaves validation ambiguous. Use JSON-native, client-facing fields for generic browser writes. For custom decoding or commands, use an explicit application event handler that validates its input and calls SetPath or WriteLocked; a non-JSON-native field can be represented in a client-facing DTO. No custom mutation callback is part of the store's browser-write transaction in the first version.
SetPath is a one-call wrapper around WriteLocked's setter. Both apply the value and record the canonical path in the same operation; no separate path list can drift from the mutation.
ReadLockedpasses the borrowed*Tunder one read lock.WriteLockedpasses locked get, set, and delete functions so read-modify-write and several path changes share one write lock. A no-op set returnschanged=falseand records no dirty path. A root replacement uses the empty path. The supplied functions are valid only during their callback and must not be retained or called from another goroutine. Callbacks must not call lock-taking store methods. Mutable values reached throughReadLockedor returned by the locked getter are borrowed under the lock and must not be retained or mutated through aliases.WriteLockedis a lock scope, not a rollback transaction: successful setters remain applied and dirty if a later step returns an error or panics. Direct edits to the captured backing pointer are outside the store contract because they bypass path tracking. No full-value snapshot is needed for ordinary grouped path writes.All server mutations that need publication must use SetPath, DeletePath, or WriteLocked's supplied setters. If a path cannot provide a safe partial JSON projection, publish a root snapshot.
JSON and browser contract
Use strict canonical dotted paths; reject empty components, protocol delimiters, prototype-sensitive names, and components that cannot represent the encoded JSON path. Arbitrary map keys need a future structured-path API. Every bound JSON object must have unique member names after encoding, including nested objects; reject ambiguous encodings before render or patch. A non-root browser proposal must address an existing visible path and may change only that visible subtree. Compare the tentative encoded result for complex Go shapes so encoder aliases, promoted fields, and custom methods cannot redirect a proposal. Reject unchanged proposals to complex shapes too: Go equality can otherwise reveal fields hidden by their JSON encoding. ClientCheck validates each changed tentative value.
For each invalidation snapshot, encode the current root once under the store lock. Release that lock, decode the frozen bytes once with exact JSON numbers and duplicate-name detection, then project all requested paths from the decoded tree. Deduplicate patches that widen to the same ancestor; a root patch supersedes all others. Plain JSON trees can use partial patches. Omitted or newly visible fields, custom JSON methods, pointers, slices, dynamic interfaces, and uncertain projection use a safe ancestor or root Patch. Deleting a map key needs an explicit delete operation; assigning null is not deletion. Never publish a raw Go field that the root marshaler redacts.
Keep optimistic browser writes for immediate local response. Make
jawsVar(name)a pure read andjawsVar(name, value)an explicit write; do not send a proposal for a read. A successful send updates the browser value locally; a failed or unavailable send leaves it unchanged. The server checks each proposal and publishes the canonical stored result to the source, including for rejection, no-op, or an accepted value identical to the proposal; coalesce source responses by path at the update tick. An earlier server patch can still be in flight when the browser sends a newer value, so skipping the identical response could let that older patch leave the browser stale. A server frame may briefly replace a newer optimistic local edit; the later proposal response restores the final server value. Server commit order resolves concurrent writes to the same path. No proposal sequence, browser revision, or acknowledgement is required.ErrJsVarTooLargemay disconnect the source, whose next render supplies a snapshot.Inbound coalescing boundary
Keep applying and validating each browser proposal by default, then coalesce the accepted canonical results for outbound delivery. Dropping inbound
Proposalmessages first changesClientCheckpolicies that inspect each proposed transition. Browser proposals replace existing paths; they cannot append to slices. Inbound coalescing also cannot reduce server-originated write traffic. A future explicitly replaceable path type could allow inbound last-value-wins coalescing, but would need a bounded per-client queue and a defined validation and ordering contract. Client-side throttling is only a convenience; a custom WebSocket client can bypass it.Delivery
Register the store's private tag and render initial JSON under the store lock. A mutation before rendering is in that JSON; one afterward records a canonical path-only invalidation. Extend JaWS's existing 100 ms dirty pass to coalesce paths per selector and copy them into every registered Request, including one waiting for its WebSocket. A Request retains a bounded union of pending paths until its loop handles them; excess paths collapse to a root invalidation. This closes the render-to-connect gap without
Ready, a connect-time snapshot, a revision counter, or a delivery cursor. The supported bundled client opens its WebSocket after parsing the page; a custom client that connects while HTML renders is outside this design's supported flow (see #409).When a Request handles an invalidation, each active matching binding encodes the current canonical Go state under the store lock. Decode and project the frozen snapshot outside that lock, then queue safe partial patches or a root replacement on that Request's ordered outbound queue. An old invalidation handled after later writes or a binding replacement therefore sends current values, never an old frozen payload. The browser applies patches by Jid, including to unselected live bindings; retained binding Jids keep their current browser value through
Replace. An accepted write invalidates every binding; rejection or no-op invalidates only the source so its optimistic value is corrected. Keep path invalidations bounded; a blocked Request eventually times out or closes and reloads. A client that cannot apply a patch reloads rather than logging and continuing. No store-side batch ring, replay, generation count, or application-level acknowledgement is needed.This keeps validation and JSON projection in
lib/ui; JaWS uses path-bearing dirty dispatch in place of the oldSetbatching. Store ExtraTags use ordinaryJaws.Dirtyso a tag registered later in a pending page render still receives the update. Pending Requests deduplicate those ordinary selectors across dirty passes.Acceptance criteria
-racetest has two Requests writing a map-valued path while the other side reads the written value, and reports no race.Local six-run benchmarks for the current audit fixes: projecting 64 paths fell from 14.41 ms to 0.55 ms per snapshot, and concurrent writer wait from 83.5 ms to 1.99 ms at
-cpu=8. Changed leaf proposals cost about 25–30% more for JSON validation. A route-selection scan measured about 10% (50–70 ns) more and was removed in favor of patches to every active binding. A final six-run rendered-binding comparison shows +0.2% geomean proposal cost with unchanged allocations; the-cpu=8parallel case was +1.7%. Dirty-tag fan-out for 100 Requests fell from 10.43 ms to 335 µs for 300 tags, and from 17.29 ms to 470 µs with 100 tags and 1,000 pending selectors. Keep these scenarios as regression benchmarks; the pending-selector case uses more memory.Breaking JsVar API changes are acceptable. Start with the plain JSON-native path and correctness fallbacks; add custom transactional mutation only if tests justify it.
Audit findings (2026-09-25)
A security audit of commit edd11fe independently verified the finding below twice. The hardening notes are unverified observations that bear on this design.
SetPather receives a value that aliases lock-protected shared state
Set<TAB>Jid.N<TAB>path=json.JsVar.JawsInput(lib/ui/jsvar.go:609) decodes the JSON intoany.setPathLocked(jsvar.go:361-367) callsjq.Set/jq.SetChecked. For an assignable composite, jq v0.6.0stageValuekeeps the value itself as the candidate (jq.go:122-123).Ptrandvaluetherefore share one map or slice.setPathAndMarshalunlocks (jsvar.go:383).setPaththen callssp.JawsPathSet(elem, jsPath, value)without the lock (jsvar.go:448).json.Marshal(value)under the lock but left this call outside it.This matters because distinct JsVar values sharing one locker and Ptr across Requests is a documented pattern (jsvar.go:174, lib/ui/AI.md JsVarMaker example). A SetPather that logs, marshals or iterates
valuethen reads a map that another Request's event goroutine writes under the lock. One client with two page loads can trigger it with ordinary Set frames. Two legitimate users writing concurrently can trigger it by accident. The event-handlerrecover()does not catch a runtime concurrent-map fatal error.Reproduction (sandboxed, no network; real Request loops, raw frames through
wire.Parse)Setup:
jawstest.NewTestRequestRequests share onesync.Mutexand one*board. Each renders its ownui.NewJsVarover them.Set<TAB>Jid.a<TAB>shapes={"k":0,"r":<i>,"x0":0,...,"x1999":1999}, which is about 23.8 KB, under the 32 KiB read limit.Set<TAB>Jid.b<TAB>shapes.k=<i+1>.Observed:
valuepassed toJawsPathSetis pointer-identical toPtr.Shapes.go test -race, 100 rounds gave 73WARNING: DATA RACEreports. The write side is jqSetMapIndexviaJsVar.setPathLocked(jsvar.go:367). The read side is slog/json inJawsPathSet(jsvar.go:448). A second harness with ajson.Marshalpersistence hook gave 49.-race, the slog hook run aborted after 0.33 s withfatal error: concurrent map iteration and map write. The hunter's harness aborted in 4 of 4 runs. Two 20000-round runs withjson.Marshalhooks did not abort. The abort depends on timing and on the hook; the race does not.-race.Fix for current code: for a browser write, pass
JawsPathSeta private value decoded from the broadcast payload thatsetPathAndMarshalalready marshals under the lock. Document that a programmaticJawsSetPathvalue may be retained by reference and should only be read under the locker. Add the-raceregression test above.For this design, the finding supports dropping PathSetter/SetPather from the store. It also means no store callback or
getPathLockedvalue may escape the lock by reference.Set batching coalesces on the raw path, not the canonical path (from #402)
Introduced with the per-tick Set coalescing in #402. The audit verified it twice.
setBatch.add(setbatch.go:62-91) keys entries on the raw path cut from the Set data (lines 63, 71, 81).jawsVarboth ignore empty dot components (lib/ui/jsvar.go:222, lib/ui/AI.md:353, jaws.js:506). Sovalue,.value,value.and..valuename one field, but each spelling becomes its own batch entry.JsVar.setPathrejects only TAB/LF/CR/=(jsvar.go:429) and broadcasts the raw client path (jsvar.go:412-415).Observed in the sandboxed harness with the real Request/Serve loops:
setBatch.add→take()returns one entry per spelling for both single-tag and[]anydestinations.TestJsVarClientBurstFanoutKeepsSlowPeerCurrent(lib/ui/jsvar_batch_test.go:149) delivered 160 frames instead of 1. A peer that easily keeps up with the coalesced rate was eventually cancelled withErrRequestOverloaded.Scope: severity low. This matters only for bindings with a small fixed canonical path set. Distinct canonical paths on growable slices or maps already allow the same fan-out by design, and inbound rate limiting is an accepted non-feature (SECURITY.md I6).
Fix for current code: normalize the coalescing key by dropping empty dot components before keying. Keep the last message's Data verbatim, and keep passing the raw path to ClientCheck, PathSetter and SetPather as documented. Add a regression test that runs the client-burst scenario with distinct spellings and expects one frame per tick. For this design, the store's canonical-path rule, applied before ClientCheck and before batching, subsumes the fix.
Hardening notes (unverified)
setGroup.forRequest(setbatch.go:25-40) takesrq.mu.RLockand does a tag lookup for every batch entry, for every subscriber, even subscribers that hold none of the batch's tags.Request.processcallssendQueueafter each Set in a group, andgetSendMsgsrebuilds its Jid set from allrq.elemson each call (1000 Sets took 0.29 ms at 1 Element and 4.86 ms at 500 Elements). Grouping by destination and building the set once per group would bound both. The store's per-binding delivery should not inherit this.[]any) destinations,setBatch.addrescans all entries for each add, so a batch costs O(entries²). Measured 0.75 ms at 1000 entries and 20 ms at 8000. Index those entries by path if they stay.toString,hasOwnPropertyandconstructor.prototype.xfor generic map JsVars once those keys exist, and broadcasts them verbatim. Only the browser's own-property traversal and function-value check neutralize them. A regression test that replays such frames and asserts the Object/Function/Array prototypes are unchanged would pin that invariant.jawsVar,jawsShouldSet's JSON-equality shortcut also applies whenobj[lastkey]is not an own property. With an empty{}snapshot, a same-named Window named property, such as an elementid, therefore stays atwindow[name]until the first root write, and peer path writes then throw and silently desync that binding. RequireObject.hasOwn(obj, lastkey)before the shortcut.JsVar.JawsGetandJsVar.JawsGetPathrelease the read lock and return maps, slices and pointers that alias Ptr, and the doc does not say so. Any future unlocked store read API should either copy or state the borrow rule.jawsVarrejects only an exact__proto__component (jaws.js:507-509), and the server name check excludes only__proto__. Rejectingconstructorandprototypecomponents on both sides would add defense in depth for bindings to existing application globals.