diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index 1868f300..293c5e15 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -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); } diff --git a/apps/api/test/launch.test.ts b/apps/api/test/launch.test.ts index a22ba4c6..251179af 100644 --- a/apps/api/test/launch.test.ts +++ b/apps/api/test/launch.test.ts @@ -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); diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 1025e77b..bb1b4ffb 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1230,7 +1230,8 @@ export namespace Session { | { outcome: 'notFound' } | { outcome: 'notRunning'; session: Info } | { outcome: 'hostTooOld' } - | { outcome: 'created'; launch: Launch.Info }; + | { outcome: 'created'; launch: Launch.Info } + | { outcome: 'existing'; launch: Launch.Info }; /** * Add a launch to a run that is going. The library and account checks are @@ -1266,6 +1267,16 @@ export namespace Session { return { outcome: 'notRunning', session: serialize(run) }; } if (!(await Launch.hostTakesLaunches(run.id))) return { outcome: 'hostTooOld' }; + // The same game asked for twice (two windows, or a retry after a + // lost reply) is one launch: under the run's lock the first add + // has committed, so the second finds it and gets it back. + const open = (await Launch.listForSession(run.id)).find( + (l) => + l.gameId === input.gameId && + (Launch.OPEN as readonly string[]).includes(l.state) && + l.timeStopRequested === null + ); + if (open) return { outcome: 'existing', launch: open }; const launch = await Launch.insert({ id: Identifier.ascending('launch'), sessionId: run.id,