--- id: match-lifecycle title: A match starts, survives, and ends properly status: open repos: [headquarters, arena, infra] dependsOn: [] exitCriterion: > No match needs an operator to end it, and one that dies is noticed without anybody thinking to look. --- # match-lifecycle Everything between a match being launched and its row being something nobody has to think about again. It spans all three repos because the failure modes do: a match can die in the container, in the control plane's idea of it, or in ECS, and only the three together cover it. The common case is handled. arena enforces `limits.idleTimeoutSeconds` in-container — no sign of play for the limit shuts the task down, where "idle" means no join, return, round, phase or unit turn — and a list load of a `ready` match now re-checks it against ECS. What is left is everything that falls outside those two. ## A match that dies quietly - [ ] **Expire non-terminal rows by age.** `poll()`'s `ready` arm (`outlived`) covers a match that died after reaching `ready`, driven by the list for `ready` rows only — the one status that hands out a link, and the one whose being wrong costs a player anything. What polling cannot reach is a row nobody loads: ECS keeps a stopped task describable for about an hour and then forgets it, and a forgotten task comes back as an error rather than a status, so a row nobody polls inside that window stays `ready` for good. Every row already stuck that way is past it. - [ ] **A match token still outlives its task if the match never ends.** The minting route refuses a match at `over` or `failed`, which covers every match that finishes or whose task is seen stopped. A row that stops moving short of that keeps a token that mints S3 writes. The age cutoff above is the other half of this. - [ ] **Nothing on this side reaps an abandoned match.** What the in-container timeout cannot cover is a task whose container hangs or dies without reporting. Catching those needs the matches registry, so it lands here. - [ ] **No external backstop for a hung task** (infra). A hung match bills until something kills it. An EventBridge rule or an alarm on task age would close the case where the watcher dies with the game, and would double as the "a match task exists at all" signal. - [ ] **Nothing raises its hand** (infra). CloudFront access logs land in S3, but that is a question someone has to think to ask. There is no alarm, no dashboard, and no notification when a match task exits non-zero. CloudWatch alarms are $0.10/month each, so the constraint is deciding what matters, not the cost. - [ ] **Enforce the match cap** (infra, then headquarters). The concurrent-task cap (SSM `///match/max-concurrent-tasks`, default 20) is published and readable by the control-plane policy, but ECS cannot cap standalone `RunTask` tasks itself. Until headquarters counts running tasks and refuses at the cap, it is documentation, not a limit. ## A match that ends badly - [ ] **A reload after the container stops has nothing to show.** The report screen lives in the page the container serves, so refreshing it once the task is gone gets the proxy's 502 instead of the result. `result.json` outlives the task in S3 and `GET /api/matches/{id}/result` already reads it; what is missing is a screen on the site that does. That screen is [after-action](after-action.md)'s, and this is the reason it is wanted sooner rather than later. - [ ] **Reconnection** (arena). The host re-arms when a player drops, but Suramadu keeps the client JVM alive after the page closes, and the spike found that reconnecting to a live session is where things break. Now that PvP works end to end this is the likeliest way a real match dies. It stays low priority until a live match actually hits it — the first report of a stuck rejoin is what makes it urgent. - [ ] **Neither half of the idle timeout has been seen firing on Fargate.** The in-container watcher and the entrypoint's SIGTERM handling are both written and neither has been observed doing its job on a real task. ## A human's view of a live match The bar says when there is one: `account.ts` asks for the matches after it has read the session, and the filled link says Resume instead of Play when any of them is not `over` or `failed`. It is the same address either way — Play is the screen that lists the live ones — so this is a signpost, not a route. Signed out, and on the blog's pages, nothing is asked and nothing changes; an unreachable API leaves the label alone. That makes the list's own idea of "still going" visible on every page of the site, which raises the cost of the stale rows above: a match the registry thinks is `ready` because nobody loaded it puts Resume on the bar for good. And Play leads with them: "Matches in progress" is the first section on that screen, above "Start a match", so following Resume off the bar lands on the live match rather than on the way to open another one. The section takes itself away when the list comes back with nothing live, so a player with none is not made to read an empty box to reach the Modes. A failed read keeps the section and says so, because removing it would claim there is nothing going when nothing is known. ## A player cannot tell whether it is us - [ ] **There is no status page.** `web/src/outage-panel.ts` tells a signed-in reader that the control plane did not answer, on the page they were already on. What has never existed is somewhere a player can look when a match will not start, and somewhere we can say a thing is known and being worked on. The signals to build it from are here already — the registry knows what is running, and the alarms above are what would notice. Not needed for alpha, and it is the first thing wanted on the day a stranger's first match fails. ## A human's view of an ended match - [ ] **Only an operator script ends a match.** `infra/scripts/matches/killall.sh` stops tasks and writes the sqlite directly, because the API has no admin surface. Fine for one operator; revisit alongside [managed-store](managed-store.md), since an admin surface that writes through the API is easier once the store is not a file on one box. - [ ] **The matches list has no retention.** The old sidebar's day cutoff was dropped when the list became a screen, so every match ever played is shown and the API just applies `LIMIT 20`. It needs server-side retention, paging, and a way to see past matches. A player with hundreds of matches has no answer today. ## Not part of this Whether a match produces a *durable record* is [match-records](match-records.md). This epic is only about the row, the task and the container agreeing on whether a match is still alive. ## Done - [x] **The report says who fought, not just what is left.** Each force is a card with the account's picture (the public appview, named in the match page's CSP), its camo (staged beside `decided.json` by arena, so same-origin and container-lifetime only), a link to the account and the victor mark. The watcher seats are dropped by name as well as by the server's observer flag, which is false in a result written before every human seat arrived — that is why `ArenaObserver` was on the screen. `/api/matches/{id}/result` also hands back `share`, the address of the public page for the result. - [x] **A finished match ends on a report, not on a disconnect.** Three arena patches got the client as far as holding MegaMek's victory screen and then closing rather than falling back to its launcher, which left Suramadu's "connection to the server is lost" card as the last thing a player saw. `GET /api/matches/{id}/result` is what replaces it: the seat-gated read of the `result.json` arena uploads about five seconds after the game is decided, polled by arena's own `report.js`, which covers the page with the result and hides the client so nothing Suramadu paints afterwards lands on top. The route answers a status alongside the result so a match that ended without one — reaped, crashed, stopped short of victory — is said out loud rather than waited on. - [x] **The victory screen is held instead of being reset away.** Taken the first of the two ways out, as a patch: MegaMek's own `resetGame()` after victory — the line upstream marks with a `FIXME` asking why it is there — cleared the entities and dropped the phase back to LOUNGE, so a player's last sight of the match was an empty lobby. Left standing, the game stays in VICTORY with the report on screen until `MatchHost` stops the server. Two patches beside it finish the exit: the client closes rather than falling back to the launcher, and the disconnect it causes is quiet, so no dialog outlives the match it belongs to. - [x] **The manifest doc describes idling correctly.** [docs/launch-manifest.md](../docs/launch-manifest.md) had said the timeout fires when no player joins, which is narrower than what arena implements. It now says "no sign of play" and names the cases — a lobby nobody joined, a game everyone left or stopped playing. - [x] **arena enforces `limits.idleTimeoutSeconds` in-container.** The host writes `state/host.activity` on every join, return, round, phase and unit turn, and `container/watch/idle.sh` sends the entrypoint the same SIGTERM Fargate would when the timestamp goes stale — so the shutdown finalizes and the artefacts still upload. One rule covers the empty lobby, the lounge nobody starts, and the match everyone left. Game-over disarms it. - [x] **A listed `ready` match is re-checked rather than remembered.** `poll()`'s `ready` arm (`outlived`) is driven by the list for `ready` rows only — the one status that hands out a link, and the one whose being wrong costs a player anything. `starting` and `running` are left to the waiting screen, because `advance` waits three seconds on the container's own web server and the list must not do that per row. One DescribeTasks per ready row, held to one a second by the existing probe window, and it converges: the first list load after a match ends writes `over`.