Wednesday, October 18, 2006

Ctrl-C and the .NET console application

I occasionally find myself writing a .NET console application, usually a one-off program used to test code that I'm working on. C# seems well-disposed to writing such programs. For example, I might need an application to listen for messages from another application, whether over sockets, HTTP, or another protocol--the sort of application that would listen for and process incoming messages, continuing forever. It's the sort of app you simply run from a console window and when you're done give it the old Ctrl-C. Just hit Ctrl-C or Ctrl-Break on the console window and the application quickly goes away.

Now, there's actually something a little sinister going on here... when the app goes away it's actually exits rather abruptly. There’s no exception thrown, no catch or finally blocks executed--it just (poof) goes away. This isn’t often a problem for me, but if my program modifies some system state while it’s running and needs to revert that state on exit, well, then it is an issue.

As a simple example consider a program that logs incoming messages. It may have a file open and log messages as they come in. (Or it could alter a registry setting or some other system state to indicate its presence only to revert that setting on exit. Get the idea?) If the app doesn’t terminate normally, how can it clean up after itself?

First Try: Simple, but broken

To make this a bit more concrete consider this example code. The tricky bit is that when you press Ctrl-C to terminate the program there is no clean up being done. Specifically, the log file isn't flushed to disk. When I run this program and hit Ctrl-C the log file is left entirely empty. This is because the program hasn't written enough to the file to flush the file buffer to disk when the program is interrupted. Remember, this program isn't exiting normally when we use Ctrl-C to interrupt it. In this case, the text "Handling messages." never appears in the log.

using System;
using System.IO;
using System.Threading;

namespace Example
{
class Program
{
static void Main(string[] args)
{
try
{
using (FileStream fs = new FileStream("sample.log", FileMode.Append, FileAccess.Write, FileShare.None))
using (StreamWriter logWriter = new StreamWriter(fs))
{
Console.WriteLine(
"This is the basic program. It doesn't call the clean-up code if you use Ctrl-C, "
+ "and the log file isn't flushed to disk.");
ProcessIncomingMessages(logWriter);
}
}
finally
{
Console.WriteLine("Clean-up code invoked.");
}
}

static void ProcessIncomingMessages(TextWriter writer)
{
writer.WriteLine("Handling messages.");
Thread.Sleep(Timeout.Infinite); // Well, pretend it processes messages, okay?
}
}
}



Console.CancelKeyPress Event


As you might guess there is a way to handle Ctrl-C and Ctrl-Break and it's the Console's CancelKeyPress event. By providing a delegate you can have the runtime notify you when a "cancel key" has been pressed. Here I've



  • Added an anonymous delegate to clean up if Ctrl-C or Ctrl-Break is pressed. As you can see it flushes the writer and closes the file stream.
  • I've also moved the fs and logWriter variables outside their using statements to allow access from the finally block.

If you read this code from top-to-bottom it may appear unusual that the code in the CancelKeyPress delegate appears to be using logWriter and fs before either variable is assigned a non-null reference. In fact, what is really happening there is that we're declaring the code for the delegate in line and handing it off to the CancelKeyPress event--it isn't executed where its position in the source suggests. In this case it's only executed when Ctrl-C or Ctrl-Break is pressed.


        static void Main(string[] args)
{
FileStream fs = null;
StreamWriter logWriter = null;
try
{
Console.WriteLine(
"This shows use of the cancel handler.");
Console.CancelKeyPress += delegate
{
Console.WriteLine("Clean-up code invoked in CancelKeyPress handler.");
if (logWriter != null)
logWriter.Flush();
if (fs != null)
fs.Close();
// The application terminates directly after executing this delegate.
};

fs = new FileStream("sample.log", FileMode.Append, FileAccess.Write, FileShare.None);
logWriter = new StreamWriter(fs);
ProcessIncomingMessages(logWriter);
}
finally
{
Console.WriteLine("Clean-up code invoked in finally.");
if (logWriter != null)
logWriter.Flush();
if (fs != null)
fs.Close();
}
}

