Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<PropertyGroup>
<IsPackable>False</IsPackable>
<OutputType>Exe</OutputType>
<StartupObject>Spice86.MicroBenchmarkTemplate.Program</StartupObject>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
12 changes: 8 additions & 4 deletions src/Spice86/Spice86DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,16 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai
}

DateTimeOffset clockStartTime = configuration.ClockStartTime ?? DateTimeOffset.UtcNow;
_emulatedClock = configuration.InstructionTimeScale != null
IEmulatedClock emulatedClock = configuration.InstructionTimeScale != null
? new CyclesClock(state, configuration.InstructionTimeScale.Value, configuration.ClockJitterSeed, clockStartTime)
: new EmulatedClock(configuration.ClockJitterSeed, clockStartTime);
// Register clock and limiter to pause/resume events
pauseHandler.Pausing += () => _emulatedClock.OnPause();
pauseHandler.Resumed += () => _emulatedClock.OnResume();
_emulatedClock = emulatedClock;
// Register clock and limiter to pause/resume events. Capture the clock through a local rather than the
// field so the event delegates do not close over this Spice86DependencyInjection instance: the pause
// handler can be rooted independently (e.g. by the MCP host service graph), and a closure over 'this'
// would keep the whole Machine graph alive after disposal.
pauseHandler.Pausing += () => emulatedClock.OnPause();
pauseHandler.Resumed += () => emulatedClock.OnResume();

DeviceScheduler emulationLoopScheduler = new(_emulatedClock, loggerService, "Emulation loop");
FloppyDiskTimingService floppyDiskTimingService = new(state, _emulatedClock, emulationLoopScheduler, FloppyDiskSpeed.Maximum);
Expand Down
6 changes: 6 additions & 0 deletions src/Spice86/ViewModels/Services/HeadlessGui.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ private void Dispose(bool disposing) {
return;
}

// These are process-lifetime static events; without unsubscribing they keep every
// HeadlessGui instance (and the whole Machine graph it references) alive for the
// lifetime of the process, which leaks an entire emulator per instance.
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
Console.CancelKeyPress -= OnProcessExit;

