Doppel-Klick Hard-Stop + ReadLineAsync Cancellation-Fix

Stop-Button: Zwei Stufen statt einer:
- 1. Klick: Graceful Stop (workCt) - laufende API-Calls/Tools abbrechen
- 2. Klick: Hard Stop (hardCt) - sofortiger Abbruch aus while-Loop

StreamingTracker: Zwei separate CancellationTokenSources (StopCts + HardCts),
Cancel() gibt 'graceful'/'hard'/null zurück je nach Stufe.

ReadLineAsync Cancellation-Fix: StreamReader.ReadLineAsync propagiert den
CancellationToken nicht zuverlässig an den underlying NetworkStream.
Bei extended thinking blockiert ReadLineAsync minutenlang ohne Reaktion
auf Cancellation. Fix: workCt.Register() disposed den HTTP-Stream,
damit ReadLineAsync sofort mit Exception abbricht.

Bug-Fix: OperationCanceledException in RunAgentChat/ChatNonStreaming wurde
vom generischen catch(Exception) geschluckt - Agent lief trotz Stop weiter.
Jetzt: catch(OperationCanceledException) { throw; } vor catch(Exception).

Frontend: Stop-Button zeigt Hard-Stop visuell (rot + Pulse-Animation),
SSE-Stream wird erst beim 2. Klick clientseitig abgebrochen.
This commit is contained in:
Meleki committed 2026-03-10 10:53:23 +01:00
1 parent eb32a2526a
commit b1d449b997
5 files changed
+92 -23

No files matched your search

+30 -11
View File
@@ -7,37 +7,56 @@ public class StreamingTracker
{
private readonly ConcurrentDictionary<string, StreamingState> _streaming = new ConcurrentDictionary<string, StreamingState>();
public CancellationTokenSource Start(string sessionId, string chainId)
public StreamingTokens Start(string sessionId, string chainId)
{
var cts = new CancellationTokenSource();
_streaming[sessionId] = new StreamingState(chainId, cts);
return cts;
var stopCts = new CancellationTokenSource();
var hardCts = new CancellationTokenSource();
var state = new StreamingState(chainId, stopCts, hardCts);
_streaming[sessionId] = state;
return new StreamingTokens(stopCts.Token, hardCts.Token);
}
public void Stop(string sessionId)
{
if (_streaming.TryRemove(sessionId, out var state))
{
state.Cts.Dispose();
state.StopCts.Dispose();
state.HardCts.Dispose();
}
}
/// <summary>
/// Cancels the running stream for a session. Returns true if a stream was cancelled.
/// Cancels the running stream for a session.
/// First call = graceful (laufende Iteration wird fertig).
/// Second call = hard (sofortiger Abbruch aus while-Loop).
/// Returns "graceful", "hard" or null if not streaming.
/// </summary>
public bool Cancel(string sessionId)
public string Cancel(string sessionId)
{
if (_streaming.TryGetValue(sessionId, out var state))
{
try { state.Cts.Cancel(); } catch { }
return true;
if (!state.StopCts.IsCancellationRequested)
{
// Erster Klick → graceful stop
try { state.StopCts.Cancel(); } catch { }
return "graceful";
}
else if (!state.HardCts.IsCancellationRequested)
{
// Zweiter Klick → hard stop
try { state.HardCts.Cancel(); } catch { }
return "hard";
}
return "hard"; // Bereits beides gecancelled
}
return false;
return null;
}
public bool IsStreaming(string sessionId) => _streaming.ContainsKey(sessionId);
public string GetChainId(string sessionId) => _streaming.TryGetValue(sessionId, out var state) ? state.ChainId : null;
private record StreamingState(string ChainId, CancellationTokenSource Cts);
public record StreamingTokens(CancellationToken StopToken, CancellationToken HardToken);
private record StreamingState(string ChainId, CancellationTokenSource StopCts, CancellationTokenSource HardCts);
}