Skip to content

Commit 032fda3

Browse files
Stop shredding every compressed session by settling our own phases (#164)
* Stop shredding every compressed session by settling our own phases Five Evennia games have had their CODEBASE stored as "enniaA 5.0.1" and "enniaF 6.0.0 (rev ea0da3ed8)R ##D HRINFO0m" for months. It is not an Evennia quirk and it is not a parser gap: everything those servers said after their connect screen arrived shredded, with fragments of two replies overlaid on each other. RAW[14] Ne, : ekMUD (Name: TrekMUD) RAW[17] Vsion 6: enniaA.1.0! (Version: Evennia 6.1.0) RAW[20] RRRRmmand 'WHVERSION scnot available. (two replies in one line) The cause is ours. Every phase ended in FlushPendingLineAsync, which pushed a newline through InterpretAsync to shake loose a line the server never terminated. That is the *inbound* channel — the same one the read loop feeds — and on an MCCP2 session an inflater sits at the head of it. So the newline was never delivered to the line buffer at all; it was spliced into the middle of the peer's deflate stream as though the peer had sent it, and the inflater's state never recovered. Ruled out in this order, each with a run rather than an argument: - Evennia. Captured the raw compressed stream off trekmud.com:1701 with a plain socket; Python's zlib decodes it perfectly. - TelnetNegotiationCore's inflater. Fed that same captured stream one byte at a time through MCCPInflateTransform: 703 of 703 bytes identical to zlib's output. - TelnetNegotiationCore generally. A minimal TNC client reads the block cleanly against the live server, with MCCP alone, with all twelve of this probe's plugins, with this probe's exact send sequence, and with the same lock-polling — and it is the same on 2.11.0 and 2.12.0. - Our plugin set, our builder, our buffer handling. Reduced the probe to a builder chain identical to that clean client and it still shredded; replaced the phase logic with fixed delays, same builder, and it stopped. The fix is not to skip the flush when compressed — that trades one loss for another, and the guard that keeps a busy DIKU from reading as a measured zero depends on seeing an unterminated prompt. TelnetNegotiationCore 2.12.0 added PacketPatchProtocol, which infers a prompt boundary from silence on the interpreter's own byte-processing loop, where the line buffer has exactly one writer and nothing is pushed into the peer's stream. It retires itself the moment a server marks a real prompt with IAC GA or IAC EOR. So: take the new version, register the plugin, and delete the hand-rolled version of it. A prompt lands in LastPromptBytes rather than through OnSubmit — the library will not pretend a prompt is a line — so the probe appends it, in the order taken. Deduplicated by identity, not content: every take allocates a fresh array, and a server repeating its gate is showing it again, which is what ARepeatingGateStopsAtTheRoundBound needs to see. ProbeOptions.PromptHold is 500ms, the library's default and what TinTin++ and Mudlet both use; shorter splits a line at any server that pauses mid-output. A phase waits it out rather than racing it, and only when HasPartialLine says something is actually being held — which is false for the overwhelming majority of settles. ACompressedSessionIsNotShreddedByOurOwnSettling fails on the old behaviour and passes on the new; FakeGame gains an AnnouncesMccp mode so the fixture really compresses. Verified live: all five Evennia games now read Evennia 6.1.0 / 5.0.1 / 6.1.0 (rev e174e49f5) / 6.0.0 (rev ea0da3ed8). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say the settle rule once, instead of waiting for it afterwards The wait for a held line was a step tacked onto the end of every flush, which read as though the probe were re-implementing something the library already does. It is not that — it is a rule about when a phase is over — so it belongs in the one place that decides that. A phase is not settled while the library is holding an unterminated line. The two clocks start at different moments: SettleAsync's at the last line, PacketPatchProtocol's at the fragment that arrived after it. Ours therefore always expires first, and a phase that ended there would push its own prompt into the next phase's slice — the misattribution the cursors exist to prevent. MaxPhase still bounds it, so a server holding a fragment for ever is not waited on for ever. Fast() now holds for the same 120ms as its QuietPeriod, mirroring production's 500/500. The suite previously passed on a margin — an 80ms hold against a 120ms settle — which is not the ratio that ships and so proved nothing about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Take the prompt where the library hands it over, not at a phase boundary OnPrompt is the hook for this, and the probe was already using it — for EOR and Suppress Go-Ahead, to record the boolean fact that a server marks its prompt boundaries. It just never took the text. All three of the library's boundaries behave the same way: IAC EOR, IAC GA and PacketPatchProtocol inferring one from silence each take the standing partial line into LastPromptBytes and then call back, on the interpreter's own byte-processing loop. None of them submits it — a prompt is not a line and the library will not pretend otherwise — so nothing reaches OnSubmit. So all three now hand it to one PromptSink, which appends it to the probe's line list. That the callback runs on the byte-processing loop is the whole reason it belongs there: the prompt lands between the lines either side of it, in the order it was taken, rather than being swept up afterwards at the next settle. Which in turn deletes the identity-dedupe the sweep needed — the callback fires once per prompt, so there is nothing to deduplicate — and the `taken` parameter that was threaded through five signatures to carry it. This also covers a marked prompt, which the previous version reached only by accident. TNC 2.12.0 routes IAC GA through TakePartialLineAsPrompt too, so a server that ends its prompts with GA had exactly the same problem as one that ends them with nothing, and neither is special-cased now. FlushPendingLineAsync is what its name says again: wait for the interpreter to finish processing. The settle rule added in the previous commit stays — it is about when a phase is over, not about collecting anything. ACompressedSessionIsNotShreddedByOurOwnSettling still fails on the old behaviour and passes on the new. Crawl 485 three times over, Catalog 640, Crawler 332, Discovery 335, Web 1153. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b997443 commit 032fda3

4 files changed

Lines changed: 176 additions & 10 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
IS the capability measurement (spec §6.1), so what a server offers is observed rather than
1212
taken from a game's own MSSP claim.
1313
-->
14-
<PackageVersion Include="TelnetNegotiationCore" Version="2.11.0" />
14+
<PackageVersion Include="TelnetNegotiationCore" Version="2.12.0" />
1515

1616
<!-- Logging abstractions (TelnetNegotiationCore takes an ILogger). -->
1717
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />

src/MUI.Crawl/Telnet/ProbeOptions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ public sealed record ProbeOptions
3131
/// </remarks>
3232
public TimeSpan QuietPeriod { get; init; } = TimeSpan.FromMilliseconds(500);
3333

34+
/// <summary>
35+
/// How long a server must stay silent on an unterminated line before it is taken as a prompt.
36+
/// </summary>
37+
/// <remarks>
38+
/// Handed to TelnetNegotiationCore's <c>PacketPatchProtocol</c>, which does the holding on its
39+
/// own byte-processing loop. 500 ms is the library's default and the convention the hobby's
40+
/// clients settled on — TinTin++'s packet patch and Mudlet's posting timer both use it. Shorter
41+
/// would split a line at any server that pauses mid-output, so a phase waits this out rather
42+
/// than racing it, and only when the library says it is holding a line.
43+
/// </remarks>
44+
public TimeSpan PromptHold { get; init; } = TimeSpan.FromMilliseconds(500);
45+
3446
/// <summary>
3547
/// How long to wait for a phase to produce anything at all before concluding it never will.
3648
/// </summary>

src/MUI.Crawl/Telnet/TelnetProbe.cs

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ public async Task<ProbeResult> ProbeAsync(ProbeTarget target, CancellationToken
100100
? client.ConnectAsync([.. target.Addresses], target.Port, budget.Token)
101101
: client.ConnectAsync(target.Host, target.Port, budget.Token));
102102

103-
var built = await Build(seen, lines).BuildAndStartAsync(client, budget.Token);
103+
var prompts = new PromptSink(lines);
104+
var built = await Build(seen, lines, prompts).BuildAndStartAsync(client, budget.Token);
105+
106+
// Before anything can be read from the socket in practice — see PromptSink.Reads.
107+
prompts.Reads(built.Interpreter);
104108

105109
// Must be disposed *before* the socket it reads. The interpreter owns a byte channel, the
106110
// draining task, and every plugin — MCCP's holds zlib streams that nothing else reclaims,
@@ -249,7 +253,10 @@ or IOException
249253
/// and for what recording the placeholder cost.
250254
/// </remarks>
251255
private async Task SettleInitialBannerAsync(
252-
TelnetInterpreter telnet, Func<int> arrived, List<byte[]> lines, CancellationToken cancellationToken)
256+
TelnetInterpreter telnet,
257+
Func<int> arrived,
258+
List<byte[]> lines,
259+
CancellationToken cancellationToken)
253260
{
254261
await SettleAsync(telnet, arrived, 0, _options.SilenceGrace, cancellationToken);
255262

@@ -741,6 +748,16 @@ private async Task SettleAsync(
741748
continue;
742749
}
743750

751+
// Not settled while the library is still holding an unterminated line. The two clocks
752+
// start at different moments — ours at the last line, PacketPatchProtocol's at the
753+
// fragment that arrived after it — so ours always expires first, and a phase that ended
754+
// here would push its own prompt into the next phase's slice. MaxPhase still bounds the
755+
// wait, so a server that holds a fragment for ever is not waited on for ever.
756+
if (telnet.HasPartialLine)
757+
{
758+
continue;
759+
}
760+
744761
if (DateTime.UtcNow - lastChange >= (seen == 0 ? grace : _options.QuietPeriod))
745762
{
746763
break;
@@ -764,10 +781,53 @@ private async Task SettleAsync(
764781
/// nothing goes on the wire and line assembly/IAC/encoding stay the library's; when the buffer is
765782
/// empty the library discards it and no line is produced.
766783
/// </remarks>
767-
private static async Task FlushPendingLineAsync(TelnetInterpreter telnet)
784+
private static Task FlushPendingLineAsync(TelnetInterpreter telnet) =>
785+
telnet.WaitForProcessingAsync(maxWaitMs: 500, additionalDelayMs: 25).AsTask();
786+
787+
/// <summary>
788+
/// Where a prompt the library took goes: onto the end of this probe's line list.
789+
/// </summary>
790+
/// <remarks>
791+
/// All three of the library's prompt boundaries — <c>IAC EOR</c>, <c>IAC GA</c>, and
792+
/// <c>PacketPatchProtocol</c> inferring one from silence — take the standing partial line into
793+
/// <c>LastPromptBytes</c> and then call back. They do not submit it: a prompt is not a line and
794+
/// the library will not pretend otherwise, so nothing reaches <c>OnSubmit</c> and the probe would
795+
/// never see an unterminated login prompt at all.
796+
/// <para>
797+
/// Every callback runs on the interpreter's own byte-processing loop, which is what makes this
798+
/// the right place to do it: the prompt lands in <c>lines</c> in the order it was taken, between
799+
/// the lines either side of it, rather than being swept up afterwards at a phase boundary.
800+
/// </para>
801+
/// </remarks>
802+
private sealed class PromptSink(List<byte[]> lines)
768803
{
769-
await telnet.InterpretAsync(NewLine);
770-
await telnet.WaitForProcessingAsync(maxWaitMs: 500, additionalDelayMs: 25);
804+
private TelnetInterpreter? _telnet;
805+
806+
/// <summary>
807+
/// Names the interpreter to read prompts from, once the builder has produced it.
808+
/// </summary>
809+
/// <remarks>
810+
/// Assigned immediately after the build rather than during it, because the callbacks are
811+
/// registered before the interpreter they read from exists. Nothing is lost in between: the
812+
/// earliest prompt a peer can produce needs either a marker it has not sent yet or
813+
/// <c>PromptHold</c> of silence, and this runs in the same continuation as the build.
814+
/// </remarks>
815+
public void Reads(TelnetInterpreter telnet) => _telnet = telnet;
816+
817+
public ValueTask TakeAsync()
818+
{
819+
if (_telnet?.LastPromptBytes is not { IsEmpty: false } prompt)
820+
{
821+
return ValueTask.CompletedTask;
822+
}
823+
824+
lock (lines)
825+
{
826+
lines.Add(prompt.ToArray());
827+
}
828+
829+
return ValueTask.CompletedTask;
830+
}
771831
}
772832

773833
/// <summary>
@@ -784,7 +844,7 @@ private static async Task FlushPendingLineAsync(TelnetInterpreter telnet)
784844
/// separate statement apiece; the TelnetNegotiationCore plugin builders are already self-typed
785845
/// fluent, so this is the shape the library wants, not a new abstraction layered over it.
786846
/// </remarks>
787-
private TelnetInterpreterBuilder Build(Observations seen, List<byte[]> lines)
847+
private TelnetInterpreterBuilder Build(Observations seen, List<byte[]> lines, PromptSink prompts)
788848
{
789849
void Note(string protocol) => seen.Note(protocol);
790850

@@ -846,7 +906,7 @@ private TelnetInterpreterBuilder Build(Observations seen, List<byte[]> lines)
846906
{
847907
seen.Prompts = true;
848908
Note("EOR");
849-
return ValueTask.CompletedTask;
909+
return prompts.TakeAsync();
850910
});
851911

852912
// The other prompt marker, and the one most of this hobby actually uses. RFC 854 makes a bare
@@ -863,7 +923,7 @@ private TelnetInterpreterBuilder Build(Observations seen, List<byte[]> lines)
863923
.OnPrompt(() =>
864924
{
865925
seen.Prompts = true;
866-
return ValueTask.CompletedTask;
926+
return prompts.TakeAsync();
867927
});
868928

869929
// CharsetProtocol takes its order as a property rather than a builder call, and exposes
@@ -912,7 +972,15 @@ private TelnetInterpreterBuilder Build(Observations seen, List<byte[]> lines)
912972
.AddPlugin(goAhead)
913973
.AddPlugin(new Watched.Naws(Note))
914974
.AddPlugin(terminalType)
915-
.AddPlugin(new Watched.Echo(Note));
975+
.AddPlugin(new Watched.Echo(Note))
976+
977+
// What FlushPendingLineAsync used to do by hand, done on the interpreter's own
978+
// byte-processing loop where the line buffer has exactly one writer — and, crucially,
979+
// without pretending the peer sent anything. It retires itself the moment a server
980+
// marks a real prompt with IAC GA or IAC EOR.
981+
.AddPlugin(new PacketPatchProtocol()
982+
.WithHoldTime(_options.PromptHold)
983+
.OnPrompt(prompts.TakeAsync));
916984
}
917985

918986
/// <summary>

tests/MUI.Crawl.Tests/Telnet/ProbeSessionTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class ProbeSessionTests
3434
PollInterval = TimeSpan.FromMilliseconds(15),
3535
Timeout = TimeSpan.FromSeconds(20),
3636
MsspSettleGrace = TimeSpan.FromMilliseconds(400),
37+
PromptHold = TimeSpan.FromMilliseconds(120),
3738
};
3839

3940
[Test]
@@ -587,6 +588,76 @@ await Assert.That(game.Received.Where(line => line.Trim().Length > 0)
587588
.All(line => TelnetProbe.PermittedCommands.Contains(line.Trim()))).IsTrue();
588589
}
589590

591+
/// <summary>
592+
/// A compressed session is not corrupted by the probe settling its own phases.
593+
/// </summary>
594+
/// <remarks>
595+
/// The bug this fixture exists for. Every phase used to end by pushing a newline into
596+
/// <c>InterpretAsync</c> to shake loose a line the server never terminated — but that is the
597+
/// <em>inbound</em> channel, the one an MCCP inflater sits at the head of, so on a compressed
598+
/// session the byte was spliced into the middle of the peer's deflate stream. The inflater never
599+
/// recovered: every reply afterwards arrived shredded, with fragments of two of them overlaid.
600+
/// <para>
601+
/// Found on the five Evennia games in the catalogue, whose <c>CODEBASE</c> had been stored as
602+
/// <c>enniaA 5.0.1</c> and <c>enniaF 6.0.0 (rev ea0da3ed8)R ##D HRINFO0m</c> for months. Neither
603+
/// Evennia nor TelnetNegotiationCore was at fault: the same captured stream decodes perfectly
604+
/// through Python's zlib, through TNC's own inflate transform byte for byte, and through a plain
605+
/// TNC client against the live server.
606+
/// </para>
607+
/// </remarks>
608+
[Test]
609+
public async Task ACompressedSessionIsNotShreddedByOurOwnSettling()
610+
{
611+
await using var game = new FakeGame
612+
{
613+
AnnouncesMccp = true,
614+
Banner = "Welcome to Mortal Realms\r\nMrMud 1.4\r\n",
615+
BannerTail = "By what name do you wish to be known? ",
616+
WhoReply = "Player Name On For Idle\r\n7 Players logged in, 22 record, no maximum.\r\n",
617+
InfoReply = "### Begin INFO 1\r\nName: Mortal Realms\r\nConnected: 7\r\nVersion: Evennia 6.1.0\r\n### End INFO\r\n",
618+
};
619+
620+
var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);
621+
622+
await Assert.That(result.Outcome).IsEqualTo(ProbeOutcome.Answered);
623+
624+
// The precondition, asserted rather than assumed: this session really was compressed.
625+
await Assert.That(result.OfferedOptions).Contains("MCCP2");
626+
627+
// Every reply intact, and read rather than merely present.
628+
await Assert.That(result.Who.Count).IsEqualTo(7);
629+
await Assert.That(result.Info).Contains("Name: Mortal Realms");
630+
await Assert.That(LoginCommandReading.MeaningfulCodebase(result.Info, result.Version))
631+
.IsEqualTo("Evennia 6.1.0");
632+
await Assert.That(LoginCommandReading.ConnectedPlayers(result.Info)).IsEqualTo(7);
633+
}
634+
635+
/// <summary>
636+
/// And the unterminated line survives compression too, which is what the newline was for.
637+
/// </summary>
638+
/// <remarks>
639+
/// The fix is not "stop flushing on a compressed session" — that would trade one loss for
640+
/// another, and the guard that keeps a busy DIKU from reading as a measured zero depends on
641+
/// seeing exactly this kind of unterminated prompt. TelnetNegotiationCore 2.12.0's
642+
/// <c>PacketPatchProtocol</c> infers the boundary from silence on its own byte-processing loop,
643+
/// where the line buffer has one writer and nothing is pushed into the peer's stream.
644+
/// </remarks>
645+
[Test]
646+
public async Task AnUnterminatedPromptIsStillDeliveredOnACompressedSession()
647+
{
648+
await using var game = new FakeGame
649+
{
650+
AnnouncesMccp = true,
651+
Banner = "Welcome to Nowhere\r\nA quiet little place.\r\n",
652+
BannerTail = "By what name do you wish to be known? ",
653+
};
654+
655+
var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);
656+
657+
await Assert.That(result.OfferedOptions).Contains("MCCP2");
658+
await Assert.That(result.Banner).Contains("By what name do you wish to be known?");
659+
}
660+
590661
/// <summary>
591662
/// A server that has negotiated MSSP but whose report is still in flight is waited for, rather
592663
/// than having the flush read <c>Supported</c> at whatever instant it happens to still be empty.
@@ -1432,6 +1503,16 @@ public FakeGame()
14321503
/// </remarks>
14331504
public bool AnnouncesMssp { get; init; }
14341505

