Skip to content

fix: terminate the Request on non-finite floats and non-reflexive UI values#191

Merged
linkdata merged 8 commits into
mainfrom
fix/179-terminate-on-nonfinite
Jul 21, 2026
Merged

fix: terminate the Request on non-finite floats and non-reflexive UI values#191
linkdata merged 8 commits into
mainfrom
fix/179-terminate-on-nonfinite

Conversation

@linkdata

@linkdata linkdata commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the scattered NaN/±Inf handling with a uniform fail-fast policy. A non-finite float64, or a jaws.UI value a container cannot use as a child — one that is not comparable at runtime or not equal to itself and so cannot serve as the pool map key (#179), or a nil interface that has no methods to render — is treated as a fatal contract violation: the offending *Request is logged and terminated instead of the value being blanked, deduped, or silently dropped.

Both failure domains converge on the existing Request.Cancel (which cancels the context and logs the cause via Jaws.Logger, mirroring the ErrRequestOverloaded teardown).

Domain A — unusable UI values in containers (fixes #179)

ContainerHelper keys its element pool by jaws.UI; a value containing math.NaN() is a legal comparable map key but NaN != NaN, so every reconcile missed the lookup and recreated the child (losing focus/selection/transitions, churning Remove/Append/Order). A value that is only statically comparable (an interface-held slice/map) additionally panicked when hashed, and a nil interface child later panicked in render.

The container is the only place a raw UI value is used as a map key (tags go through TagExpand; direct elements are keyed by Jid and never reconciled), so it is the guard:

  • ContainerHelper validates the whole wanted-children slice before locking u.mu (in both RenderContainer and reconcile) and terminates on the first nil-interface / non-reflexive / runtime-incomparable child — before any child is created, hashed, or rendered. Validation must precede the lock because Request.Cancel runs the user logger synchronously and the locking contract forbids that under a lock — a logger re-entering the container would deadlock (pinned by a reentrant-logger regression test). The scan is cheap: one deferred recover for the whole slice and a plain self-comparison per child.
  • Request.NewElement keeps only its original debug-build comparability assertion; it does not re-validate on the hot path, so container updates pay the guard once, not twice.
  • jaws.NewErrUnusableUI(ui) produces UI-specific text (the tag package's "implement JawsGetTag" advice does not apply to a raw UI map key) and matches both tag.ErrNotUsableAsTag and tag.ErrNotComparable via errors.Is.

nil semantics. Only a nil interface is special: it is a legal map key but has no methods to render, so containers reject it and a direct NewElement(nil) yields a no-op Element (Element.JawsRender/JawsUpdate no-op a nil interface). A typed nil (a non-nil interface holding a nil pointer, e.g. (*Widget)(nil)) is comparable and equal to itself, so it is treated as usable, dispatches to its Renderer/Updater, and is reused as a stable pool key across updates; tolerating a nil receiver is the concrete type's responsibility (consistent with how the tag system treats typed-nil tags).

Domain B — non-finite floats

InputFloat (renderFloatInput / JawsUpdate / JawsInput) and the click path (runAtofcallEventHandler) terminate on any NaN/±Inf, browser-originated or server-side, dropping the empty-value="" coercion and the NaN self-inequality dedup. Overflow strings like 1e999 (which strconv.ParseFloat returns as ±Inf with a range error) are classified by the parsed result, so they terminate rather than being treated as malformed.

The bind adapter keeps its own guard: sanitizeFloatForT still rejects non-finite input and bind.ErrFloatNotFinite is retained, protecting the converted numeric adapters (MakeSetterFloat64 over an int/uint/float32 setter). A raw Setter[float64] is returned unwrapped and is guarded by the InputFloat widget instead.

Sentinel & docs

  • New jaws.ErrValueNotFinite (float domain); the container domain matches tag.ErrNotUsableAsTag.
  • Updated the UI / JawsContains contract docs, the exported InputFloat type and methods, Number/Range, and the tracked JAWS skill to describe the container-level guard, NewElement's debug-only assertion, and the nil / typed-nil / non-finite handling.

Performance

This is a no-regression change, not a claimed end-to-end speedup. The guard runs once per child on the update path (no double validation) and is allocation-free.

  • End-to-end: the broad container benchmarks (BenchmarkContainerHelperUpdate{AppendHeavy,Mixed}) are dominated by rendering and allocation and are statistically level with main under order-balanced runs (all p ≥ 0.39); allocations unchanged.

  • Focused scan (committed regression guard BenchmarkContainerValidateChildren, 1000 children):

    go test -run '^$' -bench BenchmarkContainerValidateChildren -benchmem -count=8 ./lib/ui/
    # ~1.6µs/op, 0 B/op, 0 allocs/op
    

    A one-off benchstat vs the prior per-child validator (baseline: the replaced approach that called NewErrUnusableUI for every child) over -count=8: 4.479µs → 1.608µs, −64.10% (p=0.000, n=8), 0 allocations both sides. This per-child figure is measured with the small benchChild concrete type; the absolute cost of a single self-comparison scales with the child value's type and size.

Tests

NaN/Inf tests assert termination; added coverage for click/context-menu termination (incl. overflow), NewErrUnusableUI (table: nil / NaN / statically- and runtime-incomparable / valid / typed nil, both errors.Is identities), direct NewElement(nil) (no-op, no panic) and typed-nil dispatch, the container across nil / NaN / runtime-incomparable children (terminate) plus a typed-nil child (rendered and reused across updates), whole-slice prevalidation on both render and update (a usable child before an unusable one creates no Element, produces no output, and commits nothing — creation proven absent via a monotonic-Jid probe Element, and on the update path reconcile is exercised directly and asserted to return four empty operation sets so nothing is queued), a reentrant-logger deadlock regression (asserts the callback ran; verified to fail without the pre-lock fix), the InputFloat render/update/input boundaries (incl. overflow), and NewElement's debug comparability assertion.

Verification

gofmt, go vet, staticcheck, golangci-lint (0 issues), gosec (0 issues), go test -race ./..., and go test -tags debug -race ./... — all clean.

Closes #179.

linkdata added 8 commits July 20, 2026 21:36
…values

Replace the scattered NaN/±Inf handling with a uniform fail-fast policy. A
non-finite float64, or a UI value that is not equal to itself (issue #179),
has no valid rendering, wire, or map-key representation, so log the cause and
terminate the offending Request instead of blanking, deduping, or silently
dropping it.

Two domains converge on the existing Request.Cancel (cancel + log):

  - Non-reflexive comparable values: Request.NewElement now guards every
    element with tag.NewErrNotUsableAsTag (catches NaN and runtime-incomparable)
    in all builds, replacing the debug-only NewErrNotComparable panic. Container
    children flow through NewElement, so this fixes the reconcile churn in #179
    centrally without touching ContainerHelper.

  - Non-finite floats: InputFloat (render/update/input) and the click path
    (runAtof/callEventHandler) terminate on any NaN/±Inf, from the browser or
    server-side, dropping the empty-string coercion and the NaN self-inequality
    dedup.

Adds the ErrValueNotFinite sentinel. sanitizeFloatForT keeps its
finite-out-of-range check (an infinity now falls out as ErrFloatOutOfRange) and
no longer rejects finiteness itself. Removes the now-unused exported
bind.ErrFloatNotFinite (breaking).
…ch overflow

Follow-up to review feedback on the non-finite/non-reflexive termination change:

  - Container reconcile hashed a wanted child UI before reaching the NewElement
    guard, so an interface-held slice/map (runtime-incomparable) panicked instead
    of cancelling. Validate each child with a shared cancelUnusableChild helper
    before the pool lookup, in both RenderContainer and reconcile, so unusable
    children never enter u.contents. Adds an incomparable-update regression (the
    NaN child was hashable and missed this).

  - Restore the finite guard in bind.sanitizeFloatForT and the exported
    bind.ErrFloatNotFinite. MakeSetterFloat64 is public, so a direct
    JawsSet(..., NaN) must not store NaN or do an implementation-defined integer
    conversion; the widget-layer termination is not the only caller.

  - Overflow strings ("1e999") parse to ±Inf with a range error, so the err==nil
    gates in InputFloat.JawsInput and click.runAtof skipped the finiteness check,
    leaving inputs live and clicks dropped. Classify the parsed result before the
    error so overflow terminates like a literal NaN/Inf. Adds overflow cases.

  - Update the UI/JawsContains contract docs and the Number/Range docs to require
    reflexivity and describe all-build Request cancellation, replacing the stale
    debug-only-panic and blank/default-render wording.
…pecific error

  - ContainerHelper cancelled the Request while holding u.mu (reconcile validated
    each child inside the lock). Request.Cancel runs the user logger synchronously,
    which the locking contract forbids under a lock: a logger re-entering the
    container deadlocks. Validate the whole wanted-children slice with
    cancelUnusableChildren before locking (and before the render loop in
    RenderContainer), aborting on the first unusable child so later children are not
    created after fatal cancellation. Adds a reentrant-logger deadlock regression
    (verified to fail without the fix via a 2s timeout).

  - The cancellation cause reused tag.NewErrNotUsableAsTag, whose message advises
    implementing JawsGetTag — a non-fix for a raw jaws.UI map key. Add
    jaws.NewErrUnusableUI: UI-specific text that still matches tag.ErrNotUsableAsTag
    (and tag.ErrNotComparable) via errors.Is. NewElement and the container use it.

  - Document the cancellation side effect on the exported InputFloat type and its
    JawsUpdate/JawsInput methods, and update the tracked JAWS skill's comparability
    rules to require reflexivity and describe all-build cancellation.

  - Handle the previously blank-assigned render error in the container regression.
… regression

  - NewErrUnusableUI(nil) returned nil, so a nil UI passed container validation and
    later panicked in JawsRender. Reject nil explicitly (clear "nil is not usable"
    message), and make Element.JawsRender/JawsUpdate treat a nil UI as a no-op so a
    direct NewElement(nil) yields a harmless Element rather than panicking. Adds a
    validator table test (nil / NaN / statically-incomparable / runtime-incomparable /
    valid, matching both tag identities), a direct nil regression, and a container nil
    child case.

  - errUnusableUI only implements Is, not Unwrap, so the cause does not "wrap"
    tag.ErrNotUsableAsTag. Reword the UI contract to say it "matches ... with
    errors.Is", and name tag.ErrNotUsableAsTag (not the NewErrUnusableUI constructor)
    in the container comment.

  - Fix the append-heavy benchmark regression. The all-build guard in NewElement was
    a round-0 over-generalization: a raw UI value is used as a map key only in the
    container pool (tags go through TagExpand; direct elements are keyed by Jid and
    never reconciled), so the container is the correct and sufficient guard. Restore
    NewElement's original debug-only comparability assertion, leaving the container's
    pre-lock scan as the sole all-build guard, and make that scan cheap (one deferred
    recover for the whole slice, a plain self-comparison per child). This removes the
    per-child double validation. benchstat (Apple M5 Max, count=8) vs the regressed
    state: AppendHeavy -3.34%, Mixed -12.13%; AppendHeavy is now statistically level
    with the no-validation floor (123.4µs vs 122.3µs, p=0.41). Allocations unchanged.

  - Strengthen the reentrant-logger deadlock test to assert the logger callback ran,
    so the check cannot pass vacuously. Update contract docs and the JAWS skill to
    describe the container-level guard and NewElement's debug-only assertion.
…ed benchmark

  - Clarify that only a nil interface is special. A typed nil (e.g. (*Widget)(nil)) is
    comparable and equal to itself, so NewErrUnusableUI reports it usable and the
    containers reconcile it normally; whether its methods tolerate a nil receiver is
    the UI implementation's responsibility, consistent with how the tag system treats
    typed-nil tags. Element.JawsRender only no-ops a nil interface — a typed nil still
    dispatches to its Renderer. New tests cover the typed-nil path (direct dispatch and
    container acceptance) alongside the nil-interface cases.

  - Align the nil documentation with the behavior: a nil interface is a legal map key
    that does not panic; containers reject it because it has no methods to render,
    while a direct NewElement(nil) tolerates it as a no-op. Describe this in the UI and
    JawsContains contracts and the JAWS skill.

  - Add a focused, allocation-free validation-scan benchmark
    (BenchmarkContainerValidateChildren). The broad container benchmarks are dominated
    by rendering and allocation and too noisy to show the scan cost; the focused
    benchmark measures it directly (~1.5ns per child, 0 allocs). A one-off comparison
    showed the single-deferred-recover scan is ~63% faster than per-child validation,
    but end-to-end container updates are statistically level with main: this is a
    no-regression change, not a claimed end-to-end speedup.
…ger tests

  - Add b.ResetTimer() after fixture creation in BenchmarkContainerValidateChildren so
    the one-time benchChildren allocation is excluded; -benchtime=1x now reports 0 B/op
    and 0 allocs instead of the setup cost.

  - Documentation precision: Element.JawsUpdate now says "nil UI interface" and notes a
    typed nil dispatches to its Updater; NewErrUnusableUI documents both errors.Is
    identities (tag.ErrNotUsableAsTag and tag.ErrNotComparable) and the direct
    NewElement(nil) exception (tolerated as a no-op, so the error is reported only for
    the container's benefit).

  - Strengthen the typed-nil container test to assert the rendered output and that the
    child's Element and Jid are reused across an update (a stable reflexive key does
    not churn). Add TestContainerValidatesWholeSliceBeforeRender: a usable child
    preceding an unusable one is neither rendered nor committed, pinning that the
    pre-lock scan validates the whole slice before any child work.
…dation tests

TestContainerValidatesWholeSliceBeforeRender proved no rendering or commit but not the
stated "no child created" guarantee. Assert via the Request registry that the Jid a
valid prefix child would have taken is unused, so NewElement was never reached.

Add TestContainerUpdateValidatesWholeSliceBeforeReconcile: a wanted slice with a
usable child before an unusable one, on the update path, must abort before reconcile
mutates u.contents or the pool. Assert the existing child is not removed, the new
valid child is not created (its would-be Jid is unused), and u.contents is unchanged —
which precludes any queued Remove/Append/Order op.
…cile outputs

A GetElementByJid == nil check is also satisfied by a created-then-deleted Element, so
it did not prove the "no child created" guarantee. Because Jids are monotonic, both
prevalidation tests now create a probe Element after the abort and assert it receives
the expected next Jid; a created (even later-deleted) child would have advanced the
counter. Verified the probe fails when a stray element is created.

The update test now exercises reconcile directly and asserts all four returned
operation sets are empty, so UpdateContainer has nothing to queue — observing the
reconciliation outputs rather than asserting the absence of ops indirectly.
@linkdata
linkdata merged commit 577c7c6 into main Jul 21, 2026
7 checks passed
@linkdata
linkdata deleted the fix/179-terminate-on-nonfinite branch July 21, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Container reconciliation recreates unchanged UI values containing NaN

1 participant