Skip to content

Commit 42318fe

Browse files
committed
Fix Linux input echo leak by bypassing .NET Console entirely on Unix
Replace all hot-path Console.* calls with raw libc I/O to eliminate the 9-month-old echo leak caused by .NET's ConsolePal toggling termios. - Add raw terminal mode via tcgetattr/tcsetattr (cfmakeraw equivalent) - Add raw libc write() for stdout, bypassing .NET's StreamWriter - Add raw stdin fd 0 reader with ANSI input parser and mouse state machine - Add ioctl TIOCGWINSZ for window size instead of Console.WindowWidth/Height - Redirect Console.Out to /dev/null to prevent .NET runtime stdout writes - Use alternate screen buffer for clean display - Suppress SIGINT/SIGTSTP via signal() instead of Console.TreatControlCAsInput - Skip FIX24/25/27 workarounds when raw mode is active - Expose UseDirectAnsi option in ConsoleWindowSystemOptions (default: true) - Windows behavior unchanged (continues using Console.*)
1 parent fe64e03 commit 42318fe

10 files changed

Lines changed: 1649 additions & 94 deletions

SharpConsoleUI/Configuration/ConsoleWindowSystemOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ public record ConsoleWindowSystemOptions(
9090
bool Fix27_PeriodicFullRedraw = true,
9191
double Fix27_RedrawIntervalSeconds = 1.0,
9292

93+
// ===== PLATFORM-SPECIFIC =====
94+
// When true on Unix, bypasses .NET Console entirely using raw libc I/O
95+
// to eliminate the input echo leak. Has no effect on Windows.
96+
bool UseDirectAnsi = true,
97+
9398
// ===== DIAGNOSTICS & TESTING =====
9499
// Rendering diagnostics for testing and debugging (default: false, zero overhead when disabled)
95100
bool EnableDiagnostics = false,

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ public class ConsoleWindowSystem
120120

121121
#region Constructors
122122

123+
/// <summary>
124+
/// Initializes a new instance with a render mode. UseDirectAnsi is read from options.
125+
/// </summary>
126+
/// <param name="renderMode">The rendering mode to use.</param>
127+
/// <param name="pluginConfiguration">Optional plugin configuration for auto-loading plugins.</param>
128+
/// <param name="options">Optional configuration options for system behavior.</param>
129+
public ConsoleWindowSystem(RenderMode renderMode, PluginConfiguration? pluginConfiguration = null, ConsoleWindowSystemOptions? options = null)
130+
: this(CreateDriver(renderMode, options), pluginConfiguration, options)
131+
{
132+
}
133+
134+
private static NetConsoleDriver CreateDriver(RenderMode renderMode, ConsoleWindowSystemOptions? options)
135+
{
136+
var opts = options ?? ConsoleWindowSystemOptions.Default;
137+
return new NetConsoleDriver(new NetConsoleDriverOptions
138+
{
139+
RenderMode = renderMode,
140+
UseDirectAnsi = opts.UseDirectAnsi
141+
});
142+
}
143+
123144
/// <summary>
124145
/// Initializes a new instance of the <see cref="ConsoleWindowSystem"/> class with the default theme.
125146
/// </summary>

SharpConsoleUI/Drivers/ConsoleBuffer.cs

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// License: MIT
77
// -----------------------------------------------------------------------
88

9+
using SharpConsoleUI.Drivers.Input;
910
using SharpConsoleUI.Helpers;
1011
using System.Runtime.InteropServices;
1112
using System.Text;
@@ -119,7 +120,7 @@ public void AddContent(int x, int y, string content)
119120

120121
// Single-pass state machine parser for ANSI sequences
121122
// FIX6: Also limit width in AddContent to prevent writing beyond console
122-
int maxBufferX = _options.Fix6_WidthLimit ? Math.Min(_width, Console.WindowWidth) : _width;
123+
int maxBufferX = _options.Fix6_WidthLimit ? Math.Min(_width, GetCurrentWindowWidth()) : _width;
123124
while (contentPos < content.Length && bufferX < maxBufferX)
124125
{
125126
// State machine: detect ESC character
@@ -267,12 +268,13 @@ public void Render()
267268
CaptureConsoleBufferSnapshot();
268269
}
269270

270-
Console.CursorVisible = false;
271+
// Hide cursor during render — use raw write on Unix to bypass .NET entirely
272+
WriteOutput("\x1b[?25l");
271273

272274
// FIX24: Drain input buffer to prevent mouse sequences from being echoed during rendering
273-
// Race condition: If mouse events arrive while we're outputting cursor positioning,
274-
// they get echoed at our current cursor position, mixing with our content
275-
if (_options.Fix24_DrainInputBeforeRender)
275+
// Skip when raw mode active — no echo means nothing to drain, and Console.ReadKey/KeyAvailable
276+
// would go through ConsolePal which we're trying to avoid
277+
if (_options.Fix24_DrainInputBeforeRender && !TerminalRawMode.IsRawModeActive)
276278
{
277279
int drained = 0;
278280
while (Console.KeyAvailable)
@@ -291,19 +293,19 @@ public void Render()
291293
// FIX25: Temporarily disable mouse tracking during rendering
292294
// Problem: .NET Console.ReadKey() toggles echo on/off, creating windows where mouse sequences get echoed
293295
// Solution: Disable mouse tracking entirely during render to prevent new events from arriving
294-
if (_options.Fix25_DisableMouseDuringRender)
296+
// FIX25: Disable mouse tracking during render — skip in raw mode (unnecessary)
297+
if (_options.Fix25_DisableMouseDuringRender && !TerminalRawMode.IsRawModeActive)
295298
{
296-
// Disable all mouse tracking modes (reverse order of enable)
297-
Console.Out.Write("\x1b[?1003l"); // Disable any event mouse
298-
Console.Out.Write("\x1b[?1002l"); // Disable button event tracking
299-
Console.Out.Write("\x1b[?1000l"); // Disable basic mouse reporting
300-
Console.Out.Flush();
299+
WriteOutput("\x1b[?1003l"); // Disable any event mouse
300+
WriteOutput("\x1b[?1002l"); // Disable button event tracking
301+
WriteOutput("\x1b[?1000l"); // Disable basic mouse reporting
302+
FlushOutput();
301303
}
302304

303305

304306
// FIX27: Periodic redraw of clean cells to clear terminal echo leaks (Linux/macOS only)
305-
// Check if 1 second has elapsed since last full redraw (time-based, not frame-based)
306-
if (_options.Fix27_PeriodicFullRedraw && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
307+
// Keep active even in raw mode as a visual cleanup backstop
308+
if (_options.Fix27_PeriodicFullRedraw && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !TerminalRawMode.IsRawModeActive)
307309
{
308310
var elapsed = (DateTime.Now - _lastFullRedraw).TotalSeconds;
309311
if (elapsed >= _options.Fix27_RedrawIntervalSeconds)
@@ -417,11 +419,12 @@ public void Render()
417419
}
418420

419421

420-
// Single atomic write of entire screen - no cursor jumps, no flicker!
422+
// Single atomic write of entire screen via raw libc write() on Unix
423+
// Completely bypasses .NET's Console/StreamWriter/SyncTextWriter
421424
var output = screenBuilder.ToString();
422425
if (output.Length > 0)
423426
{
424-
Console.Write(output);
427+
WriteOutput(output);
425428
}
426429

427430
// Diagnostics: Capture output metrics
@@ -445,13 +448,12 @@ public void Render()
445448
}
446449

447450
// FIX25: Re-enable mouse tracking after rendering completes
448-
if (_options.Fix25_DisableMouseDuringRender)
451+
if (_options.Fix25_DisableMouseDuringRender && !TerminalRawMode.IsRawModeActive)
449452
{
450-
// Re-enable mouse tracking in same order as NetConsoleDriver initialization
451-
Console.Out.Write("\x1b[?1000h"); // Enable basic mouse reporting
452-
Console.Out.Write("\x1b[?1002h"); // Enable button event tracking
453-
Console.Out.Write("\x1b[?1003h"); // Enable any event mouse
454-
Console.Out.Flush();
453+
WriteOutput("\x1b[?1000h"); // Enable basic mouse reporting
454+
WriteOutput("\x1b[?1002h"); // Enable button event tracking
455+
WriteOutput("\x1b[?1003h"); // Enable any event mouse
456+
FlushOutput();
455457
}
456458
}
457459
}
@@ -619,6 +621,37 @@ private bool IsLineDirty(int y)
619621
return (true, false, regions);
620622
}
621623

624+
/// <summary>
625+
/// Writes output using raw libc write() when raw mode is active, or Console.Out.Write as fallback.
626+
/// Raw write completely bypasses .NET's Console/StreamWriter/SyncTextWriter infrastructure,
627+
/// eliminating any possibility of .NET runtime code touching termios during output.
628+
/// </summary>
629+
private static void WriteOutput(string text)
630+
{
631+
if (TerminalRawMode.IsRawModeActive)
632+
TerminalRawMode.WriteStdout(text);
633+
else
634+
Console.Out.Write(text);
635+
}
636+
637+
/// <summary>
638+
/// Flushes stdout. No-op when raw mode is active (raw write is unbuffered).
639+
/// </summary>
640+
private static void FlushOutput()
641+
{
642+
if (!TerminalRawMode.IsRawModeActive)
643+
Console.Out.Flush();
644+
}
645+
646+
/// <summary>
647+
/// Gets current window width using ioctl when raw mode is active, avoiding ConsolePal.
648+
/// </summary>
649+
private int GetCurrentWindowWidth()
650+
{
651+
var (w, _) = TerminalRawMode.GetWindowSize();
652+
return w;
653+
}
654+
622655
private bool IsValidPosition(int x, int y)
623656
=> x >= 0 && x < _width && y >= 0 && y < _height;
624657

@@ -668,7 +701,7 @@ private void AppendLineToBuilder(int y, StringBuilder builder)
668701
int cellsWritten = 0; // Count cells written
669702

670703
// FIX6: Limit to console window width to prevent writing beyond visible area
671-
int maxWidth = _options.Fix6_WidthLimit ? Math.Min(_width, Console.WindowWidth) : _width;
704+
int maxWidth = _options.Fix6_WidthLimit ? Math.Min(_width, GetCurrentWindowWidth()) : _width;
672705
for (int x = 0; x < maxWidth; x++)
673706
{
674707
ref var frontCell = ref _frontBuffer[x, y];

0 commit comments

Comments
 (0)