From 5192912b68119fef6afce99e194b0fc46a780c13 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:17:54 -0800 Subject: [PATCH] fix: auto-update silently fails in multiple scenarios (#1725) - Prevent recursive update check during install (SPAWN_NO_UPDATE_CHECK=1) - Increase fetch timeout from 5s to 10s for slow/cold connections - Add 1-hour failure backoff to avoid repeated failed update attempts - Bump CLI version to 0.6.6 Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) --- cli/install.sh | 2 +- cli/src/__tests__/update-check.test.ts | 12 +++++++ cli/src/update-check.ts | 50 ++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cli/install.sh b/cli/install.sh index aeeaf0bd..7ab6b17c 100755 --- a/cli/install.sh +++ b/cli/install.sh @@ -99,7 +99,7 @@ ensure_in_path() { local install_dir="$1" if echo "${PATH}" | tr ':' '\n' | grep -qx "${install_dir}"; then echo "" - "${install_dir}/spawn" version + SPAWN_NO_UPDATE_CHECK=1 "${install_dir}/spawn" version echo "" printf "${GREEN}[spawn]${NC} Run ${BOLD}spawn${NC} to get started\n" else diff --git a/cli/src/__tests__/update-check.test.ts b/cli/src/__tests__/update-check.test.ts index 1ab49ddd..9cac75c3 100644 --- a/cli/src/__tests__/update-check.test.ts +++ b/cli/src/__tests__/update-check.test.ts @@ -1,7 +1,18 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import fs from "fs"; +import path from "path"; // ── Test Helpers ─────────────────────────────────────────────────────────────── +/** Remove the .update-failed backoff file so it doesn't interfere with tests */ +function clearUpdateBackoff() { + try { + fs.unlinkSync(path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed")); + } catch { + // File may not exist + } +} + function mockEnv() { const originalEnv = { ...process.env }; process.env.NODE_ENV = undefined; @@ -23,6 +34,7 @@ describe("update-check", () => { beforeEach(() => { originalEnv = mockEnv(); + clearUpdateBackoff(); consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); // Mock process.exit to prevent tests from exiting processExitSpy = spyOn(process, "exit").mockImplementation((() => {}) as any); diff --git a/cli/src/update-check.ts b/cli/src/update-check.ts index ac001af6..4cd257bd 100644 --- a/cli/src/update-check.ts +++ b/cli/src/update-check.ts @@ -1,5 +1,7 @@ import "./unicode-detect.js"; // Ensure TERM is set before using symbols import { execSync as nodeExecSync, execFileSync as nodeExecFileSync, type ExecSyncOptions, type ExecFileSyncOptions } from "child_process"; +import fs from "fs"; +import path from "path"; import pc from "picocolors"; import pkg from "../package.json" with { type: "json" }; import { RAW_BASE } from "./manifest.js"; @@ -14,7 +16,8 @@ export const executor = { // ── Constants ────────────────────────────────────────────────────────────────── -const FETCH_TIMEOUT = 5000; // 5 seconds +const FETCH_TIMEOUT = 10000; // 10 seconds +const UPDATE_BACKOFF_MS = 60 * 60 * 1000; // 1 hour // Validate RAW_BASE matches expected GitHub raw content URL pattern (defense-in-depth, CWE-78) const GITHUB_RAW_URL_PATTERN = /^https:\/\/raw\.githubusercontent\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; @@ -59,6 +62,42 @@ function compareVersions(current: string, latest: string): boolean { return false; // Versions are equal } +// ── Failure Backoff ────────────────────────────────────────────────────────── + +function getUpdateFailedPath(): string { + return path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed"); +} + +export function isUpdateBackedOff(): boolean { + try { + const failedPath = getUpdateFailedPath(); + const content = fs.readFileSync(failedPath, "utf8").trim(); + const failedAt = parseInt(content, 10); + if (isNaN(failedAt)) return false; + return Date.now() - failedAt < UPDATE_BACKOFF_MS; + } catch { + return false; + } +} + +function markUpdateFailed(): void { + try { + const failedPath = getUpdateFailedPath(); + fs.mkdirSync(path.dirname(failedPath), { recursive: true }); + fs.writeFileSync(failedPath, String(Date.now())); + } catch { + // Best-effort — don't break the CLI if we can't write the file + } +} + +function clearUpdateFailed(): void { + try { + fs.unlinkSync(getUpdateFailedPath()); + } catch { + // File may not exist — that's fine + } +} + /** Print boxed update banner to stderr */ function printUpdateBanner(latestVersion: string): void { const line1 = `Update available: v${VERSION} -> v${latestVersion}`; @@ -143,8 +182,10 @@ function performAutoUpdate(latestVersion: string): void { console.error(); console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`))); + clearUpdateFailed(); reExecWithArgs(); } catch { + markUpdateFailed(); console.error(); console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`))); console.error(pc.dim(" Please update manually:")); @@ -159,7 +200,7 @@ function performAutoUpdate(latestVersion: string): void { /** * Check for updates on every run and auto-update if available. - * Uses a 5-second timeout to avoid blocking for too long. + * Uses a 10-second timeout to avoid blocking for too long. */ export async function checkForUpdates(): Promise { // Skip in test environment @@ -172,6 +213,11 @@ export async function checkForUpdates(): Promise { return; } + // Skip if a recent auto-update failed (backoff for 1 hour) + if (isUpdateBackedOff()) { + return; + } + // Always fetch the latest version on every run try { const latestVersion = await fetchLatestVersion();