#20693·terminal

WriteConsoleW hangs forever writing a 2-column glyph to the last column with wrapping disabled

Author: jeroenheijmansCreated Sep 17, 2026Updated Sep 17, 2026
LabelsIssue-BugNeeds-Triage

Windows Terminal version

1.24.11911.0

Windows build number

10.0.26200.9457

Other Software

No response

Steps to reproduce

This bug report started from a combination of "lefthook" pre-commit logic, running in parallel things like knip, tsc, pnpm, prettier, and others. Those steps were rather involved, and inside proprietary code - so with help from AI I did analysis, cloned the terminal repository, and wrote a failing unit tests that seems to represent the steps to reproduce. Note that it possibly is related to https://github.com/evilmartians/lefthook/issues/1256

I asked the LLM to summarize the steps to reproduce, and sign off as a human on this summary (even though it's got a bit of AI 'tone' to it):


Steps to reproduce

Minimal, deterministic:

  1. Get a console output handle and set the mode to 0: SetConsoleMode(hOut, 0) — this clears ENABLE_PROCESSED_OUTPUT, ENABLE_WRAP_AT_EOL_OUTPUT and ENABLE_VIRTUAL_TERMINAL_PROCESSING together.
  2. Move the cursor to the last column of any row: SetConsoleCursorPosition(hOut, {width - 1, y}).
  3. Call WriteConsoleW with text beginning in a grapheme cluster that measures two columns — e.g. L"\u2714\uFE0F x" (U+2714 HEAVY CHECK MARK + U+FE0F VARIATION SELECTOR-16).

WriteConsoleW never returns. The host's "Console Driver Message IO Thread" spins at 100% CPU. Every other console client then blocks on its next console API call, Ctrl+C is never serviced, and the render thread cannot take the buffer lock, so the window stops repainting.

Only one column remains, the cluster needs two, so zero characters are consumed per iteration — and with wrapping off the cursor is deliberately left where it was, so the next iteration is identical.

How it arises organically (this is the path that produced your memory dump):

  1. A Cygwin-derived sh.exe (Git for Windows) sets output mode 0 and raw input while running a native child, restoring it afterwards.
  2. Several such shells run concurrently under a Go hook runner; their restores race, leaving the mode at 0 for a window of time.
  3. With ENABLE_PROCESSED_OUTPUT off, LF is drawn as a printable glyph rather than moving the cursor — so the cursor drifts right and then stays pinned in the last column.
  4. The runner writes its coloured status line, which begins 2714 FE0F 0020 001B 005B 0033 ... — a check mark, then SGR sequences.
  5. The first two-column glyph hits the pinned cursor and the host wedges.

Then, finally, here is a unit test that is supposedly representative of my issue. Full disclosure: this was fully written by Claude Code and Opus - I have only limited knowledge of CPP. Eyeballing it this does seem to be useful as part of a bug report, but would likely need heavy re-authoring to be useful as code? If it is already decent as is I'm happy to make a PR for it of course. But mainly adding this to try and be helpful:

diff --git a/src/host/ut_host/ScreenBufferTests.cpp b/src/host/ut_host/ScreenBufferTests.cpp
index 943d3199d..73b0c959c 100644
--- a/src/host/ut_host/ScreenBufferTests.cpp
+++ b/src/host/ut_host/ScreenBufferTests.cpp
@@ -18,10 +18,13 @@
 #include "../../inc/conattrs.hpp"
 #include "../../types/inc/colorTable.hpp"
 #include "../../types/inc/Viewport.hpp"
+#include "../../types/inc/CodepointWidthDetector.hpp"
 
 #include "../../inc/TestUtils.h"
 
 #include <sstream>
+#include <chrono>
+#include <thread>
 
 using namespace WEX::Common;
 using namespace WEX::Logging;
@@ -160,6 +163,7 @@ class ScreenBufferTests
 
     TEST_METHOD(BackspaceDefaultAttrs);
     TEST_METHOD(BackspaceDefaultAttrsWriteCharsLegacy);
+    TEST_METHOD(WriteCharsLegacyWideGlyphAtEndOfRowNoWrapHangs);
 
     TEST_METHOD(BackspaceDefaultAttrsInPrompt);
 
@@ -8469,3 +8473,81 @@ void ScreenBufferTests::SimplePromptRegions()
         VERIFY_IS_FALSE(mark.outputEnd.has_value());
     }
 }
