Skip to content

Sockets: make the Qt client's close() cancel in-flight sends - #1831

Merged
Mysticial merged 2 commits into
mainfrom
qt-client-close-cancel
Nov 3, 2026
Merged

Mysticial merged 2 commits into
mainfrom
qt-client-close-cancel

Conversation

@pa-devbot

Copy link
Copy Markdown
Collaborator

Task #3 from the close()-cancel plan: proper refactor of the Qt client so close() cancels in-flight sends, per the contract specified in #1830.

Behavior

  • close() no longer waits for in-flight send() calls to finish. It cancels them: their callers return promptly with the number of bytes actually written so far (best effort), and only then is the QTcpSocket destroyed (that destruction still has to run on the event thread, so close() itself blocks until the socket is destroyed — same as before).
  • The infinite-default send() no longer carries the 5 s safety bound: done is now guaranteed to be set by either the event-thread write loop (deadline / socket error / Qt's userspace acceptance) or the cancel in close()/connect(), so the wait is always ended. Finite timeouts keep their timeout + 1 s queueing margin.
  • The write loop now reports a running byte count, so a cancel mid-write reports the bytes written so far instead of always 0.

How it works

  • In-flight SendData objects are tracked in a list under m_lock (replaces the old in-flight counter). send() adds its entry before emitting the queued write.
  • close() and connect() (which destroys the previous socket) claim the list and mark every entry done + cancelled, waking the send() callers. The queued write loops see the flag and finish without writing.
  • Lifetime is unchanged: the shared QSharedPointer keeps each packet alive until its queued call runs (or is discarded with the socket); the event loop is FIFO, so a queued write still runs before the destruction and just no-ops.

Test

New Qt-only test: with the client's event thread blocked for 6 s in on_receive_data, a send() started on the test thread queues behind the stall, and a close() from a worker thread must wake the send within a few milliseconds of starting (it asserts the lag between close() starting and the send returning). Without the cancel, the send would only wake when the 6 s stall ends and the queued write finally runs.

Full local suite green (20,330 / 0 / 3). No changes to the POSIX/Win backends or to the server (that's #4).

close() now cancels the in-flight send() calls instead of waiting for
them to finish: it marks them done so their callers return promptly
with the number of bytes actually written so far (best effort), then
destroys the QTcpSocket as before.

How it works:

- In-flight SendData objects are tracked in a list under m_lock
  (replacing the old send-in-flight counter). send() adds its entry
  before emitting the queued write.
- close() and connect() (which destroys the previous socket) claim the
  list and mark every entry done + cancelled, waking the send()
  callers. The queued write loops see the flag and finish without
  writing (or update the running byte count as they go, so a close
  mid-write reports the bytes written so far).
- The infinite default no longer carries the 5 s safety bound: "done"
  is now guaranteed to be set by either the event-thread write loop
  (deadline / socket error / Qt's userspace acceptance) or the
  cancel, so the wait can be unbounded. Finite timeouts keep their
  "timeout + 1 s" queueing margin.
- The write loop reports its running byte count, so cancellation
  reports a best-effort count instead of always 0.

Also adds a test: with the client's event thread blocked for 6 s in
on_receive_data, a send started on the test thread queues behind the
stall, and a close() from a worker thread must wake the send within a
few milliseconds of starting (without the cancel, the send would only
wake when the stall ends and the queued write finally runs).
@Mysticial
Mysticial merged commit 8a770a5 into main Nov 3, 2026
2 of 7 checks passed
@Mysticial
Mysticial deleted the qt-client-close-cancel branch November 3, 2026 07:50
@dayfine

dayfine commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Sockets (Qt) - close() cancels an in-flight send hangs for us on macOS: 4 of 4 runs on current main never finished (one full run, three --filtered to the socket tests), with the machine under heavy load (load average around 19, a parallel build running). It hangs rather than fails, so a full unit-test run never completes.

I think it's a race in ClientSocket_Qt::send() between the null check and registering the send:

{
    std::lock_guard<Mutex> lg(m_lock);
    socket = m_socket;                 // (1) non-null, CONNECTED
}
...
send_data->packet.assign((const char*)data, bytes);   // (2) 256 MiB copy, slow under load
...
{
    std::lock_guard<Mutex> lg(m_lock);
    m_inflight_sends.push_back(send_data);            // (3)
}
emit internal_send(send_data);                        // (4)
// then waits on send_data->cv with no time bound (default timeout)

If close() runs between (1) and (3), it nulls m_socket, calls cancel_inflight_sends() on a list that doesn't have this send yet, and destroys the socket. send() then registers the send and emits internal_send to a receiver that no longer exists, so the queued slot never runs. Nothing sets done, and the caller waits forever. The test gives the send only a 100 ms head start before close(). Under load, the thread-pool dispatch plus copying the 256 MiB payload can take longer than that, which lands close() in that window. The 60 s checks after send_task.wait_and_ignore_exceptions() are never reached, so it shows up as a hang instead of a failure or a SKIPPED.

A possible fix: do the check and the registration under one lock, after the copy, so close() either sees the send or send() sees the closed socket:

{
    std::lock_guard<Mutex> lg(m_lock);
    if (m_socket == nullptr){
        return 0;   // closed while we were copying
    }
    m_inflight_sends.push_back(send_data);
}

(connect() replacing the socket in that window probably wants the same treatment, comparing against the socket read at (1).)

For a deterministic repro, a sleep between (2) and (3) longer than the test's 100 ms head start should make it hang every time.

Not checked: whether the GUI --command-line-test-mode hangs the same way. These runs used a headless test runner built from the same library.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants