Files
e648735cbb feat(windows): port the Qt module host and add an x86_64-windows cross target (#7)
* feat(windows): port the module host's POSIX-bound pieces

Three changes, in the order they block a Windows module load.

1. qt_plugin_format_loader.cpp -- findInDir probed "logos_host_qt" and
   "logos_host" with no extension. On Windows the host is logos_host_qt.exe, so
   the probe finds nothing and EVERY module load fails before it starts: the
   host binary is resolved first. logos-view-module-runtime already appends
   ".exe" for its ui-host; this does the same. Behaviour on POSIX is
   byte-identical (the suffix is "").

2. token_source.cpp -- mingw-w64 ships <unistd.h> (read/close are fine) but not
   <poll.h>, and the poll() here exists purely to BOUND the wait so a
   never-delivered token cannot hang the child. Win32 has no single readiness
   wait covering all three things stdin can be here -- an anonymous pipe from
   the subprocess container, a redirected file, or a console -- so the Windows
   branch does the blocking read on a thread and bounds the wait instead. The
   shared state is a shared_ptr so a thread that wakes after we time out writes
   into live memory; detaching is safe precisely because a missing token is
   fatal at startup and the host exits immediately after.

3. logos_host.cpp -- the crash handler is POSIX signals + sigaltstack +
   backtrace(3), and mingw has no <execinfo.h> (nor SIGBUS). Guarded out rather
   than ported: the obvious Win32 translation, SetUnhandledExceptionFilter +
   DbgHelp SymFromAddr, would reintroduce exactly the hazard safeWrite's
   comment exists to avoid -- symbolisation takes the loader lock, and a crash
   handler that takes the loader lock deadlocks precisely when it is needed
   (a fault during a module load). If Windows backtraces are wanted later, the
   safe shape is CaptureStackBackTrace with raw hex addresses symbolised
   offline, mirroring safeWriteHex.

4. qt/qt_app.cpp -- the self-pipe SIGTERM/SIGINT handling is guarded out, and
   this is NOT a gap: on Windows the parent asks for shutdown by posting
   WM_QUIT to the child's main thread, and Qt's Win32 event dispatcher already
   turns WM_QUIT into QCoreApplication::quit() -- the exact effect this
   hand-rolls on POSIX. The comment warns against the tempting ::pipe -> _pipe
   swap: it compiles, but QEventDispatcherWin32::registerSocketNotifier routes
   to WSAAsyncSelect and DISCARDS its return value, so a CRT pipe fd fails
   WSAENOTSOCK silently and the notifier simply never fires.

Verified: preprocessor directives balance in all three files, and the POSIX
branches still compile unchanged.

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

* feat(windows): add an x86_64-windows cross target

Every dependency here is a target-side library compiled into this one, so they
all follow ${system}; there is no build-time code generator that must stay
native. meta.platforms widened, Qt wrapper hook gated behind !isWindows with
dontWrapQtApps set, and cmakeFlags pick up pkgs.logosQtCrossCmakeFlags.

Pairs with the source port already on this branch (.exe suffix, token_source,
crash handler, qt_app self-pipe).

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

* test(windows): port test_token_source to the mingw CRT

mingw-w64 ships <unistd.h> but not the POSIX pipe/mkstemp APIs, so the test
would not compile for the Windows target even though the production sources
did:

    tests/test_token_source.cpp:28: '::pipe' has not been declared;
                                    did you mean '_pipe'?

Three shims, all in an anonymous namespace so the POSIX path is textually
unchanged:

  - makePipe: _pipe(fds, 4096, _O_BINARY). The mode argument is not incidental
    -- the token is compared byte for byte, and text mode would translate the
    "\r\n" the CRLF test writes on purpose, silently defeating what that test
    is checking.
  - writeFd: _write, which takes unsigned int rather than size_t.
  - makeTempFile: GetTempPathA + GetTempFileNameA. There is no mkstemp, and no
    /tmp for the hardcoded template to live in.

Cleanup on that temp file becomes std::remove instead of ::unlink -- portable,
and already reachable via <cstdio>.

Verified: native aarch64-darwin build of logos-module-loader-qt still succeeds
with the tests compiled and run.

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

* fix(windows): defer gtest discovery, and suffix the compat symlink

Two Windows-only build failures in the same install/test tail.

1. gtest_discover_tests defaults to POST_BUILD, which RUNS the freshly
   linked test binary on the BUILD machine to enumerate cases. Under cross
   that binary is a PE, so the build host tries to execute it as a shell
   script:

       logos_module_loader_qt_tests.exe: line 3: syntax error

   DISCOVERY_MODE PRE_TEST moves enumeration to ctest time, where the
   binary is never run on the wrong platform. Natively this is a no-op
   beyond when discovery happens.

2. The logos_host -> logos_host_qt compatibility symlink was created
   without an executable suffix. On Windows the installed file is
   logos_host_qt.exe, so the link dangled and nixpkgs' noBrokenSymlinks
   check failed the derivation outright. Both the link and its target now
   carry CMAKE_EXECUTABLE_SUFFIX; nix/bin.nix recreates it the same way.

logos_host_qt.exe now builds as a PE32+ console executable for
x86_64-w64-mingw32.

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

logos-nix (L1), logos-protocol, logos-module, logos-container (L2), logos-cpp-sdk,
logos-module-loader (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:46:37 -03:00

157 lines
5.4 KiB
C++

// =============================================================================
// Tests for the host-side TokenSource reader.
//
// The module host reads its auth token from a channel its container designates
// via --token-source (stdin | fd:<n> | file:<path>), with no dependency on any
// container implementation. These tests exercise each source over real pipes
// and a real temp file, plus the timeout and error paths.
// =============================================================================
#include <gtest/gtest.h>
#include "token_source.h"
#include <fcntl.h>
#include <unistd.h>
#ifdef _WIN32
// Deliberately NOT <windows.h>: winnt.h declares a TOKEN_INFORMATION_CLASS
// enumerator literally named TokenSource, which collides head-on with this
// project's `namespace TokenSource` --
// winnt.h:4131: error: 'TokenSource' redeclared as different kind of entity
// Everything needed here lives in the CRT headers instead.
#include <io.h> // _pipe / _write / _mktemp_s
#include <share.h> // _SH_DENYNO
#include <sys/stat.h> // _S_IREAD / _S_IWRITE
#endif
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
namespace {
// mingw has no POSIX pipe(2): the CRT spells it _pipe and additionally wants a
// buffer size and a text/binary mode. _O_BINARY matters -- the token is
// compared byte for byte, and text mode would translate the "\r\n" the CRLF
// test deliberately writes.
#ifdef _WIN32
inline int makePipe(int fds[2]) { return ::_pipe(fds, 4096, _O_BINARY); }
inline int writeFd(int fd, const void* buf, std::size_t n) {
return ::_write(fd, buf, static_cast<unsigned int>(n));
}
#else
inline int makePipe(int fds[2]) { return ::pipe(fds); }
inline ssize_t writeFd(int fd, const void* buf, std::size_t n) {
return ::write(fd, buf, n);
}
#endif
// A temp file open for writing, plus its path. mingw has neither mkstemp nor
// a /tmp, so the Windows branch asks the OS for both. Returns -1 on failure.
#ifdef _WIN32
inline int makeTempFile(std::string& pathOut) {
// _mktemp_s rewrites the trailing XXXXXX in place. Relative to the cwd,
// which is writable while the tests run -- and unlike the POSIX branch
// there is no /tmp to reach for.
char tmpl[] = "logos_token_src_XXXXXX";
if (::_mktemp_s(tmpl, sizeof tmpl) != 0) return -1;
int fd = -1;
if (::_sopen_s(&fd, tmpl, _O_RDWR | _O_CREAT | _O_EXCL | _O_BINARY,
_SH_DENYNO, _S_IREAD | _S_IWRITE) != 0)
return -1;
pathOut = tmpl;
return fd;
}
#else
inline int makeTempFile(std::string& pathOut) {
char path[] = "/tmp/logos_token_src_XXXXXX";
const int fd = ::mkstemp(path);
if (fd >= 0) pathOut = path;
return fd;
}
#endif
// Write `data` to a fresh pipe and return the read fd (caller closes it). The
// write end is closed after writing so the reader sees EOF.
int pipeWith(const std::string& data) {
int fds[2];
if (makePipe(fds) != 0) return -1;
ssize_t off = 0;
while (off < static_cast<ssize_t>(data.size())) {
auto n = writeFd(fds[1], data.data() + off, data.size() - off);
if (n <= 0) break;
off += n;
}
::close(fds[1]); // EOF for the reader
return fds[0];
}
} // namespace
TEST(TokenSource, ReadsFromFdStripsNewline) {
const int rfd = pipeWith("my-secret-token\n");
ASSERT_GE(rfd, 0);
const std::string tok = TokenSource::read("fd:" + std::to_string(rfd));
::close(rfd);
EXPECT_EQ(tok, "my-secret-token");
}
TEST(TokenSource, ReadsFromFdWithoutNewlineAtEof) {
const int rfd = pipeWith("no-newline-token");
ASSERT_GE(rfd, 0);
const std::string tok = TokenSource::read("fd:" + std::to_string(rfd));
::close(rfd);
EXPECT_EQ(tok, "no-newline-token");
}
TEST(TokenSource, ReadsOnlyFirstLine) {
const int rfd = pipeWith("first-line\nsecond-line\n");
ASSERT_GE(rfd, 0);
const std::string tok = TokenSource::read("fd:" + std::to_string(rfd));
::close(rfd);
EXPECT_EQ(tok, "first-line");
}
TEST(TokenSource, ReadsFromFile) {
std::string path;
const int fd = makeTempFile(path);
ASSERT_GE(fd, 0);
const char* contents = "file-token\n";
ASSERT_GT(writeFd(fd, contents, std::strlen(contents)), 0);
::close(fd);
const std::string tok = TokenSource::read(std::string("file:") + path);
std::remove(path.c_str());
EXPECT_EQ(tok, "file-token");
}
TEST(TokenSource, UnknownSourceReturnsEmpty) {
EXPECT_TRUE(TokenSource::read("carrier-pigeon").empty());
}
TEST(TokenSource, InvalidFdSpecReturnsEmpty) {
EXPECT_TRUE(TokenSource::read("fd:notanumber").empty());
}
TEST(TokenSource, MissingFileReturnsEmpty) {
EXPECT_TRUE(TokenSource::read("file:/no/such/path/logos_token").empty());
}
TEST(TokenSource, TimesOutWhenNoDataArrives) {
// A pipe whose write end stays open and silent: the read must hit the
// timeout and return empty rather than block forever.
int fds[2];
ASSERT_EQ(makePipe(fds), 0);
const auto t0 = std::chrono::steady_clock::now();
const std::string tok = TokenSource::read("fd:" + std::to_string(fds[0]),
/*timeout_ms=*/200);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
::close(fds[0]);
::close(fds[1]);
EXPECT_TRUE(tok.empty());
EXPECT_GE(elapsed, 150) << "should wait out the timeout, not return early";
EXPECT_LT(elapsed, 1500) << "should not block well past the timeout";
}