mykeels.com

I turned Slack into a live console for my production dotnet app

Using Roslyn scripting and Slack's Socket Mode to inspect, debug, and interact with a running .NET application without attaching a debugger or restarting the process.

Video Demo (Sound on)

The problem

I've been using Mykeels.CSharpRepl for a few years to debug my .NET applications. It gives me a C# REPL with full access to execute my application's code interactively, much like Visual Studio's Immediate Window, except it isn't tied to a debugger breakpoint or an IDE.

For my applications, this effectively creates another entry point. I can simply run:

./myapplication.exe repl

and begin executing application code interactively.

However, when an application is already running on a server, I can SSH into the machine and start a REPL, but that launches another process. It has access to the same assemblies and configuration, but it isn't the running application.

The live process has state that a new process doesn't. It has in-memory caches, singleton services, active background workers, open connections, and the exact execution context that produced the bug I'm trying to investigate.

What I wanted was a REPL inside the running application itself.

The idea

A few weeks ago, while building an Incident Simulator, I decided to use Slack as its primary interface. Instead of exposing HTTP endpoints or building a custom dashboard, the application communicated entirely through Slack using Socket Mode.

That meant the running process already had a persistent, two-way communication channel.

Then it hit me.

If my application can receive messages over Slack...

...why couldn't those messages be C#?

More importantly, why couldn't they be executed inside the application's own process?

At that point, Slack stopped being the interesting part.

It became nothing more than the transport layer for Mykeels.CSharpRepl.

The goal was to give a live .NET application an interactive console that happened to be accessible from Slack.

Architecture

If Slack was going to become the transport layer for a live REPL, there were three problems to solve:

  • The REPL couldn't block the application's main thread.
  • Every Slack session needed its own independent execution context.
  • The REPL needed access to the application's services and configuration.

The implementation that made this possible is available in this pull request

Running the REPL in the background

The first problem was fairly straightforward.

A REPL is effectively an infinite loop waiting for user input, which obviously isn't something you want running on your application's main thread.

Instead, I host the Slack REPL inside a BackgroundService. If the Slack connection drops for any reason, the service simply reconnects after a short delay without affecting the rest of the application.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace MyApplication;

// Runs ReplHost.RunSlack for the lifetime of the host. Restarts on failure instead of
// letting an unhandled exception here bring down the whole ASP.NET app.
public sealed class SlackReplBackgroundService(
    IConfiguration configuration,
    ILogger<SlackReplBackgroundService> logger
) : BackgroundService
{
    private static readonly TimeSpan RestartDelay = TimeSpan.FromSeconds(5);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                logger.Info("Starting Slack REPL host");
                await ReplHost.RunSlack(configuration, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Slack REPL host crashed, restarting in {RestartDelay}", RestartDelay);
                try
                {
                    await Task.Delay(RestartDelay, stoppingToken);
                }
                catch (OperationCanceledException)
                {
                    break;
                }
            }
        }
    }
}

In ReplHost.cs, we have:

using Microsoft.Extensions.Configuration;
using Mykeels.CSharpRepl;

namespace Under4Games;

public static class ReplHost
{
    private static string[] Namespaces = [
        "System",
        "System.Collections.Generic",
        "System.Linq",
        "MyApplication"
    ];

    public static async Task RunSlack(IConfiguration configuration, CancellationToken cancellationToken = default)
    {
        const string ApplicationName = "MyApplication.Slack";
        string RequireConfiguration(string key) => configuration.GetValue<string>(key) ?? throw new Exception($"Configuration key {key} is required");
        await SlackReplHost.Run(
            new SlackReplOptions
            {
                BotToken = RequireConfiguration("Slack:BotToken"),
                AppToken = RequireConfiguration("Slack:AppToken"),
                // Fails closed if left unset — see SlackReplOptions.AllowedUserIds.
                AllowedChannelIds = RequireConfiguration("Slack:AllowedChannelIds")
                    .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                    .ToHashSet(),
                SlashCommand = "/myapplication-repl",
            },
            commands: [
                "using static MyApplication.ScriptGlobals;"
            ],
            config: new CSharpRepl.Services.Configuration(
                usings: Namespaces,
                applicationName: ApplicationName
            ),
            onLoad: (rosylnServices) => {
                rosylnServices
                    .EvaluateAsync(
                        "using static MyApplication.ScriptGlobals;",
                        cancellationToken: CancellationToken.None
                    )
                    .ConfigureAwait(false);
                string cwd = AppContext.BaseDirectory;
                string logFilePath = Path.Combine(cwd, $"{ApplicationName}.log");
                Console.WriteLine($"Logs: {logFilePath}");
            },
            cancellationToken: cancellationToken
        );
    }
}

In Program.cs, I can start the REPL host like this:

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        services.AddHostedService<SlackReplBackgroundService>();
    })
    .Build();

await host.RunAsync();

At this point, the application starts a Slack listener alongside the rest of its normal services.

How it works

There are a few things worth pointing out here.

  • Socket Mode means there's no public HTTP endpoint to expose or tunnel.
  • Every Slack thread gets its own Roslyn session, so multiple developers can use the REPL simultaneously without interfering with one another.
  • I preload a few namespaces and helper methods so the session feels like an extension of the application rather than a blank C# script.
  • For more information on how the REPL works, please refer to the Mykeels.CSharpRepl README.

Security and Authorization

Executing arbitrary C# inside a running process should make every engineer wary.

To mitigate this, this is designed to fail closed and only allow certain users and channels to use the REPL. I've added a few security features in the SlackReplOptions class:

  • AllowedChannelIds: Only allow the REPL to be used in specific channels.
  • AllowedUserIds: Only allow the REPL to be used by specific users.
  • RestrictRepliesToSessionOwner: Only allow the user who ran the slash command to send messages into their own session's thread. Default is true.
  • IsAuthorized: A callback that can be used to add additional checks to the authorization process.

Where this could go next

Once an application can inspect itself at runtime, it's only a small step toward allowing it to adapt at runtime as well.

Imagine a runtime companion that:

  • observes recurring failures,
  • captures the execution context,
  • proposes a temporary patch,
  • asks for human approval,
  • applies the patch without restarting the process,
  • generates the equivalent GitHub pull request,
  • and removes the runtime patch once the fix ships in a normal release.

That idea has grown into what I'm calling a Runtime Reliability Manager (RRM).

Whether that's where Mykeels.CSharpRepl eventually ends up, I don't know yet.

But turning Slack into a live console made me realize that production applications don't have to be passive observers of their own failures.

Related Articles

Tags