Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Accessed via `ReplOptions.ShellCompletion`. See [Shell Completion](shell-complet

A record passed to `app.RunAsync(...)` to control runtime behavior. Separate from `ReplOptions`.

- `ProcessSignalHandling` (`ProcessSignalHandlingMode`, default: `Automatic`) — For standalone `Run`/`RunAsync` overloads, converts the first Ctrl+C/SIGINT into cooperative cancellation and returns exit code `130`; on Unix it also converts SIGTERM and returns `143`. A second signal retains the operating system default behavior. Interactive sessions retain their existing first-press command cancellation and second-press exit behavior. `UseEmbeddedConsoleProfile()` defaults to `None`; callers can explicitly select `Automatic` per run. Overloads that use an external host, service provider, or `IReplHost` leave signal handling to that owner.
- `HostedServiceLifecycle` (`HostedServiceLifecycleMode`, default: `None`) — Hosted service lifecycle mode.
- `AnsiSupport` (`AnsiMode`, default: `Auto`) — ANSI support mode for this run.
- `TerminalOverrides` (`TerminalSessionOverrides?`, default: `null`) — Terminal session overrides.
23 changes: 22 additions & 1 deletion src/Repl.Core/Console/CancelKeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,33 @@ namespace Repl;
internal sealed class CancelKeyHandler : IDisposable
{
private static readonly TimeSpan DoubleTapWindow = TimeSpan.FromSeconds(2);
private static int s_activeConsoleHandlers;

private CancellationTokenSource? _commandCts;
private DateTimeOffset _lastCancelPress;
private readonly Lock _lock = new();
private readonly bool _hooked;
private int _disposed;

// Standalone signal scopes yield Ctrl+C while an interactive console handler owns its process-wide semantics.
internal static bool HasActiveConsoleHandler => Volatile.Read(ref s_activeConsoleHandlers) != 0;

internal CancelKeyHandler()
{
_hooked = !ReplSessionIO.IsSessionActive;
if (_hooked)
{
Console.CancelKeyPress += OnCancelKeyPress;
// Publish ownership before subscribing so an outer standalone handler never claims the same key press.
Interlocked.Increment(ref s_activeConsoleHandlers);
try
{
Console.CancelKeyPress += OnCancelKeyPress;
Comment thread
autocarl marked this conversation as resolved.
Outdated
}
catch
{
Interlocked.Decrement(ref s_activeConsoleHandlers);
throw;
}
}
}

Expand All @@ -41,9 +56,15 @@ internal void SetCommandCts(CancellationTokenSource? cts)

public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}

if (_hooked)
{
Console.CancelKeyPress -= OnCancelKeyPress;
Interlocked.Decrement(ref s_activeConsoleHandlers);
}
}

Expand Down
118 changes: 118 additions & 0 deletions src/Repl.Defaults/ProcessSignalCancellationScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Runtime.InteropServices;

namespace Repl;

internal sealed class ProcessSignalCancellationScope : IAsyncDisposable
{
private const int SigIntExitCode = 130;
private const int SigTermExitCode = 143;

private readonly CancellationTokenSource _signalCancellation = new();
private readonly CancellationTokenSource _linkedCancellation;
private readonly PosixSignalRegistration? _sigTermRegistration;
private readonly Lock _gate = new();
private Task _cancellationTask = Task.CompletedTask;
private int _exitCode;
Comment thread
autocarl marked this conversation as resolved.
Outdated
private bool _disposed;

public ProcessSignalCancellationScope(CancellationToken cancellationToken)
{
_linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_signalCancellation.Token);

var cancelKeyRegistered = false;
try
{
Console.CancelKeyPress += HandleCancelKey;
cancelKeyRegistered = true;
if (!OperatingSystem.IsWindows())
{
_sigTermRegistration = PosixSignalRegistration.Create(
PosixSignal.SIGTERM,
HandleSigTerm);
}
}
catch
{
if (cancelKeyRegistered)
{
Console.CancelKeyPress -= HandleCancelKey;
}

_linkedCancellation.Dispose();
_signalCancellation.Dispose();
throw;
}
}

