Skip to content

Commit cc8cdee

Browse files
fix(session): re-send auto-login on automatic reconnect
When a session dropped and TelnetConnection reconnected on its own, the stored connect string was never re-sent — the launcher only sent it once, on the initial connect. So an auto-reconnected session was left sitting at the server's login screen (unauthenticated), and with an empty input box the Send button stays disabled, leaving the user stuck. Move the auto-login into the Session: it now runs on every transition into Connected (initial connect AND each auto-reconnect) via a provider that resolves the character's stored connect string from the secret store on demand. The launcher supplies the provider and no longer sends the credential itself. Adds SessionSendsAutoLoginOnConnectAndReconnect and SessionWithoutAutoLoginSendsNothingOnConnect; full Core suite (196) passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp
1 parent bd28633 commit cc8cdee

3 files changed

Lines changed: 82 additions & 20 deletions

File tree

‎src/SharpClient.Core/Sessions/Session.cs‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ public sealed class Session : ISession
1919
private readonly INotifier? _notifier;
2020
private readonly ISessionHistory? _history;
2121

22+
// Auto-login command provider (resolves the character's stored connect string). Invoked on
23+
// every transition into Connected so an auto-reconnected session re-authenticates instead of
24+
// being left at the server's login screen. Null when the character has no stored credentials.
25+
private readonly Func<ValueTask<string?>>? _autoLoginProvider;
26+
private ConnectionState _lastState = ConnectionState.Disconnected;
27+
2228
// LineReceived fires off the network read thread while Blazor enumerates Scrollback on the
2329
// render thread — appending mid-enumeration throws "Collection was modified" and kills the UI.
2430
// _scrollbackLock guards every read and write of _scrollback; the Scrollback getter hands out an
@@ -41,9 +47,11 @@ public Session(
4147
ITriggerEngine? triggerEngine = null,
4248
IReadOnlyList<TriggerRule>? triggerRules = null,
4349
INotifier? notifier = null,
44-
ISessionHistory? history = null)
50+
ISessionHistory? history = null,
51+
Func<ValueTask<string?>>? autoLoginProvider = null)
4552
{
4653
_connection = connection;
54+
_autoLoginProvider = autoLoginProvider;
4755
CharacterName = characterName;
4856
WorldName = worldName;
4957
WorldId = worldId;
@@ -198,7 +206,38 @@ private async void OnLineReceived(string raw)
198206
}
199207
}
200208

201-
private void OnStateChanged(ConnectionState state) => StateChanged?.Invoke(state);
209+
private void OnStateChanged(ConnectionState state)
210+
{
211+
var previous = _lastState;
212+
_lastState = state;
213+
StateChanged?.Invoke(state);
214+
215+
// On every transition into Connected — the initial connect AND each automatic reconnect —
216+
// re-send the stored auto-login, so a dropped-and-reconnected session lands logged in
217+
// instead of stranded at the server's login screen.
218+
if (state == ConnectionState.Connected
219+
&& previous != ConnectionState.Connected
220+
&& _autoLoginProvider is not null)
221+
{
222+
_ = SendAutoLoginAsync();
223+
}
224+
}
225+
226+
private async Task SendAutoLoginAsync()
227+
{
228+
try
229+
{
230+
var command = await _autoLoginProvider!().ConfigureAwait(false);
231+
if (!string.IsNullOrWhiteSpace(command))
232+
{
233+
await SendAsync(command).ConfigureAwait(false);
234+
}
235+
}
236+
catch
237+
{
238+
// Best-effort: a failed auto-login just leaves the user at the login screen to retry.
239+
}
240+
}
202241

203242
private void OnGmcpReceived(GmcpMessage msg)
204243
{

‎src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs‎

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ public async Task<ISession> LaunchAsync(
5050
var aliasRules = MergeAliases(world.Aliases, character.Aliases);
5151
var triggerRules = MergeTriggers(world.Triggers, character.Triggers);
5252

53+
// Resolve the character's stored connect string on demand. The Session invokes this on the
54+
// initial connect AND on every automatic reconnect, so a dropped session re-authenticates
55+
// itself instead of being left at the server's login screen. The secret is fetched from the
56+
// store each time rather than held in the Session.
57+
Func<ValueTask<string?>>? autoLoginProvider = character.ConnectSecretKey is { } key
58+
? async () => await _secrets.GetAsync(key)
59+
: null;
60+
5361
var session = new Session(
5462
connection,
5563
character.Name,
@@ -61,27 +69,11 @@ public async Task<ISession> LaunchAsync(
6169
_triggerEngine,
6270
triggerRules,
6371
_notifier,
64-
_history);
72+
_history,
73+
autoLoginProvider);
6574

6675
await session.ConnectAsync(world.Host, world.Port, cancellationToken);
6776

68-
try
69-
{
70-
if (character.ConnectSecretKey is { } key)
71-
{
72-
var secret = await _secrets.GetAsync(key);
73-
if (!string.IsNullOrWhiteSpace(secret))
74-
{
75-
await session.SendAsync(secret);
76-
}
77-
}
78-
}
79-
catch
80-
{
81-
await session.DisposeAsync();
82-
throw;
83-
}
84-
8577
return session;
8678
}
8779

‎tests/SharpClient.Tests/Sessions/SessionStateTests.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,37 @@ public async Task SessionParsesLinesFromAnyConnection()
3333
await Assert.That(session.Scrollback[0].Segments[0].Text).IsEqualTo("plain");
3434
}
3535

36+
[Test]
37+
public async Task SessionSendsAutoLoginOnConnectAndReconnect()
38+
{
39+
var fake = new FakeTelnetConnection();
40+
await using var session = new Session(
41+
fake, autoLoginProvider: () => ValueTask.FromResult<string?>("connect Foo secret"));
42+
43+
await session.ConnectAsync("host", 1); // fake raises Connected
44+
await Task.Delay(50);
45+
// Simulate an unexpected drop + automatic reconnect.
46+
fake.RaiseState(ConnectionState.Reconnecting);
47+
fake.RaiseState(ConnectionState.Connected);
48+
await Task.Delay(50);
49+
50+
// Login is re-sent on the reconnect, not just the initial connect.
51+
var expected = new[] { "connect Foo secret", "connect Foo secret" };
52+
await Assert.That(fake.Sent).IsEquivalentTo(expected);
53+
}
54+
55+
[Test]
56+
public async Task SessionWithoutAutoLoginSendsNothingOnConnect()
57+
{
58+
var fake = new FakeTelnetConnection();
59+
await using var session = new Session(fake);
60+
61+
await session.ConnectAsync("host", 1);
62+
await Task.Delay(50);
63+
64+
await Assert.That(fake.Sent).IsEmpty();
65+
}
66+
3667
[Test]
3768
public async Task ReconnectingAndErrorAreDistinctStates()
3869
{

0 commit comments

Comments
 (0)