// Stop the timer to prevent any new callbacks
_drawTimer?.Dispose();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private void RunAllTests(ZipArchive archive, ISet<string> revocationList, ISet<s
_testRunner.RunTest(cpuTest, index++, entry.Name, _singleStepTestMinimalMachine, maxCycles, flagsMask);
} catch (Exception ex) {
testPassed = false;
errorMessage = ex.Message;
errorMessage = ex.ToString();
if (!GenerateRevocationList && OpcodeToFix == null) {
throw;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ namespace Spice86.Tests.Http;

using Xunit;

[Collection(HttpApiServerCollection.Name)]
public sealed partial class HttpApiGeneratedClientIntegrationTests {
public sealed partial class HttpApiGeneratedClientIntegrationTests : IClassFixture<HttpApiServerFixture> {
private const string KiotaToolVersion = "1.30.0";
private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(3);
private readonly HttpApiServerFixture _fixture;
Expand All @@ -25,6 +24,10 @@ public HttpApiGeneratedClientIntegrationTests(HttpApiServerFixture fixture) {
[InlineData("yaml", "/openapi/v1.yaml")]
public async Task KiotaGeneratedDotNetClient_CanBeGeneratedBuiltAndExecuted(string extension, string openApiPath) {
// Arrange
_fixture.Memory[0x40] = 0x12;
_fixture.Memory[0x41] = 0x34;
_fixture.Memory[0x42] = 0x56;
_fixture.Memory[0x43] = 0x78;
_fixture.PauseHandler.IsPaused.Returns(false);
_fixture.PauseHandler.ClearReceivedCalls();
TestWorkspace workspace = CreateTestWorkspace(extension);
Expand Down
59 changes: 30 additions & 29 deletions tests/Spice86.Tests/Http/HttpApiServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ private void SeedMemory() {
[Fact]
public async Task GetStatus_ReturnsMachineState() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/status");
_fixture.PauseHandler.IsPaused.Returns(false);
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/status", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);

HttpApiStatusResponse payload = await response.Content.ReadFromJsonAsync<HttpApiStatusResponse>()
HttpApiStatusResponse payload = await response.Content.ReadFromJsonAsync<HttpApiStatusResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.IsPaused.Should().BeFalse();
payload.IsCpuRunning.Should().BeTrue();
Expand All @@ -47,11 +48,11 @@ public async Task GetStatus_ReturnsMachineState() {
[Fact]
public async Task GetByte_ReturnsByteAtAddress() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);

HttpApiMemoryByteResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>()
HttpApiMemoryByteResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Address.Should().Be(64);
payload.Value.Should().Be(0x12);
Expand All @@ -63,7 +64,7 @@ public async Task GetByte_WhenNotPaused_PausesThenUnpauses() {
_fixture.PauseHandler.ClearReceivedCalls();
_fixture.PauseHandler.IsPaused.Returns(false);

HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
_fixture.PauseHandler.Received(1).RequestPause(Arg.Any<string>());
Expand All @@ -76,7 +77,7 @@ public async Task GetByte_WhenAlreadyPaused_DoesNotUnpause() {
_fixture.PauseHandler.ClearReceivedCalls();
_fixture.PauseHandler.IsPaused.Returns(true);

HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
_fixture.PauseHandler.DidNotReceive().RequestPause(Arg.Any<string>());
Expand All @@ -90,15 +91,15 @@ public async Task PutByte_WritesAndReadsBackValue() {
Value = 0xAB
};

HttpResponseMessage putResponse = await _fixture.HttpClient.PutAsJsonAsync("/api/memory/64/byte", request);
HttpResponseMessage putResponse = await _fixture.HttpClient.PutAsJsonAsync("/api/memory/64/byte", request, TestContext.Current.CancellationToken);
putResponse.StatusCode.Should().Be(HttpStatusCode.OK);

HttpApiMemoryByteResponse putPayload = await putResponse.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>()
HttpApiMemoryByteResponse putPayload = await putResponse.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null putPayload");
putPayload.Value.Should().Be(0xAB);

HttpResponseMessage getResponse = await _fixture.HttpClient.GetAsync("/api/memory/64/byte");
HttpApiMemoryByteResponse getPayload = await getResponse.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>()
HttpResponseMessage getResponse = await _fixture.HttpClient.GetAsync("/api/memory/64/byte", TestContext.Current.CancellationToken);
HttpApiMemoryByteResponse getPayload = await getResponse.Content.ReadFromJsonAsync<HttpApiMemoryByteResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null getPayload");
getPayload.Value.Should().Be(0xAB);
_fixture.Memory[0x40].Should().Be(0xAB);
Expand All @@ -113,7 +114,7 @@ public async Task PutByte_WhenNotPaused_PausesThenUnpauses() {
Value = 0xCD
};

HttpResponseMessage response = await _fixture.HttpClient.PutAsJsonAsync("/api/memory/64/byte", request);
HttpResponseMessage response = await _fixture.HttpClient.PutAsJsonAsync("/api/memory/64/byte", request, TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
_fixture.PauseHandler.Received(1).RequestPause(Arg.Any<string>());
Expand All @@ -123,11 +124,11 @@ public async Task PutByte_WhenNotPaused_PausesThenUnpauses() {
[Fact]
public async Task GetRange_ReturnsRequestedRange() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/range/4");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/range/4", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);

HttpApiMemoryRangeResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryRangeResponse>()
HttpApiMemoryRangeResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryRangeResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Address.Should().Be(64);
payload.Length.Should().Be(4);
Expand All @@ -138,7 +139,7 @@ public async Task GetRange_ReturnsRequestedRange() {
public async Task PauseEndpoint_PausesEmulator() {
_fixture.PauseHandler.ClearReceivedCalls();

HttpResponseMessage response = await _fixture.HttpClient.PostAsync("/api/status/pause", content: null);
HttpResponseMessage response = await _fixture.HttpClient.PostAsync("/api/status/pause", content: null, TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
_fixture.PauseHandler.Received(1).RequestPause(Arg.Any<string>());
Expand All @@ -148,7 +149,7 @@ public async Task PauseEndpoint_PausesEmulator() {
public async Task UnpauseEndpoint_ResumesEmulator() {
_fixture.PauseHandler.ClearReceivedCalls();

HttpResponseMessage response = await _fixture.HttpClient.PostAsync("/api/status/unpause", content: null);
HttpResponseMessage response = await _fixture.HttpClient.PostAsync("/api/status/unpause", content: null, TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
_fixture.PauseHandler.Received(1).Resume();
Expand All @@ -161,11 +162,11 @@ public async Task GetRange_TruncatesAtMemoryEnd() {
_fixture.Memory[(uint)lastAddress] = 0x9A;
_fixture.Memory[(uint)(lastAddress + 1)] = 0xBC;

HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/{lastAddress}/range/16");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/{lastAddress}/range/16", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);

HttpApiMemoryRangeResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryRangeResponse>()
HttpApiMemoryRangeResponse payload = await response.Content.ReadFromJsonAsync<HttpApiMemoryRangeResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Length.Should().Be(2);
payload.Values.Should().Equal([0x9A, 0xBC]);
Expand All @@ -174,35 +175,35 @@ public async Task GetRange_TruncatesAtMemoryEnd() {
[Fact]
public async Task GetByte_WithNegativeAddress_ReturnsBadRequest() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/-1/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/-1/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Contain("between 0 and 4294967295");
}

[Fact]
public async Task GetByte_WithAddressTooLarge_ReturnsBadRequest() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/4294967296/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/4294967296/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Contain("between 0 and 4294967295");
}

[Fact]
public async Task GetByte_WithOutOfRangeAddress_ReturnsNotFound() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/{_fixture.Memory.Length}/byte");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/{_fixture.Memory.Length}/byte", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Be("address is outside of memory range");
}
Expand All @@ -211,23 +212,23 @@ public async Task GetByte_WithOutOfRangeAddress_ReturnsNotFound() {
public async Task GetRange_WithExcessiveLength_ReturnsBadRequest() {
SeedMemory();
int excessiveLength = HttpApiEndpoint.MaxRangeLength + 1;
HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/64/range/{excessiveLength}");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync($"/api/memory/64/range/{excessiveLength}", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Contain($"{HttpApiEndpoint.MaxRangeLength}");
}

[Fact]
public async Task GetRange_WithInvalidLength_ReturnsBadRequest() {
SeedMemory();
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/range/0");
HttpResponseMessage response = await _fixture.HttpClient.GetAsync("/api/memory/64/range/0", TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Be("length must be greater than 0");
}
Expand All @@ -239,11 +240,11 @@ public async Task PutByte_WithOutOfRangeAddress_ReturnsNotFound() {
Value = 0xEF
};

HttpResponseMessage response = await _fixture.HttpClient.PutAsJsonAsync($"/api/memory/{_fixture.Memory.Length}/byte", request);
HttpResponseMessage response = await _fixture.HttpClient.PutAsJsonAsync($"/api/memory/{_fixture.Memory.Length}/byte", request, TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);

HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>()
HttpApiErrorResponse payload = await response.Content.ReadFromJsonAsync<HttpApiErrorResponse>(TestContext.Current.CancellationToken)
?? throw new InvalidOperationException("Expected non-null payload");
payload.Message.Should().Be("address is outside of memory range");
}
Expand Down
24 changes: 24 additions & 0 deletions tests/Spice86.Tests/MachineLeakGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[assembly: Xunit.AssemblyFixture(typeof(Spice86.Tests.MachineLeakGuard))]

namespace Spice86.Tests;

using Xunit;

/// <summary>
/// Assembly-wide guard that runs once after every test in the assembly has completed. It fails the run if
/// any emulator instance created through <see cref="Spice86Creator"/> is still reachable after disposal and
/// garbage collection, which would indicate a reference leak pinning the whole Machine graph.
/// </summary>
public sealed class MachineLeakGuard : IDisposable {
public void Dispose() {
int tracked = MachineLeakTracker.TrackedCount;
if (tracked == 0) {
return;
}

int surviving = MachineLeakTracker.CountSurvivingAfterCollection();
Assert.True(surviving == 0,
$"{surviving} of {tracked} emulator instances created via Spice86Creator were still alive after " +
"disposal and garbage collection. This indicates a reference leak that keeps the entire Machine graph rooted.");
}
}
39 changes: 39 additions & 0 deletions tests/Spice86.Tests/MachineLeakTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace Spice86.Tests;

using Spice86.Core.Emulator.VM;

/// <summary>
/// Tracks weak references to every <see cref="Machine"/> created through <see cref="Spice86Creator"/>.
/// A suite-wide check verifies that disposed emulator instances become collectible: a single rooted
/// Machine keeps its entire device, CFG and memory graph alive, and accumulating those across the suite
/// previously exhausted host memory.
/// </summary>
internal static class MachineLeakTracker {
private static readonly List<WeakReference> TrackedMachines = new();
private static readonly object SyncRoot = new();

public static void Track(Machine machine) {
lock (SyncRoot) {
TrackedMachines.Add(new WeakReference(machine));
}
}

public static int TrackedCount {
get {
lock (SyncRoot) {
return TrackedMachines.Count;
}
}
}

public static int CountSurvivingAfterCollection() {
for (int i = 0; i < 3; i++) {
GC.Collect();

Check warning

Code scanning / CodeQL

Call to GC.Collect() Warning test

Call to 'GC.Collect()'.
GC.WaitForPendingFinalizers();
GC.Collect();

Check warning

Code scanning / CodeQL

Call to GC.Collect() Warning test

Call to 'GC.Collect()'.
}
lock (SyncRoot) {
return TrackedMachines.Count(reference => reference.IsAlive);
}
}
}
Loading
Loading