1506+
/// <summary>
1507+
/// Whether the server compresses everything after its option handshake, as MCCP2 servers do.
1508+
/// </summary>
1509+
/// <remarks>
1510+
/// Load-bearing rather than decoration: with compression on, anything the probe pushes into
1511+
/// its own inbound channel is spliced into the peer's deflate stream, and every reply after
1512+
/// it comes out shredded. That is what this fixture exists to catch.
1513+
/// </remarks>
1514+
public bool AnnouncesMccp { get; init; }
1515+
14351516
/// <summary>
14361517
/// How long <see cref="AnnouncesMssp"/>'s report is withheld after our <c>DO MSSP</c> arrives
14371518
/// — <c>WILL MSSP</c> still negotiates on connect, same as always, so this models a server
@@ -1602,6 +1683,11 @@ private async Task ServeOverTelnetAsync(TcpClient client)
16021683
builder = builder.AddPlugin(mssp);
16031684
}
16041685

1686+
if (AnnouncesMccp)
1687+
{
1688+
builder = builder.AddPlugin(new MCCPProtocol());
1689+
}
1690+
16051691
var built = await builder.BuildAndStartAsync(client, _stopping.Token);
16061692

16071693
await using var telnet = built.Interpreter;

0 commit comments

Comments
 (0)