public CancellationToken Token => _linkedCancellation.Token;

public int ExitCode => Volatile.Read(ref _exitCode);

public async ValueTask DisposeAsync()
{
Task cancellationTask;
lock (_gate)
{
if (_disposed)
{
return;
}

_disposed = true;
cancellationTask = _cancellationTask;
}

Console.CancelKeyPress -= HandleCancelKey;
_sigTermRegistration?.Dispose();
try
{
#pragma warning disable VSTHRD003 // The OS signal callback starts this task; disposal must observe its completion.
await cancellationTask.ConfigureAwait(false);
#pragma warning restore VSTHRD003
}
finally
{
_linkedCancellation.Dispose();
_signalCancellation.Dispose();
}
}

internal void HandleCancelKey(object? sender, ConsoleCancelEventArgs context)
{
_ = sender;
if (context.SpecialKey != ConsoleSpecialKey.ControlC
|| CancelKeyHandler.HasActiveConsoleHandler)
{
return;
}

lock (_gate)
{
if (_disposed || _exitCode != 0 || CancelKeyHandler.HasActiveConsoleHandler)
{
return;
}

_exitCode = SigIntExitCode;
context.Cancel = true;
_cancellationTask = _signalCancellation.CancelAsync();
}
}

private void HandleSigTerm(PosixSignalContext context)
{
lock (_gate)
{
if (_disposed || _exitCode != 0)
{
return;
}

_exitCode = SigTermExitCode;
context.Cancel = true;
_cancellationTask = _signalCancellation.CancelAsync();
}
}
}
19 changes: 19 additions & 0 deletions src/Repl.Defaults/ProcessSignalHandlingMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Repl;

/// <summary>
/// Controls whether standalone runs translate process termination signals into cooperative cancellation.
/// </summary>
public enum ProcessSignalHandlingMode
{
/// <summary>
/// Standalone <see cref="ReplApp.Run(string[], ReplRunOptions?)"/> and
/// <see cref="ReplApp.RunAsync(string[], ReplRunOptions?, CancellationToken)"/> calls
/// handle Ctrl+C/SIGINT for the duration of the run and also handle SIGTERM on Unix platforms. Interactive sessions retain their existing Ctrl+C behavior.
/// </summary>
Automatic = 0,

/// <summary>
/// Process signal handling remains the responsibility of the caller.
/// </summary>
None = 1,
}
37 changes: 29 additions & 8 deletions src/Repl.Defaults/ReplApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public sealed class ReplApp : IReplApp
// Ensures modules resolved via DI share the same service instances
// as handler parameters resolved at runtime.
private ServiceProvider? _sharedProvider;
private ProcessSignalHandlingMode _defaultProcessSignalHandling = ProcessSignalHandlingMode.Automatic;

// Extension packages (e.g. Repl.Spectre) park per-app configuration here so it stays
// reachable even when the shared provider was materialized before the Use* call —
Expand All @@ -28,6 +29,9 @@ public sealed class ReplApp : IReplApp

internal IServiceCollection ServiceDescriptors => _services;

internal void SetDefaultProcessSignalHandling(ProcessSignalHandlingMode mode) =>
_defaultProcessSignalHandling = mode;

internal void SetExtensionState<T>(T value) where T : class => _extensionState[typeof(T)] = value;

internal T? GetExtensionState<T>() where T : class =>
Expand Down Expand Up @@ -207,32 +211,48 @@ public ReplApp MapModule(IReplModule module, Delegate isPresent)
}

/// <summary>
/// Runs using internally configured services.
/// Runs using internally configured services and owns process signals according to <see cref="ReplRunOptions"/>.
/// </summary>
public int Run(string[] args, ReplRunOptions? options = null)
{
ArgumentNullException.ThrowIfNull(args);
var provider = EnsureSharedProvider();
#pragma warning disable VSTHRD002
return RunAsync(args, provider, options).AsTask().GetAwaiter().GetResult();
return RunAsync(args, options).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
}

