Files
471dd56640 feat(windows): cross target, and quit ui-host with WM_QUIT instead of hard-killing it (#23)
* feat(windows): wire the view-module-runtime for x86_64-windows

Routes through logos-nix.lib.forAllTargets, widens meta.platforms, and
picks up Qt's host-tool cmake flags (empty natively).

wrapQtAppsHook is GATED rather than removed: it fails to EVALUATE for a
mingw host, and wrap-qt-apps-hook.sh would skip a PE anyway
(`isELF || isMachO || continue`) -- but removing it outright would change
native behaviour. dontWrapQtApps = true is the mandatory other half, or
qtbase's own setup hook hard-errors with "depends on qtbase, but no
wrapping behavior was specified".

Windows and native (aarch64-darwin) both evaluate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): quit ui-host with WM_QUIT instead of hard-killing it

QProcess::terminate() cannot stop ui-host on Windows, so every UI-plugin
teardown paid the full 3s grace period and then TerminateProcess.

MEASURED, not inferred. terminate() on Windows is EnumWindows(WM_CLOSE) plus
PostThreadMessage(tid, WM_CLOSE) (qprocess_win.cpp:648-653). ui-host owns ZERO
windows -- it is a bare QCoreApplication linking no Qt6::Gui at all
(ui-host/main.cpp:70, CMakeLists.txt:78-86); the QQuickWidget lives in the
PARENT and ui-host only remotes the plugin object over QtRO. So the enumeration
finds nothing. A controlled zero, not a broken command: the same enumeration in
the same run found 7 windows for Basecamp, including the two never-shown QPA
observer windows a GUI process must own. The thread message IS delivered, but
Qt's dispatcher only branches on WM_QUIT (qeventdispatcher_win.cpp:545-548) --
WM_CLOSE falls through to a no-op for a thread message. terminate() returns
void, so it "succeeds" having done nothing.

A/B against the shipped ui-host.exe: replicating terminate() byte-exactly left
it ALIVE past the full 3000ms; PostThreadMessage(WM_QUIT) exited it in 8ms with
code 0. End to end through the real teardown path (graceful Basecamp quit with
package_manager_ui loaded), same harness and trigger, only the payload differing:

  shipped   ui-host exit 3905ms  code 62097 (0xF291 Qt kill)  "did not exit gracefully" present
  fixed     ui-host exit  690ms  code 0                       warning absent
  (Basecamp total 4395ms -> 1186ms)

REPLACES terminate() on Windows rather than preceding it. An earlier draft
inserted WM_QUIT above the existing terminate()+3000+kill+1000, which would have
made the worst case 6000ms -- worse than today while claiming to improve it.
Since terminate() was measured to be dead time here, keeping it buys nothing:
the ceiling stays 4000ms and the typical case is now sub-second. This also
matches logos-container-subprocess, which REPLACES its equally no-op
request_exit() rather than supplementing it.

The POSIX path is byte-identical -- the original block is preserved verbatim
inside #else; git diff shows zero removed lines.

Getting the child's main THREAD id is the one subtle part. QProcess does not
expose it, but CreateProcessArguments carries the PROCESS_INFORMATION pointer it
hands to CreateProcess. The modifier runs BEFORE CreateProcess, so dwThreadId is
not populated yet: capture the pointer there, read through it once started() has
fired, and null it immediately -- QProcess owns that allocation and frees it in
cleanup(). No CreateProcessW reimplementation and no Toolhelp32 snapshot needed.

NOT fixed here, deliberately: orphan reaping is absent on Windows. ui-host's
setsid + PR_SET_PDEATHSIG + getppid watchdog are wholly inside #ifndef _WIN32
(ui-host/main.cpp:40-68) and ViewModuleHost assigns no Job Object, so a Basecamp
crash leaks ui-host.exe. That is a real second defect, but it is an untested
process-lifetime change and does not belong bundled into a latency fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): re-pin the L1-L4 inputs to their merged revs

logos-nix (L1), logos-protocol (L2), logos-cpp-sdk (L3) and logos-qt-sdk (L4)
are all on their default branches now, so the lock can name the merged revs
instead of the pre-merge branch tips it was resolving against while those PRs
were open.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:49:08 -03:00

45 lines
1.3 KiB
C++

#pragma once
#include <QObject>
#include <QProcess>
#include <QString>
class QLocalServer;
class ViewModuleHost : public QObject {
Q_OBJECT
public:
explicit ViewModuleHost(QObject* parent = nullptr);
~ViewModuleHost();
bool spawn(const QString& moduleName, const QString& pluginPath,
const QString& authToken);
void stop();
bool isRunning() const;
QString socketName() const;
signals:
void processExited(int exitCode);
void ready();
private:
QProcess* m_process = nullptr;
QLocalServer* m_tokenServer = nullptr;
QString m_moduleName;
QString m_socketName;
QByteArray m_stdoutBuffer;
bool m_readyEmitted = false;
#ifdef Q_OS_WIN
// The child's MAIN THREAD id, which is what stop() posts WM_QUIT to.
// QProcess does not expose it directly, but CreateProcessArguments carries
// the PROCESS_INFORMATION pointer it hands to CreateProcess -- capture the
// pointer in the modifier (which runs BEFORE CreateProcess, so dwThreadId is
// not filled in yet) and read it once started() has fired.
//
// m_procInfo is owned by QProcess and deleted in its cleanup(); it is nulled
// as soon as started() reads through it and is never dereferenced elsewhere.
void* m_procInfo = nullptr;
unsigned long m_mainThreadId = 0; // DWORD
#endif
};