Skip to content

Commit 72cd715

Browse files
committed
fix bugs
1 parent 4b9ff09 commit 72cd715

21 files changed

Lines changed: 453 additions & 644 deletions

InferenceWeb.Tests/BatchedExecutorTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,43 @@ public void BatchExecutor_BatchedPath_RoutesPerSeqLogitsCorrectly()
180180
}
181181
}
182182

183+
[Fact]
184+
public async Task InferenceEngine_ExecutorStepException_CompletesErroredRequestAndContinuesWaiting()
185+
{
186+
var model = new ThrowingBatchedStubModel("fp-step-error", badRequestId: "bad", peakToken: 7);
187+
var cfg = new SchedulerConfig
188+
{
189+
MaxNumBatchedTokens = 256,
190+
MaxNumRunningSequences = 1,
191+
MaxPrefillChunkSize = 64,
192+
NumBlocks = 4,
193+
BlockSize = BlockSize,
194+
EnablePrefixCaching = false,
195+
DecodeQuantumTokens = 1,
196+
};
197+
using var engine = new InferenceEngine(model, cfg, NullLogger.Instance);
198+
199+
var badSeq = new SequenceState("bad", Enumerable.Range(1, 4).ToList(),
200+
maxNewTokens: 2, BlockSize, SamplingConfig.Default);
201+
var goodSeq = new SequenceState("good", Enumerable.Range(1, 4).ToList(),
202+
maxNewTokens: 2, BlockSize, SamplingConfig.Default);
203+
204+
var bad = engine.SubmitRequest(badSeq);
205+
var good = engine.SubmitRequest(goodSeq);
206+
207+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
208+
() => bad.Completion.WaitAsync(TimeSpan.FromSeconds(5)));
209+
Assert.Contains("bad", ex.Message);
210+
211+
var goodCompletion = await good.Completion.WaitAsync(TimeSpan.FromSeconds(5));
212+
Assert.Equal(SequenceStatus.FinishedLengthCapped, goodCompletion.Status);
213+
Assert.Equal(0, badSeq.BlockTable.NumBlocks);
214+
Assert.Equal(SequenceStatus.FinishedError, badSeq.Status);
215+
Assert.Contains("bad", model.ReleasedRequestIds);
216+
Assert.Contains("good", model.ReleasedRequestIds);
217+
Assert.Equal(cfg.NumBlocks, engine.PoolStats.freeBlocks);
218+
}
219+
183220
// ----- helpers -----
184221

185222
private static SchedulerConfig SmallConfig() => new()
@@ -301,6 +338,61 @@ public IReadOnlyList<float[]> ForwardBatch(BatchedForwardContext ctx)
301338
}
302339
}
303340

