Xania Builder

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

#2 · PR on the fork · PR on upstream

Quitting on a tick pulse: char_update reads a freed Descriptor (use-after-free)

gate passed · researched at Xania 82171fa443 · branch fix/2-extract-char-desc at c6aac511ea

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

When a player quits, extract_char unhooks the descriptor from the character (desc->character(nullptr)) but not the character from the descriptor: ch->desc keeps pointing at it. Since September 2023 an extracted character isn't removed from char_list straight away; it waits there for collect_all_garbage() at the end of the pulse. In between, the game loop frees the closed descriptor (reap_closed()) and, on the one pulse in 120 that is a tick, char_update() walks char_list, finds the departed character and follows its dangling ch->desc. AddressSanitizer builds abort with a heap-use-after-free; other builds read freed memory, which is usually harmless and occasionally isn't. The fix clears ch->desc in extract_char, so the character is detached both ways before anything can free the descriptor.

Root cause

WhenCommitWhat happened
2020-07-0533a9553The Merc-era extract_char already only did ch->desc->character = nullptr (one way). Harmless then: the character was deleted on the spot, so nothing could follow its desc afterwards.
2023-09-08dc2b39c"Implement new solution for collecting garbage Object & Char. This enables extract_char() and extract_obj() to mark entities reapable while the owning vector is being iterated on." char_list.erase(...) becomes reapable_chars.push_back(ch). The character now outlives the call until the end of the pulse, still pointing at its descriptor.
From here on, a quit (or an immortal's purge of a player) followed in the same pulse by a tick reads the freed descriptor.
2026-11-09Xania Sim's regression suite hits it about one run in three on an ASan build; its churn trial then reproduces it in about a minute (42 quits). Filed as #2.

The order of events in one pulse (MudImpl::game_loop):

1. Input: the player's quit runs do_quit: save_char_obj, d = ch->desc, extract_char(ch, true), d->close(). 2. MudImpl.cpp:327, descriptors().reap_closed(): the closed Descriptor is erased from its map and freed. 3. MudImpl.cpp:332, update_handler(): on a tick pulse it runs char_update() (update.cpp:882), which walks char_list, where the extracted character still is. 4. update.cpp:890, collect_all_garbage(): only now is the character removed and deleted.

Step 3 follows the dangling ch->desc in (at least) three places:

WhereHow it reaches descSeen
update.cpp:705 ch->is_immortal()get_trust() → desc->is_switched()the ASan report in #2
update.cpp:678 mana_gain(ch)get_skill() → Char::player() → desc->person()Sim's area trial, a second signature with the same cause
update.cpp:815 the autosave loopch->desc != nullptr && ch->desc->channel() % save_every_n == save_numberby reading: and if the stale channel number matched, save_char_obj would re-save a character that had already quit, with in_room == nullptr

A tick is every 120th pulse (PULSE_TICK = 30 s at 4 pulses a second, update.cpp:879), so a single quit has a 1-in-120 chance of landing on one: rare for people, routine for anything that connects a lot. (The issue said ticks come every 30-60 s; the code says exactly 30.)

The fix

// Detach both ways. ch stays in char_list until collect_all_garbage() at the end of the pulse, and the descriptor
// may be closed and freed before then (do_quit closes it, DescriptorList::reap_closed() frees it), so the
// character must not keep pointing at it: char_update()'s get_trust(), get_skill() and autosave all follow
// ch->desc.
if (ch->desc) {
    ch->desc->character(nullptr);
    ch->desc = nullptr;
}

Every reader in char_update already treats a null desc as "no connection" (a link-dead player has one), so the departed character is simply treated as link-dead for the rest of its last pulse: no trust lookup through the descriptor, no autosave.

The callers are fine with it: do_quit and do_purge both copy ch->desc into a local before calling extract_char, and close that; raw_kill of a player uses extract_char(victim, false), which returns before this code (the player stays connected, and the new test checks that); the other callers extract NPCs, which only have a descriptor while an immortal is switched into them, and extract_char already returns the immortal to their own body (do_return) before this point.

Alternatives considered

Answers to the dossier's questions

1. Explorer. No Explorer check covers lifetimes; nothing to update.

2. Architecture. The fix is in extract_char, the one place every exit from the world goes through, so it covers quit, delete, deny (both via do_quit), purge of a player, and the idle auto-quit in char_update itself. Every caller still gets what it expects (see The fix).

3. Design patterns. It mirrors the line above it: the code already cleared one side of the link; now it clears both, the usual way to break a two-way pointer.

4. Technical debt. Pays down the dangling pointer. Deliberately leaves the larger debt the dossier's research points at: since dc2b39c, an extracted character still gets the rest of its pulse in every loop over char_list (regeneration, affects wearing off, plague and poison damage), with in_room == nullptr. It no longer reads freed memory, but it's still processed. That's worth its own issue: a Char::is_extracted() that loops skip, or a filtered view of char_list.

5. Gameplay. Nothing a player can see: quitting works as before. A debug MUD stops crashing when someone quits on a tick, and a release MUD stops reading freed memory then (and can no longer re-save a departed character on a stale channel number).

6. Documentation. None needed.

7. Tests. ExtractCharTest.cpp: after extract_char(ch, true) neither side points at the other and the character is queued for garbage collection; after extract_char(ch, false) (a player's death) the connection is kept. On the base the first section fails (ch->desc is still set) in both builds. From the player's side, Sim's churn (six bots creating characters and quitting in a loop on the AddressSanitizer build) reproduces the crash on the base and has to run clean for 15 minutes on the branch.

Because the bug depends on timing, a clean run only counts as evidence if it's long enough. In the gate, the base crashed after 42 quits (68 s); the branch survived 1,260 quits in 15 minutes. If each quit had the 1-in-120 chance of landing on a tick worked out above, the chance of 1,260 quits all missing with the bug still there would be (119/120)^1260 ≈ 0.000026, about 1 in 38,000. (That model is an estimate: bots quitting in step aren't fully independent. It's the order of magnitude that matters.)

8. History. Answered under Root cause. Upstream: upstream #182 (closed) was about lists mutated while iterated, the problem dc2b39c solved; upstream #127 (closed, "Heap use after free in xania") was an earlier lifetime bug in the same area.

Follow-ups (not in this PR)

Branch fix/2-extract-char-desc at c6aac511ea against base 82171fa443: passed in 18 min, 2026-11-11 11:03.

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: 2 files changed, 76 insertions(+), 1 deletion(-)

StepExpectedResultTime
✅clang-format (make check-format, as CI)no formatting differencesclean2 s
✅Debug build of the branchit compiles (-Werror)built in 23 s23 s
✅Unit tests, Debug, the branchevery test passes1074 test cases and sections, all passed3 s
✅Release build of the branchit compiles (-Werror)built in 21 s21 s
✅Unit tests, Release, the branchevery test passes1074 test cases and sections, all passed0 s
✅Debug build of the base with the branch's testsit compiles (-Werror)built in 20 s20 s
➖Unit tests, Debug, the base with the branch's teststhe new tests fail here (they pin the bug)1 of 1074 failed: extract_char detaches the character from its descriptor both ways/leaving the world (quit, purge)2 s
✅Release build of the base with the branch's testsit compiles (-Werror)built in 21 s21 s
➖Unit tests, Release, the base with the branch's teststhe new tests fail here (they pin the bug)1 of 1074 failed: extract_char detaches the character from its descriptor both ways/leaving the world (quit, purge)0 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 branch1 of 1 new tests pin the fix0 s
✅Sim smoke on the branchit runs and finds no crashno crash in 21 s22 s
✅Sim churn --bots 6 --minutes 15 on the baseit reproduces heap-use-after-free (the bug is there)reproduced: xania crashed (AddressSanitizer: heap-use-after-free) in Descriptor::is_switched (Descriptor.hpp)68 s
✅Sim churn --bots 6 --minutes 15 on the branchit runs and finds no crashno crash in 912 s (quits 1260, bot errors 0, crashed False)913 s
✅Builder's detectors on the focus, base against branchno string_view indexed like a C string is left in the focus0 before, 0 after0 s

The new tests, before and after

TestBase, DebugBase, ReleaseBranch, DebugBranch, Release
extract_char detaches the character from its descriptor both waysfailfailpasspasspins the fix

Detectors, base against branch

FunctionBaseBranch
do_quitnonenone
extract_charnonenone
MudImpl::game_loopnonenone
update_handlernonenone
char_updatenonenone
Char::is_immortalnonenone
collect_all_garbagenonenone
Char::get_trustnonenone
is_switchednonenone

Logs

✅ Sim smoke on the branch
smoke: Smoke: create a bot, look, quit (21 s, Xania c6aac511ea)
  transcript: runs\20261111-110532\Smokea.log
  result: clean
✅ Sim churn --bots 6 --minutes 15 on the base
churn: Churn: 6 bots creating and quitting (68 s, Xania 82171fa443)
  quits 42, bot errors 0, crashed True
  [known] high xania crashed (AddressSanitizer: heap-use-after-free) in Descriptor::is_switched (Descriptor.hpp)
  crash_report: runs\churn-20261111-110538\xania-crash.txt
  result: clean
✅ Sim churn --bots 6 --minutes 15 on the branch
churn: Churn: 6 bots creating and quitting (912 s, Xania c6aac511ea)
  quits 1260, bot errors 0, crashed False
  result: clean

#2 · Xania 82171fa443 · 2026-11-11 10:16

The idea as given

Summary

A player quitting on the same pulse as a tick makes char_update() read a freed Descriptor. extract_char leaves ch->desc pointing at the descriptor and leaves the character in char_list until garbage collection, so when the game loop frees the closed descriptor and a tick runs before the character is collected, char_update() follows the dangling pointer.

  • AddressSanitizer builds abort with a heap-use-after-free.
  • Normal builds have undefined behaviour: usually a harmless read of stale memory, occasionally a crash or a wrong is_immortal() answer.

It needs a quit in the same 250 ms pulse as a tick (ticks are every 30-60 s): rare for people, common for anything that logs in and out a lot.

Present at 82171fa (current main of both this fork and mattgodbolt/xania; same lines in handler.cpp and update.cpp).

How to reproduce

With an ASan build, have several clients create a character and quit in a loop. xania-sim's churn trial does this and crashes it in about a minute (42 quits in 59 s on 82171fa):

python -m xania_sim churn --bots 6 --minutes 15
==95237==ERROR: AddressSanitizer: heap-use-after-free
READ of size 8
    #0 Descriptor::is_switched() const     src/Descriptor.hpp:163
    #1 Char::get_trust() const             src/Char.cpp:118
    #2 Char::is_immortal() const           src/Char.cpp:113
    #3 char_update()                       src/update.cpp:705
    #4 update_handler(Mud&)                src/update.cpp:882
    #5 MudImpl::game_loop()                src/MudImpl.cpp:332
freed by:
    DescriptorList::reap_closed()          src/DescriptorList.cpp:8
    MudImpl::game_loop()                   src/MudImpl.cpp:327
allocated by:
    DescriptorList::create(...)            src/DescriptorList.cpp:18
    MudImpl::handle_doorman_packet(...)    src/MudImpl.cpp:487

Faulty lines

WhereCodeRole
src/act_comm.cpp:375-379do_quit: save, extract_char(ch, true), then d->close()starts it
src/handler.cpp:632-635extract_char: ch->desc->character(nullptr)the bug: detaches the descriptor from the character but leaves ch->desc pointing at it, and only queues the character in reapable_chars instead of removing it from char_list
src/MudImpl.cpp:327descriptors().reap_closed()frees the closed descriptor, before update_handler runs (line 332)
src/update.cpp:882 / :890char_update() runs before collect_all_garbage()on a tick pulse, the quitting character is still iterated
src/update.cpp:705ch->is_immortal()goes through get_trust() to desc->is_switched() on the freed descriptor

Suggested fix

In extract_char, clear the back-pointer too:

if (ch->desc) {
    ch->desc->character(nullptr);
    ch->desc = nullptr;
}

do_quit already reads d = ch->desc before calling extract_char, so it still closes the right descriptor. Alternatively (or as well), char_update and the other per-tick loops could skip characters already queued in reapable_chars.

How this was found

It first showed up in xania-sim's regression suite (1 run in 3), during a scenario that creates a character, quits and logs back in, on an AddressSanitizer build. xania-sim's churn trial was then written to reproduce it on purpose. It also turns up in the area trial, whose bots quit together at the end of each level.

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: