diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index c553c19..0a356f0 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Threading; @@ -35,6 +36,26 @@ public class ClaudeCliService public bool IsAvailable => _isAvailable; public string CliPath => _cliPath; + /// + /// Aktiviert detailliertes Encoding-Debugging für CLI-stdout. + /// Schreibt alle rohen Bytes vom CLI-Prozess in eine .bin-Datei und + /// loggt jedes Vorkommen von U+FFFD (Replacement-Char) in eine .log-Datei. + /// Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1 + /// + private static bool IsByteDebugEnabled() + { + var v = Environment.GetEnvironmentVariable("GOTT_CLI_DEBUG_BYTES"); + return !string.IsNullOrEmpty(v) && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + } + + private static string GetDebugDir() + { + // data/cli-debug/ relativ zur Anwendung – wird automatisch erstellt + var dir = Path.Combine(AppContext.BaseDirectory, "data", "cli-debug"); + Directory.CreateDirectory(dir); + return dir; + } + private ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false) { var psi = new ProcessStartInfo @@ -219,6 +240,9 @@ public class ClaudeCliService var lineBuffer = new StringBuilder(); + // Encoding-Debug Cleanup-Holder (im finally geschlossen) + StreamWriter debugMarkerWriter = null; + try { var startInfo = CreateCliStartInfo(args, redirectInput: true); @@ -248,10 +272,83 @@ public class ClaudeCliService await process.StandardInput.FlushAsync(); process.StandardInput.Close(); - using var reader = process.StandardOutput; + // === ENCODING-DIAGNOSE === (nur aktiv wenn GOTT_CLI_DEBUG_BYTES=1) + // Wenn aktiviert, alle rohen Bytes in eine .bin-Datei teen und Marker-Log + // für jedes Vorkommen von U+FFFD schreiben. So können wir feststellen, ob das + // Replacement-Char schon vom Claude-CLI kommt (.bin enthält bereits invalide + // UTF-8-Bytes) oder erst beim Decode entsteht (.bin ist sauber, aber decoded + // line hat �). Im Default-Modus (env nicht gesetzt) bleibt der ursprüngliche + // Default-Reader aktiv – kein zusätzlicher Disk-IO im Normalbetrieb. + bool debugBytes = IsByteDebugEnabled(); + FileStream rawStream = null; + StreamWriter markerWriter = null; + string debugSessionId = null; + + if (debugBytes) + { + try + { + debugSessionId = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"); + var debugDir = GetDebugDir(); + var rawPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.bin"); + var markerPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.log"); + rawStream = new FileStream(rawPath, FileMode.Create, FileAccess.Write, FileShare.Read); + markerWriter = new StreamWriter(markerPath, append: false, new UTF8Encoding(false)) { AutoFlush = true }; + debugMarkerWriter = markerWriter; // für finally-Cleanup + markerWriter.WriteLine($"# CLI Encoding Debug Log – Session {debugSessionId}"); + markerWriter.WriteLine($"# Roh-Bytes: {Path.GetFileName(rawPath)}"); + markerWriter.WriteLine($"# Args: {string.Join(" ", args)}"); + markerWriter.WriteLine($"# OS: {RuntimeInformation.OSDescription}, ACP={Encoding.Default.CodePage}, OEM={Console.OutputEncoding.CodePage}"); + markerWriter.WriteLine(); + _logger.LogWarning("CLI encoding debug aktiv. Roh-Bytes → {Raw}, Marker-Log → {Log}", rawPath, markerPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Konnte CLI-Debug-Logging nicht initialisieren – fahre ohne fort"); + debugBytes = false; + rawStream?.Dispose(); rawStream = null; + markerWriter?.Dispose(); markerWriter = null; + } + } + + // Im Debug-Modus: Custom-Reader mit TeeStream (16 KB Buffer + UTF-8 ohne BOM-Detection). + // Im Default-Modus: ursprünglicher Reader (= process.StandardOutput) – kein Encoding- + // override, weil die diversen Härtungsversuche (chcp, UTF-8-Reader, Console.OutputEncoding) + // das �-Problem nachweislich nicht behoben haben. Wir warten auf das Diagnose-Ergebnis, + // bevor wir den nächsten Fix-Versuch starten. + using StreamReader reader = (debugBytes && rawStream != null) + ? new StreamReader( + new TeeReadStream(process.StandardOutput.BaseStream, rawStream, leaveTeeOpen: false), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + detectEncodingFromByteOrderMarks: false, + bufferSize: 16384, + leaveOpen: false) + : process.StandardOutput; string line; + int lineNum = 0; while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { + lineNum++; + + // Marker-Log: U+FFFD im decoded Output erkennen + if (debugBytes && markerWriter != null && line.IndexOf('�') >= 0) + { + try + { + var firstAt = line.IndexOf('�'); + int ctxStart = Math.Max(0, firstAt - 40); + int ctxEnd = Math.Min(line.Length, firstAt + 40); + var ctx = line.Substring(ctxStart, ctxEnd - ctxStart); + // U+FFFD im Kontext durch sichtbares Marker ersetzen für die Log-Zeile + var ctxVis = ctx.Replace("�", "<>"); + markerWriter.WriteLine($"[Line {lineNum}] FFFD count={CountChar(line, '�')} firstPos={firstAt} totalLen={line.Length}"); + markerWriter.WriteLine($" Context: ...{ctxVis}..."); + markerWriter.WriteLine($" Full line (first 500 chars): {(line.Length > 500 ? line.Substring(0, 500) : line).Replace("�", "<>")}"); + markerWriter.WriteLine(); + _logger.LogWarning("CLI U+FFFD detected on line {Ln} at pos {Pos}: ...{Ctx}...", lineNum, firstAt, ctxVis); + } + catch { } + } if (!line.StartsWith("{")) continue; @@ -534,10 +631,33 @@ public class ClaudeCliService result.Error = ex.Message; _logger.LogError(ex, "Fehler beim Streaming der Claude CLI"); } + finally + { + // Encoding-Debug Marker-Writer abschließen (rawStream wird vom TeeReadStream-Dispose geschlossen) + if (debugMarkerWriter != null) + { + try + { + debugMarkerWriter.WriteLine(); + debugMarkerWriter.WriteLine($"# End. fullOutput length = {fullOutput.Length}, FFFD count = {CountChar(fullOutput.ToString(), '�')}"); + debugMarkerWriter.Flush(); + debugMarkerWriter.Dispose(); + } + catch { } + } + } return result; } + private static int CountChar(string s, char c) + { + if (string.IsNullOrEmpty(s)) return 0; + int n = 0; + for (int i = 0; i < s.Length; i++) if (s[i] == c) n++; + return n; + } + /// /// Prüft ob die CLI funktioniert und authentifiziert ist. /// @@ -620,6 +740,103 @@ public class ClaudeCliService } } +/// +/// Stream-Wrapper, der jedes gelesene Byte parallel in einen zweiten Stream (Tee-File) schreibt. +/// Wird für Encoding-Diagnose verwendet, um die rohen Bytes vom Claude-CLI-Prozess zu konservieren, +/// bevor sie durch den UTF-8-StreamReader gehen. +/// +internal sealed class TeeReadStream : Stream +{ + private readonly Stream _source; + private readonly Stream _tee; + private readonly bool _leaveTeeOpen; + private long _totalRead; + + public TeeReadStream(Stream source, Stream tee, bool leaveTeeOpen = false) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _tee = tee ?? throw new ArgumentNullException(nameof(tee)); + _leaveTeeOpen = leaveTeeOpen; + } + + public long TotalRead => _totalRead; + + public override bool CanRead => _source.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => _totalRead; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + var n = _source.Read(buffer, offset, count); + if (n > 0) + { + _tee.Write(buffer, offset, n); + _tee.Flush(); + _totalRead += n; + } + return n; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var n = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (n > 0) + { + await _tee.WriteAsync(buffer.Slice(0, n), cancellationToken).ConfigureAwait(false); + await _tee.FlushAsync(cancellationToken).ConfigureAwait(false); + _totalRead += n; + } + return n; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var n = await _source.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + if (n > 0) + { + await _tee.WriteAsync(buffer.AsMemory(offset, n), cancellationToken).ConfigureAwait(false); + await _tee.FlushAsync(cancellationToken).ConfigureAwait(false); + _totalRead += n; + } + return n; + } + + public override void Flush() { /* read-only */ } + + public override int Read(Span buffer) + { + var n = _source.Read(buffer); + if (n > 0) + { + _tee.Write(buffer.Slice(0, n)); + _tee.Flush(); + _totalRead += n; + } + return n; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + try { _source.Dispose(); } catch { } + try + { + if (!_leaveTeeOpen) _tee.Dispose(); + else _tee.Flush(); + } + catch { } + } + base.Dispose(disposing); + } +} + public enum CliEventType { TextStart,