Consolidating Clean Up Code


Here I've opted to put the clean-up code in one place rather than duplicating it in the finally block. I've declared my own delegate type CleanUpMethod so I have a tidy, parameter-less delegate that I can also call from the finally block.


        delegate void CleanUpMethod();

static void Main(string[] args)
{
FileStream fs = null;
StreamWriter logWriter = null;

string cleanUpLocation = "handler.";
CleanUpMethod cleanUp =
delegate
{
Console.WriteLine("Clean-up code invoked in " + cleanUpLocation);
if (logWriter != null)
logWriter.Flush();
if (fs != null)
fs.Close();
};

try
{
Console.WriteLine(
"This shows use of a single, no-param clean-up handler.");
Console.CancelKeyPress +=
delegate
{
cleanUp();
// The application terminates directly after executing this delegate.
};

fs = new FileStream("sample.log", FileMode.Append, FileAccess.Write, FileShare.None);
logWriter = new StreamWriter(fs);
ProcessIncomingMessages(logWriter);
}
finally
{
cleanUpLocation = "finally.";
cleanUp();
}
}


But why put the clean-up code in a delegate instead of it's own method? One very helpful aspect of using an anonymous delegate in this example is that the delegate code has access to the local variables in this method. If you use Lutz Roeder's .NET Reflector to take a look at this program you will see the substantial amount of work that the compiler does for you under the covers in order to provide easy access to the local variables from the delegate method:


private static void Main(string[] args)
{
ConsoleCancelEventHandler handler1 = null;
Program.<>c__DisplayClass3 class1 = new Program.<>c__DisplayClass3();
class1.fs = null;
class1.logWriter = null;
class1.cleanUpLocation = "handler.";
class1.cleanUp = new Program.CleanUpMethod(class1.
b__0);
try
{
Console.WriteLine("This shows use of a single, no-param clean-up handler.");
if (handler1 == null)
{
handler1 = new ConsoleCancelEventHandler(class1.
b__1);
}
Console.CancelKeyPress += handler1;
class1.fs = new FileStream("sample.log", FileMode.Append, FileAccess.Write, FileShare.None);
class1.logWriter = new StreamWriter(class1.fs);
Program.ProcessIncomingMessages(class1.logWriter);
}
finally
{
class1.cleanUpLocation = "finally.";
class1.cleanUp();
}
}

[CompilerGenerated]
private sealed class <>c__DisplayClass3
{
// Methods
public <>c__DisplayClass3()
{
}

public void
b__0()
{
Console.WriteLine("Clean-up code invoked in " + this.cleanUpLocation);
if (this.logWriter != null)
{
this.logWriter.Flush();
}
if (this.fs != null)
{
this.fs.Close();
}
}

public void <main>b__1(object, ConsoleCancelEventArgs)
{
this.cleanUp();
}

// Fields
public Program.CleanUpMethod cleanUp;
public string cleanUpLocation;
public FileStream fs;
public StreamWriter logWriter;
}



I'm pleased the C# compiler does so much work just to make my life easier. :)



Technorati tags: , ,

3 comments:

Anonymous said...

Great article! Thanks for the info. Just some feedback: One of the lines extends 4 times the width of the screen on IE6. Also, this comment link opens a window much too small, and in fact it auto scrolls down so that you can barely see the text box for the comments itself. Nice site!

Anonymous said...

Also, after posting a comment, a new comment page appears in the same window that popped up for the first comment. It gives the confirmation text, but it is immediately scrolled off the screen, so it can't be seen. It made me wonder if it even worked, until I scrolled up to read it.

Jody Shumaker said...

As an additional note, if you use a full method for this event delegate with the parameters, the arguments have an option to cancel termination of the app. You can use this to optionally only set a flag in your app to cancel operations, letting them cleanly finish what they were doing and exiting out.

Of course you could also make it so if a second ctrl+c is received, it just does simple cleanup and quits. The flag method is useful though for a more complicated app.