/// <summary>
/// Runs using internally configured services.
/// Runs using internally configured services and owns process signals according to <see cref="ReplRunOptions"/>.
/// </summary>
public async ValueTask<int> RunAsync(
string[] args,
ReplRunOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(args);
var provider = EnsureSharedProvider();
return await RunAsync(args, provider, options, cancellationToken).ConfigureAwait(false);
var runOptions = options ?? new ReplRunOptions { ProcessSignalHandling = _defaultProcessSignalHandling };
Comment thread
autocarl marked this conversation as resolved.
Outdated
if (runOptions.ProcessSignalHandling == ProcessSignalHandlingMode.None)
{
var provider = EnsureSharedProvider();
return await RunAsync(args, provider, runOptions, cancellationToken).ConfigureAwait(false);
}

var signals = new ProcessSignalCancellationScope(cancellationToken);
await using var configuredSignals = signals.ConfigureAwait(false);
try
{
var provider = EnsureSharedProvider();
var exitCode = await RunAsync(args, provider, runOptions, signals.Token).ConfigureAwait(false);
return signals.ExitCode == 0 ? exitCode : signals.ExitCode;
}
catch (OperationCanceledException) when (signals.ExitCode != 0)
{
return signals.ExitCode;
}
}

/// <summary>
/// Runs using internally configured services.
/// Runs using internally configured services and owns process signals according to <see cref="ReplRunOptions"/>.
/// </summary>
public ValueTask<int> RunAsync(string[] args, CancellationToken cancellationToken) =>
RunAsync(args, options: null, cancellationToken);
Expand Down Expand Up @@ -353,7 +373,8 @@ public async ValueTask<int> RunAsync(
using (ReplSessionIO.SetSession(host.Output, host.Input, runOptions.AnsiSupport, sessionHost?.SessionId))
{
ApplyTerminalOverrides(runOptions);
return await RunAsync(args, runOptions, cancellationToken).ConfigureAwait(false);
var provider = EnsureSharedProvider();
return await RunAsync(args, provider, runOptions, cancellationToken).ConfigureAwait(false);
}
}

Expand Down
7 changes: 5 additions & 2 deletions src/Repl.Defaults/ReplAppProfileExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ public static ReplApp UseDefaultInteractive(this ReplApp app)
options.Interactive.Prompt = ">";
options.Interactive.InteractivePolicy = InteractivePolicy.Auto;
});
app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.Automatic);

return app;
}

/// <summary>
/// Applies defaults suited for CLI one-shot execution.
/// Applies process-owning defaults suited for CLI one-shot execution.
/// </summary>
/// <param name="app">Target app.</param>
/// <returns>The same app instance.</returns>
Expand All @@ -38,12 +39,13 @@ public static ReplApp UseCliProfile(this ReplApp app)
options.Output.DefaultFormat = "human";
options.Output.BannerEnabled = true;
});
app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.Automatic);

return app;
}

/// <summary>
/// Applies defaults suited for embedded host scenarios.
/// Applies defaults suited for embedded host scenarios, leaving process signal ownership with the caller.
/// </summary>
/// <param name="app">Target app.</param>
/// <returns>The same app instance.</returns>
Expand All @@ -56,6 +58,7 @@ public static ReplApp UseEmbeddedConsoleProfile(this ReplApp app)
options.AmbientCommands.ExitCommandEnabled = false;
options.Interactive.InteractivePolicy = InteractivePolicy.Auto;
});
app.SetDefaultProcessSignalHandling(ProcessSignalHandlingMode.None);

return app;
}
Expand Down
5 changes: 5 additions & 0 deletions src/Repl.Defaults/ReplRunOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ namespace Repl;
/// </summary>
public sealed record ReplRunOptions
{
/// <summary>
/// Gets how standalone runs handle process termination signals.
/// </summary>
public ProcessSignalHandlingMode ProcessSignalHandling { get; init; } = ProcessSignalHandlingMode.Automatic;

/// <summary>
/// Gets or sets the hosted-service lifecycle behavior.
/// </summary>
Expand Down
Loading
Loading