diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a8a8712..bfdd7be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,10 @@ jobs: go-whatsapp-security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e with: go-version: "1.26.5" cache-dependency-path: src/whatsapp-whatsmeow-worker/go.sum @@ -49,10 +49,10 @@ jobs: build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -102,10 +102,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name != 'schedule' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -152,10 +152,10 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' timeout-minutes: 60 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -225,10 +225,10 @@ jobs: needs: build-and-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -268,7 +268,7 @@ jobs: permissions: packages: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c @@ -299,10 +299,10 @@ jobs: env: OPENCLAW_PUBLIC_SMOKE: "1" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index 29075487..f37e31b8 100644 --- a/.github/workflows/nuget.yml +++ b/.github/workflows/nuget.yml @@ -21,10 +21,10 @@ jobs: env: PACKAGE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb821fe2..546afcf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,10 +53,10 @@ jobs: rid: osx-arm64 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -211,7 +211,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/src/OpenClaw.Channels/WebSocketChannel.cs b/src/OpenClaw.Channels/WebSocketChannel.cs index 29f48176..75e0d596 100644 --- a/src/OpenClaw.Channels/WebSocketChannel.cs +++ b/src/OpenClaw.Channels/WebSocketChannel.cs @@ -123,10 +123,14 @@ await SendEnvelopeToStateAsync( continue; } + if (IsIgnorableClientEnvelope(parsed.Type)) + continue; + var msg = new InboundMessage { ChannelId = ChannelId, SenderId = clientId, + RequestCancellation = ct, AuthenticatedUserId = authenticatedUserId, SessionId = parsed.SessionId, Type = parsed.Type, @@ -142,8 +146,7 @@ await SendEnvelopeToStateAsync( ValueJson = parsed.CanvasEnvelope?.ValueJson, Sequence = parsed.CanvasEnvelope?.Sequence, ApprovalId = parsed.ApprovalId, - Approved = parsed.Approved, - RequestCancellation = ct + Approved = parsed.Approved }; if (OnMessageReceived is not null) @@ -558,6 +561,11 @@ private static ParsedWsInbound TryParseClientEnvelope(string payload) return new ParsedWsInbound(true, env.Type, "", env.SessionId, env.MessageId, env.ReplyToMessageId, env.ApprovalId, env.Approved); } + if (env is not null && IsIgnorableClientEnvelope(env.Type)) + { + return new ParsedWsInbound(true, env.Type, "", env.SessionId, env.MessageId, env.ReplyToMessageId, null, null); + } + if (env is not null && IsCanvasClientEnvelope(env.Type)) { var text = env.Type switch @@ -586,6 +594,9 @@ private static ParsedWsInbound TryParseClientEnvelope(string payload) return new ParsedWsInbound(false, null, payload, null, null, null, null, null, null); } + private static bool IsIgnorableClientEnvelope(string? type) + => type is "heartbeat"; + private async ValueTask HandleCanvasClientEnvelopeAsync(string clientId, WsClientEnvelope envelope, CancellationToken ct) { if (OnCanvasClientEnvelopeReceived is not null) diff --git a/src/OpenClaw.Gateway/wwwroot/webchat.js b/src/OpenClaw.Gateway/wwwroot/webchat.js index a941961a..c1894e08 100644 --- a/src/OpenClaw.Gateway/wwwroot/webchat.js +++ b/src/OpenClaw.Gateway/wwwroot/webchat.js @@ -145,6 +145,7 @@ let activeRawContent = ""; let isAwaitingResponse = false; let reconnectAttempts = 0; let reconnectTimer = null; +let heartbeatTimer = null; let streamRenderTimer = null; let liveResponseHasAudio = false; let liveAudioQueue = []; @@ -172,6 +173,11 @@ let pendingFileUrls = []; // non-image files queued for upload const WEBCHAT_CONFIG = { streamRenderDebounceMs: Math.max(20, Number(window.OPENCLAW_WEBCHAT_CONFIG?.streamRenderDebounceMs ?? 120)), + heartbeatIntervalMs: (() => { + const raw = Number(window.OPENCLAW_WEBCHAT_CONFIG?.heartbeatIntervalMs ?? 45000); + const safe = Number.isFinite(raw) ? raw : 45000; + return Math.min(2147483647, Math.max(1000, safe)); + })(), initialReconnectDelayMs: Math.max(250, Number(window.OPENCLAW_WEBCHAT_CONFIG?.initialReconnectDelayMs ?? 1000)), maxReconnectDelayMs: Math.max(1000, Number(window.OPENCLAW_WEBCHAT_CONFIG?.maxReconnectDelayMs ?? 30000)), reconnectBackoffFactor: Math.max(1.1, Number(window.OPENCLAW_WEBCHAT_CONFIG?.reconnectBackoffFactor ?? 2)), @@ -946,6 +952,31 @@ function resetActiveResponse() { activeRawContent = ''; } +function clearHeartbeatTimer() { + if (!heartbeatTimer) { + return; + } + + clearInterval(heartbeatTimer); + heartbeatTimer = null; +} + +function startHeartbeatLoop() { + clearHeartbeatTimer(); + + heartbeatTimer = setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + + try { + ws.send(JSON.stringify({ type: 'heartbeat' })); + } catch (_) { + clearHeartbeatTimer(); + } + }, WEBCHAT_CONFIG.heartbeatIntervalMs); +} + function createRow(type) { const row = document.createElement('div'); row.className = `message-row ${type}-row`; @@ -2406,6 +2437,7 @@ async function openDoctorDiagnostics(addToChat = false) { } function connect() { + clearHeartbeatTimer(); setConnectionState(reconnectAttempts > 0 ? 'reconnecting' : 'connecting'); appendSystem('Connecting to OpenClaw.NET Gateway...'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -2427,6 +2459,7 @@ function connect() { reconnectTimer = null; } dismissFirstRunCard(); + startHeartbeatLoop(); refreshChatState(); appendSystem('Connected successfully.'); sendCanvasReady(); @@ -2566,6 +2599,7 @@ function connect() { }; ws.onclose = (event) => { + clearHeartbeatTimer(); updateComposerAvailability(); resetActiveResponse(); diff --git a/src/OpenClaw.Tests/WebSocketChannelTests.cs b/src/OpenClaw.Tests/WebSocketChannelTests.cs index 2a841c19..eeda344f 100644 --- a/src/OpenClaw.Tests/WebSocketChannelTests.cs +++ b/src/OpenClaw.Tests/WebSocketChannelTests.cs @@ -208,6 +208,35 @@ await channel.HandleConnectionAsync( Assert.Equal("acct-123", received!.AuthenticatedUserId); } + [Fact] + public async Task HandleConnectionAsync_ForwardsRequestCancellationToken() + { + var channel = new WebSocketChannel(new WebSocketConfig { MaxMessageBytes = 1024 }); + var ws = new TestWebSocket(); + + ws.QueueReceiveText("hello"); + ws.QueueClose(); + + InboundMessage? received = null; + channel.OnMessageReceived += (msg, _) => + { + received = msg; + return ValueTask.CompletedTask; + }; + + using var requestCts = new CancellationTokenSource(); + await channel.HandleConnectionAsync( + ws, + "client", + IPAddress.Loopback, + requestCts.Token, + authenticatedUserId: null); + + Assert.NotNull(received); + Assert.Equal(requestCts.Token, received!.RequestCancellation); + Assert.True(received.RequestCancellation.CanBeCanceled); + } + [Fact] public async Task HandleConnectionAsync_RoutesCanvasReadyWithoutUserMessage() { @@ -238,6 +267,27 @@ public async Task HandleConnectionAsync_RoutesCanvasReadyWithoutUserMessage() Assert.False(messageObserved); } + [Fact] + public async Task HandleConnectionAsync_IgnoresHeartbeatEnvelopeWithoutUserMessage() + { + var channel = new WebSocketChannel(new WebSocketConfig { MaxMessageBytes = 1024 }); + var ws = new TestWebSocket(); + + ws.QueueReceiveText("""{"type":"heartbeat"}"""); + ws.QueueClose(); + + var messageObserved = false; + channel.OnMessageReceived += (_, _) => + { + messageObserved = true; + return ValueTask.CompletedTask; + }; + + await channel.HandleConnectionAsync(ws, "client", IPAddress.Loopback, TestContext.Current.CancellationToken); + + Assert.False(messageObserved); + } + [Fact] public async Task HandleConnectionAsync_RoutesCanvasAckAndResultsWithoutUserMessage() {