From c3db07f1f255ed42afe3fc6c74a338ff235f28fd Mon Sep 17 00:00:00 2001 From: 0xfandom <50949929+0xfandom@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:29:49 +0530 Subject: [PATCH] feat(codex-oauth): manual callback URL paste for SSH / remote sessions (#1288) (#1414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codex-oauth): manual callback paste for SSH / remote sessions Codex OAuth required the browser to reach the openclaude host's localhost:1455 callback. On SSH / containerized installs that callback resolves to the user's workstation instead of the openclaude host, so the redirect lands on a dead URL and the CLI hangs. Add a manual-paste fallback (mirrors the xAI OAuth recovery path): after authorizing in the browser, the user copies the full redirected URL from the address bar and pastes it into the CLI. CodexOAuthService validates the state parameter against the in-flight flow, races the manual code against the loopback listener, and reuses the same authorization-code → token exchange. SSH_CONNECTION / SSH_CLIENT triggers a warning banner explaining why the loopback redirect failed; non-SSH sessions get a dim hint covering containerized / remote setups. Closes #1288 * test(codex-oauth): type manual-paste fetch mock via asMockFetch The raw `mock(...) as typeof fetch` cast no longer typechecks against the base branch's stricter `fetch` type (now requires `preconnect`). Route the manual-callback-paste test's mock through the shared `asMockFetch` helper, matching the other fetch mocks in this file. * fix(codex-oauth): mask the pasted manual callback URL input The manual-recovery field echoed the redirected callback URL verbatim, which carries the OAuth code and state query params — enough to complete the in-flight exchange. Mask it with mask="*", matching the adjacent xAI manual-code field, so it stays out of terminal scrollback, recordings, and shared sessions. * test(codex-oauth): bound state wait and cover hook manual-callback contract - Bound the while (!capturedState) wait in the manual-callback test with a 5s deadline so a regression fails with a clear assertion instead of hanging the suite. - Add a useCodexOAuthFlow test asserting the waiting status exposes submitManualCallback and delegates both success and failure results from the service back to the caller. * test(codex-oauth): stabilize onAuthenticated in the manual-callback test The new hook test passed a fresh inline `onAuthenticated: async () => {}` on every render, so the hook effect re-ran each render, restarting the flow and looping setStatus → render ("Maximum update depth exceeded" when run alongside ProviderManager.test.tsx). Hoist the callback to a stable reference, matching the other tests in the file. * test(codex-oauth): cover the ProviderManager manual-callback UI Add focused coverage for the waiting-state paste surface: the masked callback input renders, a good callback delegates to status.submitManualCallback with no inline error, the SSH banner appears when SSH_CONNECTION is set, and a rejected callback surfaces the hook's inline error. --- src/components/ProviderManager.test.tsx | 141 ++++++++++++++++++++++ src/components/ProviderManager.tsx | 66 ++++++++++ src/components/useCodexOAuthFlow.test.tsx | 95 +++++++++++++++ src/components/useCodexOAuthFlow.ts | 22 ++-- src/services/api/codexOAuth.test.ts | 115 ++++++++++++++++++ src/services/api/codexOAuth.ts | 110 ++++++++++++++++- 6 files changed, 540 insertions(+), 9 deletions(-) diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 22e8c6a64..d596fed64 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -342,6 +342,10 @@ function mockProviderManagerDependencies( authUrl?: string browserOpened?: boolean | null message?: string + submitManualCallback?: (input: string) => { + ok: boolean + error?: string + } } }, ): void { @@ -1586,6 +1590,143 @@ test('ProviderManager first-run Codex OAuth switches the current session after l await mounted.dispose() }) +test('ProviderManager Codex OAuth waiting state masks the paste field and delegates a good callback', async () => { + delete process.env.CLAUDE_CODE_SIMPLE + delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.GITHUB_TOKEN + delete process.env.GH_TOKEN + delete process.env.SSH_CONNECTION + delete process.env.SSH_CLIENT + + const onDone = mock(() => {}) + const submitManualCallback = mock((_input: string) => ({ ok: true })) + + mockProviderManagerDependencies( + () => undefined, + async () => undefined, + { + // Stay in `waiting` (never call onAuthenticated) so the manual-paste UI + // renders. The hook returns a spy submitManualCallback. + useCodexOAuthFlow: () => ({ + state: 'waiting', + authUrl: 'https://chatgpt.com/codex', + browserOpened: true, + submitManualCallback, + }), + }, + ) + + const nonce = `${Date.now()}-${Math.random()}` + const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) + const mounted = await mountProviderManager(ProviderManager, { + mode: 'first-run', + onDone, + }) + + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'), + ) + + await navigateToPreset(mounted.stdin, 'Codex OAuth') + mounted.stdin.write('\r') + + // Non-SSH session shows the generic "paste the callback URL" hint and the input. + await waitForFrameOutput( + mounted.getOutput, + frame => + frame.includes('Callback URL') && + frame.includes('paste the full callback URL'), + ) + + const callbackUrl = + 'http://localhost:41100/auth/callback?code=goodsecret&state=s' + mounted.stdin.write(callbackUrl) + // The pasted secret must be masked — the raw code must never reach the frame. + await waitForFrameOutput( + mounted.getOutput, + frame => !frame.includes('goodsecret') && frame.includes('Callback URL'), + ) + + mounted.stdin.write('\r') + await waitForCondition(() => submitManualCallback.mock.calls.length > 0) + expect(submitManualCallback).toHaveBeenCalledWith(callbackUrl) + // A successful submit leaves no inline error on screen. + expect( + stripAnsi(extractLastFrame(mounted.getOutput())), + ).not.toContain('State mismatch') + + await mounted.dispose() +}) + +test('ProviderManager Codex OAuth waiting state shows the SSH banner and surfaces a bad-callback error', async () => { + delete process.env.CLAUDE_CODE_SIMPLE + delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.GITHUB_TOKEN + delete process.env.GH_TOKEN + process.env.SSH_CONNECTION = '10.0.0.1 22 10.0.0.2 22' + delete process.env.SSH_CLIENT + + const onDone = mock(() => {}) + const submitManualCallback = mock((_input: string) => ({ + ok: false, + error: 'State mismatch', + })) + + try { + mockProviderManagerDependencies( + () => undefined, + async () => undefined, + { + useCodexOAuthFlow: () => ({ + state: 'waiting', + authUrl: 'https://chatgpt.com/codex', + browserOpened: true, + submitManualCallback, + }), + }, + ) + + const nonce = `${Date.now()}-${Math.random()}` + const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) + const mounted = await mountProviderManager(ProviderManager, { + mode: 'first-run', + onDone, + }) + + await waitForFrameOutput( + mounted.getOutput, + frame => + frame.includes('Set up provider') && frame.includes('Codex OAuth'), + ) + + await navigateToPreset(mounted.stdin, 'Codex OAuth') + mounted.stdin.write('\r') + + // SSH session shows the dedicated banner instead of the generic hint. + await waitForFrameOutput( + mounted.getOutput, + frame => + frame.includes('SSH session detected') && + frame.includes('Callback URL'), + ) + + mounted.stdin.write('http://localhost:41100/auth/callback?code=x&state=s') + mounted.stdin.write('\r') + + // A rejected callback renders the inline error returned by the hook. + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('State mismatch'), + ) + expect(submitManualCallback).toHaveBeenCalledTimes(1) + + await mounted.dispose() + } finally { + delete process.env.SSH_CONNECTION + } +}) + test('ProviderManager first-run Codex OAuth surfaces credential storage warnings', async () => { delete process.env.CLAUDE_CODE_SIMPLE delete process.env.CLAUDE_CODE_USE_GITHUB diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 2b527a4df..0e5b5ad8d 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -602,6 +602,40 @@ function XaiManualCodeInput({ ) } +function CodexManualCallbackInput({ + onSubmit, +}: { + onSubmit: (input: string) => void +}): React.ReactNode { + const [value, setValue] = React.useState('') + const [cursorOffset, setCursorOffset] = React.useState(0) + const { columns: terminalColumns } = useTerminalSize() + const inputColumns = Math.max(20, Math.min(120, terminalColumns - 12)) + return ( + + Callback URL › + { + const trimmed = submitted.trim() + if (trimmed) onSubmit(trimmed) + }} + // The pasted callback URL carries the OAuth `code` and `state` query + // params — enough to complete the in-flight exchange — so mask it the + // same way the xAI manual-code field above does, to keep it out of + // terminal scrollback, recordings, and shared sessions. + mask="*" + // The parent `CodexOAuthSetup` owns Esc via `useKeybinding('confirm:no')`. + disableEscapeDoublePress + /> + + ) +} + function CodexOAuthSetup({ onBack, onConfigured, @@ -638,6 +672,10 @@ function CodexOAuthSetup({ const status = useCodexOAuthFlow({ onAuthenticated: handleAuthenticated, }) + const [pasteError, setPasteError] = React.useState() + const isRemoteSession = Boolean( + process.env['SSH_CONNECTION'] || process.env['SSH_CLIENT'], + ) if (status.state === 'error') { return ( @@ -693,6 +731,34 @@ function CodexOAuthSetup({ ) : ( Opening your browser... )} + {status.state === 'waiting' ? ( + <> + {isRemoteSession ? ( + + SSH session detected — the browser cannot reach this host's + localhost callback. After signing in, copy the full URL your + browser was redirected to (it starts with http://localhost:) and + paste it below. + + ) : ( + + If the browser cannot reach localhost (remote / containerized + session), paste the full callback URL it was redirected to: + + )} + { + const result = status.submitManualCallback(input) + if (!result.ok) { + setPasteError(result.error) + } else { + setPasteError(undefined) + } + }} + /> + {pasteError ? {pasteError} : null} + + ) : null} Press Esc to cancel and go back. ) diff --git a/src/components/useCodexOAuthFlow.test.tsx b/src/components/useCodexOAuthFlow.test.tsx index 3bd7eaf9e..662bd871f 100644 --- a/src/components/useCodexOAuthFlow.test.tsx +++ b/src/components/useCodexOAuthFlow.test.tsx @@ -122,6 +122,7 @@ test('does not persist credentials when downstream setup rejects', async () => { return TOKENS }, cleanup, + submitManualCallback: () => ({ ok: true as const }), }), openBrowser: async () => true, saveCodexCredentials, @@ -192,6 +193,7 @@ test('persists credentials with profile linkage after downstream setup succeeds' return TOKENS }, cleanup, + submitManualCallback: () => ({ ok: true as const }), }), openBrowser: async () => true, saveCodexCredentials, @@ -271,6 +273,7 @@ test('returns a successful storage warning without entering the error state', as return TOKENS }, cleanup, + submitManualCallback: () => ({ ok: true as const }), }), openBrowser: async () => true, saveCodexCredentials, @@ -343,6 +346,7 @@ test('reports credential persistence failures without token values', async () => return TOKENS }, cleanup, + submitManualCallback: () => ({ ok: true as const }), }), openBrowser: async () => true, saveCodexCredentials, @@ -389,3 +393,94 @@ test('reports credential persistence failures without token values', async () => await Bun.sleep(0) } }) + +test('exposes submitManualCallback in the waiting state and delegates success and failure results', async () => { + // The service's manual-callback result is what the ProviderManager UI relies + // on; verify the hook surfaces it in the waiting status and passes both the + // raw input and the service's result straight back to the caller. + const submitManualCallback = mock((input: string) => + input.includes('code=good') + ? ({ ok: true as const }) + : ({ ok: false as const, error: 'State mismatch' }), + ) + const cleanup = mock(() => {}) + const deps = { + createOAuthService: () => ({ + async startOAuthFlow( + onAuthorizationUrl: (authUrl: string) => void | Promise, + ) { + await onAuthorizationUrl('https://chatgpt.com/codex') + // Stay in the waiting state so the manual-callback path is reachable. + return new Promise(() => {}) + }, + cleanup, + submitManualCallback, + }), + openBrowser: async () => false, + saveCodexCredentials: mock(() => ({ success: true })), + isBareMode: () => false, + } + + const { useCodexOAuthFlow } = await import( + `./useCodexOAuthFlow.js?waiting-manual-${Date.now()}-${Math.random()}` + ) + + let latestStatus: + | { + state: string + submitManualCallback?: ( + input: string, + ) => { ok: boolean; error?: string } + } + | undefined + + // Stable reference: a fresh inline `onAuthenticated` on every render would + // re-run the hook's effect each render, restarting the OAuth flow and + // looping setStatus → render → setStatus ("Maximum update depth exceeded"). + // The other tests in this file memoize the callback for the same reason. + const onAuthenticated = async (): Promise => {} + + function Harness(): React.ReactNode { + const status = useCodexOAuthFlow({ + onAuthenticated, + deps, + }) + latestStatus = status as typeof latestStatus + return {status.state} + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => latestStatus?.state === 'waiting') + expect(latestStatus?.state).toBe('waiting') + expect(typeof latestStatus?.submitManualCallback).toBe('function') + + const okResult = latestStatus?.submitManualCallback?.( + 'http://localhost:41100/auth/callback?code=good&state=s', + ) + expect(okResult).toEqual({ ok: true }) + + const failResult = latestStatus?.submitManualCallback?.( + 'http://localhost:41100/auth/callback?code=bad&state=s', + ) + expect(failResult?.ok).toBe(false) + expect(failResult?.error).toBe('State mismatch') + + expect(submitManualCallback).toHaveBeenCalledTimes(2) + expect(submitManualCallback).toHaveBeenCalledWith( + 'http://localhost:41100/auth/callback?code=good&state=s', + ) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) diff --git a/src/components/useCodexOAuthFlow.ts b/src/components/useCodexOAuthFlow.ts index bacf38735..0261a51ba 100644 --- a/src/components/useCodexOAuthFlow.ts +++ b/src/components/useCodexOAuthFlow.ts @@ -2,6 +2,7 @@ import * as React from 'react' import { CodexOAuthService, + type CodexManualCallbackResult, type CodexOAuthTokens, } from '../services/api/codexOAuth.js' import { openBrowser } from '../utils/browser.js' @@ -14,6 +15,7 @@ export type CodexOAuthFlowStatus = state: 'waiting' authUrl: string browserOpened: boolean | null + submitManualCallback: (input: string) => CodexManualCallbackResult } | { state: 'error' @@ -24,20 +26,19 @@ type PersistCodexOAuthCredentials = (options?: { profileId?: string }) => { warning?: string } +type CodexOAuthServiceLike = Pick< + CodexOAuthService, + 'startOAuthFlow' | 'cleanup' | 'submitManualCallback' +> + type CodexOAuthFlowDependencies = { - createOAuthService?: () => Pick< - CodexOAuthService, - 'startOAuthFlow' | 'cleanup' - > + createOAuthService?: () => CodexOAuthServiceLike openBrowser?: typeof openBrowser saveCodexCredentials?: typeof saveCodexCredentials isBareMode?: typeof isBareMode } -function createDefaultOAuthService(): Pick< - CodexOAuthService, - 'startOAuthFlow' | 'cleanup' -> { +function createDefaultOAuthService(): CodexOAuthServiceLike { return new CodexOAuthService() } @@ -71,6 +72,9 @@ export function useCodexOAuthFlow(options: { let cancelled = false const oauthService = createOAuthService() + const submitManualCallback = ( + input: string, + ): CodexManualCallbackResult => oauthService.submitManualCallback(input) void oauthService .startOAuthFlow(async authUrl => { @@ -79,6 +83,7 @@ export function useCodexOAuthFlow(options: { state: 'waiting', authUrl, browserOpened: null, + submitManualCallback, }) const browserOpened = await openBrowserFn(authUrl) if (cancelled) return @@ -86,6 +91,7 @@ export function useCodexOAuthFlow(options: { state: 'waiting', authUrl, browserOpened, + submitManualCallback, }) }) .then(async tokens => { diff --git a/src/services/api/codexOAuth.test.ts b/src/services/api/codexOAuth.test.ts index c5f1fcf7b..7990d3140 100644 --- a/src/services/api/codexOAuth.test.ts +++ b/src/services/api/codexOAuth.test.ts @@ -257,6 +257,121 @@ test('serves updated success copy after a successful Codex OAuth flow', async () } }) +test('manual callback paste completes the flow when the loopback is unreachable', async () => { + await acquireCodexOAuthTestIsolation() + + try { + process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id' + + globalThis.fetch = asMockFetch( + mock(async () => { + return new Response( + JSON.stringify({ + access_token: 'manual-access-token', + refresh_token: 'manual-refresh-token', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }), + ) + + // Hanging listener — never resolves on its own. The manual paste path + // must be what completes the flow. + let capturedState = '' + let pending = false + const hangingListenerFactory = ((callbackPath: string) => ({ + callbackPath, + async start(): Promise { + return 41100 + }, + hasPendingResponse(): boolean { + return pending + }, + async waitForAuthorization( + state: string, + onReady: () => Promise, + ): Promise { + capturedState = state + pending = true + await onReady() + return new Promise(() => { + /* never resolves */ + }) + }, + handleSuccessRedirect(): void { + pending = false + }, + handleErrorRedirect(): void { + pending = false + }, + cancelPendingAuthorization(): void { + pending = false + }, + })) as unknown as NonNullable< + ConstructorParameters[0] + >['createAuthCodeListener'] + + const service = new CodexOAuthService({ + callbackPort: 0, + callbackHost: '127.0.0.1', + createAuthCodeListener: hangingListenerFactory, + }) + + const flowPromise = service.startOAuthFlow(async () => {}) + + // Wait until startOAuthFlow has populated expectedState via the listener. + // Bound the wait so a regression that never captures the state fails with a + // clear assertion instead of hanging the suite indefinitely. + const stateDeadline = Date.now() + 5_000 + while (!capturedState) { + if (Date.now() > stateDeadline) { + throw new Error( + 'startOAuthFlow did not capture the OAuth state within 5s', + ) + } + await Bun.sleep(0) + } + + const stateMismatch = service.submitManualCallback( + 'http://localhost:41100/auth/callback?code=foo&state=wrong', + ) + expect(stateMismatch.ok).toBe(false) + if (!stateMismatch.ok) { + expect(stateMismatch.error).toContain('State mismatch') + } + + const missingCode = service.submitManualCallback( + `http://localhost:41100/auth/callback?state=${capturedState}`, + ) + expect(missingCode.ok).toBe(false) + if (!missingCode.ok) { + expect(missingCode.error).toContain('`code`') + } + + const errorRedirect = service.submitManualCallback( + `http://localhost:41100/auth/callback?error=access_denied&state=${capturedState}`, + ) + expect(errorRedirect.ok).toBe(false) + if (!errorRedirect.ok) { + expect(errorRedirect.error).toContain('access_denied') + } + + const success = service.submitManualCallback( + `http://localhost:41100/auth/callback?code=manual-auth-code&state=${capturedState}`, + ) + expect(success.ok).toBe(true) + + const tokens = await flowPromise + expect(tokens.accessToken).toBe('manual-access-token') + expect(tokens.refreshToken).toBe('manual-refresh-token') + } finally { + restoreCodexOAuthTestIsolation() + } +}) + test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => { await acquireCodexOAuthTestIsolation() diff --git a/src/services/api/codexOAuth.ts b/src/services/api/codexOAuth.ts index be2b99d24..be9dd19b1 100644 --- a/src/services/api/codexOAuth.ts +++ b/src/services/api/codexOAuth.ts @@ -203,10 +203,17 @@ type CodexOAuthListener = Pick< | 'cancelPendingAuthorization' > +export type CodexManualCallbackResult = + | { ok: true } + | { ok: false; error: string } + export class CodexOAuthService { private authCodeListener: CodexOAuthListener | null = null private port: number | null = null private tokenExchangeAbortController: AbortController | null = null + private manualResolver: ((authorizationCode: string) => void) | null = null + private manualRejecter: ((error: Error) => void) | null = null + private expectedState: string | null = null constructor(private readonly options: CodexOAuthServiceOptions = {}) {} @@ -214,6 +221,83 @@ export class CodexOAuthService { return new Error('Codex OAuth flow was cancelled.') } + /** + * Recover the flow when the loopback callback is unreachable — typically a + * remote SSH session where the user's browser redirects to a localhost URL + * that resolves to their workstation, not the openclaude host. The user + * pastes the full redirected URL (or just its query string), we validate + * the state parameter against the in-flight flow, and resolve the same + * authorization code the loopback path would have produced. + * + * Returns a structured outcome instead of throwing so the UI layer can + * surface parse / state errors inline without unmounting the flow. + */ + submitManualCallback(input: string): CodexManualCallbackResult { + const trimmed = input.trim() + if (!trimmed) { + return { ok: false, error: 'Paste the callback URL or its query string.' } + } + if (!this.manualResolver || !this.expectedState) { + return { + ok: false, + error: 'No Codex OAuth flow is waiting for a manual callback.', + } + } + + let searchParams: URLSearchParams + try { + if (trimmed.includes('://')) { + searchParams = new URL(trimmed).searchParams + } else { + searchParams = new URLSearchParams( + trimmed.startsWith('?') ? trimmed.slice(1) : trimmed, + ) + } + } catch { + return { + ok: false, + error: 'Could not parse the callback URL — paste the full address.', + } + } + + const code = asTrimmedString(searchParams.get('code') ?? undefined) + const state = asTrimmedString(searchParams.get('state') ?? undefined) + const errorParam = asTrimmedString(searchParams.get('error') ?? undefined) + if (errorParam) { + const description = asTrimmedString( + searchParams.get('error_description') ?? undefined, + ) + return { + ok: false, + error: description + ? `Authorization failed: ${errorParam} — ${description}` + : `Authorization failed: ${errorParam}`, + } + } + if (!code) { + return { ok: false, error: 'Callback URL is missing the `code` parameter.' } + } + if (!state) { + return { + ok: false, + error: 'Callback URL is missing the `state` parameter.', + } + } + if (state !== this.expectedState) { + return { + ok: false, + error: + 'State mismatch — the URL is from a different login attempt. Start over.', + } + } + + const resolver = this.manualResolver + this.manualResolver = null + this.manualRejecter = null + resolver(code) + return { ok: true } + } + async startOAuthFlow( authURLHandler: (authUrl: string) => Promise, ): Promise { @@ -242,13 +326,29 @@ export class CodexOAuthService { state, }) + this.expectedState = state + const manualPromise = new Promise((resolve, reject) => { + this.manualResolver = resolve + this.manualRejecter = reject + }) + // Manual path may never be taken; swallow its rejection (raised from + // cleanup()) so it doesn't bubble as an unhandledRejection when the + // loopback wins the race. + manualPromise.catch(() => undefined) + try { - const authorizationCode = await authCodeListener.waitForAuthorization( + const loopbackPromise = authCodeListener.waitForAuthorization( state, async () => { await authURLHandler(authUrl) }, ) + loopbackPromise.catch(() => undefined) + + const authorizationCode = await Promise.race([ + loopbackPromise, + manualPromise, + ]) const tokenExchangeAbortController = new AbortController() this.tokenExchangeAbortController = tokenExchangeAbortController @@ -344,5 +444,13 @@ export class CodexOAuthService { this.authCodeListener?.cancelPendingAuthorization(cancellationError) this.authCodeListener = null this.port = null + + // Unblock any caller awaiting Promise.race against the manual path so the + // race resolves to the listener's rejection (or to this cancellation) + // instead of hanging forever. + this.manualRejecter?.(cancellationError) + this.manualResolver = null + this.manualRejecter = null + this.expectedState = null } }