// Protomux ProtocolInfo lifetime — the reject path frees an info twice.
//
// Build: SAN=address ./build_tests.sh && ./test_protomux_gc
// (glibc catches it too, but a sanitizer names the two frees.)
//
// WHY THIS EXISTS. Protomux is a port of holepunchto/protomux v3.10.1, and JS
// `_gc(info)` only DELETES THE MAP ENTRIES — the object survives until nothing
// references it, so JS may call `_gc` on the same info twice and does:
//
// if (session !== null) session._close(true) // Session#_close -> this._mux._gc(this._info)
// this._gc(info) // ... and again, right here
//
// The port made `_gc` `delete info`, and kept both calls. So a reject frees the
// info inside _close() and then walks it again — a use-after-free read, and a
// second delete of the same block. It reaches the game whenever a peer opens a
// channel the other side has no handler for, which is exactly what happens when
// a peer joins a mesh before its overlay is attached: every already-meshed peer
// gets a reject back and the process dies in an allocator, far from here.
//
// Two muxes wired to each other, one channel for a protocol only one side knows.
// No sockets, no threads, no DHT — the reject is provoked by the protocol layer
// itself, which is where the bug lives.
#include "protomux.h"
#include <cstdio>
#include <string>
#include <vector>
static int tests_run = 0;
static int tests_passed = 0;
static void check(const char *name, bool ok) {
tests_run++;
if (ok) {
tests_passed++;
printf(" PASS %s\n", name);
} else {
printf(" FAIL %s\n", name);
}
}
int main() {
printf("=== Protomux ProtocolInfo lifetime ===\n");
Protomux a, b;
a.set_write_callback([&b](const uint8_t *d, size_t n) { b.recv(d, n); return true; });
b.set_write_callback([&a](const uint8_t *d, size_t n) { a.recv(d, n); return true; });
// B answers for this one. A pair callback is how a real consumer accepts a
// protocol it did not open first — the overlay attaches exactly this way —
// and it is what makes the accepted case distinguishable from the rejected
// one in a loopback, where the two Opens cannot be in flight at once.
ProtomuxChannel *b_known = nullptr;
b.pair("known/1", [&b, &b_known](const std::string &proto, const std::vector<uint8_t> &id) {
b_known = b.create_channel(proto, id, {}, true);
if (b_known) b_known->open();
});
ProtomuxChannel *a_known = a.create_channel("known/1", {}, {}, true);
check("A creates the agreed channel", a_known != nullptr);
a_known->open();
check("B paired and answered", b_known != nullptr);
check("the agreed channel opens on A", a_known->opened);
// And this one B has never heard of, with no pair callback to catch it — so B
// rejects, and A processes that reject. THIS is the line under test.
bool a_closed = false;
ProtomuxChannel *a_orphan = a.create_channel("nobody-has-this/1", {}, {}, true);
check("A creates the unmatched channel", a_orphan != nullptr);
a_orphan->on_close = [&a_closed](bool) { a_closed = true; };
a_orphan->open();
check("A saw the channel rejected", a_closed);
// Survival is the assertion. If _gc double-freed, we are already dead — the
// allocator aborts inside the reject, before this line.
check("the mux still works after the reject", !a.opened("nobody-has-this/1", {}));
check("the agreed channel is unaffected", a.opened("known/1", {}));
// A second reject for a second unknown protocol: the free-list and the info
// map must both still be sane after the first one.
ProtomuxChannel *a_orphan2 = a.create_channel("nobody-has-this/2", {}, {}, true);
check("A creates a second unmatched channel", a_orphan2 != nullptr);
a_orphan2->open();
check("survives a second reject", !a.opened("nobody-has-this/2", {}));
printf("\n=== %d/%d passed ===\n", tests_passed, tests_run);
return tests_passed == tests_run ? 0 : 1;
}