mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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.
This commit is contained in:
@@ -164,12 +164,17 @@ export namespace SessionApi {
|
||||
// A box runs one thing at a time. Refusing is the honest answer;
|
||||
// starting a second run would leave two rows that both think they
|
||||
// own the same hardware.
|
||||
//
|
||||
// This read is the message, not the guarantee — two callers can
|
||||
// both pass it. `Session.request` is refused by a unique index on
|
||||
// the same predicate, and answers with the same 409 in the same
|
||||
// words, so which one caught it is not visible from here.
|
||||
const active = await Session.activeForBox(box.id);
|
||||
if (active) {
|
||||
conflict('That box already has a run that has not stopped');
|
||||
conflict(Session.BOX_BUSY);
|
||||
}
|
||||
|
||||
const session = await Session.create({
|
||||
const session = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
@@ -185,7 +190,7 @@ export namespace SessionApi {
|
||||
tags: ['Session'],
|
||||
summary: 'Read a run you asked for',
|
||||
description:
|
||||
'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else.',
|
||||
'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else. Once the run reaches a terminal state the ticket is null: stop polling and stop dialling it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
|
||||
@@ -191,6 +191,29 @@ describe('POST /session', () => {
|
||||
expect(second.body.type).toBe('already_exists');
|
||||
});
|
||||
|
||||
test('two requests racing for one box still start it once', async () => {
|
||||
const s = await scene('route-race', 5509);
|
||||
const [a, b] = await Promise.all([requestSession(s), requestSession(s)]);
|
||||
|
||||
// Which request wins is a timing detail; that exactly one does is not.
|
||||
// The pre-check and the unique index answer identically, so the loser
|
||||
// cannot tell which caught it.
|
||||
//
|
||||
// This asserts the endpoint's answer, not the invariant: two requests
|
||||
// in one process usually interleave such that the pre-check catches
|
||||
// the second, so it passes with the unique index dropped. The index is
|
||||
// pinned in the core tests, where both callers can be made to read
|
||||
// before either writes.
|
||||
const statuses = [a.res.status, b.res.status].sort();
|
||||
expect(statuses).toEqual([201, 409]);
|
||||
expect([a.body, b.body].find((x) => x.type)?.type).toBe('already_exists');
|
||||
|
||||
expect(await Session.listByBox(s.box.id)).toHaveLength(1);
|
||||
// The failure this prevents: the host offered the same box twice.
|
||||
const jobs = await app.request('/machine/jobs', { headers: s.host });
|
||||
expect(((await jobs.json()) as any).data).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('you can only play as an account you have linked', async () => {
|
||||
const mine = await scene('route-account-mine', 5505);
|
||||
const theirs = await scene('route-account-theirs', 5506);
|
||||
@@ -539,6 +562,35 @@ describe('POST /session/:id/ticket', () => {
|
||||
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
|
||||
});
|
||||
|
||||
test('a run that stops loses the address it published', async () => {
|
||||
const s = await scene('route-ticket-cleared', 5545);
|
||||
const { body } = await requestSession(s);
|
||||
const report = (state: string) =>
|
||||
app.request(`/session/${body.data.id}/state`, {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({ state })
|
||||
});
|
||||
await report('starting');
|
||||
await report('live');
|
||||
const published = await app.request(`/session/${body.data.id}/ticket`, {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({ ticket: 'nodeaaa-live' })
|
||||
});
|
||||
expect(((await published.json()) as any).data.ticket).toBe('nodeaaa-live');
|
||||
|
||||
await report('ended');
|
||||
|
||||
// The polling client is the reason. It reads this endpoint until it has
|
||||
// an address, and an address left behind by a run that stopped is one
|
||||
// it would dial — while publishing a replacement is already refused.
|
||||
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||||
const after = (await read.json()) as any;
|
||||
expect(after.data.state).toBe('ended');
|
||||
expect(after.data.ticket).toBeNull();
|
||||
});
|
||||
|
||||
test('a ticket has to say something', async () => {
|
||||
const s = await scene('route-ticket-empty', 5544);
|
||||
const { body } = await requestSession(s);
|
||||
|
||||
Reference in New Issue
Block a user