Hi there! I'm Chris Benard.

I'm a software developer living in Texas. I specialize in .Net and connected systems, and I like to help machines talk to each other. I write about programming topics and personal stuff too here on this site. Feel free to read more or get in touch!

Get in Touch – or – Read My Blog

Latest From My Blog

Extract Text from RTF in C#/.Net

At work, I was tasked with creating a class to strip RTF tags from RTF formatted text, leaving only the plain text. Microsoft’s RichTextBox can do this with its Text property, but it was unavailable in the context in which I’m working.

RTF formatting uses control characters escaped with backslashes along with nested curly braces. Unfortunately, the nesting means I can’t kill the control characters using a single regex, since I’d have to process the stack, and in addition, some control characters should be translated, such as newline and tab characters.

Example:

{\rtf1\ansi\deff0
{\colortbl;\red0\green0\blue0;\red255\green0\blue0;}
This line is the default color\line
\cf2
This line is red\line
\cf1
This line is the default color
}

Thankfully, Markus Jarderot provided a great answer over at StackOverflow, but unfortunately for me, it’s written in Python. I don’t know Python, but I translated it to the best of my abilities to C# since it was very readable.

If this is useful to you, you can download the C# version, or view the original/new code below.

def striprtf(text):
   pattern = re.compile(r"\\([a-z]{1,32})(-?\d{1,10})?[ ]?|\\'([0-9a-f]{2})|\\([^a-z])|([{}])|[\r\n]+|(.)", re.I)
   # control words which specify a "destionation".
   destinations = frozenset((
      'aftncn','aftnsep','aftnsepc','annotation','atnauthor','atndate','atnicn','atnid',
      'atnparent','atnref','atntime','atrfend','atrfstart','author','background',
      'bkmkend','bkmkstart','blipuid','buptim','category','colorschememapping',
      'colortbl','comment','company','creatim','datafield','datastore','defchp','defpap',
      'do','doccomm','docvar','dptxbxtext','ebcend','ebcstart','factoidname','falt',
      'fchars','ffdeftext','ffentrymcr','ffexitmcr','ffformat','ffhelptext','ffl',
      'ffname','ffstattext','field','file','filetbl','fldinst','fldrslt','fldtype',
      'fname','fontemb','fontfile','fonttbl','footer','footerf','footerl','footerr',
      'footnote','formfield','ftncn','ftnsep','ftnsepc','g','generator','gridtbl',
      'header','headerf','headerl','headerr','hl','hlfr','hlinkbase','hlloc','hlsrc',
      'hsv','htmltag','info','keycode','keywords','latentstyles','lchars','levelnumbers',
      'leveltext','lfolevel','linkval','list','listlevel','listname','listoverride',
      'listoverridetable','listpicture','liststylename','listtable','listtext',
      'lsdlockedexcept','macc','maccPr','mailmerge','maln','malnScr','manager','margPr',
      'mbar','mbarPr','mbaseJc','mbegChr','mborderBox','mborderBoxPr','mbox','mboxPr',
      'mchr','mcount','mctrlPr','md','mdeg','mdegHide','mden','mdiff','mdPr','me',
      'mendChr','meqArr','meqArrPr','mf','mfName','mfPr','mfunc','mfuncPr','mgroupChr',
      'mgroupChrPr','mgrow','mhideBot','mhideLeft','mhideRight','mhideTop','mhtmltag',
      'mlim','mlimloc','mlimlow','mlimlowPr','mlimupp','mlimuppPr','mm','mmaddfieldname',
      'mmath','mmathPict','mmathPr','mmaxdist','mmc','mmcJc','mmconnectstr',
      'mmconnectstrdata','mmcPr','mmcs','mmdatasource','mmheadersource','mmmailsubject',
      'mmodso','mmodsofilter','mmodsofldmpdata','mmodsomappedname','mmodsoname',
      'mmodsorecipdata','mmodsosort','mmodsosrc','mmodsotable','mmodsoudl',
      'mmodsoudldata','mmodsouniquetag','mmPr','mmquery','mmr','mnary','mnaryPr',
      'mnoBreak','mnum','mobjDist','moMath','moMathPara','moMathParaPr','mopEmu',
      'mphant','mphantPr','mplcHide','mpos','mr','mrad','mradPr','mrPr','msepChr',
      'mshow','mshp','msPre','msPrePr','msSub','msSubPr','msSubSup','msSubSupPr','msSup',
      'msSupPr','mstrikeBLTR','mstrikeH','mstrikeTLBR','mstrikeV','msub','msubHide',
      'msup','msupHide','mtransp','mtype','mvertJc','mvfmf','mvfml','mvtof','mvtol',
      'mzeroAsc','mzeroDesc','mzeroWid','nesttableprops','nextfile','nonesttables',
      'objalias','objclass','objdata','object','objname','objsect','objtime','oldcprops',
      'oldpprops','oldsprops','oldtprops','oleclsid','operator','panose','password',
      'passwordhash','pgp','pgptbl','picprop','pict','pn','pnseclvl','pntext','pntxta',
      'pntxtb','printim','private','propname','protend','protstart','protusertbl','pxe',
      'result','revtbl','revtim','rsidtbl','rxe','shp','shpgrp','shpinst',
      'shppict','shprslt','shptxt','sn','sp','staticval','stylesheet','subject','sv',
      'svb','tc','template','themedata','title','txe','ud','upr','userprops',
      'wgrffmtfilter','windowcaption','writereservation','writereservhash','xe','xform',
      'xmlattrname','xmlattrvalue','xmlclose','xmlname','xmlnstbl',
      'xmlopen',
   ))
   # Translation of some special characters.
   specialchars = {
      'par': '\n',
      'sect': '\n\n',
      'page': '\n\n',
      'line': '\n',
      'tab': '\t',
      'emdash': u'\u2014',
      'endash': u'\u2013',
      'emspace': u'\u2003',
      'enspace': u'\u2002',
      'qmspace': u'\u2005',
      'bullet': u'\u2022',
      'lquote': u'\u2018',
      'rquote': u'\u2019',
      'ldblquote': u'\201C',
      'rdblquote': u'\u201D', 
   }
   stack = []
   ignorable = False       # Whether this group (and all inside it) are "ignorable".
   ucskip = 1              # Number of ASCII characters to skip after a unicode character.
   curskip = 0             # Number of ASCII characters left to skip
   out = []                # Output buffer.
   for match in pattern.finditer(text):
      word,arg,hex,char,brace,tchar = match.groups()
      if brace:
         curskip = 0
         if brace == '{':
            # Push state
            stack.append((ucskip,ignorable))
         elif brace == '}':
            # Pop state
            ucskip,ignorable = stack.pop()
      elif char: # \x (not a letter)
         curskip = 0
         if char == '~':
            if not ignorable:
                out.append(u'\xA0')
         elif char in '{}\\':
            if not ignorable:
               out.append(char)
         elif char == '*':
            ignorable = True
      elif word: # \foo
         curskip = 0
         if word in destinations:
            ignorable = True
         elif ignorable:
            pass
         elif word in specialchars:
            out.append(specialchars[word])
         elif word == 'uc':
            ucskip = int(arg)
         elif word == 'u':
            c = int(arg)
            if c < 0: c += 0x10000
            if c > 127: out.append(unichr(c))
            else: out.append(chr(c))
            curskip = ucskip
      elif hex: # \'xx
         if curskip > 0:
            curskip -= 1
         elif not ignorable:
            c = int(hex,16)
            if c > 127: out.append(unichr(c))
            else: out.append(chr(c))
      elif tchar:
         if curskip > 0:
            curskip -= 1
         elif not ignorable:
            out.append(tchar)
   return ''.join(out)
