mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
aecae0c69ed5037d3b0e49e502b14a7cd6d6d13b
442 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
15f8d3eb34 |
fix(core): hold the account rules when two requests arrive together
Three rules here are enforced across a lookup and then a write, and each was only as good as whatever stopped the two from interleaving. Nothing did. The connection cap counted with `select ... for update` over the connections a user already had. That locks the rows it finds, and when it finds none it locks nothing — there are no gap locks under read committed — so several first-time links all counted zero and all inserted. Six concurrent links against a cap of four produced six. The count now happens under a lock on the account's own row, which is the one thing every caller for that account is guaranteed to contend on. Creating an account from a verified address looked the address up and then inserted. Two tabs finishing the same sign-in both found nothing, and the loser got the driver's constraint violation instead of the account the winner had just made. The unique index is the thing that actually arbitrates, so the loser now reads back what the winner wrote. Claiming an address on an older account had the same shape and now gives the same sentence a screen would have shown a moment earlier. The tests run each call several times at once against a real database, because run one at a time all three pass whether or not any of this exists. |
||
|
|
cb8f37a0e4 |
fix(nesinit): one thing reaps, and the workload stops alone
Three problems in the shutdown and reaping paths, all of them found in review. Reaping and waiting cannot be two mechanisms. `waitpid(-1, ...)` collects any child, so the reaper and a caller waiting on its own child race for the same status, and whichever loses gets nothing — losing the workload's exit, which is the one thing this component exists to report. The reaper is now the only waiter and hands each exit to whoever asked for that pid. Registering interest holds the same lock the delivery takes, so a child that exits before its caller is registered is still delivered rather than dropped; there is a test that fails without that. Killing the workload killed everything. `kill(-1, SIGKILL)` is every process init may signal, so a workload that overstayed its grace period took the guest's services with it, before the ordered stop the shutdown promises them had even started. It signals the one pid now. The shutdown had no workload to stop. It built a fresh handle with no pid, so the graceful stop was a no-op and the workload only died in the sweep that follows — which is exactly the order this was written to avoid. The handle the session used is now the handle the shutdown uses, and waiting for the workload waits for that pid rather than for any child to leave. |
||
|
|
2c4e9d9b0b |
fix(auth): refuse to send a sign-in code rather than log one
The rule was "throw when the environment says production, otherwise log the code and carry on". The deployment sets no such marker, so the branch that ran was the developer one: every recipient and every usable sign-in code printed to a retained log, the screen reporting success, and nobody receiving anything. That is what a fail-open default costs. The deployment that forgets its mail settings is exactly the deployment with no marker saying it is a real one, so it takes the lenient branch precisely when it should not. Turned around: printing a live code is asked for by name and anything else is an error, so absence of configuration is a refusal instead of an assumption. Two settings out of three is also an error now, because it means somebody is halfway through wiring a provider up and a quiet fallback would hide the missing half. Stages anyone else can reach are checked at deploy time, so a missing setting stops the deploy with the name of the variable it wanted rather than surfacing later as a person waiting for mail that never comes. |
||
|
|
affe1e3c73 |
refactor(auth): serve one provider, and make it the email one
Signing in with a gaming account or with an SSH key could both bring a user into existence. That makes an account only as recoverable as the thing that created it, and gives one person as many accounts as they have gaming logins — neither of which is what an account is supposed to be now that verifying an address is what creates one. Both are unwired rather than deleted. The provider implementations stay where they are, because connecting a gaming account is still something this product does; it just does it from the API, against a user who already exists, which is a connection hanging off an identity rather than an identity of its own. The worker test followed: it exercised the two flows that are gone, and now covers the one that is left plus an assertion that the other two are not routed, so they cannot come back quietly. |
||
|
|
a461cbafa5 |
feat(nesinit): PID 1 for a box — reaping, ordered shutdown, and one channel out
A microVM has no init unless something is it, and three of the jobs belong to nothing else in the guest: reaping whatever the workload orphans, turning a signal into an ordered shutdown, and being the guest end of the one channel out. None of it knows what it is running. The guest dials out on a fixed vsock port, says its protocol version first, is handed one boot descriptor — a command line, shares, output geometry, and what an exit means — and carries that out. There is no code path that branches on which workload started, which is the property the component exists to keep. It reports and does not supervise. When the workload ends, the exit goes up the channel and the session is over; `on_exit` says what the exit means, and starting something again is a decision for the end that can see whether restarting is repair or a loop. A signalled workload is reported as signalled with no exit code, because reporting 0 for a killed process makes a kill look like a clean run. Two seams keep this testable without a VM, which is the reason for both of them. Reaping runs against real forked children, with the subreaper bit making a test process inherit orphans the way PID 1 does. The channel is generic over the byte stream, so the exchange is driven over an in-memory pipe — the transport contributes nothing to the protocol beyond ordering and framing. The lifecycle types live in nesprotocol behind a feature, off by default: both ends of the channel read one definition and cannot drift from it silently, while the media components keep building without serde. Mounting shares is not implemented in this build. The descriptor's mounts are refused rather than ignored — a workload started without the shares it was promised fails later, somewhere else, for a reason nobody can see from here. |
||
|
|
bd163392ca |
fix(core): declare the claim column the migration adds
The migration adds session.claim_token, but neither the schema nor the
snapshot knew about it. Nothing breaks today because the two agree with
each other; it breaks the moment someone declares the field, because
generate then diffs against a snapshot without it and emits
ALTER TABLE "session" ADD COLUMN "claim_token" text;
which fails on every database the migration has already run against.
Declared with no writer yet, so the schema, the snapshot and the
database say the same thing.
|
||
|
|
1b61d2251e |
fix(core): hold the connection cap on the path a settings screen uses
Connecting a Steam account wrote the row itself, so the limit on how many one person may connect was enforced on the sign-in path and nowhere else — and this is the path the settings screen calls, which makes it the one that would have gone over. It now resolves who is asking and hands over to the single place the rule lives. Two things fall out of that. A Steam account already connected to somebody else is a conflict rather than a silent success returning the other person's row id, and a Steam id of the wrong shape is refused before a lookup. |
||
|
|
96b0cf8111 |
feat(auth): sign in with an email address
Wires the pin-code provider, which existed and was never reachable, and makes it the only branch that can create an account. Steam now resolves an existing connection instead of minting a user from a persona, and refuses when there is no account behind it — which is an answer the interface renders rather than an implicit signup. Delivery is a small provider-neutral POST rather than a vendor SDK: configure an endpoint, a key and a from address. With none of them set it logs the code outside production so a local sign-in works, and throws in production, because a screen that says "check your email" when nothing was sent leaves someone waiting instead of telling anybody. A person who has only ever signed in by email has no connected account, and the token says so with an empty value — the same one a server-to-server caller has always carried. |
||
|
|
2a1b7abe9a |
feat(auth): serve the device authorization grant
A program with no browser — the desktop app — had a client for RFC 8628 and nothing to point it at. This serves the other half: a device authorization request that hands back a code, a page a person enters that code on, and a token endpoint that answers the poll. Both of the paths the client already implements are now reachable. Polling faster than the advertised interval gets slow_down, and each warning widens the interval so ignoring one costs more than the last; refusing gets access_denied, so a request nobody started stops instead of being polled until it ages out. The interval is capped, because it only ever grows and a code has to stay pollable for the whole of its life. The codes live in the same storage as the other short-lived grants rather than in a table, since that is what they are. User codes are drawn from an alphabet with no vowels and no look-alike pairs, and are accepted back in whatever case and spacing a person retyped them in. |
||
|
|
1e81a8f92d |
feat(core): make one address one account, on rows that never had one
Runs against a database where every user was created by a gaming sign-in, so most rows have no email at all and nothing has ever stopped two rows from sharing one. The address is normalized first, duplicates are separated before the unique index exists — the older row keeps the address, the newer one is asked for a new one and loses nothing else — and the index is partial so that accounts with no address do not collide with each other. Verified against a database built to contain the awkward rows rather than against an empty schema, by the script alongside it: an account with no address, one with both, one with two connections, a duplicated address in two different cases, an account already over the connection cap, and a deleted row holding an address a live row also holds. Removing the de-duplication makes the index creation fail, which is how we know the fixtures are load-bearing. Also adds a nullable column recording which attempt holds a session run. It is not part of the change above and carries no reason of its own; the endpoint that reads and writes it arrives separately, and it is here because a schema change has one owner at a time. |
||
|
|
da65cca4f2 |
feat(core): an account is an email address, and Steam is a connection
Signing in with Steam used to create the account. That made a second Steam account a second person, and it made losing a Steam account lose everything attached to it — the boxes, the team, the billing history. Invert it. A user comes into existence by verifying an email address and nothing else; a Steam account hangs off a user that already exists, capped at four. Signing in with Steam resolves an account and refuses when there is none, so the accounts made before this keep working — they already have the connection this looks for — while nothing new is created behind a persona. The cap lives here rather than in the schema because a unique index cannot count the rows sharing a foreign key. The email column gains a partial unique index instead, which is the constraint that can be expressed, and the address is trimmed and lower-cased at the edge so two spellings are not two accounts. |
||
|
|
4eff67a11a | fix(core): point the placement marker at the decision that covers placement | ||
|
|
ecebc528ae |
feat(api): the session endpoint, and a claim that only one caller can win (#317)
## What this is
`POST /session`, `GET /session/:id`, and the three endpoints the host
agent
calls: `GET /machine/jobs`, `POST /session/:id/state`, `POST
/session/:id/ticket`.
Core had `Session` and `Box` and no HTTP surface at all; this adds the
surface
and the two things core was missing to support it safely.
### The access rule
An agent may only see or touch a run whose box is placed on its own
hardware,
and that is a `where` clause on all three agent endpoints — not a check
sitting
next to the query, and not the agent asking politely for its own work.
Host
credentials are long-lived secrets on hardware in somebody's living
room, so
the blast radius of one leaking is decided by the join and nowhere else.
`Session.listJobsForMachine`, `forMachine`, `compareAndSetState` and
`publishTicket` all scope to the machine inside the statement. The
compare-and-set is scoped there too, and not only by the read above it:
a
caller checking first and the write being scoped are different
properties, and
only one of them survives somebody adding a second caller.
"No such run" and "not your run" are the same 403 with the same body, so
reporting states at ids cannot be used to discover which ids exist.
### The claim
`Session.setState` updated on the id alone. Two agents polling the same
run
both succeeded and both started the same box. There is one host today,
which
is exactly why that would have been built wrong and stayed wrong.
`Session.compareAndSetState` puts the state being moved *out of* into
the
`where` clause, so the database picks the winner and the loser matches
zero
rows. `Session.transition` layers the classification on top and returns
one of
five outcomes, which the route maps:
| Case | Answer |
|---|---|
| same host re-reports a state it already reported | `200`, nothing
changes |
| a different host reports anything | `403` |
| a transition not in the table | `409`, row does not move |
| a legal transition something else won first | `409` |
| it happened | `200` |
`setState` is left exactly as it was — it is the unscoped primitive the
core
tests already pin, and the new path is additive.
### Placement
`POST /session` makes no placement decision. A box names its hardware,
so a
run inherits it by join, and the request body is `.strict()` so that
naming a
machine there is a validation error rather than a field quietly ignored.
The interface went where boxes are actually placed: `Placement.Placer`
in
`packages/core/src/box/placement.ts`, with `Placement.onlyHost` as the
implementation and `Box.createPlaced` as the one caller. A real
scheduler
replaces that file and nothing else.
## The test failing first
Tests were written and run against unmodified code. Verbatim, trimmed to
the
result lines (the full log is stack traces for the same failures):
```
(fail) POST /session > a request creates the job, in the envelope both ends read [199.00ms]
(fail) POST /session > creating a session makes no placement decision [55.00ms]
(fail) POST /session > a box somebody else owns is not there to run [106.00ms]
(fail) POST /session > a box already running refuses a second run rather than picking one [51.00ms]
(fail) POST /session > you can only play as an account you have linked [85.00ms]
(fail) POST /session > a host cannot ask for a session on its owner’s behalf [54.00ms]
(fail) POST /session > requesting a session requires a signed-in person [1.00ms]
(fail) GET /session/:id > the owner reads their own run, ticket and all [47.00ms]
(fail) GET /session/:id > somebody else’s run is not visible, and neither is its absence [97.00ms]
(fail) GET /session/:id > reading a run requires a signed-in person [1.00ms]
(fail) GET /machine/jobs > a host is handed the work for its own boxes, with the kind on the wire [50.00ms]
(fail) GET /machine/jobs > a host never sees work for a box on other hardware [84.00ms]
(fail) GET /machine/jobs > bad credentials are indistinguishable from none [41.00ms]
(fail) GET /machine/jobs > a person cannot poll for jobs [44.00ms]
(fail) POST /session/:id/state > the claim moves the row, and the job stops being offered [41.00ms]
(fail) POST /session/:id/state > the same host re-reporting a state it already reported is fine [54.00ms]
(fail) POST /session/:id/state > a different host reporting anything is refused, and learns nothing [99.00ms]
(fail) POST /session/:id/state > a transition that is not allowed is a conflict, and the row stays put [47.00ms]
(fail) POST /session/:id/state > a stopped run cannot be started again [47.00ms]
(fail) POST /session/:id/state > a duplicate live report does not extend a run somebody is billed for [44.00ms]
(fail) POST /session/:id/state > a state nobody defined is a validation error, not a conflict [49.00ms]
(fail) POST /session/:id/state > a person cannot report a state on their own session [48.00ms]
(fail) POST /session/:id/ticket > a later ticket replaces the first, because it is a better address [44.00ms]
(fail) POST /session/:id/ticket > a different host cannot publish an address for someone else’s run [75.00ms]
(fail) POST /session/:id/ticket > a stopped run has no address to publish [48.00ms]
(fail) POST /session/:id/ticket > a ticket has to say something [47.00ms]
(fail) Session routes in the spec > every path a caller needs is documented [48.00ms]
error: Cannot find module './placement.js' from '/home/…/packages/core/src/box/placement.test.ts'
(fail) Session jobs > a requested session is the job, and it carries its kind [37.00ms]
(fail) Session jobs > a job belongs to the machine its box is placed on and to no other [71.00ms]
(fail) Session jobs > only a requested session is work; a claimed one is not offered again [34.00ms]
(fail) Session claim > the claim is a compare-and-set, so the second attempt finds nothing to move [37.00ms]
(fail) Session claim > a machine that is not the box’s host cannot move the row [72.00ms]
(fail) Session claim > a session that does not exist is refused the same way as one that is not yours [31.00ms]
(fail) Session claim > re-reporting the state you already reported changes nothing [37.00ms]
(fail) Session claim > a transition off the table is refused and the row does not move [36.00ms]
(fail) Session claim > the timestamps survive a duplicate report, which is what billing rests on [33.00ms]
(fail) Session claim > publishing a ticket is scoped to the host too [73.00ms]
(fail) Session claim > a stopped session has no address to publish [36.00ms]
7 pass
39 fail
1 error
Ran 46 tests across 3 files. [2.88s]
```
(46 rather than 49 because the three `Placement` tests never ran — the
module
they import did not exist.)
## And passing after
```
49 pass
0 fail
126 expect() calls
Ran 49 tests across 3 files. [3.72s]
```
Full suite, against a fresh migrated database on `DATABASE_URL` and
`TEST_DATABASE_URL`:
```
181 pass
0 fail
479 expect() calls
Ran 181 tests across 16 files. [7.89s]
```
Baseline before this branch was `138 pass, 0 fail, 371 expect() calls`
across
14 files, so 43 tests and 108 assertions are new and nothing regressed.
`tsc --noEmit -p apps/api` reports the same five pre-existing errors in
`app/utils/hook.ts` and `app/utils/validator.ts` as it does on `dev`,
and none
in the files this branch adds.
## Shared files touched
- `apps/api/app/index.ts` — three lines: one import, and two mounts
(`/session`, plus `SessionApi.machineRoute` at `/machine`).
- `packages/core/src/box/index.ts` — one import and `Box.createPlaced`
added.
Nothing existing changed.
- `packages/core/src/session/session.test.ts` — `scene()` now also
returns
`machineId`; two new `describe` blocks appended.
- `packages/core/src/examples.ts` was **not** touched; the job schema's
examples reuse the existing `Examples.Session`, `Examples.Box` and
`Examples.Game` values.
No migration was created and none was needed.
## Judgement calls
1. **`GET /machine/jobs` lives in `session.ts`, mounted at `/machine`.**
The
path belongs under the prefix a host already uses for everything it asks
about itself, but the handler is this lane's code, so it is a second
Hono
instance (`SessionApi.machineRoute`) rather than an edit to
`machine.ts`.
2. **A job's payload beyond `kind` was not specified anywhere**, so it
is:
`kind`, `sessionId`, `boxId`, `boxTier`, `gameId`, `steamAppId`,
`linkedAccountId`. `steamAppId` is in there because the agent launches
by
store id and not by our internal one, and `boxTier` because the tier
also
sets output geometry. **This is the most likely place for the two
implementations to disagree** — see the disagreement note below.
3. **A run's terminal states refuse a ticket** (`409`). Nothing said
what
publishing an address for a stopped run should do; writing one can only
mislead a client that is still polling.
4. **A state report accepts only `starting`, `live`, `ended`,
`failed`.**
`requested` is written once, at creation, so reporting it is a `400`
rather
than a no-op.
5. **A box already running refuses a second run** with `409`. Not stated
in so
many words, but `Session.activeForBox` documents itself as the query
that
should start refusing rather than picking a winner silently, and this is
the
caller that would otherwise have made it pick.
6. **`Placement.onlyHost` refuses when there is more than one
candidate**
rather than taking the first row. Picking would be a scheduling policy
invented by accident and impossible to find later. It also refuses when
there are none, because `box.machineId` is not nullable and a placer
that
cannot answer must say so.
7. **`POST /session` returns `201`**, and its body is `.strict()`.
8. **Person-facing 404s, agent-facing 403s.** Both are the settled shape
for
their realm and both are asserted identical between "does not exist" and
"not yours".
9. **`POST /session` accepts an explicit `linkedAccountId`**, defaulting
to the
one the caller's session carries. A personal access token carries none,
so
requiring the session to supply it would make that credential unable to
start a run; the account is verified to belong to the caller either way.
## Where the specification was ambiguous or came out wrong
- **The lost-claim 409 is not reachable through the HTTP endpoint as
specified.** A box names exactly one machine, so both racers for a given
run
authenticate as *the same* machine — and the same machine re-reporting a
state it already reported is specified as `200`. Which of the two
answers a
caller gets therefore depends only on whether its read happened before
or
after the winner's write: concurrent gets `409`, a sequential retry gets
`200`. That is a defensible reading and it is what is implemented, but
it
means the two rules are distinguished by timing and not by anything a
caller
can see. If a second agent process per host is ever real, this needs a
claim
token in the request rather than a timing accident.
- **The job payload is unspecified**, which is the one part of the wire
the
agent parses and the one thing a document written before two
implementations
exist was supposed to pin. If the other end's test expects different
field
names, that is this gap and not a bug in either implementation, and it
should
be settled in the specification before either side moves.
- **Nothing said whether a person may report a state.** Implemented as
`403`:
terminal states are the agent's to write, and a person closing their app
is
a different fact from a run that stopped. That reading is also why
cancellation, listed below, has
nowhere to live yet.
## What this does not verify
- **No live round trip.** Every assertion here goes through
`app.request`
against a real Postgres. No real host agent has ever called any of these
five endpoints, and no client has ever read a ticket out of one.
- **The claim is not verified under concurrency.** The compare-and-set
is
pinned by stepping two attempts in sequence and showing the second
matches
zero rows. Two agents actually racing, on two connections, is not tested
and
cannot be from a single-process test.
- **The wire shape is asserted against this lane's reading of the
specification, not against the other end.** That is deliberate — each
end
writes its own test — but it means a shape agreement has *not* been
demonstrated. Both tests passing separately is the evidence, and it does
not
exist yet.
- **No ticket in this repository has ever been a real iroh ticket.**
They are
opaque strings to every assertion here; that the value survives a round
trip
says nothing about whether anything could connect to it.
- **`steamAppId` reaching the agent is untested end to end.** It is
asserted
present and correct in the poll response and nothing consumes it.
- **Nothing reaps a stuck `requested`.** A run whose host never polls
sits
there forever, and no screen distinguishes that from "starting soon".
Left
open deliberately: a timeout here would hide the gap rather than close
it.
- **Cancellation has no path.** A person who closes their app between
`requested` and `starting` leaves work that will be claimed and started
into
an empty room. There is no endpoint for it and this branch did not
invent
one.
- **Nobody writes `ended` when the agent itself dies.** The only writer
of
terminal states is the agent, so a host that loses power leaves a `live`
run
that keeps billing. The fix belongs with whatever decides host liveness
has
authority over a run's state, and that does not exist.
`Machine.isOnline`
exists but nothing joins it to a session.
- **Placement is verified only for the single-host case and the two
refusals.**
No test exercises a real alternative placer beyond an inline stub, and
nothing calls `Box.createPlaced` from an endpoint, because there is no
box
creation endpoint yet.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR adds the HTTP and core-domain session lifecycle, including user
session requests, machine job polling, host-scoped state transitions and
ticket publication, placement support, box-state synchronization, and a
database constraint ensuring one active session per box.
- Adds authenticated user and machine session endpoints with
resource-level ownership and host scoping.
- Uses compare-and-set state transitions so only one caller can claim a
requested session.
- Adds a partial unique index and duplicate-data repair migration to
enforce one active run per box.
- Clears connection tickets when sessions become terminal.
- Adds placement abstractions and extensive API, domain, migration, and
concurrency-oriented tests.
- The latest changes document and test that ownership is checked per
user rather than per selected linked account; they do not resolve the
previously reported multi-account launch failure.
<h3>Confidence Score: 4/5</h3>
The PR is not yet safe to merge because selecting a different linked
account can still launch a game that only another account owns.
The previously reported ownership issue remains unresolved: the current
request path checks the game against the user-wide library and
separately verifies only that the selected linked account belongs to
that user. The new test explicitly accepts this behavior, so a user with
multiple Steam links can request a game through an account that does not
own it, causing the host launch to fail. The three resolved previous
findings are fully addressed or manually resolved and do not remain
outstanding.
**Files Needing Attention:** apps/api/app/routes/session.ts
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/api/app/routes/session.ts | Adds user and host session endpoints
with scoped authorization and validation, but the previously reported
selected-account ownership mismatch remains. |
| packages/core/src/session/index.ts | Adds job queries, host-scoped
compare-and-set transitions, ticket lifecycle handling, box-state
synchronization, and active-session conflict translation. |
| packages/core/src/session/session.sql.ts | Declares the partial unique
index enforcing at most one undeleted, unstopped session per box. |
| packages/core/migrations/0008_session_one_active_run_per_box.sql |
Repairs pre-existing duplicate active sessions, clears their stale
tickets, and creates the active-session unique index. |
| packages/core/src/box/placement.ts | Introduces a placement seam that
selects the sole owner host and refuses ambiguous or unavailable
placement. |
| apps/api/test/session.test.ts | Adds broad endpoint coverage and now
explicitly demonstrates the unresolved per-user rather than
per-linked-account ownership behavior. |
<h3>Sequence Diagram</h3>
```mermaid
sequenceDiagram
participant U as User client
participant A as Session API
participant D as Core domain
participant DB as PostgreSQL
participant H as Host agent
U->>A: POST /session
A->>D: Validate box, game, library, and linked account
D->>DB: Insert requested session
DB-->>D: Enforce one active session per box
H->>A: GET /machine/jobs
A->>D: List requested sessions scoped to host
D->>DB: Join session through box.machineId
H->>A: POST /session/:id/state (starting)
A->>D: Compare-and-set state for host
D->>DB: Atomic scoped transition
H->>A: POST /session/:id/ticket
A->>D: Publish ticket while addressable
U->>A: GET /session/:id
A-->>U: Current state and ticket
H->>A: POST /session/:id/state (ended/failed)
D->>DB: Set terminal state and clear ticket
```
<sub>Reviews (4): Last reviewed commit: ["fix(api): say what the library
check
act..."](
|
||
|
|
7bdca1240f |
fix(api): say what the library check actually proves
A library entry records the person, not the account the games were synced from, and `POST /library/sync` is not even told which account a list came from. So the ownership check added for session requests asks "has somebody this person linked got this game?" and not "does the account about to play own it?" — for the one Steam account most people have those are the same sentence, and for two they are not. Confirmed rather than reasoned about: a person with two Steam links, a game synced at person level, and a request naming the second account is accepted today. The check stays, because it still turns a box that boots, tries to launch and fails minutes later into an immediate refusal, and it never refuses on account grounds that the data cannot support. What changes is the comment, which claimed the stronger property, and a test that pins the gap so it is found deliberately rather than by surprise. Closing it properly means recording the linked account on a library entry: a column, a sync contract that says which account a list belongs to, a uniqueness rule per account rather than per person, and a backfill with no correct answer for rows already written. That is a decision about what a library is, and inferring it here would be the kind of modelling taken by accident that this branch refuses elsewhere. |
||
|
|
51dabddbd8 |
fix(api): a run drives the box under it, and needs a game and a claim
Four things the session endpoints did not do, or did wrongly. The box had three states and nothing wrote them. A box read `created` while a run on it was `live`, so every screen showing a person what their hardware is doing was reading a column no code had ever moved. A run reaching `live` now makes its box `running`, and a terminal run stops it: `ended` cleanly, `failed` not, carrying the reason the agent gave. Not every run state maps — a box has no `starting` on purpose, because that transition is synchronous from the agent's side and a state nobody sets is a state that lies. Both writes are one transaction, since "this run is live" and "the box under it is running" are one fact in two tables, and a box stuck `running` with nothing on it has nothing to correct it. `POST /session` accepted any game in the catalog. A run launches as a Steam account that has to own the game, so one outside the caller's library is a box that starts, tries to launch and fails minutes later with nothing to point at; it is now refused up front. Told apart from a game that does not exist rather than hidden, because the catalog is public and "you do not own this" is a sentence a person can act on. The library is a synced copy, so this refuses a game bought since the last sync — that is a staleness bug in the sync, not a reason to start runs that cannot work. Publishing a ticket only refused terminal runs, so a host could publish an address for a run it had never claimed. A ticket is the address of something being brought up, so only `starting` and `live` accept one, and the state is in the write rather than only in the check above it. The two refusals stay separate answers because they are different mistakes: one agent skipped a step, the other has nothing left to reach. The migration that adds the one-active-run index stopped older duplicate runs without clearing the ticket they had published, which is the invariant that same migration exists to establish. It clears it now, verified against a box carrying two unstopped runs. Nine tests, each checked against the unfixed code first. |
||
|
|
0d8630379b |
fix(api): a box gets one run, and a stopped run keeps no address
Two invariants the session endpoint stated but did not hold. A box runs one thing at a time. `POST /session` read `activeForBox` and refused when something was already running, but the read and the insert are two statements with nothing between them: two requests that both saw "nothing is running" each got a row, and the job poll then handed the host the same box to start twice. Demonstrated at 2 rows and 2 jobs from one box. That is the failure the state claim exists to prevent, one step earlier, and it takes the same answer — a partial unique index on the predicate the read asks about, so the database refuses the second insert. `Session.request` turns that refusal into the same 409 in the same words, so a caller cannot tell which of the two caught it. The migration resolves any existing duplicates before creating the index, keeping each box's newest unstopped run because that is the one a person is waiting on, and stopping the rest rather than deleting them. Separately, a run that reached `ended` or `failed` kept the last ticket it published. Publishing a new one is already refused, so the stale address was both the only ticket a client could read for a dead run and the one nothing was allowed to replace — and a client that polls would dial it. Terminal transitions now clear it, in `setState` as well as in the compare-and-set, so the invariant does not depend on which writer stopped the run. Seven tests, each checked against the unfixed code first. The published descriptions for the ticket field and the read endpoint now say that a stopped run has no address. |
||
|
|
e70f05e245 |
fix(nescope): make HDR reachable — start XWayland, advertise the opaque FourCCs (#314)
Three related fixes. Together they take HDR from unreachable to working
end to end on the XWayland path.
## XWayland was never started
Three lines had been commented out since the initial import: the call
that spawns XWayland, the guard that waits for it, and the `DISPLAY`
handed to the child. Every game therefore launched as a native Wayland
client. Nothing reported it -- the compositor still logged the X display
it was telling clients to point at, which is why it read as working.
That is also why HDR never fired. The colour space is signalled over a
protocol whose Vulkan layer lives in the game process and finds the
compositor through the X11 root window, so the one path able to carry it
was the one path no game was on. `ENABLE_GAMESCOPE_WSI` and `DXVK_HDR`
were already being set, which switched that layer on and then handed it
a
display it could not use.
Restoring the guard also fixes the ordering it was written for: the
launch
now happens after XWayland reports ready rather than ~40ms before it.
## Mesa was dropping every format we advertised alpha-only
Mesa tracks two flags per VkFormat -- one contributed by a format alpha
FourCC, one by its opaque FourCC -- and skips any format carrying only
one:
```c
if (!(disp_fmt->flags & WSI_WL_FMT_ALPHA) ||
!(disp_fmt->flags & WSI_WL_FMT_OPAQUE))
continue;
```
We advertised `ARGB8888` and `XRGB8888`, so `B8G8R8A8` survived and made
the list look like it was working. Everything else was alpha-only and
was
dropped in silence -- `ABGR8888` had been advertised all along while
`R8G8B8A8` never once appeared on a surface. Adding the opaque spellings
takes the surface from 6 formats to 21 and restores the three that carry
HDR.
The comment above that list claimed it was for XWayland DRI3 and that a
game swapchain format was independent of it. It was the opposite: the
list decides what a game can select, and deleting an entry removes that
format from every client.
## Verified against swapchains, not format lists
A client asking for `A2B10G10R10` + `HDR10_ST2084` now gets a swapchain
and the compositor is told colorspace `1000104008`; one asking for
`R16G16B16A16_SFLOAT` + scRGB linear gets `1000104002`. Previously both
were refused at creation -- the WSI layer re-checks the requested format
against the driver own surface list, so the colour space and the pixel
format arrive from two different places and only one was being supplied.
`apps/nescope/scripts/verify-hdr-formats.sh` asks what a client is
offered
from inside a child process, keeping the XCB and Wayland surfaces apart
since a game presents through the XCB one. The default mode guards both
halves of what the compositor controls; `--expect-layer` states the full
target and passes once a WSI layer is present. No new dependencies
(`vulkaninfo` + `python3`).
## Still open
HDR is XWayland-only, documented as a FIXME in `hdr.rs`. A WSI layer
binds
the swapchain factory on its own Wayland connection while a native
client
surface lives on the client one, and object IDs do not cross
connections.
The FIXME records the fix both reference implementations point at, and
the
trap to avoid when we take it: gating format injection on "the
compositor
supports HDR" rather than on being able to signal the surface hands a
client PQ pixels that arrive tagged as SDR, with nothing reporting an
error.
nescope ships no WSI layer of its own; the above was verified with the
stock gamescope one, which drives our protocol unmodified.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR makes HDR-capable native Wayland presentation reachable, adds
the alpha/opaque DMA-BUF FourCC pairs Mesa requires, makes XWayland an
explicit compatibility mode, and adds an HDR surface-format diagnostic.
- Starts XWayland only with `--xwayland`, waits for readiness before
launching the child, and stops cleanly if startup fails or times out.
- Routes Proton through Wayland unconditionally while retaining
`DXVK_HDR` as an HDR-specific setting.
- Advertises paired alpha and opaque FourCC variants with portable
modifiers.
- Separates XCB and Wayland probe results, selects one hardware adapter,
and distinguishes probe failures from format regressions.
- Documents the limitations of the legacy gamescope WSI path and the
external-layer dependency.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge; no outstanding correctness, security, or
repository-rule issue remains.
All previous findings are resolved in the current code, including the
XWayland failure lifecycle, removal of unsupported vendor modifiers,
corrected HDR documentation, reliable diagnostic failure handling,
per-GPU format selection, and unconditional Proton Wayland routing. The
changes since the previous review preserve diagnostic output handling
without introducing a new failure.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nescope/src/main.rs | Adds opt-in XWayland lifecycle handling,
readiness timeout, conditional DISPLAY propagation, and unconditional
Proton Wayland routing; the previous launch-environment finding is
fixed. |
| apps/nescope/src/state.rs | Stops the event loop on reported XWayland
startup failure and advertises portable paired alpha/opaque DMA-BUF
formats without vendor-specific modifiers. |
| apps/nescope/src/hdr.rs | Documents the working native Wayland HDR
path and accurately distinguishes it from the external, deliberately
disabled gamescope WSI route. |
| apps/nescope/scripts/verify-hdr-formats.sh | Adds a diagnostic that
keeps GPU and surface paths separate and now preserves the intended exit
behavior when filtered Vulkan diagnostics contain no matching lines. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Launch[nescope child launch] --> Mode{--xwayland?}
Mode -->|No| Wayland[Native Wayland surface]
Mode -->|Yes| Wait[Start and await XWayland]
Wait -->|Ready| XCB[XCB / XWayland surface]
Wait -->|Error or 10s timeout| Stop[Log failure and stop]
Wayland --> Formats[Paired alpha and opaque FourCCs]
Formats --> HDR[HDR10 and scRGB formats available]
XCB --> SDR[X11 compatibility path without native HDR]
Proton[Proton child] -->|PROTON_ENABLE_WAYLAND=1| Wayland
```
<sub>Reviews (9): Last reviewed commit: ["nescope/scripts: guard the
diagnostic
pi..."](
|
||
|
|
200bc9c75f |
fix(nescapture): stop defaulting unreadable formats to BGRA (#315)
Stacked on #313, which this depends on.
Found while checking whether the 10-bit HDR path actually works now that
a
client can obtain an HDR swapchain (see #314). **It does** — verified
end to
end rather than from the format list: a client requesting `A2B10G10R10`
+
`HDR10_ST2084` produces
```
pix_fmt=yuv420p10le color_range=pc
color_space=bt2020nc color_transfer=smpte2084 color_primaries=bt2020
```
which is a correctly tagged HDR10 stream, and the first pixel-level HDR
check
here with 10-bit rather than 8-bit input. Three ways it could have gone
wrong
instead.
## Unrecognised formats defaulted to BGRA
`vk_format_to_input_format` returned `BGRA` for anything it did not
know, which
reads a packed 10-bit or FP16 buffer as eight-bit channels. It now
returns
`None`, and the encode loop drops those frames with one log line per
format.
A stalled stream is a complaint. A stream at full frame rate carrying
nonsense
is not, and that is the failure this area keeps producing.
## Bit depth and input format had drifted apart
They were two separate matches on the same `VkFormat`. `A2R10G10B10`
counted as
ten-bit in one and had no entry in the other, so it fell back to
eight-bit
BGRA — the encoder configured for ten bits while the converter read
eight.
Depth now derives from the input format, so that disagreement is
unrepresentable. `A2R10G10B10` stays unmapped deliberately: a WSI layer
offers
it as one of its HDR pairs and the compositor dmabuf list advertises it,
but
the converter has no red-first 10-bit input, so there is nothing correct
to map
it to.
## The CPU fallback could not read either HDR format
It read four bytes per pixel for every format and encoded eight-bit
regardless, so a packed 10-bit buffer became garbage and an FP16 one was
half
an image of misread floats. It now refuses what it cannot read.
## Recorded, not fixed: the colour space we see is not always the one
requested
A FIXME at the point the value is read. A WSI layer rewrites
`imageColorSpace`
to `SRGB_NONLINEAR` before calling down — deliberately, since it carries
the
real colour space to the compositor out of band. We sit below it, so we
read
the rewrite. Measured, all three lines from one run:
```
[Gamescope WSI] ... colorspace: VK_COLOR_SPACE_HDR10_ST2084_EXT
swapchain created — format=A2B10G10R10 colorspace=SRGB_NONLINEAR
(re)init encoder: H265 Yuv420 Ten Bt709 → P010
```
Ten-bit right, BT.709 wrong: PQ samples encoded and tagged as SDR. The
same
client *without* the layer gives `Ten Bt2020` and an smpte2084 stream,
so this
is specific to the layer path — which is the path Proton titles take.
The fix cannot be local; the true colour space only exists in the
compositor,
which does receive it, so it needs a channel from there. Layer ordering
is not
a fix — we do not control it, and the non-layer path still needs the
Vulkan
value. Left out of this PR as a design change rather than a bug fix.
## Verification
- 4 new tests, 12 total, all passing.
- No new clippy warnings (diffed against the base branch).
- 10-bit HDR path: unchanged, still `Ten Bt2020` / smpte2084.
- SDR path: `verify-chain.sh` passes, 835 frames, 8-bit BGRA, brightness
agreement 0.57.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR makes Vulkan format handling fail safely instead of interpreting
unsupported swapchain buffers as BGRA.
- Maps supported Vulkan formats to explicit converter inputs and derives
bit depth from that mapping.
- Drops unsupported GPU frames with rate-limited logging.
- Rejects unsupported HDR formats in the eight-bit CPU fallback.
- Documents the color-space limitation caused by rewritten WSI metadata.
- Adds tests covering unsupported, eight-bit, 10-bit, and FP16 formats.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge, with no new actionable issues introduced
since the previous review.
The changes since the previous review are empty, the sole previous
finding was manually resolved after Greptile conceded it based on the
stacked PR dependency, and the full PR introduces no confirmed rule
violations or remaining correctness failures.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nescapture/src/encode.rs | Replaces unsafe BGRA fallback behavior
with explicit format validation, consistent bit-depth derivation,
guarded CPU fallback, and focused tests. |
| apps/nescapture/src/swapchain.rs | Documents the known WSI color-space
rewrite limitation at the point where swapchain metadata is recorded. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Captured Vulkan frame] --> B{Known converter input?}
B -->|No| C[Log format change and drop frame]
B -->|Yes| D[Derive input format and bit depth]
D --> E{DMA-BUF path available?}
E -->|Yes| F[GPU color conversion and encoding]
E -->|No| G{Eight-bit RGBA or BGRA?}
G -->|Yes| H[CPU conversion and encoding]
G -->|No| I[Return recoverable error]
```
<sub>Reviews (3): Last reviewed commit: ["docs(nescapture): the
colour-space note
..."](
|
||
|
|
3389e6065f |
fix(nescapture): tag encoded streams full-range to match the samples written (#313)
## The bug
`nescapture` sets the colour converter full-range unconditionally, but
the video usability information carried pixelforge's **default
limited-range flag**. A compliant decoder then expanded 16–235 out of
samples that already covered 0–255 — darkening midtones and clipping
both ends.
pixelforge keeps two separate flags for this, one on the converter and
one on the colour description, and its own documentation says they must
agree. Only the first was being set.
The two lines are about forty apart, each is correct on its own, and the
comment above the second states the right intent while the call below it
does the opposite:
```rust
// GPU framebuffer captures are always full-range — use BT.709 full-range
// so the decoder doesn't apply limited-range expansion.
enc_cfg = enc_cfg.with_color_description(ColorDescription::bt709());
// ^ this constructor is limited-range
```
## Evidence
Measured on a Radeon RX 9060 XT, comparing the encoded result against
the compositor's own readback of the same frames:
| ground truth = `51` | before | after |
|---|---|---|
| flat background, decoded | **`38`** | `49–51` |
| mean luma, capture path vs readback | **10.41 apart** | **0.55 apart**
|
| luma histogram intersection | **0.090** | **0.913** |
| declared `color_range` | `tv` | `pc` |
**The encoded luma is byte-identical before and after** — `Y = 51.00`,
standard deviation `0.00` on both runs. Only the tag changed, which is
what identifies this as a signalling bug rather than a conversion one,
and why nothing short of a comparison against ground truth could see it:
the stream was valid, the frame rate was right, the picture was
recognisable, and every liveness check passed.
The HDR arm (`bt2020_pq`) carried the same defect and is fixed the same
way, but **has not been run** — no 10-bit verification here.
## `scripts/verify-chain.sh`
Runs a Vulkan workload under `nescope` with the layer active and
compares the encoded output against `nescope-shot`'s readback of the
same frames. Two paths that share almost no code see the same content,
so disagreement localises the fault; a single path cannot tell a correct
frame from a plausible-looking wrong one.
**Confirmed it fails when this change is reverted** — both the tag check
and the brightness-agreement check fire.
One note on its thresholds, since it is easy to get backwards: the
not-blank check is a low absolute floor plus a comparison against the
readback's own structure, rather than a fixed number. A fixed number was
tried first and was wrong in the worst way — the **broken** build scored
20.49 on it and the **fixed** build 17.74, because the range defect
stretched contrast and that reads as more detail. How much structure a
correct frame carries depends on what the workload drew, so the only
stable reference is ground truth measured in the same run.
## Not covered
`vkcube` rather than a real workload; 720p, H.264, 8-bit; one card, one
driver. XWayland, HUD detection and real swapchain formats are
untouched.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
The PR aligns encoded-stream color metadata with the full-range samples
produced by nescapture and updates the CPU fallback to BT.709 full-range
conversion.
- Updates pixelforge and configures matching converter color space,
range, and SDR reference white.
- Corrects Vulkan color-space mapping and adds regression tests for SDR,
HDR, and CPU fallback behavior.
- Adds SDR capture-chain and HDR comparison verification scripts.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge.
No blocking failure remains.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nescapture/src/encode.rs | Aligns GPU and CPU conversion output
with encoded color metadata and adds focused regression coverage. |
| apps/nescapture/scripts/verify-chain.sh | Adds an end-to-end SDR
verifier using a static corner patch to avoid the previously reported
temporal mismatch. |
| apps/nescapture/scripts/verify-hdr.sh | Adds an HDR comparison harness
for inspecting conversion behavior across builds. |
| apps/nescapture/Cargo.toml | Advances pixelforge to the revision
providing the required color-conversion configuration. |
| Cargo.lock | Records the pixelforge update and resulting transitive
dependency refresh. |
<sub>Reviews (5): Last reviewed commit: ["test(nescapture): check the
HDR
conversi..."](
|
||
|
|
bbe729e5c7 |
feat(api): the session endpoint, and a claim that only one caller can win
A run of a box had core support and no HTTP surface. This adds both halves of it: a person asks for a run and reads it back, and the host agent the box is placed on is handed the work and reports what happened. The access rule is the point. An agent may only see or touch a run whose box is placed on its own hardware, and that is a `where` clause on every one of the three agent endpoints rather than a check next to them — host credentials are long-lived secrets sitting on hardware in somebody's home, so what one leaking can reach has to be decided by the query. "No such run" and "not your run" are the same refusal, so ids cannot be discovered by reporting states at them. `Session.setState` updated on the id alone, which means two agents polling the same work both succeed and both start the same box. There is one host today, which is exactly why that would have been built wrong and stayed wrong. The state a run is moving out of is now part of the `where` clause, so the database picks the winner; the loser gets a conflict rather than a silent no-op. Three cases that look alike are kept apart: re-reporting a state you already reported changes nothing and is not an error, a transition that does not exist is refused with the run left where it was, and another host reporting anything is forbidden. Asking for a run makes no decision about where it happens — a box already names its hardware, so the run inherits it by join. Placement therefore gets an interface at box creation, where the decision actually is, with the single-host case as its implementation and a deliberate refusal when there is more than one candidate and no policy to choose with. Tests cover the wire shape from both sides, the query scoping, the claim, and the timestamp idempotence a run's billing rests on. |
||
|
|
aaa1bbd0f4 |
fix(nesdoctor): the closing prose still said "launch times"
Missed when the histogram was corrected: the tool spent a paragraph asking people to send the JSON and described it as holding "installed titles with sizes and launch times", which is the same claim the histogram itself no longer makes. Steam stores when a title was last played, not each time it was launched. Found by running the installer end to end after the release — the shipped 0.3.0 binary prints it. Fixed here for the next one; it is prose in the closing paragraph, not a number anybody acted on. Same wording in the --json flag's doc comment, in the submit URL's comment, and in the consent prompt that asks to read the library at all. That last one matters most of the four: it is what somebody reads before saying yes. |
||
|
|
d8e5c19181 |
release(nesdoctor): point the installers at v0.3.0
Flipped after publishing, so there was never a window where the live installer named a tag that did not exist. Verified before committing: all five assets under the new tag return 200, and the installer was run end to end — it fetched the published binary, checked it against SHA256SUMS, and the thing it ran reported 0.3.0. |
||
|
|
29a826d9f9 |
release(nesdoctor): 0.3.0
Minor rather than patch, because the output changed meaning and not just its numbers. `up=` was overstated by about a fifth and is now measured over the window the bytes were actually counted in, so a figure from 0.2.x and one from this release are not comparable. And the hour-of-day histogram was labelled launches when Steam only stores one timestamp per title, so the summary line now carries which sample a peak came from — `n=28/all` where it used to say `n=28`, which is a shape people paste. New wire keys hours30, n30, nspan, playh and peaksrc. hours, n and peak keep their names and meaning so the corpus stays continuous; a submission without peaksrc is whole-library by construction.nesdoctor-v0.3.0 |
||
|
|
cf56aaf04c |
docs: this repo is public, so say what things are, not who decided them
Comments and served API descriptions here had grown references that only make sense to someone with our internal notes: relative paths that escape this tree, filenames and titles of documents nobody outside can open, quoted prose from them, and the name of a component that has no public surface — once in an OpenAPI description, which is published output rather than source. None of it was load-bearing. Every case restates as what the code actually requires, and every rewrite came out shorter: "in the words the host agent reports" for a component name, "republished as addresses are discovered" for a quoted phrase, "a size tier sets vCPU, RAM and the output geometry" for a sentence that had been carrying a path. Internal reasoning is now cited exactly one way, ref(d-NNNN) in a source comment, with the rule that the sentence must still stand if the marker is deleted. CLAUDE.md leads with it, because the previous version of this mistake was made by people who knew the repo was public and it still took ten occurrences to notice, so "be careful" is not a mechanism. Commit messages get the stricter rule and carry no references at all: a comment can be fixed by the next commit and a published message cannot be fixed at all. Git hooks now enforce both halves. The check caught a real one while being written: the CLAUDE.md table spelled out the paths it was prohibiting, which discloses them to exactly the reader it protects against. 138 tests, 0 fail. |
||
|
|
c3682136f1 |
test(api): hold the heartbeat wire contract from the host's side
`lastSeen` and `intervalSeconds` are the two field names neslet's plane.rs reads out of the reply, and a rename on either side yields a host that beats, parses nothing and reports success. These tests are what make that a contract rather than a coincidence. Also asserts the property the middleware comment claims and nothing checked: wrong machine credentials and no credentials produce *identical* responses, because bad credentials fall through to `public` rather than erroring so that probing cannot reveal which machine ids exist. Comparing the two bodies is the only way that stays true. Two of these tests started out asserting 401 and were wrong, not the code — `machineOnly` sees a public actor either way and forbids. 138 tests, 0 fail. |
||
|
|
4315510de8 |
feat(api): a host can say it is alive, and is told how often to
Second half of G1's "neslet registers against api.nestri.io and heartbeats".
Registration already worked; there was no heartbeat endpoint at all — grep for
it across apps/api and packages/core returned nothing, and neslet's own
main.rs says the same from its side.
POST /machine/heartbeat, machine credentials only. Two decisions worth stating
because neither is obvious from the diff:
**It returns the interval.** The auth middleware already touches lastSeen on
every authenticated machine request, so an endpoint that only did that would
add an endpoint and no capability. What a host cannot know on its own is how
often the control plane wants to hear from it, so the response carries the
cadence. A fleet whose interval can only change by shipping a new agent is a
fleet whose interval never changes.
**It takes no body.** neslet has a HostSummary ready to send, and week 2 owns
box state reporting. Accepting fields nothing acts on yet would mean a wire
shape we would have to keep, chosen before the thing that consumes it exists.
Online-ness is derived from lastSeen rather than stored: a host that stops
beating goes offline through the passage of time, which is the one mechanism
that cannot itself fail. Three missed beats, not one — a single missed beat is
a lost packet, and treating that as offline would make placement flap.
Also: the machine actor's teamID stops being optional. It was `...(teamId ? {}
: {})` in the middleware, a branch for a state that cannot exist now that
machine.team_id is notNull.
134 tests, 0 fail.
|
||
|
|
6c1d407985 |
feat(core): a box is a row, a session is the billing unit
Migration 1 of 0048, and the first of the seven weeks — nothing about a live
feed works without these two tables, so it is not a cleanup during them.
box a VM someone owns: an id that is also its DNS label, an editable
label, an owning user, the machine it sits on, a tier and a state.
Owned by a person and placed on a team's hardware, which are two
different relationships, hence both userId and machineId.
session one run of one box by one linked Steam account, and what costs
money. Separate from box because the ticket changes after bind as
addresses are discovered — the vsock contract calls it "a stream,
not one value" — so it is a column a client polls, not a value it
is handed once.
Box states are neslet's own three and no more. `starting` and `stopping` are
the obvious additions and both are omitted because nothing would ever write
them; a failed box is `stopped` with stopClean false, which is how neslet
models it too.
The generated migration would have failed on live rows in three ways, so it
is hand-written and tested against a database seeded at the old schema:
- machine.team_id becomes notNull, and *every existing row is null* because
the old registration path passed null. Personal teams are backfilled for
machine owners first, reusing a team they already own rather than minting
a second, with the owner membership row repaired where missing.
- game_download.host_id becomes a foreign key. It held free-form strings,
so unattributable rows are deleted before the cast — the only destructive
statement here, and a considered loss: it is a progress report neslet
re-derives from disk.
- Team.createPersonal was written and documented in packages/core/CLAUDE.md
as part of the login flow and never actually called, so no user has a
team. ensurePersonal is idempotent and now runs on every login, which is
what backfills accounts the migration does not reach.
Verified on a seeded legacy database: three null-team machines backfilled, an
existing team reused rather than duplicated, a blank display name handled, and
both unattributable download rows dropped while the attributable one survived.
Also fixes two things this work ran into rather than caused:
- Database.client() built a new postgres pool on every call, and use()
called it twice per invocation — pools of ten connections held for a 30s
idle timeout. Invisible in a Worker where requests are short; the suite
crossed 100 connections and Postgres said "sorry, too many clients
already" in whichever file ran last, which reads as a flaky test rather
than a leak. Now one pool per connection string.
- download.test.ts asserted against `hst_…` host ids, which is exactly the
unattributable row the new foreign key exists to refuse.
There is no "no team" any more: PATCH /machine/:id took teamId null to mean
"mine alone" and now requires a team, because the personal team is the one to
name. Its test is updated to the new contract rather than deleted.
113 → 128 tests, 0 fail.
|
||
|
|
1bfdfcf3cf |
fix(nesdoctor): "launch records" were never launches
Steam keeps one LastPlayed per title, so the hour-of-day histogram holds one sample per *title* — at the hour it was last closed, over the whole life of the library. It was labelled and reported as a launch histogram, and the module header claimed a library of eighty games is "eighty samples of what hour this person launches a game at — a real distribution". It is not. The bias has a direction, and everything pushes the same way: a title played once years ago weighs exactly as much as a daily driver, a daily driver contributes one sample ever, and an afternoon spent installing and trying a dozen games stamps a dozen titles with that afternoon's hour. So the metric over-weights trying and under-weights playing. On the production host it reads n=331 with 329 titles no longer installed; on this dev machine, 28 titles spanning 570 days. Corrected rather than disclaimed, because the direction is knowable: - Fields say what they hold — last_played_hours, titles_sampled, and no "launch" anywhere. The display says "when you last played each game — 28 titles, local time, reaching back 19 months". - A second histogram over titles played in the last 30 days, which is one sample per title still in use, and the peak window prefers it when it has the samples to claim a shape. - Which histogram the peak came from is stated in the output and on the wire (peaksrc=30d|all), and the summary line carries the sample count the window was actually computed from, so a narrow peak drawn from nine titles cannot borrow the authority of three hundred. - New keys hours30, n30, nspan, playh. hours/n/peak keep their names and meaning so the corpus stays continuous; submissions without peaksrc are whole-library by construction. Playtime is read and reported but deliberately not used as a weight: it is a lifetime total against a single timestamp, so weighting by it would multiply one arbitrary hour by five hundred. Five tests, including the wrapping midnight window, which is the case a non-wrapping scan gets wrong and exactly the evening peak 0017 is about. |
||
|
|
3dba825f17 |
fix(nesdoctor): up= was overstated by about a fifth
The throughput window and the byte count disagreed. Bytes were counted from the moment the upload threads started, the 1.5 s queue-fill ramp included; the divisor was that same span with 1.5 s subtracted from it. So a numerator covering ~8.3 s was divided by ~6.8 s, and every up= figure nesdoctor has ever published is high by ~22%. Snapshot the counter and the clock together after the ramp, and measure both from there. Excluding the ramp is also the better measurement: TCP slow-start lives in it, so it is not the steady state a session gets. Found by running speedtest on the same line in the same afternoon — 284 Mbps against our 502 — which is the only way it could have been found. The code was self-consistent and the number it printed was plausible, so no amount of re-reading would have shown it. A boundary effect remains and is documented in the code rather than papered over: bytes arrive one completed 8 MiB POST at a time, so up= keeps a few per cent of upward slack. Submissions collected to date stay useful as a floor and as a bufferbloat corpus. They are not usable as throughput. |
||
|
|
90bd93f47f |
docs(nesdoctor): Windows will block it, and here is why and what to press
Reported from a real machine on release day. The README asks strangers to run a binary, so it should say what actually happens when they try. The why matters more than the workaround: SmartScreen objects to the file being unsigned and having no download history, not to anything the program does. And history attaches to the file hash, so a project releasing four times in an afternoon never accumulates any -- waiting is not a strategy. Offers the source build as the version that requires no trust, and says that stopping is a reasonable choice. Reproducible CI builds and published checksums prove provenance without moving SmartScreen an inch, and pretending otherwise would be the kind of overclaim this tool cannot afford. |
||
|
|
f0227201db |
release(nesdoctor): point the installers at v0.2.2
Flipped after publishing, so there was never a window where the live installer named a tag that did not exist. |
||
|
|
dc99bd2743 |
fix(nesdoctor): the Apple Silicon GPU name had doubled parentheses
The fallback worked -- the macOS runner now reports a GPU instead of `unknown`, and the raw probe dump settled which of the two candidate causes it was: a headless virtual Mac with no display adapter to enumerate, so `system_profiler` had nothing and the parser was never at fault. It read `Apple M1 (Virtual) (integrated)`, because the SoC name already carries a parenthetical on a VM. Em-dash instead. The suffix stays: it records that the name came from the chip rather than from a display adapter, which is the difference between a machine with no GPU and a machine with no display.nesdoctor-v0.2.2 |
||
|
|
730739a5c0 |
fix(nesdoctor): fall back to the SoC name on Apple Silicon, and print raw probes
The macOS arm added in the previous commit did not change anything -- the runner still reported `gpu=unknown`. Checked rather than assumed, which is the only reason it is known. Two possible causes and no way to choose between them from here: either the `system_profiler SPDisplaysDataType` parsing is wrong, or that machine is a headless virtual Mac with no display adapter to enumerate at all, in which case `unknown` was the correct answer and there is nothing to fix. The second is likely and the first is not ruled out. So, rather than guessing again: on an arm64 Mac the GPU *is* the SoC, so the chip name is a true and useful answer even with no display attached. `sysctl -n machdep.cpu.brand_string` works headless and yields "Apple M1 (integrated)". Intel Macs get no fallback, because there the GPU may be integrated or discrete and a guess would be wrong rather than coarse. And the CI step now dumps the **raw** output of each platform's probes -- `system_profiler`, `Get-CimInstance Win32_VideoController`, `Get-PSDrive`, `df -Pk`, `/sys/class/drm` -- into its own log group. A field that comes back empty can then be told apart from a parser that is wrong, which is exactly the distinction that cost this round trip. All of it is `|| true`: the step exists for looking, and a probe that misbehaves on a runner must never fail a release. |
||
|
|
0f26df5c02 |
fix(nesdoctor): every Mac reported gpu=unknown, because the probe had no macOS arm
Seen in the macOS CI log: nesdoctor 0.2.2 | macos/aarch64 | gpu=unknown | ... `gpus()` had a Linux arm, a Windows arm, and `Vec::new()` for everything else. Macs are clients rather than hosts, so it went unnoticed -- but 0041 wants a client vendor matrix and an unlabelled row is no use in one. An M-series integrated GPU and a discrete Radeon in an Intel Mac decode very differently, and "unknown" cannot tell them apart. `system_profiler SPDisplaysDataType` is the only place the chipset name lives. Parsed loosely: the format has changed between macOS releases, so a name we cannot find costs a field rather than the run. Vendor is matched over Apple, AMD, Radeon, NVIDIA and Intel; `render_node` stays `None` because macOS has none and a Mac cannot host regardless. Still missing on macOS and stated rather than papered over: filesystem types and the display probe. The EDID path is sysfs on Linux and WMI on Windows, and macOS exposes neither -- so Mac respondents report no colour depth or HDR capability. That is a real gap for the video work, since Mac panels are exactly the P3 and high-refresh cases worth knowing about, and it needs `CoreDisplay`/`system_profiler` parsing rather than a one-line fix. |
||
|
|
53d69ca289 |
fix(nesdoctor): parse df from the right; a device name can contain a space
Three more things the macOS CI log showed, none of which had ever been visible
from this laptop.
`df -P` fixes the column order but not that the filesystem name is one word.
macOS emits
map auto_home 0 0 0 100% /System/Volumes/Data/home
which shifts every field by one, so indexing from the left read the capacity
percentage as part of the mount point and a device name as the size. The row
appeared in the log as `100% /System/Volumes/Data/home`, which is what gave it
away. Columns are now counted from the right, where `df` actually guarantees
them: size, used, avail, capacity, mount. A test covers the plain row, the
two-word `map auto_home` row, and an SMB share whose device name contains a
space -- the case that makes left-indexing wrong in principle rather than just
on Macs.
The `/System` filter I claimed to have added in the previous commit was not in
the file. The assertion that was supposed to catch that passed against the
wrong block, so it went in silently and `/System/Volumes/xarts` kept appearing
in the very output I had just quoted as fixed. It is there now, along with
`/private/var/vm` and `/Volumes/Recovery`, and verified by grep rather than by
belief.
And a filesystem reporting no capacity is not storage: `map auto_home`, devfs
and macOS signed asset bundles all report zero and were padding the filesystem
count in the summary line.
Net effect on the runner, across this commit and the last: 11 filesystems and
"483 GiB free of 1600 GiB" on a 320 GiB machine, down to the one real volume.
|
||
|
|
f32db5393b |
fix(nesdoctor): APFS volumes share one pool, and were counted eleven times
Found by reading what the macOS CI runner prints, which is the whole reason that step was added an hour ago. First time anyone had looked at what these probes return on a platform that is not this laptop: / 96 GiB free of 320 GiB /System/Volumes/VM 96 GiB free of 320 GiB /System/Volumes/Preboot 96 GiB free of 320 GiB /System/Volumes/Update 96 GiB free of 320 GiB /System/Volumes/Data 96 GiB free of 320 GiB 11 filesystems · 483 GiB free of 1600 GiB total On a machine with 320 GiB. An APFS container presents each volume as its own filesystem with its own `/dev/diskNsM`, so the device-name dedupe -- which correctly collapses btrfs subvolumes -- cannot see that the space is shared. Two changes. `/System/Volumes` and `/private/var/vm` are skipped: they are not user storage, and on a Mac they are most of the rows. And filesystems are deduped by *pool* as well as by device. Two filesystems reporting byte-identical capacity and byte-identical free space are one store, whatever their device names say -- which also covers bind mounts and thin-provisioned LVM, neither of which the device check catches either. Two genuinely separate disks agreeing to the byte on both figures would cost one row; a storage total inflated fivefold is a number a capacity plan gets built on. Simulated against the exact runner output: eight rows and 2560 GiB become one row and 320 GiB. The Windows runner, by contrast, was correct first time -- `C:` and `D:` are genuinely separate and totalled 179 GiB free of 299 GiB. Worth recording that the reason we know is that we looked, rather than that we reasoned about it. |
||
|
|
ac4bf666e6 |
docs(claude): nesdoctor is not guest-side, and cannot be tested from here
CLAUDE.md said "Rust components are guest-side: they run inside a virtual machine". That was true of every one of them until yesterday and is now false, which makes it worse than a gap -- it is instruction that would send someone looking for nesdoctor in the wrong half of the system. Records the two things about it that hold nowhere else in this tree. Its dependency list is part of its interface, because it is handed to strangers and asked to be trusted. And it cannot be verified on the development machine alone: both bugs it has shipped were Windows-only, found by users, in code paths Linux never executes -- so prefer a property test that runs everywhere over a platform check that runs nowhere, and read what the Windows and macOS runners print. |
||
|
|
786267f30a |
fix(nesdoctor): report storage properly, and stop being blind on Windows
A submission from a team machine with four drives and 22 TiB reported
`disk=8880`, and the field was not wrong so much as meaningless: it was the
free space on the single largest mount, with no capacity anywhere and no total.
A content store is sized against capacity.
Storage now reports four things, because they answer different questions and
one number could not:
diskfree total free across every real filesystem
disksize total capacity
diskmax the largest single filesystem, which is the real ceiling for any
one store -- a dataset cannot be spread across drives
disks how many there are
The ambiguous `disk` key is gone rather than silently redefined, so old rows
stay readable as what they were. `Get-PSDrive` reports Free *and* Used and we
were reading only Free, hence no capacity on Windows at all.
Pseudo-filesystems are now excluded by *type* rather than by mount path. Path
filtering missed `/tmp` on a tmpfs, whose free space is RAM -- so 7 GiB of
memory was being added to a storage total, which is exactly the sort of number
a capacity plan gets built on.
## The real finding, which was not about disks
"We are working blind on Windows" is correct, and both Windows bugs this tool
has had prove it: a virtual display adapter reported as the GPU, and a URL
truncated at its first `&`. Both were in code that only runs on Windows, both
were found by a person reading the results channel, and neither could have been
found here -- the development machine is Linux and `xdg-open` never sees a
shell.
Two things about that, and the first is the one that generalises.
`OPENERS` is now a const with a test asserting the property that actually
matters: **never hand a URL to anything that will re-parse it.** No `cmd`, no
`sh`, no `powershell`, no `start` builtin, and no argument that looks like it
wants the URL interpolated into it. Unlike the bug, that is checkable on every
platform in a millisecond. Verified by reintroducing `cmd /C start "" <url>`
and confirming the test fails with the right message, then reverting.
And CI already runs a real Windows machine and a real macOS one -- we simply
were not looking at them. Each smoke-tested target now prints its full report
and JSON into a collapsed log group. Deliberately not `set -e`: this step is
for looking, and a probe that misbehaves on a runner must not fail a release.
It turns "working blind" into "looking at it once per release", which would
have shown the Parsec adapter problem the first time a Windows binary was ever
built.
Version to 0.2.2.
|
||
|
|
a84886861c |
release(nesdoctor): point the installers at v0.2.1
v0.2.0 and earlier lose Windows submissions entirely, so nothing should be installing them. |
||
|
|
a7eeb14de5 |
fix(nesdoctor): cmd re-parsed the submit URL and destroyed every Windows result
Two submissions arrived carrying `v=0.2.0` and nothing else. Flagged from the
channel, not caught by us.
The Windows arm of `open_in_browser` was `cmd /C start "" <url>`. `cmd.exe`
re-parses its own command line and treats `&` as a command separator; Rust's
`Command` quotes arguments for the MSVC C runtime convention, which `cmd` does
not honour. So the URL was cut at its first `&` -- which in ours falls
immediately after `v=` -- and the browser opened
https://doctor.nestri.io/?v=0.2.0
carrying nothing whatsoever. Reproduced exactly with the same mechanism in a
POSIX shell: `sh -c 'echo <url>'` unquoted prints precisely that prefix.
Every Windows user who pressed Enter lost their entire report, and lost it
silently -- the page returned 200 and thanked them. Windows is most of this
audience, so most of the data we would ever have collected was going to
disappear this way.
Now `rundll32 url.dll,FileProtocolHandler`, which hands the URL to the shell's
protocol handler with no command interpreter anywhere in the path, so nothing
re-parses it. `explorer.exe` also opens URLs and was rejected: it returns a
non-zero exit status even on success, which would make the caller believe it
had failed and fall through.
The relay now also refuses to thank anyone for a version-only arrival, since an
older binary keeps producing them and a URL pasted into a shell unquoted does
the same thing.
Version to 0.2.1.
nesdoctor-v0.2.1
|
||
|
|
19e1bf4152 |
docs: put nesdoctor in the README, near the top
The README opens by saying the repository is mid-rewrite, nothing is stable, and the documentation is behind the code -- all true, and it leaves a visitor with nothing to do. There is now one thing here that is finished and runs on its own machine, so it goes immediately after that note rather than buried in a component table. The section leads with the two install commands and the number worth having: added latency under load, which decides whether a stream feels right and which almost nobody has ever seen for their own connection. Then the display probe, because "it tells you what your monitor can actually accept" is a better hook for this audience than anything else in the file. It also says plainly that this does not stream a game and that most machines come back CLIENT, "a real answer, not a failure". A README that oversells the one runnable thing would undo the reason it is worth running. Three smaller corrections that follow: `nesdoctor` gets its own heading rather than a row under the guest components. It is neither control plane nor guest -- it runs on the reader's own machine, which is the whole point of it. Getting started said the Rust components "are not much use on their own yet". That was true of every one of them yesterday and is now wrong; scoped to the guest half, with `cargo run --release -p nesdoctor` called out as the exception. And Contributing now asks for the thing we actually need. "Tell us where the documentation failed you" was the most useful contribution when nothing could be run; running nesdoctor and sending the result is worth more, because we have almost no idea what the machines on the other end look like. Checked: every relative link in the file resolves, and the scripts doctor.nestri.io serves are byte-identical to the files the README points at -- which is the claim the section makes about them. |
||
|
|
a244c9213c |
release(nesdoctor): point the installers at v0.2.0
Flipped after publishing, so there was never a window where the live installer named a tag that did not exist. |
||
|
|
3544f857ff |
feat(nesdoctor): read the display, and offer early access
Two things, both of which every response collected without them is a response we cannot go back for -- since a submission carries nothing that identifies anyone, there is no second chance to ask. ## The display and decode probe This is the readable half of the client capability probe our build order already specifies -- GPU, decoder, display -- and its stated purpose is attribution: told only that a stream "looks bad", the cheapest available explanation is that our reconstruction ratio was too aggressive, so without this we would lower the ratio and pay density for somebody else's window manager. presentation path x11 · bspwm eDP-1 1920x1200 @ 60 Hz, 8-bit Vulkan decode h264, h265 VA-API decode h264, h265, vp9 Session type, compositor, and whether we are under XWayland -- which is exactly the objection raised against our own A/B rounds, now recorded automatically rather than argued about. A bare window manager sets none of the XDG variables, so bspwm and thirteen others are matched from the process list; a report that cannot name bspwm cannot answer the challenge that named it. From EDID, parsed here rather than shelled out to: native mode, refresh, colour bit depth, which HDR transfer functions the panel accepts, BT.2020 colorimetry, and 4:2:0 chroma. The CTA-861 extension blocks are where all the colour capability lives -- base EDID says nothing about any of it. That decides real choices. Whether 10-bit is worth sending, whether BT.2020 is worth encoding, which codec to reach for. Every one of those has so far been decided against the one panel in this room -- which this now reports as 8-bit, meaning the 10-bit work cannot be validated on it at all. EDID is untrusted binary from a device node. Every read is bounds-checked and every field optional: monitors ship broken EDIDs and docks synthesise worse ones, so a bad panel costs one field rather than the run. Three tests, one of which truncates the block mid-extension and asserts that no colour capability is invented. The colorimetry byte offset was wrong the first time and the test caught it, which is the argument for the test. Present mode, tearing and fractional scaling need a real window and swapchain, so they are absent and said to be absent rather than guessed. ## Early access An optional email, asked last, after the verdict has printed -- so nobody types an address before seeing what this said about their machine. Blank skips it. The offer branches on the verdict, because telling someone with no KVM and a grade-F uplink that we liked what their machine can do is a lie, and this program's only real asset is that it does not flatter anyone. A host-capable machine gets the host offer; everyone else gets early access as a player, which is a true offer too. It is the one identifying thing collected here, so: it appears in the pre-submit disclosure with everything else, and the promise elsewhere had to be reworded -- "no username, no identifiers" stopped being true the moment this field existed, and leaving the old line standing would have been the dishonest option. Validation is deliberately loose; arguing with somebody about their own address over a regex loses the response outright. Version to 0.2.0.nesdoctor-v0.2.0 |
||
|
|
c2c3bb5ba0 |
feat(nesdoctor): ask about the other Linux box while we still can
Our first respondent answered `otherlinux=yes` -- they have a Linux machine -- and their Windows desktop came back CLIENT, which is a dead end. The Linux box is the result we have none of. There is no way to ask them. A submission carries no hostname, no address and no name, by design, so the moment the program exits whoever ran it is anonymous and unreachable. That is the correct trade and it is not being changed. What was wrong is that the program knew about the other machine *while they were still reading* and said nothing. So when someone answers that they have, or could set up, a Linux machine and this one cannot host, the run now ends by asking for it -- with the command, and with the reason stated plainly: nearly every result is a client, a host has to be Linux with KVM, and one run over there is worth more than a hundred of these. Including why we cannot follow up, since that is the honest argument for doing it now. The nudge is suppressed when the machine already qualifies as a host, because then it is noise. |
||
|
|
b055e40546 |
fix(nesdoctor): the first Windows submission recorded a virtual display adapter
Our first response, and the GPU field is wrong: gpu=Parsec%20Virtual%20Display%20Adapter&gpus=2 Parsec installs an indirect display driver, it enumerated first out of `Win32_VideoController`, and the primary was taken as the first entry -- so the real card on that machine is gone. `gpus=2` is the only reason we can tell anything was lost, and it cannot tell us what. This is not an edge case for this audience. Parsec, Sunshine, Moonlight, TeamViewer and Splashtop all install one, and a cloud-gaming community is precisely the population that has one already. A recorded gpu_model is a hard requirement for a host; a virtual display driver satisfies it in name only. Three changes. Adapters are now sorted so real hardware is first, by two keys: whether the name matches a known software adapter, then whether a vendor could be identified at all. Order is the only signal the rest of the program has for which GPU is "the" GPU. The vendor comes from `AdapterCompatibility` rather than from pattern-matching the marketing name. An "AMD Radeon" string is easy; an OEM-rebadged one is not. And every adapter name is now sent, not only the count. `gpus=2` told us something had been lost and not what, which is the kind of field that wastes a response we cannot ask again. The known-software-adapter list has unit tests, on all platforms -- it is a list of strings and it will need extending, so it should fail loudly rather than quietly stop matching. Version to 0.1.2. For the record, what that submission got right, because none of it needed asking: 50% of 165 Steam launch records fall in five hours of twenty-four (21:00-01:59) against five records across the whole of 07:00-13:59. That is 0017's evening peak, measured, from one person's own files. 140 of the 165 records are titles no longer installed -- restricting the histogram to installed titles, as review suggested, would have left 25 samples and lost the shape entirely. |
||
|
|
d6c5eafe9e |
release(nesdoctor): point the installers at v0.1.1
v0.1.1 is published, so the pin moves. Flipped after publishing rather than with the version bump, so there was never a moment where the live installer pointed at a tag that did not exist yet. v0.1.0's added-latency grade cannot be trusted -- it measured bloat against a median that goes unstable on a bimodally routed link -- so nothing should be installing it. |
||
|
|
f0e65b9738 |
fix(nesdoctor): bloat was measured against the median, and could grade a bad line A
Found by running the published one-liner, which is the only reason it was
found: `up=34Mbps rtt=188ms rttload=181ms bloat=+0ms grade=A` on a connection
that measured +115 ms and grade F three hours earlier.
The idle baseline was the median of twelve handshakes to one anycast address.
On the development connection those twelve came back **bimodal**:
[56, 56, 57, 59, 60, 176, 177, 179, 179, 179, 182, 368]
min 56 p50 177 max 368 spread 312 ms on an *idle* link
Two points of presence answering. The median therefore lands wherever the split
happens to fall, and when it lands high the loaded median comes in *below* it,
the difference goes negative, `.max(0.0)` clamps it to zero, and the headline
number reports grade A.
That is the one error direction that cannot be tolerated here. A tool whose
whole pitch is a number nobody else shows you has no business saying "your line
is fine" about a line that is not.
Bloat is now measured against the **minimum**. Queueing is delay above the
floor the path can achieve, so the floor is the baseline -- which is also how
every bufferbloat test does it. Twenty samples rather than twelve.
The distance verdict deliberately keeps the **median**, because it asks a
different question. Bloat asks how much queueing is added, so its baseline is
the best case. HOST-READY-LOCAL asks what a player will actually see, so it
takes the typical case: on a link that is bimodal between 56 ms and 180 ms, the
floor would call it near when half of all connections are not.
Both are now reported, and the gap between them is itself the finding -- a
floor of 55 ms against a typical of 180 ms says the route is the problem, which
no single number could have said.
Verified on the same connection: floor 55, typical 180, loaded 95, **+39 ms,
grade C**, verdict HOST-NET. Defensible, and no longer flattering.
Version to 0.1.1. Submissions carry it, so any row with `v=0.1.0` has a grade
that cannot be trusted.
nesdoctor-v0.1.1
|
||
|
|
166c1e9c24 |
fix(nesdoctor): pin the release tag; releases/latest is a time bomb here
Both installers fetched from `releases/latest/download`, which is wrong in this repository specifically: it ships product releases as well as this tool, so `latest` is whichever release went out most recently regardless of what it contains. Measured before publishing anything: `releases/latest` resolved to `v0.2.0`, from May 2024, and the asset URL 404'd. Publishing nesdoctor-v0.1.0 would have papered over it by becoming the newest release -- and then the first product release after it would have moved `latest` again and broken every `curl | sh` in the announcement, silently, for everyone, with the tool itself untouched and nothing to point at. Now pinned to a tag that is bumped when a nesdoctor release is cut, with `NESDOCTOR_TAG` still overriding for testing. The download failure message also now names the tag and says outright that an unpublished tag is the likely cause, since that is the one mistake this arrangement invites.nesdoctor-v0.1.0 |
||
|
|
09b9472134 |
ci(nesdoctor): cross-compile the Intel Mac target, and tag to a draft
Two things a dispatch on 2026-09-02 exposed. `macos-13` is being retired and the x86_64-apple-darwin job sat queued indefinitely waiting for a runner, while the other three targets built and smoke-tested in under three minutes. A release should not have that as a dependency, so Intel macOS is now cross-compiled from the arm64 runner. The cost is real and is stated rather than hidden: an x86_64 binary cannot be executed on an arm64 runner without Rosetta, which these images do not carry, so it is the one target whose smoke test cannot run. The matrix carries an explicit `smoke` flag, the step is gated on it, and the generated release notes say which binary is unexercised. Fabricating a pass for it would have been easy and worse. And a tag now produces a **draft** release rather than a published one. The binaries get attached and the notes get written, then a person reads both and presses publish -- which is the only step in this pipeline that cannot be undone in public. For the record, from the successful three: the musl smoke test measured the network from inside the static binary -- `up=1635Mbps rtt=2ms bloat=+0ms grade=A` -- so `ring` and the platform verifier do resolve root certificates in a fully static build. That was the one thing about this release nobody could have known without running it. |
||
|
|
d01e4a180e |
fix: nesdoctor.json was committed to a public repository (#312)
My mistake, in
|