Release hardening from the v0.2.5 panel - #275
Conversation
GroupHandle.viewLevelsDegraded was computed but never read, so a truncated overview pyramid looked identical to a short recording. The status line now carries a suffix and a standing note sits under the minimap; both re-check when viewLevelsReady settles after first paint.
mutate() runs syncAll() synchronously before the debounced flush, so a write that fails on the last annotation before a tab close never re-armed the beforeunload guard nor repainted the "not being saved" banner. The store now notifies subscribers from degrade(); the annotation layer subscribes at open and re-runs syncBeforeUnload() + renderPanel().
PrefetchController.loop walked the whole recording re-failing forever with only a static buffered bar as feedback. Four consecutive segment failures (each already past store.ts's retry ladder) now stop the walk and fire onStalled; the viewer shows a note beside the preload toggle, and any restart -- toggle, cache cap, group switch -- re-arms it. Aborts do not count as failures.
isPopoverOpen() had zero callers, so prev/next and the subject/task dropdowns destroyed the mounted instance and its unsaved popover draft silently. The viewer now hands the page a ViewerAnnotationHandle on the same seam as onTransfer; navigateEegViewer, stepEegViewer and both select handlers refuse and flash+focus the popover instead. No window.confirm -- the refusal plus a visible cue says the same thing without a blocking modal.
applyTransfer carried a manually-set gain onto any new recording. Gain multiplies the modality's DEFAULT_SCALINGS, so an EEG gain on an MEG recording is a different physical scale, not the same zoom. The transfer record now carries the modality it was chosen against; a mismatch falls back to auto-scale. Same-modality swaps are unchanged.
The close handler relied on eegLive.destroy, which navigateEegViewer nulls for the duration of the new mount. Fall back to the host's own _eegvCleanup so an instance that published it but has not returned its disposer yet is released immediately instead of at the post-await staleness check.
The export is the escape hatch for annotations that live nowhere else, so a throwing createObjectURL/click must not read as an inert button. Wrap it and render the failure beside the export buttons; a later success clears the message.
writeNavOrder returns false for exactly this (privacy mode, blocked storage) and the return value was discarded.
- hed-vocab.ts / annotation-ui.ts: the bundle is ~341 KB and 1525 entries, not ~140 KB / 500, and it is no longer curated (website#269). - HedPath / HedVocabEntry.path / extract-hed-vocab.mjs: the long form is the storage and lookup key; the SHORT form is what an export writes (#268). - annotation-ui.ts labelForPath: it falls back to the derived short form, matching its own inline comment. - render-dir-listing.ts: the missing half of the CHEVRON_SVG sync comment. - ADR 0013: dated update note; search now covers both schemas in full, curation narrowed to quick picks, decision unchanged. - AGENTS.md: real test count, and eeg-viewer/ now names what it holds. - [id].astro: releaseEegViewer has two call sites, and openRecordingInDialog can be reached with an inline viewer live. - extract-hed-vocab.mjs: drop the unused byPath map (regenerating the bundle after the change produces a byte-identical file).
Deploying nemar-website with
|
| Latest commit: |
f0715ee
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://92490bc0.nemar-website.pages.dev |
| Branch Preview URL: | https://fix-v0-2-5-release-hardening.nemar-website.pages.dev |
DialogCloseButton calls dialog.close(), which fires only the non-cancelable "close" event -- a decision already taken. So the X walked straight past the annotation-draft guard that Escape respects via "cancel", and the detached-close branch destroyed the draft with no trace. The button now dispatches a cancelable pre-close event; the dataset page vetoes it while a detached viewer holds an open popover. Inert for every dialog that does not listen.
The note advertised "turn it off and on again to retry", and that cure provably failed: renderImpl's read-failure branch returns before updatePrefetchTarget, and the toggle-on path early-returns while no view level is known, so the latched flag survived its own recovery and the note stuck for good. The outage that trips the breaker is the same one that fails the interactive read, so this was the common case. The note is now derived from PrefetchController.stalled, which stop() clears alongside start(), and is re-evaluated on every render exactly as the degraded-pyramid note already was -- that asymmetry was why one self-healed and the other did not.
Three handles point at the one disposer, and a dialog close during a navigate mount fires two of them: the close handler takes host._eegvCleanup, then navigateEegViewer's superseded branch calls the same returned destroy. Running the cleanups twice double-disposes the GL context and tears the annotation layer down mid-flush. Guard in the closure, which is the only place that can see all three call sites.
A throwing isPopoverOpen/focusPopover would otherwise wedge every prev/next and both dropdowns for the rest of the session, with the selects re-syncing away from the user's pick and no gesture that recovers. Losing at most one unsaved draft is the smaller failure.
gainCarriesOver was an inline expression in applyTransfer's closure. Its failure mode is a trace off by orders of magnitude -- a flat line or a wall of clipping that reads as the recording, not as a bug -- so it does not belong untested. Pure, exported, and covered for same/different modality, case, whitespace, and the null-safety convention that an absent modality counts as a match.
The realistic first contact is mount, subscribe, load -- with no prior save to have caught an unusable connection. That is the visit where the annotator would be told their marks are safe when they are not.
ViewLevelDiscoveryError covers a retry-exhausted 5xx, a 403 from an expired token and a decode error alike, and the flag does not say which, so "connection problem" was a guess presented as a diagnosis. The action is the same either way: reload.
ADR 0013 grew an "## Update -- YYYY-MM-DD" section for a factual change that left the Decision standing. Write the rule down: amend by appending, never by editing Context/Decision/Alternatives, and supersede when the Decision itself changes.
releaseEegViewer's inline comment still named openEegViewerInline as its only caller, contradicting the header six lines above it that now names both. AGENTS.md's test count follows the suite to 1402.
Second panel: all ten addressedNine commits on top of the original nine. Gates green on the tip:
1 — corner X (critical)Confirmed: Fixed as a pre-check, per the finding. I chose a cancelable event over a capture-phase On the non-detached branch: I checked whether it can drop a draft, and it 2 — stuck preload note (high)Confirmed, and the diagnosis was right about why: Fixed by removing the latch rather than only adding a re-derive. The note is now Also added: 3 — double destroy (important)Applied as prescribed: 4 — fail open (medium)Done, with the reasoning recorded: a throwing guard would refuse every prev/next 6 — degraded copyAgreed. 8 —
|
Fixes from the four-agent review panel on release PR #272. Behavioural findings
first, one commit each; the documentation corrections are collected in a single
commit at the end.
This should merge before #272. It targets
staging, so once it lands therelease PR picks it up automatically as part of the
staging->mainpromotion; no second release PR is needed.
Findings and disposition
GroupHandle.viewLevelsDegradedwas computed instore.tsand read nowhere, so a view pyramid truncated by a connection failure was indistinguishable from a genuinely short recordingbeforeunloadguard or repainted the "not being saved" bannerPrefetchController.loopretried into an outage forever with only a static buffered bar as feedbackisPopoverOpen()had zero callers; in-dialog navigation silently discarded an unsaved annotation draftapplyTransfercarried a manually-set gain across a recording swap with no modality checkdownload()could fail silentlywriteNavOrder's boolean return was discarded1. Degraded view pyramid is now visible (
viewer.ts,BidsTree.astro)The viewer's status line carries a
· overview incomplete (connection problem)suffix, and a standing note sits under the overview minimap saying it is a
connection problem rather than a short recording. Both are re-evaluated when
viewLevelsReadysettles, so a group that degrades after first paint updateswithout waiting for the reader's next interaction. The status line is now
composed from a held base string precisely so that late re-application is not a
string patch.
2. Persistence degrade is pushed, not polled (
annotation-store.ts,annotation-ui.ts)mutate()runssyncAll()synchronously and only then schedules the debouncedwrite, so a
QuotaExceededon the last write before a tab close was neverannounced.
AnnotationStoregainsonPersistenceChange(listener), fired fromdegrade(); the annotation layer subscribes before its first read and re-runssyncBeforeUnload()+renderPanel(). A late subscriber is told immediately,and a listener that throws does not stop the others.
Tested against
fake-indexeddb(a platform shim, not a mock): open, force afailure by closing the connection underneath, assert the callback fires exactly
once and that
shouldWarnBeforeUnload's answer flips from false to true for asigned-in annotator. Covered on both the
save()and theload()path — thelatter is the realistic first contact, since the layer loads before it ever
saves.
3. Preloader circuit breaker (
prefetch.ts,viewer.ts)Four consecutive segment failures stop the walk and fire a new
onStalledcallback. Four is documented in the constant's own comment: each of those
failures has already been through
store.ts'sretryingFetch(seven attemptswith backoff), so four in a row is the backend being down, while two would
abandon a recording over an unlucky cluster and ten would just be more retry
traffic. Any success resets the counter; an
AbortErroris a cancellation anddoes not count. The viewer shows "Preload paused — network trouble" beside the
preload toggle, and any restart (toggle, cache cap, group switch) re-arms the
walk and clears the note.
Six new pure-logic tests in
prefetch.test.tscover N-1-then-success, analternating connection, N failures stopping with
onStalledfired once,coverage surviving the trip, aborts not counting, and
start()re-arming.4. Navigation refuses to discard an open draft (
annotation-ui.ts,viewer.ts,[id].astro)mountEegViewergains anonAnnotationsoption that hands the page aViewerAnnotationHandle— the same seamonTransferalready uses. The layergains
focusPopover(), so the DOM stays owned by the layer rather than reachedinto from the page.
navigateEegViewer,stepEegViewerand both the subjectand task select handlers refuse when a popover is open and flash + focus it
instead; the two select handlers additionally re-sync so the dropdown does not
sit on a value the viewer never moved to.
Deliberately no
window.confirm: it blocks the page on an unstyleable dialogand asks a question whose answer is almost always no. Refusing the navigation
and drawing attention to the popover already on screen says the same thing, and
Escape-or-Save-then-click works immediately. The flash is a two-pulse accent
ring with a
prefers-reduced-motionstatic fallback, removed on a timer so itnever becomes a permanent state.
5. Manual gain does not cross a modality (
viewer.ts)ViewerTransferStategains an optionalmodality.applyTransferkeeps amanually-set gain only when the incoming modality matches the new recording's
first group; otherwise it drops it and lets auto-scale measure. Same-modality
swaps behave exactly as before, which was the intended behaviour all along.
6. Dialog-close backstop (
[id].astro)navigateEegViewernullseegLive.destroybefore awaiting the new mount, sothere is a window where the mount has published
_eegvCleanupon the host buthas not returned its disposer. The close handler now takes
live.destroy ?? host._eegvCleanup, clears the host's copy, and calls it once —one teardown whichever handle exists, never two.
7. Export download failures are visible (
annotation-ui.ts)The export is the escape hatch for annotations that live nowhere else, so a
throwing
createObjectURL/clickmust not read as an inert button. Wrapped;the failure renders as a
role="alert"line directly under the export buttonsand clears on a later success.
8.
writeNavOrder(viewer.ts)Its boolean return exists to report "storage refused". Now warned, with the note
that the choice still applies to the current page.
Documentation (one commit)
hed-vocab.ts,annotation-ui.ts: the bundle is ~341 KB and 1525 entries,not ~140 KB and 500, and it is no longer curated (website#269).
HedPath,HedVocabEntry.path,extract-hed-vocab.mjs: the long form is thestorage and lookup key; the SHORT form is what an export writes (website#268).
labelForPathJSDoc: falls back to the derived short form, matching its owninline comment.
render-dir-listing.ts: added the missing half of theCHEVRON_SVG/DIR_CHEVRON_SVGsync comment.set of both schemas, curation narrowed to quick picks, decision unchanged.
AGENTS.md: real test count (1395), and theeeg-viewer/line now namesrecording nav, background preload and HED/SCORE annotation authoring.
[id].astro:releaseEegViewerhas two call sites, not one; andopenRecordingInDialogcan be reached with an inline viewer live, so therelease there is not a no-op.
extract-hed-vocab.mjs: dropped the unusedbyPathmap. Regenerating thebundle after the change produces a byte-identical file.
Gates
bun run test(1402 passed, up from 1385: +10 in the first round, +7 in thereview round),
bun run typecheck(0 errors),bun run lint,bun run build—all green on the tip.
Review round
A second review panel raised ten more items, all fixed in the nine commits after
1d6615f. The substantial ones:.close()fires only thenon-cancelable
closeevent, so the X never reached thecancelguard thatprotects Escape — the more natural exit destroyed the draft with no trace.
DialogCloseButtonnow dispatches a cancelable pre-close event(
src/lib/dialog-close.ts) that a page can veto. The non-detached close isdeliberately not blocked: it moves the host back inline with the popover
inside it, so nothing is lost.
(toggle off and on) ran through
updatePrefetchTarget, which early-returnswhen no view level is known — and the outage that trips the breaker is the same
one that fails the interactive read supplying that level. The note is now
derived from
PrefetchController.stalled(whichstop()clears too) andre-evaluated every render, exactly as the degraded-pyramid note already was.
destroy()is now idempotent, closing the close-during-navigate race thatfired it twice through two different handles.
gainCarriesOverisextracted and tested, the degraded-overview copy no longer claims a
cause it cannot know, and the ADR amendment pattern is written into
.context/decisions/README.md.Not addressed (out of scope, worth a follow-up)
Collapsing the originating tree row while an annotation draft is open still
discards it, via
releaseEegViewer. That is an explicit "close this viewer"gesture rather than a navigation or a dialog dismissal, and neither panel scoped
it; noting it rather than widening the change.