From ef3609e4baf531abb145603d22b7c74cb698701f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Fri, 10 Jul 2026 23:53:45 +0100 Subject: [PATCH 1/5] chore(desktop): bump version to 0.4.0 --- desktop/app/src-tauri/Cargo.lock | 2 +- desktop/app/src-tauri/Cargo.toml | 2 +- desktop/app/src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/app/src-tauri/Cargo.lock b/desktop/app/src-tauri/Cargo.lock index 5821ece9..62c1f6a2 100644 --- a/desktop/app/src-tauri/Cargo.lock +++ b/desktop/app/src-tauri/Cargo.lock @@ -2305,7 +2305,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orcabot-desktop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ctrlc", "filetime", diff --git a/desktop/app/src-tauri/Cargo.toml b/desktop/app/src-tauri/Cargo.toml index a652990e..858892df 100644 --- a/desktop/app/src-tauri/Cargo.toml +++ b/desktop/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orcabot-desktop" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Orcabot Desktop shell" license = "UNLICENSED" diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json index d586c936..8eeb8eff 100644 --- a/desktop/app/src-tauri/tauri.conf.json +++ b/desktop/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Orcabot", - "version": "0.3.0", + "version": "0.4.0", "identifier": "com.orcabot.desktop", "build": { "beforeDevCommand": "sh -c \"cd ../../frontend && NEXT_PUBLIC_API_URL=http://localhost:8787 NEXT_PUBLIC_SITE_URL=http://localhost:8788 NEXT_PUBLIC_DEV_MODE_ENABLED=true NEXT_PUBLIC_DESKTOP_MODE=true npx wrangler dev -c wrangler.toml --port 8788\"", From 113972e9c0f1089d647fc6fc1535e32941237e4f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sat, 11 Jul 2026 11:36:03 +0100 Subject: [PATCH 2/5] test(e2e,desktop): measure the flaky first-PTY connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first terminal on a fresh dashboard flakes between four outcomes: (A) looks stuck on "Connecting to sandbox..." but a keypress reveals the prompt, (B) the block disappears, (C) stuck forever, (D) works. A single connect check can't see this — it's a race that only shows up as a rate. The existing e2e waitForPrompt masked it: it returned on `content.trim().length > 0`, which green-lights the stuck banner ("Connecting to sandbox...", "$ Connected to sandbox") with no shell prompt. Tighten it to require a real `user@host:path[#$]` prompt (SHELL_PROMPT_RE) so the stuck state fails instead of passing. Add two measurement harnesses: - e2e recipe 09 (first-connect flake loop): N fresh dashboards, each a new VM + first PTY; without sending input, classify ok / ok-after-enter (A) / stuck (C) / gone (B). The Enter probe splits "connected but unrendered" from "never connected". Only "ok" passes; reports a rate. This is the full-stack repro (run it against dev cloud, where the latency-driven flake lives). - desktop first-prompt-smoke.sh: attaches to a freshly-created PTY with NO input and checks the prompt arrives via the hub's scrollback replay (pty/hub.go:787), looped for a rate. Guards the replay path against regressions; 12/12 (100%) locally, as expected — the race window is tiny on a fast local VM. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBxVYcEidYfRm5Gf4j7nPL --- desktop/tests/first-prompt-smoke.sh | 78 +++++++++++ e2e/fixtures/terminal.ts | 37 +++-- e2e/recipes/09-terminal-first-connect.spec.ts | 127 ++++++++++++++++++ 3 files changed, 231 insertions(+), 11 deletions(-) create mode 100755 desktop/tests/first-prompt-smoke.sh create mode 100644 e2e/recipes/09-terminal-first-connect.spec.ts diff --git a/desktop/tests/first-prompt-smoke.sh b/desktop/tests/first-prompt-smoke.sh new file mode 100755 index 00000000..82ae258b --- /dev/null +++ b/desktop/tests/first-prompt-smoke.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Orcabot desktop FIRST-PROMPT smoke — the "connected but blank" first-PTY race. +# +# Reproduces the SANDBOX side of the flaky first terminal (frontend recipe 09 +# covers the full-stack UI side). A client that attaches to a freshly-created PTY +# must see the shell prompt WITHOUT sending any input: it arrives either via the +# live broadcast (if the client registers before the shell emits its prompt) or +# via the hub's scrollback replay on attach (sandbox/internal/pty/hub.go:787, +# `if raw := h.ScrollbackRaw(0); len(raw) > 0`). If the prompt was emitted after +# the session was created but before this client registered AND wasn't yet in the +# 64KB ring buffer, the client sees a blank terminal until it presses a key. +# +# This loops many fresh terminals, attaches to each immediately with NO input +# (`orcabot tail`, no --send), and reports how often the prompt shows up +# input-free. On the local desktop VM the race window is tiny, so this normally +# passes — its job is to guard the replay path against regressions (e.g. someone +# removing the replay, or breaking the ring buffer) and to be pointable at a +# laggier stack. The statistical repro of the ~30% flake lives in e2e recipe 09. +# +# Usage: desktop/tests/first-prompt-smoke.sh [iterations] +# Requires: stack running (`orcabot up`), python3, curl. Exits non-zero if the +# input-free prompt rate is below FIRST_PROMPT_THRESHOLD (default 100%). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ORCABOT="${ORCABOT_BIN:-$SCRIPT_DIR/../app/src-tauri/target/release/orcabot}" +CP="http://127.0.0.1:8787" +U="X-User-ID: dev-desktop" +ITER="${1:-15}" +TAIL_SECS="${FIRST_PROMPT_TAIL_SECS:-3}" +THRESHOLD="${FIRST_PROMPT_THRESHOLD:-100}" # percent input-free prompts required + +# Per-boot surface token (dev-auth gate), read like the CLI/app do. Empty is +# harmless on stacks that predate surface enforcement. +SURFACE_TOKEN_FILE="${ORCABOT_SURFACE_TOKEN_FILE:-$HOME/Library/Application Support/com.orcabot.desktop/surface-token}" +ST="$(cat "$SURFACE_TOKEN_FILE" 2>/dev/null | tr -d '[:space:]')" +AUTH=(-H "$U" -H "X-Orcabot-Surface: $ST") + +curl -s -m3 "$CP/health" >/dev/null \ + || { echo "control plane not reachable on :8787 — run 'orcabot up' first." >&2; exit 2; } + +DID="$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + --data-raw '{"name":"first-prompt-smoke"}' "$CP/dashboards" \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['dashboard']['id'])")" +echo "smoke dashboard: $DID (iterations=$ITER, tail=${TAIL_SECS}s, threshold=${THRESHOLD}%)" + +# Does captured PTY output contain a real shell prompt (user@host:...#/$)? +# ANSI/OSC escapes are stripped first so styling can't hide the prompt text. +has_prompt() { + python3 - "$1" <<'PY' +import re, sys +s = sys.argv[1] +s = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', s) # OSC (e.g. title sets) +s = re.sub(r'\x1b\[[0-9;?]*[ -/]*[@-~]', '', s) # CSI / SGR colour codes +sys.exit(0 if re.search(r'[\w.-]+@[\w.-]+:[^\n]*[#$]', s) else 1) +PY +} + +ok=0; bad=0 +for i in $(seq 1 "$ITER"); do + ITEM="$("$ORCABOT" new terminal shell --dash "$DID" 2>/dev/null | sed 's/created shell terminal //')" + if [ -z "$ITEM" ]; then echo " [$i] FAIL: could not create terminal"; bad=$((bad + 1)); continue; fi + # Attach IMMEDIATELY and read WITHOUT sending any input. + out="$(timeout $((TAIL_SECS + 6)) "$ORCABOT" tail "$ITEM" --dash "$DID" --secs "$TAIL_SECS" 2>/dev/null)" + if has_prompt "$out"; then + ok=$((ok + 1)); echo " [$i] ok" + else + bad=$((bad + 1)); echo " [$i] BLANK — no input-free prompt" + fi + curl -s -o /dev/null -X DELETE "${AUTH[@]}" "$CP/dashboards/$DID/items/$ITEM" +done + +curl -s -o /dev/null -X DELETE "${AUTH[@]}" "$CP/dashboards/$DID" +total=$((ok + bad)) +rate="$(python3 -c "print(round(100*$ok/max($total,1)))")" +echo +echo "================ input-free prompt: $ok/$total (${rate}%) ================" +[ "$rate" -ge "$THRESHOLD" ] diff --git a/e2e/fixtures/terminal.ts b/e2e/fixtures/terminal.ts index b645edf3..02a2896d 100644 --- a/e2e/fixtures/terminal.ts +++ b/e2e/fixtures/terminal.ts @@ -22,7 +22,7 @@ export async function addTerminal( * layer underneath (.xterm-rows) with span elements containing text. * Falls back to other selectors if .xterm-rows is not available. */ -async function readTerminalText(page: Page): Promise { +export async function readTerminalText(page: Page): Promise { return page.evaluate(() => { // Approach 1: .xterm-rows (xterm v4/v5 DOM layer) const rows = document.querySelector(".xterm-rows"); @@ -49,7 +49,20 @@ async function readTerminalText(page: Page): Promise { } /** - * Wait for the terminal to connect and show a shell prompt. + * A real shell prompt looks like `user@host:path#` (or `$`), e.g. + * `root@78451ddfd73298:/workspace#`. + * + * This deliberately does NOT match the terminal block's own status lines + * ("Connecting to sandbox...", "$ Connected to sandbox"). Those are exactly what + * a *stuck* first-PTY shows — connected but with no shell prompt rendered — and + * the old `content.trim().length > 0` check green-lit that stuck state, hiding + * the flaky-first-connect bug. Requiring the `user@host:…[#$]` shape is what + * makes the stuck state fail a test instead of passing it. + */ +export const SHELL_PROMPT_RE = /[\w.-]+@[\w.-]+:[^\n]*[#$]/; + +/** + * Wait for the terminal to connect and show a real shell prompt. * The sandbox needs to boot (Fly machine start), create a session, * and establish a WebSocket connection before the prompt appears. */ @@ -58,19 +71,21 @@ export async function waitForPrompt( timeoutMs = 90_000 ): Promise { const startTime = Date.now(); + let last = ""; while (Date.now() - startTime < timeoutMs) { - const content = await readTerminalText(page); - - // A connected terminal will have some content — a prompt character - // like $, #, >, or a username/path in the prompt - if (content && content.trim().length > 0) { - return; - } + last = await readTerminalText(page); + if (SHELL_PROMPT_RE.test(last)) return; await page.waitForTimeout(1_000); } + const stuck = /Connecting to sandbox|Connected to sandbox/i.test(last); throw new Error( - `Terminal did not show a prompt within ${timeoutMs}ms. ` + - "Check that the sandbox boots and the WebSocket connects." + `Terminal did not show a shell prompt within ${timeoutMs}ms` + + (stuck + ? " — stuck on the connect banner with no prompt (first-PTY render race)" + : "") + + `. Last content (first 300 chars): ${last + .replace(/\n/g, "⏎") + .slice(0, 300)}` ); } diff --git a/e2e/recipes/09-terminal-first-connect.spec.ts b/e2e/recipes/09-terminal-first-connect.spec.ts new file mode 100644 index 00000000..0a5df2f1 --- /dev/null +++ b/e2e/recipes/09-terminal-first-connect.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from "../fixtures/base"; +import { readTerminalText, SHELL_PROMPT_RE } from "../fixtures/terminal"; + +/** + * Recipe 09 — First-PTY connect flake loop. + * + * Reproduces the flaky first terminal on a fresh dashboard, where one of four + * things happens on that early PTY: + * A. looks stuck on "Connecting to sandbox..." but a keypress reveals the + * prompt -> the WS is connected, the initial prompt just never rendered + * B. the block disappears after a second and doesn't come back + * C. it gets stuck and never resolves + * D. it works normally + * + * A single `waitForPrompt` run can't see this — the outcome is a race that only + * shows up as a *rate*. So this loops N fresh dashboards (each a brand-new VM + + * first PTY), and for each one, WITHOUT sending any input, classifies the + * outcome: + * - "ok" : a real shell prompt appeared input-free (D) + * - "ok-after-enter" : blank until we pressed Enter, then a prompt showed (A) + * - "stuck" : never showed a prompt, even after Enter (C) + * - "gone" : the .xterm block vanished mid-connect (B) + * + * Only "ok" is a pass. The Enter probe is what distinguishes A (connected but + * unrendered) from C (never connected) — the exact split in the bug report. + * + * Tunable via env: FIRST_CONNECT_ITERATIONS (default 10), + * FIRST_CONNECT_TIMEOUT_MS (default 45000). This is a slow, resource-heavy + * measurement suite (one VM boot per iteration) — run it deliberately, not in + * the fast lane. + */ + +const ITERATIONS = Number(process.env.FIRST_CONNECT_ITERATIONS || 10); +const PROMPT_TIMEOUT = Number(process.env.FIRST_CONNECT_TIMEOUT_MS || 45_000); + +type Outcome = "ok" | "ok-after-enter" | "stuck" | "gone"; + +test.describe("Recipe: First-PTY connect (flake loop)", () => { + test.beforeEach(async ({ page, auth }) => { + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await auth.login(); + }); + + test(`first PTY on a fresh dashboard connects cleanly (${ITERATIONS}x)`, async ({ + page, + dashboard, + terminal, + api, + }) => { + // Each iteration boots a VM; give the whole loop generous headroom. + test.setTimeout((PROMPT_TIMEOUT + 40_000) * ITERATIONS + 60_000); + + const results: { i: number; outcome: Outcome; ms: number }[] = []; + + for (let i = 0; i < ITERATIONS; i++) { + const id = await dashboard.create(`E2E-FirstConnect-${i}-${Date.now()}`); + await terminal.add(); + + const t0 = Date.now(); + let outcome: Outcome = "stuck"; + + // Poll for a real prompt WITHOUT sending any input. + while (Date.now() - t0 < PROMPT_TIMEOUT) { + if ((await page.locator(".xterm").count()) === 0) { + outcome = "gone"; // symptom B — the block unmounted mid-connect + break; + } + if (SHELL_PROMPT_RE.test(await readTerminalText(page))) { + outcome = "ok"; // symptom D — clean input-free connect + break; + } + await page.waitForTimeout(1_000); + } + + // If still blank, probe whether a keypress reveals the prompt. Enter + // fixing it == symptom A (connected, initial prompt never rendered); + // Enter NOT fixing it == symptom C (never really connected). + if (outcome === "stuck" && (await page.locator(".xterm").count()) > 0) { + await page.locator(".xterm-screen").first().click(); + await page.waitForTimeout(200); + await page.keyboard.press("Enter"); + const t1 = Date.now(); + while (Date.now() - t1 < 8_000) { + if (SHELL_PROMPT_RE.test(await readTerminalText(page))) { + outcome = "ok-after-enter"; + break; + } + await page.waitForTimeout(500); + } + } + + results.push({ i, outcome, ms: Date.now() - t0 }); + + // Release the VM/session before the next iteration so only one is live. + try { + await api.client.deleteDashboard(id); + } catch { + // auto-cleanup in the dashboard fixture will catch it at teardown + } + } + + // Report a rate — the whole point is to measure, not just pass/fail once. + const tally = results.reduce>((a, r) => { + a[r.outcome] = (a[r.outcome] || 0) + 1; + return a; + }, {}); + const clean = results.filter((r) => r.outcome === "ok").length; + // eslint-disable-next-line no-console + console.log( + `[first-connect] ${clean}/${ITERATIONS} clean input-free connects; tally=${JSON.stringify( + tally + )}` + ); + // eslint-disable-next-line no-console + console.table(results); + + // Every non-"ok" outcome is a first-PTY UX failure (A/B/C all count). + const failures = results.filter((r) => r.outcome !== "ok"); + expect( + failures, + `first-PTY flakes (${failures.length}/${ITERATIONS}): ${JSON.stringify( + tally + )}` + ).toHaveLength(0); + }); +}); From 3ce76b3e58bc65e9942800ee70e47aa62ecdc91f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sat, 11 Jul 2026 13:38:48 +0100 Subject: [PATCH 3/5] test(e2e): robust first-connect loop + derive CONTROLPLANE_URL from ORCABOT_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two harness fixes surfaced while running recipe 09 against a live stack: - Recipe 09 aborted after iteration 0: createDashboard's guard is `!url.includes("/dashboards")`, but after the per-iteration API delete the browser sits on a stale /dashboards/ detail URL, so it never returned to the template picker and timed out on the "Blank" card. Now each iteration navigates to /dashboards first, and the per-iteration body is wrapped in try/catch/finally so one bad iteration records an "error" outcome instead of nuking the whole measurement (and losing the tally). - ORCABOT_URL only set the browser baseURL; devModeLogin and the api/cleanup fixtures hardcoded a localhost:8787 default, so pointing ORCABOT_URL at a hosted instance silently sent auth/API calls to localhost (connection refused). Add helpers/controlplane-url.ts, which derives the control-plane URL from ORCABOT_URL using the same host->API mapping as frontend/src/config/env.ts (localhost->:8787, dev.orcabot.com-> api.dev.orcabot.com, orcabot.com->workers.dev). CONTROLPLANE_URL still overrides. One knob now works. Baseline: 20/20 clean (tally={"ok":20}, ~3s each) against a local stack — the flake is latency-driven and does not reproduce on localhost, as expected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBxVYcEidYfRm5Gf4j7nPL --- e2e/fixtures/auth.ts | 8 +- e2e/fixtures/base.ts | 6 +- e2e/helpers/controlplane-url.ts | 42 +++++++++ e2e/recipes/09-terminal-first-connect.spec.ts | 87 +++++++++++-------- 4 files changed, 99 insertions(+), 44 deletions(-) create mode 100644 e2e/helpers/controlplane-url.ts diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index a72353a5..77dc59eb 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,16 +1,10 @@ import { type Page, type BrowserContext, expect } from "@playwright/test"; import { generateUserId } from "../helpers/api"; +import { CONTROLPLANE_URL } from "../helpers/controlplane-url"; const DEFAULT_NAME = process.env.E2E_USER_NAME || "E2E Test User"; const DEFAULT_EMAIL = process.env.E2E_USER_EMAIL || "e2e-test@orcabot.test"; -/** - * Control plane URL for API calls. - * Default: localhost:8787 (matches frontend/src/config/env.ts for localhost target). - */ -const CONTROLPLANE_URL = - process.env.CONTROLPLANE_URL || "http://localhost:8787"; - /** * Log in by creating a server-side session directly via the control plane API, * then injecting the session cookie and auth state into the browser. diff --git a/e2e/fixtures/base.ts b/e2e/fixtures/base.ts index d44edf7b..41f184cc 100644 --- a/e2e/fixtures/base.ts +++ b/e2e/fixtures/base.ts @@ -12,6 +12,7 @@ import { waitForOutput, } from "./terminal"; import { OrcabotAPI } from "../helpers/api"; +import { CONTROLPLANE_URL } from "../helpers/controlplane-url"; /** Bundled auth helpers available in every test */ export interface AuthFixture { @@ -77,7 +78,7 @@ export const test = base.extend<{ }); // Auto-cleanup: attempt API-based delete for all tracked dashboards - const cpUrl = process.env.CONTROLPLANE_URL || "http://localhost:8787"; + const cpUrl = CONTROLPLANE_URL; if (trackedIds.length > 0) { const email = process.env.E2E_USER_EMAIL || "e2e-test@orcabot.test"; @@ -105,8 +106,7 @@ export const test = base.extend<{ api: async ({ page }, use) => { const email = process.env.E2E_USER_EMAIL || "e2e-test@orcabot.test"; const name = process.env.E2E_USER_NAME || "E2E Test User"; - const cpUrl = process.env.CONTROLPLANE_URL || "http://localhost:8787"; - const client = new OrcabotAPI(page.request, cpUrl, email, name); + const client = new OrcabotAPI(page.request, CONTROLPLANE_URL, email, name); await use({ client }); }, }); diff --git a/e2e/helpers/controlplane-url.ts b/e2e/helpers/controlplane-url.ts new file mode 100644 index 00000000..25bdaa86 --- /dev/null +++ b/e2e/helpers/controlplane-url.ts @@ -0,0 +1,42 @@ +/** + * Resolve the control-plane base URL for the harness. + * + * The browser target is `ORCABOT_URL` (the frontend origin), but the control + * plane is a *different* origin on the hosted instances — mirroring + * frontend/src/config/env.ts's `API_URL_BY_TARGET`: + * localhost:3000 -> http://localhost:8787 + * dev.orcabot.com -> https://api.dev.orcabot.com + * orcabot.com -> https://orcabot-controlplane.orcabot.workers.dev + * + * `devModeLogin` and the `api` fixture used to hardcode a `localhost:8787` + * default, so pointing only `ORCABOT_URL` at a hosted instance silently sent + * auth/API calls to localhost (connection refused). Deriving the control-plane + * URL from `ORCABOT_URL` makes a single knob work; `CONTROLPLANE_URL` still + * overrides for anything non-standard (e.g. a self-hosted split origin). + */ +export function deriveControlPlaneUrl(siteUrl?: string): string { + if (!siteUrl) return "http://localhost:8787"; + + let url: URL; + try { + url = new URL(siteUrl); + } catch { + return "http://localhost:8787"; + } + + const host = url.hostname; + if (host === "localhost" || host === "127.0.0.1" || host === "::1") { + return "http://localhost:8787"; + } + if (host === "dev.orcabot.com") return "https://api.dev.orcabot.com"; + if (host === "orcabot.com" || host === "www.orcabot.com") { + return "https://orcabot-controlplane.orcabot.workers.dev"; + } + // Unknown host: assume the control plane is same-origin. Override with + // CONTROLPLANE_URL if it lives elsewhere. + return url.origin; +} + +/** The resolved control-plane URL: explicit override, else derived from ORCABOT_URL. */ +export const CONTROLPLANE_URL = + process.env.CONTROLPLANE_URL || deriveControlPlaneUrl(process.env.ORCABOT_URL); diff --git a/e2e/recipes/09-terminal-first-connect.spec.ts b/e2e/recipes/09-terminal-first-connect.spec.ts index 0a5df2f1..84832c26 100644 --- a/e2e/recipes/09-terminal-first-connect.spec.ts +++ b/e2e/recipes/09-terminal-first-connect.spec.ts @@ -33,7 +33,7 @@ import { readTerminalText, SHELL_PROMPT_RE } from "../fixtures/terminal"; const ITERATIONS = Number(process.env.FIRST_CONNECT_ITERATIONS || 10); const PROMPT_TIMEOUT = Number(process.env.FIRST_CONNECT_TIMEOUT_MS || 45_000); -type Outcome = "ok" | "ok-after-enter" | "stuck" | "gone"; +type Outcome = "ok" | "ok-after-enter" | "stuck" | "gone" | "error"; test.describe("Recipe: First-PTY connect (flake loop)", () => { test.beforeEach(async ({ page, auth }) => { @@ -54,50 +54,69 @@ test.describe("Recipe: First-PTY connect (flake loop)", () => { const results: { i: number; outcome: Outcome; ms: number }[] = []; for (let i = 0; i < ITERATIONS; i++) { - const id = await dashboard.create(`E2E-FirstConnect-${i}-${Date.now()}`); - await terminal.add(); - const t0 = Date.now(); let outcome: Outcome = "stuck"; + let id = ""; - // Poll for a real prompt WITHOUT sending any input. - while (Date.now() - t0 < PROMPT_TIMEOUT) { - if ((await page.locator(".xterm").count()) === 0) { - outcome = "gone"; // symptom B — the block unmounted mid-connect - break; - } - if (SHELL_PROMPT_RE.test(await readTerminalText(page))) { - outcome = "ok"; // symptom D — clean input-free connect - break; - } - await page.waitForTimeout(1_000); - } + try { + // Always return to the dashboards list first. After an API delete the + // browser is left on a stale /dashboards/ URL, and createDashboard's + // guard (`!url.includes("/dashboards")`) treats that as "already on the + // list" and never re-navigates to the template picker. + await page.goto("/dashboards"); + id = await dashboard.create(`E2E-FirstConnect-${i}-${Date.now()}`); + await terminal.add(); - // If still blank, probe whether a keypress reveals the prompt. Enter - // fixing it == symptom A (connected, initial prompt never rendered); - // Enter NOT fixing it == symptom C (never really connected). - if (outcome === "stuck" && (await page.locator(".xterm").count()) > 0) { - await page.locator(".xterm-screen").first().click(); - await page.waitForTimeout(200); - await page.keyboard.press("Enter"); - const t1 = Date.now(); - while (Date.now() - t1 < 8_000) { + // Poll for a real prompt WITHOUT sending any input. + while (Date.now() - t0 < PROMPT_TIMEOUT) { + if ((await page.locator(".xterm").count()) === 0) { + outcome = "gone"; // symptom B — the block unmounted mid-connect + break; + } if (SHELL_PROMPT_RE.test(await readTerminalText(page))) { - outcome = "ok-after-enter"; + outcome = "ok"; // symptom D — clean input-free connect break; } - await page.waitForTimeout(500); + await page.waitForTimeout(1_000); + } + + // If still blank, probe whether a keypress reveals the prompt. Enter + // fixing it == symptom A (connected, initial prompt never rendered); + // Enter NOT fixing it == symptom C (never really connected). + if (outcome === "stuck" && (await page.locator(".xterm").count()) > 0) { + await page.locator(".xterm-screen").first().click(); + await page.waitForTimeout(200); + await page.keyboard.press("Enter"); + const t1 = Date.now(); + while (Date.now() - t1 < 8_000) { + if (SHELL_PROMPT_RE.test(await readTerminalText(page))) { + outcome = "ok-after-enter"; + break; + } + await page.waitForTimeout(500); + } + } + } catch (err) { + // One bad iteration must not abort the whole measurement. + outcome = "error"; + // eslint-disable-next-line no-console + console.log( + `[first-connect] iter ${i} threw: ${ + (err as Error).message.split("\n")[0] + }` + ); + } finally { + // Release the VM/session before the next iteration so only one is live. + if (id) { + try { + await api.client.deleteDashboard(id); + } catch { + // dashboard-fixture auto-cleanup catches it at teardown + } } } results.push({ i, outcome, ms: Date.now() - t0 }); - - // Release the VM/session before the next iteration so only one is live. - try { - await api.client.deleteDashboard(id); - } catch { - // auto-cleanup in the dashboard fixture will catch it at teardown - } } // Report a rate — the whole point is to measure, not just pass/fail once. From 9a535788e0af80ba9f5ab94ec972d64e83fc04c2 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 00:11:44 +0100 Subject: [PATCH 4/5] fix(controlplane): stop killing fresh PTY sessions on the WS-connect readiness race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-PTY flake ("stuck on Connecting…", terminal disappears, or never resolves) is a race in the PTY WebSocket proxy, confirmed from dev logs: `[ws-proxy] upstream PTY WS failed status=404` fires when the browser opens the PTY WS before the sandbox has registered the PTY (right after createPty, or while an autostop:suspend machine resumes). The upstream 404s, then succeeds ~seconds later. proxySandboxWebSocket made it worse: on a 404 it *unconditionally* marked the session `stopped` in D1 — and this ran BEFORE the caller's 30s creation-age grace period (index.ts:2934), so the grace check was dead code. Every readiness-race 404 killed the just-created session, which the frontend then reconciles away (disappearing block) or recreates (stuck/churn). Two changes, both in proxySandboxWebSocket: - Remove the premature reconcile. A not-ready-yet 404 is indistinguishable from a dead-PTY 404 at this layer; the sole caller already reconciles WITH the grace period, so this inner UPDATE was redundant and harmful. - Add a bounded readiness retry: re-attempt the bodyless upgrade up to 6×500ms (~3s) on a 404 before giving up, so the client gets a clean 101 instead of a 404-triggered recreate. Log `attempts=` on both success and failure so the race is observable in `wrangler tail`. Verify on dev: the ws-proxy lines should now show `upstream PTY WS upgraded attempts=N` (N>0) where they previously showed `failed status=404`. Local (no provisioning lag) stays attempts=0. Covered end-to-end by e2e recipe 09. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBxVYcEidYfRm5Gf4j7nPL --- controlplane/src/index.ts | 42 +++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 5840d3ad..24b228d0 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -364,34 +364,42 @@ async function prоxySandbоxWebSоcket( const body = ['POST', 'PUT', 'PATCH'].includes(request.method) ? request.clone().body : undefined; - const proxyRequest = new Request(sandboxUrlValue.toString(), { + const makeRequest = () => new Request(sandboxUrlValue.toString(), { method: request.method, headers, body, redirect: 'manual', }); - const response = await fetch(proxyRequest); + // REVISION: ws-proxy-v2-readiness-retry-no-premature-reconcile + // Readiness retry: right after createPty — or when an autostop:suspend machine + // is resuming — the PTY isn't registered on the sandbox yet, so the upstream + // upgrade 404s. That's a *timing* miss, not a dead PTY. Retry the upgrade for a + // few seconds so the client gets a clean 101 instead of a 404 that triggers a + // disruptive session recreate (the "stuck connecting" / "disappearing terminal" + // first-PTY flake). Only a bodyless (GET) upgrade is safe to replay. + let response = await fetch(makeRequest()); + let attempts = 0; + const maxRetries = body ? 0 : 6; // ~3s total (6 × 500ms) + while (response.status === 404 && attempts < maxRetries) { + attempts++; + await new Promise((resolve) => setTimeout(resolve, 500)); + response = await fetch(makeRequest()); + } + if (response.status !== 101) { console.warn( - `[ws-proxy] upstream PTY WS failed status=${response.status} dashboardPath=${requestUrl.pathname} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}` + `[ws-proxy] upstream PTY WS failed status=${response.status} attempts=${attempts} dashboardPath=${requestUrl.pathname} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}` ); - // Reconcile: the sandbox no longer has this PTY (process died, or the VM was - // restarted), but D1 may still show the session 'active' ("ghost" session → - // stale 'running' status + "stuck connecting"). Mark it stopped so the client - // recreates instead of hanging, and listings reflect reality. Best-effort. - if (response.status === 404) { - try { - await env.DB.prepare( - `UPDATE sessions SET status = 'stopped', stopped_at = ? WHERE pty_id = ? AND status != 'stopped'` - ).bind(new Date().toISOString(), ptyId).run(); - } catch { - // ignore — reconciliation is best-effort - } - } + // Do NOT reconcile-to-stopped here. A 404 that's a genuinely dead PTY vs. one + // that's merely not-ready-yet is indistinguishable at this layer, and the sole + // caller already reconciles WITH a creation-age grace period. The previous + // unconditional UPDATE ran *before* that grace check and marked freshly-created + // sessions stopped during the provisioning race — defeating the grace period + // and causing the first-PTY disappear/stuck bug. } else { console.log( - `[ws-proxy] upstream PTY WS upgraded sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}` + `[ws-proxy] upstream PTY WS upgraded attempts=${attempts} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}` ); } return response; From 58e3310a94c1c46c27040d978643eb4215062dd4 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 01:14:30 +0100 Subject: [PATCH 5/5] chore(desktop): commit VM image manifest bump to v2 vm-image.json (embedded into the desktop binary via include_str! to pick the download-on-demand VM image) was left uncommitted at v2 while HEAD still pointed at v1. The v2 image is published (releases/vm-image-v2), staged, and passed the browser smoke test (chromium 150.0.7871.100, the good build); the sha256 matches the published artifact. Committing so clean builds / CI / the next desktop release embed v2 instead of silently regressing to the old v1 image. Must be in before any desktop build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBxVYcEidYfRm5Gf4j7nPL --- desktop/app/src-tauri/vm-image.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/app/src-tauri/vm-image.json b/desktop/app/src-tauri/vm-image.json index 1154fe5a..306a2f06 100644 --- a/desktop/app/src-tauri/vm-image.json +++ b/desktop/app/src-tauri/vm-image.json @@ -1,5 +1,5 @@ { - "version": "v1", - "sha256": "d5fb4c823f7e92020c9d85a2351898a10162e44400f4b44d92454252f2b43caa", - "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v1/sandbox.img.gz" + "version": "v2", + "sha256": "eba0a9e5c17778f5de08371dd9bcb46aa3697a827d8c0ed7c801a465c907f633", + "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v2/sandbox.img.gz" }