Not sure if you ever solved this, but I can tell you what seems to work fine for me.
In my override of ExecuteAsync, everything is within a try...catch...finally statement. In my case, within the try block I'm calling all sorts of async tasks and passing the CancellationToken to them.
My catch statements look something like this:
catch (OperationCanceledException)
{
// maybe you do some state management or cleanup stuff
_logger.Verbose("Shutting down service by request."); // I'm using serilog, but whatever
// you can see this is basically just logging and swallowing the exception.
}
catch (Exception ex) // generic handler
{
_logger.Fatal($"Big problems abound: {ex.Message}");
Environment.Exit(1);
}
I have a finally block as follows (shown in context, just sub the catch for the ones above):
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try {..}
catch { } // the specific catch blocks I had above
finally
{
_logger.Information("Service Stopped.");
}
}
And that's it, I'm out! Notice there's no Environment.Exit(0), and notice that the catch of the OperationCanceledException also isn't calling an exit. It's just logging that an organized call to shut down happened, and let's the execution flow through to the bottom of the method.
As you said, if I have the Environment.Exit(0) anywhere in there, it throws the 'stopped unexpectedly' error in Windows. I believe this happens because your program is technically not able to run to the end -- the ExecuteAsync method is just passing control back to the Main method in program.cs, so when you call the Exit(0) method, you're actually cutting the whole process short.
With the above, I get clean logging of the shutdown process I go through, and no error when stopping the service.
Hope it helps!