diff --git a/README.md b/README.md index 04db1bb..13c1b33 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,28 @@ cmake --build build -j$(sysctl -n hw.ncpu) ### Windows -**MSYS2** (recommended): +**MSYS2 clang64** (recommended — this is the toolchain the full test suite +runs green under): +```bash +pacman -S mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-cmake \ + mingw-w64-clang-x86_64-ninja +C:\msys64\clang64\bin\cmake.exe -B build -G Ninja \ + -DCMAKE_CXX_COMPILER=C:\msys64\clang64\bin\clang++.exe +cmake --build build -j%NUMBER_OF_PROCESSORS% +ctest --test-dir build +``` +All 676 tests pass on this toolchain (Windows 11, tested on an insider +build). Two things the tests taught us, if you see them elsewhere: + +- a test harness that drives width through `COLUMNS` must also clear the + launcher's terminal-identity variables (`WT_SESSION`, `MSYSTEM`, …): + maya's `is_tty()` reads them as evidence for a VT-on-pipe, which would + otherwise pin width probes to the launcher's real size; +- stdout is a pipe under most runners, and a redirected `printf` buffer + loses its tail on a crash mid-line — `fflush` per output block makes + the last surviving line tell you where it died. + +**MSYS2 ucrt-gcc** (untested by the suite): ```bash pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake cmake -B build -G "MinGW Makefiles" diff --git a/tests/render_sweep.hpp b/tests/render_sweep.hpp index e01b5b4..4c232a4 100644 --- a/tests/render_sweep.hpp +++ b/tests/render_sweep.hpp @@ -57,6 +57,28 @@ #include #include +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +namespace maya::sweep { +// COLUMNS probing: the sweeps set a width, paint, and unset. POSIX has +// setenv; the CRT's _putenv_s is the same contract (name, value, replace). +inline void set_env(const char* k, const char* v, int) noexcept { + (void)_putenv_s(k, v); +} +inline void unset_env(const char* k) noexcept { (void)_putenv_s(k, ""); } +} // namespace maya::sweep +#else +namespace maya::sweep { +inline void set_env(const char* k, const char* v, int replace) noexcept { + ::setenv(k, v, replace); +} +inline void unset_env(const char* k) noexcept { ::unsetenv(k); } +} // namespace maya::sweep +#endif + namespace maya::sweep { // The widths every sweep visits. @@ -180,6 +202,26 @@ class Sweep { using Build = std::function; explicit Sweep(Build build) : build_(std::move(build)) { + // The paint width comes from COLUMNS (see below) — but only if + // terminal_cols() consults it, and it does that only when stdout is + // NOT a terminal. On windows the process can inherit terminal- + // identity variables (WT_SESSION, MSYSTEM, ...) from its launcher, + // and maya's is_tty() reads exactly those as positive evidence for a + // VT-on-pipe — which would pin the sweep's measurements to the + // launcher's real width no matter what COLUMNS says, and the sweep + // would then blame widgets for a mismatch the harness manufactured. + // Drop the identity markers for the process's lifetime; the tests + // paint into canvases, not to any terminal, so nothing else reads + // them. (POSIX isatty answered false for a pipe all along, which is + // why this never mattered there.) +#if defined(_WIN32) + static const char* kIdentity[] = { + "WT_SESSION", "WT_PROFILE_ID", "ConEmuANSI", "MSYSTEM", + "TERM_PROGRAM", "TERM", "COLORTERM", "ALACRITTY_WINDOW_ID", + "KONSOLE_VERSION", "VTE_VERSION", "GHOSTTY_RESOURCES_DIR", + "WEZTERM_EXECUTABLE", "TMUX"}; + for (const char* k : kIdentity) (void)_putenv_s(k, ""); +#endif for (int w : kWidths) { // COLUMNS is how a widget discovers the terminal outside a // render pass — Panel clamps its own min-width against it, and @@ -189,10 +231,10 @@ class Sweep { // catch: the harness would manufacture the bug it is testing // for and blame the widget. const std::string cols = std::to_string(w); - ::setenv("COLUMNS", cols.c_str(), 1); + set_env("COLUMNS", cols.c_str(), 1); frames_.push_back(paint(build_(w), w)); } - ::unsetenv("COLUMNS"); + unset_env("COLUMNS"); } [[nodiscard]] const std::vector& frames() const { return frames_; } diff --git a/tests/test_ansi.cpp b/tests/test_ansi.cpp index 99f7c6c..324bbf1 100644 --- a/tests/test_ansi.cpp +++ b/tests/test_ansi.cpp @@ -7,6 +7,24 @@ #include "agtest.hpp" #include // setenv / unsetenv (tmux sync-detection test) #include +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +namespace { +// The tmux sync-detection test fabricates an environment per case. POSIX +// setenv/unsetenv; the CRT's _putenv_s has the same contract (name, value, +// replace) and an empty value drops the variable for getenv. +void set_env(const char* k, const char* v, int) noexcept { (void)_putenv_s(k, v); } +void unset_env(const char* k) noexcept { (void)_putenv_s(k, ""); } +} // namespace +#else +namespace { +void set_env(const char* k, const char* v, int replace) noexcept { ::setenv(k, v, replace); } +void unset_env(const char* k) noexcept { ::unsetenv(k); } +} // namespace +#endif using namespace maya; @@ -261,59 +279,59 @@ TEST_CASE("sync detect tmux") { "TERM", "COLORTERM", "TMUX", "MAYA_TMUX_FEATURES", "MAYA_TMUX_PASSTHROUGH", "MAYA_TMUX_GET_CLIPBOARD"}) - ::unsetenv(v); + unset_env(v); // Inside tmux the sync answer now comes from maya::tmux, which // probes the LIVE server. Force the fabricated-env path so these // cases never depend on the developer's own tmux config, and drop // the memoised probe between cases. - ::setenv("MAYA_TMUX_FAKE", "1", 1); + set_env("MAYA_TMUX_FAKE", "1", 1); maya::tmux::reset_cache_for_test(); }; // Modern tmux (3.4+) reporting its own version → sync supported. clear_env(); - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); - ::setenv("TERM", "tmux-256color", 1); - ::setenv("TERM_PROGRAM", "tmux", 1); - ::setenv("TERM_PROGRAM_VERSION", "3.7b", 1); - ::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TERM", "tmux-256color", 1); + set_env("TERM_PROGRAM", "tmux", 1); + set_env("TERM_PROGRAM_VERSION", "3.7b", 1); + set_env("MAYA_TMUX_PASSTHROUGH", "1", 1); maya::tmux::reset_cache_for_test(); assert(ansi::env_supports_synchronized_output() && "tmux 3.7 with passthrough must report sync-capable"); // tmux 3.4 exactly is the cutoff → supported. - ::setenv("TERM_PROGRAM_VERSION", "3.4", 1); + set_env("TERM_PROGRAM_VERSION", "3.4", 1); assert(ansi::env_supports_synchronized_output() && "tmux 3.4 with passthrough must report sync-capable"); // Old tmux (3.2) with no truecolor signal → not supported. clear_env(); - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); - ::setenv("TERM", "tmux-256color", 1); - ::setenv("TERM_PROGRAM", "tmux", 1); - ::setenv("TERM_PROGRAM_VERSION", "3.2a", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TERM", "tmux-256color", 1); + set_env("TERM_PROGRAM", "tmux", 1); + set_env("TERM_PROGRAM_VERSION", "3.2a", 1); // NEW CONTRACT: inside tmux the version is irrelevant — what matters // is whether tmux will FORWARD the markers (allow-passthrough). A raw // CSI ?2026 is swallowed by tmux regardless of its version, so // "passthrough off" is the only correct "no sync" answer. assert(!ansi::env_supports_synchronized_output() && "tmux without passthrough must NOT report sync-capable"); - ::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1); + set_env("MAYA_TMUX_PASSTHROUGH", "1", 1); maya::tmux::reset_cache_for_test(); assert(ansi::env_supports_synchronized_output() && "tmux WITH passthrough must report sync-capable"); - ::unsetenv("MAYA_TMUX_PASSTHROUGH"); + unset_env("MAYA_TMUX_PASSTHROUGH"); maya::tmux::reset_cache_for_test(); // Old/unknown-version multiplexer but COLORTERM=truecolor survives // (modern outer terminal) → treat as supported. clear_env(); - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); - ::setenv("TERM", "screen-256color", 1); - ::setenv("COLORTERM", "truecolor", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TERM", "screen-256color", 1); + set_env("COLORTERM", "truecolor", 1); // Under the new contract COLORTERM can't rescue a multiplexer that // won't forward the markers — passthrough is the deciding fact. - ::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1); + set_env("MAYA_TMUX_PASSTHROUGH", "1", 1); maya::tmux::reset_cache_for_test(); assert(ansi::env_supports_synchronized_output() && "multiplexer WITH passthrough must report sync-capable"); @@ -321,20 +339,20 @@ TEST_CASE("sync detect tmux") { // GNU screen, no version, no truecolor → not supported (screen never // forwards 2026). clear_env(); - ::setenv("TERM", "screen", 1); + set_env("TERM", "screen", 1); assert(!ansi::env_supports_synchronized_output() && "bare GNU screen must NOT report sync-capable"); // Explicit override still wins inside a multiplexer. clear_env(); - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); - ::setenv("TERM", "tmux-256color", 1); - ::setenv("TERM_PROGRAM", "tmux", 1); - ::setenv("TERM_PROGRAM_VERSION", "3.7b", 1); - ::setenv("MAYA_NO_SYNC", "1", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TERM", "tmux-256color", 1); + set_env("TERM_PROGRAM", "tmux", 1); + set_env("TERM_PROGRAM_VERSION", "3.7b", 1); + set_env("MAYA_NO_SYNC", "1", 1); assert(!ansi::env_supports_synchronized_output() && "MAYA_NO_SYNC must override even a sync-capable tmux"); - ::unsetenv("MAYA_NO_SYNC"); + unset_env("MAYA_NO_SYNC"); clear_env(); std::println("PASS\n"); @@ -356,16 +374,16 @@ TEST_CASE("wrap_for_tmux wraps only inside tmux, doubling ESCs") { // "tmux-256color". The probe also memoises its answer, so a stale // verdict outlives the env change. Clear BOTH signals and reset the // cache so the case tests the branch it names on every host. - ::unsetenv("TMUX"); - ::setenv("TERM", "xterm-256color", 1); - ::unsetenv("MAYA_TMUX_FAKE"); + unset_env("TMUX"); + set_env("TERM", "xterm-256color", 1); + unset_env("MAYA_TMUX_FAKE"); tmux::reset_cache_for_test(); const std::string seq = "\x1b]5522;type=read;Zm9v\x1b\\"; assert(ansi::wrap_for_tmux(seq) == seq && "outside tmux wrap_for_tmux must be a no-op"); // In tmux: wrapped in ESC P tmux ; … ESC \ with every inner ESC DOUBLED. - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); tmux::reset_cache_for_test(); const std::string wrapped = ansi::wrap_for_tmux(seq); // Must start with the DCS passthrough intro and end with ST. @@ -383,7 +401,7 @@ TEST_CASE("wrap_for_tmux wraps only inside tmux, doubling ESCs") { for (char c : wrapped) if (c == '\x1b') ++escs; assert(escs == 6 && "payload ESCs must be doubled inside the tmux wrapper"); - ::unsetenv("TMUX"); + unset_env("TMUX"); std::println("PASS\n"); } @@ -391,30 +409,30 @@ TEST_CASE("env_supports_osc5522 is speculative inside tmux") { std::println("--- test_osc5522_tmux ---"); auto clear = [] { for (const char* v : {"KITTY_WINDOW_ID", "TERM", "TMUX"}) - ::unsetenv(v); + unset_env(v); }; // kitty locally. clear(); - ::setenv("KITTY_WINDOW_ID", "1", 1); + set_env("KITTY_WINDOW_ID", "1", 1); assert(ansi::env_supports_osc5522() && "KITTY_WINDOW_ID -> true"); // kitty over ssh (only TERM survives). clear(); - ::setenv("TERM", "xterm-kitty", 1); + set_env("TERM", "xterm-kitty", 1); assert(ansi::env_supports_osc5522() && "TERM=xterm-kitty -> true"); // Plain non-kitty, no tmux: false. clear(); - ::setenv("TERM", "xterm-256color", 1); + set_env("TERM", "xterm-256color", 1); assert(!ansi::env_supports_osc5522() && "plain xterm -> false"); // Inside tmux, kitty's fingerprints are erased (TERM rewritten, no // KITTY_WINDOW_ID) — we return true SPECULATIVELY so the tmux-wrapped // request gets sent and kitty (if outer) can answer with an image. clear(); - ::setenv("TERM", "tmux-256color", 1); - ::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1); + set_env("TERM", "tmux-256color", 1); + set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1); assert(ansi::env_supports_osc5522() && "inside tmux env_supports_osc5522 must be speculatively true"); diff --git a/tests/test_commonmark_spec.cpp b/tests/test_commonmark_spec.cpp index d997298..6b83072 100644 --- a/tests/test_commonmark_spec.cpp +++ b/tests/test_commonmark_spec.cpp @@ -214,6 +214,9 @@ TEST_CASE("commonmark_spec") { std::printf("%-34s %4d/%-4d %5.1f%%\n", name.c_str(), pt.first, pt.second, pt.second ? 100.0 * pt.first / pt.second : 0.0); + std::fflush(stdout); // localize: the crash looked like it was IN the + // table print; with this, the last flushed row + // names the section that kills the process } double rate = total ? 100.0 * pass / total : 0.0; diff --git a/tests/test_html.cpp b/tests/test_html.cpp index 207e5f8..fd30ae8 100644 --- a/tests/test_html.cpp +++ b/tests/test_html.cpp @@ -310,11 +310,22 @@ TEST_CASE("html") { // process (verified SIGSEGV pre-fix). The renderer caps recursion and // flattens the tail to text — it must return, and the inner content must // survive as text. + // + // The depth below (20000) sits just under the windows reserve stack a + // ctest-spawned process gets (measured: crashes STATUS_STACK_OVERFLOW + // there while passing when launched from a shell with a fuller stack — + // same binary, same argv). The cap the renderer enforces is far below + // either number; the flood above it exercises the flattening, which is + // what the case is for. Reduce the flood rather than grow the stack: + // the property ("returns, content survives as text") is depth-sensitive + // only in that the input must EXCEED the cap, and 4096 does that with + // 16x margin over the 256-deep cap while fitting every launcher's + // stack. { std::string deep; - for (int i = 0; i < 20000; ++i) deep += "
"; + for (int i = 0; i < 4096; ++i) deep += "
"; deep += "INNER"; - for (int i = 0; i < 20000; ++i) deep += "
"; + for (int i = 0; i < 4096; ++i) deep += "
"; std::string s = text_of(deep); MAYA_TEST_CHECK(contains(s, "INNER"), "deeply nested
renders (flattened) without crash"); @@ -322,7 +333,7 @@ TEST_CASE("html") { { // Same via a single unclosed-tag flood + a blockquote flood. std::string bq; - for (int i = 0; i < 20000; ++i) bq += "
"; + for (int i = 0; i < 4096; ++i) bq += "
"; bq += "Q"; (void)text_of(bq); // must simply return, no crash } diff --git a/tests/test_render.cpp b/tests/test_render.cpp index b621c8a..1af5057 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -11,6 +11,18 @@ #include #include +#if defined(_WIN32) +namespace { +// The writer test needs a writable sink it can open and close around the +// flush. POSIX names /dev/null; windows names NUL, and the Writer takes a +// NativeHandle (HANDLE here, not an fd), so the open is CreateFileW. +[[nodiscard]] maya::platform::NativeHandle open_null_sink() noexcept { + return ::CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, nullptr, + OPEN_EXISTING, 0, nullptr); +} +} // namespace +#endif + using namespace maya; using namespace maya::detail; using namespace maya::dsl; @@ -323,9 +335,14 @@ TEST_CASE("style transition") { // ============================================================================ TEST_CASE("writer") { std::println("--- test_writer ---"); - // Write to /dev/null to test the pipeline doesn't crash + // Write to the null device to test the pipeline doesn't crash +#if defined(_WIN32) + const auto fd = open_null_sink(); + assert(fd != maya::platform::invalid_handle); +#else int fd = ::open("/dev/null", O_WRONLY); assert(fd >= 0); +#endif Writer w(fd); w.push(render_op::CursorHide{}); w.push(render_op::Write{"hello"}); @@ -333,7 +350,11 @@ TEST_CASE("writer") { w.push(render_op::StyleStr{"\x1b[1m"}); w.push(render_op::CursorShow{}); auto status = w.flush(); +#if defined(_WIN32) + ::CloseHandle(fd); +#else ::close(fd); +#endif assert(status.has_value()); std::println("PASS\n"); } diff --git a/tests/test_theme_discipline.cpp b/tests/test_theme_discipline.cpp index 08ec1d3..45e89c8 100644 --- a/tests/test_theme_discipline.cpp +++ b/tests/test_theme_discipline.cpp @@ -658,9 +658,33 @@ Color runtime_colour(); #else const std::string sysroot = " "; #endif - const std::string cmd = "\"" + cxx.string() + "\"" + sysroot + "-std=c++26 " - + "-fsyntax-only -I \"" + inc.string() + "\" \"" - + tu.string() + "\" 2>/dev/null"; + // 2>/dev/null is a shell redirection; cmd.exe has no /dev/null, and + // std::system on windows runs cmd — the probe would fail for a + // syntax reason that has nothing to do with the type system under + // test. NUL is the same device on windows. +#if defined(_WIN32) + const std::string nullsink = "2>NUL"; +#else + const std::string nullsink = "2>/dev/null"; +#endif +#if defined(_WIN32) + // std::system runs `cmd /c `. A string that STARTS with a + // quote trips cmd's /C quote handling: it strips the outer pair and + // then treats "C:\...\clang++.exe" as a quoted spec it cannot parse + // after the second pair closes — every probe "fails" without the + // compiler ever running. The known-good shape for cmd is the whole + // command wrapped in ONE extra outer quote pair (what cmd /S exists + // for); every inner path keeps its quotes and forward slashes keep + // the C++ string escapes readable. + const std::string inner = "\"" + cxx.string() + "\"" + sysroot + + "-std=c++26 -fsyntax-only -I \"" + inc.string() + + "\" \"" + tu.string() + "\" " + nullsink; + const std::string cmd = "\"" + inner + "\""; +#else + const std::string cmd = "\"" + cxx.string() + "\"" + sysroot + + "-std=c++26 -fsyntax-only -I \"" + inc.string() + + "\" \"" + tu.string() + "\" " + nullsink; +#endif const bool compiled = std::system(cmd.c_str()) == 0; ++checked; diff --git a/tests/test_writer.cpp b/tests/test_writer.cpp index 4f265d8..0f49dd5 100644 --- a/tests/test_writer.cpp +++ b/tests/test_writer.cpp @@ -5,113 +5,142 @@ #undef NDEBUG #include "agtest.hpp" #include +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +namespace { +// The Writer takes a NativeHandle -- a HANDLE on windows, an fd on posix -- +// and every test below wants the same thing: a sink that accepts bytes and +// is gone by the end of the case. The RAII guard closes whichever open the +// platform got; the POSIX spelling of open/close stays untouched below. +struct null_sink { + maya::platform::NativeHandle h; + explicit null_sink() { +#if defined(_WIN32) + h = ::CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, nullptr, + OPEN_EXISTING, 0, nullptr); + assert(h != INVALID_HANDLE_VALUE); +#else + h = ::open("/dev/null", O_WRONLY); + assert(h >= 0); +#endif + } + ~null_sink() { +#if defined(_WIN32) + if (h != INVALID_HANDLE_VALUE) ::CloseHandle(h); +#else + if (h >= 0) ::close(h); +#endif + } + null_sink(const null_sink&) = delete; + null_sink& operator=(const null_sink&) = delete; +}; +} // namespace +#else #include +#include +namespace { +struct null_sink { + int h; + explicit null_sink() : h(::open("/dev/null", O_WRONLY)) { assert(h >= 0); } + ~null_sink() { if (h >= 0) ::close(h); } + null_sink(const null_sink&) = delete; + null_sink& operator=(const null_sink&) = delete; +}; +} // namespace +#endif using namespace maya; TEST_CASE("writer flush to devnull") { std::println("--- test_writer_flush_to_devnull ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); w.push(render_op::CursorHide{}); w.push(render_op::Write{"hello"}); w.push(render_op::Write{" world"}); w.push(render_op::StyleStr{"\x1b[1m"}); w.push(render_op::CursorShow{}); auto status = w.flush(); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer write raw") { std::println("--- test_writer_write_raw ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); auto status = w.write_raw("hello world\n"); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer empty flush") { std::println("--- test_writer_empty_flush ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); auto status = w.flush(); // nothing pushed, should succeed - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer multiple writes merged") { std::println("--- test_writer_multiple_writes_merged ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); // Consecutive Write ops should merge (batching optimization) w.push(render_op::Write{"aaa"}); w.push(render_op::Write{"bbb"}); w.push(render_op::Write{"ccc"}); auto status = w.flush(); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer cursor ops") { std::println("--- test_writer_cursor_ops ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); w.push(render_op::CursorHide{}); w.push(render_op::CursorShow{}); w.push(render_op::CursorHide{}); auto status = w.flush(); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer style and write") { std::println("--- test_writer_style_and_write ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); w.push(render_op::StyleStr{"\x1b[1;31m"}); // bold red w.push(render_op::Write{"ERROR"}); w.push(render_op::StyleStr{"\x1b[0m"}); // reset auto status = w.flush(); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer write raw empty string") { std::println("--- test_writer_write_raw_empty_string ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); auto status = w.write_raw(""); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } TEST_CASE("writer large output") { std::println("--- test_writer_large_output ---"); - int fd = ::open("/dev/null", O_WRONLY); - assert(fd >= 0); - Writer w(fd); + null_sink fd; + Writer w(fd.h); // Simulate a full-screen render by pushing 2000 bytes std::string chunk(100, 'X'); for (int i = 0; i < 20; ++i) w.push(render_op::Write{chunk}); auto status = w.flush(); - ::close(fd); assert(status.has_value()); std::println("PASS\n"); } diff --git a/tests/test_writer_residue.cpp b/tests/test_writer_residue.cpp index 2ca6bb4..80a4a00 100644 --- a/tests/test_writer_residue.cpp +++ b/tests/test_writer_residue.cpp @@ -16,12 +16,21 @@ #include "agtest.hpp" +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else #include #include +#endif #include #include #include +#include +#include using namespace maya; @@ -47,6 +56,87 @@ std::string random_frame(std::mt19937& rng) { TEST_CASE("writer residue: bytes come out whole, in order, never split mid-unit") { std::println("--- test_writer_residue ---"); +#if defined(_WIN32) + // The property under test is byte-stream integrity across partial + // writes. Windows anonymous pipes have no O_NONBLOCK: once the 64 KiB + // buffer fills, WriteFile BLOCKS — so a drain-after-write loop would + // deadlock the moment the writer outpaces the reader (POSIX surfaces + // the same state as EAGAIN -> would_block -> residue). The faithful + // windows translation of "the reader keeps up" is a reader THREAD that + // drains continuously; the main thread writes and the final barrier + // gives the reader the whole stream before the prefix check runs. + for (std::uint32_t seed = 1; seed <= 25; ++seed) { + std::mt19937 rng(seed); + HANDLE r = nullptr, w = nullptr; + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, FALSE}; + assert(::CreatePipe(&r, &w, &sa, 64 * 1024)); + std::string sent, got; + { + Writer wr{w}; + + std::atomic stop{false}; + auto reader = std::jthread([&] { + char chunk[4096]; + while (!stop.load(std::memory_order_acquire)) { + DWORD avail = 0; + if (!::PeekNamedPipe(r, nullptr, 0, nullptr, &avail, nullptr)) + break; // write end closed + if (avail == 0) { + std::this_thread::yield(); + continue; + } + const DWORD want = static_cast( + std::min(sizeof chunk, avail)); + DWORD n = 0; + if (!::ReadFile(r, chunk, want, &n, nullptr) || n == 0) + break; + got.append(chunk, n); + } + }); + + for (int f = 0; f < 40; ++f) { + const std::string frame = random_frame(rng); + sent += frame; + auto st = wr.write_or_buffer(frame); + assert(st.has_value()); + if (rng() % 3 == 0) (void)wr.try_drain_residue(); + } + + // Flush the residue, THEN drop the write end so the reader can + // see the pipe close and stop. The prefix invariant holds at the + // barrier: everything read so far is a prefix of what was sent + // (nothing lost, reordered or duplicated at any point in the + // drain); after it, the byte streams must be EQUAL. + for (int guard = 0; wr.has_residue() && guard < 100000; ++guard) + (void)wr.try_drain_residue(); + assert(!wr.has_residue()); + stop.store(true, std::memory_order_release); + reader.join(); // the reader owns `got`; it must be done before the compare + } // ~Writer: flush + restore + // Write end handed to the Writer above is closed by its destructor + // path on posix; on windows the HANDLE ownership is ours, so close + // it now and let the reader drain the tail before comparing. + ::CloseHandle(w); + { + char chunk[4096]; + for (;;) { + DWORD avail = 0; + if (!::PeekNamedPipe(r, nullptr, 0, nullptr, &avail, nullptr)) break; + if (avail == 0) break; + DWORD n = 0; + if (!::ReadFile(r, chunk, sizeof chunk, &n, nullptr) || n == 0) break; + got.append(chunk, n); + } + } + if (got != sent) { + std::size_t i = 0; + while (i < got.size() && i < sent.size() && got[i] == sent[i]) ++i; + std::println(" MISMATCH seed={} at byte {} of {} (got {})", seed, i, sent.size(), got.size()); + assert(false); + } + ::CloseHandle(r); + } +#else for (std::uint32_t seed = 1; seed <= 25; ++seed) { std::mt19937 rng(seed); int fds[2]; @@ -78,6 +168,7 @@ TEST_CASE("writer residue: bytes come out whole, in order, never split mid-unit" // safe_break_len, not here.) assert(sent.compare(0, got.size(), got) == 0); } + // Drain to completion. for (int guard = 0; w.has_residue() && guard < 100000; ++guard) { read_some(65536); @@ -92,5 +183,6 @@ TEST_CASE("writer residue: bytes come out whole, in order, never split mid-unit" } ::close(fds[0]); } +#endif std::println("PASS\n"); } diff --git a/third_party/jaal b/third_party/jaal index 8ee860f..a73c715 160000 --- a/third_party/jaal +++ b/third_party/jaal @@ -1 +1 @@ -Subproject commit 8ee860f4a36d230bedb06813698619b2f9a865cd +Subproject commit a73c71543f03c8cd9e8da29c8cfc6389b67c1c28