+
+// Repro for a hang in WriteConsoleW / _writeCharsLegacyUnprocessed.
+//
+// When the output mode is 0 (ENABLE_PROCESSED_OUTPUT, ENABLE_WRAP_AT_EOL_OUTPUT and
+// ENABLE_VIRTUAL_TERMINAL_PROCESSING all off), the cursor sits in the last column of a
+// row, and the text starts with a glyph that measures 2 columns, then:
+//   * ROW::WriteHelper::_replaceTextUnicode bails out with charsConsumed == 0, because
+//     the glyph does not fit into the single remaining column,
+//   * ROW::ReplaceText therefore leaves state.text untouched and reports
+//     columnEnd == columnLimit,
+//   * AdjustCursorPosition sees x >= width but, with wrapping off, deliberately puts the
+//     cursor back where it was ("otherwise leave it where it is"),
+//   * so the `while (!state.text.empty())` loop in _writeCharsLegacyUnprocessed starts the
+//     next iteration with exactly the same cursor position and the same text.
+// Nothing makes progress, and the console's API thread spins at 100% CPU forever.
+void ScreenBufferTests::WriteCharsLegacyWideGlyphAtEndOfRowNoWrapHangs()
+{
+    auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer().GetActiveBuffer();
+    auto& textBuffer = si.GetTextBuffer();
+    auto& cursor = textBuffer.GetCursor();
+
+    // The loop requires the leading glyph to measure 2 columns, so pin the measurement
+    // mode explicitly instead of depending on whatever the default happens to be.
+    auto& cwd = CodepointWidthDetector::Singleton();
+    auto restoreMeasurementMode = wil::scope_exit([&, previous = cwd.GetMode()]() { cwd.Reset(previous); });
+    cwd.Reset(TextMeasurementMode::Graphemes);
+
+    auto restoreOutputMode = wil::scope_exit([&, previous = si.OutputMode]() { si.OutputMode = previous; });
+    si.OutputMode = 0;
+
+    const auto width = textBuffer.GetSize().Width();
+    const til::point lastColumn{ width - 1, 0 };
+    cursor.SetPosition(lastColumn);
+
+    // U+2714 HEAVY CHECK MARK followed by U+FE0F VARIATION SELECTOR-16 is one grapheme
+    // cluster that measures 2 columns, but only 1 column is left in the row.
+    static constexpr std::wstring_view text{ L"\u2714\uFE0F x" };
+
+    Log::Comment(NoThrowString().Format(
+        L"Writing a 2-column glyph into the last column of a %d-wide row with output mode 0.", width));
+
+    // WriteCharsLegacy never returns on an affected build, so drive it from a worker thread
+    // and give up after a timeout rather than hanging the whole test host.
+    std::atomic<bool> completed{ false };
+    std::thread worker{ [&]() {
+        WriteCharsLegacy(si, text, nullptr);
+        completed.store(true, std::memory_order_release);
+    } };
+
+    static constexpr auto timeout = std::chrono::seconds{ 5 };
+    const auto deadline = std::chrono::steady_clock::now() + timeout;
+    while (!completed.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline)
+    {
+        std::this_thread::sleep_for(std::chrono::milliseconds{ 10 });
+    }
+
+    const auto watchdogFired = !completed.load(std::memory_order_acquire);
+    if (watchdogFired)
+    {
+        // Break the spin so the worker can finish and the test host can shut down cleanly:
+        // the loop re-reads the cursor on every iteration, so moving it to a column where
+        // the glyph fits lets the write consume its input and return.
+        Log::Comment(L"Watchdog fired: the write did not return. Rescuing the spinning thread.");
+        cursor.SetPosition({ 0, 0 });
+    }
+    worker.join();
+
+    VERIFY_IS_FALSE(watchdogFired,
+                    L"WriteCharsLegacy must consume its input and return instead of spinning forever.");
+
+    // Only meaningful once the call actually returns on its own. Exactly where the glyph
+    // ends up (dropped, or moved to the next row) is a decision for the fix; what matters
+    // here is that the text was consumed and the cursor is back inside the buffer.
+    Log::Comment(NoThrowString().Format(
+        L"Cursor ended at (%d, %d).", cursor.GetPosition().x, cursor.GetPosition().y));
+    VERIFY_IS_LESS_THAN(cursor.GetPosition().x, width);
+}

Expected Behavior

Terminal can handle a process that does terminal work in parallel while spitting out colored output that includes complex characters/glyphs. The process should finish (fail or succeed) normally.

Actual Behavior

The Terminal will hang and become nonresponsive, not even able to handle "CTRL+C" break anymore.