subagent transcript
session 17f09f89 · workflow wf_93de0fbc
delegated task
PROVEN (empirical, ground truth): On FreeCAD-for-WebAssembly (Qt 6.11 static, JSPI + native wasm-EH, single-thread QT_FEATURE_thread=-1, shiboken/PySi
▸ Magik
PROVEN (empirical, ground truth): On FreeCAD-for-WebAssembly (Qt 6.11 static, JSPI + native wasm-EH, single-thread QT_FEATURE_thread=-1, shiboken/PySide bindings), activating the CAM **or** BIM GUI workbench throws a wasm 'RuntimeError: memory access out of bounds'. It is NOT OOM (peak wasm memory 23-29MB, MAXIMUM_MEMORY=4GB, no emscripten abort() — onAbort never fires) and NOT a missing Python module (all imports succeed first). CAM and BIM produce the IDENTICAL, deterministic stack (top frames, truncated below by the JSPI promise boundary):
RuntimeError: memory access out of bounds
at QMetaObject::cast(QObject const*) const <-- OOB read occurs HERE
at QWidget::isActiveWindow() const
at QStyleOption::initFrom(QWidget const*)
at QGroupBox::initStyleOption(QStyleOptionGroupBox*) const
at QGroupBox::event(QEvent*)
at QApplicationPrivate::notify_helper(QObject*, QEvent*)
at QApplication::notify -> Gui::GUIApplication::notify
at QCoreApplication::sendEvent(QObject*, QEvent*)
at QWidgetPrivate::hideChildren(bool)
Working workbenches (Part, Draft, Sketcher) do NOT crash on activation. Qt source tree: /opt/toolchains/qtsrc/qtbase (read qtbase/src/corelib/kernel/qmetaobject.cpp, qtbase/src/widgets/kernel/qwidget.cpp, qtbase/src/widgets/styles/qstyleoption.cpp, qtbase/src/widgets/widgets/qgroupbox.cpp). FreeCAD source: /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Gui (Application.cpp activateWorkbench/onToolBarSwitch, Workbench.cpp, MainWindow.cpp, DockWindowManager.cpp, PreferencePages/dialog). Read-only — do NOT build.
Four parallel analyses (JSON):
[{"area":"Qt use-after-free: QMetaObject::cast dereferencing a dangling QObject's corrupted vtable pointer during QGroupBox hide on CAM/BIM workbench activation","findings":"EXACT OOB: The out-of-bounds read is the virtual-dispatch load inside `obj->metaObject()` in QMetaObject::cast (qtbase/src/corelib/kernel/qmetaobject.cpp:413 -- `return (obj && obj->metaObject()->inherits(this)) ? obj : nullptr;`). cast() first null-checks obj, so obj is NON-null. It then does the virtual call obj->metaObject(): (a) load the vtable base pointer from *(obj+0), then (b) load the metaObject() slot at (vptr + slotOffset), then (c) call_indirect. The OOB memory-access trap is step (b): the load of the vtable slot through a garbage vtable base.\n\nWhy it is a memory-access-out-of-bounds and not something else: obj is a DANGLING/freed QObject whose backing heap chunk has been returned to the allocator and its first word (the vptr slot) overwritten with allocator/freelist data (emscripten dlmalloc/emmalloc store freelist next-pointers exactly at offset 0 of a freed chunk). So *(obj+0) yields a large garbage integer G, and load(G + slotOffset) addresses beyond linear memory -> wasm \"memory access out of bounds\". This is the classic use-after-free signature. Peak memory 23-29MB rules out OOM; the fault is a wild address, not exhaustion.\n\nIt is DEFINITIVELY NOT: (1) the superdata/inherits walk (qmetaobject.cpp:387-395) -- that walks static QMetaObject data (&Class::staticMetaObject and ->d.superdata) which is compile-time-constant and always in bounds; a merely-freed-but-intact object would return a valid &staticMetaObject and inherits() would not fault. (2) a null/dangling QWindow handle -- the windowHandle()->handle() path (qwidget.cpp:7010-7012) is explicitly null-guarded, and cast() null-guards obj. The only way to reach an OOB in cast() is a NON-null but INVALID (freed, allocator-reused) obj whose vptr word is corrupted.\n\nWhich obj: the sole QMetaObject::cast reachable in QWidget::isActiveWindow() is the qobject_cast in the QGuiApplication::focusWindow() container loop -- qwidget.cpp:6997 `qobject_cast<QWidgetWindow *>(ww)` and 6998 `qobject_cast<QWindowContainer *>(qww->widget())`. ww originates from QGuiApplicationPrivate::focus_window, a raw QWindow* (qguiapplication.cpp:194) that is not cleared on widget teardown; qww->widget() reads QWidgetWindow::m_widget (qwidgetwindow_p.h:43). So the dangling obj is a stale QWindow/QWidget reached from focus_window (or its m_widget), left dangling when the object that had focus was destroyed during the workbench switch.\n\nTRIGGER CHAIN (all confirmed in source): QGroupBox::event() (qgroupbox.cpp:288) unconditionally calls initStyleOption(&box) at line 306 for EVERY event -> QStyleOption::initFrom() (qstyleoption.cpp:141) computes window=widget->window() (143) and calls window->isActiveWindow() (153) -> isActiveWindow() (qwidget.cpp:6967) enters the focusWindow loop (6995-7003) -> qobject_cast (6997/6998) -> qobject_cast_helper (qobjectdefs.h:758) -> QMetaObject::cast (qmetaobject.cpp:413) -> obj->metaObject() OOB. This event is delivered by QWidgetPrivate::hideChildren() -> QCoreApplication::sendEvent(widget,&e) (qwidget.cpp:8560), reached from hide_helper (8264) during workbench-switch teardown.","evidence":["qtbase/src/corelib/kernel/qmetaobject.cpp:413 -- cast(): obj null-checked then obj->metaObject() (virtual) dereferenced; the vtable-slot load through obj's corrupted vptr is the OOB","qtbase/src/corelib/kernel/qmetaobject.cpp:387-395 -- inherits()/superdata walk uses static QMetaObject data only; ruled out as the OOB site","qtbase/src/widgets/kernel/qwidget.cpp:6967-7003 -- isActiveWindow(); only cast is the focusWindow container loop at 6997-6998","qtbase/src/widgets/kernel/qwidget.cpp:6997 -- qobject_cast<QWidgetWindow*>(ww), ww from focusWindow()","qtbase/src/widgets/kernel/qwidget.cpp:7010-7012 -- windowHandle()->handle() path is null-guarded (rules out null-handle theory)","qtbase/src/gui/kernel/qguiapplication.cpp:194 -- QGuiApplicationPrivate::focus_window is a raw QWindow* (can dangle)","qtbase/src/widgets/kernel/qwidgetwindow_p.h:43 -- QWidgetWindow::widget() returns raw m_widget","qtbase/src/corelib/kernel/qobjectdefs.h:758 -- qobject_cast_helper -> staticMetaObject.cast(object)","qtbase/src/widgets/styles/qstyleoption.cpp:141-153 -- initFrom(): …
Fable
thinking
I'm working through the conflicting analyses to identify the actual dangling object at the root of this issue. The key disagreement centers on what exactly is being held—whether it's the focus_window QWindow itself, an orphaned dock's top-level widget, or a shiboken-backed Python form—so I need to trace through the evidence carefully to determine which analysis correctly identifies the culprit.
Looking at the stack trace more closely, the critical insight is that QWidget::isActiveWindow() actually executes and appears as a frame on the stack, which means the window object itself must be live. If the QGroupBox's window() were dangling, the virtual dispatch would crash immediately without ent …
Fable
I'll verify the key discriminating claims against the actual source before reconciling.
Fable
⚙ Bash {"command": "sed -n '6960,7015p' /opt/toolchains/qtsrc/qtbase/src/widgets/kernel/qwidget.cpp", "description": "Read QWidget::isActiveWindow"}
Fable
⚙ Bash {"command": "sed -n '135,160p' /opt/toolchains/qtsrc/qtbase/src/widgets/styles/qstyleoption.cpp", "description": "Read QStyleOption::initFrom"}
Fable
⚙ Bash {"command": "sed -n '380,415p' /opt/toolchains/qtsrc/qtbase/src/corelib/kernel/qmetaobject.cpp", "description": "Read QMetaObject::cast and inherits"}
Fable
thinking
I'm tracing through the execution path and pinpointing where the dangling object occurs. The issue is in `qstyleoption.cpp:153` where `window->isActiveWindow()` gets called on a live window object. Inside that function, there's a `qobject_cast` that checks if the focus window is a `QWidgetWindow`, and that focus window (`ww` from `QGuiApplication::focusWindow()`) is the dangling pointer—not the window object itself as the earlier analysis suggested.
The out-of-bounds access happens when `qobject_cast` tries to call `ww->metaObject()` on the freed object. If the allocator has reused that memory and overwritten the vtable pointer with garbage, then dereferencing it to load the vtable slot cau …
Fable
Now the wasm platform teardown and focus_window lifecycle — the crux of who invalidates the pointer.
Fable
⚙ Bash {"command": "grep -n \"focus_window\" /opt/toolchains/qtsrc/qtbase/src/gui/kernel/qwindow.cpp /opt/toolchains/qtsrc/qtbase/src/gui/kernel/qguiapplication.cpp", "description": "Find focus_window references"}
Fable
⚙ Bash {"command": "sed -n '240,300p' /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmwindow.cpp", "description": "Read QWasmWindow destructor/shutdown"}
Fable
thinking
I'm finding two different code paths where focus_window gets cleared in qwindow.cpp—one in the destructor that sets it to null, and another in QWindowPrivate::destroy that reassigns it to the parent window. Let me examine the context around each of these to understand when they're triggered.
Fable
⚙ Bash {"command": "sed -n '190,205p' /opt/toolchains/qtsrc/qtbase/src/gui/kernel/qwindow.cpp; echo \"=====DESTROY=====\"; sed -n '2160,2195p' /opt/toolchains/qtsrc/qtbase/src/gui/kernel/qwindow.cpp", "description": "Read ~QWindow and QWindowPriva…
Fable
⚙ Bash {"command": "sed -n '1080,1105p' /opt/toolchains/qtsrc/qtbase/src/plugins/platforms/wasm/qwasmwindow.cpp", "description": "Read requestActivateWindow"}
Fable
thinking
I'm looking at two different code paths that clear the focus_window variable in qwindow.cpp. The first one in QWindowPrivate::destroy() reassigns focus to the parent window, while the second in the destructor sets it to nullptr. The key insight is that destroy() executes before the destructor, so that determines the actual behavior.
The bug emerges when focus_window gets reassigned to a parent during teardown, but that parent is also being destroyed in the same batch, leaving the pointer dangling. There's also a complication with QWasmWindow::shutdown() — when the platform window is deleted inside destroy(), it can trigger a reactivation of a peer window, which synchronously updates focus_w …
Fable
I have the decisive evidence. Let me verify one more discriminator — the live-window registry usable for the confirmation test.
Fable
⚙ Bash {"command": "grep -n \"window_list\" /opt/toolchains/qtsrc/qtbase/src/gui/kernel/qguiapplication.cpp | head; echo \"---qgroupbox 300-310---\"; sed -n '300,310p' /opt/toolchains/qtsrc/qtbase/src/widgets/widgets/qgroupbox.cpp", "description":…
Fable
thinking
I've confirmed the key details across the codebase. The critical path is that `QStyleOptionGroupBox` gets initialized unconditionally in qgroupbox.cpp before the event switch, and the style option queries the active window state through a live window pointer that's obtained during initialization. I'm tracing through qwidget.cpp to verify the complete flow.
Fable
I have verified all load-bearing claims against source. Reconciling now.
Fable
⚙ StructuredOutput {"rootCause": "Use-after-free of QGuiApplicationPrivate::focus_window (a raw, non-owning QWindow*, qguiapplication.cpp:194), dereferenced by QMetaObject::cast during QGroupBox style-init on the workbench-switch hide broadcast. The stack its…