Xania Builder

Research, proof and review notes for changes to Xania, the MUD. Source: github.com/abraidotti/xania-builder

#3 · PR on the fork · PR on upstream

nanny reads argument[0] of an empty reply at five lobby prompts (debug builds abort)

gate passed · researched at Xania 82171fa443 · branch fix/3-lobby-empty-reply at f7a0c3b52d

Written by whoever made the change (here, Claude Code working from the dossier), answering the dossier's questions. The facts it cites are in dossier.md; the proof is in gate.md.

In one paragraph

Five of the lobby's prompts (the questions a connecting player answers before playing) look at the first letter of the reply with argument[0], without checking there is one. argument has been a std::string_view since December 2021, and indexing an empty view is undefined behaviour, so pressing Enter at "Did I hear that right (Y/N)?" (or four other prompts) reads past the end. Since the project moved to GCC 15 on 2026-10-31, debug builds abort on the standard library's bounds assertion there, taking the whole MUD down for everyone connected. Release builds get away with it, by luck rather than design: the byte past the end happens to be a NUL terminator. The fix reads the first letter through a tiny first_char() that returns '\0' for an empty reply, so each prompt falls into the default: branch it already has and simply asks again, as the ANSI prompt already does for an empty reply.

Root cause

WhenCommitWhat happened
2021-12-0503e4dc4"nanny() lobby func upgraded to use string_view". argument goes from const char * to std::string_view. With a C string, argument[0] on an empty reply was the terminator, '\0'. Two states tested for it explicitly (if (argument[0] == '\0') in GetName and GetAnsi), and the commit correctly turned those into argument.empty(). The other five relied on '\0' matching no case and falling into default:; there was no test to convert, so they kept argument[0], which is now out of range on an empty view.
2025-05-11687f6cenanny moves from comm.cpp into MudImpl (the dossier's history uses move-aware blame, so the faulty lines are still credited to 03e4dc4).
2026-10-3182823ff, 7bd8c82g++-15 becomes the default. Its libstdc++ turns _GLIBCXX_ASSERTIONS on for unoptimised builds (GCC bug 112808), so Debug builds start aborting here.
2026-11-10Builder's string_view detector flags the lines while researching #1 (the same mistake, made two days earlier in 35c8f3a); Xania Sim's lobby trial confirms all five. Filed as #3.

Why debug builds abort and release builds don't

The same reason as #1, checked the same way with g++ 15.2: at -O0 libstdc++ checks operator[] and aborts; at -O2 (and at -O0 with -D_GLIBCXX_NO_ASSERTIONS) the read goes ahead silently, and AddressSanitizer doesn't report it, because the byte after the view is still inside the input buffer. So it is undefined behaviour in every build, and the assertion is only what makes it visible.

What release builds actually read: every caller passes a view of something NUL-terminated (the std::string pop_incomm returns, or the literal "" on reconnect), and ltrim only moves the start of the view. So the byte just past an empty view is always a '\0', which matches no case and lands in default:, the right answer by accident. (The issue said release builds "switch on whatever byte follows"; in practice that byte is always the terminator. The optimiser is still entitled to assume the read never happens, which is why it's worth fixing anyway.)

One contrast that explains why only these lines are affected: Descriptor::pop_incomm does next_line[0] on the same empty line, but next_line is a std::string, and s[s.size()] on a std::string is defined (it's the terminator). Only std::string_view has no terminator to read.

A second way in, found while checking callers

Not every empty reply comes from a player. On a doorman reconnect (PACKET_RECONNECT, when doorman hands xania back an already-authenticated connection, e.g. after xania restarts), handle_doorman_packet calls nanny(d, "") itself, twice (MudImpl.cpp:506 and 509). If the first call finds the character already playing on another connection, check_playing moves the descriptor to BreakConnect, and the second call indexes the empty literal there: a debug MUD would abort with no player input at all. This is from reading the code; it hasn't been reproduced (Sim would need to restart xania under a live doorman). The fix covers it the same way: BreakConnect gets '\0' and answers "Please type Y or N?".

The fix

// The first character of str, or '\0' if str is empty. For code that switches on the first letter of a reply,
// where str[0] on an empty string_view would read past its end.
[[nodiscard]] constexpr char first_char(std::string_view str) noexcept { return str.empty() ? '\0' : str.front(); }

in string_utils.hpp, next to is_vowel and has_prefix, and in the five prompts:

switch (first_char(argument)) {                       // BreakConnect, ConfirmNewName, GetAlignment, DefaultChoice
if (auto sex = Sex::try_from_char(first_char(argument))) {   // GetNewSex

'\0' matches no case, so each prompt does what it already does for a reply it doesn't understand:

PromptWhat an empty reply now gets
BreakConnect"Please type Y or N?"
ConfirmNewName"It's quite simple - type Yes or No:"
GetNewSex"Please specify (M)ale, (F)emale or (O)ther."
GetAlignment"That's not a valid alignment." and the question again
DefaultChoice"Please answer (Y/N)?"

That's what the C version did before 03e4dc4, so the behaviour is restored, not invented.

Alternatives considered

Answers to the dossier's questions

1. Explorer. No Explorer check covers this yet; code.lobby-empty-reply is proposed (separate tool, separate change), which would list any of the five lines still indexing argument[0].

2. Architecture. Local to MudImpl::nanny, plus a header-only helper. Every caller of nanny benefits: the game loop (player input) and both doorman reconnect calls.

3. Design patterns. string_utils.hpp is where small string_view helpers live (is_vowel, has_prefix, matches); first_char is constexpr and noexcept like the trivial ones. Each prompt's existing default: is reused instead of a new branch.

4. Technical debt. Pays down the five unguarded reads. Leaves GetAnsi's argument[0] (line 762) as it is: it's guarded by the empty() check above it, and changing it would only be churn. (Builder's detector still lists it; it doesn't yet understand guards.) The same pattern elsewhere: do_get is #1; do_who and do_permit are guarded (false positives); skill_lookup (practice '') is an unverified lead, noted in #1's analysis.

5. Gameplay. A player who presses Enter at one of the five prompts is asked again instead of crashing a debug MUD. Release builds already asked again (by accident, see above), so on the live game nothing visible changes. Nothing new for an abusive player: before the fix, pressing Enter at a prompt could take a debug MUD down for everyone, which was the real exploit. No balance numbers involved.

6. Documentation. None needed: the prompts' messages already exist and are what players see.

7. Tests. first_char gets a unit test (including an empty view into a longer string, the case that bites, and a static_assert). nanny is private to MudImpl and needs a whole MUD, so the end-to-end proof is Xania Sim's lobby trial: a careless player pressing Enter at each of the five prompts. The gate runs it on the base (it should crash at every prompt) and on the branch (every prompt must be reached and survive), and the before/after proof comes from that.

8. History. Answered under Root cause. Upstream: no issue about empty replies; upstream #279 (open) wants nanny broken up; upstream #129 (closed, "Splat in reconnect") and upstream #178 (open) are about the reconnect path, which this also hardens.

Follow-ups (not in this PR)

Branch fix/3-lobby-empty-reply at f7a0c3b52d against base 82171fa443: passed in 3 min, 2026-11-11 11:22.

Environment: gcc g++-15 (Ubuntu 15.2.0-16ubuntu1) 15.2.0; cmake cmake version 4.2.3; os Ubuntu 26.04.1 LTS; distro Ubuntu-26.04

Changes: 3 files changed, 23 insertions(+), 5 deletions(-)

StepExpectedResultTime
✅clang-format (make check-format, as CI)no formatting differencesclean2 s
✅Debug build of the branchit compiles (-Werror)up to date (nothing to rebuild)0 s
✅Unit tests, Debug, the branchevery test passes1075 test cases and sections, all passed4 s
✅Release build of the branchit compiles (-Werror)up to date (nothing to rebuild)0 s
✅Unit tests, Release, the branchevery test passes1075 test cases and sections, all passed0 s
➖Debug build of the base with the branch's testsit compiles (-Werror)the new tests use code the change adds, so they can't compile without it; Sim's trials on the base are the before/after proof2 s
➖Release build of the base with the branch's testsit compiles (-Werror)the new tests use code the change adds, so they can't compile without it; Sim's trials on the base are the before/after proof2 s
✅Debug build of the base as it is (for Sim's trials on it)it compiles and installsbuilt in 7 s7 s
➖The new tests, before and after the changeeach new test fails or aborts on the base in at least one build, and passes on the branchthe new tests pass on the branch; they can't build on the base (they test code the change adds), so the base side is Sim's0 s
✅Sim smoke on the branchit runs and finds no crashno crash in 20 s21 s
✅Sim lobby on the baseit reproduces nanny (the bug is there)reproduced: xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp) (ConfirmNewName crashed, GetNewSex crashed, GetAlignment crashed, DefaultChoice crashed, BreakConnect crashed)112 s
✅Sim lobby on the branchit runs and finds no crashno crash in 50 s (ConfirmNewName still up, GetNewSex still up, GetAlignment still up, DefaultChoice still up, BreakConnect still up)50 s
✅Proof the bug was there before the changea new test fails on the base, or a Sim trial shows the bug on the baseSim showed it on the base: Sim lobby on the base0 s
✅Builder's detectors on the focus, base against branchno string_view indexed like a C string is left in the focus6 before, 1 after0 s

The new tests, before and after

TestBase, DebugBase, ReleaseBranch, DebugBranch, Release
first_charnot runnot runpasspasscan't build before the change

Detectors, base against branch

FunctionBaseBranch
MudImpl::nannyargument[0] at src/MudImpl.cpp:711, argument[0] at src/MudImpl.cpp:740, argument[0] at src/MudImpl.cpp:762, argument[0] at src/MudImpl.cpp:854, argument[0] at src/MudImpl.cpp:890, argument[0] at src/MudImpl.cpp:915argument[0] at src/MudImpl.cpp:762
Descriptor::pop_incommnonenone

Logs

✅ Sim smoke on the branch
smoke: Smoke: create a bot, look, quit (20 s, Xania f7a0c3b52d)
  transcript: runs\20261111-112305\Smokea.log
  result: clean
✅ Sim lobby on the base
[00:37] lobby GetNewSex: xania died (prompt=Are you male, female or other (M/F/O)? reply=)
[00:50] lobby GetAlignment: walking to the prompt
[00:56] lobby GetAlignment: xania died (prompt=Which alignment (G/N/E)? reply=)
[01:09] lobby DefaultChoice: walking to the prompt
[01:15] lobby DefaultChoice: xania died (prompt=Customize (Y/N)? reply=)
[01:28] lobby BreakConnect: walking to the prompt
[01:37] lobby BreakConnect: xania died (prompt=Do you wish to connect anyway (Y/N)? reply=)
lobby: Lobby: an empty reply at each login prompt (112 s, Xania 82171fa443)
  ConfirmNewName crashed, GetNewSex crashed, GetAlignment crashed, DefaultChoice crashed, BreakConnect crashed
  failed: lobby: ConfirmNewName: XANIA DIED after an empty reply to "Did I hear that right - 'Lobbya' (Y/N)?"
  failed: lobby: GetNewSex: XANIA DIED after an empty reply to 'Are you male, female or other (M/F/O)?'
  failed: lobby: GetAlignment: XANIA DIED after an empty reply to 'Which alignment (G/N/E)?'
  failed: lobby: DefaultChoice: XANIA DIED after an empty reply to 'Customize (Y/N)?'
  failed: lobby: BreakConnect: XANIA DIED after an empty reply to 'Do you wish to connect anyway (Y/N)?'
  [known] high xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp)
  [known] high xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp)
  [known] high xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp)
  [known] high xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp)
  [known] high xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp)
  crash_ConfirmNewName: runs\lobby-20261111-112312\xania-crash-ConfirmNewName.txt
  crash_GetNewSex: runs\lobby-20261111-112312\xania-crash-GetNewSex.txt
  crash_GetAlignment: runs\lobby-20261111-112312\xania-crash-GetAlignment.txt
  crash_DefaultChoice: runs\lobby-20261111-112312\xania-crash-DefaultChoice.txt
  crash_BreakConnect: runs\lobby-20261111-112312\xania-crash-BreakConnect.txt
  result: clean
