diff --git a/docs/architecture.md b/docs/architecture.md index bf9a0694..617702f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,10 @@ - `Repl.Mcp` - MCP (Model Context Protocol) integration: `UseMcpServer()`, `BuildMcpServerOptions()`, tool/resource/prompt mapping, MCP Apps UI resources, client roots, transport factory. - `Repl.Testing` - - In-memory multi-session testing toolkit (`ReplTestHost`, `ReplSessionHandle`, typed execution results/events). + - In-memory multi-session testing toolkit (`ReplTestHost`, `ReplSessionHandle`, typed execution results/events), + plus process-signal testing: `ReplProcessSignalHarness` drives the lifecycle in memory for any + declared platform, and `ReplProcessProbe` spawns an application for what only a real process shows. + See [Testing toolkit](testing-toolkit.md#process-signals). - `Repl.Tests` - Unit tests for pure logic and contracts. - `Repl.IntegrationTests` @@ -45,6 +48,10 @@ reverse is not guaranteed: a release's `Repl.Defaults` may call APIs that only e release's `Repl.Core`. Upgrading one and pinning the other is unsupported. The `Repl` meta-package takes both at matched versions, which is the reason to prefer it. +`Repl.Testing` belongs to that same set. It drives the signal coordinators and the session sink through +internals both assemblies grant it, so it is version-matched to them too — a newer `Repl.Testing` +against an older `Repl.Core` or `Repl.Defaults` is unsupported for the same reason. + ## Quality gates - Strict build rules from `src/Directory.Build.props`: diff --git a/docs/best-practices.md b/docs/best-practices.md index 352d06ad..3357651c 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -363,6 +363,10 @@ That keeps status/progress/problem events out of the main Spectre surface and av Use `UseCliProfile()` (or an explicit `ProcessSignalHandlingMode.Automatic`) for a standalone CLI where Repl is the process owner. An unprofiled `ReplApp.Create()` remains caller-owned. Use `UseEmbeddedConsoleProfile()` or explicitly set `ProcessSignalHandlingMode.None` when an ASP.NET Core host, worker service, test runner, or another command framework already owns console cancellation and shutdown. Feed that host's cancellation token into `RunAsync` instead of installing competing handlers. External `IServiceProvider`, `IHost`, and `IReplHost` overloads always remain caller-owned and diagnose an explicit `Automatic` request instead of applying it. +Whichever ownership you pick, assert it rather than assume it: `ReplProcessSignalHarness` covers the +signal lifecycle in memory and `ReplProcessProbe` covers what only a spawned process can show. See +[Testing toolkit](testing-toolkit.md#process-signals). + Supplying a `ReplRunOptions` instance for an unrelated setting preserves the profile default because `ProcessSignalHandling` is nullable: ```csharp diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 6fc30ad4..02498909 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -234,6 +234,12 @@ A record passed to `app.RunAsync(...)` to control runtime behavior. Separate fro ### Process signal handling +Everything in this section is testable without spawning a process: `Repl.Testing`'s +`ReplProcessSignalHarness` drives the lifecycle in memory and can declare the platform whose decisions +apply, so a Windows wiring decision is assertable from Linux and the other way round. See +[Testing toolkit](testing-toolkit.md#process-signals), which is also where the boundary is written +down — what an in-memory test can prove, and what needs a real process. + `ProcessSignalHandling` applies only to standalone `Run`/`RunAsync` overloads that use the app's internally configured services. Overloads that receive an external `IServiceProvider`, `IHost`, or `IReplHost` do not install the standalone process-signal bridge; the external owner remains responsible for translating shutdown into the caller-owned cancellation token. Passing an explicit `Automatic` value to one of those overloads writes a diagnostic to the active error channel and ignores the value. If such a run enters Repl's interactive loop, that loop still retains its own console command-cancellation policy. The mode that actually applies to a run is resolved in this order: diff --git a/docs/testing-toolkit.md b/docs/testing-toolkit.md index 7d5b277d..eab48658 100644 --- a/docs/testing-toolkit.md +++ b/docs/testing-toolkit.md @@ -150,6 +150,163 @@ await using var host = ReplTestHost.Create( - `NormalizeAnsi`: strips ANSI escape sequences from `OutputText` when `true`. - `RunOptionsFactory`: provides base `ReplRunOptions` for each session. +## Process Signals + +Signal handling is not a session concern, so it has its own entry points rather than living on +`ReplTestHost`. There are two, and they prove different things: + +- `ReplProcessSignalHarness` drives the whole lifecycle **in memory**. Deterministic, fast, runs + everywhere, and covers every decision the framework makes. +- `ReplProcessProbe` **spawns your application** and sends it real signals. Slower and platform-bound, + and the only way to show that a process actually terminates. + +Reach for the harness first. Use the probe for the handful of guarantees the harness cannot honestly +make. + +### Deterministic, in memory + +```csharp +await using var harness = ReplProcessSignalHarness.Create(CreateApp); +var run = await harness.StartRunAsync("work"); + +var delivery = harness.SendSignal(ReplProcessSignal.Interrupt); +var result = await run.Completion; + +delivery.Should().Be(ReplSignalDelivery.CancellationRequested); +result.OutcomeKind.Should().Be(ReplExecutionOutcomeKind.Interrupted); +result.ExitCode.Should().Be(130); +``` + +`SendSignal` is synchronous on purpose: a signal callback owes the operating system a suppression +decision before it returns, so the framework decides synchronously and so does this. Await +`run.Completion` to see what the decision did. + +Runs execute as **standalone invocations**, not hosted sessions — the same classification a +process-owning `Main` gets. Commands gated to the CLI channel are therefore present, and handlers see +`IReplIoContext.IsHostedSession == false`. + +`RunTimeout` is measured against the clock rather than against the run agreeing to stop, so a command +that never observes its cancellation token still fails the test instead of hanging the suite. Such a +run cannot be killed: the harness abandons it, and disposal says so, because it still holds a place in +the process-wide signal epoch. + +**`StartRunAsync` guarantees the signal will reach the run, not that the command is running.** The +signal scope is installed around the whole run, before its arguments are parsed, so the command body +has usually not started when the call returns. If you are asserting on what the command did — that its +cleanup ran, say — have the command say when it is running: + +```csharp +var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +app.Map("work", async (CancellationToken ct) => +{ + started.TrySetResult(); + try { await Task.Delay(Timeout.Infinite, ct); return "unreachable"; } + finally { /* cleanup you want to assert on */ } +}); +// ... +var run = await harness.StartRunAsync("work"); +await started.Task; +harness.SendSignal(ReplProcessSignal.Interrupt); +``` + +Start a second run to test a **late joiner** — a run that begins while a signal is already claimed and +inherits that epoch. Starting is serialised; the runs are not. Note that an epoch resets once its last +run drains, so the earlier run has to still hold its scope for the window to exist. + +### Declaring a platform + +By default the harness applies the decisions of the platform you are on. Declare another one and they +become assertable from anywhere: + +```csharp +await using var harness = ReplProcessSignalHarness.Create( + CreateApp, + options => options.Platform = ReplPlatformProfile.Windows); +var run = await harness.StartRunAsync("work"); + +// Ctrl+Break is a signal on Windows and nothing anywhere else — this passes on Linux too. +harness.SendSignal(ReplProcessSignal.Break).Should().Be(ReplSignalDelivery.CancellationRequested); +``` + +Profiles: `Current`, `Windows`, `Unix` (Linux and macOS decide identically here, so they share one), +`Android`, `Browser`, `IOS`, `TvOS`. The last four have no signal bridge, so a run under them degrades +to caller-owned handling and says so once. + +A declared platform changes **decisions only**: the harness never installs an operating-system signal +registration on that platform's behalf. That matters more than it sounds — .NET accepts a `SIGTERM` +registration on Windows too, so nothing but this rule would stop a declared-Unix test from installing +a live handler in your test process. + +One consequence to know: a profile with no signal bridge (`Browser`, `Android`, `IOS`, `TvOS`) +registers no console cancel-key handler either, so a real Ctrl+C aimed at your runner during such a run +takes its normal course rather than being claimed by it. + +Profiles are the only way to build one — the flags are readable so you can assert on them, not +settable, so combinations no device has cannot be constructed by mistake. + +### One harness at a time + +Signal handling is process-global, and a harness owns it for its lifetime — really owns it, not by +convention. While it holds ownership, a second harness is refused, and so is any other run that would +install its own signal handling: such a run joins the harness's isolated epoch, gets cancelled by its +synthetic signals, and releases its readiness wait. Failing that run loudly is the point. Configure +your framework so it does not happen: + +| Framework | Parallel by default? | What to add | +| --- | --- | --- | +| MSTest | yes | `[DoNotParallelize]` on the test class | +| xUnit | yes, across classes | a shared `[Collection("...")]`, or `[assembly: CollectionBehavior(DisableTestParallelization = true)]` | +| NUnit | no | `[NonParallelizable]`, only if you opted into parallelism | + +Sharding across separate processes needs none of this: the state is per-process. And the package +references no test framework, so it cannot apply any of these for you. + +### What only a real process can prove + +```csharp +await using var probe = ReplProcessProbe.Start("./my-app", ["wait", markerPath]); +await probe.WaitForOutputAsync("READY"); +await probe.SendSignalAsync(ReplProcessSignal.Terminate); + +(await probe.WaitForExitAsync()).Should().Be(143); +``` + +Output is drained continuously, so a chatty child never blocks; every wait reports what was captured +when it fails; and disposal kills the process tree, so a failed assertion does not leave the probed +application running. That last one reaches only as far as the tree's root: a child that spawns +something long-lived and then exits on its own leaves that descendant behind, because there is no +parent left to walk down from. + +**Signals are delivered on Unix only.** Sending one to another process on Windows needs a console +control event and console attachment rather than a signal, which is deliberately out of scope until +the Windows lifecycle work lands. `SendSignalAsync` throws `PlatformNotSupportedException` there; +spawning, waiting on output and exit codes all still work, so a cross-platform suite shares everything +but the delivery. `ReplProcessSignal.Break` is refused on every platform — it is a Windows console +event, and the nearest Unix signal is one the framework deliberately leaves unclaimed. + +One trap worth knowing: `probe.Output` holds what the child flushed. To observe what happened during a +shutdown the process might not survive, have the application append to a file and read that. + +### What each half proves + +| | Harness | Probe | +| --- | --- | --- | +| The decision about each signal | yes | — | +| Diagnostics the framework wrote | yes, on `harness.DiagnosticText` | in the child's output | +| The exit code the run resolves to | yes | yes, as the shell sees it | +| Cleanup was given time to run | yes | yes | +| Every platform's wiring decisions | yes, from any host | only the host's own | +| **The process actually terminated** | **no** | **yes** | + +A second signal makes the harness report `WouldTerminateProcess`. That is the framework's decision and +nothing more: nothing dies in-process, so the run keeps unwinding and your code after the call keeps +executing. Read it as "Repl chose not to intervene again", never as "it stopped". + +Signal diagnostics are written from whichever context delivers the signal — an operating-system +callback thread with no session, in production — so they belong to the delivery rather than to a run. +`harness.DiagnosticText` holds them; `ReplSignalRunResult.DiagnosticText` holds what the run itself +wrote. + ## Notes - The harness is in-memory and transport-agnostic (no loopback network stack required). diff --git a/eng/ci/process-signal-stress.sh b/eng/ci/process-signal-stress.sh index 580bb6da..e260e170 100755 --- a/eng/ci/process-signal-stress.sh +++ b/eng/ci/process-signal-stress.sh @@ -61,9 +61,15 @@ run_stress \ "FullyQualifiedName~Given_ProcessSignalCancellationScope" \ 25 +# Given_ProcessSignalHarness does not contain the substring "Given_ProcessSignals" — there is no "s" +# after "Signal" — so it needs its own clause rather than riding along on the one below. +# +# The count below is a floor: the run fails only when fewer tests match, so adding tests never breaks +# it — and never raises it either. Raise it when you add tests to any of the three classes, or the +# tripwire loses exactly as much sensitivity as the slack it accumulates. run_stress \ "process-signal integration stress" \ "$integration_iterations" \ src/Repl.IntegrationTests/Repl.IntegrationTests.csproj \ - "FullyQualifiedName~Given_ProcessSignals" \ - 8 + "FullyQualifiedName~Given_ProcessSignals|FullyQualifiedName~Given_ProcessSignalHarness|FullyQualifiedName~Given_ProcessProbe" \ + 39 diff --git a/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs b/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs index 8abf4214..d28c68cd 100644 --- a/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs +++ b/src/Repl.Core/Console/ConsoleCancelKeyCoordinator.cs @@ -65,6 +65,37 @@ internal static ConsoleCancelKeyHandlingResult HandleCancelKeyForTesting( bool? isWindows = null) => HandleCancelKey(specialKey, isWindows ?? OperatingSystem.IsWindows(), afterInitialSelection); + /// + /// Delivers a synthetic key on behalf of a standalone test harness, refusing rather than dispatching + /// when an interactive session owns the keys. + /// + /// The refusal is decided from the same revalidated selection the dispatch would have used, so it + /// cannot be overtaken by a registration arriving between a separate check and this call — and no + /// handler runs when it refuses, which is the point: reporting an interactive session's result as + /// the harness's own is a test passing on a cancellation that never reached the run it names. + /// + /// + internal static ConsoleCancelKeyHandlingResult HandleStandaloneCancelKeyForTesting( + ConsoleSpecialKey specialKey, + bool isWindows, + out bool interactiveOwned) + { + interactiveOwned = false; + if (!IsHandledCancelKey(specialKey, isWindows)) + { + return ConsoleCancelKeyHandlingResult.NotHandled; + } + + var selection = RevalidateSelection(CaptureSelection()); + if (selection.IsInteractive) + { + interactiveOwned = true; + return ConsoleCancelKeyHandlingResult.NotHandled; + } + + return Invoke(selection.Handlers, specialKey); + } + private static ConsoleCancelKeyHandlingResult HandleCancelKey( ConsoleSpecialKey specialKey, bool isWindows, @@ -84,6 +115,23 @@ private static bool IsHandledCancelKey(ConsoleSpecialKey specialKey, bool isWind specialKey == ConsoleSpecialKey.ControlC || (isWindows && specialKey == ConsoleSpecialKey.ControlBreak); + /// + /// Whether an interactive session currently owns the console keys. Selection is exclusive — an + /// interactive handler takes Ctrl+C instead of, not alongside, the standalone ones — so a test + /// harness delivering a synthetic key while one is registered would be told the signal was handled + /// when it reached somebody else entirely. + /// + internal static bool HasInteractiveHandlersForTesting + { + get + { + lock (Gate) + { + return InteractiveHandlers.Count > 0; + } + } + } + private static DispatchSelection CaptureSelection() { lock (Gate) @@ -104,10 +152,9 @@ private static DispatchSelection RevalidateSelection(DispatchSelection selection private static DispatchSelection CaptureSelectionUnsafe() { - var handlers = InteractiveHandlers.Count > 0 - ? InteractiveHandlers.Values - : StandaloneHandlers.Values; - return new DispatchSelection(s_registrationVersion, [.. handlers]); + var isInteractive = InteractiveHandlers.Count > 0; + var handlers = isInteractive ? InteractiveHandlers.Values : StandaloneHandlers.Values; + return new DispatchSelection(s_registrationVersion, isInteractive, [.. handlers]); } private static ConsoleCancelKeyHandlingResult Invoke( @@ -133,6 +180,7 @@ private static ConsoleCancelKeyHandlingResult Invoke( private readonly record struct DispatchSelection( long Version, + bool IsInteractive, IReadOnlyList> Handlers); private sealed class Registration(long registrationId, bool isInteractive) : IDisposable diff --git a/src/Repl.Defaults/ProcessSignalCoordinator.cs b/src/Repl.Defaults/ProcessSignalCoordinator.cs index b34bd6a2..9bb6de28 100644 --- a/src/Repl.Defaults/ProcessSignalCoordinator.cs +++ b/src/Repl.Defaults/ProcessSignalCoordinator.cs @@ -27,11 +27,60 @@ internal static class ProcessSignalCoordinator private static int s_generation; private static int s_pendingDrainCount; private static bool s_registrationsInitialized; + private static bool s_sigTermRegistrationDeclared; private static RegistrationFault? s_registrationFaultForTesting; + private static SignalRegistrationPolicy? s_registrationPolicyForTesting; + // Invoked once a scope has joined the epoch and any cancellation it inherited has started, so a test + // harness can await the moment a signal stops being inert instead of guessing with a delay. Owned by + // the isolation scope like every other test knob here, so it cannot outlive the harness that set it. + private static Action? s_scopeRegisteredCallbackForTesting; + private static object? s_testOwner; + + // Flows into the run's async context, so a scope constructed inside a run the owner launched is + // recognised as the owner's while one created anywhere else is not. Ambient rather than passed, + // because ProcessSignalCancellationScope is constructed deep inside ReplApp.RunAsync and neither + // the coordinator nor the scope has a channel to carry an owner through. + // + // It carries the claim rather than a flag: a bare bool also flows into anything a handler spawned, + // so a background task outliving its harness would still read true and pass as a run launched by + // whichever harness owns the coordinator next. + private static readonly AsyncLocal OwnedRun = new(); /// - /// Gives a test a coordinator with no installed registrations, optionally failing the next - /// registration attempt, and leaves it able to install fresh ones on disposal. + /// Whether the platform in force wants a SIGTERM registration at all. Read with + /// : wanted but not installed is what a + /// declared platform under test looks like, and is the state that proves no operating-system + /// registration was created on its behalf. + /// + internal static bool SigTermRegistrationDeclaredForTesting + { + get + { + lock (Gate) + { + return s_sigTermRegistrationDeclared; + } + } + } + + /// + /// Whether a live operating-system SIGTERM registration exists right now. + /// + internal static bool SigTermRegistrationInstalledForTesting + { + get + { + lock (Gate) + { + return s_sigTermRegistration is not null; + } + } + } + + /// + /// Gives a test a coordinator with no installed registrations, optionally under a declared + /// platform and optionally failing the next registration attempt, and leaves it able to install + /// fresh ones on disposal. /// /// Registrations capture the generation counter they were created under, so putting a saved /// registration object back after the counter has moved would leave it permanently stale and @@ -41,19 +90,105 @@ internal static class ProcessSignalCoordinator /// internal static IDisposable IsolateRegistrationsForTesting( Exception? registrationFault = null, - bool faultAfterSigTermRegistration = false) => + bool faultAfterSigTermRegistration = false, + SignalRegistrationPolicy? policy = null, + Action? scopeRegisteredCallback = null) => new RegistrationIsolationScope( registrationFault is null ? null - : new RegistrationFault(registrationFault, faultAfterSigTermRegistration)); + : new RegistrationFault(registrationFault, faultAfterSigTermRegistration), + policy, + scopeRegisteredCallback); + + /// + /// Claims the coordinator for a test harness. Atomic with the emptiness check, so no run can slip + /// in between the two, and held until the returned scope is disposed. While it is held, + /// refuses any scope the owner did not launch. + /// + /// The claim, to be disposed when it is released, or when the previous epoch has not finished or another owner has it. + internal static IDisposable? TryClaimTestOwnership() + { + lock (Gate) + { + // An empty scope set is not an idle coordinator. UnregisterAsync removes a scope before its + // signal-triggered callbacks have drained and keeps the claimed signal alive until they have, + // so claiming in that window would hand the next harness an epoch that is still claimed — and + // its first run would be cancelled on registration, with no signal ever sent. + if (s_testOwner is not null + || ActiveScopes.Count != 0 + || s_pendingDrainCount != 0 + || s_claimedSignal is not null) + { + return null; + } + + var claim = new TestOwnershipClaim(); + s_testOwner = claim; + return claim; + } + } + + /// + /// Marks the current async context as belonging to the test owner, so scopes constructed beneath it + /// are accepted while the claim is held. Returns a scope that restores the previous marking. + /// + /// + /// Identifies the individual run, so a registration can be matched to the start that launched it + /// rather than merely to the harness. A start whose wait gave up leaves its launch running, and that + /// launch may register long afterwards; without the token its late registration would be taken for + /// whichever start is waiting by then. + /// + internal static IDisposable MarkOwnedRunForTesting(object runToken) + { + ArgumentNullException.ThrowIfNull(runToken); + var previous = OwnedRun.Value; + lock (Gate) + { + OwnedRun.Value = new OwnedRunContext(s_testOwner, runToken); + } + + return new OwnedRunMarker(previous); + } + + /// + /// How many scopes hold the epoch right now. A harness reads this as it releases ownership: a scope + /// it did not start and cannot drain — a command that launched its own automatic run, say — would + /// otherwise keep the epoch occupied and make every later harness refuse to start, with nothing + /// saying why. + /// + internal static int ActiveScopeCountForTesting + { + get + { + lock (Gate) + { + return ActiveScopes.Count; + } + } + } internal static void Register(ProcessSignalCancellationScope scope) { ArgumentNullException.ThrowIfNull(scope); RegistrationOutcome outcome; Action? startCancellation = null; + string? orphanDiagnostic = null; lock (Gate) { + // A test harness owns process-signal handling for its lifetime. A run it did not launch would + // join its isolated epoch, be cancelled by its synthetic signals, and release its readiness + // wait — so it is refused here rather than corrupted quietly. Running one concurrently with a + // signal test is already what the harness documentation tells callers not to do. + if (s_testOwner is not null && !ReferenceEquals(OwnedRun.Value?.Owner, s_testOwner)) + { + throw new InvalidOperationException( + "A process-signal test harness currently owns signal handling in this process, so this " + + "run cannot install its own. Signal handling is process-global: let the harness finish, " + + "and configure your test framework not to run signal tests in parallel with anything " + + "that starts an automatic run."); + } + + orphanDiagnostic = DiscardOrphanedClaim(); outcome = TryInitializeRegistrations(); // The scope joins the epoch even when no bridge could be installed, so that disposal stays // symmetric and a run started before an earlier scope claimed a signal still inherits it. @@ -67,6 +202,11 @@ internal static void Register(ProcessSignalCancellationScope scope) // Installing the bridge is a convenience, not a precondition for running the command. Every way // it can fail to install — an unsupported platform, or an environment that refuses the // registration — degrades to caller-owned handling and says so once, on the same path. + if (orphanDiagnostic is { } orphanMessage) + { + WriteDiagnostic(orphanMessage); + } + if (outcome.Diagnostic is { } diagnostic) { outcome.OrphanedCancelKeyRegistration?.Dispose(); @@ -75,6 +215,35 @@ internal static void Register(ProcessSignalCancellationScope scope) } startCancellation?.Invoke(); + if (OwnedRun.Value is { } ownedRun && ReferenceEquals(ownedRun.Owner, s_testOwner)) + { + s_scopeRegisteredCallbackForTesting?.Invoke(ownedRun.RunToken); + } + } + + /// + /// Clears a claim left behind by an owner that has since been released. Its scopes were abandoned + /// rather than drained, so nothing is going to clear it, and a run inheriting it would be cancelled + /// by a signal nobody sent — reported as an interruption with no diagnostic naming a signal, which + /// from the caller's side is indistinguishable from a bug in their own application. + /// + /// + /// What to report, or when there was nothing to discard. Returned rather than + /// written: is caller-supplied, and this class promises no consumer + /// callback runs while the gate is held — a writer that blocked here would stop a concurrent signal + /// callback from reaching its suppression decision. + /// + private static string? DiscardOrphanedClaim() + { + if (s_claimedSignal is not { Owner: not null } orphaned || ReferenceEquals(orphaned.Owner, s_testOwner)) + { + return null; + } + + s_claimedSignal = null; + return $"Discarding a {orphaned.Name} claim left by a process-signal test harness that was " + + "disposed while a run it could not stop was still executing. This run is unaffected, " + + "but that run may still be running."; } private static RegistrationOutcome TryInitializeRegistrations() @@ -94,9 +263,13 @@ private static RegistrationOutcome TryInitializeRegistrations() OrphanedSigTermRegistration: null); } + return InstallRegistrations(++s_generation); + } + + private static RegistrationOutcome InstallRegistrations(int generation) + { PosixSignalRegistration? sigTermRegistration = null; IDisposable? cancelKeyRegistration = null; - var generation = ++s_generation; try { // No supported platform rejects a signal registration on demand, so the failure policy @@ -105,7 +278,15 @@ private static RegistrationOutcome TryInitializeRegistrations() // the orphaned-registration cleanup below. ThrowIfFaultInjected(afterSigTermRegistration: false); - if (!OperatingSystem.IsWindows()) + // Windows already gets Ctrl+C and Ctrl+Break through the console coordinator, and .NET maps + // PosixSignal.SIGTERM onto CTRL_SHUTDOWN_EVENT there, so registering it would add a second + // handler alongside the one that already owns those keys. That is a wiring decision, not a + // capability limit, so it comes from the policy in force rather than straight from the host: + // a declared platform's wiring becomes assertable from any platform. Whether a real + // registration may be created is a separate bit, because a declared platform must never + // install one in the test runner's own process. + s_sigTermRegistrationDeclared = !IsWindowsForRegistration(); + if (s_sigTermRegistrationDeclared && MayCreateRealRegistrations()) { sigTermRegistration = PosixSignalRegistration.Create( PosixSignal.SIGTERM, @@ -203,8 +384,26 @@ private static void HandleSigTerm(int generation, PosixSignalContext e) } } + /// + /// Claims SIGTERM the way a freshly installed registration would, without an operating-system + /// registration to deliver it. Ctrl+C and Ctrl+Break have + /// for this; SIGTERM had no + /// counterpart, so the claim logic was reachable in-process only through the console path. + /// + /// This does not exercise itself: translating the decision into + /// needs a real signal context, and stays covered only by + /// the out-of-process suite. + /// + /// + internal static ConsoleCancelKeyHandlingResult HandleSigTermForTesting() => + TryClaimSignal(generation: null, "SIGTERM", SigTermExitCode); + + // A null generation accepts whichever epoch is current, which is what a freshly installed + // registration would see. Reading the counter before taking the gate would race + // TryInitializeRegistrations' failure path and the test isolation scope, both of which advance it, + // so the caller passes null rather than a value it read itself. private static ConsoleCancelKeyHandlingResult TryClaimSignal( - int generation, + int? generation, string name, int exitCode) { @@ -212,7 +411,7 @@ private static ConsoleCancelKeyHandlingResult TryClaimSignal( List? startCancellations = null; lock (Gate) { - if (generation != s_generation) + if (generation is { } capturedGeneration && capturedGeneration != s_generation) { return ConsoleCancelKeyHandlingResult.NotHandled; } @@ -225,7 +424,7 @@ private static ConsoleCancelKeyHandlingResult TryClaimSignal( if (previousSignal is null) { - s_claimedSignal = new ClaimedSignal(name, exitCode); + s_claimedSignal = new ClaimedSignal(name, exitCode, s_testOwner); startCancellations = []; foreach (var scope in ActiveScopes) { @@ -260,14 +459,26 @@ private static ConsoleCancelKeyHandlingResult TryClaimSignal( } private static bool IsSignalBridgeSupported() => - IsSignalBridgeSupportedForTesting( - OperatingSystem.IsAndroid(), - OperatingSystem.IsBrowser(), - // Named explicitly rather than relied upon through IsIOS: Mac Catalyst is documented here as - // unsupported, and OperatingSystem exposes it as its own guard, so the check states what it - // means instead of resting on whether one platform predicate implies the other. - OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst(), - OperatingSystem.IsTvOS()); + s_registrationPolicyForTesting is { } policy + ? IsSignalBridgeSupportedForTesting( + policy.IsAndroid, + policy.IsBrowser, + policy.IsIOSOrMacCatalyst, + policy.IsTvOS) + : IsSignalBridgeSupportedForTesting( + OperatingSystem.IsAndroid(), + OperatingSystem.IsBrowser(), + // Named explicitly rather than relied upon through IsIOS: Mac Catalyst is documented here as + // unsupported, and OperatingSystem exposes it as its own guard, so the check states what it + // means instead of resting on whether one platform predicate implies the other. + OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst(), + OperatingSystem.IsTvOS()); + + private static bool IsWindowsForRegistration() => + s_registrationPolicyForTesting?.IsWindows ?? OperatingSystem.IsWindows(); + + private static bool MayCreateRealRegistrations() => + s_registrationPolicyForTesting?.CreateRealRegistrations ?? true; internal static bool IsSignalBridgeSupportedForTesting( bool isAndroid, @@ -299,14 +510,44 @@ internal static void WriteDiagnostic(string message) } } - private sealed class RegistrationIsolationScope : IDisposable + /// + /// The platform whose registration decisions apply while a test isolation scope is open, and + /// whether the coordinator may create real operating-system registrations under it. + /// + /// is deliberately independent of the platform flags; the + /// reason is at the point that enforces it, in InstallRegistrations. + /// + /// + internal sealed record SignalRegistrationPolicy { - public RegistrationIsolationScope(RegistrationFault? registrationFault) => - TearDownRegistrations(registrationFault); + internal bool IsWindows { get; init; } + + internal bool IsAndroid { get; init; } + + internal bool IsBrowser { get; init; } + + internal bool IsIOSOrMacCatalyst { get; init; } - public void Dispose() => TearDownRegistrations(registrationFault: null); + internal bool IsTvOS { get; init; } - private static void TearDownRegistrations(RegistrationFault? registrationFault) + internal bool CreateRealRegistrations { get; init; } + } + + private sealed class RegistrationIsolationScope : IDisposable + { + public RegistrationIsolationScope( + RegistrationFault? registrationFault, + SignalRegistrationPolicy? policy, + Action? scopeRegisteredCallback) => + TearDownRegistrations(registrationFault, policy, scopeRegisteredCallback); + + public void Dispose() => + TearDownRegistrations(registrationFault: null, policy: null, scopeRegisteredCallback: null); + + private static void TearDownRegistrations( + RegistrationFault? registrationFault, + SignalRegistrationPolicy? policy, + Action? scopeRegisteredCallback) { IDisposable? cancelKeyRegistration; PosixSignalRegistration? sigTermRegistration; @@ -319,8 +560,11 @@ private static void TearDownRegistrations(RegistrationFault? registrationFault) // Uninstalled, so the next Register installs fresh registrations under a current // generation instead of reviving ones the counter has already left behind. s_registrationsInitialized = false; + s_sigTermRegistrationDeclared = false; s_generation++; s_registrationFaultForTesting = registrationFault; + s_registrationPolicyForTesting = policy; + s_scopeRegisteredCallbackForTesting = scopeRegisteredCallback; } cancelKeyRegistration?.Dispose(); @@ -328,7 +572,31 @@ private static void TearDownRegistrations(RegistrationFault? registrationFault) } } - private readonly record struct ClaimedSignal(string Name, int ExitCode); + private sealed class TestOwnershipClaim : IDisposable + { + public void Dispose() + { + lock (Gate) + { + if (ReferenceEquals(s_testOwner, this)) + { + s_testOwner = null; + } + } + } + } + + private sealed record OwnedRunContext(object? Owner, object RunToken); + + private sealed class OwnedRunMarker(OwnedRunContext? previous) : IDisposable + { + public void Dispose() => OwnedRun.Value = previous; + } + + // Owner is the test claim in force when the signal was claimed, or null for an ordinary run. A + // claim whose owner has since been released belongs to an epoch nobody is draining any more: a + // later run must not inherit its cancellation, which would look like a signal the test never sent. + private readonly record struct ClaimedSignal(string Name, int ExitCode, object? Owner); /// /// The result of one registration attempt. A null means the bridge is diff --git a/src/Repl.IntegrationTests/Given_ProcessProbe.cs b/src/Repl.IntegrationTests/Given_ProcessProbe.cs new file mode 100644 index 00000000..7c4ab93d --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ProcessProbe.cs @@ -0,0 +1,166 @@ +using AwesomeAssertions; +using Repl.Testing; + +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ProcessProbe +{ + [TestMethod] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] + [Description("Regression guard: verifies a real SIGTERM reaches a spawned application, which exits 143 after its cleanup ran. This is the half no in-memory test can reach: the harness proves the framework decided not to intervene a second time, only a real process proves it actually terminated with the code a shell would see.")] + public async Task When_ARealSigTermIsDelivered_Then_TheProcessExitsAfterCleanup() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + try + { + await using var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => options.Environment["REPL_TEST_SCENARIO"] = "process-signal"); + + await probe.WaitForOutputAsync("READY"); + await probe.SendSignalAsync(ReplProcessSignal.Terminate); + var exitCode = await probe.WaitForExitAsync(); + + exitCode.Should().Be(143); + (await File.ReadAllTextAsync(marker)).Should().Contain( + "FINALLY", + because: "a cooperative signal must give the command its cleanup, not cut the process down"); + } + finally + { + File.Delete(marker); + } + } + + [TestMethod] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] + [Description("Regression guard: verifies a real SIGINT exits a spawned application with 130, so the conventional codes are asserted against an operating system rather than against the framework's own table.")] + public async Task When_ARealSigIntIsDelivered_Then_TheProcessExitsWith130() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + try + { + await using var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => options.Environment["REPL_TEST_SCENARIO"] = "process-signal"); + + await probe.WaitForOutputAsync("READY"); + await probe.SendSignalAsync(ReplProcessSignal.Interrupt); + + (await probe.WaitForExitAsync()).Should().Be(130); + } + finally + { + File.Delete(marker); + } + } + + [TestMethod] + [OSCondition(ConditionMode.Include, OperatingSystems.Windows)] + [Description("Regression guard: verifies the probe refuses to send a signal on Windows with a message that says what to use instead, rather than appearing to deliver one. Delivering to another process there needs a console control event and console attachment, which is deliberately out of scope until issue #83 settles the Windows lifecycle.")] + public async Task When_ASignalIsSentOnWindows_Then_ItIsRefusedWithGuidance() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + try + { + await using var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => options.Environment["REPL_TEST_SCENARIO"] = "process-signal"); + + // Spawning, waiting on output and killing all work here — only delivery is refused, so a + // cross-platform suite can share everything but the signal. + await probe.WaitForOutputAsync("READY"); + var act = async () => await probe.SendSignalAsync(ReplProcessSignal.Terminate).ConfigureAwait(false); + + await act.Should().ThrowAsync() + .WithMessage("*ReplProcessSignalHarness*"); + } + finally + { + File.Delete(marker); + } + } + + [TestMethod] + [Description("Regression guard: verifies Ctrl+Break is refused by the probe on every platform, because it is a Windows console event with no Unix signal the framework treats the same way. Silently mapping it to SIGQUIT would assert against a signal this framework deliberately leaves unclaimed.")] + public async Task When_BreakIsSentToAProcess_Then_ItIsRefused() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + try + { + await using var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => options.Environment["REPL_TEST_SCENARIO"] = "process-signal"); + await probe.WaitForOutputAsync("READY"); + + var act = async () => await probe.SendSignalAsync(ReplProcessSignal.Break).ConfigureAwait(false); + + await act.Should().ThrowAsync(); + } + finally + { + File.Delete(marker); + } + } + + [TestMethod] + [Description("Regression guard: verifies waiting for output the application never writes fails with what it did write, instead of timing out silently. A signal test that waits on the wrong marker is otherwise indistinguishable from one whose signal never arrived.")] + public async Task When_TheExpectedOutputNeverArrives_Then_TheFailureCarriesWhatWasWritten() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + try + { + await using var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => + { + options.Environment["REPL_TEST_SCENARIO"] = "process-signal"; + options.Timeout = TimeSpan.FromSeconds(2); + }); + + var act = async () => await probe.WaitForOutputAsync("NEVER-WRITTEN").ConfigureAwait(false); + + await act.Should().ThrowAsync() + .WithMessage("*READY*", because: "the failure must show what the process did write"); + } + finally + { + File.Delete(marker); + } + } + + [TestMethod] + [Description("Regression guard: verifies disposal kills an application still running, so a failed assertion cannot leak a blocked process into the rest of the suite.")] + public async Task When_AProbeIsDisposedWhileRunning_Then_TheProcessIsKilled() + { + var marker = Path.Combine(Path.GetTempPath(), $"probe-{Guid.NewGuid():N}.marker"); + int processId; + try + { + await using (var probe = ReplProcessProbe.Start( + ShellCompletionTestHostRunner.ResolveHostExecutablePath(), + ["wait", marker], + options => options.Environment["REPL_TEST_SCENARIO"] = "process-signal")) + { + await probe.WaitForOutputAsync("READY"); + processId = probe.ProcessId; + } + + var act = () => System.Diagnostics.Process.GetProcessById(processId); + + act.Should().Throw( + because: "the process must be gone, not merely asked to stop"); + } + finally + { + File.Delete(marker); + } + } +} diff --git a/src/Repl.IntegrationTests/Given_ProcessSignalHarness.cs b/src/Repl.IntegrationTests/Given_ProcessSignalHarness.cs new file mode 100644 index 00000000..ddc80a2d --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ProcessSignalHarness.cs @@ -0,0 +1,656 @@ +using AwesomeAssertions; +using Repl.Testing; + +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ProcessSignalHarness +{ + [TestMethod] + [Description("Regression guard: verifies the first signal cancels a run in flight cooperatively, reports Interrupted, resolves the conventional 130, and lets the command's cleanup finish. Delivered from outside the run, which is what the harness exists for: before it, a signal could only be raised from inside the handler under test.")] + public async Task When_TheFirstSignalArrives_Then_TheRunIsCancelledCooperatively() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanedUp = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var harness = ReplProcessSignalHarness.Create(() => CreateBlockingApp(started, cleanedUp)); + var run = await harness.StartRunAsync("work"); + // Starting a run guarantees a signal will reach it, not that the command body is executing yet: + // the scope is installed before the arguments are even parsed. Asserting on cleanup means + // waiting for the command to say it is running. + await started.Task; + + var delivery = harness.SendSignal(ReplProcessSignal.Interrupt); + var result = await run.Completion; + + delivery.Should().Be(ReplSignalDelivery.CancellationRequested); + result.OutcomeKind.Should().Be(ReplExecutionOutcomeKind.Interrupted); + result.ExitCode.Should().Be(130); + cleanedUp.Task.IsCompletedSuccessfully.Should().BeTrue( + because: "a cooperative first signal must let the command unwind, not cut it off"); + harness.DiagnosticText.Should().Contain("cancelling active standalone runs"); + } + + [TestMethod] + [Description("Regression guard: verifies SIGTERM claims a run in flight and carries the conventional 143, so the two signal kinds are not assumed to share one exit code. Declares Unix because that is where the framework wires SIGTERM up; on Windows it installs no registration for it, and this then passes identically on every host.")] + public async Task When_TerminateArrives_Then_TheRunResolvesTheSigTermCode() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.Platform = ReplPlatformProfile.Unix); + var run = await harness.StartRunAsync("work"); + + var delivery = harness.SendSignal(ReplProcessSignal.Terminate); + var result = await run.Completion; + + delivery.Should().Be(ReplSignalDelivery.CancellationRequested); + result.ExitCode.Should().Be(143); + result.OutcomeKind.Should().Be(ReplExecutionOutcomeKind.Interrupted); + } + + [TestMethod] + [Description("Regression guard: verifies a configured ExitCodes.Interrupted governs a signalled run, so the conventional code is a fallback rather than something the signal path hard-codes.")] + public async Task When_InterruptedIsConfigured_Then_ItGovernsTheExitCode() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(configure: options => options.ExitCodes.Interrupted = 75)); + var run = await harness.StartRunAsync("work"); + + harness.SendSignal(ReplProcessSignal.Interrupt); + + (await run.Completion).ExitCode.Should().Be(75); + } + + [TestMethod] + [Description("Regression guard: verifies a second signal reports that the operating system would take over and leaves the first claim's exit code intact, so escalating cannot relabel what the run is exiting with. Escalates with a different signal kind than it claimed with, which is why it declares the platform that wires SIGTERM up.")] + public async Task When_ASecondSignalArrives_Then_ItWouldTerminateAndTheFirstClaimStands() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.Platform = ReplPlatformProfile.Unix); + var run = await harness.StartRunAsync("work"); + + var first = harness.SendSignal(ReplProcessSignal.Interrupt); + var second = harness.SendSignal(ReplProcessSignal.Terminate); + var third = harness.SendSignal(ReplProcessSignal.Terminate); + var result = await run.Completion; + + first.Should().Be(ReplSignalDelivery.CancellationRequested); + second.Should().Be(ReplSignalDelivery.WouldTerminateProcess); + third.Should().Be( + ReplSignalDelivery.WouldTerminateProcess, + because: "the decision stays the same while the epoch is open, rather than resetting"); + result.ExitCode.Should().Be(130, because: "the first claim owns the exit code"); + harness.DiagnosticText.Should().Contain("allowing immediate operating-system termination"); + } + + [TestMethod] + [Description("Regression guard: verifies a run that starts while a signal is already claimed inherits that epoch and is cancelled as it joins, instead of reading the next signal as a fresh first one. Needs two runs genuinely in flight, which is why starting a run does not serialise against the runs already going.")] + public async Task When_ARunJoinsAfterAClaim_Then_ItInheritsTheEpoch() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Held so the first run cannot finish and reset the epoch: once the last run of a claimed epoch + // drains, the claim is cleared and the next signal is a first signal again. The window a late + // joiner lives in is exactly "claimed, and still occupied". + using var holdFirstRunInCleanup = new SemaphoreSlim(initialCount: 0, maxCount: 1); + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(started, cleanupGate: holdFirstRunInCleanup)); + var first = await harness.StartRunAsync("work"); + await started.Task; + harness.SendSignal(ReplProcessSignal.Interrupt); + + var lateJoiner = await harness.StartRunAsync("work"); + var lateResult = await lateJoiner.Completion; + holdFirstRunInCleanup.Release(); + + lateResult.OutcomeKind.Should().Be(ReplExecutionOutcomeKind.Interrupted); + lateResult.ExitCode.Should().Be(130, because: "the late joiner inherits the claimed signal's code"); + (await first.Completion).ExitCode.Should().Be(130); + } + + [TestMethod] + [Description("Regression guard: verifies a signal delivered with no run in flight is left to the operating-system default, so the harness cannot claim an epoch the real registration would have ignored.")] + public async Task When_NoRunIsInFlight_Then_TheSignalIsNotHandled() + { + await using var harness = ReplProcessSignalHarness.Create(() => CreateBlockingApp()); + + harness.SendSignal(ReplProcessSignal.Interrupt).Should().Be(ReplSignalDelivery.NotHandled); + } + + [TestMethod] + [Description("Regression guard: verifies Ctrl+Break counts as a signal on a declared Windows platform and is ignored on a declared Unix one. Both run on every host, which is the point: before the platform could be declared, half of this pair was unverifiable on any given machine.")] + [DataRow(true, ReplSignalDelivery.CancellationRequested, DisplayName = "Declared Windows: Ctrl+Break is a signal")] + [DataRow(false, ReplSignalDelivery.NotHandled, DisplayName = "Declared Unix: Ctrl+Break is not a signal")] + public async Task When_BreakIsDelivered_Then_OnlyADeclaredWindowsPlatformHandlesIt( + bool declareWindows, + ReplSignalDelivery expected) + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.Platform = declareWindows + ? ReplPlatformProfile.Windows + : ReplPlatformProfile.Unix); + var run = await harness.StartRunAsync("work"); + + harness.SendSignal(ReplProcessSignal.Break).Should().Be(expected); + + // Release the run either way, so the assertion above is what fails a broken case rather than + // the harness blocking until its timeout. + harness.SendSignal(ReplProcessSignal.Interrupt); + await run.Completion; + } + + [TestMethod] + [Description("Regression guard: verifies a declared platform without a signal bridge degrades to caller-owned handling and says so once, rather than silently leaving a run unprotected. Asserted from any host.")] + public async Task When_TheDeclaredPlatformHasNoBridge_Then_TheRunSaysHandlingIsCallerOwned() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateEchoApp(), + options => options.Platform = ReplPlatformProfile.Browser); + + var run = await harness.StartRunAsync("echo"); + var result = await run.Completion; + + result.DiagnosticText.Should().Contain("unavailable on this platform"); + result.ExitCode.Should().Be(0, because: "a missing bridge degrades handling, it does not fail the run"); + } + + [TestMethod] + [Description("Regression guard: verifies a registration the environment refuses degrades to caller-owned handling with a diagnostic naming the failure. No supported platform refuses one on demand, so an injected fault is the only way to cover the path an operator would actually hit.")] + public async Task When_RegistrationIsRefused_Then_TheDegradationIsDiagnosed() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateEchoApp(), + options => options.RegistrationFault = new PlatformNotSupportedException("registration refused")); + + var run = await harness.StartRunAsync("echo"); + var result = await run.Completion; + + result.DiagnosticText.Should().Contain("Failed to install automatic process-signal handling"); + result.DiagnosticText.Should().Contain("registration refused"); + } + + [TestMethod] + [Description("Regression guard: verifies a second live harness is refused. Taking ownership tears down and reinstalls process-global registration state, so two harnesses would corrupt each other's isolation rather than merely race on the application under test — the failure has to be loud, not intermittent.")] + public async Task When_ASecondHarnessIsCreated_Then_ItIsRefused() + { + await using var harness = ReplProcessSignalHarness.Create(() => CreateEchoApp()); + + var second = () => ReplProcessSignalHarness.Create(() => CreateEchoApp()); + + second.Should().Throw() + .WithMessage("*already owned*"); + } + + [TestMethod] + [Description("Regression guard: verifies a harness released by disposal can be replaced, so the exclusivity guard cannot leave a suite unable to run a second signal test.")] + public async Task When_AHarnessIsDisposed_Then_AnotherCanBeCreated() + { + await using (var first = ReplProcessSignalHarness.Create(() => CreateEchoApp())) + { + var firstRun = await first.StartRunAsync("echo"); + await firstRun.Completion; + } + + await using var second = ReplProcessSignalHarness.Create(() => CreateEchoApp()); + var secondRun = await second.StartRunAsync("echo"); + + (await secondRun.Completion).ExitCode.Should().Be(0); + } + + [TestMethod] + [Description("Regression guard: verifies a blank command line is refused before a run starts, so a typo cannot silently start a run with no command and leave a signal test asserting against nothing.")] + public async Task When_TheCommandLineIsBlank_Then_StartingItIsRefused() + { + await using var harness = ReplProcessSignalHarness.Create(() => CreateEchoApp()); + + var act = async () => await harness.StartRunAsync(" ").ConfigureAwait(false); + + await act.Should().ThrowAsync(); + } + + [TestMethod] + [Description("Regression guard: verifies a cancellation callback that throws while the run unwinds is reported on the run's diagnostics rather than swallowed. Until now this was only provable through internal APIs a package consumer cannot reach, which is the gap this toolkit exists to close.")] + public async Task When_ACancellationCallbackThrows_Then_TheRunReportsIt() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var harness = ReplProcessSignalHarness.Create(() => + { + var app = CreateApp(configure: null); + app.Map("work", async (CancellationToken cancellationToken) => + { + // Registered on the signal-linked token, so the failure happens on the cancellation path + // the signal drives, not on an unrelated one. + using var registration = cancellationToken.Register( + static () => throw new InvalidOperationException("callback refused to unwind")); + started.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + return "unreachable"; + }); + + return app; + }); + + var run = await harness.StartRunAsync("work"); + await started.Task; + harness.SendSignal(ReplProcessSignal.Interrupt); + var result = await run.Completion; + + result.DiagnosticText.Should().Contain("callback refused to unwind"); + } + + [TestMethod] + [Description("Regression guard: verifies a run that produced its own outcome keeps it when a signal lands, rather than having it replaced by the interruption code. This is the precedence rule the framework calls IsInterruptible, and it decides whether a command's reported failure survives a Ctrl+C that arrives while it renders.")] + public async Task When_TheRunProducedItsOwnFailure_Then_TheSignalDoesNotRelabelIt() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var harness = ReplProcessSignalHarness.Create(() => + { + var app = CreateApp(configure: null); + app.Map("work", async (CancellationToken cancellationToken) => + { + started.TrySetResult(); + // Waits for the signal, then ends with its own explicit code instead of propagating. + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Deliberately swallowed: the point is a run that resolves its own outcome. + } + + return Results.Exit(7); + }); + + return app; + }); + + var run = await harness.StartRunAsync("work"); + await started.Task; + harness.SendSignal(ReplProcessSignal.Interrupt); + var result = await run.Completion; + + result.ExitCode.Should().Be(7, because: "a run that resolved its own outcome outranks the interruption"); + result.OutcomeKind.Should().NotBe(ReplExecutionOutcomeKind.Interrupted); + } + + [TestMethod] + [Description("Regression guard: verifies a run nothing ever signals fails on its timeout instead of hanging the suite. The timeout is the only thing that turns a signal that never arrived into a failing test rather than a stuck one, and it is also what makes disposal terminate.")] + public async Task When_NoSignalEverArrives_Then_TheRunTimesOut() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.RunTimeout = TimeSpan.FromMilliseconds(250)); + var run = await harness.StartRunAsync("work").ConfigureAwait(false); + var completion = run.Completion; + + // VSTHRD003 fires on returning a foreign task as well as awaiting one — "avoid awaiting or + // returning" — so a bare task-returning lambda does not avoid it either. The run was started by + // this test, two lines up. +#pragma warning disable VSTHRD003 + var act = async () => await completion.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + + await act.Should().ThrowAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Regression guard: verifies disposal releases process-signal ownership even when a run it is draining ended in failure. A run nobody signalled faults on its timeout, and without that being contained the exclusivity flag would stay set and the isolation never torn down — turning one failed test into every later harness in the suite refusing to start.")] + public async Task When_ARunFailsAndTheHarnessIsDisposed_Then_OwnershipIsStillReleased() + { + var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.RunTimeout = TimeSpan.FromMilliseconds(250)); + // Started and never signalled, so its completion faults and disposal has a failure to drain. + _ = await harness.StartRunAsync("work").ConfigureAwait(false); + + await harness.DisposeAsync().ConfigureAwait(false); + + var next = ReplProcessSignalHarness.Create(() => CreateEchoApp()); + try + { + var run = await next.StartRunAsync("echo").ConfigureAwait(false); + var completion = run.Completion; + + (await completion.ConfigureAwait(false)).ExitCode.Should().Be( + 0, + because: "the failed run must not have stranded ownership"); + } + finally + { + await next.DisposeAsync().ConfigureAwait(false); + } + } + + [TestMethod] + [Description("Exercises disposal overlapping a start, which without serialisation can enumerate the run list while it is being appended to. The interleaving is not deterministic, so this is a smoke guard rather than a proof; the invariant it pins — ownership always comes back — is asserted deterministically by When_ARunFailsAndTheHarnessIsDisposed_Then_OwnershipIsStillReleased.")] + public async Task When_DisposalRacesAStart_Then_OwnershipIsStillReleased() + { + var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.RunTimeout = TimeSpan.FromMilliseconds(250)); + try + { + var starting = Task.Run(async () => + { + try + { + _ = await harness.StartRunAsync("work").ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // Losing the race to disposal is a legitimate outcome; stranding ownership is not. + } + }); + + var disposing = harness.DisposeAsync().AsTask(); +#pragma warning disable VSTHRD003 // Both tasks are started here, in this method. + await Task.WhenAll(starting, disposing).ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + finally + { + await harness.DisposeAsync().ConfigureAwait(false); + } + + // The real assertion: ownership came back, so the suite can keep going. + var next = ReplProcessSignalHarness.Create(() => CreateEchoApp()); + try + { + var run = await next.StartRunAsync("echo").ConfigureAwait(false); + var completion = run.Completion; + + (await completion.ConfigureAwait(false)).ExitCode.Should().Be(0); + } + finally + { + await next.DisposeAsync().ConfigureAwait(false); + } + } + + [TestMethod] + [Description("Regression guard: verifies SIGTERM is left to the operating-system default on a declared platform that wires no SIGTERM registration. On Windows the framework installs none, so a harness that claimed anyway would let a cross-platform test assert a cancellation that could never happen on the platform it names.")] + [DataRow(false, ReplSignalDelivery.CancellationRequested, DisplayName = "Declared Unix: SIGTERM is wired and claims")] + [DataRow(true, ReplSignalDelivery.NotHandled, DisplayName = "Declared Windows: SIGTERM is not wired")] + public async Task When_TerminateIsDelivered_Then_OnlyADeclaredPlatformThatWiresItClaims( + bool declareWindows, + ReplSignalDelivery expected) + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.Platform = declareWindows + ? ReplPlatformProfile.Windows + : ReplPlatformProfile.Unix); + var run = await harness.StartRunAsync("work"); + + harness.SendSignal(ReplProcessSignal.Terminate).Should().Be(expected); + + // Release the run either way, so a broken case fails on the assertion above rather than on a + // harness blocking until its timeout. + harness.SendSignal(ReplProcessSignal.Interrupt); + await run.Completion; + } + + [TestMethod] + [Description("Regression guard: verifies a run whose command swallows the timeout cancellation still fails the test. The application returns normally in that case, so nothing throws out of the run and the timeout would otherwise be reported as a successful result for a signal that never arrived.")] + public async Task When_TheCommandSwallowsTheTimeout_Then_TheRunStillFails() + { + await using var harness = ReplProcessSignalHarness.Create( + () => + { + var app = CreateApp(configure: null); + app.Map("work", async (CancellationToken cancellationToken) => + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Swallowed on purpose: this is the application shape the guard exists for. + } + + return "finished anyway"; + }); + + return app; + }, + options => options.RunTimeout = TimeSpan.FromMilliseconds(250)); + var run = await harness.StartRunAsync("work").ConfigureAwait(false); + var completion = run.Completion; + +#pragma warning disable VSTHRD003 // The run was started by this test, two lines up. + var act = async () => await completion.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + + await act.Should().ThrowAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Regression guard: verifies an unrelated automatic run started WHILE a harness owns signal handling is refused. A snapshot taken at construction cannot see this one: it registers afterwards, joins the harness's isolated epoch, releases its readiness wait and is cancelled by its synthetic signals. Failing that run loudly is the point — it is concurrent with a signal test, which the harness documentation forbids.")] + public async Task When_AnUnrelatedRunStartsWhileOwned_Then_ItIsRefused() + { + await using var harness = ReplProcessSignalHarness.Create(() => CreateBlockingApp()); + var run = await harness.StartRunAsync("work"); + + var unrelated = CreateEchoApp(); + var act = async () => await unrelated.RunAsync( + ["echo"], + new ReplRunOptions { ProcessSignalHandling = ProcessSignalHandlingMode.Automatic }) + .ConfigureAwait(false); + + await act.Should().ThrowAsync() + .WithMessage("*owns signal handling*").ConfigureAwait(false); + + harness.SendSignal(ReplProcessSignal.Interrupt); + await run.Completion.ConfigureAwait(false); + } + + [TestMethod] + [Description("Regression guard: verifies a harness is refused while an unrelated run with automatic signal handling is already in flight. Taking ownership tears down that run's registrations without removing its scope, so the first signal this harness delivered would cancel a run it has nothing to do with.")] + public async Task When_AnUnrelatedRunIsInFlight_Then_CreatingAHarnessIsRefused() + { + await using var owner = ReplProcessSignalHarness.Create(() => CreateBlockingApp()); + var run = await owner.StartRunAsync("work"); + + var second = () => ReplProcessSignalHarness.Create(() => CreateEchoApp()); + + second.Should().Throw(); + + owner.SendSignal(ReplProcessSignal.Interrupt); + await run.Completion; + } + + [TestMethod] + [Description("Regression guard: verifies a harness run is a standalone invocation, not a hosted session. The capture session decides the runtime channel, and defaulting it to hosted makes every run take the Session channel: commands gated to the CLI channel vanish and handlers see IsHostedSession true, so a signal test would exercise a different application path from the process-owning invocation it claims to model.")] + public async Task When_ARunExecutes_Then_ItIsAStandaloneInvocationNotAHostedSession() + { + await using var harness = ReplProcessSignalHarness.Create(() => + { + var app = CreateApp(configure: null); + app.Map("where", (IReplIoContext io) => io.IsHostedSession ? "hosted" : "standalone"); + return app; + }); + + var run = await harness.StartRunAsync("where"); + var result = await run.Completion; + + result.ExitCode.Should().Be(0); + result.OutputText.Should().Contain( + "standalone", + because: "the harness models a process-owning run, which is not a hosted session"); + } + + [TestMethod] + [Description("Regression guard: verifies the timeout holds against a command that ignores its cancellation token. CancelAfter only requests cancellation, so a handler that never observes the token blocks forever; without a wall-clock bound the promised TimeoutException never arrives and disposal hangs draining the run, which is exactly the stuck suite the timeout exists to prevent.")] + public async Task When_TheCommandIgnoresCancellation_Then_TheTimeoutStillFires() + { + var release = new SemaphoreSlim(initialCount: 0, maxCount: 1); + var harness = ReplProcessSignalHarness.Create( + () => + { + var app = CreateApp(configure: null); + // Deliberately uncooperative: no CancellationToken parameter at all. + app.Map("work", async () => + { + await release.WaitAsync(CancellationToken.None).ConfigureAwait(false); + return "released"; + }); + + return app; + }, + options => options.RunTimeout = TimeSpan.FromMilliseconds(250)); + try + { + var run = await harness.StartRunAsync("work").ConfigureAwait(false); + var completion = run.Completion; + +#pragma warning disable VSTHRD003 // The run was started by this test, two lines up. + var act = async () => await completion.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + + await act.Should().ThrowAsync().ConfigureAwait(false); + } + finally + { + // Let the abandoned run finish so disposal is not reporting a leak this test created. + release.Release(); + await harness.DisposeAsync().ConfigureAwait(false); + release.Dispose(); + } + } + + [TestMethod] + [Description("Regression guard: verifies a registration arriving from a start that already gave up does not release a different start. The abandoned launch keeps running and registers late; pairing that notification with whichever start is waiting by then would return a run to the caller before it joined the epoch, so the next signal would cancel the abandoned run and miss the one just handed over.")] + public async Task When_AnAbandonedStartRegistersLate_Then_ItDoesNotReleaseAnotherStart() + { + using var releaseFirstFactory = new SemaphoreSlim(initialCount: 0, maxCount: 1); + using var releaseSecondFactory = new SemaphoreSlim(initialCount: 0, maxCount: 1); + var secondFactoryEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factoryCalls = 0; + + var harness = ReplProcessSignalHarness.Create( + () => + { + // Blocking here is what makes a start give up before its run ever reaches the coordinator. + var call = Interlocked.Increment(ref factoryCalls); + if (call == 1) + { + releaseFirstFactory.Wait(); + } + else if (call == 2) + { + secondFactoryEntered.TrySetResult(); + releaseSecondFactory.Wait(); + } + + return CreateEchoApp(); + }, + // Generous, so the second start is still well inside its own budget while it waits for the + // first launch to register: the point is which start gets released, not which times out. + options => options.RunTimeout = TimeSpan.FromSeconds(2)); + try + { + var firstStart = async () => await harness.StartRunAsync("echo").ConfigureAwait(false); + await firstStart.Should().ThrowAsync().ConfigureAwait(false); + + // The second start arms its own wait and then blocks in its factory, so it is outstanding when + // the abandoned first launch finally registers. + var secondStart = Task.Run(async () => await harness.StartRunAsync("echo").ConfigureAwait(false)); + await secondFactoryEntered.Task.ConfigureAwait(false); + + releaseFirstFactory.Release(); + var releasedEarly = await Task.WhenAny(secondStart, Task.Delay(TimeSpan.FromMilliseconds(300))) + .ConfigureAwait(false); + + releasedEarly.Should().NotBeSameAs( + secondStart, + because: "the first launch's late registration belongs to the start that gave up, not this one"); + + releaseSecondFactory.Release(); + var second = await secondStart.ConfigureAwait(false); + await second.Completion.ConfigureAwait(false); + } + finally + { + releaseFirstFactory.Release(); + releaseSecondFactory.Release(); + await harness.DisposeAsync().ConfigureAwait(false); + } + } + + [TestMethod] + [Description("Regression guard: verifies a signal that would reach an interactive session instead of the run under test fails loudly, and that SIGTERM is unaffected because it never goes through console-key arbitration. Console cancel-key selection is exclusive — an interactive handler takes Ctrl+C in place of the standalone ones — so reporting that delivery as handled would give a green test asserting a cancellation that never touched the run it names. Declares Unix so both halves mean the same thing on every host.")] + public async Task When_AnInteractiveSessionOwnsTheConsoleKeys_Then_DeliveryIsRefused() + { + await using var harness = ReplProcessSignalHarness.Create( + () => CreateBlockingApp(), + options => options.Platform = ReplPlatformProfile.Unix); + var run = await harness.StartRunAsync("work"); + + using (new CancelKeyHandler()) + { + var act = () => harness.SendSignal(ReplProcessSignal.Interrupt); + + act.Should().Throw() + .WithMessage("*interactive session*"); + + // SIGTERM never goes through console-key arbitration, so an interactive owner does not stand + // between it and the run — it claims, which is also what releases the run below. + harness.SendSignal(ReplProcessSignal.Terminate) + .Should().Be(ReplSignalDelivery.CancellationRequested); + } + + (await run.Completion).OutcomeKind.Should().Be(ReplExecutionOutcomeKind.Interrupted); + } + + private static ReplApp CreateBlockingApp( + TaskCompletionSource? started = null, + TaskCompletionSource? cleanedUp = null, + SemaphoreSlim? cleanupGate = null, + Action? configure = null) + { + var app = CreateApp(configure); + app.Map("work", async (CancellationToken cancellationToken) => + { + started?.TrySetResult(); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + return "unreachable"; + } + finally + { + if (cleanupGate is not null) + { + await cleanupGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + } + + cleanedUp?.TrySetResult(); + } + }); + + return app; + } + + private static ReplApp CreateEchoApp() + { + var app = CreateApp(configure: null); + app.Map("echo", () => "echoed"); + return app; + } + + private static ReplApp CreateApp(Action? configure) + { + var app = ReplApp.Create(); + app.Options(options => + { + options.Output.BannerEnabled = false; + options.Interactive.InteractivePolicy = InteractivePolicy.Prevent; + configure?.Invoke(options); + }); + + return app; + } +} diff --git a/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs b/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs index dcdb2bca..32c4e314 100644 --- a/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs +++ b/src/Repl.IntegrationTests/ShellCompletionTestHostRunner.cs @@ -125,7 +125,7 @@ private static void EnsureExitedWithinTimeout(Process process, Func read + $"{Environment.NewLine}Captured output:{Environment.NewLine}{readOutput()}"); } - private static string ResolveHostExecutablePath() + internal static string ResolveHostExecutablePath() { var root = ResolveRepositoryRoot(); var configuration = ResolveBuildConfiguration(); diff --git a/src/Repl.ShellCompletionTestHost/Program.cs b/src/Repl.ShellCompletionTestHost/Program.cs index 76026d06..c49ab3d5 100644 --- a/src/Repl.ShellCompletionTestHost/Program.cs +++ b/src/Repl.ShellCompletionTestHost/Program.cs @@ -57,9 +57,13 @@ private static void ConfigureScenario(ReplApp app, string? scenario) private static void ConfigureProcessSignalScenario(ReplApp app) { app.UseCliProfile(); - app.Map("wait {marker}", async (string marker, CancellationToken cancellationToken) => + app.Map("wait {marker}", async (string marker, IReplIoContext io, CancellationToken cancellationToken) => { await File.WriteAllTextAsync(marker, "READY\n", CancellationToken.None).ConfigureAwait(false); + // Also on the session's own output, for a caller watching the stream rather than the file. + // The file stays because it is the only way to observe what happened during a shutdown the + // process may not survive long enough to flush. + await io.Output.WriteLineAsync("READY").ConfigureAwait(false); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); diff --git a/src/Repl.Testing/CommandExecution.cs b/src/Repl.Testing/CommandExecution.cs index ac153965..96943dec 100644 --- a/src/Repl.Testing/CommandExecution.cs +++ b/src/Repl.Testing/CommandExecution.cs @@ -30,24 +30,63 @@ internal CommandExecution( CompletedAtUtc = completedAtUtc; } + /// + /// The command line as it was handed to , + /// before tokenization and before any prefilled answers were appended. + /// public string CommandText { get; } + /// + /// The process-style status the run resolved to, through the application's own + /// policy rather than a value this toolkit chooses. + /// public int ExitCode { get; } + /// + /// Everything the command wrote, with ANSI escape sequences stripped unless + /// was turned off. + /// public string OutputText { get; } + /// + /// The normalized handler result, or when the command produced none. + /// Prefer or over casting this yourself. + /// public object? ResultObject { get; } + /// + /// The semantic interaction events the command raised — statuses, notices, warnings, problems — + /// in the order they were observed. + /// public IReadOnlyList InteractionEvents { get; } + /// + /// The command's events: everything it wrote as a single event, then each interaction it raised in + /// the order it was observed, then the result it produced. + /// + /// The output event is a single aggregate of everything the command wrote, and it always comes + /// first — it is not interleaved with the interactions. An assertion that a particular line was + /// written before or after a given interaction is asserting this shape, not an observed chronology. + /// + /// public IReadOnlyList TimelineEvents { get; } + /// When the command started, in UTC. public DateTimeOffset StartedAtUtc { get; } + /// When the command finished, in UTC. public DateTimeOffset CompletedAtUtc { get; } + /// How long the command took, measured across the whole execution. public TimeSpan Duration => CompletedAtUtc - StartedAtUtc; + /// + /// Reads as , reporting failure instead of + /// throwing when the command produced no result or produced a different type. + /// + /// The type the result is expected to have. + /// The typed result when this returns . + /// when the result was available as . public bool TryGetResult([NotNullWhen(true)] out T? result) { if (ResultObject is T typed) @@ -60,11 +99,26 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) return false; } + /// + /// Reads as , failing the test outright when the + /// command produced no result or produced a different type. + /// + /// The type the result is expected to have. + /// The typed result. + /// The result is not available as . public T GetResult() => ResultObject is T typed ? typed : throw new InvalidOperationException($"Command result is not available as '{typeof(T).FullName}'."); + /// + /// Deserializes as JSON. Asserts on what the command actually rendered, so + /// it needs the command to have produced JSON — typically through --output:json. + /// + /// The type to deserialize the output into. + /// The deserialized value. + /// The output deserialized to . + /// The output is not valid JSON for . [RequiresUnreferencedCode("JSON deserialization of arbitrary T may require preserved metadata when trimming.")] public T ReadJson() { diff --git a/src/Repl.Testing/README.md b/src/Repl.Testing/README.md index 66e91d2a..0bde142d 100644 --- a/src/Repl.Testing/README.md +++ b/src/Repl.Testing/README.md @@ -4,6 +4,11 @@ `Repl.Testing` is an in-memory harness for **multi-step** and **multi-session** tests over a Repl command surface. +It also covers the process-signal lifecycle: `ReplProcessSignalHarness` drives SIGINT, SIGTERM and +Ctrl+Break in memory — deterministically, and for any platform's decisions from any host — and +`ReplProcessProbe` spawns your application to show what only a real process can, that it terminates +with the code a shell sees. + ## Install ```bash @@ -31,3 +36,4 @@ var execution = await session.RunCommandAsync("hello --no-logo"); - [Cookbook: Testing](https://repl.yllibed.org/cookbook/testing/) — test host setup, typed assertions, multi-session, interaction supply - [Best Practices](https://repl.yllibed.org/reference/best-practices/) — test-first patterns and testing at the command level +- [Testing toolkit: process signals](https://repl.yllibed.org/cookbook/testing/#process-signals) — signal tests, declaring a platform, and what needs a spawned process diff --git a/src/Repl.Testing/Repl.Testing.csproj b/src/Repl.Testing/Repl.Testing.csproj index bd03f0a2..5a47a98a 100644 --- a/src/Repl.Testing/Repl.Testing.csproj +++ b/src/Repl.Testing/Repl.Testing.csproj @@ -2,7 +2,10 @@ net10.0 - false + + true In-memory multi-session testing toolkit for Repl applications. README.md diff --git a/src/Repl.Testing/ReplPlatformProfile.cs b/src/Repl.Testing/ReplPlatformProfile.cs new file mode 100644 index 00000000..7640ec55 --- /dev/null +++ b/src/Repl.Testing/ReplPlatformProfile.cs @@ -0,0 +1,106 @@ +namespace Repl.Testing; + +/// +/// The platform whose signal-handling decisions a test wants to exercise, independent of the platform +/// it is running on. Declaring one lets a Windows wiring decision be asserted from Linux and the other +/// way round. +/// +/// A declared platform changes decisions only, and never causes an operating-system registration to be +/// installed on that platform's behalf. One consequence is worth knowing: a profile with no signal +/// bridge — , , , — +/// installs no console cancel-key handler either, so while such a run is in flight a real Ctrl+C aimed +/// at your test runner takes its normal course instead of being claimed by the run. Under the other +/// profiles it is claimed, as it would be for any standalone run. +/// +/// +/// What a declared platform cannot buy: real delivery. No kernel will deliver a Windows console +/// control event on Linux, and whether a registration would succeed on a given host is a fact about +/// that host. Those need a spawned process on the matching platform. +/// +/// +/// Use the named profiles. The flags are readable so a test can assert on them, but not settable, so +/// that combinations no device has — Android and Windows at once — cannot be built by mistake. +/// +/// +public sealed record ReplPlatformProfile +{ + // Named profiles only: the flags describe real platforms, and an object initialiser would let a + // caller build combinations no device has. + private ReplPlatformProfile() + { + } + + /// + /// The platform the test is actually running on, taken from . + /// + public static ReplPlatformProfile Current { get; } = new() + { + IsWindows = OperatingSystem.IsWindows(), + IsAndroid = OperatingSystem.IsAndroid(), + IsBrowser = OperatingSystem.IsBrowser(), + IsIOSOrMacCatalyst = OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst(), + IsTvOS = OperatingSystem.IsTvOS(), + }; + + /// + /// Windows: Ctrl+Break counts as a signal, and no SIGTERM registration is installed because the + /// console keys are already owned by the cancel-key path. + /// + public static ReplPlatformProfile Windows { get; } = new() { IsWindows = true }; + + /// + /// Linux or macOS: Ctrl+Break is not a signal, and SIGTERM is registered. Both make the same + /// decisions here, so they share one profile rather than pretending to differ. + /// + public static ReplPlatformProfile Unix { get; } = new(); + + /// Android, where the signal bridge is unsupported and handling stays caller-owned. + public static ReplPlatformProfile Android { get; } = new() { IsAndroid = true }; + + /// WebAssembly in a browser, where the signal bridge is unsupported. + public static ReplPlatformProfile Browser { get; } = new() { IsBrowser = true }; + + /// iOS or Mac Catalyst, where the signal bridge is unsupported. + public static ReplPlatformProfile IOS { get; } = new() { IsIOSOrMacCatalyst = true }; + + /// tvOS, where the signal bridge is unsupported. + public static ReplPlatformProfile TvOS { get; } = new() { IsTvOS = true }; + + /// Whether the declared platform is Windows. + public bool IsWindows { get; internal init; } + + /// Whether the declared platform is Android. + public bool IsAndroid { get; internal init; } + + /// Whether the declared platform is WebAssembly in a browser. + public bool IsBrowser { get; internal init; } + + /// Whether the declared platform is iOS or Mac Catalyst. + public bool IsIOSOrMacCatalyst { get; internal init; } + + /// Whether the declared platform is tvOS. + public bool IsTvOS { get; internal init; } + + /// + /// Whether the signal bridge is available at all on the declared platform. When it is not, + /// automatic handling degrades to caller-owned and says so once through the run's diagnostics. + /// + public bool IsSignalBridgeSupported => + ProcessSignalCoordinator.IsSignalBridgeSupportedForTesting( + IsAndroid, + IsBrowser, + IsIOSOrMacCatalyst, + IsTvOS); + + // CreateRealRegistrations is deliberately never set from here: a declared platform must not install + // a live operating-system handler in the test runner's process, and .NET would accept one. + internal ProcessSignalCoordinator.SignalRegistrationPolicy ToPolicy() => + new() + { + IsWindows = IsWindows, + IsAndroid = IsAndroid, + IsBrowser = IsBrowser, + IsIOSOrMacCatalyst = IsIOSOrMacCatalyst, + IsTvOS = IsTvOS, + }; +} diff --git a/src/Repl.Testing/ReplProcessProbe.cs b/src/Repl.Testing/ReplProcessProbe.cs new file mode 100644 index 00000000..2620d058 --- /dev/null +++ b/src/Repl.Testing/ReplProcessProbe.cs @@ -0,0 +1,442 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace Repl.Testing; + +/// +/// Spawns an application and sends it real operating-system signals, for the guarantees an in-memory +/// test cannot reach: that the process actually terminates, with the exit code the shell sees, after +/// the cleanup it was given time to run. +/// +/// Use for everything else. It is deterministic, fast, and +/// covers every decision the framework makes. This one exists for the part that is only true of a +/// real process, and it is correspondingly slower and platform-bound. +/// +/// +/// Signals are sent on Unix only. Delivering one to another process on Windows needs console +/// control events and console attachment rather than a signal, which this deliberately does not do — +/// see issue #83. throws +/// there, and the rest of the probe still works, so a cross-platform suite can spawn and assert +/// everywhere and skip only the delivery. +/// +/// +public sealed class ReplProcessProbe : IAsyncDisposable +{ + private const int SigInt = 2; + private const int SigTerm = 15; + + // How long to let the asynchronous readers settle after the child exits. Bounded on purpose: the + // blocking drain waits for end-of-stream, and a descendant that inherited the child's redirected + // handles keeps the stream open for as long as it lives — so waiting for EOF can wait forever, in + // the one method whose entire job is to honour a deadline. + private static readonly TimeSpan ExitDrainGrace = TimeSpan.FromMilliseconds(500); + + // Real time against a real process: there is no clock to fake when the thing being waited on is an + // operating-system process, so the system provider is passed explicitly rather than left implicit. + private static readonly TimeProvider Clock = TimeProvider.System; + + private readonly Process _process; + private readonly OutputCapture _capture; + private readonly ReplProcessProbeOptions _options; + private bool _disposed; + + private ReplProcessProbe(Process process, OutputCapture capture, ReplProcessProbeOptions options) + { + _process = process; + _capture = capture; + _options = options; + } + + /// The child's process id, which is what a signal is addressed to. + public int ProcessId => _process.Id; + + /// + /// Everything the child has written so far, standard output and standard error interleaved in the + /// order they were read. + /// + /// Output is drained continuously rather than at exit, so a child that fills its pipe is never + /// blocked by this probe. What a killed child never got to flush is gone, though: to observe what + /// happened during a shutdown that may not complete, have the application append to a file and + /// read that instead. + /// + /// + public string Output => _capture.Read(); + + /// + /// Starts the process with its output drained from the moment it starts. + /// + /// The executable to run. + /// Arguments, passed without shell interpretation. + /// Adjusts the probe options. + /// A running probe. Dispose it to make sure nothing is left behind. + /// is empty or whitespace. + /// The process could not be started. + public static ReplProcessProbe Start( + string fileName, + IEnumerable? arguments = null, + Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + var options = new ReplProcessProbeOptions(); + configure?.Invoke(options); + + var process = new Process { StartInfo = CreateStartInfo(fileName, arguments, options) }; + var capture = new OutputCapture(); + process.OutputDataReceived += (_, e) => capture.Append(e.Data); + process.ErrorDataReceived += (_, e) => capture.Append(e.Data); + try + { + if (!process.Start()) + { + throw new InvalidOperationException($"Failed to start '{fileName}'."); + } + } + catch + { + process.Dispose(); + throw; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + // Closed so a child that reads standard input sees end-of-input instead of waiting for a test + // that is never going to write anything. + process.StandardInput.Close(); + return new ReplProcessProbe(process, capture, options); + } + + /// + /// Waits until the child has written , which is how a test knows the + /// application has reached the point worth signalling rather than guessing with a delay. + /// + /// + /// The text to wait for. Redirected output is read a line at a time, so the child has to terminate + /// the marker with a newline — Console.WriteLine does, a bare Write does not, and a + /// marker left unterminated is not seen until the stream closes. + /// + /// Cancels the wait. + /// is empty or whitespace. + /// The child exited before writing it. + /// It did not appear within . + public async ValueTask WaitForOutputAsync(string expected, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expected); + ThrowIfDisposed(); + + var deadline = Clock.GetUtcNow() + _options.Timeout; + while (Clock.GetUtcNow() < deadline) + { + if (_capture.Read().Contains(expected, StringComparison.Ordinal)) + { + return; + } + + if (_process.HasExited) + { + // Exiting does not mean the capture is complete: the last line may still be sitting in an + // asynchronous callback. Let it settle, then look again before calling this a failure. + await DrainAfterExitAsync(deadline, cancellationToken).ConfigureAwait(false); + if (_capture.Read().Contains(expected, StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + Describe($"exited with code {_process.ExitCode} before writing '{expected}'")); + } + + await Task.Delay(TimeSpan.FromMilliseconds(25), Clock, cancellationToken).ConfigureAwait(false); + } + + // One last look before giving up: text written during the final delay lands after the loop + // condition was evaluated, and rejecting it would fail a wait whose own error message contains + // the very marker it says never arrived. + if (_capture.Read().Contains(expected, StringComparison.Ordinal)) + { + return; + } + + throw new TimeoutException(Describe($"did not write '{expected}' within {_options.Timeout}")); + } + + /// + /// Delivers a real signal to the child through kill. + /// + /// The signal to send. has no Unix equivalent and is refused. + /// + /// The child is checked for having exited before the signal is sent, but the two cannot be made + /// atomic without a process handle the operating system keeps alive: if the child exits in between + /// and its id is reused, the signal reaches whatever inherited that id. The window is small and the + /// check narrows it, but on a busy host it is not zero — the same limitation that applies to killing + /// a process tree during disposal. + /// + /// Cancels waiting for the sender to finish. + /// Running on Windows, or is . + /// is not a known signal. + /// The child had already exited, or kill refused the signal. + public async ValueTask SendSignalAsync( + ReplProcessSignal signal, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "Sending a signal to another process on Windows needs a console control event and " + + "console attachment rather than a signal, which this probe deliberately does not do. " + + "Use ReplProcessSignalHarness to assert the framework's decisions on Windows; see " + + "issue #83 for the Windows lifecycle work."); + } + + var number = signal switch + { + ReplProcessSignal.Interrupt => SigInt, + ReplProcessSignal.Terminate => SigTerm, + ReplProcessSignal.Break => throw new PlatformNotSupportedException( + "Ctrl+Break is a Windows console event with no Unix signal the framework treats the " + + "same way. Deliver it in memory with ReplProcessSignalHarness instead."), + _ => throw new ArgumentOutOfRangeException(nameof(signal), signal, "Unknown process signal."), + }; + + // Signalling a process that has already exited would either do nothing or, once the id is + // reused, reach something else entirely. + if (_process.HasExited) + { + throw new InvalidOperationException( + Describe($"had already exited with code {_process.ExitCode} when {signal} was sent")); + } + + await SendAsync(number, cancellationToken).ConfigureAwait(false); + } + + /// + /// Waits for the child to exit and returns its exit code — the one a shell would report. + /// + /// Cancels the wait. + /// The exit code. + /// It did not exit within . + public async ValueTask WaitForExitAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + try + { + await _process.WaitForExitAsync(cancellationToken) + .WaitAsync(_options.Timeout, Clock, cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new TimeoutException(Describe($"did not exit within {_options.Timeout}"), ex); + } + + // Settles the asynchronous readers so everything the child wrote is in Output by the time the exit + // code is read — bounded, for the same reason as the drain in WaitForOutputAsync. + await DrainAfterExitAsync(Clock.GetUtcNow() + _options.Timeout, cancellationToken).ConfigureAwait(false); + return _process.ExitCode; + } + + /// + /// Kills the child and its descendants if the child is still running, then releases the process. + /// A test that asserted an exit already has nothing left to kill; this is what keeps a failed + /// assertion from leaking a process into the rest of the suite. + /// + /// The tree is reachable only while its root is: a child that spawned something long-lived and then + /// exited on its own leaves that descendant running, because there is no longer a parent to walk + /// down from. Holding descendants beyond the parent needs a job object or a process group, which is + /// platform-specific and deliberately not done here — so a probed application that forks background + /// work has to clean up after itself. Walking the tree has the same residual risk as signalling: + /// .NET's Unix implementation matches descendants by process id without a start-time check, so an id + /// reused before cleanup runs belongs to whoever inherited it. + /// + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (!_process.HasExited) + { + TryKill(_process); + await _process.WaitForExitAsync().ConfigureAwait(false); + } + + _process.Dispose(); + } + + private static ProcessStartInfo CreateStartInfo( + string fileName, + IEnumerable? arguments, + ReplProcessProbeOptions options) + { + var startInfo = new ProcessStartInfo(fileName) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + if (arguments is not null) + { + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + } + + foreach (var pair in options.Environment) + { + startInfo.Environment[pair.Key] = pair.Value; + } + + if (options.WorkingDirectory is { } workingDirectory) + { + startInfo.WorkingDirectory = workingDirectory; + } + + return startInfo; + } + + private async ValueTask SendAsync(int number, CancellationToken cancellationToken) + { + // An absolute path rather than a bare name. Started without a shell, a bare name is resolved + // through PATH, so a consumer's CI step that prepends a directory — a third-party action, say — + // decides which binary receives the signal. That is a trust decision this package should not be + // making on the caller's behalf. Falls back to the name only if neither standard location exists, + // so an unusual layout still works. + var startInfo = new ProcessStartInfo(ResolveKillPath()) + { + UseShellExecute = false, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add($"-{number.ToString(CultureInfo.InvariantCulture)}"); + startInfo.ArgumentList.Add(_process.Id.ToString(CultureInfo.InvariantCulture)); + + using var sender = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start 'kill' to deliver the signal."); + try + { + await sender.WaitForExitAsync(cancellationToken) + .WaitAsync(_options.Timeout, Clock, cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException ex) + { + // Disposing the sender would not stop it, and a bare timeout here would drop the captured + // output every other wait on this type promises. + TryKill(sender); + throw new TimeoutException(Describe($"was still being signalled when 'kill -{number}' timed out"), ex); + } + catch (OperationCanceledException) + { + // Same reasoning: the sender outlives its wrapper, so giving up on the wait without killing it + // leaves a helper process behind for whoever cancelled. + TryKill(sender); + throw; + } + if (sender.ExitCode == 0) + { + return; + } + + var error = await sender.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + $"'kill -{number}' failed with exit code {sender.ExitCode} for process {_process.Id}: {error}"); + } + + /// + /// Waits for the capture to stop growing, or for , whichever comes first. + /// + /// A poll rather than : that overload drains by waiting for + /// end-of-stream on the redirected handles, and a descendant that inherited them holds the stream + /// open until it exits — so the drain outlives the process it was draining, and every deadline above + /// it stops meaning anything. Settling on "nothing new arrived" gives up that certainty in exchange + /// for terminating, which is the trade a timeout exists to make. + /// + /// + private async Task DrainAfterExitAsync(DateTimeOffset deadline, CancellationToken cancellationToken) + { + var limit = Clock.GetUtcNow() + ExitDrainGrace; + if (limit > deadline) + { + limit = deadline; + } + + var previous = _capture.Read().Length; + var settled = 0; + while (Clock.GetUtcNow() < limit && settled < 2) + { + await Task.Delay(TimeSpan.FromMilliseconds(25), Clock, cancellationToken).ConfigureAwait(false); + var current = _capture.Read().Length; + settled = current == previous ? settled + 1 : 0; + previous = current; + } + } + + private static string ResolveKillPath() + { + foreach (var candidate in (string[])["/bin/kill", "/usr/bin/kill"]) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return "kill"; + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // It exited between the check and the kill. That is the outcome this wanted anyway, and + // turning a won race into a teardown failure would fail tests that had already passed. + } + } + + private string Describe(string what) => + $"The probed process ({_process.StartInfo.FileName}) {what}." + + $"{System.Environment.NewLine}Captured output:{System.Environment.NewLine}{_capture.Read()}"; + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + private sealed class OutputCapture + { + private readonly Lock _gate = new(); + private readonly StringBuilder _text = new(); + // Materialised once per change rather than once per read: WaitForOutputAsync reads every 25ms, + // and copying the whole buffer each time costs more the longer the child talks. + private string? _materialized; + + public void Append(string? line) + { + if (line is null) + { + return; + } + + lock (_gate) + { + _text.AppendLine(line); + _materialized = null; + } + } + + public string Read() + { + lock (_gate) + { + return _materialized ??= _text.ToString(); + } + } + } +} diff --git a/src/Repl.Testing/ReplProcessProbeOptions.cs b/src/Repl.Testing/ReplProcessProbeOptions.cs new file mode 100644 index 00000000..52803523 --- /dev/null +++ b/src/Repl.Testing/ReplProcessProbeOptions.cs @@ -0,0 +1,26 @@ +namespace Repl.Testing; + +/// +/// Options for one . +/// +public sealed class ReplProcessProbeOptions +{ + /// + /// How long any single wait may take — for expected output, for a signal to be delivered, or for + /// the process to exit — before it fails with a carrying everything + /// captured so far. Defaults to 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Environment variables to set on the child, on top of the ones it inherits. A + /// value removes an inherited variable. + /// + public IDictionary Environment { get; } = + new Dictionary(StringComparer.Ordinal); + + /// + /// The child's working directory. inherits the current one. + /// + public string? WorkingDirectory { get; set; } +} diff --git a/src/Repl.Testing/ReplProcessSignal.cs b/src/Repl.Testing/ReplProcessSignal.cs new file mode 100644 index 00000000..3297cebb --- /dev/null +++ b/src/Repl.Testing/ReplProcessSignal.cs @@ -0,0 +1,36 @@ +namespace Repl.Testing; + +/// +/// A process signal a test can deliver. Append-only: a new member may be added, but the meaning of an +/// existing one never changes. +/// +public enum ReplProcessSignal +{ + /// + /// Ctrl+C on a console, SIGINT on Unix. Carries the conventional 130. + /// + /// Goes through console cancel-key arbitration, so an interactive session that owns the console + /// keys handles it instead of the standalone signal bridge. + /// + /// + Interrupt, + + /// + /// SIGTERM. Carries the conventional 143. + /// + /// Unlike and , SIGTERM does not participate in the + /// interactive console-key priority rule: an interactive session does not shield a run from it. + /// + /// + Terminate, + + /// + /// Ctrl+Break, which is a signal only on Windows and is ignored elsewhere. Carries the same + /// 130 as ; only the operator-facing name differs. + /// + /// Whether it counts follows the platform declared in + /// , not the platform the test happens to run on. + /// + /// + Break, +} diff --git a/src/Repl.Testing/ReplProcessSignalHarness.cs b/src/Repl.Testing/ReplProcessSignalHarness.cs new file mode 100644 index 00000000..a557109d --- /dev/null +++ b/src/Repl.Testing/ReplProcessSignalHarness.cs @@ -0,0 +1,708 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Repl.Testing; + +/// +/// Drives the process-signal lifecycle deterministically, in memory, against an application under +/// test: a first signal claims the runs in flight and cancels them cooperatively, a second one steps +/// aside for the operating system. +/// +/// This is a sibling of rather than part of it. Sessions are isolated from +/// each other; signal handling is process-global, so a harness owns it for its lifetime and only one +/// can exist at a time. Dispose it — ownership is released there and nowhere else, so a harness +/// that is never disposed leaves every later run with automatic signal handling refused for the rest +/// of the process. Constructing a second one while the first is alive throws instead of +/// producing a flaky pair — configure your test framework not to run these in parallel: +/// MSTest [DoNotParallelize], xUnit a shared [Collection] or +/// [assembly: CollectionBehavior(DisableTestParallelization = true)], NUnit +/// [NonParallelizable]. Sharding across separate processes needs none of this. +/// +/// +/// What it proves: the framework's decision about each signal, the diagnostics it writes, the exit +/// code the run resolves to, and every platform wiring decision through +/// . What it cannot prove: that a process would +/// actually die. Nothing dies here, so a second signal reports +/// and execution continues. That half needs a +/// spawned process on the matching platform. +/// +/// +/// No operating-system signal registration is ever installed — no PosixSignalRegistration, +/// on any platform. What is not isolated, and cannot be, is the console cancel-key handler: starting a +/// run registers one, because arbitrating Ctrl+C between an interactive session and a standalone run is +/// part of what these tests exist to exercise, and the subscription behind it is installed once per +/// process and never removed by design. So while a run is in flight, a real Ctrl+C aimed at your +/// test runner is claimed by the run under test and the first press does not stop it — press again to +/// escalate. See for how a declared platform affects that. +/// +/// +public sealed class ReplProcessSignalHarness : IAsyncDisposable +{ + // Real elapsed time bounds a run that will not cooperate, so the provider is passed explicitly + // rather than left implicit. + private static readonly TimeProvider Clock = TimeProvider.System; + + private readonly Func _appFactory; + private readonly ReplProcessSignalOptions _options; + private readonly IDisposable _isolation; + private readonly IDisposable _ownership; + private readonly SemaphoreSlim _startGate = new(initialCount: 1, maxCount: 1); + private readonly List> _runs = []; + private readonly List> _bounded = []; + private readonly StringWriter _deliveryDiagnostics = new(); + private readonly ScopeRegistrationSignal _scopeRegistered; + private bool _disposed; + + /// + /// Everything the framework wrote about the signals delivered through this harness: the line naming + /// a claimed signal, and the one saying a later signal is being left to the operating system. + /// + /// These belong to the delivery, not to a run. A real signal callback runs on its own thread with + /// no session, so the framework's diagnostics reach the process's error stream rather than any + /// run's captured output — holds what the run + /// itself wrote, which is a different thing. + /// + /// + public string DiagnosticText => + _options.NormalizeAnsi + ? ReplTestText.NormalizeOutput(_deliveryDiagnostics.ToString()) + : _deliveryDiagnostics.ToString(); + + private ReplProcessSignalHarness( + Func appFactory, + ReplProcessSignalOptions options, + IDisposable isolation, + IDisposable ownership, + ScopeRegistrationSignal scopeRegistered) + { + _appFactory = appFactory; + _options = options; + _isolation = isolation; + _ownership = ownership; + _scopeRegistered = scopeRegistered; + } + + /// + /// Takes ownership of process-signal handling and returns a harness over an application factory. + /// The factory is invoked once per run, so each run gets its own application instance. + /// + /// Builds the application under test. + /// Adjusts the harness options. + /// A harness holding process-signal ownership until it is disposed. + /// is . + /// set to . + /// Another harness is already active in this process, or a run with automatic signal handling is already in flight. + public static ReplProcessSignalHarness Create( + Func appFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(appFactory); + var options = new ReplProcessSignalOptions(); + configure?.Invoke(options); + if (options.Platform is null) + { + throw new ArgumentException( + $"{nameof(ReplProcessSignalOptions)}.{nameof(ReplProcessSignalOptions.Platform)} cannot be set to null.", + nameof(configure)); + } + + // Taken atomically with the check that nothing else holds the epoch: a snapshot would let a run + // register in the gap, and taking ownership tears down registrations without removing the scopes + // using them — so that run would keep its place in the epoch and be cancelled by this harness's + // first signal. While the claim is held the coordinator refuses any run this harness did not + // launch, which is loud rather than quietly wrong. + // Console cancel-key selection is exclusive: an interactive session takes Ctrl+C instead of the + // standalone handlers, so a harness created alongside one would deliver Interrupt into that + // session and be told it was handled while its own run went untouched — a green test asserting + // something that never happened. + if (ConsoleCancelKeyCoordinator.HasInteractiveHandlersForTesting) + { + throw new InvalidOperationException( + "An interactive session currently owns the console cancel keys in this process. It takes " + + "Ctrl+C instead of a standalone run, so a signal delivered here would reach that session " + + "rather than the run under test: end the interactive session before creating the harness."); + } + + var ownership = ProcessSignalCoordinator.TryClaimTestOwnership() + ?? throw new InvalidOperationException( + "Process-signal handling in this process is already owned — either by another harness, or " + + "by a run with automatic signal handling that is still in flight. Only one owner at a " + + "time: let it finish, and configure your test framework not to run signal tests in " + + "parallel with anything that starts an automatic run."); + + try + { + // The readiness callback is handed to the isolation scope rather than parked on the + // coordinator, so all of this harness's reach into process-global state has one owner and one + // teardown — and cannot outlive the harness that installed it. + var scopeRegistered = new ScopeRegistrationSignal(); + var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( + registrationFault: options.RegistrationFault, + policy: options.Platform.ToPolicy(), + scopeRegisteredCallback: scopeRegistered.Signal); + return new ReplProcessSignalHarness(appFactory, options, isolation, ownership, scopeRegistered); + } + catch + { + ownership.Dispose(); + throw; + } + } + + /// + /// Starts a run and returns once its signal scope has joined the ownership epoch, so a signal + /// delivered afterwards reaches it instead of falling through as inert. + /// + /// That guarantee is about the signal, not about progress. The scope is installed around the whole + /// run, before its arguments are even parsed, so when this returns the command body has usually not + /// started yet. A test that needs the command to be executing — to assert its cleanup ran, say — + /// must have the command say so: complete a from inside the + /// handler and await it before delivering the signal. + /// + /// + /// The run keeps executing after this returns. Call it again to put a second run in flight — that + /// is what a late joiner is, and it is why starting is serialised while the runs are not. A late + /// joiner needs the earlier run to still hold its scope: once the last run of a claimed epoch + /// finishes, the epoch resets and the next signal is a first signal again. + /// + /// + /// The command line to run. Split on whitespace, with double quotes grouping — not a shell parser: single quotes are literal and escape sequences are not interpreted. + /// Cancels waiting for the run to register, and the run itself. + /// The run, still in flight. + /// is empty or whitespace. + /// The harness has been disposed. + /// The run finished without ever registering a signal scope, which means the application never took process-signal ownership. + public async ValueTask StartRunAsync( + string commandLine, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(commandLine); + ThrowIfDisposed(); + + // Serialised so the readiness signal below cannot be consumed by a concurrent start. The runs + // themselves stay concurrent; only their starts queue. + await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Re-checked: the first check happened before this wait, and disposal may have taken the gate + // in between. Starting now would run outside an isolation that has already been torn down. + ThrowIfDisposed(); + var runToken = new object(); + var registered = _scopeRegistered.Arm(runToken); + // Marked before the run is launched so the marker flows into the context where the run's + // ProcessSignalCancellationScope is constructed, letting the coordinator tell this harness's + // scopes from anybody else's. + using var owned = ProcessSignalCoordinator.MarkOwnedRunForTesting(runToken); + var completion = Task.Run( + () => ExecuteRunAsync(commandLine, cancellationToken), + CancellationToken.None); + // Both are retained. The raw task is what tells an abandoned run from a finished one during + // the drain; the wrapper is what the caller is handed, so it is also what can fault unobserved + // when a caller deliberately never awaits it — which one of this suite's own tests does. + var bounded = BoundByWallClockAsync(completion, commandLine); + _runs.Add(completion); + _bounded.Add(bounded); + + try + { + await WaitForRegistrationAsync( + registered.Task, + completion, + commandLine, + cancellationToken).ConfigureAwait(false); + } + catch + { + // This wait gave up on a registration that may still arrive. Drop the reservation so the + // late registration cannot be paired with a later start's wait instead. + _scopeRegistered.Abandon(runToken); + throw; + } + + return new ReplSignalRun(commandLine, bounded); + } + finally + { + _startGate.Release(); + } + } + + /// + /// Delivers a signal the way the operating system would, and reports what the framework decided. + /// + /// Synchronous on purpose: a signal callback owes the operating system a suppression decision + /// before it returns, so the framework decides synchronously and so does this. Await + /// to see what the decision did to a run. + /// + /// + /// The signal to deliver. + /// What the framework decided about this delivery. + /// The harness has been disposed. + /// is not a known signal. + public ReplSignalDelivery SendSignal(ReplProcessSignal signal) + { + ThrowIfDisposed(); + + // The framework writes its signal diagnostics from whichever context delivers the signal. In + // production that is an operating-system callback thread with no session of its own, so they + // reach the real console and belong to no run in particular — which is why they are captured + // here, on the harness, rather than folded into a run's result where they were never written. + var sessionId = $"signal-delivery-{Guid.NewGuid():N}"; + using var session = ReplSessionIO.SetSession( + TextWriter.Null, + TextReader.Null, + sessionId: sessionId, + commandOutput: TextWriter.Null, + error: _deliveryDiagnostics, + isHostedSession: false); + try + { + return Deliver(signal); + } + finally + { + ReplSessionIO.RemoveSession(sessionId); + } + } + + private ReplSignalDelivery Deliver(ReplProcessSignal signal) + { + // Interrupt and Break go through console cancel-key arbitration, so an interactive owner wins + // exactly as it does in production. Terminate has no such layer and reaches the claim directly: + // SIGTERM does not participate in the interactive console-key priority rule. That asymmetry is + // the framework's, so the routing is fixed rather than configurable — sending SIGTERM through + // the console path would fake a priority rule that does not exist. + var interactiveOwned = false; + var decision = signal switch + { + ReplProcessSignal.Interrupt => ConsoleCancelKeyCoordinator.HandleStandaloneCancelKeyForTesting( + ConsoleSpecialKey.ControlC, + _options.Platform.IsWindows, + out interactiveOwned), + ReplProcessSignal.Break => ConsoleCancelKeyCoordinator.HandleStandaloneCancelKeyForTesting( + ConsoleSpecialKey.ControlBreak, + _options.Platform.IsWindows, + out interactiveOwned), + // Gated on the platform in force actually wiring SIGTERM up. On a declared Windows profile the + // framework installs no SIGTERM registration, and on an unsupported one it installs nothing at + // all, so claiming here would have a cross-platform test assert a cancellation that could + // never happen on the platform it names. + ReplProcessSignal.Terminate when !ProcessSignalCoordinator.SigTermRegistrationDeclaredForTesting => + ConsoleCancelKeyHandlingResult.NotHandled, + ReplProcessSignal.Terminate => ProcessSignalCoordinator.HandleSigTermForTesting(), + _ => throw new ArgumentOutOfRangeException( + nameof(signal), + signal, + "Unknown process signal."), + }; + + // Reported, not returned as NotHandled: the delivery did not merely go unclaimed, it could not be + // made at all, and a test told "nothing happened" would go looking in the wrong place. + if (interactiveOwned) + { + throw new InvalidOperationException( + $"An interactive session owns the console cancel keys, so {signal} would reach it rather " + + "than the run under test. Only Terminate bypasses that selection, because SIGTERM does " + + "not participate in the interactive console-key priority rule."); + } + + return decision switch + { + ConsoleCancelKeyHandlingResult.SuppressProcessTermination => ReplSignalDelivery.CancellationRequested, + ConsoleCancelKeyHandlingResult.AllowProcessTermination => ReplSignalDelivery.WouldTerminateProcess, + _ => ReplSignalDelivery.NotHandled, + }; + } + + /// + /// Releases process-signal ownership. + /// + /// Waits for every run it started first. That is not politeness: a run still in flight still holds + /// a scope in the ownership epoch, and letting it outlive the harness would leak that epoch into + /// whatever runs next. is what guarantees the + /// wait ends, so a harness whose timeout is disabled and whose run was never released will block + /// here. + /// + /// + /// + /// One or more runs were still executing and could not be stopped — a command that never observes + /// its cancellation token cannot be interrupted. Ownership is released before this is raised, so the + /// next harness can still be created; it is reported because such a run still holds a place in the + /// process-wide signal epoch. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + var abandoned = 0; + var leakedScopes = 0; + try + { + // Taken so draining cannot enumerate the run list while a start is appending to it, and so a + // start already past its own gate check finishes registering before the callback goes away. + await _startGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + abandoned = await DrainRunsAsync().ConfigureAwait(false); + ObserveBoundedTasks(); + _runs.Clear(); + _bounded.Clear(); + } + finally + { + _startGate.Release(); + } + } + finally + { + // Read before ownership goes: a scope this harness never started — a command that launched its + // own automatic run, say — stays in the epoch, and every later harness is then refused with + // nothing saying why. Reported below rather than prevented, since nothing here can drain it. + leakedScopes = ProcessSignalCoordinator.ActiveScopeCountForTesting; + + // Unconditionally: anything thrown above would otherwise leave the exclusivity flag set and + // the coordinator isolated for the rest of the process, turning one failed disposal into + // every later harness in the suite refusing to start. + _isolation.Dispose(); + _startGate.Dispose(); + _deliveryDiagnostics.Dispose(); + _ownership.Dispose(); + } + + // Reported after ownership is released, so the next harness can still be created — but reported, + // because a run still holding a scope outlives the isolation that was just torn down, and every + // later signal test in this process inherits that epoch. + ReportWhatOutlivedTheHarness(abandoned, leakedScopes); + } + + // Raised after ownership is released, so the next harness can still be created. Reported at all + // because both states leave the process-wide epoch occupied by something this harness cannot drain, + // and the alternative is a later test failing for a reason nothing explains. + private static void ReportWhatOutlivedTheHarness(int abandoned, int leakedScopes) + { + if (abandoned > 0) + { + throw new InvalidOperationException( + $"{abandoned} run(s) were still executing when the harness was disposed and could not be " + + "stopped: a command that never observes its cancellation token cannot be interrupted. " + + "They still hold a place in the process-wide signal epoch, so later signal tests in " + + "this process may see cancellations they did not cause."); + } + + if (leakedScopes > 0) + { + throw new InvalidOperationException( + $"{leakedScopes} process-signal scope(s) outlived the harness without having been started " + + "through it — a command under test started its own run with automatic signal handling. " + + "The harness cannot drain what it did not start, and while those scopes hold the epoch " + + "every later harness in this process is refused."); + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Disposal exists to drain the ownership epoch before the next test. A run that failed or timed out has already reported that through its own Completion, which the test either awaited or chose not to; rethrowing it from a using block would replace the test's own failure with this one.")] + private async Task DrainRunsAsync() + { + var abandoned = 0; + foreach (var run in _runs) + { + try + { + _ = HasRunTimeout + ? await run.WaitAsync(DrainTimeout, Clock).ConfigureAwait(false) + : await run.ConfigureAwait(false); + } + catch (TimeoutException) when (run.IsCompleted) + { + // The run itself ended on its own timeout. That is a finished run reporting a failure the + // test has already seen, not one this drain gave up on. + } + catch (TimeoutException) + { + // Still running, and nothing here can stop it. Observe whatever it eventually produces so + // it does not resurface as an unobserved task exception, and report it below. + abandoned++; + _ = run.ContinueWith( + static observed => _ = observed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + catch (Exception) + { + // Intentionally observed and dropped; see the justification above. + } + } + + return abandoned; + } + + // Cancelling a run only asks it to stop. A command that never observes its token blocks forever, so + // the timeout has to be measured against the clock rather than against the run's cooperation — + // otherwise the one guarantee that keeps a signal test from hanging a suite is the one it cannot + // make. The run itself cannot be killed; it is abandoned, and disposal reports it. + private async Task BoundByWallClockAsync( + Task run, + string commandLine) + { +#pragma warning disable VSTHRD003 // Started by StartRunAsync, one frame up. + if (!HasRunTimeout) + { + return await run.ConfigureAwait(false); + } + + try + { + return await run.WaitAsync(_options.RunTimeout, Clock).ConfigureAwait(false); + } + catch (TimeoutException) when (!run.IsCompleted) + { + // Only when the wait is what gave up. A TimeoutException the run itself produced — a slow + // provider build, say — propagates untouched: replacing it would send the test after a + // deadline that never elapsed instead of the failure that actually happened. + throw CreateTimeoutException(commandLine); + } +#pragma warning restore VSTHRD003 + } + + private bool HasRunTimeout => ReplTestTimeout.IsEnabled(_options.RunTimeout); + + // Twice the run timeout, because draining can begin before a run's own timeout has elapsed and the + // run still has to unwind once it fires. A cooperative run therefore always finishes within this; + // only one that never observes its token is still here at the end, which is what it is measuring. + private TimeSpan DrainTimeout => _options.RunTimeout + _options.RunTimeout; + + // Every task handed to a caller is observed, whether or not the caller awaited it. A test that + // deliberately never reads Completion — because the run's outcome is not what it is asserting — must + // not leave a faulted task for TaskScheduler.UnobservedTaskException to raise later. + private void ObserveBoundedTasks() + { + foreach (var bounded in _bounded) + { + if (bounded.IsCompleted) + { + _ = bounded.Exception; + continue; + } + + _ = bounded.ContinueWith( + static observed => _ = observed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + + private async Task WaitForRegistrationAsync( + Task registered, + Task completion, + string commandLine, + CancellationToken cancellationToken) + { +#pragma warning disable VSTHRD003 // Both tasks were started by the caller one frame up, not handed in from elsewhere. + // Bounded here as well as around the run. The run's own timeout starts inside ExecuteRunAsync, + // after the application factory has returned — so a factory that blocks would otherwise hang this + // wait with nothing to stop it, and the token documented as cancelling it would do nothing. + var pending = Task.WhenAny(registered, completion); + _ = HasRunTimeout + ? await pending.WaitAsync(_options.RunTimeout, Clock, cancellationToken).ConfigureAwait(false) + : await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Which task WhenAny hands back is not the question — a run short enough to finish before this + // resumes has both of them complete, and picking the loser would fail a perfectly good start. + // The question is whether the scope ever joined the epoch. + if (registered.IsCompleted) + { + return; + } + + // It did not. Await the run first so a real failure is reported as itself; if it succeeded, the + // application never took process-signal ownership, which would otherwise show up only as every + // later delivery being silently inert. + _ = await completion.WaitAsync(cancellationToken).ConfigureAwait(false); +#pragma warning restore VSTHRD003 + throw new InvalidOperationException( + $"The run '{commandLine}' finished without registering a process-signal scope, so no signal " + + "could have reached it. The application under test did not take process-signal ownership: " + + "check that its profile or run options leave automatic handling enabled."); + } + + private async Task ExecuteRunAsync( + string commandLine, + CancellationToken cancellationToken) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + // The process-owning overload installs no session of its own, so without this the run writes + // straight to the real console and nothing it produced could be asserted. Its own id, removed + // afterwards, so a suite of signal tests does not accumulate session entries. + var sessionId = $"signal-run-{Guid.NewGuid():N}"; + var observer = new RunObserver(); + var app = _appFactory(); + int exitCode; + try + { + // isHostedSession decides the runtime channel: left at its default the run would take + // ReplRuntimeChannel.Session, hiding commands gated to the CLI channel and telling handlers + // they are in a hosted session. This harness models a process-owning standalone invocation, + // so it has to say so. + using (ReplSessionIO.SetSession( + output, + TextReader.Null, + sessionId: sessionId, + commandOutput: output, + error: error, + isHostedSession: false)) + { + app.Core.ExecutionObserver = observer; + using var timeout = ReplTestTimeout.CreateSource(_options.RunTimeout, cancellationToken); + exitCode = await RunAsync(app, commandLine, timeout, cancellationToken).ConfigureAwait(false); + } + } + finally + { + // The scope restores the ambient writers but does not remove an explicitly named session, so + // a run that failed would otherwise leave its entry in the process-wide dictionary forever. + ReplSessionIO.RemoveSession(sessionId); + } + + var outputText = output.ToString(); + var diagnosticText = error.ToString(); + if (_options.NormalizeAnsi) + { + outputText = ReplTestText.NormalizeOutput(outputText); + diagnosticText = ReplTestText.NormalizeOutput(diagnosticText); + } + + return new ReplSignalRunResult + { + ExitCode = exitCode, + OutcomeKind = observer.OutcomeKind, + OutputText = outputText, + DiagnosticText = diagnosticText, + }; + } + + private static async Task RunAsync( + ReplApp app, + string commandLine, + CancellationTokenSource? timeout, + CancellationToken cancellationToken) + { + int exitCode; + try + { + // The overload taking no IServiceProvider, IHost or IReplHost is the only one that installs + // the standalone signal bridge. Every other overload writes a diagnostic and drops + // ProcessSignalHandling, so routing this through one of them would leave every delivery + // inert with nothing but an unasserted line to show for it. + exitCode = await app.RunAsync( + ReplTestText.Tokenize(commandLine), + new ReplRunOptions { ProcessSignalHandling = ProcessSignalHandlingMode.Automatic }, + timeout?.Token ?? cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ReplTestTimeout.Expired(timeout, cancellationToken)) + { + throw CreateTimeoutException(commandLine); + } + finally + { + app.Core.ExecutionObserver = null; + } + + // An application that maps cancellation to an exit code, or a command that swallows it, returns + // normally and never reaches the filter above. Reporting that as a result would hand the test a + // passing run for a signal that never arrived. + return ReplTestTimeout.Expired(timeout, cancellationToken) + ? throw CreateTimeoutException(commandLine) + : exitCode; + } + + private static TimeoutException CreateTimeoutException(string commandLine) => + new($"The run '{commandLine}' exceeded its timeout. A signal test starts a run that blocks " + + "until it is cancelled, so this usually means the signal never claimed it."); + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + /// + /// Carries the "a scope has joined the epoch" notification from the coordinator to whichever start + /// is waiting for it. Starts are serialised, so at most one is ever armed. + /// + /// + /// Pairs each "a scope joined the epoch" notification with the start that caused it. + /// + /// Keyed by run token rather than ordered, because order is not something this can rely on: a start + /// whose wait gave up leaves its launch running, and that launch may register long afterwards. With + /// a queue its late registration would be handed to whichever start was waiting by then, reporting + /// that run as having joined the epoch when it had not — and a signal sent next would cancel the + /// abandoned run while missing the one just returned to the caller. + /// + /// + private sealed class ScopeRegistrationSignal + { + private readonly Lock _gate = new(); + private readonly Dictionary _pending = []; + + public TaskCompletionSource Arm(object runToken) + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_gate) + { + _pending[runToken] = pending; + } + + return pending; + } + + public void Signal(object? runToken) + { + if (runToken is null) + { + return; + } + + TaskCompletionSource? pending; + lock (_gate) + { + _ = _pending.Remove(runToken, out pending); + } + + pending?.TrySetResult(); + } + + /// + /// Drops a wait whose start gave up, so the entry does not linger. Its launch may still register + /// later; with nothing left under that token, the notification is simply discarded instead of + /// being handed to another start. + /// + public void Abandon(object runToken) + { + lock (_gate) + { + _ = _pending.Remove(runToken); + } + } + } + + private sealed class RunObserver : IReplExecutionObserver + { + // Every run that returns an exit code reports its kind while that code is resolved, so this is + // only ever read after the run completed. + public ReplExecutionOutcomeKind OutcomeKind { get; private set; } = ReplExecutionOutcomeKind.Success; + + public void OnResult(object? result) + { + } + + public void OnOutcome(ReplExecutionOutcomeKind kind) => OutcomeKind = kind; + + public void OnInteractionEvent(ReplInteractionEvent evt) + { + } + } +} diff --git a/src/Repl.Testing/ReplProcessSignalOptions.cs b/src/Repl.Testing/ReplProcessSignalOptions.cs new file mode 100644 index 00000000..5e061fc4 --- /dev/null +++ b/src/Repl.Testing/ReplProcessSignalOptions.cs @@ -0,0 +1,42 @@ +namespace Repl.Testing; + +/// +/// Options for one . +/// +public sealed class ReplProcessSignalOptions +{ + /// + /// How long a run may take before fails with a + /// . A signal test starts a run that blocks until it is cancelled, + /// so this is what turns "the signal never arrived" into a failing test instead of a hung one. + /// Defaults to 10 seconds. Use to disable it. + /// + /// Measured against the clock, not against the run agreeing to stop: a command that never observes + /// its cancellation token cannot be interrupted, so the harness abandons it rather than waiting. + /// The command keeps running — nothing here can kill it — and because it still holds a place in the + /// process-wide signal epoch, disposal reports it rather than letting later signal tests inherit it. + /// Disabling the timeout gives that up: a run that never ends then hangs both its completion and the + /// harness's disposal. + /// + /// + public TimeSpan RunTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Strips ANSI escape sequences and carriage returns from captured text, so an assertion does not + /// depend on whether the run decided to colour its output. Defaults to . + /// + public bool NormalizeAnsi { get; set; } = true; + + /// + /// The platform whose decisions apply. Defaults to . + /// + public ReplPlatformProfile Platform { get; set; } = ReplPlatformProfile.Current; + + /// + /// Makes the next registration attempt fail with this exception, so a test can assert that + /// automatic handling degrades to caller-owned and says so, rather than taking it on trust. No + /// supported platform refuses a registration on demand, so this is the only way to reach that + /// path. to let registration proceed normally. + /// + public Exception? RegistrationFault { get; set; } +} diff --git a/src/Repl.Testing/ReplSessionHandle.cs b/src/Repl.Testing/ReplSessionHandle.cs index 33d69fca..7c787737 100644 --- a/src/Repl.Testing/ReplSessionHandle.cs +++ b/src/Repl.Testing/ReplSessionHandle.cs @@ -1,12 +1,11 @@ using Microsoft.Extensions.DependencyInjection; -using System.Text.RegularExpressions; namespace Repl.Testing; /// /// Handle for a single live in-memory REPL session. /// -public sealed partial class ReplSessionHandle : IAsyncDisposable +public sealed class ReplSessionHandle : IAsyncDisposable { private readonly ReplTestHost _owner; private readonly ReplApp _app; @@ -36,6 +35,10 @@ private ReplSessionHandle( _sessionId = sessionId; } + /// + /// This session's id, unique within its and stable for the session's + /// lifetime. + /// public string SessionId => _sessionId; /// @@ -87,8 +90,8 @@ private async ValueTask ExecuteCommandCoreAsync( using var output = new StringWriter(); var host = new TestSessionHost(_sessionId, output); var observer = new SessionExecutionObserver(); - var args = BuildArgsWithAnswers(Tokenize(commandText), _sessionAnswers, answers); - using var timeout = CreateTimeoutSource(cancellationToken); + var args = BuildArgsWithAnswers(ReplTestText.Tokenize(commandText), _sessionAnswers, answers); + using var timeout = ReplTestTimeout.CreateSource(_options.CommandTimeout, cancellationToken); var token = timeout?.Token ?? cancellationToken; _app.Core.ExecutionObserver = observer; @@ -97,7 +100,7 @@ private async ValueTask ExecuteCommandCoreAsync( { exitCode = await _app.RunAsync(args, host, _services, _runOptions, token).ConfigureAwait(false); } - catch (OperationCanceledException) when (IsCommandTimeout(timeout, cancellationToken)) + catch (OperationCanceledException) when (ReplTestTimeout.Expired(timeout, cancellationToken)) { throw CreateTimeoutException(commandText); } @@ -111,7 +114,7 @@ private async ValueTask ExecuteCommandCoreAsync( var outputText = output.ToString(); if (_options.NormalizeAnsi) { - outputText = NormalizeOutput(outputText); + outputText = ReplTestText.NormalizeOutput(outputText); } var timeline = BuildTimeline(outputText, observer.Events, observer.LastResult); @@ -131,6 +134,12 @@ private async ValueTask ExecuteCommandCoreAsync( } } + /// + /// Captures the session's current terminal metadata. Returns + /// when the session has registered none yet, so this + /// never returns . + /// + /// A snapshot of this session. public SessionSnapshot GetSnapshot() { if (ReplSessionIO.TryGetSession(SessionId, out var session)) @@ -149,6 +158,9 @@ public SessionSnapshot GetSnapshot() return SessionSnapshot.Empty(SessionId); } + /// + /// Ends the session and removes it from its host. Disposing twice is a no-op. + /// public ValueTask DisposeAsync() { if (_disposed) @@ -193,13 +205,13 @@ internal static ValueTask StartAsync( } private static string[] BuildArgsWithAnswers( - List baseTokens, + string[] baseTokens, IReadOnlyDictionary? sessionAnswers, IReadOnlyDictionary? commandAnswers) { if (sessionAnswers is null && commandAnswers is null) { - return baseTokens.ToArray(); + return baseTokens; } var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -219,7 +231,7 @@ private static string[] BuildArgsWithAnswers( } } - var args = new List(baseTokens.Count + merged.Count); + var args = new List(baseTokens.Length + merged.Count); args.AddRange(baseTokens); foreach (var pair in merged) { @@ -254,7 +266,7 @@ private void ThrowIfCancelledByTimeout( string commandText, CancellationToken cancellationToken) { - if (observer.WasCancelled && IsCommandTimeout(timeout, cancellationToken)) + if (observer.WasCancelled && ReplTestTimeout.Expired(timeout, cancellationToken)) { throw CreateTimeoutException(commandText); } @@ -284,55 +296,6 @@ private void ThrowIfDisposed() ObjectDisposedException.ThrowIf(_disposed, this); } - private static List Tokenize(string value) - { - var tokens = new List(); - var current = new System.Text.StringBuilder(); - var inQuotes = false; - foreach (var ch in value) - { - if (ch == '"') - { - inQuotes = !inQuotes; - continue; - } - - if (!inQuotes && char.IsWhiteSpace(ch)) - { - if (current.Length > 0) - { - tokens.Add(current.ToString()); - current.Clear(); - } - - continue; - } - - current.Append(ch); - } - - if (current.Length > 0) - { - tokens.Add(current.ToString()); - } - - return tokens; - } - - private static string NormalizeOutput(string output) - { - if (string.IsNullOrEmpty(output)) - { - return output; - } - - var normalized = output.Replace("\r", string.Empty, StringComparison.Ordinal); - return BuildAnsiEscapeRegex().Replace(normalized, string.Empty); - } - - [GeneratedRegex(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.None, matchTimeoutMilliseconds: 50)] - private static partial Regex BuildAnsiEscapeRegex(); - private sealed class TestSessionHost(string sessionId, TextWriter output) : IReplSessionHost { public string SessionId { get; } = sessionId; diff --git a/src/Repl.Testing/ReplSignalDelivery.cs b/src/Repl.Testing/ReplSignalDelivery.cs new file mode 100644 index 00000000..035e0899 --- /dev/null +++ b/src/Repl.Testing/ReplSignalDelivery.cs @@ -0,0 +1,30 @@ +namespace Repl.Testing; + +/// +/// What the framework decided about one delivered signal. +/// +public enum ReplSignalDelivery +{ + /// + /// Nothing claimed the signal, so a real process would have taken the operating-system default. + /// A signal that arrives with no run in flight lands here, as does + /// on a declared platform where Ctrl+Break is not a signal. + /// + NotHandled, + + /// + /// The signal claimed the run cooperatively: every run in flight was asked to cancel, and a real + /// process would have kept running to finish its cleanup. + /// + CancellationRequested, + + /// + /// A signal arrived after one was already claimed, so the framework stepped aside and a real + /// process would have been terminated by the operating system. + /// + /// This reports the framework's decision and nothing more — see + /// for what an in-process test can and cannot prove. + /// + /// + WouldTerminateProcess, +} diff --git a/src/Repl.Testing/ReplSignalRun.cs b/src/Repl.Testing/ReplSignalRun.cs new file mode 100644 index 00000000..56dad77c --- /dev/null +++ b/src/Repl.Testing/ReplSignalRun.cs @@ -0,0 +1,30 @@ +namespace Repl.Testing; + +/// +/// A run started by and still in flight. Its +/// signal scope is already registered by the time the start call returns, so a signal delivered from +/// here on reaches it rather than falling through as inert. +/// +public sealed class ReplSignalRun +{ + internal ReplSignalRun(string commandLine, Task completion) + { + CommandLine = commandLine; + Completion = completion; + } + + /// The command line this run was started with. + public string CommandLine { get; } + + /// + /// Completes when the run finishes. Faults with when the run + /// outlives , which is what a signal that never + /// arrived looks like. + /// + /// Bind the run to a local before awaiting this. Chaining the two — await (await + /// harness.StartRunAsync(x)).Completion — trips VSTHRD003 in a project that treats the + /// Visual Studio threading analyzers as errors. + /// + /// + public Task Completion { get; } +} diff --git a/src/Repl.Testing/ReplSignalRunResult.cs b/src/Repl.Testing/ReplSignalRunResult.cs new file mode 100644 index 00000000..68939b19 --- /dev/null +++ b/src/Repl.Testing/ReplSignalRunResult.cs @@ -0,0 +1,36 @@ +namespace Repl.Testing; + +/// +/// What one run under a ended with. +/// +public sealed record ReplSignalRunResult +{ + /// + /// The status the run resolved to, through the application's own + /// policy. This is what a real Main would return; the harness does not exit the process, so + /// nothing acts on it here. + /// + public required int ExitCode { get; init; } + + /// + /// How the run ended. A claimed signal reports ; + /// a run cancelled by its own caller token reports . + /// + public required ReplExecutionOutcomeKind OutcomeKind { get; init; } + + /// Everything the run wrote to its output. + public required string OutputText { get; init; } + + /// + /// Everything the run wrote to its error stream. That includes the framework diagnostics raised + /// while the run itself was running: a signal bridge that could not be installed, and a + /// cancellation callback that threw while the run was unwinding. + /// + /// It does not include the diagnostics for delivered signals. Those are written from whichever + /// context delivers the signal — an operating-system callback thread in production, with no session + /// of its own — so they belong to the delivery and are captured on + /// instead. + /// + /// + public required string DiagnosticText { get; init; } +} diff --git a/src/Repl.Testing/ReplTestHost.cs b/src/Repl.Testing/ReplTestHost.cs index 6809baf6..803d1453 100644 --- a/src/Repl.Testing/ReplTestHost.cs +++ b/src/Repl.Testing/ReplTestHost.cs @@ -19,6 +19,14 @@ private ReplTestHost(Func appFactory, ReplScenarioOptions options) _options = options; } + /// + /// Creates a host over an application factory. The factory is invoked once per session, so each + /// session gets its own and service provider. + /// + /// Builds the application under test. + /// Adjusts the scenario options shared by every session this host opens. + /// A host ready to open sessions. + /// is . public static ReplTestHost Create(Func appFactory, Action? configure = null) { ArgumentNullException.ThrowIfNull(appFactory); @@ -27,6 +35,15 @@ public static ReplTestHost Create(Func appFactory, Action + /// Opens a session. Sessions are independent and may run concurrently: application state persists + /// across the commands of one session and is not shared with another. + /// + /// Describes the simulated transport, terminal and prefilled answers. A default descriptor is used when omitted. + /// Cancels opening the session. + /// A handle for running commands in the new session. + /// The host has been disposed. + /// A session with the same id is already open. public async ValueTask OpenSessionAsync( SessionDescriptor? descriptor = null, CancellationToken cancellationToken = default) @@ -48,6 +65,13 @@ public async ValueTask OpenSessionAsync( return handle; } + /// + /// Snapshots every session currently open on this host, ordered by session id so an assertion does + /// not depend on the order they were opened in. + /// + /// Cancels the query. + /// One snapshot per open session. + /// The host has been disposed. public ValueTask> QuerySessionsAsync(CancellationToken cancellationToken = default) { ThrowIfDisposed(); @@ -69,6 +93,9 @@ internal void RemoveSession(string sessionId) _sessions.TryRemove(sessionId, out _); } + /// + /// Disposes every session this host still owns. Disposing twice is a no-op. + /// public async ValueTask DisposeAsync() { if (_disposed) diff --git a/src/Repl.Testing/ReplTestText.cs b/src/Repl.Testing/ReplTestText.cs new file mode 100644 index 00000000..8b0d76b4 --- /dev/null +++ b/src/Repl.Testing/ReplTestText.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Repl.Testing; + +/// +/// Command-line and captured-text handling shared by the session handle and the signal harness. Both +/// take a command line as one string and both compare captured output, so the tokenizer and the +/// normalizer live here rather than once per entry point. +/// +/// The tokenizer splits on whitespace and lets double quotes group; it is not a shell parser. Single +/// quotes are literal characters, escape sequences are not interpreted, and an empty quoted argument +/// produces no token. Both public entry points document that, so a command needing more than this has +/// to be expressed differently rather than quoted harder. +/// +/// +internal static partial class ReplTestText +{ + internal static string[] Tokenize(string value) + { + var tokens = new List(); + var current = new StringBuilder(); + var inQuotes = false; + foreach (var ch in value) + { + if (ch == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && char.IsWhiteSpace(ch)) + { + if (current.Length > 0) + { + tokens.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(ch); + } + + if (current.Length > 0) + { + tokens.Add(current.ToString()); + } + + return [.. tokens]; + } + + internal static string NormalizeOutput(string output) + { + if (string.IsNullOrEmpty(output)) + { + return output; + } + + var normalized = output.Replace("\r", string.Empty, StringComparison.Ordinal); + return BuildAnsiEscapeRegex().Replace(normalized, string.Empty); + } + + [GeneratedRegex(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.None, matchTimeoutMilliseconds: 50)] + private static partial Regex BuildAnsiEscapeRegex(); +} diff --git a/src/Repl.Testing/ReplTestTimeout.cs b/src/Repl.Testing/ReplTestTimeout.cs new file mode 100644 index 00000000..fc6b3287 --- /dev/null +++ b/src/Repl.Testing/ReplTestTimeout.cs @@ -0,0 +1,40 @@ +namespace Repl.Testing; + +/// +/// The wall-clock timeout both entry points put around a run. The session handle bounds one command, +/// the signal harness bounds one run, and both need the same two things: a source linked to the +/// caller's token, and a way to tell their own deadline from the caller cancelling. +/// +internal static class ReplTestTimeout +{ + /// + /// Whether is a real deadline rather than "no timeout". + /// + internal static bool IsEnabled(TimeSpan timeout) => + timeout > TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan; + + /// + /// A source that fires after and also when the caller cancels, or + /// when no deadline applies. The caller owns disposal. + /// + internal static CancellationTokenSource? CreateSource(TimeSpan timeout, CancellationToken cancellationToken) + { + if (!IsEnabled(timeout)) + { + return null; + } + + var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + source.CancelAfter(timeout); + return source; + } + + /// + /// Whether the deadline is what fired, and not the caller's own token. The distinction decides + /// whether a cancellation is reported as a timeout or left to propagate as the caller's. + /// + internal static bool Expired(CancellationTokenSource? timeout, CancellationToken cancellationToken) => + timeout is not null + && timeout.IsCancellationRequested + && !cancellationToken.IsCancellationRequested; +} diff --git a/src/Repl.Testing/SessionSnapshot.cs b/src/Repl.Testing/SessionSnapshot.cs index 08263d42..e3f30f76 100644 --- a/src/Repl.Testing/SessionSnapshot.cs +++ b/src/Repl.Testing/SessionSnapshot.cs @@ -13,6 +13,12 @@ public sealed record SessionSnapshot( bool? AnsiSupported, DateTimeOffset LastUpdatedUtc) { + /// + /// A snapshot for a session that has registered no terminal metadata yet — every field unset and + /// at . + /// + /// The session the snapshot describes. + /// An empty snapshot. public static SessionSnapshot Empty(string sessionId) => new( sessionId, diff --git a/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs b/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs index 00e65cf4..2713d4ca 100644 --- a/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs +++ b/src/Repl.Tests/Given_ProcessSignalCancellationScope.cs @@ -379,20 +379,28 @@ public async Task When_SignalRegistrationFailed_Then_LaterRunsDoNotRetry() } [TestMethod] - [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] - [Description("A registration that fails after SIGTERM was registered disposes the orphan instead of leaking it. This is the only ordering that reaches the cleanup, and a leaked PosixSignalRegistration would keep suppressing SIGTERM for a process that has already been told the bridge is caller-owned.")] + [Description("A registration that fails after SIGTERM was registered disposes the orphan instead of leaking it. This is the only ordering that reaches the cleanup, and a leaked PosixSignalRegistration would keep suppressing SIGTERM for a process that has already been told the bridge is caller-owned. Declaring a non-Windows platform with real registrations allowed is what lets this ordering exist on a Windows host too, since .NET accepts PosixSignal.SIGTERM there as well.")] public async Task When_RegistrationFailsAfterSigTerm_Then_TheOrphanedRegistrationIsReleased() { using var error = new StringWriter(); using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error); using (var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( new PlatformNotSupportedException("cancel-key registration rejected"), - faultAfterSigTermRegistration: true)) + faultAfterSigTermRegistration: true, + policy: new ProcessSignalCoordinator.SignalRegistrationPolicy + { + IsWindows = false, + CreateRealRegistrations = true, + })) { await using var degraded = new ProcessSignalCancellationScope(default); degraded.Token.IsCancellationRequested.Should().BeFalse(); error.ToString().Should().Contain("Failed to install automatic process-signal handling"); + // Pin the failure to the injected fault. Without this the test still passes when the SIGTERM + // registration itself is what failed — in which case no orphan existed and the cleanup this + // test exists for was never reached. + error.ToString().Should().Contain("cancel-key registration rejected"); } // A leaked registration would still be claiming SIGTERM under a stale generation. After the @@ -447,6 +455,189 @@ public void When_APlatformFlagIsSet_Then_SignalBridgeIsUnsupported( isTvOS).Should().BeFalse(); } + [TestMethod] + [Description("SIGTERM claims the epoch cooperatively and carries 143, reached in-process rather than only through a spawned child.")] + public async Task When_FirstSigTermArrives_Then_ActiveScopeIsCancelledWithTheSigTermCode() + { + await using var scope = new ProcessSignalCancellationScope(default); + + var result = ProcessSignalCoordinator.HandleSigTermForTesting(); + + result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigTermExitCode); + scope.Token.IsCancellationRequested.Should().BeTrue(); + } + + [TestMethod] + [Description("A SIGTERM after Ctrl+C escalates to the operating system and leaves the first claim's exit code intact, so the second signal cannot relabel what the run is exiting with.")] + public async Task When_SigTermFollowsCtrlC_Then_TheFirstClaimKeepsItsExitCode() + { + await using var scope = new ProcessSignalCancellationScope(default); + + var firstSignal = ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting(); + var secondSignal = ProcessSignalCoordinator.HandleSigTermForTesting(); + + firstSignal.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + secondSignal.Should().Be(ConsoleCancelKeyHandlingResult.AllowProcessTermination); + scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigIntExitCode); + } + + [TestMethod] + [Description("SIGTERM stays inert without an active scope, so the seam cannot claim an epoch the real registration would have left to the operating-system default.")] + public void When_NoScopeIsActive_Then_SigTermIsNotHandled() => + ProcessSignalCoordinator.HandleSigTermForTesting() + .Should().Be(ConsoleCancelKeyHandlingResult.NotHandled); + + [TestMethod] + [Description("The SIGTERM seam accepts whichever epoch is current, the way a freshly installed registration would: isolating registrations advances the generation counter, and a scope that survives it is still claimed.")] + public async Task When_TheGenerationAdvancesUnderAnActiveScope_Then_SigTermStillClaimsTheCurrentEpoch() + { + await using var scope = new ProcessSignalCancellationScope(default); + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting(); + + var result = ProcessSignalCoordinator.HandleSigTermForTesting(); + + result.Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigTermExitCode); + } + + [TestMethod] + [Description("A declared non-Windows platform wants a SIGTERM registration and, with real registrations left suppressed, does not get one. That pair is what a platform test looks like from any host: the wiring decision is asserted without an operating-system registration being installed on the declared platform's behalf.")] + public async Task When_ANonWindowsPlatformIsDeclared_Then_SigTermIsWantedButNotInstalled() + { + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( + policy: new ProcessSignalCoordinator.SignalRegistrationPolicy { IsWindows = false }); + await using var scope = new ProcessSignalCancellationScope(default); + + ProcessSignalCoordinator.SigTermRegistrationDeclaredForTesting.Should().BeTrue(); + ProcessSignalCoordinator.SigTermRegistrationInstalledForTesting.Should().BeFalse(); + } + + [TestMethod] + [Description("A declared Windows platform wants no SIGTERM registration at all, because the console coordinator already owns Ctrl+C and Ctrl+Break there and .NET maps PosixSignal.SIGTERM onto CTRL_SHUTDOWN_EVENT. Assertable from a non-Windows host, which is where this decision was previously unverifiable.")] + public async Task When_WindowsIsDeclared_Then_NoSigTermRegistrationIsWanted() + { + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( + policy: new ProcessSignalCoordinator.SignalRegistrationPolicy { IsWindows = true }); + await using var scope = new ProcessSignalCancellationScope(default); + + ProcessSignalCoordinator.SigTermRegistrationDeclaredForTesting.Should().BeFalse(); + ProcessSignalCoordinator.SigTermRegistrationInstalledForTesting.Should().BeFalse(); + } + + [TestMethod] + [Description("Suppressing real registrations does not suppress delivery: the in-process seams reach the claim logic either way. Without this the platform tests above could pass against a coordinator that had quietly stopped claiming anything.")] + public async Task When_RealRegistrationsAreSuppressed_Then_SignalsAreStillClaimed() + { + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( + policy: new ProcessSignalCoordinator.SignalRegistrationPolicy { IsWindows = false }); + await using var scope = new ProcessSignalCancellationScope(default); + + ProcessSignalCoordinator.HandleSigTermForTesting() + .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + scope.ExitCode.Should().Be(ProcessSignalCoordinator.SigTermExitCode); + } + + [TestMethod] + [Description("A declared unsupported platform degrades the bridge through the real Register path rather than only through the platform predicate, so the diagnostic and the caller-owned fallback are exercised from any host.")] + public async Task When_AnUnsupportedPlatformIsDeclared_Then_TheBridgeDegradesWithADiagnostic() + { + using var error = new StringWriter(); + using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error); + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting( + policy: new ProcessSignalCoordinator.SignalRegistrationPolicy { IsBrowser = true }); + + await using var scope = new ProcessSignalCancellationScope(default); + + error.ToString().Should().Contain("unavailable on this platform"); + scope.Token.IsCancellationRequested.Should().BeFalse(); + ProcessSignalCoordinator.SigTermRegistrationInstalledForTesting.Should().BeFalse(); + } + + [TestMethod] + [Description("Test ownership is refused while a claimed epoch is still draining. UnregisterAsync removes a scope before its cancellation callbacks finish and keeps the claim alive until they do, so an empty scope set is not an idle coordinator: claiming in that window hands the next owner an epoch that is still claimed, and its first run is cancelled on registration with no signal ever sent.")] + public async Task When_AClaimedEpochIsStillDraining_Then_TestOwnershipIsRefused() + { + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting(); + var drainStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseDrain = new ManualResetEventSlim(initialState: false); + IDisposable? claimedDuringDrain = null; + + var scope = new ProcessSignalCancellationScope(default); + // Runs while the scope is unregistering, which is exactly the window: removed from ActiveScopes, + // claim not yet cleared. + scope.Token.Register(() => + { + drainStarted.TrySetResult(); + releaseDrain.Wait(); + }); + + ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting() + .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + + var disposal = scope.DisposeAsync().AsTask(); + await drainStarted.Task; + claimedDuringDrain = ProcessSignalCoordinator.TryClaimTestOwnership(); + releaseDrain.Set(); + await disposal; + + claimedDuringDrain.Should().BeNull(because: "the previous epoch had not finished draining"); + using var afterDrain = ProcessSignalCoordinator.TryClaimTestOwnership(); + afterDrain.Should().NotBeNull(because: "once drained, the coordinator is claimable again"); + } + + [TestMethod] + [Description("An owned-run marker belongs to the claim that set it, not to whoever owns the coordinator next. A bare flag also flows into anything a handler spawned, so a background task outliving its harness would still read as owned and could join a later owner's epoch.")] + public void When_AMarkerOutlivesItsClaim_Then_ItIsNotHonouredByTheNextOwner() + { + var first = ProcessSignalCoordinator.TryClaimTestOwnership(); + first.Should().NotBeNull(); + + // A context marked under the first claim, captured the way a spawned background task would. + var marker = ProcessSignalCoordinator.MarkOwnedRunForTesting(new object()); + first!.Dispose(); + + using var second = ProcessSignalCoordinator.TryClaimTestOwnership(); + second.Should().NotBeNull(because: "the first claim was released"); + + // The stale marker must not pass as the second owner's run. + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting(); + var act = () => new ProcessSignalCancellationScope(default); + + act.Should().Throw().WithMessage("*owns signal handling*"); + marker.Dispose(); + } + + [TestMethod] + [Description("A claim left by an owner that has been released is discarded rather than inherited. A harness disposed while a run it could not stop was still executing leaves its scope in the epoch and its signal claimed; the next ordinary run would otherwise be cancelled by a signal nobody sent and report Interrupted with no diagnostic naming one — indistinguishable from a bug in the caller's own application.")] + public async Task When_AClaimOutlivesItsOwner_Then_TheNextRunDoesNotInheritIt() + { + using var error = new StringWriter(); + using var session = ReplSessionIO.SetSession(TextWriter.Null, TextReader.Null, error: error); + using var isolation = ProcessSignalCoordinator.IsolateRegistrationsForTesting(); + + var owner = ProcessSignalCoordinator.TryClaimTestOwnership(); + owner.Should().NotBeNull(); + ProcessSignalCancellationScope? abandoned; + using (ProcessSignalCoordinator.MarkOwnedRunForTesting(new object())) + { + abandoned = new ProcessSignalCancellationScope(default); + } + + ConsoleCancelKeyCoordinator.HandleCancelKeyForTesting() + .Should().Be(ConsoleCancelKeyHandlingResult.SuppressProcessTermination); + // Released without draining, which is what disposal does when a run cannot be stopped. + owner!.Dispose(); + + await using var next = new ProcessSignalCancellationScope(default); + + next.Token.IsCancellationRequested.Should().BeFalse( + because: "a claim nobody is draining must not cancel an unrelated run"); + next.ExitCode.Should().BeNull(); + error.ToString().Should().Contain("Discarding"); + await abandoned.DisposeAsync(); + } + [TestMethod] [Description("A platform with no mobile flag keeps the signal bridge, so the platform predicate is not vacuously false for every input.")] public void When_NoPlatformFlagIsSet_Then_SignalBridgeIsSupported() diff --git a/src/Repl.Tests/Given_ProcessSignalExitCodePolicy.cs b/src/Repl.Tests/Given_ProcessSignalExitCodePolicy.cs index 92ffcb1c..2f25c74d 100644 --- a/src/Repl.Tests/Given_ProcessSignalExitCodePolicy.cs +++ b/src/Repl.Tests/Given_ProcessSignalExitCodePolicy.cs @@ -9,6 +9,10 @@ namespace Repl.Tests; /// ExitCodes.Resolver both govern a signalled run. /// [TestClass] +// Drives the same process-global coordinator state as every other class that carries this attribute. +// It was safe without it only because those are all tagged, so MSTest ran this one alone in the +// parallel pass — an invariant that would break the moment a second untagged class touched that state. +[DoNotParallelize] public sealed class Given_ProcessSignalExitCodePolicy { [TestMethod]