341+
private sealed class ThrowingBatchedStubModel : IModelArchitecture, IBatchedPagedModel
342+
{
343+
private readonly string _fp;
344+
private readonly string _badRequestId;
345+
private readonly int _peak;
346+
347+
public ThrowingBatchedStubModel(string fp, string badRequestId, int peakToken)
348+
{
349+
_fp = fp;
350+
_badRequestId = badRequestId;
351+
_peak = peakToken;
352+
Tokenizer = new StubTokenizer(VocabSize);
353+
}
354+
355+
public List<string> ReleasedRequestIds { get; } = new();
356+
357+
public ModelConfig Config { get; } = new ModelConfig { VocabSize = VocabSize };
358+
public ITokenizer Tokenizer { get; }
359+
public IMultimodalInjector MultimodalInjector => null;
360+
public IBackendExecutionPlan ExecutionPlan => null;
361+
public bool SupportsKVCacheTruncation => true;
362+
public bool SupportsKVStateSnapshot => true;
363+
public string KVStateFingerprint => _fp;
364+
public long ComputeKVBlockByteSize(int n) => 2L * NumLayers * NumKVHeads * n * HeadDim * sizeof(float);
365+
public float[] Forward(int[] tokens) => new float[VocabSize];
366+
public void ResetKVCache() { }
367+
public void TruncateKVCache(int n) { }
368+
public bool TryExtractKVBlock(int s, int n, Span<byte> dst) => true;
369+
public bool TryInjectKVBlock(int s, int n, ReadOnlySpan<byte> src) => true;
370+
public void Dispose() { }
371+
372+
public IReadOnlyList<float[]> ForwardBatch(BatchedForwardContext ctx)
373+
{
374+
for (int i = 0; i < ctx.Sequences.Count; i++)
375+
{
376+
if (ctx.Sequences[i].RequestId == _badRequestId)
377+
throw new InvalidOperationException($"boom for {_badRequestId}");
378+
}
379+
380+
var result = new float[ctx.Sequences.Count][];
381+
for (int i = 0; i < result.Length; i++)
382+
{
383+
var logits = new float[VocabSize];
384+
logits[_peak] = 10f;
385+
result[i] = logits;
386+
}
387+
return result;
388+
}
389+
390+
public void OnSequenceReleased(string requestId)
391+
{
392+
ReleasedRequestIds.Add(requestId);
393+
}
394+
}
395+
304396
private sealed class StubTokenizer : ITokenizer
305397
{
306398
public StubTokenizer(int vocab)

InferenceWeb.Tests/ChatSessionTests.cs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@
1111
namespace InferenceWeb.Tests;
1212

1313
/// <summary>
14-
/// Unit tests for <see cref="ChatSession"/>: ensures each session owns an isolated
15-
/// <see cref="KVCache"/> and tracked-history buffer, and that disposing the session
16-
/// fully clears that state.
14+
/// Unit tests for <see cref="ChatSession"/>: ensures each session owns an
15+
/// isolated tracked-history buffer, and that disposing the session clears it.
1716
/// </summary>
1817
public class ChatSessionTests
1918
{
@@ -26,8 +25,6 @@ public void NewSession_HasUniqueIdAndEmptyState()
2625
Assert.False(string.IsNullOrEmpty(a.Id));
2726
Assert.False(string.IsNullOrEmpty(b.Id));
2827
Assert.NotEqual(a.Id, b.Id);
29-
Assert.True(a.KVCache.IsEmpty);
30-
Assert.True(b.KVCache.IsEmpty);
3128
Assert.Empty(a.TrackedHistory);
3229
Assert.Empty(b.TrackedHistory);
3330
Assert.False(a.IsDisposed);
@@ -36,30 +33,25 @@ public void NewSession_HasUniqueIdAndEmptyState()
3633
[Fact]
3734
public void SessionsHaveIndependentState()
3835
{
39-
// Mutating one session's cache / history must not leak into another.
36+
// Mutating one session's history must not leak into another.
4037
var a = new ChatSession();
4138
var b = new ChatSession();
4239

43-
a.KVCache.RecordAppend(new[] { 1, 2, 3 }, new float[] { 0.1f });
4440
a.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "hello-a" });
4541

46-
Assert.Equal(3, a.KVCache.Count);
4742
Assert.Single(a.TrackedHistory);
48-
Assert.True(b.KVCache.IsEmpty);
4943
Assert.Empty(b.TrackedHistory);
5044
}
5145

5246
[Fact]
53-
public void Dispose_ClearsTokensAndHistory()
47+
public void Dispose_ClearsHistory()
5448
{
5549
var session = new ChatSession();
56-
session.KVCache.RecordAppend(new[] { 42, 43 }, new float[] { 0.5f });
5750
session.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "hi" });
5851

5952
session.Dispose();
6053

6154
Assert.True(session.IsDisposed);
62-
Assert.True(session.KVCache.IsEmpty);
6355
Assert.Empty(session.TrackedHistory);
6456
}
6557

