fix(api): the same game added to a run twice is one launch (#426)

Follow-up to #422, from the nesktop #3 review. POST /session/{id}/launch
for a game that already has an open launch (requested or running, no
stop requested) in the run now answers that launch with 200 instead of
inserting a second one. The check runs under the run's row lock that
addLaunch already takes, so two concurrent adds resolve to one launch.
OpenAPI documents the 200.

Tested: apps/api 208 pass against the local test Postgres, including a
new test adding the same game twice concurrently.
This commit is contained in:
Wanjohi
2026-10-17 16:51:25 +03:00
committed by GitHub
parent 0c348d3973
commit 3ce8aee427
3 changed files with 29 additions and 1 deletions
+7
View File
@@ -476,6 +476,11 @@ export namespace SessionApi {
description:
'Adds a launch to a run that its host has taken on and that is not being stopped; the host starts it in the box without stopping what already runs there. Answers with the launch: its `id` is what the launched game’s window carries, and what to stop when that window closes. A run that is waiting for a host, stopping or over is 409: ask for a new run instead. So is a run on a host that does not handle launches, with code `host_too_old`: stop the run and ask for a new one, as before launches. The run is still what is billed and what the allowance counts, so adding a launch asks nothing of either.',
responses: {
200: {
content: { 'application/json': { schema: Result(Launch.Info) } },
description:
'That game already has a launch in this run that is not over: it is answered instead of a second one'
},
201: {
content: { 'application/json': { schema: Result(Launch.Info) } },
description: 'The launch has been requested'
@@ -537,6 +542,8 @@ export namespace SessionApi {
conflict('That run is not running, so it cannot take another launch');
case 'hostTooOld':
hostTooOld();
case 'existing':
return c.json({ data: result.launch }, 200);
default:
return c.json({ data: result.launch }, 201);
}
+10
View File
@@ -226,6 +226,16 @@ describe('launches', () => {
expect(run.launches.map((l: any) => l.state)).toEqual(['running', 'running']);
});
test('the same game added twice is one launch', async () => {
const { s, id, second } = await liveRun('launch-dedupe', 7109);
const [a, b] = await Promise.all([addLaunch(s, id, second), addLaunch(s, id, second)]);
const statuses = [a.status, b.status].sort();
expect(statuses).toEqual([200, 201]);
const ids = [((await a.json()) as any).data.id, ((await b.json()) as any).data.id];
expect(ids[0]).toBe(ids[1]);
expect((await read(s, id)).launches.length).toBe(2);
});
test('a run that is not running takes no launch', async () => {
const s = await scene('launch-notrunning', 7102);
const { body } = await requestSession(s);