Skip to content

Socket segfaults on PeerClosedError: onSocketDisconnected calls deleteLater() on a null socket #1342

Open

Description

@rammanokar-plateron

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), git 2b4501a282689792b62e94f3fb30d8f512324fe6
  • 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

  1. Gberfield commented on Oct 19, 2026

    @Gberfield

    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 onSocketError itself.

    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.Socket connected to that bridge. The shell logged QLocalSocket::PeerClosedError, then QObject::disconnect: Unexpected nullptr parameter, and crashed. The journal records an automatic shell restart.

    The relevant custom plugin configuration is:

    Loader { id: channelLoader; active: false; sourceComponent: socketComponent }
    Component {
        id: socketComponent
        Socket {
            path: root.runtimePath + "/control.sock"
            Component.onCompleted: Qt.callLater(function() {
                if (root.viewCount > 0 && root.channel) root.channel.connected = true
            })
            onError: error => {
                if (!connected)
                    Qt.callLater(function() { channelLoader.active = false })
                else
                    connected = false
            }
            // onConnectionStateChanged updates local application state;
            // a timer retries while views remain registered.
        }
    }

    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:

    rbx = 0x0
    => mov 0x8(%rbx),%rax
    

    The caller returns to quickshell+0x18da9b. Disassembly immediately before that address is:

    18da5a: call QMetaObject::activate(...)
    18da60: cmpb $0x0,0x38(%rbx)
    18da64: je 18da80
    ...
    18da80: mov 0x30(%rbx),%rdi
    ...
    18da8b: call QObject::disconnect(...)
    18da91: mov 0x30(%rbx),%rdi
    18da95: call QObject::deleteLater()
    18da9b: movq $0x0,0x30(%rbx)
    18daa3: movb $0x0,0x39(%rbx)
    

    In that caller frame, the pointer at offset 0x30 and flag at 0x38 are both zero. This matches Socket::onSocketError at revision 2b4501a282689792b62e94f3fb30d8f512324fe6: emit error, check !connected, disconnect, deleteLater, clear pointer and disconnecting flag. It does not match the ordering in onSocketDisconnected.

    The configuration synchronously sets connected = false from onError if 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

    • Quickshell 0.3.2, revision 2b4501a282689792b62e94f3fb30d8f512324fe6, distributed by Omarchy
    • Package 0.3.2.r7.g2b4501a-1; executable build ID 0daf438b404296d64116a7a26855ba762a405e6c
    • Qt runtime and build version: 6.12.0
    • Omarchy 4.0.5, Hyprland 0.56.2-4, NVIDIA GPU
    • Command: quickshell -n -p /usr/share/omarchy/shell
    • Custom plugin loaded into the Omarchy shell; not a stock-config reproduction
    • No OOM kill or GPU fault in the checked kernel-journal window

    GDB 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.

    Thread 1 (Thread 0x7fb5ad1ab400 (LWP 355382)):
    #0  0x00007fb5aec9a11c in ?? () from /usr/bin/../lib/libc.so.6
    #1  0x00007fb5aec3e5d0 in raise () from /usr/bin/../lib/libc.so.6
    #2  0x000055cbd19e7089 in ?? ()
    #3  <signal handler called>
    #4  0x00007fb5af5e74bd in QObject::deleteLater() () from /usr/bin/../lib/libQt6Core.so.6
    #5  0x000055cbd1a4da9b in ?? ()
    #6  0x00007fb5af5f6cc8 in ?? () from /usr/bin/../lib/libQt6Core.so.6
    #7  0x00007fb5afd7218c in ?? () from /usr/bin/../lib/libQt6Network.so.6
    #8  0x00007fb5af5f6ee4 in ?? () from /usr/bin/../lib/libQt6Core.so.6
    #9  0x00007fb5afcbf762 in QAbstractSocketPrivate::setErrorAndEmit(QAbstractSocket::SocketError, QString const&) () from /usr/bin/../lib/libQt6Network.so.6
    #10 0x00007fb5afcbfa75 in QAbstractSocketPrivate::readFromSocket() () from /usr/bin/../lib/libQt6Network.so.6
    #11 0x00007fb5afcbfb27 in QAbstractSocketPrivate::canReadNotification() () from /usr/bin/../lib/libQt6Network.so.6
    #12 0x00007fb5afccc2d2 in ?? () from /usr/bin/../lib/libQt6Network.so.6
    #13 0x00007fb5af5891d8 in QCoreApplication::notifyInternal2(QObject*, QEvent*) () from /usr/bin/../lib/libQt6Core.so.6
    #14 0x00007fb5af8bda33 in ?? () from /usr/bin/../lib/libQt6Core.so.6
    #15 0x00007fb5b1eb775e in ?? () from /usr/bin/../lib/libglib-2.0.so.0
    #16 0x00007fb5b1ec0c50 in ?? () from /usr/bin/../lib/libglib-2.0.so.0
    #17 0x00007fb5b1ec0db5 in g_main_context_iteration () from /usr/bin/../lib/libglib-2.0.so.0
    #18 0x00007fb5af8c4d22 in QEventDispatcherGlib::processEvents(QFlags<QEventLoop::ProcessEventsFlag>) () from /usr/bin/../lib/libQt6Core.so.6
    #19 0x00007fb5af596a74 in QEventLoop::exec(QFlags<QEventLoop::ProcessEventsFlag>) () from /usr/bin/../lib/libQt6Core.so.6
    #20 0x00007fb5af58cfca in QCoreApplication::exec() () from /usr/bin/../lib/libQt6Core.so.6
    #21 0x000055cbd199b894 in ?? ()
    #22 0x000055cbd1993294 in ?? ()
    #23 0x000055cbd197b5ef in ?? ()
    #24 0x00007fb5aec27781 in ?? () from /usr/bin/../lib/libc.so.6
    #25 0x00007fb5aec278b9 in __libc_start_main () from /usr/bin/../lib/libc.so.6
    #26 0x000055cbd197e215 in ?? ()
    

    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.

  2. Tunahanyrd commented on Oct 21, 2026

    @Tunahanyrd

    Deterministic reproduction of the reentrant onSocketError crash, confirming the distinction already noted in the earlier comment.

    Versions and real trigger

    • Quickshell 0.3.2.r7.g2b4501a-1, revision 2b4501a282689792b62e94f3fb30d8f512324fe6.
    • Qt build/runtime 6.12.0; Omarchy 4.0.5.
    • Three real crashes coincided with hyprmoncfgd restarting. The plugin uses onError: function(error) { backendSocket.connected = false }.
    • The standalone reproduction below needs neither Hyprland nor hyprmoncfg, a display, or a GPU. It uses one private Unix socket and an offscreen Quickshell instance. It does not restart the real shell or daemon. Core dumps and Quickshell's crash-dialog/relaunch handler are disabled for the test.

    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 none
    #!/usr/bin/env python3
    import argparse,json,os,pathlib,resource,socket,subprocess,tempfile,threading,time
    parser=argparse.ArgumentParser();parser.add_argument('binary');parser.add_argument('--handler',choices=['disconnect','none'],default='disconnect');parser.add_argument('--output');args=parser.parse_args()
    with tempfile.TemporaryDirectory(prefix='qs-socket-repro-') as d:
     p=pathlib.Path(d);path=str(p/'peer.sock');server=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);server.bind(path);server.listen(1);server.settimeout(8)
     connected=threading.Event();closed=threading.Event();errors=[]
     def peer():
      try:
       conn,_=server.accept();connected.set();time.sleep(.15);conn.close();closed.set()
      except Exception as e:errors.append(type(e).__name__)
     t=threading.Thread(target=peer);t.start()
     handler='onError: function(error) { console.log("REPRO_ERROR", error); connected = false }' if args.handler=='disconnect' else 'onError: function(error) { console.log("REPRO_ERROR", error) }'
     (p/'shell.qml').write_text('''import Quickshell
    import Quickshell.Io
    import QtQuick
    ShellRoot {
     Socket { path: Quickshell.env("QS_REPRO_SOCKET"); connected: true; %s
      onConnectedChanged: console.log("REPRO_CONNECTED", connected)
     }
     Timer { interval: 1000; running: true; onTriggered: { console.log("REPRO_SURVIVED"); Qt.quit() } }
    }
    '''%handler)
     env=dict(os.environ,QT_QPA_PLATFORM='offscreen',QT_QUICK_BACKEND='software',QT_QPA_PLATFORMTHEME='',QS_DISABLE_CRASH_HANDLER='1',QS_REPRO_SOCKET=path,XDG_CACHE_HOME=str(p/'cache'),XDG_STATE_HOME=str(p/'state'),XDG_CONFIG_HOME=str(p/'config'))
     def limits():resource.setrlimit(resource.RLIMIT_CORE,(0,0))
     try:r=subprocess.run([args.binary,'--path',str(p/'shell.qml'),'--no-color'],env=env,capture_output=True,text=True,timeout=8,preexec_fn=limits)
     except subprocess.TimeoutExpired as e:
      r=None;errors.append('TimeoutExpired')
     finally:server.close();t.join(timeout=9)
     result={'binary':args.binary,'handler':args.handler,'peer_connected':connected.is_set(),'peer_closed':closed.is_set(),'harness_errors':errors,'returncode':r.returncode if r else None,'survived':bool(r and 'REPRO_SURVIVED' in r.stdout+r.stderr),'log':r.stdout+r.stderr if r else ''}
     if args.output:pathlib.Path(args.output).write_text(json.dumps(result,indent=2)+'\n')
     print(json.dumps(result,indent=2))

    Observed with the installed binary:

    Handler Peer accepted/closed Exit Reaches REPRO_SURVIVED
    onError: connected = false yes / yes SIGSEGV (-11) no
    log error only yes / yes 0 yes

    The failing run ends with:

    REPRO_CONNECTED true
    Socket error ... QLocalSocket::PeerClosedError
    REPRO_ERROR 1
    REPRO_CONNECTED false
    QObject::disconnect: Unexpected nullptr parameter
    

    Source-level confirmation

    I also compiled the actual, unchanged src/io/socket.cpp and datastream.cpp at 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 connects Socket::error directly to socket.setConnected(false), accepts the connection with QLocalServer, and closes the peer.

    A breakpoint in onSocketDisconnected catches this synchronous nested call chain:

    Socket::onSocketError
      -> emit error
      -> test error handler
      -> Socket::setConnected(false)                   socket.cpp:93
      -> QAbstractSocket::disconnectFromHost
      -> Socket::onSocketDisconnected                 socket.cpp:60
    

    Continuing produces:

    SIGSEGV in QObject::deleteLater()
    #1 Socket::onSocketError(... PeerClosedError)       socket.cpp:79
    79    this->socket->deleteLater();
    (gdb) print this->socket
    $1 = (QLocalSocket *) 0x0
    

    Thus this occurrence crashes in onSocketError after 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:

    1. Peer closure without a disconnecting error handler, followed by reconnection and successful parsed data receipt.
    2. Peer closure with a synchronous disconnecting error handler, followed by reconnection and successful parsed data receipt.
    3. An initially failed connection (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.

  3. not-that-nda commented on Oct 21, 2026

    @not-that-nda

    Deterministic reproduction on the same revision (2b4501a, Omarchy package, Qt 6.12.0): a Socket connected to a local daemon's socket, then systemctl --user stop <daemon>. The shell segfaults on every attempt, with the same PeerClosedError → QObject::disconnect: Unexpected nullptr parameter → QObject::deleteLater sequence.

    Deferring the QML side (onError: Qt.callLater(() => connected = false)) does not prevent it. So it isn't caused by the config setting connected synchronously: this->socket is already null when onSocketError runs its !connected cleanup.

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions