openclaw - ✅(Solved) Fix [CLI] Gateway connection fails with "gateway closed (1000)" in non-JSON mode (Potential race condition with `withProgress` spinner) [1 pull requests, 3 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#49405Fetched 2026-04-08 00:55:32
View on GitHub
Comments
3
Participants
2
Timeline
9
Reactions
2
Author
Participants
Timeline (top)
commented ×3cross-referenced ×2labeled ×2referenced ×1

When running CLI commands like openclaw browser status, the connection to the local gateway consistently fails with: Error: gateway closed (1000 normal closure): no close reason

Interestingly, the command works perfectly when the --json flag is added. This suggests a race condition or event-loop blockage caused by the terminal progress indica tor (spinner).

Error Message

Error: gateway closed (1000 normal closure): no close reason

  • Result: Error: gateway closed (1000 normal closure): no close reason
  1. Server-Side: Provide a clear reason string and a more appropriate error code when closing due to handshake timeout (e.g., close(1008, "handshake timeout")).

Root Cause

Interestingly, the command works perfectly when the --json flag is added. This suggests a race condition or event-loop blockage caused by the terminal progress indica tor (spinner).

Fix Action

Fixed

PR fix notes

Fix: Terminal Spinner Blocks WebSocket Handshake, Causing Gateway Closed (1000) Error

Problem

CLI commands like openclaw browser status consistently fail with:

Error: gateway closed (1000 normal closure): no close reason

Adding --json resolves the issue immediately:

openclaw browser status        # fails
openclaw browser status --json # succeeds

This confirms the failure is caused by the terminal spinner (enabled by default in non-JSON mode), not the gateway itself.


Root Cause

Client side

callGatewayFromCli in gateway-rpc-DDuWmIVq.js wraps the gateway call in withProgress, which activates a @clack/prompts spinner in non-JSON mode:

const showProgress = extra?.progress ?? opts.json !== true;
return await withProgress({
    label: `Gateway ${method}`,
    indeterminate: true,
    enabled: showProgress
}, async () => await callGateway({...}));

The spinner writes to stdout at frequent intervals. This blocks the Node.js event loop, delaying processing of the WebSocket connect.challenge event during the handshake sequence.

Server side

The gateway enforces a 3-second handshake timeout (DEFAULT_HANDSHAKE_TIMEOUT_MS = 3000). If the full handshake (Challenge → Connect → HelloOk) is not completed within this window, the server closes the connection:

// gateway-cli-Ol-vpIk7.js line 23225
const handshakeTimer = setTimeout(() => {
    if (!client) {
        setCloseCause("handshake-timeout", ...);
        close(); // code 1000, no reason string
    }
}, handshakeTimeoutMs);

The spinner-induced event loop delay causes the handshake to exceed 3 seconds, triggering this timeout — producing the misleading 1000 normal closure: no close reason error.


Workaround

Option 1: Use --json flag (Immediate)

openclaw browser status --json
openclaw gateway probe --json

Disables the spinner, handshake completes without event loop interference.

Option 2: Pipe output to suppress spinner

openclaw browser status | cat

Piping to cat may suppress TTY detection and disable the spinner depending on implementation.


Fix

Fix 1 — Client: Defer spinner start until after WebSocket handshake (Recommended)

Move withProgress to wrap only the post-handshake portion of the call, not the handshake itself:

// Before
return await withProgress({ ... }, async () => await callGateway({...}));

// After
const result = await callGateway({...}); // handshake completes first, no spinner
return await withProgress({ ... }, async () => result); // spinner only for processing

Or disable the spinner entirely during the WebSocket connect phase:

const gateway = await connectGateway(); // no spinner here
return await withProgress({ enabled: showProgress }, async () => {
    return await gateway.call(method, params);
});

Fix 2 — Client: Replace blocking spinner with non-blocking alternative

@clack/prompts spinner uses synchronous stdout writes that can block the event loop. Replace with a non-blocking timer-based approach:

function nonBlockingSpinner(label: string) {
  const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
  let i = 0
  // setImmediate yields to I/O between frames, preventing event loop blockage
  const tick = () => {
    process.stdout.write(`\r${frames[i++ % frames.length]} ${label}`)
    handle = setImmediate(tick)
  }
  let handle = setImmediate(tick)
  return { stop: () => { clearImmediate(handle); process.stdout.write('\r\x1b[K') } }
}

Fix 3 — Server: Increase handshake timeout and improve error clarity

Raise DEFAULT_HANDSHAKE_TIMEOUT_MS to provide a more generous window for slow TTY environments, and use a specific close code so the error is actionable:

// Before
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3000;
close(); // code 1000, no reason

// After
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 8000; // more generous for spinner-heavy CLIs
close(1008, "handshake timeout: challenge not completed within allowed window");

This also makes the error immediately distinguishable from a genuine normal closure.


Fix 4 — Config: Allow spinner to be disabled globally

Add a config option to disable the spinner without requiring --json on every command:

{
  "cli": {
    "spinner": false
  }
}
const showProgress = extra?.progress
  ?? (opts.json !== true && config.cli?.spinner !== false);

Recommended Fix Order

PriorityFixImpact
1Defer spinner until post-handshakeFixes root cause, no UX regression
2Increase server handshake timeout to 8sReduces blast radius while Fix 1 ships
3Improve close reason string (1008 handshake timeout)Makes future debugging easier
4Add cli.spinner: false config optionUser-controlled escape hatch

Verification

After applying fixes:

# Should succeed without --json
openclaw browser status
openclaw gateway probe

# Gateway log should no longer show handshake-timeout cause
openclaw gateway logs --tail 20 | grep handshake

Environment

FieldValue
OpenClaw version2026.3.13
OSLinux
Gateway modelocal
Failing commandopenclaw browser status (no flags)
Working commandopenclaw browser status --json
Root cause@clack/prompts spinner blocks event loop during WebSocket handshake
Server timeout3000ms (DEFAULT_HANDSHAKE_TIMEOUT_MS)

Upstream Fix Required

  1. Decouple spinner lifecycle from WebSocket handshake — spinner must not be active during the Challenge → Connect → HelloOk sequence
  2. Increase DEFAULT_HANDSHAKE_TIMEOUT_MS from 3000ms to at least 8000ms
  3. Use close(1008, "handshake timeout") instead of close() — makes the error message actionable instead of misleading
  4. Add cli.spinner config flag — allows users to globally opt out of spinner without --json

Code Example

const showProgress = extra?.progress ?? opts.json !== true;
return await withProgress({
    label: `Gateway ${method}`,
    indeterminate: true,
    enabled: showProgress
}, async () => await callGateway({...}));

---

// line 23159 - close() defaults to code 1000 with no reason
const close = (code = 1e3, reason) => { ... };

// line 23225 - Handshake timeout triggers close()
const handshakeTimer = setTimeout(() => {
    if (!client) {
        setCloseCause("handshake-timeout", ...);
        close(); // Triggers code 1000, no reason
    }
}, handshakeTimeoutMs);

---
RAW_BUFFERClick to expand / collapse

Bug type

Regression (worked before, now fails)

Summary

Description

When running CLI commands like openclaw browser status, the connection to the local gateway consistently fails with: Error: gateway closed (1000 normal closure): no close reason

Interestingly, the command works perfectly when the --json flag is added. This suggests a race condition or event-loop blockage caused by the terminal progress indica tor (spinner).

Steps to reproduce

Steps to Reproduce

  1. Ensure the OpenClaw gateway is running locally (gateway.mode: "local").
  2. Run openclaw browser status.
    • Result: Error: gateway closed (1000 normal closure): no close reason
  3. Run openclaw browser status --json.
    • Result: Success (Correct status returned).

Expected behavior

Technical Analysis

A deep dive into the source code reveals that the withProgress spinner (enabled by default in non-JSON mode) interferes with the WebSocket handshake.

1. Client-Side Behavior

In gateway-rpc-DDuWmIVq.js, callGatewayFromCli wraps the gateway call in withProgress. In non-JSON mode, @clack/prompts is used to render a terminal spinner.

const showProgress = extra?.progress ?? opts.json !== true;
return await withProgress({
    label: `Gateway ${method}`,
    indeterminate: true,
    enabled: showProgress
}, async () => await callGateway({...}));

2. Server-Side Behavior (Gateway)

The gateway server (gateway-cli-Ol-vpIk7.js) has a default handshake timeout of 3 seconds (DEFAULT_HANDSHAKE_TIMEOUT_MS = 3000). If the handshake (Challenge -> Conn ect -> HelloOk) isn't completed within this window, the server calls close():

// line 23159 - close() defaults to code 1000 with no reason
const close = (code = 1e3, reason) => { ... };

// line 23225 - Handshake timeout triggers close()
const handshakeTimer = setTimeout(() => {
    if (!client) {
        setCloseCause("handshake-timeout", ...);
        close(); // Triggers code 1000, no reason
    }
}, handshakeTimeoutMs);

3. Hypothesis

The terminal spinner rendering logic (writing to stdout at frequent intervals) appears to be blocking the event loop or delaying the handling of the WebSocket connect.challenge event in the CLI client, eventually causing the server-side 3-second timeout to trip.

Actual behavior

Suggested Fix

  1. Server-Side: Provide a clear reason string and a more appropriate error code when closing due to handshake timeout (e.g., close(1008, "handshake timeout")).
  2. Client-Side: Investigate if the spinner library is blocking the event loop during WebSocket handshakes.
  3. Configuration: Consider increasing the default handshake timeout or providing a way to disable the spinner globally without requiring --json.

OpenClaw version

2026.3.13

Operating system

Linux

Install method

No response

Model

N/A

Provider / routing chain

N/A

Config file / key location

No response

Additional provider/model setup details

No response

Logs, screenshots, and evidence

Impact and severity

No response

Additional information

No response

extent analysis

Fix Plan

To address the issue, we'll implement the following steps:

  • Server-Side: Modify the close function to provide a clear reason string and a more appropriate error code when closing due to handshake timeout.
  • Client-Side: Investigate and adjust the spinner library to prevent event loop blockage during WebSocket handshakes.
  • Configuration: Increase the default handshake timeout or provide a way to disable the spinner globally.

Server-Side Fix

Update the close function in gateway-cli-Ol-vpIk7.js to include a reason string and error code:

const close = (code = 1008, reason = "handshake timeout") => { 
    // implementation details
};

And update the handshake timeout trigger:

const handshakeTimer = setTimeout(() => {
    if (!client) {
        setCloseCause("handshake-timeout", ...);
        close(1008, "handshake timeout"); // Updated close call
    }
}, handshakeTimeoutMs);

Client-Side Fix

Adjust the withProgress wrapper in gateway-rpc-DDuWmIVq.js to prevent event loop blockage:

const showProgress = extra?.progress ?? opts.json !== true;
return await withProgress({
    label: `Gateway ${method}`,
    indeterminate: true,
    enabled: showProgress,
    // Add an option to prevent event loop blockage
    asyncRender: true
}, async () => await callGateway({...}));

Configuration Fix

Increase the default handshake timeout or provide a way to disable the spinner globally:

// Increase default handshake timeout
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10000; // 10 seconds

// or provide a way to disable the spinner globally
const showProgress = extra?.progress ?? opts.json !== true || opts.disableSpinner;

Verification

To verify the fix, run the following commands:

  • openclaw browser status (should succeed without errors)
  • openclaw browser status --json (should still succeed)

Check the server logs for any handshake timeout errors and verify that the error code and reason string are correctly reported.

Extra Tips

  • Consider implementing a retry mechanism for WebSocket connections to handle temporary handshake timeouts.
  • Review the spinner library documentation for any known issues or workarounds related to event loop blockage.
  • Test the fixes thoroughly to ensure they don't introduce any regressions or new issues.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING