Skip to content

[Windows] Cold launch hangs forever on loading indicator; renderer ignores its own reload but recovers instantly on external CDP Page.reload #48878

Open

Description

@zw41886-cpu

What version of the Codex App are you using?

26.924.2738.0 (app build 11645, Codex Desktop/26.924.22138)

What subscription do you have?

ChatGPT Plus

What platform is your computer?

Microsoft Windows NT 10.0.26200.0 x64 (Windows 11 25H2)

What issue are you seeing?

On a cold launch the app frequently hangs permanently on the loading spinner. The
window is responsive at OS level (Responding = True) but the renderer never recovers
by itself.

Measured state while hung (CDP attached to the main window, app://-/index.html):

  • document.getElementById('root').innerHTML.length = 1236
  • document.querySelectorAll('*').length = 55
  • exactly 1 element with an animate-spin class (the spinner); #root innerText is empty

The renderer is completely idle while hung:

  • 0 network requests over 90 seconds (Network.enable active)
  • 0 messages on the preload worker channel over 5 seconds
  • Debugger.pause yields only empty/minified frames — no app code executing, no pending timers
  • DOM byte-identical across 9 samples 10 s apart (90 s total)

The main process is healthy at the same time; these preload bridge calls all return in
0–7 ms with real data:

  • electronBridge.getInitialSidebarBootstrap() → real catalog entries and projects
  • electronBridge.getAppSessionId() → valid UUID
  • electronBridge.getDesktopUserAgent() → Codex Desktop/26.924.22138 (Windows NT 10.0; x64)

Statsig is also fine: window.__STATSIG__.firstInstance.loadingStatus === "Ready".

The key finding: the app's own reload is dead, an external reload works

While hung:

Action Result
F5 sent to the focused window (keybd_event after SetForegroundWindow) no effect, DOM unchanged after 25 s
Ctrl+R sent the same way no effect, DOM unchanged after 25 s
External CDP Page.reload({ignoreCache:true}) works — full UI renders, #root grows to ~129 000 chars / ~1100 nodes

The UI only appears 10–25 seconds after the external reload is issued (measured
progression: 4 264 → 1 236 → 99 979 → 126 098 chars), so any check that polls for less
than ~25 s will wrongly conclude the reload failed.

What steps can reproduce the bug?

  1. Fully exit the app (kill all ChatGPT.exe and codex.exe processes).
  2. Wait 10–15 seconds for the processes to fully exit.
  3. Launch it from the Start menu / taskbar.
  4. Observe that the loading spinner never resolves.

It is intermittent — some cold launches succeed. In my testing roughly 3 of 4 cold
launches hang.

What is the expected behavior?

The app should finish initializing on cold launch, and pressing F5 / Ctrl+R
(or View → Reload) should recover the window if it does get stuck.

Anything else?

Environment was ruled out with measurements:

  • The app works immediately after a reload, so the package and its files are intact.
    Reinstalling the MSIX package does not help.
  • Network is fine: chatgpt.com returns HTTP 200 with the full page over the same
    proxy path the app uses; TCP/TLS to chatgpt.com, ab.chatgpt.com, api.openai.com,
    auth.openai.com, persistent.oaistatic.com all succeed. Switching VPN providers and
    rolling back the proxy client made no difference.
  • CBS.log reports RepairNeeded: no — system image healthy.
  • The account is authenticated (account/read succeeds; tokens refresh normally).

Possibly related entries from the app's own logs
(%LOCALAPPDATA%\Packages\OpenAI.Codex_*\LocalCache\Local\Codex\Logs), may or may not
be relevant:

[electron-message-handler] Request failed method=account/read
    error={"code":-32603,"message":"workspace routing discovery timed out"} durationMs=15019

[electron-message-handler] sa_server_request_failed
    errorMessage="Workspace routing is unavailable" status=432 url=/wham/usage

[electron-message-handler] Statsig: error while bootstrapping post-login client
    errorMessage="Timed out while fetching post-login Statsig bootstrap"

This React Router warning fires on every launch, including successful ones, so it is
probably a red herring — included for completeness:

Matched leaf route at location "/" does not have an element or Component.
This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.

Workaround

Killing all ChatGPT.exe / codex.exe processes, relaunching with
--remote-debugging-port=9222, and issuing a CDP Page.reload recovers the app in
about 4 seconds, every time.

Note that a plain taskkill /F /IM "ChatGPT.exe" /T often does not terminate all
processes; a Stop-Process -Force round is sometimes required before the app will
accept the --remote-debugging-port argument, because the single-instance mechanism
otherwise swallows it.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    windows-osIssues related to Codex on Windows systems
    on Sep 28, 2026
  2. kyleisbased commented on Sep 29, 2026

    @kyleisbased

    Independent confirmation of this failure signature, plus a launcher-integrated workaround that has been reliable across cold starts on a Windows 11 Store/MSIX installation.

    Observed failure signature

    On a hung cold launch, attached over CDP to the main window (app://-/index.html):

    document.getElementById('root').innerHTML.length : 1236
    document.getElementById('root').innerText.length  : 0
    document.querySelectorAll('*').length             : 55
    

    That matches the measurements in the report above exactly, including innerHTML.length = 1236. The window is OS-responsive, the app-server completes its handshake and answers config/read, account/read, experimentalFeature/list, plugin/installed and skills/list in well under 50 ms each, and auth is valid — but the renderer never sends the follow-up work (no thread/list) and the UI stays on the spinner.

    Consistent with the analysis already posted in this thread, the renderer appears to lose the initialization snapshot because it arrives before the renderer's message listeners are registered, and is not replayed or buffered.

    Two things I can confirm as not causal on my machine:

    • The React Router warning (Matched leaf route at location "/" does not have an element or Component) fires on working and hung launches alike. It is a red herring, as noted above.
    • config.toml content does not control it. I A/B tested a comment-only config, a regenerated config, a deleted config, and an unchanged frozen config across repeated cold launches. A rendered run and a hung run ended up with byte-identical config files (same SHA-256). Removing config.toml occasionally yields one working launch because it forces a fresh handshake — the same reason an external reload works — not because of its contents.

    A practical detail that cost me several failed attempts

    The UI can take a long time to actually paint after the reload is issued. My first attempts polled for ~20 s and concluded failure, then gave up. Any verification loop needs to allow at least 25–30 s after Page.reload before deciding it did not work. Intermittency is also real: I measured roughly 3 of 4 cold launches hanging, consistent with the report above.

    Workaround

    The approach is the one already described (launch with --remote-debugging-port, then drive CDP). What I added, which is what made it dependable as a daily launcher rather than a manual recovery step:

    1. Wait for the app to finish starting (~12 s) before the first repair attempt. Issuing the reload immediately after the debug port opens frequently lands before the shell exists and does nothing.
    2. Retry the repair, re-verifying between attempts, instead of running it once.
    3. Verify against real DOM content (text length, #root child count, spinner count, error-page detection) rather than a loose "is there any text" check. I had an early version that reported success against the browser's own error page because the error text exceeded the threshold, and a separate version that hung indefinitely because the CDP calls had no timeouts.
    4. Fall back cleanly. The launcher logs its outcome to a file rather than showing a modal dialog — a modal on the failure path blocks the desktop until someone clicks it, which is worse than the original bug.

    PowerShell launcher. It reuses an existing session if one is already running, otherwise starts the app with the debug port and repairs it:

    $ErrorActionPreference = 'Stop'
    
    $port   = 9333
    $repair = Join-Path $PSScriptRoot 'repair.mjs'
    $probe  = Join-Path $PSScriptRoot 'probe.mjs'
    
    $node = (Get-Command node -ErrorAction SilentlyContinue).Source
    if (-not $node) { $node = 'C:\Program Files\nodejs\node.exe' }
    
    $package = Get-AppxPackage OpenAI.Codex
    if (-not $package) { throw 'Codex package not installed.' }
    $aumid = "$($package.PackageFamilyName)!App"
    
    Add-Type -TypeDefinition @"
    using System;
    using System.Runtime.InteropServices;
    public static class CodexActivate {
        [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
        private class ApplicationActivationManager { }
        [ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"),
         InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
        private interface IApplicationActivationManager {
            int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
                [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId);
            int ActivateForFile(string appUserModelId, IntPtr itemArray, string verb, out uint processId);
            int ActivateForProtocol(string appUserModelId, IntPtr itemArray, out uint processId);
        }
        public static uint Activate(string appUserModelId, string arguments) {
            var manager = (IApplicationActivationManager)new ApplicationActivationManager();
            uint pid;
            int hr = manager.ActivateApplication(appUserModelId, arguments, 0, out pid);
            if (hr < 0) { Marshal.ThrowExceptionForHR(hr); }
            return pid;
        }
    }
    "@
    
    function Test-DebugPort {
        try { $null = Invoke-RestMethod "http://127.0.0.1:$port/json/version" -TimeoutSec 2; return $true }
        catch { return $false }
    }
    
    # Capture the exit code immediately, with no pipeline in between.
    function Test-UiRendered {
        $out = & $node $probe $port 2>&1
        $code = $LASTEXITCODE
        ($out | Out-String).Trim() -split "`n" | ForEach-Object { Write-Host "    $_" }
        return ($code -eq 0)
    }
    
    $logFile = Join-Path $env:LOCALAPPDATA 'CodexLauncherLogs\launch.log'
    New-Item -ItemType Directory -Force -Path (Split-Path $logFile) | Out-Null
    
    if (Test-DebugPort) {
        Write-Host 'Codex already running - will repair it.'
    } else {
        Write-Host 'Starting Codex...'
        Get-Process ChatGPT -ErrorAction SilentlyContinue |
            Where-Object { $_.Path -like "$($package.InstallLocation)*" } |
            Stop-Process -Force -ErrorAction SilentlyContinue
        Start-Sleep -Milliseconds 800
        [void][CodexActivate]::Activate($aumid, "--remote-debugging-port=$port")
    
        $deadline = (Get-Date).AddSeconds(90)
        while (-not (Test-DebugPort)) {
            if ((Get-Date) -gt $deadline) { throw "Debug endpoint never came up." }
            Start-Sleep -Milliseconds 500
        }
    }
    
    # Let the shell mount before steering it - this is the step that mattered.
    Write-Host 'Letting the app finish starting...'
    Start-Sleep -Seconds 12
    
    $attempts = 4
    for ($i = 1; $i -le $attempts; $i++) {
        Write-Host "Repair attempt $i of $attempts..."
        if (Test-UiRendered) {
            Write-Host 'Codex is ready.' -ForegroundColor Green
            Add-Content $logFile "$(Get-Date -f s) rendered (attempt $i)"
            exit 0
        }
        & $node $repair $port
        Start-Sleep -Seconds 4
        if (Test-UiRendered) {
            Write-Host 'Codex is ready.' -ForegroundColor Green
            Add-Content $logFile "$(Get-Date -f s) rendered after repair (attempt $i)"
            exit 0
        }
    }
    
    Add-Content $logFile "$(Get-Date -f s) NOT rendered after $attempts attempts"
    Write-Host 'Window still not rendering. Run the shortcut again.' -ForegroundColor Yellow
    exit 1

    repair.mjs — targets the main window explicitly, re-requests the initialization snapshot first (works for some reporters, not on my build), then falls back to a reload, and allows 40 s for the UI to appear:

    const PORT = Number(process.argv[2] || 9333);
    const WAIT_MS = Number(process.argv[3] || 40000);
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const isMain = (u) => !u.includes('initialRoute') &&
      (u === 'app://-/app' || u === 'app://-/index.html' || u === 'app://-/');
    
    async function listPages() {
      const r = await fetch(`http://127.0.0.1:${PORT}/json/list`);
      return (await r.json()).filter((t) => t.type === 'page');
    }
    
    function connect(url) {
      return new Promise((resolve, reject) => {
        const ws = new WebSocket(url);
        const pending = new Map();
        let id = 1;
        const timer = setTimeout(() => reject(new Error('connect timeout')), 10000);
        ws.addEventListener('open', () => {
          clearTimeout(timer);
          ws.addEventListener('message', (ev) => {
            let m; try { m = JSON.parse(ev.data); } catch { return; }
            const p = pending.get(m.id);
            if (!p) return;
            pending.delete(m.id); clearTimeout(p.timer);
            m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result);
          });
          resolve({
            call: (method, params = {}, ms = 15000) => new Promise((res, rej) => {
              const i = id++;
              const t = setTimeout(() => { pending.delete(i); rej(new Error(method + ' timeout')); }, ms);
              pending.set(i, { resolve: res, reject: rej, timer: t });
              try { ws.send(JSON.stringify({ id: i, method, params })); } catch (e) { clearTimeout(t); rej(e); }
            }),
            close: () => { try { ws.close(); } catch {} },
          });
        });
        ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('ws error')); }, { once: true });
      });
    }
    
    async function state(call) {
      try {
        const r = await call('Runtime.evaluate', {
          expression: `(() => {
            const t = document.body ? document.body.innerText.trim() : '';
            const root = document.getElementById('root');
            return [t.length, root ? root.innerHTML.length : -1,
                    document.querySelectorAll('.animate-spin').length,
                    t.includes('reached') ? 'ERR' : 'OK'].join('|');
          })()`,
          returnByValue: true,
        }, 6000);
        const [len, rootLen, spins, flag] = String(r.result?.value ?? '-1|-1|-1|OK').split('|');
        return { len: +len, rootLen: +rootLen, spins: +spins, errorPage: flag === 'ERR' };
      } catch { return { len: -1, rootLen: -1, spins: -1, errorPage: false }; }
    }
    
    const good = (s) => s.len > 60 && !s.errorPage;
    
    let main = null;
    for (let i = 0; i < 20 && !main; i++) {
      try { main = (await listPages()).find((t) => isMain(t.url)); } catch {}
      if (!main) await sleep(500);
    }
    if (!main) { console.log('no main window'); process.exit(3); }
    
    const conn = await connect(main.webSocketDebuggerUrl);
    try { await conn.call('Page.enable', {}, 8000); } catch {}
    try { await conn.call('Runtime.enable', {}, 8000); } catch {}
    
    let s = await state(conn.call);
    if (good(s)) { console.log('already rendered'); conn.close(); process.exit(0); }
    
    // Strategy 1: replay the app's own initialization snapshot (no reload).
    console.log('snapshot replay (best effort)');
    try {
      await conn.call('Runtime.evaluate', {
        expression: `window.postMessage({ type: 'ready', initializationOnly: true }, '*')`,
        returnByValue: true,
      }, 6000);
    } catch {}
    
    let deadline = Date.now() + Math.min(WAIT_MS, 20000);
    while (Date.now() < deadline) {
      await sleep(1000);
      s = await state(conn.call);
      if (good(s)) { console.log(`rendered via snapshot replay (${s.len})`); conn.close(); process.exit(0); }
    }
    
    // Strategy 2: reload the renderer.
    console.log('reloading renderer');
    try { await conn.call('Page.reload', { ignoreCache: true }, 15000); } catch {}
    
    deadline = Date.now() + WAIT_MS;
    while (Date.now() < deadline) {
      await sleep(1500);
      s = await state(conn.call);
      if (good(s)) { console.log(`rendered via reload (${s.len})`); conn.close(); process.exit(0); }
    }
    console.log(`failed (text=${s.len} rootHtml=${s.rootLen} spinners=${s.spins})`);
    conn.close(); process.exit(1);

    probe.mjs — the pass/fail check. Only the main window counts; the app always has background windows (detached-window.html, avatar-overlay via initialRoute) that legitimately never paint, and judging those makes the result meaningless:

    const PORT = Number(process.argv[2] || 9333);
    const isMain = (u) => !u.includes('initialRoute') &&
      (u === 'app://-/app' || u === 'app://-/index.html' || u === 'app://-/');
    
    try {
      const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
      const main = list.filter((t) => t.type === 'page').find((t) => isMain(t.url));
      if (!main) { console.log('MAIN WINDOW NOT FOUND'); process.exit(3); }
    
      const ws = new WebSocket(main.webSocketDebuggerUrl);
      const pending = new Map(); let id = 1;
      await new Promise((res, rej) => {
        ws.addEventListener('open', res, { once: true });
        ws.addEventListener('error', rej, { once: true });
      });
      ws.addEventListener('message', (e) => {
        const m = JSON.parse(e.data); const p = pending.get(m.id);
        if (!p) return; pending.delete(m.id);
        m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result);
      });
      const call = (method, params = {}) => new Promise((res, rej) => {
        const i = id++; pending.set(i, { resolve: res, reject: rej });
        ws.send(JSON.stringify({ id: i, method, params }));
      });
      await call('Runtime.enable');
      const ev = async (x) => (await call('Runtime.evaluate', { expression: x, returnByValue: true })).result?.value;
    
      const textLen = await ev('document.body ? document.body.innerText.trim().length : -1');
      const rootKids = await ev('document.getElementById("root") ? document.getElementById("root").children.length : -1');
      const preview = String(await ev('document.body ? document.body.innerText.trim().slice(0,110) : ""'));
      ws.close();
    
      const ok = textLen > 60 && rootKids > 0 && !preview.includes('reached');
      console.log(`text=${textLen} rootKids=${rootKids} verdict=${ok ? 'RENDERED' : 'BLANK'}`);
      process.exit(ok ? 0 : 1);
    } catch (e) { console.log('probe error: ' + e.message); process.exit(4); }

    Measured results

    With the version that fired the repair immediately after the debug port opened: 0 of 4 cold starts rendered. With the wait-then-retry version: 7 of 7 cold starts rendered, and after consolidating the scripts, a further 3 of 3.

    Caveats

    • This works around a renderer bug; it does not fix it, and it leaves a local debugging port open on 127.0.0.1 while the app runs, which is not an acceptable permanent state.
    • It is verified only against a Windows 11 Store/MSIX install. The timing constants (12 s head start, 40 s paint allowance, 4 attempts) may need adjusting on slower or faster machines.
    • The underlying fix still needs to come from the app: buffer or replay the initialization state, and add a bounded recovery instead of an indefinite spinner.

    Thanks to everyone who documented the mechanism above — the listener-timing measurements in this thread are what made this reproducible rather than guesswork.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingwindows-osIssues related to Codex on Windows systems

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions