From 2b5ab54e94b554a74f156ca2c29cde7532a0a30c Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 27 Aug 2026 15:53:23 +0000 Subject: [PATCH] Break cluster lines on a bare carriage return N1MM splits the telnet stream on CR, not LF, which says some nodes send CR with no LF after it. Against those the assembler was handing the whole session back as one unfinished line. Also adds "user:" to the login prompts, which N1MM matches and we did not. Co-Authored-By: Claude Opus 5 --- src/Nonemm.Spotting/Telnet/ClusterPrompts.cs | 2 +- src/Nonemm.Spotting/Telnet/LineAssembler.cs | 28 +++++++++++++------ .../LineAssemblerTests.cs | 27 ++++++++++++++++++ .../TelnetStreamTests.cs | 6 ++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs index 330feaf..e35f879 100644 --- a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs +++ b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs @@ -7,7 +7,7 @@ public static class ClusterPrompts private static readonly string[] Callsign = [ "login", "log in", "logon", "enter call", "enter your call", - "your call", "callsign", "call:", + "your call", "callsign", "call:", "user:", ]; private static readonly string[] Password = ["password"]; diff --git a/src/Nonemm.Spotting/Telnet/LineAssembler.cs b/src/Nonemm.Spotting/Telnet/LineAssembler.cs index 6038d54..b8fdbb9 100644 --- a/src/Nonemm.Spotting/Telnet/LineAssembler.cs +++ b/src/Nonemm.Spotting/Telnet/LineAssembler.cs @@ -8,27 +8,39 @@ namespace Nonemm.Spotting.Telnet; public sealed class LineAssembler { private readonly StringBuilder pending = new(); + private bool afterCarriageReturn; /// What has arrived since the last line ending. public string Pending => pending.ToString(); + /// Breaks on CR, LF or CRLF. Most nodes send CRLF, but N1MM splits on CR + /// alone, which says some of them leave the LF out. public IReadOnlyList Add(string text) { List lines = []; foreach (char character in text) { - if (character == '\n') + bool wasAfterCarriageReturn = afterCarriageReturn; + afterCarriageReturn = character == '\r'; + switch (character) { - lines.Add(pending.ToString().TrimEnd('\r')); - pending.Clear(); - } - else - { - pending.Append(character); + case '\n' when wasAfterCarriageReturn: + break; + case '\n' or '\r': + lines.Add(pending.ToString()); + pending.Clear(); + break; + default: + pending.Append(character); + break; } } return lines; } - public void Clear() => pending.Clear(); + public void Clear() + { + pending.Clear(); + afterCarriageReturn = false; + } } diff --git a/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs index 632ca2f..1ce02dd 100644 --- a/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs +++ b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs @@ -31,4 +31,31 @@ public class LineAssemblerTests Assert.Empty(assembler.Add("login: ")); Assert.Equal("login: ", assembler.Pending); } + + /// N1MM splits on CR alone, so some nodes evidently leave the LF out. + [Fact] + public void ALineEndedWithACarriageReturnAloneIsStillALine() + { + LineAssembler assembler = new(); + + Assert.Equal(["one", "two"], assembler.Add("one\rtwo\r")); + } + + [Fact] + public void CarriageReturnAndLineFeedTogetherAreOneEnding() + { + LineAssembler assembler = new(); + + Assert.Equal(["one"], assembler.Add("one\r\n")); + Assert.Equal("", assembler.Pending); + } + + [Fact] + public void AnEndingSplitAcrossTwoBlocksIsStillOneEnding() + { + LineAssembler assembler = new(); + + Assert.Equal(["one"], assembler.Add("one\r")); + Assert.Empty(assembler.Add("\n")); + } } diff --git a/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs index 4fe413b..abe8c36 100644 --- a/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs +++ b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs @@ -38,8 +38,10 @@ public class TelnetStreamTests Assert.Equal("DX de W3LPL: 14025.0 JA1XYZ\r\n", read); } - /// A client that leaves the negotiation unanswered gets these bytes mixed - /// into the first line it reads. + /// N1MM never answers the negotiation. It drops bytes 251, 252 and 255 from + /// what it displays but lets 253 and 254 through, so a node that opens with + /// IAC DO TERMINAL-TYPE puts two stray characters in its packet window and + /// leaves the node waiting for an answer that never comes. We answer. [Fact] public async Task TheNegotiationIsAnsweredAndKeptOutOfTheText() {