feat(debug): Encoding-Diagnose für Claude CLI (opt-in via Env-Var)
Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1
Im Debug-Modus wird ein TeeReadStream zwischengeschaltet, der alle rohen Bytes
vom Claude-CLI-Prozess in eine .bin-Datei schreibt (vor dem UTF-8-Decode).
Zusätzlich wird jedes Vorkommen von U+FFFD im decoded Output mit Position,
Kontext und FFFD-Counter in eine .log-Datei protokolliert.
Logs landen in data/cli-debug/cli-stdout-{timestamp}.{bin,log}.
So können wir das intermittierende �-Problem auf Windows beweisbar einkreisen:
- Wenn .bin valide UTF-8-Bytes enthält aber .log FFFD-Treffer hat → unser Decoder
- Wenn .bin bereits invalide Bytes (CP1252) enthält → Claude-CLI/Node ist schuld
- Wenn .bin bereits EF BF BD enthält → Anthropic-Server schickt schon kaputt
Im Default-Modus (Env-Var nicht gesetzt) bleibt der ursprüngliche Default-Reader
aktiv – kein zusätzlicher Disk-IO im Normalbetrieb.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
01346a952e
commit
e87d7c4eec
1 file changed
+218
-1
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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<string> 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("�", "<<U+FFFD>>");
|
||||
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("�", "<<U+FFFD>>")}");
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob die CLI funktioniert und authentifiziert ist.
|
||||
/// </summary>
|
||||
@@ -620,6 +740,103 @@ public class ClaudeCliService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int> ReadAsync(Memory<byte> 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<int> 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<byte> 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,
|
||||
|
||||
Reference in new issue
Block a user