diff --git a/LinguaEngine.sln b/LinguaEngine.sln index 1250894..455da5d 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -85,6 +85,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame", "samples\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame.Tests", "tests\PhysicsDemoGame.Tests\PhysicsDemoGame.Tests.csproj", "{0201686F-0E2A-400F-8166-CBD11B65D724}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandbox.failing-configure", "sandbox.failing-configure", "{AB8A31F6-ADF4-A607-5774-F0AC7E353102}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.FailingConfigure", "plugins\sandbox.failing-configure\Sandbox.FailingConfigure\Sandbox.FailingConfigure.csproj", "{428A0279-9B16-473D-92BC-858D4ABB15E2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -407,6 +411,18 @@ Global {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.Build.0 = Release|Any CPU {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.ActiveCfg = Release|Any CPU {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.Build.0 = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.Build.0 = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.Build.0 = Debug|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.Build.0 = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.ActiveCfg = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.Build.0 = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.ActiveCfg = Release|Any CPU + {428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -449,5 +465,7 @@ Global {83FF5113-4091-0AE3-A9AD-50B7BB917258} = {9760813E-A577-3C56-DE20-0218A108BABD} {1AFDA567-D86A-43C8-91B9-34CC13F48FEF} = {83FF5113-4091-0AE3-A9AD-50B7BB917258} {0201686F-0E2A-400F-8166-CBD11B65D724} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {AB8A31F6-ADF4-A607-5774-F0AC7E353102} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {428A0279-9B16-473D-92BC-858D4ABB15E2} = {AB8A31F6-ADF4-A607-5774-F0AC7E353102} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index e1fb5bb..a75164d 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,23 @@ and is unit-tested on its own — including the case a screenshot can't easily catch, dragging an object parented under a non-uniformly-scaled parent. -No physics yet — see the build order (M0–M4) in -[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next. +**M4 in progress.** `engine.physics` wraps Box3D and `engine.audio` wraps +miniaudio, both through a deliberately narrow C shim — neither library's +own config structs (large, with function pointers and, for Box3D, at +least one type that "cannot be directly copied") cross the P/Invoke +boundary, only plain scalars and handles do. `samples/PhysicsDemo` ties +physics, audio, input, and rendering together in one running project with +`engine.editor` deliberately left out of its `project.json` — the +shippable configuration, not the dev one. `.github/workflows/build.yml` +builds and tests both native shims and the full solution on real +`ubuntu-latest` and `windows-latest` runners, since nothing on a single +Linux dev machine can otherwise verify the Windows half of M4's own "done +when." Still open: the build pipeline hasn't actually run on GitHub yet +(pending a `workflow` OAuth scope grant), and M4's "one small game" +target is the physics demo so far, not yet a scored 20-minute one. + +See the build order (M0–M4) in +[`docs/kernel-contract.md`](docs/kernel-contract.md) for the rest. Design and implementation are argued over in the same place: the doc is still the thing to disagree with before code changes to match. diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index 0753508..f223532 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -53,8 +53,8 @@ nothing. | 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. | | 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. | | 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. | -| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. | -| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. | +| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; `engine.assets` publishes `TextureReloaded` when a watched file changes on disk (M2). Still nothing publishes `GameObject` created/destroyed facts — `GameWorld` doesn't touch the bus at all, deliberately: a publish on every structural change would tax the hot path for listeners that usually don't exist. | +| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`, `FixedDeltaTime`) and logging, both on `IPluginContext`. The fixed-step accumulator (`Time.ConsumeFixedSteps`) shipped with M4, driving `Stage.FixedUpdate` — `engine.physics` is its first real consumer. | `GameObject.Transform` is the one field embedded directly rather than modeled as a `Component` subclass — it's a plain struct holding local @@ -436,7 +436,7 @@ cost of changing course is still zero. | **M1** | **Window, input, a triangle.** Three separate plugins over Silk.NET. First real-load test of the data channel. | The triangle's color changes by editing system code, with no app restart. | | **M2** | **Assets and scenes.** Hot-reloading asset plugin, scene format, `World` serialization. | Swapping a texture on disk changes the picture with nothing stopped; a scene loads and saves. | | **M3** | **Editor as plugins — done.** Shell (`engine.editor`, ImGui over the live scene, see §5 and the new `Stage.Present`), reflection-based inspector and hierarchy, Play/Stop on snapshots, a real 3D translate gizmo driven by the actual camera. | Entering Play takes under 100 ms — the original complaint about Unity is closed. Proven twice: `WorldSnapshotTests` (kernel, 300 `GameObject`s) and a real editor run against `samples/WindowDemo` (13ms, logged by `PlayModeController`). | -| **M4** | **One small game, end to end.** `engine.physics` over Box3D, audio, a Linux + Windows build pipeline. A 20-minute game, shipped as an executable. | The build runs on both platforms with no editor plugins in the shipped binary. | +| **M4** | **One small game, end to end — in progress.** `engine.physics` (Box3D) and `engine.audio` (miniaudio) done, both over a narrow, scalars-only P/Invoke shim; `samples/PhysicsDemo` ties physics/audio/input/render together with `engine.editor` deliberately excluded from its `project.json`; `.github/workflows/build.yml` builds and tests on real `ubuntu-latest`/`windows-latest` runners. Not yet: the workflow has actually run on GitHub (blocked on an OAuth `workflow` scope grant), and the demo is a physics sandbox, not yet a scored 20-minute game. | The build runs on both platforms with no editor plugins in the shipped binary. | --- diff --git a/native/physics-native/lingua_physics.c b/native/physics-native/lingua_physics.c index c2b98f5..28680c2 100644 --- a/native/physics-native/lingua_physics.c +++ b/native/physics-native/lingua_physics.c @@ -83,6 +83,21 @@ LINGUA_API void Lingua_DestroyWorld( int32_t worldHandle ) g_worldUsed[worldHandle] = false; } +// Exists so C# can assert on the native side's own bookkeeping directly — +// PhysicsWorld's C#-side _bodies dictionary can look correct while the +// native table underneath it has leaked a body Sync should have destroyed +// (see PhysicsWorldTests for the regression this catches). +LINGUA_API int32_t Lingua_GetBodyCount( void ) +{ + int32_t count = 0; + for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ ) + { + if ( g_bodyUsed[i] ) + count++; + } + return count; +} + LINGUA_API void Lingua_WorldStep( int32_t worldHandle, float timeStep, int32_t subStepCount ) { if ( !ValidWorld( worldHandle ) ) diff --git a/plugins/engine.assets/Engine.Assets/AssetService.cs b/plugins/engine.assets/Engine.Assets/AssetService.cs index 4d4c9a8..34ad7ca 100644 --- a/plugins/engine.assets/Engine.Assets/AssetService.cs +++ b/plugins/engine.assets/Engine.Assets/AssetService.cs @@ -84,6 +84,16 @@ internal sealed class AssetService(IEventBus events, ILogger log) : IAssetServic { await Task.Delay(50); } + catch (IOException ex) + { + // The 5th and final attempt — the `when` guard above only + // covers attempts 0-3, so this is the one case that used + // to fall out of the loop and propagate from a + // fire-and-forget Task (`_ = Task.Run(...)`) as an + // unobserved exception: no log, no event, nothing to show + // the reload silently never happened. + log.Warn($"Giving up reloading '{path}' after 5 attempts: {ex.Message}"); + } } } diff --git a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs index 3f8ba2f..799fb51 100644 --- a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs +++ b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs @@ -37,6 +37,7 @@ public sealed class EditorPlugin : IPlugin private PlayModeController? _playMode; private IEngineWindow? _window; private ICameraService? _camera; + private bool _wasPlaying; public void Configure(IPluginContext ctx) { @@ -107,10 +108,50 @@ public sealed class EditorPlugin : IPlugin ImGui.Text(_playMode.IsPlaying ? "(Playing)" : "(Edit mode)"); ImGui.End(); + // Found by independent review: ExitPlay's Restore destroys every + // GameObject and rebuilds fresh instances from the snapshot, but + // EditorState.Selected kept pointing at the old, now-detached one + // — Inspector and the gizmo would silently keep editing an object + // no longer in the world. Comparing against last frame's own + // IsPlaying (not just reacting to the button above) catches this + // uniformly regardless of *how* Play exited — the Stop button + // here, or the "stop" stdin command Engine.Host already processed + // before this Render-stage system ran this frame; the button + // alone would miss the second path entirely, the same + // stdin-vs-real-control gap already hit once this session. + if (_wasPlaying && !_playMode.IsPlaying) + { + var previousName = _state.Selected?.Name; + _state.Selected = previousName is null ? null : FindByName(world.Roots, previousName); + } + _wasPlaying = _playMode.IsPlaying; + HierarchyPanel.Draw(world, _state); InspectorPanel.Draw(_state); _gizmo.Draw(_state, _camera!, _window!); _controller.Render(); } + + // Best-effort by name, not identity — Restore rebuilds fresh + // GameObject instances, so there's no identity to match against + // anymore. Two siblings sharing a name (nothing stops it, same caveat + // HierarchyPanel's own PushID-by-hashcode already documents) means + // this picks the first match, not necessarily "the same one" — an + // acceptable approximation for reselecting after Play, not a + // guarantee. + private static GameObject? FindByName(IReadOnlyList roots, string name) + { + foreach (var go in roots) + { + if (go.Name == name) + return go; + + var found = FindByName(go.Children, name); + if (found is not null) + return found; + } + + return null; + } } diff --git a/plugins/engine.editor/Engine.Editor/EditorState.cs b/plugins/engine.editor/Engine.Editor/EditorState.cs index 59113e0..47bbc76 100644 --- a/plugins/engine.editor/Engine.Editor/EditorState.cs +++ b/plugins/engine.editor/Engine.Editor/EditorState.cs @@ -7,7 +7,10 @@ namespace Engine.Editor; /// Hierarchy panel last clicked, so the Inspector panel (built against this /// same instance) knows what to show. One instance per EditorPlugin, not /// static: reloading engine.editor should start with nothing selected, not -/// hold a reference to a GameObject that may not even exist anymore. +/// hold a reference to a GameObject that may not even exist anymore. The +/// same staleness can happen without a reload too — see EditorPlugin. +/// DrawUi's IsPlaying-transition check, which re-resolves this after +/// ExitPlay's Restore replaces every GameObject with a fresh instance. /// internal sealed class EditorState { diff --git a/plugins/engine.physics/Engine.Physics/Native.cs b/plugins/engine.physics/Engine.Physics/Native.cs index 59e4931..d31f69d 100644 --- a/plugins/engine.physics/Engine.Physics/Native.cs +++ b/plugins/engine.physics/Engine.Physics/Native.cs @@ -29,6 +29,12 @@ internal static class Native [DllImport(Lib)] public static extern void Lingua_WorldStep(int worldHandle, float timeStep, int subStepCount); + /// Total live bodies across every world — a test-only escape + /// hatch (see PhysicsWorldTests) to assert on the native side's own + /// bookkeeping, not just PhysicsWorld's C#-side dictionary. + [DllImport(Lib)] + public static extern int Lingua_GetBodyCount(); + [DllImport(Lib)] public static extern int Lingua_CreateBoxBody( int worldHandle, diff --git a/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs b/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs index aeb5427..c41db77 100644 --- a/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs +++ b/plugins/engine.physics/Engine.Physics/PhysicsWorld.cs @@ -20,7 +20,7 @@ internal sealed class PhysicsWorld : IDisposable private readonly int _handle; private readonly ILogger _log; private readonly Dictionary _bodies = []; - private readonly HashSet _warnedMissingCollider = []; + private readonly HashSet _warnedFailed = []; public PhysicsWorld(Vector3 gravity, ILogger log) { @@ -39,14 +39,23 @@ internal sealed class PhysicsWorld : IDisposable TryCreateBody(go); } - if (_bodies.Count == live.Count) + // Comparing counts alone would miss this: destroying one tracked + // GameObject and gaining a different untracked one in the same + // Sync leaves _bodies.Count == live.Count with the sets actually + // different — the stale native body would never get destroyed and + // would keep simulating forever. Restore (every ExitPlay) hits + // this reliably whenever the scene has both a Rigidbody+collider + // GameObject and a Rigidbody-without-collider one, since the + // latter never enters _bodies to begin with. + var stale = _bodies.Keys.Where(go => !live.Contains(go)).ToList(); + if (stale.Count == 0) return; - foreach (var stale in _bodies.Keys.Where(go => !live.Contains(go)).ToList()) + foreach (var go in stale) { - Native.Lingua_DestroyBody(_bodies[stale]); - _bodies.Remove(stale); - _warnedMissingCollider.Remove(stale); + Native.Lingua_DestroyBody(_bodies[go]); + _bodies.Remove(go); + _warnedFailed.Remove(go); } } @@ -111,11 +120,25 @@ internal sealed class PhysicsWorld : IDisposable } else { - if (_warnedMissingCollider.Add(go)) + if (_warnedFailed.Add(go)) _log.Warn($"'{go.Name}' has a Rigidbody but no BoxCollider/SphereCollider — no physics body created."); return; } + // -1 means the native shim refused (an invalid world handle, or + // its fixed-capacity body table — 8192 — is full). Storing it + // anyway would silently "work": every later Lingua_GetBodyTransform + // call on handle -1 fails ValidBody's check and leaves the out + // params at their P/Invoke-zeroed default, teleporting the + // GameObject to the origin with a degenerate all-zero rotation + // every FixedUpdate — no exception, no log, just a wrong position. + if (handle < 0) + { + if (_warnedFailed.Add(go)) + _log.Warn($"'{go.Name}' failed to create a native physics body (world invalid or body table full)."); + return; + } + _bodies[go] = handle; } diff --git a/plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so b/plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so index de72e27..1aa03d1 100755 Binary files a/plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so and b/plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so differ diff --git a/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/FailingConfigurePlugin.cs b/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/FailingConfigurePlugin.cs new file mode 100644 index 0000000..3423d27 --- /dev/null +++ b/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/FailingConfigurePlugin.cs @@ -0,0 +1,41 @@ +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.World; +using Sandbox.Echo.Contracts; + +namespace Sandbox.FailingConfigure; + +/// +/// A test fixture, not a real plugin — exists only so +/// FailedConfigureRollbackTests can exercise PluginHost.Load's rollback +/// path against a real ALC/assembly load, the same way sandbox.echo exists +/// for the successful-load path. Registers a system that increments a +/// shared Ping component (so there's a real, deterministic side effect for +/// the test to check — did the dangling system actually get removed, not +/// just "did the ALC eventually get GC'd," which turned out to happen +/// either way regardless of whether rollback ran, making it useless as a +/// regression signal here), then throws — reproducing "Configure got +/// partway through before failing," not "Configure failed immediately." +/// +public sealed class FailingConfigurePlugin : IPlugin +{ + public void Configure(IPluginContext ctx) + { + ctx.Schedule.Add(Stage.Update, Tick).Writes(); + ctx.Events.Subscribe(_ => { }); + + throw new InvalidOperationException("deliberate failure for PluginHostTests"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Schedule.RemoveAllFrom("sandbox.failing-configure"); + ctx.Events.RemoveAllFrom("sandbox.failing-configure"); + } + + private static void Tick(IWorld world) + { + foreach (var go in world.Query()) + go.GetComponent()!.Count++; + } +} diff --git a/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/Sandbox.FailingConfigure.csproj b/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/Sandbox.FailingConfigure.csproj new file mode 100644 index 0000000..873ddcf --- /dev/null +++ b/plugins/sandbox.failing-configure/Sandbox.FailingConfigure/Sandbox.FailingConfigure.csproj @@ -0,0 +1,10 @@ + + + + + + + diff --git a/plugins/sandbox.failing-configure/plugin.json b/plugins/sandbox.failing-configure/plugin.json new file mode 100644 index 0000000..429759b --- /dev/null +++ b/plugins/sandbox.failing-configure/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "sandbox.failing-configure", + "version": "0.1.0", + "assembly": "Sandbox.FailingConfigure.dll", + "dependsOn": {}, + "reloadable": true +} diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 47bdf06..3ab8818 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -230,13 +230,24 @@ if (windowed) Console.WriteLine($"Reloading '{cmd.Argument}'..."); try { - host.Unload(cmd.Argument); + // Not unconditional: a plugin whose previous + // reload already failed inside Load (a bad + // Configure — now rolled back, see PluginHost. + // Load) isn't loaded any more, and Unload would + // just throw "not loaded" on this retry instead of + // ever reaching Load again. + if (host.IsLoaded(cmd.Argument)) + host.Unload(cmd.Argument); + host.Load(pluginDirectory); Console.WriteLine($"Reloaded '{cmd.Argument}'. World state and the window were untouched."); } catch (Exception ex) { - Console.Error.WriteLine($"Failed to reload '{cmd.Argument}': {ex.Message}"); + Console.Error.WriteLine( + $"Failed to reload '{cmd.Argument}': {ex.Message} " + + $"'{cmd.Argument}' is now unloaded (not just still running the old code) — " + + $"fix the error and run 'r {cmd.Argument}' again."); } break; diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index eb5f4a4..3da153e 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -73,7 +73,34 @@ public sealed class PluginHost( events.RegisterPlugin(manifest.Id, implAssembly); var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time); - instance.Configure(ctx); + try + { + instance.Configure(ctx); + } + catch + { + // Configure can fail after already doing real work — Schedule. + // Add, Events.Subscribe, Services.Provide calls all happen + // before whatever line actually throws. None of that unwinds + // on its own, and because _loaded never gets an entry for this + // id below, the plugin ends up neither loaded nor unloadable: + // its ALC stays rooted forever and any systems/subscriptions it + // did register before throwing keep running. Best-effort + // Shutdown first — it's the only thing that knows which + // services this specific plugin provided, so it's the only way + // to Revoke them — then the two RemoveAllFrom calls PluginHost + // itself can make unconditionally, then unload the ALC. Each + // step is wrapped so a broken Shutdown/unload can't hide the + // real Configure failure being rethrown below. + try { instance.Shutdown(ctx); } catch { /* best-effort */ } + + schedule.RemoveAllFrom(manifest.Id); + events.RemoveAllFrom(manifest.Id); + + try { alc.Unload(); } catch { /* best-effort */ } + + throw; + } _loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx); events.Publish(new PluginLoaded(manifest.Id)); @@ -112,6 +139,16 @@ public sealed class PluginHost( return loadedIds; } + /// + /// Lets a caller check before calling Unload/Load instead of catching + /// InvalidOperationException to find out — Engine.Host's live-reload + /// command needs this: a plugin whose previous reload attempt failed + /// partway through Load isn't loaded any more (see Load's own rollback + /// on a Configure failure), so unconditionally trying Unload first + /// would itself throw "not loaded" on every retry. + /// + public bool IsLoaded(string pluginId) => _loaded.ContainsKey(pluginId); + /// /// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak /// reference to the ALC so a caller can verify it actually collected — diff --git a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj index 00cf5fc..1c684e1 100644 --- a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj +++ b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj @@ -32,6 +32,11 @@ the harness's Default ALC, which defeats the point of a leak test. --> + + +