fix(gateway): handle Windows OpenClaw process exit error during in-process restarts (#794)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,9 @@ const wsState = vi.hoisted(() => ({
|
||||
this.emit('close', code, Buffer.from(String(reason)));
|
||||
});
|
||||
});
|
||||
readonly terminate = vi.fn(() => {
|
||||
this.readyState = 3;
|
||||
});
|
||||
readonly send = vi.fn((payload: string) => {
|
||||
this.sentFrames.push(payload);
|
||||
});
|
||||
@@ -61,6 +64,7 @@ vi.mock('@electron/utils/logger', () => ({
|
||||
import {
|
||||
GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS,
|
||||
connectGatewaySocket,
|
||||
probeGatewayReady,
|
||||
} from '@electron/gateway/ws-client';
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
@@ -182,3 +186,126 @@ describe('connectGatewaySocket', () => {
|
||||
expect(pendingRequests.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeGatewayReady', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
wsState.sockets.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
wsState.sockets.length = 0;
|
||||
});
|
||||
|
||||
it('resolves true when connect.challenge message is received', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 5000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
socket.emitJsonMessage({
|
||||
type: 'event',
|
||||
event: 'connect.challenge',
|
||||
payload: { nonce: 'probe-nonce' },
|
||||
});
|
||||
|
||||
await expect(probePromise).resolves.toBe(true);
|
||||
expect(socket.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false on WebSocket error', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 5000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emit('error', new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
expect(socket.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false on timeout when no message is received', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 2000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
// No message sent — just advance time past timeout
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
expect(socket.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false when socket closes before challenge', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 5000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
// Emit close directly (not through the mock's close() method)
|
||||
socket.emit('close', 1006, Buffer.from(''));
|
||||
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
expect(socket.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT resolve true on plain open event (key behavioral change)', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 500);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
// Only emit open — no connect.challenge message
|
||||
socket.emitOpen();
|
||||
|
||||
// The old implementation would have resolved true here.
|
||||
// The new implementation waits for connect.challenge.
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('uses terminate() instead of close() for cleanup to avoid Windows TIME_WAIT', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 5000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
socket.emitJsonMessage({
|
||||
type: 'event',
|
||||
event: 'connect.challenge',
|
||||
payload: { nonce: 'nonce-1' },
|
||||
});
|
||||
|
||||
await expect(probePromise).resolves.toBe(true);
|
||||
|
||||
// Must use terminate(), not close()
|
||||
expect(socket.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(socket.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-challenge messages', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 1000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
// Send a message that is NOT connect.challenge
|
||||
socket.emitJsonMessage({
|
||||
type: 'event',
|
||||
event: 'some.other.event',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
// Should still be waiting — not resolved yet
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ignores malformed JSON messages', async () => {
|
||||
const probePromise = probeGatewayReady(18789, 1000);
|
||||
const socket = getLatestSocket();
|
||||
|
||||
socket.emitOpen();
|
||||
// Send raw invalid JSON
|
||||
socket.emit('message', Buffer.from('not-json'));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await expect(probePromise).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user