diff --git a/cli/package.json b/cli/package.json index 32ed8e460d..96fbef963c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -83,6 +83,7 @@ "test:build-zip-filter": "bun test/test-build-zip-filter.mjs", "test:checksum": "bun test/test-checksum-algorithm.mjs", "test:build-needed": "bun test/test-build-needed.mjs", + "test:build-cancellation": "bun test/test-build-cancellation.mjs", "test:ci-prompts": "bun test/test-ci-prompts.mjs", "test:ci-secrets": "bun test/test-ci-secrets.mjs", "test:android-onboarding-progress": "bun test/test-android-onboarding-progress.mjs", @@ -174,7 +175,7 @@ "test:android-version": "bun test/test-android-version.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:builder-project-discovery": "bun test/test-builder-project-discovery.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", diff --git a/cli/src/build/cancellation.ts b/cli/src/build/cancellation.ts new file mode 100644 index 0000000000..4959d30a83 --- /dev/null +++ b/cli/src/build/cancellation.ts @@ -0,0 +1,73 @@ +export type BuildCancellationResult + = | { ok: true } + | { ok: false, message: string } + +// npx can deliver one physical Ctrl+C to the CLI twice: once through the +// foreground process group and once when its wrapper forwards SIGINT. +export const DUPLICATE_SIGINT_WINDOW_MS = 500 + +export interface BuildCancellationRequestOptions { + url: string + headers: Record + appId: string + fetchImpl?: typeof fetch + timeoutMs?: number +} + +export async function requestBuildCancellation(options: BuildCancellationRequestOptions): Promise { + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 4_000) + + try { + const response = await (options.fetchImpl ?? fetch)(options.url, { + method: 'POST', + headers: options.headers, + body: JSON.stringify({ app_id: options.appId }), + signal: abortController.signal, + }) + if (!response.ok) { + const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}` + return { ok: false, message: `Build cancellation request failed: HTTP ${status}` } + } + return { ok: true } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, message: `Build cancellation request failed: ${message}` } + } + finally { + clearTimeout(timeout) + } +} + +export interface BuildCancellationSignalHandlerOptions { + requestCancellation: () => Promise + onCancellationStarted: () => void + onCancellationResult: (result: BuildCancellationResult) => void + abortLogStream: () => void + forceExit: (code: number) => void + now?: () => number + duplicateSignalWindowMs?: number +} + +export function createBuildCancellationSignalHandler(options: BuildCancellationSignalHandlerOptions): () => Promise { + const now = options.now ?? Date.now + const duplicateSignalWindowMs = options.duplicateSignalWindowMs ?? DUPLICATE_SIGINT_WINDOW_MS + let firstSignalAt: number | null = null + + return async () => { + const signalAt = now() + if (firstSignalAt !== null) { + if (signalAt - firstSignalAt <= duplicateSignalWindowMs) + return + options.forceExit(1) + return + } + + firstSignalAt = signalAt + options.onCancellationStarted() + const result = await options.requestCancellation() + options.onCancellationResult(result) + options.abortLogStream() + } +} diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index 9123e2107b..09d22ab6cd 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -68,6 +68,7 @@ import { offerSupportUploadBeforeAi } from '../support/support-upload-prompt.js' import { buildCliRequestHeaders } from '../analytics/cli-headers' import { assertCliPermission, canPromptInteractively, createSupabaseClient, findSavedKey, getConfig, getOrganizationId, getRemoteConfig, sendEvent, trimTrailingSlashes, TUS_UPLOAD_RETRY_DELAYS } from '../utils' import { syncAndroidVersion } from './android-version' +import { createBuildCancellationSignalHandler, requestBuildCancellation } from './cancellation' import { mergeCredentials, MIN_OUTPUT_RETENTION_SECONDS, parseAndroidPlayStoreReleaseStatus, parseAndroidPlayStoreTrack, parseInAppUpdatePriority, parseOptionalBoolean, parseOutputRetentionSeconds } from './credentials' import { buildProvisioningMap } from './credentials-command' import { withCwd } from './cwd' @@ -2212,46 +2213,29 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO log.info('Streaming build logs...') const abortController = new AbortController() - let cancelRequested = false - const cancelBuild = async () => { - if (cancelRequested) - return - cancelRequested = true - const cancelAbort = new AbortController() - const timeout = setTimeout(() => cancelAbort.abort(), 4000) - try { - await fetch(`${host}/build/cancel/${buildRequest.job_id}`, { - method: 'POST', - headers: buildCliRequestHeaders({ - 'Content-Type': 'application/json', - authorization: options.apikey, - }), - body: JSON.stringify({ app_id: appId }), - signal: cancelAbort.signal, - }) - } - catch (err) { - appendInternalLog(`build cancel request errored (ignored): ${err instanceof Error ? err.message : String(err)}`) - // ignore cancellation errors - } - finally { - clearTimeout(timeout) - } - } - - const onSigint = async () => { - try { - if (cancelRequested) { - process.exit(1) - } + const onSigint = createBuildCancellationSignalHandler({ + requestCancellation: () => requestBuildCancellation({ + url: `${host}/build/cancel/${buildRequest.job_id}`, + headers: buildCliRequestHeaders({ + 'Content-Type': 'application/json', + authorization: options.apikey, + }), + appId, + }), + onCancellationStarted: () => { log.warn('Canceling build... (press Ctrl+C again to force quit)') - await cancelBuild() - abortController.abort() - } - catch { - // Prevent unhandled rejection from crashing the process - } - } + }, + onCancellationResult: (result) => { + if (result.ok) { + log.success('Build cancellation requested.') + return + } + appendInternalLog(result.message) + log.warn(`${result.message}. The remote build may still be running.`) + }, + abortLogStream: () => abortController.abort(), + forceExit: code => process.exit(code), + }) process.on('SIGINT', onSigint) diff --git a/cli/test/test-build-cancellation.mjs b/cli/test/test-build-cancellation.mjs new file mode 100644 index 0000000000..60836da6eb --- /dev/null +++ b/cli/test/test-build-cancellation.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' + +console.log('๐Ÿงช Testing native build cancellation...\n') + +const cancellation = await import('../src/build/cancellation.ts').catch(() => null) + +let failures = 0 + +async function test(name, fn) { + try { + await fn() + console.log(`โœ… ${name}`) + } + catch (error) { + failures += 1 + console.error(`โŒ ${name}`) + console.error(error) + } +} + +await test('ignores an immediate duplicate SIGINT while cancellation is in flight', async () => { + assert.ok(cancellation, 'build cancellation helper has not been implemented') + + let resolveCancellation + const pendingCancellation = new Promise((resolve) => { + resolveCancellation = resolve + }) + let now = 1_000 + let started = 0 + let aborted = 0 + let forcedExits = 0 + + const onSigint = cancellation.createBuildCancellationSignalHandler({ + requestCancellation: async () => pendingCancellation, + onCancellationStarted: () => { started += 1 }, + onCancellationResult: () => {}, + abortLogStream: () => { aborted += 1 }, + forceExit: () => { forcedExits += 1 }, + now: () => now, + }) + + const firstSignal = onSigint() + await Promise.resolve() + now += 1 + await onSigint() + + assert.equal(started, 1, 'only the first signal starts cancellation') + assert.equal(forcedExits, 0, 'the duplicate signal must not force-exit the process') + assert.equal(aborted, 0, 'log streaming stays active until cancellation settles') + + resolveCancellation({ ok: true }) + await firstSignal + + assert.equal(aborted, 1, 'log streaming stops after cancellation settles') +}) + +await test('reports a rejected cancellation response instead of treating it as success', async () => { + assert.equal(typeof cancellation?.requestBuildCancellation, 'function', 'cancellation request helper has not been implemented') + + const result = await cancellation.requestBuildCancellation({ + url: 'https://api.capgo.app/build/cancel/job_test', + headers: { authorization: 'test-key' }, + appId: 'com.demo.app', + fetchImpl: async () => new Response(null, { status: 401, statusText: 'Unauthorized' }), + }) + + assert.deepEqual(result, { + ok: false, + message: 'Build cancellation request failed: HTTP 401 Unauthorized', + }) +}) + +await test('allows a later SIGINT to force-quit a stuck cancellation', async () => { + let now = 1_000 + let forcedExitCode = null + + const onSigint = cancellation.createBuildCancellationSignalHandler({ + requestCancellation: async () => new Promise(() => {}), + onCancellationStarted: () => {}, + onCancellationResult: () => {}, + abortLogStream: () => {}, + forceExit: (code) => { forcedExitCode = code }, + now: () => now, + duplicateSignalWindowMs: 500, + }) + + void onSigint() + now += 501 + await onSigint() + + assert.equal(forcedExitCode, 1) +}) + +await test('sends the cancellation request with the app id and reports success', async () => { + let capturedUrl = null + let capturedInit = null + + const result = await cancellation.requestBuildCancellation({ + url: 'https://api.capgo.app/build/cancel/job_test', + headers: { authorization: 'test-key' }, + appId: 'com.demo.app', + fetchImpl: async (url, init) => { + capturedUrl = url + capturedInit = init + return new Response(null, { status: 200 }) + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(capturedUrl, 'https://api.capgo.app/build/cancel/job_test') + assert.equal(capturedInit.method, 'POST') + assert.equal(capturedInit.body, JSON.stringify({ app_id: 'com.demo.app' })) +}) + +if (failures > 0) { + console.error(`\nโŒ ${failures} build cancellation test(s) failed`) + process.exit(1) +} + +console.log('\nโœ… Native build cancellation behaves correctly')