Skip to content

Windows poll engine skips the last ready socket while a closed socket is in the poll set #1657

Description

@Gluzix

Windows poll engine skips the last ready socket while a closed socket is in the poll set

Found by Claude (Anthropic's AI assistant) while analysing the logs of my Discord bot.

Git commit reference
fd5802b (tag v10.1.6), used through the prebuilt package libdpp-10.1.6-win64-debug-vs2022. src/dpp/socketengines/poll.cpp is byte-identical on master (786ceb2) and dev (1631331) as of 2026-09-28.

Describe the bug

socket_engine_poll::process_events() takes the return value of WSAPoll as the number of entries to handle. It counts every entry with revents > 0 against that number and stops when the count reaches it.

On Windows, an entry whose socket was closed before the WSAPoll call gets POLLNVAL in revents, but WSAPoll does not count that entry in its return value and does not wake up for it. (A socket closed by another thread during the wait ends the wait and is counted once, in that call.) So when such an entry sits in front of the last ready entry, the loop stops before it reaches that last ready entry. Because new sockets are appended to the set, that entry is the most recently registered of the ready sockets.

The skipped socket stays ready, so the next WSAPoll returns at once, and the loop spins. Sockets registered before the closed entry keep working.

src/dpp/socketengines/poll.cpp at v10.1.6:

72		int i = dpp::compat::poll(out_set, static_cast<unsigned int>(fd_count), poll_delay);
73		int processed = 0;
74
75		for (size_t index = 0; index < fd_count && processed < i; index++) {
76			const dpp::socket fd = out_set[index].fd;
77			const short revents = out_set[index].revents;
78
79			if (revents > 0) {
80				processed++;
81			}

On Windows, dpp::compat::poll is WSAPoll (include/dpp/compat.h:29-30). New entries are appended (poll.cpp:152, poll_set.push_back(fd_info);).

According to Microsoft's documentation of WSAPoll, a positive return value is the number of entries whose revents is nonzero. That is not what happens here: in Test 1 below, three entries have nonzero revents and WSAPoll returns 2.

Also, the engine has no POLLNVAL branch (lines 97-124 handle only POLLHUP, POLLERR, POLLIN and POLLOUT). An entry is removed only when its flags contain WANT_DELETION (lines 131-135), or when a new socket with the same number is registered (src/dpp/socketengine.cpp:41-46). So a closed entry without that flag stays in the set until a new socket gets its number.

Expected behavior

Every ready socket's handler runs, whatever else is in the set.

To Reproduce

Two small programs. Neither needs a token or the network; they use loopback sockets only.

1. repro_wsapoll.cpp, Winsock only, no DPP. It polls three entries: a readable socket, a closed socket, and another readable socket. Then it applies the loop condition of poll.cpp:75-81 to the result. Test 2 polls a closed socket together with an idle socket.

repro_wsapoll.cpp
// What WSAPoll reports for a set that contains a closed socket.
// Winsock only, no DPP. Loopback sockets, no network.
// Build: cl /nologo /EHsc /W3 repro_wsapoll.cpp ws2_32.lib
#include <winsock2.h>
#include <chrono>
#include <cstdio>

// A connected TCP pair over 127.0.0.1.
static void tcp_pair(SOCKET &mine, SOCKET &peer) {
    SOCKET listener = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    bind(listener, (sockaddr *)&addr, sizeof addr);
    listen(listener, 1);
    int len = sizeof addr;
    getsockname(listener, (sockaddr *)&addr, &len);
    mine = socket(AF_INET, SOCK_STREAM, 0);
    connect(mine, (sockaddr *)&addr, sizeof addr);
    peer = accept(listener, nullptr, nullptr);
    closesocket(listener);
}

static void print_entry(int index, const char *what, const WSAPOLLFD &e) {
    printf("  entry %d, %-14s revents=0x%04x%s%s\n", index, what, e.revents,
           (e.revents & POLLRDNORM) ? " POLLRDNORM" : "",
           (e.revents & POLLNVAL) ? " POLLNVAL" : "");
}

int main() {
    WSADATA wsa;
    WSAStartup(MAKEWORD(2, 2), &wsa);

    SOCKET a, a_peer, b, b_peer;
    tcp_pair(a, a_peer);
    tcp_pair(b, b_peer);
    send(a_peer, "x", 1, 0);  // a and b have data to read
    send(b_peer, "x", 1, 0);
    Sleep(100);

    SOCKET closed = socket(AF_INET, SOCK_STREAM, 0);
    closesocket(closed);  // the number no longer names a socket

    printf("Test 1: [ready socket, closed socket, ready socket], timeout 1000 ms\n");
    WSAPOLLFD set[3] = {{a, POLLIN, 0}, {closed, POLLIN | POLLOUT, 0}, {b, POLLIN, 0}};
    int ret = WSAPoll(set, 3, 1000);
    printf("  WSAPoll returned %d\n", ret);
    print_entry(0, "ready socket:", set[0]);
    print_entry(1, "closed socket:", set[1]);
    print_entry(2, "ready socket:", set[2]);
    int nonzero = 0;
    for (const WSAPOLLFD &e : set) {
        nonzero += e.revents != 0;
    }
    printf("  entries with revents != 0: %d\n", nonzero);

    // The loop condition of src/dpp/socketengines/poll.cpp:75-81 applied to this result.
    printf("  DPP's loop visits entries:");
    int processed = 0;
    for (int index = 0; index < 3 && processed < ret; index++) {
        printf(" %d", index);
        if (set[index].revents > 0) {
            processed++;
        }
    }
    printf("\n");

    printf("Test 2: [closed socket, idle socket], timeout 1000 ms\n");
    WSAPOLLFD idle_set[2] = {{closed, POLLIN | POLLOUT, 0}, {a_peer, POLLIN, 0}};
    auto start = std::chrono::steady_clock::now();
    ret = WSAPoll(idle_set, 2, 1000);
    auto took = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start);
    printf("  WSAPoll returned %d after %lld ms\n", ret, (long long)took.count());
    print_entry(0, "closed socket:", idle_set[0]);
    print_entry(1, "idle socket:", idle_set[1]);
    return 0;
}
cl /nologo /EHsc /W3 repro_wsapoll.cpp ws2_32.lib
Test 1: [ready socket, closed socket, ready socket], timeout 1000 ms
  WSAPoll returned 2
  entry 0, ready socket:  revents=0x0100 POLLRDNORM
  entry 1, closed socket: revents=0x0004 POLLNVAL
  entry 2, ready socket:  revents=0x0100 POLLRDNORM
  entries with revents != 0: 3
  DPP's loop visits entries: 0 1
Test 2: [closed socket, idle socket], timeout 1000 ms
  WSAPoll returned 0 after 1014 ms
  entry 0, closed socket: revents=0x0004 POLLNVAL
  entry 1, idle socket:   revents=0x0000

2. repro_engine.cpp, against dpp.dll through the public headers. It creates a dpp::cluster without a token and never calls start(). It drives bot.socketengine->process_events() by hand.

  • G is registered first.

  • A is registered next. A's read handler does what ssl_connection does when handle_buffer() has closed the connection and still returns true:

    • delete_socket(fd) and closesocket(fd), as in close() (src/dpp/sslconnection.cpp:690-694);
    • then update_socket() with WANT_READ | WANT_WRITE | WANT_ERROR, as in on_read() (sslconnection.cpp:394-401).

    update_socket() overwrites the whole entry (src/dpp/socketengine.cpp:55), so the WANT_DELETION flag is lost and the entry stays.

  • B is registered after A. Then G and B get one byte each, and the program runs process_events() for one second.

  • C is registered after B, gets one byte, and the program runs for one more second.

  • The control run is the same, except that A's handler does not call update_socket().

The program creates every socket before it closes A. A socket registered later with A's number would replace A's entry (socketengine.cpp:41-46).

repro_engine.cpp
// DPP 10.1.6 on Windows: a closed socket whose entry stays in the poll engine
// makes process_events() skip the last ready socket, and the loop spins.
// Loopback sockets only: no bot token, no Discord, no network.
//
// Socket A is closed inside its own read handler, the way ssl_connection
// does it when handle_buffer() closes the connection and still returns true
// (line numbers at tag v10.1.6):
//   close():   socketengine->delete_socket(sfd); close_socket(sfd);  src/dpp/sslconnection.cpp:690-694
//   on_read(): se.flags = WANT_READ | WANT_WRITE | WANT_ERROR;
//              socketengine->update_socket(se);                       src/dpp/sslconnection.cpp:394-401
// update_socket() overwrites the whole entry, WANT_DELETION included
// (src/dpp/socketengine.cpp:55), so the engine never removes it
// (src/dpp/socketengines/poll.cpp:131-135).
// The control run closes A the same way but without the update_socket() call.
#include <dpp/dpp.h>
#include <winsock2.h>
#include <atomic>
#include <chrono>
#include <cstdio>

using namespace std::chrono;

// A connected TCP pair over 127.0.0.1.
static void tcp_pair(SOCKET &mine, SOCKET &peer) {
    SOCKET listener = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    bind(listener, (sockaddr *)&addr, sizeof addr);
    listen(listener, 1);
    int len = sizeof addr;
    getsockname(listener, (sockaddr *)&addr, &len);
    mine = socket(AF_INET, SOCK_STREAM, 0);
    connect(mine, (sockaddr *)&addr, sizeof addr);
    peer = accept(listener, nullptr, nullptr);
    closesocket(listener);
}

static dpp::socket_events reader(SOCKET s, int &calls) {
    return dpp::socket_events(s, dpp::WANT_READ | dpp::WANT_ERROR,
        [&calls](dpp::socket fd, const dpp::socket_events &) {
            char buf[16];
            recv(fd, buf, sizeof buf, 0);
            ++calls;
        },
        {}, [](dpp::socket, const dpp::socket_events &, int) {});
}

// Calls process_events() for one second. A call that is waiting in WSAPoll when
// the second ends is finished and counted.
static long run_one_second(dpp::socket_engine_base &engine) {
    long passes = 0;
    auto end = steady_clock::now() + seconds(1);
    while (steady_clock::now() < end) {
        engine.process_events();
        ++passes;
    }
    return passes;
}

static void run(bool rearm) {
    printf(rearm ? "A closed in its read handler, then update_socket() (as ssl_connection::on_read does):\n"
                 : "Control: A closed in its read handler, no update_socket():\n");
    int g_calls = 0, b_calls = 0, c_calls = 0;
    std::atomic<bool> a_closed{false}, a_removed{false};

    // All sockets exist before A is closed, so none of them can get A's number
    // (a socket registered with that number replaces A's entry,
    // src/dpp/socketengine.cpp:41-46).
    SOCKET g, g_peer, a, a_peer, b, b_peer, c, c_peer;
    tcp_pair(g, g_peer);
    tcp_pair(a, a_peer);
    tcp_pair(b, b_peer);
    tcp_pair(c, c_peer);

    dpp::cluster bot;  // no token; start() is never called
    dpp::socket_engine_base &engine = *bot.socketengine;
    bot.on_socket_close([&](const dpp::socket_close_t &e) {
        if (e.fd == a) {
            a_removed = true;
        }
    });

    engine.register_socket(reader(g, g_calls));
    engine.register_socket(dpp::socket_events(a, dpp::WANT_READ | dpp::WANT_ERROR,
        [&](dpp::socket fd, const dpp::socket_events &ev) {
            char buf[16];
            recv(fd, buf, sizeof buf, 0);
            engine.delete_socket(fd);
            closesocket(fd);
            if (rearm) {
                dpp::socket_events se{ev};
                se.flags = dpp::WANT_READ | dpp::WANT_WRITE | dpp::WANT_ERROR;
                engine.update_socket(se);
            }
            a_closed = true;
        },
        {}, [](dpp::socket, const dpp::socket_events &, int) {}));
    send(a_peer, "x", 1, 0);
    auto deadline = steady_clock::now() + seconds(3);
    while (!a_closed && steady_clock::now() < deadline) {
        engine.process_events();
    }
    printf("  A closed: %s; engine removed A's entry: %s\n", a_closed ? "yes" : "no", a_removed ? "yes" : "no");

    engine.register_socket(reader(b, b_calls));
    send(g_peer, "x", 1, 0);
    send(b_peer, "x", 1, 0);
    long passes = run_one_second(engine);
    printf("  G (registered before A) and B (after A) get data: G read calls=%d, B read calls=%d, passes in 1 s=%ld\n",
           g_calls, b_calls, passes);

    engine.register_socket(reader(c, c_calls));
    send(c_peer, "x", 1, 0);
    passes = run_one_second(engine);
    printf("  C (after B) gets data:                            B read calls=%d, C read calls=%d, passes in 1 s=%ld\n",
           b_calls, c_calls, passes);
    printf("  engine removed A's entry by the end: %s\n\n", a_removed ? "yes" : "no");

    for (SOCKET s : {g, g_peer, a_peer, b, b_peer, c, c_peer}) {
        closesocket(s);
    }
}

int main() {
    WSADATA wsa;
    WSAStartup(MAKEWORD(2, 2), &wsa);
    run(true);
    run(false);
    return 0;
}
cl /nologo /EHsc /std:c++17 /W3 /wd4251 /utf-8 /MDd /I"<dpp>\include\dpp-10.1" repro_engine.cpp /link /LIBPATH:"<dpp>\lib\dpp-10.1" dpp.lib ws2_32.lib

(/MDd for the debug package; use /MD with a release build.) Run it with <dpp>\bin on PATH.

A closed in its read handler, then update_socket() (as ssl_connection::on_read does):
  A closed: yes; engine removed A's entry: no
  G (registered before A) and B (after A) get data: G read calls=1, B read calls=0, passes in 1 s=505032
  C (after B) gets data:                            B read calls=1, C read calls=0, passes in 1 s=468450
  engine removed A's entry by the end: no

Control: A closed in its read handler, no update_socket():
  A closed: yes; engine removed A's entry: yes
  G (registered before A) and B (after A) get data: G read calls=1, B read calls=1, passes in 1 s=4
  C (after B) gets data:                            B read calls=1, C read calls=1, passes in 1 s=3
  engine removed A's entry by the end: yes

With the closed entry, B's handler is never called and the loop makes several hundred thousand passes a second. B is served only once C is registered behind it, and then C starves instead. In four more runs the read counts were the same every time.

What my bot showed

At 20:36:10 DPP closed my bot's voice websocket on "Voice session error: 1002 ... Endpoint received a malformed frame". These are all the voice connections opened after that:

  • The reconnect at 20:36:11 got no hello from Discord in the 9 s before the bot closed it.
  • The connection at 20:36:22 got its hello after 0.2 s.
  • The connections at 20:37:35 and 20:39:06 waited 19 s and 6.5 s. They got their hello 42 and 39 ms after the bot's /queue handler returned. That handler sends its reply as a REST request. A REST request runs as soon as it is posted, unless it is rate limited, and each one opens a new HTTPS connection (src/dpp/queues.cpp:228, 446-447; src/dpp/sslconnection.cpp:237, 281).
  • None of the six opened from 20:40:25 on got a hello.

After 20:36:10, three POST .../channels/<cut>/messages REST requests failed with "Timed out while waiting for the response" (logged at 20:37:23, 20:38:54 and 20:40:13).

From 20:40:22 to the end of the log (21:11), all 61 gateway heartbeat lines are stamped .000 or .001; the 131 before 20:36 fell anywhere between .001 and .603. DPP writes the heartbeat from its one-second timer (src/dpp/discordclient.cpp:427-452). DPP runs its timers from prune() at the start of process_events() (poll.cpp:50, src/dpp/socketengine.cpp:112-131), so a timer line at .000 means process_events() was entered within a millisecond of the second changing. I did not look at the CPU load at the time.

How DPP itself leaves a closed entry in the set

ssl_connection::on_read() calls update_socket() with WANT_READ | WANT_WRITE | WANT_ERROR whenever handle_buffer() returns true (sslconnection.cpp:394-401). It does this even when handle_buffer() has just called close().

For a websocket that receives a close frame:

  • websocket_client calls error() and still returns true from handle_buffer() (src/dpp/wsclient.cpp:187, 195, 278-284);
  • discord_voice_client::error() calls close() (src/dpp/discordvoiceclient.cpp:239).

If the same pass also has POLLOUT, the on_write lambda deletes the entry again (poll.cpp:119-124, sslconnection.cpp:632-637). With POLLIN only, the entry stays. repro_engine.cpp makes exactly these calls.

A voice client's error() is called only from websocket_client::parseheader(): with the close code in the OP_CLOSE branch (wsclient.cpp:282), and with 0 in the default branch (line 288). So the 1002 in my bot's log came from a close frame.

The on_read behaviour is a separate problem, but any entry like this makes the engine skip sockets.

Claude's reading of my log (not proven)

Claude's reading is that this is how the closed entry got into my bot's engine: the 1002 came from a close frame, which takes the path above, and the first voice reconnect after it (20:36:11) got no hello. Claude also reads the heartbeat lines at .000 and .001 as the loop spinning, as it does in repro_engine. The log has no socket numbers, so this cannot be shown directly.

Suggested fix

The loop should not depend on WSAPoll's count. It could walk the whole set, for example by removing && processed < i from line 75. Or it could leave entries with POLLNVAL out of processed.

Separately, because there is no POLLNVAL branch, a closed entry without WANT_DELETION is not removed until a new socket gets its number. The engine could drop it the way lines 131-135 do.

I have not built DPP with either change.

System Details

  • OS: Windows 11 Pro 25H2 (build 26200), x64
  • Compiler: MSVC 19.44.35222 (Visual Studio 2022)
  • DPP: 10.1.6, prebuilt libdpp-10.1.6-win64-debug-vs2022 (debug build, programs compiled with /MDd)
  • Discord client: not relevant

Activity

  1. braindigitalis commented on Sep 29, 2026

    @braindigitalis
    Contributor

    I appreciate the detail in the bug report but it would be nice to make Claude get to the point instead of writing a novel lol

  2. Gluzix commented on Sep 29, 2026

    @Gluzix
    Author

    Fair point, sorry for the wall of text. Short version:

    • Where: src/dpp/socketengines/poll.cpp:72-81, Windows only.
    • What: the loop stops once processed reaches the return value of WSAPoll, and it counts every entry with revents > 0. For a closed socket WSAPoll sets POLLNVAL in revents but does not count that entry in its return value. With one closed socket in the set, the loop stops one entry early and never reaches the last ready socket.
    • Effect: that socket's handler never runs, the next WSAPoll returns at once, and the loop spins. In my bot, REST requests timed out and voice never reconnected until a restart.
    • Repro: repro_wsapoll.cpp above, Winsock only, no DPP: WSAPoll returns 2 while 3 entries have nonzero revents.
    • Suggested fix: remove && processed < i from line 75 so the loop walks the whole set, and drop an entry that has POLLNVAL.
  3. braindigitalis commented on Sep 29, 2026

    @braindigitalis
    Contributor

    I have summarised your issue as follows with a simplified patch:


    On Windows, "WSAPoll()" can set "POLLNVAL" on a closed socket without including that entry in its return count.

    DPP currently uses the return value to stop iterating after that many entries with non-zero "revents". If a "POLLNVAL" entry appears before the last ready socket, it is counted by DPP but not by "WSAPoll()", causing iteration to stop early. The ready socket is skipped and can cause the event loop to spin.

    The simple fix is to scan the entire returned poll set:

    - for (size_t index = 0; index < fd_count && processed < i; index++) {
    + for (size_t index = 0; index < fd_count; index++) {

    "processed" and its increment can then be removed. The return value from "poll()" should still be retained for error handling.

    This is also safe for POSIX "poll()", where entries without events simply have "revents == 0".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions