...And sure enough, that turned out to be my solution in C# as well.
The fact that I wasn't able to use the various String classes, and had to write my own "network buffer" class to do protocol-ish stuff was one of my earliest gripes with Java, too.
Made a class "UsefulStream" which has an async method "BeginGetLine" taking the .NET-standard AsyncCallback delegate. The class then sees if it has a line. If not, begins an async read with an internal callback. Keeps checking for lines or eof, reading more as necessary. Eventually calls back into the caller's callback with the useless IAsyncResult value (I used null). Then caller invokes EndGetLine() on its UsefulStream instance, which returns the String, or null if EOF.
This actually makes things pretty easy now. I can centralize timeouts inside that class. I'll probably mimic Apache with both soft and hard timeouts (time since last read, time since first read).
UsefulStream always assumes lines are ASCII. (as protocol headers tend to be... except memcached's text protocol, which supports object key names with 8bit, I believe... feh)
And next I plan to add normal "BeginRead/EndRead" to UsefulStream, which pretty much wrap the normal NetworkStream, but also return the left-over crap that was read in while we were reading lines. (it already works the other way around: switching from getting byte chunks to reading lines...)
But yeah, hacky.
And of course this is all 2 fucking lines in Perl:
$line = <F>
read(F, $dst_scalar, $n_bytes_to_read);
(and you can even change the line separator with $/ !)
I love you Perl.