fix: the waiting counter was never once visible
All checks were successful
deploy / build (push) Successful in 44s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s

It refused to count until a block arrived while the page was open, on the
grounds that the block the snapshot hands over on load may be a minute old and
starting it at zero would lie. That was honest and completely useless: on
mainnet the block that triggers the counter is the same block that puts the
first row in the console, so it became visible and unnecessary in the same
tick. Reported from the live site after a refresh — the placeholder was still
there, because the counter had no window in which to exist.

It now seeds from the snapshot's block. The ticking is still
`performance.now()`, which cannot be skewed or stepped; only the seed can be
inexact, and only for that first block, whose age nothing but the server's
`observed_at` can attest. A seed outside a plausible range is refused outright
and the plain text stands — no counter beats a wrong one.

Measured against the live API: the counter appears within 800ms of load
reading "26,378 ms since #43,588", climbs, and gives way to rows about two
seconds later, which is how long the console is honestly empty.

Also recorded, because it wasted a verification: `/v1/healthz`'s `commit` is
stamped into the API binary, so a frontend-only push leaves it on the previous
sha *forever*. A watcher polling it for the sha just pushed waits for something
that will never arrive and reports "still deploying" over a site that finished
minutes ago. Compare the served bundle name to the local build instead — Vite
hashes it by content, so equal names mean byte-identical.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:45:39 +03:00
parent a245caef99
commit 67e7b2ec24
2 changed files with 65 additions and 21 deletions

View File

@@ -952,6 +952,26 @@ OPNsense LAN interface (`reverse-proxies.md` §2). Verify the vhost with
`--resolve blackbeard.observer:443:127.0.0.1`, which still exercises the `:443`
stream router, SNI, the vhost, the cross-site hop and the API.
**`/v1/healthz`'s `commit` only ever reports the API's.** It is stamped into
the binary, so a frontend-only push — which by design does not rebuild or
redeploy the API — leaves it reading the previous commit **forever**. A watcher
polling it for the sha you just pushed waits for something that will never
arrive, and reports "still deploying" over a site that finished deploying
minutes ago. That is exactly what happened while the wormhole console shipped.
To verify a frontend deploy, compare what is *served* against what you built:
```sh
asset=$(curl -s https://blackbeard.observer/ | grep -o '/assets/index-[A-Za-z0-9_-]*\.js' | head -1)
basename "$asset" # served
ls web/dist/assets/index-*.js | xargs -n1 basename # built
```
Vite hashes the bundle by content, so equal names mean the deployed frontend is
byte-identical to the local build. Unequal means it has not landed yet. Grepping
the fetched bundle for a string the change introduced is the same check by
another road, and works when the local build is not to hand.
**A frontend-only push skips the Rust build and the API deploy.** The `what
changed` step in `deploy.yaml` diffs against `github.event.before` and sets one
output; the gate, the musl build, the ts-rs drift check and the whole

View File

@@ -90,6 +90,16 @@ const CONSOLE_LINES = 12
* first line.
*/
const CONSOLE_LINES_PER_BLOCK = 4
/**
* How old the snapshot's block may be said to be before the figure is refused.
*
* The only seed available on load is the difference between two machines'
* clocks, so it is believed at all only while it stays plausible. Generous
* against a slow chain, tight enough that gross skew shows the plain text
* instead of a number nobody should read.
*/
const SEED_LIMIT_MS = 120_000
/** Most marks any single block may spawn, so one big block cannot fill the pool. */
const PER_PULSE_CAP = 28
@@ -218,30 +228,44 @@ export function WormholeStage({
const nextLineId = useRef(0)
/**
* When *this page* saw the newest block, from the monotonic clock.
* Where the waiting counter counts from: a monotonic mark, plus however long
* the block was already old when we took it.
*
* Not `observed_at`. That is the server's wall clock, and subtracting it from
* the browser's would fold in whatever the two disagree by — a viewer whose
* machine is a minute fast would watch the counter run backwards.
* `performance.now()` cannot be skewed or stepped, and "since this page saw
* it" is the honest claim anyway.
* The ticking is always `performance.now()`, which cannot be skewed or
* stepped by the system clock. Only the *seed* can be inexact, and only for
* the block the snapshot hands over on load — for that one the sole evidence
* of its age is `observed_at`, the server's wall clock, so the seed carries
* whatever the two machines disagree by. Every block that arrives afterwards
* seeds at zero and is exact.
*
* `null` until a block actually arrives while the page is open: the first
* block the ticker hands over came from the snapshot and may be a minute old,
* so counting from the moment it was *rendered* would start at zero and lie.
* The first cut refused to seed at all and waited for a live arrival. That
* was more honest and completely useless: on mainnet the block that triggers
* it is the same block that puts the first row in the console, so the counter
* became visible and unnecessary in the same tick and **was never once
* seen**. Reported from the live site after a refresh.
*/
const [blockSeenAt, setBlockSeenAt] = useState<number | null>(null)
const [anchor, setAnchor] = useState<{ at: number; offsetMs: number; height: number } | null>(
null,
)
const lastHeight = useRef<number | null>(null)
useEffect(() => {
if (lastBlock === null) return
if (lastHeight.current === null) {
// The snapshot's tail, not an arrival. Note it and wait for a real one.
lastHeight.current = lastBlock.height
if (lastBlock.height === lastHeight.current) return
const first = lastHeight.current === null
lastHeight.current = lastBlock.height
if (!first) {
setAnchor({ at: performance.now(), offsetMs: 0, height: lastBlock.height })
return
}
if (lastBlock.height !== lastHeight.current) {
lastHeight.current = lastBlock.height
setBlockSeenAt(performance.now())
// The snapshot's newest block, whose age only the server can tell us.
// Clamped rather than trusted: a clock disagreeing by more than a couple of
// minutes would show a wild figure or count backwards, and no counter beats
// a wrong one.
const seed = Date.now() - Date.parse(lastBlock.observed_at)
if (Number.isFinite(seed) && seed >= 0 && seed <= SEED_LIMIT_MS) {
setAnchor({ at: performance.now(), offsetMs: seed, height: lastBlock.height })
}
}, [lastBlock])
@@ -254,12 +278,12 @@ export function WormholeStage({
*/
const elapsed = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (blockSeenAt === null || log.length > 0) return
if (anchor === null || log.length > 0) return
let frame = 0
let timer = 0
const paint = () => {
if (elapsed.current) {
const ms = Math.max(0, Math.round(performance.now() - blockSeenAt))
const ms = Math.max(0, Math.round(anchor.offsetMs + performance.now() - anchor.at))
elapsed.current.textContent = ms.toLocaleString('en-US')
}
}
@@ -278,7 +302,7 @@ export function WormholeStage({
if (frame) cancelAnimationFrame(frame)
if (timer) window.clearInterval(timer)
}
}, [blockSeenAt, log.length, reduced])
}, [anchor, log.length, reduced])
useEffect(() => {
if (chain === null) return
@@ -647,14 +671,14 @@ export function WormholeStage({
<ol className="stage-log" aria-label="Recent notes through the wormhole">
{log.length === 0 ? (
<li className="stage-log-empty">
{blockSeenAt === null || lastBlock === null ? (
{anchor === null ? (
'Each note a block commits or releases will be listed here as it happens.'
) : (
<>
<span ref={elapsed} className="stage-elapsed">
0
</span>{' '}
ms since <BlockLink chain={chain} height={lastBlock.height} />
ms since <BlockLink chain={chain} height={anchor.height} />
</>
)}
</li>