mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-23 03:05:20 +03:00
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.
This commit is contained in:
@@ -530,3 +530,111 @@ describe('Session tickets and the end of a run', () => {
|
||||
expect(ended?.ticket).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session and the box underneath it', () => {
|
||||
test('a live run is what makes its box running', async () => {
|
||||
const { machineId, box, session } = await requestedRun('ses-box-live', 5460);
|
||||
// A box starts out `created` and nothing had ever moved it, so it read
|
||||
// `created` while a run on it was `live`.
|
||||
expect((await Box.fromID(box.id))?.state).toBe('created');
|
||||
|
||||
await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null });
|
||||
// `starting` is deliberately not a box state: that transition is
|
||||
// synchronous from the agent's side, so nothing would ever write it.
|
||||
expect((await Box.fromID(box.id))?.state).toBe('created');
|
||||
|
||||
await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null });
|
||||
const running = await Box.fromID(box.id);
|
||||
expect(running?.state).toBe('running');
|
||||
expect(running?.stopReason).toBeNull();
|
||||
expect(running?.stopClean).toBeNull();
|
||||
});
|
||||
|
||||
test('a run that ends stops its box, cleanly', async () => {
|
||||
const { machineId, box, session } = await requestedRun('ses-box-ended', 5461);
|
||||
await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null });
|
||||
await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null });
|
||||
await Session.transition({ id: session.id, machineId, state: 'ended', errorMessage: null });
|
||||
|
||||
const stopped = await Box.fromID(box.id);
|
||||
expect(stopped?.state).toBe('stopped');
|
||||
expect(stopped?.stopClean).toBe(true);
|
||||
expect(stopped?.stopReason).toBeNull();
|
||||
});
|
||||
|
||||
test('a run that fails stops its box in the words the agent used', async () => {
|
||||
const { machineId, box, session } = await requestedRun('ses-box-failed', 5462);
|
||||
await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null });
|
||||
await Session.transition({
|
||||
id: session.id,
|
||||
machineId,
|
||||
state: 'failed',
|
||||
errorMessage: 'the guest never came up'
|
||||
});
|
||||
|
||||
const stopped = await Box.fromID(box.id);
|
||||
expect(stopped?.state).toBe('stopped');
|
||||
// "It is not running" and "it faulted" are different facts, and the
|
||||
// difference lives in the reason rather than in a fourth state.
|
||||
expect(stopped?.stopClean).toBe(false);
|
||||
expect(stopped?.stopReason).toBe('the guest never came up');
|
||||
});
|
||||
|
||||
test('a refused report leaves the box alone', async () => {
|
||||
const { machineId, box, session } = await requestedRun('ses-box-untouched', 5463);
|
||||
const other = await scene('ses-box-otherhost', 5464);
|
||||
|
||||
const refused = await Session.transition({
|
||||
id: session.id,
|
||||
machineId: other.machineId,
|
||||
state: 'starting',
|
||||
errorMessage: null
|
||||
});
|
||||
expect(refused.outcome).toBe('forbidden');
|
||||
|
||||
// An illegal transition does not move the run, so it must not move the
|
||||
// box either — otherwise the box records a run that never happened.
|
||||
const illegal = await Session.transition({
|
||||
id: session.id,
|
||||
machineId,
|
||||
state: 'live',
|
||||
errorMessage: null
|
||||
});
|
||||
expect(illegal.outcome).toBe('illegal');
|
||||
expect((await Box.fromID(box.id))?.state).toBe('created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session tickets need a claim first', () => {
|
||||
test('a run nobody has claimed has no address to publish', async () => {
|
||||
const { machineId, session } = await requestedRun('ses-ticket-unclaimed', 5465);
|
||||
|
||||
// A ticket is the address of something being brought up, so publishing
|
||||
// one for a `requested` run means the agent skipped the claim — the
|
||||
// step that is the only mutual exclusion in the design.
|
||||
const early = await Session.publishTicket({ id: session.id, machineId, ticket: 'too-soon' });
|
||||
expect(early.outcome).toBe('unclaimed');
|
||||
expect((await Session.fromID(session.id))?.ticket).toBeNull();
|
||||
|
||||
await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null });
|
||||
const now = await Session.publishTicket({ id: session.id, machineId, ticket: 'in-time' });
|
||||
expect(now.outcome).toBe('published');
|
||||
expect(now.session?.ticket).toBe('in-time');
|
||||
});
|
||||
|
||||
test('the two refusals are different answers, because they are different mistakes', async () => {
|
||||
const { machineId, session } = await requestedRun('ses-ticket-refusals', 5466);
|
||||
const unclaimed = await Session.publishTicket({ id: session.id, machineId, ticket: 'a' });
|
||||
|
||||
await Session.transition({ id: session.id, machineId, state: 'starting', errorMessage: null });
|
||||
await Session.transition({ id: session.id, machineId, state: 'live', errorMessage: null });
|
||||
await Session.transition({ id: session.id, machineId, state: 'ended', errorMessage: null });
|
||||
const closed = await Session.publishTicket({ id: session.id, machineId, ticket: 'b' });
|
||||
|
||||
// One is an agent that has not claimed the work; the other is a run
|
||||
// with nothing left to reach. Collapsing them would tell an agent
|
||||
// retrying the wrong thing.
|
||||
expect(unclaimed.outcome).toBe('unclaimed');
|
||||
expect(closed.outcome).toBe('closed');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user