✅ Sim lobby on the branch
[00:14] lobby ConfirmNewName: walking to the prompt
[00:19] lobby ConfirmNewName: survived (prompt=Did I hear that right - 'Lobbya' (Y/N)? reply=It's quite simple - type Yes or No:)
[00:19] lobby GetNewSex: walking to the prompt
[00:25] lobby GetNewSex: survived (prompt=Are you male, female or other (M/F/O)? reply=Please specify (M)ale, (F)emale or (O)ther.)
[00:25] lobby GetAlignment: walking to the prompt
[00:31] lobby GetAlignment: survived (prompt=Which alignment (G/N/E)? reply=That's not a valid alignment.
Which alignment (G/N/E)?)
[00:31] lobby DefaultChoice: walking to the prompt
[00:38] lobby DefaultChoice: survived (prompt=Customize (Y/N)? reply=Please answer (Y/N)?)
[00:38] lobby BreakConnect: walking to the prompt
[00:47] lobby BreakConnect: survived (prompt=Do you wish to connect anyway (Y/N)? reply=Please type Y or N?)
lobby: Lobby: an empty reply at each login prompt (50 s, Xania f7a0c3b52d)
  ConfirmNewName still up, GetNewSex still up, GetAlignment still up, DefaultChoice still up, BreakConnect still up
  result: clean

#3 · Xania 82171fa443 · 2026-11-11 10:11

The idea as given

Summary

MudImpl::nanny (src/MudImpl.cpp) reads argument[0] at five lobby prompts without checking that the reply is non-empty. Blank lines do reach nanny (Descriptor::pop_incomm passes them on, and nanny trims a line of spaces to nothing), so an empty reply indexes an empty std::string_view.

  • Debug builds (the default BUILD_TYPE=Debug) abort on the libstdc++ bounds assertion, taking the whole MUD down for everyone connected.
  • Release builds don't abort but read past the end of the view, so the switch acts on whatever byte follows.

Present at 82171fa (current main of this fork and of mattgodbolt/xania).

How to reproduce

On a debug build, answer any of the five prompts below with an empty line (just press Enter). For example, connect, type a new character name, and press Enter at "Did I hear that right - '<name>' (Y/N)?". Each ends in:

/usr/include/c++/15/string_view:260: ... operator[](size_type) const ...: Assertion '__pos < this->_M_len' failed.
Program received signal SIGABRT, Aborted.

with the first frame in our source at that prompt's line in the table below.

Faulty lines

All in src/MudImpl.cpp, in MudImpl::nanny:

LineStatePrompt
711BreakConnect"Do you wish to connect anyway (Y/N)?"
740ConfirmNewName"Did I hear that right - '<name>' (Y/N)?"
854GetNewSex"Are you male, female or other (M/F/O)?"
890GetAlignment"Which alignment (G/N/E)?"
915DefaultChoice"Customize (Y/N)?"

The GetAnsi case already guards it (if (argument.empty()) at line 759), so the pattern is known.

Suggested fix

Treat an empty reply like any other invalid answer and ask again, as GetAnsi does, e.g. for ConfirmNewName:

case DescriptorState::ConfirmNewName:
    if (argument.empty()) {
        d->write(fmt::format("Did I hear that right - '{}' (Y/N)? ", ch->name));
        break;
    }
    switch (argument[0]) {

or switch on a value that can't be out of range: switch (argument.empty() ? '\0' : argument[0]), letting each state's default: branch re-prompt.

How this was found

xania-builder's check (a research tool, not yet published) for string_views indexed like C strings flagged these lines while researching #1 (the same family of bug). xania-sim's lobby trial (python -m xania_sim lobby) then confirmed all five on an isolated test MUD, answering each prompt with an empty line. A check for it (code.lobby-empty-reply) is on its way to Xania Explorer's Bugs page, where it will list any of the five lines still unfixed.

Focus

1. Explorer insights

To decide

2. Architecture

To decide

3. Design patterns

To decide

4. Technical debt

To decide

5. Gameplay

To decide

6. Documentation

To decide

7. Test coverage

To decide

8. History

To decide

Gate plan

What prototype runs on the fork before this can ship: