Research, proof and review notes for changes to Xania, the MUD. Source: github.com/abraidotti/xania-builder
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.
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.
| When | Commit | What happened |
|---|---|---|
| 2021-12-05 | 03e4dc4 | "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-11 | 687f6ce | nanny 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-31 | 82823ff, 7bd8c82 | g++-15 becomes the default. Its libstdc++ turns _GLIBCXX_ASSERTIONS on for unoptimised builds (GCC bug 112808), so Debug builds start aborting here. |
| 2026-11-10 | Builder'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. |
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.
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 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:
| Prompt | What 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.
if (argument.empty()) block in each of the five states, as GetAnsi has: five copies of a re-prompt that each default: already writes, and five more places for them to drift apart. Rejected.switch (argument.empty() ? '\0' : argument[0]) inline: the same idea without a name, repeated five times. A named helper says why and can be tested.nanny (in pop_incomm or the game loop): would also change states where an empty line means something (GetAnsi treats it as "no"; the MOTD's "press return"), and wouldn't cover the reconnect path, which calls nanny directly. Rejected.Lobby class): the right long-term home for this, but not in a bug fix. first_char will move with the code.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.
code.lobby-empty-reply check.empty() guards (it flags line 762 and two other guarded places).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(-)
f7a0c3b nanny: an empty reply at a Y/N-style lobby prompt asks again instead of reading past the end (#3)| Step | Expected | Result | Time | |
|---|---|---|---|---|
| ✅ | clang-format (make check-format, as CI) | no formatting differences | clean | 2 s |
| ✅ | Debug build of the branch | it compiles (-Werror) | up to date (nothing to rebuild) | 0 s |
| ✅ | Unit tests, Debug, the branch | every test passes | 1075 test cases and sections, all passed | 4 s |
| ✅ | Release build of the branch | it compiles (-Werror) | up to date (nothing to rebuild) | 0 s |
| ✅ | Unit tests, Release, the branch | every test passes | 1075 test cases and sections, all passed | 0 s |
| ➖ | Debug build of the base with the branch's tests | it 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 proof | 2 s |
| ➖ | Release build of the base with the branch's tests | it 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 proof | 2 s |
| ✅ | Debug build of the base as it is (for Sim's trials on it) | it compiles and installs | built in 7 s | 7 s |
| ➖ | The new tests, before and after the change | each new test fails or aborts on the base in at least one build, and passes on the branch | the 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's | 0 s |
| ✅ | Sim smoke on the branch | it runs and finds no crash | no crash in 20 s | 21 s |
| ✅ | Sim lobby on the base | it 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 branch | it runs and finds no crash | no 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 change | a new test fails on the base, or a Sim trial shows the bug on the base | Sim showed it on the base: Sim lobby on the base | 0 s |
| ✅ | Builder's detectors on the focus, base against branch | no string_view indexed like a C string is left in the focus | 6 before, 1 after | 0 s |
| Test | Base, Debug | Base, Release | Branch, Debug | Branch, Release | |
|---|---|---|---|---|---|
| first_char | not run | not run | pass | pass | can't build before the change |
| Function | Base | Branch |
|---|---|---|
MudImpl::nanny | argument[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:915 | argument[0] at src/MudImpl.cpp:762 |
Descriptor::pop_incomm | none | none |
smoke on the branchsmoke: Smoke: create a bot, look, quit (20 s, Xania f7a0c3b52d)
transcript: runs\20261111-112305\Smokea.log
result: clean
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
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
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.
BUILD_TYPE=Debug) abort on the libstdc++ bounds assertion, taking the whole MUD down for everyone connected.switch acts on whatever byte follows.Present at 82171fa (current main of this fork and of mattgodbolt/xania).
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.
All in src/MudImpl.cpp, in MudImpl::nanny:
| Line | State | Prompt |
|---|---|---|
| 711 | BreakConnect | "Do you wish to connect anyway (Y/N)?" |
| 740 | ConfirmNewName | "Did I hear that right - '<name>' (Y/N)?" |
| 854 | GetNewSex | "Are you male, female or other (M/F/O)?" |
| 890 | GetAlignment | "Which alignment (G/N/E)?" |
| 915 | DefaultChoice | "Customize (Y/N)?" |
The GetAnsi case already guards it (if (argument.empty()) at line 759), so the pattern is known.
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.
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.
MudImpl::nanny (src/MudImpl.cpp:603, 444 lines): named in the ideaDescriptor::pop_incomm (src/Descriptor.cpp:60, 21 lines): named in the ideacode.lobby-empty-reply (present, crash): An empty reply at a login prompt crashes debug builds. MudImpl.cpp:711, MudImpl.cpp:740, MudImpl.cpp:854, MudImpl.cpp:890, MudImpl.cpp:915To decide
fixed (it matches the faulty pattern). Does it, and are there other occurrences to fix at the same time?MudImpl::nanny is defined at src/MudImpl.cpp:603-1046 (444 lines): void MudImpl::nanny(Descriptor *d, std::string_view argument)MudImpl::game_loop (src/MudImpl.cpp:180), MudImpl::handle_doorman_packet (src/MudImpl.cpp:483)nanny calls ArgParser (3 definitions), act (7 definitions), all (2 definitions), announce, bug (2 definitions), by_name, channel, char_to_room, character (3 definitions), check_ban (2 definitions), check_enum_bit, check_playing and 60 morenanny reaches persisted or shared state: inventoryDescriptor::pop_incomm is defined at src/Descriptor.cpp:60-80 (21 lines): std::optional<std::string> Descriptor::pop_incomm()MudImpl::game_loop (src/MudImpl.cpp:180)pop_incomm calls pop_raw, sanitise_input, size (2 definitions), write_directsrc/MudImpl.cpp: 1114 lines, 44 functionssrc/Descriptor.cpp: 322 lines, 26 functionsTo decide
nanny: ArgParser x3 (modern), std::string_view x1 (modern), fmt::format x5 (modern), matches()/matches_start() x4 (modern), range-for over contents x1 (modern), act() x2 (either), '\0' terminator checks x1 (legacy)nanny mixes modern (ArgParser, std::string_view, fmt::format, matches()/matches_start(), range-for over contents) and legacy ('\0' terminator checks) idioms: the modern ones already in the function are the pattern to follow.To decide
nanny indexes string_views like C strings (reading past the end when the view is that long, and never seeing a terminator): argument[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:915nanny is 444 lines long (src/ median 7).nanny: '\0' terminator checksdo_who arg[0] :1249; do_get arg1[3] :641, &arg1[4] :641, arg1[3] :648, &arg1[4] :651, arg1[3] :707; do_permit player[0] :95, player[0] :98; skill_lookup name[0] :39, name[0] :39To decide
nanny can show: "Nanny: bad d->state() {}."; "Illegal name, try another.\n\rName: "; "Denying access to {}@{}."; "You are denied access.\n\r"; "The game is wizlocked. Try again later - a reboot may be imminent.\n\r"; "Password: "; "The game is newlocked. Try again later - a reboot may be imminent.\n\r"; "Your site has been banned.\n\r"; "Did I hear that right - '{}' (Y/N)? "; "Our survey said <Crude buzzer noise>.\n\rWrong password.\n\r" (+49)pop_incomm can show: "Line too long.\n\r"To decide
To decide
src/apps/test/pfutest.cpp: includes MudImpl.hppsrc/test/save_test.cpp: includes MudImpl.hppsrc/test/DescriptorTest.cpp: named after Descriptor.cpp, includes Descriptor.hppnanny.pop_incomm.crash:SIGABRT:MudImpl.cpp:MudImpl::nanny [known, seen 5x]: xania crashed (SIGABRT, failed assertion) in MudImpl::nanny (MudImpl.cpp) (https://github.com/abraidotti/xania/issues/3)To decide
argument is declared at src/MudImpl.cpp:603, a line last changed in 687f6ce (2025-05-11, snellers): Step 2 of the next big refactoring project.argument[0] at src/MudImpl.cpp:711 was last changed in 03e4dc4 (2021-12-05, snellers): nanny() lobby func upgraded to use string_view....(#263)argument[0] at src/MudImpl.cpp:740 was last changed in 03e4dc4 (2021-12-05, snellers): nanny() lobby func upgraded to use string_view....(#263)argument[0] at src/MudImpl.cpp:762 was last changed in 03e4dc4 (2021-12-05, snellers): nanny() lobby func upgraded to use string_view....(#263)argument[0] at src/MudImpl.cpp:854 was last changed in 0fbb3f4 (2021-03-31, snellers): * New Sex wrapper class and enum:argument[0] at src/MudImpl.cpp:890 was last changed in 7a71102 (2020-07-22, Matt Godbolt): Reformat all the code with clang-formatargument[0] at src/MudImpl.cpp:915 was last changed in 7a71102 (2020-07-22, Matt Godbolt): Reformat all the code with clang-format03e4dc4 (2021-12-05, snellers): nanny() lobby func upgraded to use string_view....(#263). The indexing above has been on a view, with no terminator, since thennanny (444 lines) comes from 55 commits; the most: 33a9553 (2020-07-05, Matt Godbolt): Initial import of just code, areas, and html (178 lines); 03e4dc4 (2021-12-05, snellers): nanny() lobby func upgraded to use string_view....(#263) (44 lines); e4a77fe (2020-08-08, Matt Godbolt): Replace free function with call to d->write() throughout (28 lines); f2c8558 (2022-04-03, snellers): Char creation lobby is colourized to improve usability (detect ansi early). (20 lines)pop_incomm (21 lines) comes from 2 commits; the most: d5e56a6 (2020-08-07, Matt Godbolt): Descriptor refactor (#106) (20 lines); f2ee4e1 (2020-08-08, Matt Godbolt): Remove outbuf and outsize (1 lines)7bd8c82 (2026-10-31, snellers): github action upgrade to Ubuntu 26.04 and g++-15; 82823ff (2026-10-31, snellers): General tooling upgrades, largely for Ubuntu 26.04.1 compatibility conda 26.9.4-0 conan 2.33.0 cmake >= 3.31 g++-15 default, g++-13 fallback Compiles on ubunt 26.04.1; 425fd97 (2025-04-07, snellers): Docker: ubuntu 24 is the default these days. Install clang 19. Stop trying to install awscli as it's not in the ubuntu apt sources now. The session doesn't start tmux by default. [skip ci]; e5483b6 (2025-03-28, snellers): Update g++ versions in README. Retire the g++ 10/11 toolchains.; 55c9b1b (2025-03-28, snellers): github: upgrade to ubuntu 24 and use g++-13-fno-omit-frame-pointer -fsanitize=leak -fsanitize=address -fsanitize=undefined-O2 -DNDEBUG -Wno-error=array-boundsnanny)nanny)nanny)nanny)nanny)To decide
What prototype runs on the fork before this can ship:
src/apps/test/pfutest.cpp, src/test/DescriptorTest.cpp, src/test/save_test.cppsmoke: the game still boots and a player can log inchurn --minutes 5: logins and quits reach it (lifetime bugs show here)crash:SIGABRT:MudImpl.cpp:MudImpl::nanny should stop reproducing (https://github.com/abraidotti/xania/issues/3)