diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs
new file mode 100644
index 000000000..a4cf396a8
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs
@@ -0,0 +1,130 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Mattermost.Events;
+using Microsoft.Extensions.Time.Testing;
+using Netclaw.Channels.Mattermost.Transport;
+using Xunit;
+
+namespace Netclaw.Actors.Tests.Channels;
+
+public sealed class MattermostInitialConnectionGateTests
+{
+ [Fact]
+ public async Task StartWaitsForConnectedAndRemovesHandler()
+ {
+ var timeProvider = new FakeTimeProvider();
+ var subscription = new ConnectionSubscription();
+ var startCount = 0;
+
+ var start = MattermostInitialConnectionGate.StartAndWaitAsync(
+ cancellationToken =>
+ {
+ Assert.False(cancellationToken.IsCancellationRequested);
+ Assert.NotNull(subscription.Handler);
+ startCount++;
+ return Task.CompletedTask;
+ },
+ subscription.Subscribe,
+ subscription.Unsubscribe,
+ timeProvider,
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, startCount);
+ Assert.False(start.IsCompleted);
+
+ subscription.RaiseConnected(timeProvider);
+ await start;
+
+ Assert.Equal(1, subscription.UnsubscribeCount);
+ Assert.Equal(subscription.Handler, subscription.RemovedHandler);
+ }
+
+ [Fact]
+ public async Task TimeoutUsesTimeProviderAndRemovesHandler()
+ {
+ var timeProvider = new FakeTimeProvider();
+ var subscription = new ConnectionSubscription();
+ var start = MattermostInitialConnectionGate.StartAndWaitAsync(
+ _ => Task.CompletedTask,
+ subscription.Subscribe,
+ subscription.Unsubscribe,
+ timeProvider,
+ TestContext.Current.CancellationToken);
+
+ timeProvider.Advance(MattermostInitialConnectionGate.Timeout);
+
+ await Assert.ThrowsAsync(() => start);
+ Assert.Equal(1, subscription.UnsubscribeCount);
+ Assert.Equal(subscription.Handler, subscription.RemovedHandler);
+ }
+
+ [Fact]
+ public async Task CancellationRemovesHandler()
+ {
+ var timeProvider = new FakeTimeProvider();
+ var subscription = new ConnectionSubscription();
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ TestContext.Current.CancellationToken);
+ var start = MattermostInitialConnectionGate.StartAndWaitAsync(
+ _ => Task.CompletedTask,
+ subscription.Subscribe,
+ subscription.Unsubscribe,
+ timeProvider,
+ cancellation.Token);
+
+ await cancellation.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() => start);
+ Assert.Equal(1, subscription.UnsubscribeCount);
+ Assert.Equal(subscription.Handler, subscription.RemovedHandler);
+ }
+
+ [Fact]
+ public async Task StartFailureRemovesHandler()
+ {
+ var timeProvider = new FakeTimeProvider();
+ var subscription = new ConnectionSubscription();
+ var start = MattermostInitialConnectionGate.StartAndWaitAsync(
+ _ => throw new InvalidOperationException("Start failed."),
+ subscription.Subscribe,
+ subscription.Unsubscribe,
+ timeProvider,
+ TestContext.Current.CancellationToken);
+
+ var exception = await Assert.ThrowsAsync(() => start);
+
+ Assert.Equal("Start failed.", exception.Message);
+ Assert.Equal(1, subscription.UnsubscribeCount);
+ Assert.Equal(subscription.Handler, subscription.RemovedHandler);
+ }
+
+ private sealed class ConnectionSubscription
+ {
+ public EventHandler? Handler { get; private set; }
+
+ public EventHandler? RemovedHandler { get; private set; }
+
+ public int UnsubscribeCount { get; private set; }
+
+ public void Subscribe(EventHandler handler) => Handler = handler;
+
+ public void Unsubscribe(EventHandler handler)
+ {
+ RemovedHandler = handler;
+ UnsubscribeCount++;
+ }
+
+ public void RaiseConnected(TimeProvider timeProvider)
+ {
+ Assert.NotNull(Handler);
+ Handler(
+ this,
+ new ConnectionEventArgs(
+ new Uri("wss://mattermost.test/api/v4/websocket"),
+ timeProvider.GetUtcNow().UtcDateTime));
+ }
+ }
+}
diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs
index 88a0cd895..f55dd86d0 100644
--- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs
+++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs
@@ -195,7 +195,15 @@ public async Task StartAsync(string serverUrl, string bot
_logger.LogInformation("Bot identity resolved: {BotUserId} (@{Username})",
me.Id, me.Username);
- await _client.StartReceivingAsync(cancellationToken);
+ // Mattermost.NET starts its receiver before the authenticated WebSocket
+ // exists. Preserve the transport contract by waiting for OnConnected.
+ await MattermostInitialConnectionGate.StartAndWaitAsync(
+ _client.StartReceivingAsync,
+ handler => _client.OnConnected += handler,
+ handler => _client.OnConnected -= handler,
+ _timeProvider,
+ cancellationToken);
+
return new MattermostBotIdentity(me.Id, me.Username);
}
@@ -400,3 +408,30 @@ private async Task AwaitDispatchAsync(string operation, Task task)
}
}
}
+
+internal static class MattermostInitialConnectionGate
+{
+ internal static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
+
+ public static async Task StartAndWaitAsync(
+ Func startReceivingAsync,
+ Action> subscribe,
+ Action> unsubscribe,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken)
+ {
+ var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ void HandleConnected(object? sender, ConnectionEventArgs e) => connected.TrySetResult();
+
+ subscribe(HandleConnected);
+ try
+ {
+ await startReceivingAsync(cancellationToken);
+ await connected.Task.WaitAsync(Timeout, timeProvider, cancellationToken);
+ }
+ finally
+ {
+ unsubscribe(HandleConnected);
+ }
+ }
+}