From e122a3b3f0b8a07d73fd107e8a0b2cc96dfa3551 Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Fri, 21 Aug 2026 12:14:58 +0400 Subject: [PATCH] Wait before rebuilding a computer that just lost a race Creating a computer and starting it are two calls to Docker. A name the daemon has not published yet answers the second with a 404, which the supervisor already reads correctly as a lost race and answers by rebuilding. It went straight back round, though, and the retry is the whole budget: it arrived a millisecond later, saw the same unpublished name, and spent the second attempt on the state that failed the first. The request then failed as Docker being unreachable, so the first browser action a Bot was ever asked for was refused with "no such container", and the next message seconds later worked. One poll interval first, which is what waitUntilAnswering already uses for the same question. The two sleeps share a helper. --- CHANGELOG.md | 7 +++++++ supervisor/src/docker.ts | 29 ++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945ff421..88d6370b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,13 @@ Sessions survive and nobody signs in again. laptop `http://localhost` counts as one, so this never showed up in development; on a real address it does not, and the surface did nothing at all when you pressed send. No message, no error. Ids now come from an API with no such restriction. +- **The first browser action a Bot was ever asked for failed.** Creating a computer and starting it + are two calls to Docker, and a name the daemon has not published yet answers the second with a 404. + The supervisor treats that as a lost race and rebuilds, which is right, but it went straight back + round: the retry landed a millisecond later, saw the same unpublished name, and spent the only + other attempt on it. The whole request then failed as Docker being unreachable, the person was told + the computer could not be started, and the next message worked. It waits one poll interval before + rebuilding now, which is what the health wait already uses for the same question. ### Changed diff --git a/supervisor/src/docker.ts b/supervisor/src/docker.ts index 7b3d551f..898513ed 100644 --- a/supervisor/src/docker.ts +++ b/supervisor/src/docker.ts @@ -45,6 +45,13 @@ function statusOf(error: unknown): number | undefined { return (error as { statusCode?: number }).statusCode; } +/** One poll interval, used both by the health wait and by the retry that follows a lost race. */ +function pause(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + export type ComputerState = { botId: string; container: string; @@ -180,7 +187,7 @@ async function waitUntilAnswering( } catch { // Mid-creation, or gone. The deadline is what ends this. } - await new Promise((resolve) => setTimeout(resolve, 250)); + await pause(250); } } @@ -311,10 +318,22 @@ export async function ensure( await docker.getContainer(names.container).start(); } catch (error) { const status = statusOf(error); - // Gone between creating it and starting it: a concurrent reset took the container away, or - // the create this request lost the race to was itself rolled back. Nothing about the Bot has - // changed, so the answer is to build it again rather than to report Docker as unreachable. - if (status === 404 && attempt > 1) continue; + /* + * Gone between creating it and starting it: a concurrent reset took the container away, the + * create this request lost the race to was itself rolled back, or the daemon has not yet + * published the name this request just created. Nothing about the Bot has changed, so the + * answer is to build it again rather than to report Docker as unreachable. + * + * Paused first, because the retry is the whole budget. Going straight back round arrives + * within a millisecond, sees the same not-yet-published name, and spends the second attempt + * on the state that failed the first: the first browser action a Bot is ever asked for fails, + * and the second one, seconds later, works. One poll interval is what the health wait uses + * for the same question. + */ + if (status === 404 && attempt > 1) { + await pause(250); + continue; + } // 304 is "already running", which is success for an idempotent verb. if (status !== 304) { throw new DockerUnavailableError(String(error));