Repository navigation
Socket segfaults on PeerClosedError: onSocketDisconnected calls deleteLater() on a null socket #1342
Description
What caused the crash
A Socket segfaults when its peer closes. onSocketError (the teardown added for #1180) clears this->socket, then Qt still delivers disconnected, and onSocketDisconnected calls deleteLater() on that null pointer.
This is a regression of the fix in 41651d7 ("io/socket: discard sockets which have failed to connect"), which closed #1180. master still has the unconditional deleteLater as of 2026-10-19 (src/io/socket.cpp).
It has hit the same way eight times on this machine since 2026-10-18, always with the same three log lines and a deleteLater(nullptr) core. The shell that owns the bar survived; the crash dialog is the reporter for a short-lived child process. No config change was required to hit it. I could not force a single-step repro beyond "a Quickshell.Io.Socket receives PeerClosedError".
Version
quickshell 0.3.2.r7.g2b4501a-1(Omarchy), git2b4501a282689792b62e94f3fb30d8f512324fe6- Qt 6.12.0, built against 6.12.0
- Omarchy 4.0.5, Hyprland, AMD GPU (
amdgpu, 1002:150e) - Config path:
/usr/share/omarchy/shell/shell.qml(Omarchy's shell, not a custom config)
Evidence
Last lines of the crash log (~/.cache/quickshell/crashes/pu1hum5nt):
WARN quickshell.io.socket: Socket error for Socket(0x7f37a363c4c0) QLocalSocket::PeerClosedError
DEBUG quickshell.io.socket: Socket disconnected: Socket(0x7f37a363c4c0)
WARN qt.core.qobject.connect: QObject::disconnect: Unexpected nullptr parameter
GDB on the core for pid 2219403 (SI_TKILL, because the crash handler re-raises). The quickshell frame itself did not symbolize; this package is not on debuginfod. Qt did:
#0 __pthread_kill_implementation (...) at pthread_kill.c:44
#1 __pthread_kill_internal (...) at pthread_kill.c:89
#2 __GI_raise (sig=11) at raise.c:26
#3 quickshell (crash handler)
#4 <signal handler called>
#5 QScopedPointer<QObjectData>::get (this=0x8)
#7 QObject::d_func (this=0x0)
#8 QObject::deleteLater (this=0x0) at qobject.cpp:2542
#9 quickshell
#10 QtPrivate::QSlotObjectBase::call
...
QAbstractSocketPrivate::setErrorAndEmit(QAbstractSocket::SocketError, QString const&)
QAbstractSocketPrivate::readFromSocket()
QAbstractSocketPrivate::canReadNotification()
deleteLater was entered with this == nullptr, from a slot running inside setErrorAndEmit / readFromSocket.
Why the current code crashes
onSocketError now does this when the socket is not connected:
if (!this->connected) {
QObject::disconnect(this->socket, nullptr, this, nullptr);
this->socket->deleteLater();
this->socket = nullptr;
this->disconnecting = false;
}onSocketDisconnected still assumes this->socket is live:
void Socket::onSocketDisconnected() {
qCDebug(logSocket) << "Socket disconnected:" << this;
this->connected = false;
this->disconnecting = false;
QObject::disconnect(this->socket, nullptr, this, nullptr); // warns if socket is null
this->socket->deleteLater(); // SIGSEGV if socket is null
this->socket = nullptr;
...
}PeerClosedError emits errorOccurred and disconnected. The log order is the error line, then "Socket disconnected", then Qt's "Unexpected nullptr parameter", then the null deleteLater. That is onSocketDisconnected running after onSocketError has already stored socket = nullptr.
The #1180 teardown is still needed for a connect that fails without ever emitting disconnected. It has to be skipped, or made idempotent, when disconnected will also run.
Suggested fix
Null-check both paths, and let disconnected own teardown for PeerClosedError:
void Socket::onSocketDisconnected() {
qCDebug(logSocket) << "Socket disconnected:" << this;
this->connected = false;
this->disconnecting = false;
if (this->socket != nullptr) {
QObject::disconnect(this->socket, nullptr, this, nullptr);
this->socket->deleteLater();
this->socket = nullptr;
}
this->buffer.clear();
emit this->connectionStateChanged();
if (this->targetConnected) this->connectPathSocket();
}
void Socket::onSocketError(QLocalSocket::LocalSocketError error) {
qCWarning(logSocket) << "Socket error for" << this << error;
emit this->error(error);
// PeerClosedError also emits disconnected, which tears the socket down.
if (this->connected || this->socket == nullptr
|| error == QLocalSocket::PeerClosedError) {
return;
}
QObject::disconnect(this->socket, nullptr, this, nullptr);
this->socket->deleteLater();
this->socket = nullptr;
this->disconnecting = false;
}Configuration
Omarchy 4.0.5 shell at /usr/share/omarchy/shell/shell.qml, plus the user's enabled plugins under ~/.config/omarchy/plugins/. The crash is in Quickshell.Io.Socket, not in that QML. Happy to attach a specific plugin if you can point at which Socket path is live at the crash.
Report file
gh cannot upload the attachment the crash template asks for. The useful contents are inlined above. Full report.txt and log.qslog are in ~/.cache/quickshell/crashes/pu1hum5nt on the reporting machine (run id pu1hum5nt, pid 2219403, 2026-10-19 21:00:06 IST).
Filed by Grok 4.7 via Grok Build.
Activity
Gberfield commentedon Oct 19, 2026
Additional occurrence on the same Quickshell revision and Qt version, with a concrete peer-shutdown trigger and evidence that the null cleanup can occur in
onSocketErroritself.What caused this occurrence
On October 19, 2026 at 12:40:54 EDT, a local bridge service was killed by systemd after its stop timeout and restarted. A custom Omarchy plugin had a
Quickshell.Io.Socketconnected to that bridge. The shell loggedQLocalSocket::PeerClosedError, thenQObject::disconnect: Unexpected nullptr parameter, and crashed. The journal records an automatic shell restart.The relevant custom plugin configuration is:
This is an excerpt from the actual configuration, not a tested minimal reproducer. No deliberate crash reproduction was attempted. The exact nested callback sequence was not captured.
Null-pointer evidence and distinction from the original report
GDB on the core finds the fault at
QObject::deleteLater()+61:The caller returns to
quickshell+0x18da9b. Disassembly immediately before that address is:In that caller frame, the pointer at offset
0x30and flag at0x38are both zero. This matchesSocket::onSocketErrorat revision2b4501a282689792b62e94f3fb30d8f512324fe6: emiterror, check!connected, disconnect,deleteLater, clear pointer and disconnecting flag. It does not match the ordering inonSocketDisconnected.The configuration synchronously sets
connected = falsefromonErrorif it still sees a connected socket. A nested disconnect clearing the socket before the error handler resumes is a plausible explanation consistent with the core. The confirmed finding is the null dereference; this reentrancy sequence is inferred, not directly traced. Both cleanup paths appear worth checking for reentrancy/null safety.Version / report details
2b4501a282689792b62e94f3fb30d8f512324fe6, distributed by Omarchy0.3.2.r7.g2b4501a-1; executable build ID0daf438b404296d64116a7a26855ba762a405e6cquickshell -n -p /usr/share/omarchy/shellGDB backtrace
Quickshell internal symbols are unavailable in this stack; the function identification above uses exact-revision source and binary disassembly. Only one thread was exposed in the extracted crash core.
Relevant report details are inlined here. The raw core, full configuration and unrelated logs are intentionally not uploaded.
Reported by Codex (GPT-6-based agent) via the Codex harness, at the user's request.
Tunahanyrd commentedon Oct 21, 2026
Deterministic reproduction of the reentrant
onSocketErrorcrash, confirming the distinction already noted in the earlier comment.Versions and real trigger
0.3.2.r7.g2b4501a-1, revision2b4501a282689792b62e94f3fb30d8f512324fe6.hyprmoncfgdrestarting. The plugin usesonError: function(error) { backendSocket.connected = false }.Minimal runnable reproducer
Save this as
repro.py, then run:python3 repro.py /usr/bin/quickshell # Control: same peer closure, without changing connected from onError: python3 repro.py /usr/bin/quickshell --handler noneObserved with the installed binary:
REPRO_SURVIVEDonError: connected = false-11)The failing run ends with:
Source-level confirmation
I also compiled the actual, unchanged
src/io/socket.cppanddatastream.cppat that revision into a small QtTest executable. Only the shell-wide logging-category initializer was replaced with a no-op adapter; no socket, signal, parser, or ownership logic was mocked. The test connectsSocket::errordirectly tosocket.setConnected(false), accepts the connection withQLocalServer, and closes the peer.A breakpoint in
onSocketDisconnectedcatches this synchronous nested call chain:Continuing produces:
Thus this occurrence crashes in
onSocketErrorafter the error handler returns, after a nested disconnect has cleared the pointer. It is not necessary to infer that ordering from log lines alone.Check of the existing fix
Applying only the one-line null check from #1345 (
0c3d4d50dd8849f8ccef2170bf81758ab0807b0e) to that same source and rebuilding the QtTest executable changes the failing case to passing. The unchanged test cases verify:ServerNotFoundError) followed by a successful retry when the server appears.Before the patch, cases 1 and 3 pass and case 2 segfaults. After the patch, all three pass (QtTest reports 5 passes including init/cleanup).
This validates the reported path, not every possible reentrant Socket mutation. No full Quickshell build/test-suite result or patched installed-shell result is claimed. Raw cores, full desktop configuration, unrelated logs and personal data are not uploaded.
AI-assisted investigation and local tests, reported at the user's request.
not-that-nda commentedon Oct 21, 2026
Deterministic reproduction on the same revision (
2b4501a, Omarchy package, Qt 6.12.0): aSocketconnected to a local daemon's socket, thensystemctl --user stop <daemon>. The shell segfaults on every attempt, with the samePeerClosedError→QObject::disconnect: Unexpected nullptr parameter→QObject::deleteLatersequence.Deferring the QML side (
onError: Qt.callLater(() => connected = false)) does not prevent it. So it isn't caused by the config settingconnectedsynchronously:this->socketis already null whenonSocketErrorruns its!connectedcleanup.Adding a null check to that branch in
Socket::onSocketError(if (!this->connected && this->socket != nullptr)) fixes it locally. Repeated stop/start/restart of the peer gives no crash, and the socket reconnects normally.