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; 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\"", 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" } 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/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/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/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 new file mode 100644 index 00000000..84832c26 --- /dev/null +++ b/e2e/recipes/09-terminal-first-connect.spec.ts @@ -0,0 +1,146 @@ +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" | "error"; + +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 t0 = Date.now(); + let outcome: Outcome = "stuck"; + let id = ""; + + 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(); + + // 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); + } + } + } 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 }); + } + + // 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); + }); +});