InferenceWeb.Tests/ModelServiceKVCacheTests.cs renamed to InferenceWeb.Tests/ModelServiceRawTokenHistoryTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ namespace InferenceWeb.Tests;
1313
/// <summary>
1414
/// Tests for the ModelService-level conversation tracking that keeps raw output tokens
1515
/// associated with assistant messages across HTTP requests, enabling the next turn's
16-
/// prompt render to splice raw tokens in for cached KV state.
16+
/// prompt render to preserve the exact tokenized conversation prefix.
1717
/// </summary>
18-
public class ModelServiceKVCacheTests
18+
public class ModelServiceRawTokenHistoryTests
1919
{
2020
[Fact]
2121
public void ResolvePrefillChunkSize_CudaLongPrompt_UsesSafeChunkSize()
@@ -82,13 +82,13 @@ public void AugmentWithCachedRawTokens_PreservesIncomingRawTokensIfAlreadySet()
8282
}
8383

8484
/// <summary>
85-
/// Reproduces the Qwen 3.5 / 3.6 WebUI cache-reset bug:
85+
/// Reproduces the Qwen 3.5 / 3.6 WebUI raw-token history bug:
8686
/// the streaming output parser strips &lt;think&gt;...&lt;/think&gt; framing from the
8787
/// assistant text before the WebUI accumulates it, so on the next chat request the
8888
/// WebUI sends back an assistant message whose Content is a STRIPPED subset of what
8989
/// our tracked history stored. The augmenter MUST still recognise this as the same
9090
/// turn and splice the cached raw output tokens, otherwise every multi-turn chat with
91-
/// a thinking model degenerates to a full prompt re-prefill.
91+
/// a thinking model changes the rendered token prefix.
9292
/// </summary>
9393
[Fact]
9494
public void AugmentWithCachedRawTokens_WebUIParsedContentMismatch_StillSplicesRawTokens()

InferenceWeb.Tests/ModelServiceSessionTests.cs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,20 @@
1111
namespace InferenceWeb.Tests;
1212

1313
/// <summary>
14-
/// Unit tests for <see cref="ModelService"/> session-lifecycle operations. These do
15-
/// not load a real model (they exercise only the cache bookkeeping), so they are
16-
/// safe to run on any machine. The expensive K/V tensor reset is a no-op when no
17-
/// model is loaded.
14+
/// Unit tests for <see cref="ModelService"/> session-lifecycle operations. These
15+
/// do not load a real model; server sessions now only own tracked history.
1816
/// </summary>
1917
public class ModelServiceSessionTests
2018
{
2119
[Fact]
22-
public void ResetSession_ClearsCacheAndHistoryForTheGivenSession()
20+
public void ResetSession_ClearsHistoryForTheGivenSession()
2321
{
2422
var svc = new ModelService();
2523
var session = new ChatSession();
26-
session.KVCache.RecordAppend(new[] { 1, 2, 3 }, new float[] { 0.5f });
2724
session.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "hi" });
2825

2926
svc.ResetSession(session);
3027

31-
Assert.True(session.KVCache.IsEmpty);
3228
Assert.Empty(session.TrackedHistory);
3329
Assert.False(session.IsDisposed);
3430
}
@@ -40,16 +36,12 @@ public void ResetSession_LeavesOtherSessionsUntouched()
4036
var sessA = new ChatSession();
4137
var sessB = new ChatSession();
4238

43-
sessA.KVCache.RecordAppend(new[] { 1, 2 }, new float[] { 0.1f });
4439
sessA.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "a" });
45-
sessB.KVCache.RecordAppend(new[] { 9, 8 }, new float[] { 0.2f });
4640
sessB.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "b" });
4741

4842
svc.ResetSession(sessA);
4943

50-
Assert.True(sessA.KVCache.IsEmpty);
5144
Assert.Empty(sessA.TrackedHistory);
52-
Assert.Equal(2, sessB.KVCache.Count);
5345
Assert.Single(sessB.TrackedHistory);
5446
}
5547

@@ -65,13 +57,11 @@ public void DisposeSession_MarksSessionDisposedAndFreesState()
6557
{
6658
var svc = new ModelService();
6759
var session = new ChatSession();
68-
session.KVCache.RecordAppend(new[] { 10, 20 }, new float[] { 0.3f });
6960
session.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "x" });
7061

7162
svc.DisposeSession(session);
7263

7364
Assert.True(session.IsDisposed);
74-
Assert.True(session.KVCache.IsEmpty);
7565
Assert.Empty(session.TrackedHistory);
7666
}
7767

@@ -83,13 +73,11 @@ public void DisposeSession_DoesNotAffectOtherSessions()
8373
var sessA = new ChatSession();
8474
var sessB = new ChatSession();
8575

86-
sessB.KVCache.RecordAppend(new[] { 77 }, new float[] { 0.9f });
8776
sessB.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "keep" });
8877

8978
svc.DisposeSession(sessA);
9079

9180
Assert.False(sessB.IsDisposed);
92-
Assert.Equal(1, sessB.KVCache.Count);
9381
Assert.Single(sessB.TrackedHistory);
9482
}
9583

@@ -120,6 +108,16 @@ public void InvalidateKVCache_DoesNotThrowWhenNoModelLoaded()
120108
Assert.False(svc.IsLoaded);
121109
}
122110

111+
[Fact]
112+
public void KVCache_ReturnsIsolatedCompatibilityShim()
113+
{
114+
var svc = new ModelService();
115+
var legacyView = svc.KVCache;
116+
legacyView.RecordAppend(new[] { 1, 2, 3 }, new float[] { 0.5f });
117+
118+
Assert.True(svc.KVCache.IsEmpty);
119+
}
120+
123121
[Fact]
124122
public void ActiveSession_StartsNullUntilInferenceActivatesOne()
125123
{

InferenceWeb.Tests/SessionManagerTests.cs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ public void TryRemove_NonexistentIdReturnsNull()
8787
[Fact]
8888
public void TryRemove_DefaultSessionIdReturnsNullAndKeepsSession()
8989
{
90-
// The default session must survive for the lifetime of the server since the
91-
// Ollama / OpenAI endpoints rely on it for cache reuse.
90+
// The default session must survive for the lifetime of the server since
91+
// the Ollama / OpenAI endpoints rely on it for tracked-history reuse.
9292
var mgr = new SessionManager();
9393
var before = mgr.DefaultSession;
9494

@@ -106,28 +106,25 @@ public void CreatedSessions_HaveIsolatedState()
106106
var a = mgr.CreateSession();
107107
var b = mgr.CreateSession();
108108

109-
a.KVCache.RecordAppend(new[] { 10, 20, 30 }, new float[] { 1.0f });
110109
a.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "A's secret" });
111110

112111
// Session B must not see any of A's state.
113-
Assert.True(b.KVCache.IsEmpty);
114112
Assert.Empty(b.TrackedHistory);
115-
Assert.Equal(3, a.KVCache.Count);
113+
Assert.Single(a.TrackedHistory);
116114
}
117115

118116
[Fact]
119117
public void TryRemove_SessionIsNotAutoDisposed()
120118
{
121-
// TryRemove only unregisters; disposal is the caller's responsibility so that
122-
// ModelService can reset the model's K/V tensors before the bookkeeping is
123-
// torn down. The returned session should still be usable for inspection.
119+
// TryRemove only unregisters; disposal is the caller's responsibility.
120+
// The returned session should still be usable for inspection.
124121
var mgr = new SessionManager();
125122
var created = mgr.CreateSession();
126-
created.KVCache.RecordAppend(7, new float[] { 0.1f });
123+
created.TrackedHistory.Add(new ChatMessage { Role = "user", Content = "keep" });
127124

128125
var removed = mgr.TryRemove(created.Id);
129126

130127
Assert.False(removed!.IsDisposed);
131-
Assert.Equal(1, removed.KVCache.Count);
128+
Assert.Single(removed.TrackedHistory);
132129
}
133130
}

0 commit comments

Comments
 (0)