feat(hyperion): a boss bar is an entity with an audience, and the host has two - #1026
Merged
Conversation
…t has two #1018 put one boss bar on the wire, hand rolled in a game event's adapter. This is the API it should have been, and the two bars the operator asked for on top of it. A bar is an entity, its audience is `(ShownTo, player)` edges, and what each viewer was last told is the data on `(Sent, viewer)`. That last choice is what removes the two cases that are usually bugs: a mid-match joiner has no pair, so the next tick sends them `Add`, and every way a bar stops being shown ends with that pair going away, which one `OnRemove` observer turns into a `Remove`. There is no join or leave handler in this change. One field moves and it is that field's narrow operation; two or more and it is a single fresh `Add`, which is both atomic and smaller than the updates it replaces. The wire gate found that one: a lobby bar becoming a countdown was briefly a countdown in the lobby's blue. `/serverload`, Admin, opt in, puts the host's own CPU and RSS on two bars. CPU is `getrusage`, not `ps`, so the first sample reports nothing rather than a lifetime average. Both are drawn against the point the machine stops being able to give more and print that ceiling beside the reading, so the fill is the quotient of the two numbers in the bar's own label and `hud-check.py` divides them to check it. `smash-hud-e2e` goes from 25 checks to 48, including one that reads every boss bar packet of the whole run and asserts none of them said anything the client already knew. It also fixes a check #1019 left red on main.
andrewgazelka
force-pushed
the
feat/boss-bar-api
branch
from
July 28, 2026 11:01
ecd1a59 to
3b49ac7
Compare
Benchmark Results for generalComparing to a25c3a9 |
Benchmark Results for generalComparing to a25c3a9 |
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## main #1026 +/- ##
==========================================
+ Coverage 51.14% 51.46% +0.32%
==========================================
Files 335 337 +2
Lines 30052 30554 +502
Branches 1137 1167 +30
==========================================
+ Hits 15369 15725 +356
- Misses 14438 14577 +139
- Partials 245 252 +7
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BossEventreached the wire for the first time in #1018, as one bar, handrolled in a game event's adapter: one UUID per player,
Addfor every change,and a diff in the game half to stop it resending. That was correct and it was
measured, and it did not survive the next ask, which was two more bars carrying
something that is not a game concept at all.
A bar is an entity and its audience is a set of edges
and later
bar.set(Progress(0.31)), which sends oneUpdateProgressto eachviewer and nothing else.
smash'sServer::set_boss_baris now foursets onone of these, and the packet it used to build is gone.
The load-bearing choice is not the components; it is where the per-viewer sent
state lives.
Sentis the data on the(Sent, viewer)pair, holding exactlywhat that client was last told, and it is what makes the two cases that are
usually bugs stop being code:
(Sent, bar)pair, so thenext tick sends them
Add. There is no join handler in this change.shrinks. All three end with the pair going away -- flecs does the first two
itself under
(OnDeleteTarget, Remove)-- and oneOnRemoveobserver on(Sent, Wildcard)sends theRemove. Teardown cannot leak, because the factthat says "this client has this bar" and the thing that gets cleaned up are
the same fact.
There is no explicit join or leave path anywhere in
boss_bar.rs. That was thetest of whether the shape was right.
The change the wire gate found
A change of state usually moves several fields at once, each operation is its
own packet, and between the first and the last the client is holding a bar that
is half one state and half the next. There is no ordering that fixes it: put
the title last and a countdown ticking over draws the next second's fill under
this second's number; put it first and a lobby bar becoming a countdown is a
countdown in the lobby's blue.
smash-hud-e2ecaught the second of those as a real failure -- one of #1018'sown checks,
and it is yellow, not the lobby's blue, against a bar that wasbriefly exactly that. So one field moving is that field's narrow operation, and
two or more is a single fresh
Add.Addis also the smaller packet: everyoperation repeats the sixteen byte id, so two updates already cost more than
the one that carries all four fields, and
BossHealthOverlay.addis aputinto a map keyed on the id, so it replaces the bar in place.
Packet count is unchanged from #1018's 1,135 -- both send one packet per change
-- and each packet is now at most the size of the
Addit replaced.The two bars that carry the host
/serverload, Admin, opt in. Fifteen people in a lobby staring at servertelemetry is a strange default and three stacked bars is most of the strip.
CPU is
getrusage(RUSAGE_SELF), notps.ru_utime + ru_stimeis acumulative counter, so a rate over a window is the only reading it can give and
the first sample reports that it has nothing yet.
psanswers the lifetimeaverage instead, which is a different question wearing the same units: a server
busy for a minute an hour ago reads busy under
psforever.Memory is RSS, which is what the kernel counts when it decides who to kill.
/proc/self/statmfield one on Linux,proc_pidinfo/PROC_PIDTASKINFOondarwin. The Linux path cannot run on the box the gates run on, so the parser is
a pure function over a string with a fixture whose seven fields all hold
different numbers -- an off-by-one cannot pass it.
The bar that has no natural full
Neither quantity is a fraction. A process can use more than one core's worth of
CPU, and it can allocate until the machine refuses. So both are drawn against
the point where the machine stops being able to give more -- every core it has,
every byte of physical memory -- and both print that ceiling beside the
reading:
which turns the choice into something checkable rather than a matter of taste:
hud-check.pyreads both numbers off the wire and divides. A ceiling nobodyprinted cannot satisfy that, and neither can one invented to make the bar look
busy. It is also what keeps the label unnormalised: the honest figure is the
numerator, and the fill is that same fact taken against the denominator
standing next to it. 200% is two cores, which is what
topsays and what thequestion meant.
Cores come from
available_parallelism, not the raw processor count, becausein a container the honest ceiling is what the container is allowed.
The gate
nix run .#smash-hud-e2e, 48 checks, up from 25.decode_boss_eventread onlyAddand returnedNonefor everything else,because
Addwas all the server sent. Left alone against this server it wouldnot have failed -- it would have reported that no boss bar ever arrived, which
is a silent pass. It now decodes all six operations into a model of the
client's own
BossHealthOverlay, so #1018's twenty-five checks ask the samequestions of the same states, and the operations are kept separately as the
only place the diffing is visible at all.
One check that was already there was also red before this branch touched
anything. #1019 moved
LobbyConfig::min_playersfrom four to two, and the hubbar's
1/4was written intohud-check.pyas a literal, so the gate has beenfailing on
mainagainst a server saying1/2. #1019 rewrote sixtests/lobby.rstests for exactly this reason and the one intools/wasmissed, because nothing in CI runs the wire gates. It now reads the denominator
off the bar and additionally checks that the fill under the label is
1/needed, which is a stronger claim than the literal it replaces and cannotgo stale again. Filed as ENG-10878.
The new checks, beyond the two bars' own numbers:
run rather than at one moment. Three rules -- no packet says anything the
client already knew, a whole bar is only resent where two or more fields
moved, nothing arrives for a bar the client was never given -- checked
against 849 packets in the run below.
Addcarrying the whole thing.
the first one holds. The load bars are the only ones in the game with an
audience of more than one person, so they are the only place this is
observable.
Removeto that viewer and nothing to anybodyelse.
everybody else's.
Broken, and watched to fail
Every new guard was inverted and run. Selected output.
The parser reads
/proc/self/statm's first field instead of its secondThe fixture's seven fields all hold different numbers, so the off-by-one is a
wrong answer rather than a coincidence, and the second test caught it too.
The CPU label is normalised, which is the whole point of the ask
and on the wire, with the same edit:
The first CPU sample answers with the lifetime average, the way
psdoesSentis cleaned up withDeleterather thanRemoveWhich is the whole teardown design failing in the direction that would be
hardest to notice in play: one person quits and the bar goes off everybody's
screen.
Every change resends the whole bar
A client is recorded as having a bar it was never sent
The second line is what this failure looks like from the outside, and it is why
the first one exists: a bar that never arrives is indistinguishable from a
server that never drew one, until something is watching for updates to a bar
nobody has.
The id is per viewer rather than per bar, and the teardown observer is gone
Four ids where there should be two, because every viewer minted its own, and no
Removeat all where a viewer had just left the audience.Verified
Local only, per ENG-10817.
nix run .#test-- 704 passed, 1 skippednix run .#smash-hud-e2e-- 48 checks, 0 failednix run .#fmt-- cleannix run .#lint-- one failure, and it is not this branch's:Reproduced against a detached worktree at
origin/mainwith its ownCARGO_TARGET_DIR, running the same commandflake.nix:182runs, with no partof this branch present:
Filed as ENG-10890 rather than fixed here, because it belongs to #1023 and not
to this change. Worth knowing while chasing it: plain
cargo clippy -p smash --testsover the same tree exits 0, becauseunused_importsis only a failureunder the
-- -D warningsthe flake adds -- which is how it got past #1023'sown check, and how it briefly looked like mine.
Deliberately not built
fraction, that the ceiling is every core, and that the fill is the quotient
of the label's own two numbers; it does not observe a reading above one
core's worth, because forcing the server process over a core from a test
client would mean a burn command in production code. The unnormalised case is
a unit test with the numbers injected: 2.4 cores of 16 reads
CPU 240% of 1600%, and 2.4 cores of 1 readsCPU 240% of 100%with a full bar./proc/self/statmfixture is written toproc(5), not captured off arunning host. The gates run on darwin. What the fixture does prove is the
field index, which is the failure that would ship, because every field holds
a different number.
argument as the boss bar and it should follow, but porting it is a second
change and this one already moves a gate.
ShownToEveryonehas no caller. It is in the API because "everyone" is arule rather than a list -- a player who connects a minute from now is in it --
and it is covered by the drive system's own path, but nothing in the tree
shows a bar to everybody yet.
Also filed
ENG-10878:
smash-hud-e2ewas red onmainbefore this branch, as above.Fixed here. The follow-up worth having is a job that runs the eight gates in
e2eOffsets, because a change that breaks only one of them is invisible todayuntil somebody runs it by hand.
ENG-10871:
/perms set <player> Adminis gated atNormal, so any playerwho can connect can make themselves Admin. The derive gates a whole clap enum
at once, so
GetandSetcannot differ. This gate depends on the hole -- itpromotes its own clients, because there is no other way for a test client to
reach an Admin command -- so closing it needs a way to seed a group at startup
landed alongside.
hud-check.pynames the ticket at the line that depends onit.
Written by Claude (Opus 5) with human direction.