/// <summary>
/// Rich Text Stripper
/// </summary>
/// <remarks>
/// Translated from Python located at:
/// http://stackoverflow.com/a/188877/448
/// </remarks>
public static class RichTextStripper
{
    private class StackEntry
    {
        public int NumberOfCharactersToSkip { get; set; }
        public bool Ignorable { get; set; }

        public StackEntry(int numberOfCharactersToSkip, bool ignorable)
        {
            NumberOfCharactersToSkip = numberOfCharactersToSkip;
            Ignorable = ignorable;
        }
    }

    private static readonly Regex _rtfRegex = new Regex(@"\\([a-z]{1,32})(-?\d{1,10})?[ ]?|\\'([0-9a-f]{2})|\\([^a-z])|([{}])|[\r\n]+|(.)", RegexOptions.Singleline | RegexOptions.IgnoreCase);

    private static readonly List<string> destinations = new List<string>
    {
        "aftncn","aftnsep","aftnsepc","annotation","atnauthor","atndate","atnicn","atnid",
        "atnparent","atnref","atntime","atrfend","atrfstart","author","background",
        "bkmkend","bkmkstart","blipuid","buptim","category","colorschememapping",
        "colortbl","comment","company","creatim","datafield","datastore","defchp","defpap",
        "do","doccomm","docvar","dptxbxtext","ebcend","ebcstart","factoidname","falt",
        "fchars","ffdeftext","ffentrymcr","ffexitmcr","ffformat","ffhelptext","ffl",
        "ffname","ffstattext","field","file","filetbl","fldinst","fldrslt","fldtype",
        "fname","fontemb","fontfile","fonttbl","footer","footerf","footerl","footerr",
        "footnote","formfield","ftncn","ftnsep","ftnsepc","g","generator","gridtbl",
        "header","headerf","headerl","headerr","hl","hlfr","hlinkbase","hlloc","hlsrc",
        "hsv","htmltag","info","keycode","keywords","latentstyles","lchars","levelnumbers",
        "leveltext","lfolevel","linkval","list","listlevel","listname","listoverride",
        "listoverridetable","listpicture","liststylename","listtable","listtext",
        "lsdlockedexcept","macc","maccPr","mailmerge","maln","malnScr","manager","margPr",
        "mbar","mbarPr","mbaseJc","mbegChr","mborderBox","mborderBoxPr","mbox","mboxPr",
        "mchr","mcount","mctrlPr","md","mdeg","mdegHide","mden","mdiff","mdPr","me",
        "mendChr","meqArr","meqArrPr","mf","mfName","mfPr","mfunc","mfuncPr","mgroupChr",
        "mgroupChrPr","mgrow","mhideBot","mhideLeft","mhideRight","mhideTop","mhtmltag",
        "mlim","mlimloc","mlimlow","mlimlowPr","mlimupp","mlimuppPr","mm","mmaddfieldname",
        "mmath","mmathPict","mmathPr","mmaxdist","mmc","mmcJc","mmconnectstr",
        "mmconnectstrdata","mmcPr","mmcs","mmdatasource","mmheadersource","mmmailsubject",
        "mmodso","mmodsofilter","mmodsofldmpdata","mmodsomappedname","mmodsoname",
        "mmodsorecipdata","mmodsosort","mmodsosrc","mmodsotable","mmodsoudl",
        "mmodsoudldata","mmodsouniquetag","mmPr","mmquery","mmr","mnary","mnaryPr",
        "mnoBreak","mnum","mobjDist","moMath","moMathPara","moMathParaPr","mopEmu",
        "mphant","mphantPr","mplcHide","mpos","mr","mrad","mradPr","mrPr","msepChr",
        "mshow","mshp","msPre","msPrePr","msSub","msSubPr","msSubSup","msSubSupPr","msSup",
        "msSupPr","mstrikeBLTR","mstrikeH","mstrikeTLBR","mstrikeV","msub","msubHide",
        "msup","msupHide","mtransp","mtype","mvertJc","mvfmf","mvfml","mvtof","mvtol",
        "mzeroAsc","mzeroDesc","mzeroWid","nesttableprops","nextfile","nonesttables",
        "objalias","objclass","objdata","object","objname","objsect","objtime","oldcprops",
        "oldpprops","oldsprops","oldtprops","oleclsid","operator","panose","password",
        "passwordhash","pgp","pgptbl","picprop","pict","pn","pnseclvl","pntext","pntxta",
        "pntxtb","printim","private","propname","protend","protstart","protusertbl","pxe",
        "result","revtbl","revtim","rsidtbl","rxe","shp","shpgrp","shpinst",
        "shppict","shprslt","shptxt","sn","sp","staticval","stylesheet","subject","sv",
        "svb","tc","template","themedata","title","txe","ud","upr","userprops",
        "wgrffmtfilter","windowcaption","writereservation","writereservhash","xe","xform",
        "xmlattrname","xmlattrvalue","xmlclose","xmlname","xmlnstbl",
        "xmlopen"
    };

    private static readonly Dictionary<string, string> specialCharacters = new Dictionary<string, string>
    {
        { "par", "\n" },
        { "sect", "\n\n" },
        { "page", "\n\n" },
        { "line", "\n" },
        { "tab", "\t" },
        { "emdash", "\u2014" },
        { "endash", "\u2013" },
        { "emspace", "\u2003" },
        { "enspace", "\u2002" },
        { "qmspace", "\u2005" },
        { "bullet", "\u2022" },
        { "lquote", "\u2018" },
        { "rquote", "\u2019" },
        { "ldblquote", "\u201C" },
        { "rdblquote", "\u201D" }, 
    };
    /// <summary>
    /// Strip RTF Tags from RTF Text
    /// </summary>
    /// <param name="inputRtf">RTF formatted text</param>
    /// <returns>Plain text from RTF</returns>
    public static string StripRichTextFormat(string inputRtf)
    {
        if (inputRtf == null)
        {
            return null;
        }

        string returnString;

        var stack = new Stack<StackEntry>();
        bool ignorable = false;              // Whether this group (and all inside it) are "ignorable".
        int ucskip = 1;                      // Number of ASCII characters to skip after a unicode character.
        int curskip = 0;                     // Number of ASCII characters left to skip
        var outList = new List<string>();    // Output buffer.

        MatchCollection matches = _rtfRegex.Matches(inputRtf);

        if (matches.Count > 0)
        {
            foreach (Match match in matches)
            {
                string word = match.Groups[1].Value;
                string arg = match.Groups[2].Value;
                string hex = match.Groups[3].Value;
                string character = match.Groups[4].Value;
                string brace = match.Groups[5].Value;
                string tchar = match.Groups[6].Value;

                if (!String.IsNullOrEmpty(brace))
                {
                    curskip = 0;
                    if (brace == "{")
                    {
                        // Push state
                        stack.Push(new StackEntry(ucskip, ignorable));
                    }
                    else if (brace == "}")
                    {
                        // Pop state
                        StackEntry entry = stack.Pop();
                        ucskip = entry.NumberOfCharactersToSkip;
                        ignorable = entry.Ignorable;
                    }
                }
                else if (!String.IsNullOrEmpty(character)) // \x (not a letter)
                {
                    curskip = 0;
                    if (character == "~")
                    {
                        if (!ignorable)
                        {
                            outList.Add("\xA0");
                        }
                    }
                    else if ("{}\\".Contains(character))
                    {
                        if (!ignorable)
                        {
                            outList.Add(character);
                        }
                    }
                    else if (character == "*")
                    {
                        ignorable = true;
                    }
                }
                else if (!String.IsNullOrEmpty(word)) // \foo
                {
                    curskip = 0;
                    if (destinations.Contains(word))
                    {
                        ignorable = true;
                    }
                    else if (ignorable)
                    {
                    }
                    else if (specialCharacters.ContainsKey(word))
                    {
                        outList.Add(specialCharacters[word]);
                    }
                    else if (word == "uc")
                    {
                        ucskip = Int32.Parse(arg);
                    }
                    else if (word == "u")
                    {
                        int c = Int32.Parse(arg);
                        if (c < 0)
                        {
                            c += 0x10000;
                        }
                        outList.Add(Char.ConvertFromUtf32(c));
                        curskip = ucskip;
                    }
                }
                else if (!String.IsNullOrEmpty(hex)) // \'xx
                {
                    if (curskip > 0)
                    {
                        curskip -= 1;
                    }
                    else if (!ignorable)
                    {
                        int c = Int32.Parse(hex, System.Globalization.NumberStyles.HexNumber);
                        outList.Add(Char.ConvertFromUtf32(c));
                    }
                }
                else if (!String.IsNullOrEmpty(tchar))
                {
                    if (curskip > 0)
                    {
                        curskip -= 1;
                    }
                    else if (!ignorable)
                    {
                        outList.Add(tchar);
                    }
                }
            }
        }
        else
        {
            // Didn't match the regex
            returnString = inputRtf;
        }

        returnString = String.Join(String.Empty, outList.ToArray());

        return returnString;
    }
}

Conditional Proxying In Chrome Like FoxyProxy

This article assumes you already know how to set up a SOCKS proxy, likely via SSH using PuTTY.

Lots of people use FoxyProxy in Firefox to selectively proxy based on rules. FoxyProxy for Chrome exists now, which uses the Chrome Proxy API. However, this still leaks DNS via pre-fetch queries in other places in the Chrome browser (and possibly via other extensions).

If you want to force Chrome to use a conditional proxy and stop DNS leaks, you can use the --host-resolver-rules switch with a series of rules. You can either use FoxyProxy for Chrome if you trust it, or pass your own PAC (Proxy Auto Configuration) file, which is just a simple javascript function.

Create the PAC

Assume you save this file as C:\mypac.pac and you’ve set up a SOCKS5 proxy at localhost:8000.

function FindProxyForURL(url, host)
{
    // The "(.*\.)?" pattern ensures it matches the
    //   top level and sub-domains.
    if (/^(.*\.)?nonproxieddomain1\.com$/i.test(host) ||
        /^(.*\.)?nonproxieddomain2\.com$/i.test(host) ||
        /^localhost$/i.test(host)) {
        // Do not proxy
        return "DIRECT";
    }
    else {
        // Go through proxy
        return "SOCKS5 localhost:1080";
    }
}

Host Resolver Rules

Chrome has a command line parameter called --host-resolver-rules. This parameter allows you stop DNS leaks as mentioned above. You use the MAP command to map all addresses to ~NOTFOUND except for addresses you EXCLUDE.

You must keep this in lockstep with the FindProxyForURL function from your PAC. The reason for this is that you are telling Chrome to use your proxy for name resolution, for all but the regex matched domains. If a regex matches, then it will attempt to use DIRECT, meaning regular machine name resolution. If the regex matches, and you haven’t added an EXCLUDE entry, you will get a “domain not found” or similar name resolution error in Chrome when you try to reach the site.

It’s worth mentioning that the EXCLUDE entries in the resolver rules do not use regex and instead just use a wild card syntax, so you will need to duplicate each domain (once with wild card and once without) to match the top level and sub-domains.

Now that you understand both options and you have created your PAC file, you can now close Chrome and re-run it with new options!

Run Chrome With New Options

You will probably want to change your Chrome shortcut to the following:

"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --proxy-pac-url="file:///c:/mypac.pac" --host-resolver-rules="MAP * ~NOTFOUND,EXCLUDE localhost,EXCLUDE nonproxieddomain1.com,EXCLUDE *.nonproxieddomain1.com,EXCLUDE nonproxieddomain2.com,EXCLUDE *.nonproxieddomain2.com"

You can now check your IP at a site like IPChicken or by simply Googling “IP”. You can play with excluding those sites from the proxy (but be sure to add them to the EXCLUDE list) and re-checking. One thing to note: Chrome does not pick up PAC changes immediately. You need to go to chrome://net-internals/#proxy and click “Re-apply settings”. You can also clear the DNS cache at chrome://net-internals/#dns.

Prove You Are Completely Proxied

You can prove that you are proxied and your DNS is not leaking by running the following:

ipconfig /flushdns
ipconfig /displaydns | find /i "example.org"

Then, visit a site which is not excluded and is proxied in Chrome. Then run the following again (do not re-run ipconfig /flushdns)

ipconfig /displaydns | find /i "example.org"

You should not see any entries for “example.org”. If you repeat the process for a site which is EXCLUDEd and sent DIRECT instead of proxied, you should see it listed in the /displaydns output.


Interesting Threading Issue In .Net

Yesterday I noticed a strange anomaly in the logs of an application I wrote and manage at work while investigating another issue. It manifested as us sending duplicate messages from a queue to a third party over and over.

I looked into the code and since threading was involved, I figured there was some thread safety/shared state issue involved. I consulted with a couple of coworkers who didn’t immediately notice any issues, but I created some simple test cases to test my assumptions about threading with lambdas.

Simple Example of the problem

Below you will see me attempting to create 5 workers, do a small unit of work, and then write to the console. I would expect each worker to write out the Int32 it was created with. My assumption was that the C# compiler would, with the lambda expression, create a copy of i for the thread being created and use that copy. I was entirely wrong.

As you can see, when the worker is created, on the initial thread, each Console.WriteLine has the right value of i, but when the thread is running, it contains the last value of i, 6 (for loop increments it after its last value value causing the loop to exit).

var rand = new Random();
var threads = new List<Thread>();

for (int i = 1; i <= 5; i++)
{
    Console.WriteLine("Creating worker {0}.", i);
    
    Thread t = new Thread(() =>
    {
        // Simulate work
        Thread.Sleep(rand.Next(500, 2000));
        
        Console.WriteLine("Finished running worker {0}.", i);
    });
    threads.Add(t);
}

threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());

/* Output:
Creating worker 1.
Creating worker 2.
Creating worker 3.
Creating worker 4.
Creating worker 5.
Finished running worker 6.
Finished running worker 6.
Finished running worker 6.
Finished running worker 6.
Finished running worker 6. */

Simple Fix

The C# compiler did not make a copy of the state, but we can do this directly and pass it in using ParameterizedThreadStart. This makes the list a collection of Int32/Thread pairs. Obviously, in our actual app, our state object is more complex than an Int32.

var rand = new Random();
var threads = new List<Tuple<int, Thread>>();

for (int i = 1; i <= 5; i++)
{
    Console.WriteLine("Creating worker {0}.", i);
    
    Thread t = new Thread(x =>
    {
        // Simulate work
        Thread.Sleep(rand.Next(500, 2000));
        
        Console.WriteLine("Finished running worker {0}.", x);
    });
    threads.Add(Tuple.Create(i, t));
}

threads.ForEach(tuple => tuple.Item2.Start(tuple.Item1));
threads.ForEach(tuple => tuple.Item2.Join());

/* Output:
Creating worker 1.
Creating worker 2.
Creating worker 3.
Creating worker 4.
Creating worker 5.
Finished running worker 1.
Finished running worker 4.
Finished running worker 2.
Finished running worker 3.
Finished running worker 5. */

Better Fix

That works, but it doesn’t really reflect the original intent. I receive X number of things to do, and in the real product, a Semaphore was used to control the maximum number of messages that were sent at a time.

For instance, if I received 200 messages from the queue to send and I can send 50 messages at a time, I would spin up 200 threads which would wait on a semaphore, sending 50 maximum at a time. Obviously this is inefficient, and I don’t really have an excuse for why I did it this way when I converted it from a single-threaded process that could not keep up with demand to a multi-threaded process which ended up with this duplication problem. In retrospect, I would never have done this.

The following has a queue of 15 work items which is serviced by 5 worker threads and represents close to how the code works now.

Queue With Workers Serving It
var rand = new Random();
var threads = new List<Thread>();
var queueLocker = new object();
var queue = new Queue<int>();
const short maxWorkers = 5;

// Create dummy data for processing
for (int job = 1; job <= 15; job++)
{
    queue.Enqueue(job);
}

for (int i = 1; i <= maxWorkers; i++)
{
    Console.WriteLine("Creating worker {0}.", i);
    
    Thread t = new Thread(() =>
    {
        int? job = null;
        
        // Try to get job from queue to handle
        while (queue.Count > 0)
        {
            lock (queueLocker)
            {
                if (queue.Count > 0)
                {
                    job = queue.Dequeue();
                }
            }
            
            if (job.HasValue)
            {
                // Simulate work
                Thread.Sleep(rand.Next(500, 2000));
                
                Console.WriteLine(
                    "Worker {0} finished running job {1}.",
                    Thread.CurrentThread.Name,
                    job);
            }
        }

        Console.WriteLine(
            "Worker {0} has no more work. Exiting.",
            Thread.CurrentThread.Name);
    });
    t.Name = i.ToString();
    threads.Add(t);
}

threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());

/* Output:
Creating worker 1.
Creating worker 2.
Creating worker 3.
Creating worker 4.
Creating worker 5.
Worker 4 finished running job 4.
Worker 5 finished running job 5.
Worker 1 finished running job 1.
Worker 2 finished running job 2.
Worker 3 finished running job 3.
Worker 1 finished running job 8.
Worker 2 finished running job 9.
Worker 4 finished running job 6.
Worker 5 finished running job 7.
Worker 3 finished running job 10.
Worker 1 finished running job 11.
Worker 1 has no more work. Exiting.
Worker 2 finished running job 12.
Worker 2 has no more work. Exiting.
Worker 4 finished running job 13.
Worker 4 has no more work. Exiting.
Worker 5 finished running job 14.
Worker 5 has no more work. Exiting.
Worker 3 finished running job 15.
Worker 3 has no more work. Exiting. */

Even Better Fix I Can’t Use

Unfortunately, this code is limited to .Net 3.5 right now, but this particular problem looks like a great match for the Task Parallel Library in .Net >= 4.0. It would offload all the thread handling to .Net which is particularly well-suited to this problem: running tasks in parallel.