Bound signup waits and preserve retry state when provisioning stalls
Abort stalled requests after 30 seconds, cap registration at two minutes, and distinguish device checking from instance startup. Browser regressions verify form recovery and reuse of the same signup nonce.
Co-Authored-By: Codex <codex@openai.com>
4 files changed,
+39
−11
docs/infinity/status.md+4 −0internal/infinity/static/api.js+12 −5internal/infinity/static/onboarding.js+7 −6test/infinity/browser.mjs+16 −0
Intent
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 3ec0866..1827e85 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -33,3 +33,7 @@ Source work is on `infinity-v1` in the isolated `.worktrees/infinity` checkout. |
| Invitation landing pages use the personal app’s `INFINITY_HOME_ORIGIN` setting (default `https://ocho.chat`). During DNS setup this pointed to the preview registration address; after HTTPS verification it was switched to `https://ocho.chat`. Invitation fragments survive the front-door redirect, saved-account navigation and same-origin recovery. Browser regression follows an actual invitation through the configured front door into the recipient’s existing account. |
| Personal release `47a9b014` is verified live, including the reserved user invitation instance’s configured home origin. Registration release `47a9b014` is also verified live after an app-scoped restart. Initial concurrent binary uploads failed reading the second part; sequential HTTP/1.1 retries shipped both releases successfully. The production WebKit invitation navigation check also passed against personal release `47a9b014`. |
| + |
| +## Signup delay correction — 2026-09-06 (Codex) |
| + |
| +A real signup after the successful public smoke encountered the platform’s already-staged live-symlink permission failure: the release existed but a new personal hostname could not get its live link. No account/database reset is needed. The reviewed platform fix `90b0878` remains the durable remedy. Client requests now abort after 30 seconds, and the registration attempt has a two-minute overall deadline instead of up to 80 slow readiness requests. It distinguishes device checking from allocation and startup, re-enables retry on failure, and preserves the registration nonce so retry continues the same account. Chromium/WebKit regression simulates a stalled challenge request and checks timeout, form recovery and nonce reuse. |
diff --git a/internal/infinity/static/api.js b/internal/infinity/static/api.js| index 57ab35b..28ff7a1 100644 |
| --- a/internal/infinity/static/api.js |
| +++ b/internal/infinity/static/api.js |
| @@ -1,11 +1,18 @@ |
| // 🤖 Only explicit owner credentials authorize account APIs. |
| -export async function request(origin, path, { auth, method = 'GET', body } = {}) { |
| +export async function request(origin, path, { auth, method = 'GET', body, timeoutMs = 30000 } = {}) { |
| const headers = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; |
| if (auth) headers.Authorization = 'Bearer ' + auth; |
| - const response = await fetch(origin + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'omit', cache: 'no-store' }); |
| - let value; try { value = await response.json(); } catch { throw new Error('Your account is temporarily unavailable. Please try again.'); } |
| - if (!response.ok) { const error = new Error(value.error || 'Could not finish. Please try again.'); error.status = response.status; throw error; } |
| - return value; |
| + const controller = new AbortController(); |
| + const timer = setTimeout(() => controller.abort(), timeoutMs); |
| + try { |
| + const response = await fetch(origin + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'omit', cache: 'no-store', signal: controller.signal }); |
| + let value; try { value = await response.json(); } catch { throw new Error('Your account is temporarily unavailable. Please try again.'); } |
| + if (!response.ok) { const error = new Error(value.error || 'Could not finish. Please try again.'); error.status = response.status; throw error; } |
| + return value; |
| + } catch (error) { |
| + if (controller.signal.aborted) throw new Error('The connection took too long. Please try again.'); |
| + throw error; |
| + } finally { clearTimeout(timer); } |
| } |
| export function peerOrigin(text) { |
| const u = new URL(text); |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index a66ab4d..6aea101 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -38,16 +38,17 @@ export function welcome(root, params, ready) { |
| history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return; |
| } |
| const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce); |
| - note.replaceChildren(message('Preparing your private account…')); |
| + const deadline = Date.now() + 120000; |
| + note.replaceChildren(message('Checking this device…')); |
| const proof = await registrationProof(nonce); |
| let result; |
| - for (let attempt = 0; attempt < 80; attempt++) { |
| - note.replaceChildren(message('Preparing your private account…')); |
| - result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce, proof } }); |
| + while (Date.now() < deadline) { |
| + note.replaceChildren(message(result?.preparing ? 'Your account is being started. This can take a minute…' : 'Creating your private account…')); |
| + result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce, proof }, timeoutMs: Math.min(30000, deadline - Date.now()) }); |
| if (!result.preparing) break; |
| - await new Promise(resolve => setTimeout(resolve, 1500)); |
| + await new Promise(resolve => setTimeout(resolve, Math.min(1500, Math.max(0, deadline - Date.now())))); |
| } |
| - if (!result?.origin) throw new Error('Your account is still being prepared. Please try again in a moment.'); |
| + if (!result?.origin) throw new Error('Your account is taking longer than expected to start. Try again to continue setting up the same account.'); |
| const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName); |
| localStorage.setItem('infinity.home', origin); |
| const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite }))); |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| index 20f423a..94e867b 100644 |
| --- a/test/infinity/browser.mjs |
| +++ b/test/infinity/browser.mjs |
| @@ -12,6 +12,22 @@ try{ |
| for(let attempt=0;;attempt++){try{if((await fetch(origins[i]+'/api/profile')).ok)break;}catch{}if(attempt>100)throw Error('server failed to start');await new Promise(r=>setTimeout(r,100));} |
| } |
| browser=await engine.launch({headless:true}); |
| + // A stalled connection must release the form and preserve the signup nonce for retry. |
| + const stalledContext=await browser.newContext(),stalled=await stalledContext.newPage(); |
| + await stalled.clock.install(); |
| + await stalled.route('**/api/registration-challenge',()=>{}); |
| + await stalled.goto(origins[0]);await stalled.getByLabel('Your name',{exact:true}).fill('Timeout test'); |
| + const pending=stalled.waitForRequest('**/api/registration-challenge'); |
| + await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await pending; |
| + const nonce=await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup')); |
| + await stalled.clock.fastForward(31000); |
| + await stalled.getByRole('alert').filter({hasText:'The connection took too long.'}).waitFor(); |
| + assert.equal(await stalled.getByRole('button',{name:'Create your account',exact:true}).isEnabled(),true); |
| + const retry=stalled.waitForRequest('**/api/registration-challenge'); |
| + await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await retry; |
| + assert.equal(await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup')),nonce); |
| + await stalledContext.close(); |
| + |
| for(let i=0;i<3;i++){ |
| const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page); |