Repository navigation
Conversation
…play#814) On macOS 27, Syncplay segfaulted when connecting to a server whose IPv6 address refuses connections (such as syncplay.pl), and once the crash is avoided it would loop on "Connection with server lost, attempting to reconnect". Both are caused by how Qt's CFSocket-based socket notifiers interact with the vendored Twisted Qt reactor: * A refused connection is both readable and writable. Qt delivers the read and write callbacks back-to-back, and handling the first one disconnects the socket, unregistering both notifiers and freeing Qt's per-socket data before the second callback runs. The second callback then uses freed memory and crashes in QCoreApplication::sendEvent. On macOS, a notifier now only disables itself within its callback and the Twisted I/O is processed once the callback has returned. * Qt's notifier also consumes the socket's pending SO_ERROR, so Twisted saw no error for the refused connection and treated it as established. The dead IPv6 attempt then won the IPv4/IPv6 race and immediately closed. On macOS, a connecting socket that has no peer is now reported to Twisted as a failed connection. Behaviour on other platforms is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
@Et0h FYI Syncplay is currently unusable on macOS without this fix |
|
Thanks for the wok on this.
Is this only a problem when connecting to the default Syncplay public server port or for all ports? |
|
Thanks for this very useful project :) It affects all ports Keep in mind that I've never worked on this project before and networking is not my strength, so take my code with a grain of salt |
TheCrazyLex
left a comment
There was a problem hiding this comment.
@albertosottile @Et0h @romainhedouin So I had the same crash on mac os 27 and installing this fix resolved it.
Also reviewed the code, and this is the correct approach to address this from my perspective
|
This fixes the crash for me as well on macOS 26.7 and the change looks good. |
albertosottile
left a comment
There was a problem hiding this comment.
LGTM in general, though I am not an expert on the matter. Just a minor question. Additionally, I wonder whether we should submit a bug ticket to PySide6.
The `except AttributeError: return False` was a defensive check for a connecting socket's `socket` attribute having already been deleted when a late event arrives. It cannot be reached: * Twisted only deletes `socket` on a connecting client inside `failIfNotConnected()`, which stops reading and writing first. That shuts down our notifiers and clears their `watcher`, so any later event is dropped by the `if not self.watcher: return` checks before `_connectionRefused()` is called. * It then deletes `connector` right after, and `_connectionRefused()` already returns early when `connector` is missing. * The only other place `socket` is deleted is `Connection.connectionLost`, which only runs for established connections, which are excluded by the `connected` check. Keeping it would also have risked silently swallowing a genuine error. Verified by running the guard instrumented to log when it fires: it did not fire in any test, including a refused connection whose failure callback spins a nested event loop and 25 simultaneous refused connections. Results are identical with the lines removed, including the full client against syncplay.pl and a local Syncplay server. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Cheers @romainhedouin for your work on this, and to everyone who tested and reviewed the fix. The code looks fine to me, but I've not been able to test it myself so it's good to see from everyone's testing that it works on macOS 26 and 27 and appears to resolve the issue without any apparent problems. Given that we're using a vendored Qt reactor, I don't see upstream acceptance as a requirement for merging, but I agree with @albertosottile that consideration should be given to reporting the matter upstream to Qt/PySide6. What are your thoughts on that, @romainhedouin? |
|
I just dug into it and Qt fixed it in their 6.12.0 version, which was published two days after I created this PR |
Fixes #814.
Summary
On macOS 27, Syncplay crashes with a segfault in
QCoreApplication::sendEventwhen it connects to a server whose IPv6 address refuses connections, such assyncplay.pl. That is why it appears to crash when clicking "Store configuration and run Syncplay": that click is the first connection attempt. Avoiding the crash alone is not enough, because the client then loops on "Connection with server lost, attempting to reconnect". Both problems come from the vendored Twisted Qt reactor (qt5reactor.py) interacting with Qt's CFSocket-basedQSocketNotifieron macOS, so this PR fixes both.Root causes
1. Use-after-free crash. A refused connection is both readable and writable. Qt's macOS dispatcher delivers the read and write callbacks for the socket back-to-back. Handling the first one makes Twisted disconnect the socket, which calls
removeReader/removeWriterand unregisters both notifiers. That frees Qt's per-socket data while the second callback is still pending, and the second callback then uses it. This matches the crash reports:__CFSocketPerformV0→ QtCore →QCoreApplication::sendEvent, at either a null-ish address (0x58) or a freed one. It is a race, so it is intermittent (about 10 in 12 runs on an unmodified checkout against a closed port).2. Refused connections look successful. Once a write
QSocketNotifieris registered on a connecting socket, Qt's CFSocket layer consumes the socket's pendingSO_ERROR. Twisted'sdoConnect()then readsSO_ERROR == 0, callsconnect_ex()again, getsEISCONN, and decides the connection succeeded. A raw-socket check on macOS 27.0.1 shows the underlying behaviour: after a refused non-blocking connect, the firstSO_ERRORread givesECONNREFUSED, and later reads give0withconnect_exreturningEISCONN. With only a write notifier registered,SO_ERRORis already0after Qt has dispatched events. Because Syncplay races IPv6 against IPv4, the falsely "connected" IPv6 attempt wins, sendsstartTLS, reads EOF, and the client reconnects forever. Before this PR the crash happened first, so this was never visible.Changes (
syncplay/vendor/qt5reactor.pyonly)TwistedSocketNotifier.read()/write()now only disable their own notifier inside Qt's callback and process the event just afterwards viaQTimer.singleShot(0, ...). Unregistration therefore never happens while a sibling callback for the same socket is still pending, and it still happens synchronously and before Twisted closes the fd. (I tried deferring the unregistration itself first. That breaks normal connections because the fd number can be reused before the late unregister runs, so this PR does not do that.)_connectionRefused()checks whether the connection really exists. IfSO_ERRORis set or the socket has no peer (getpeername()raises), it callsfailIfNotConnected()with the real errno, orECONNREFUSEDwhen the error was already consumed. Twisted then reportsConnectionRefusedErroras it normally would.Testing
Environment: macOS 27.0.1, Apple Silicon, Python 3.12, PySide6 6.11.2 (the same Qt as the 1.7.6 app bundle that crashes), Twisted 26.4.0. Everything below was run against the final code. "Before" means an unmodified checkout.
connectionMade(wrong)ConnectionRefusedError, 10 of 10 each for 127.0.0.1, ::1 andsyncplay.pl's IPv6 addresssyncplay.plHellohandshake, about 17 state updates, 0 "connection lost", 3 of 3I have not run this on Windows or Linux. Both fixes are guarded by
sys.platform == 'darwin', so those platforms keep the existing code path, but I'd appreciate a review from someone who can.Notes
getpeername()check is a workaround for losingSO_ERROR, so it can only reportECONNREFUSEDwhen the original error was consumed. Failures such as a timeout or an unreachable host surface as "connection refused" in that case. That is still correct as "failed", and the client retries as it would anyway.SO_ERRORor tolerates unregistering from within a callback, this workaround becomes unnecessary and can be dropped.🤖 Generated with Claude Code