Spec-Driven

Spec-Driven C#: Output

137 lines that replace Console.WriteLine and flush on disposal

Synopsis:
This page covers CSharp_TextFinder_Output, the sink. It receives fully formed strings, writes each as one line, and adds nothing but the terminator.
  • Two write methods differing only in what they add, and the placement is the access control - WriteText is on the class rather than the interface, so traversal cannot emit unterminated text.
  • The class is sealed, since a sink whose Output a subclass could override would put the emission order in the hands of a type this specification does not describe.
C# is the language the terminator rule was written for, and this component is where the prediction gets tested.
  • Console.WriteLine terminates with Environment.NewLine, so the framework's most obvious way to write a line is the one the specification forbids.
  • The rule was promoted to the parent specification on the C# case before any C# code existed. It held.
  • Console.Out is not used at all - the sink wraps the standard output stream directly, so nothing a library loaded later reconfigures can reach this program's output.
One sink, one buffer, and failure absorbed rather than reported.
  • A second sink throws, because two buffers over one stream reorder the output silently and only under load.
  • The rule costs one static field here, where Rust needs two constructs and C++ needs none at all.
  • A failed write flushes, writes one notice, and discards everything after - and no caller ever learns of it.

1.  The Sink

CSharp_TextFinder_Output receives fully formed strings from CSharp_TextFinder_Dirnav, writes each as one line to stdout, and absorbs any write failure so that neither the traversal nor the binary has to reason about it.
public sealed class StdoutSink : IOutput, IDisposable
{
    public StdoutSink();

    public void Output(string text);
    public void WriteText(string text);
    public void Flush();
    public void Dispose();
}
The class is sealed. Nothing in this project derives from it, and a sink whose Output a subclass could override would put the emission order §3.4 fixes in the hands of a type this specification does not describe. C++ reaches the same place from the other direction, its sink deriving from a base class it may not itself be derived from in practice; Rust has no inheritance to close off. Two methods write, and they differ only in what they add.
  • Output, the interface method, writes the string it is given followed by the single LF Spec_TextFinder.md §3.4 fixes. It is the method CSharp_TextFinder_Dirnav reaches, and the interface is the only thing that library knows about this one.
  • WriteText writes the string verbatim, adding nothing. It exists for the help text of §5.1 and the option listing of §5.3, which CSharp_TextFinder_Cmdline returns already terminated.
WriteText is declared on the class and not on IOutput, and that placement is the access control: CSharp_TextFinder_Dirnav holds a TOutput constrained to the interface and can reach only what the interface declares, so it cannot emit unterminated text. Both route through one private Emit(text, terminate), so the failed-state check and the write have one definition. The type takes no configuration. CSharp_TextFinder_Dirnav formats every line in full before emitting it - a block's path line, a block's indented detail lines, and every announcement alike - so there is nothing left here to parameterize. The library does not know a block line from an announcement, does not indent a detail line, and does not apply /h, /n, or /L. Once constructed, neither method throws and neither reports failure to a caller.

2.  The Rule Written for This Language

Spec_TextFinder.md §3.4 obliges an implementation to prevent its runtime translating the LF terminator to CRLF. C# is the language that obligation was written for. Console.WriteLine terminates with Environment.NewLine, which is CRLF on Windows, so the framework's most obvious way to write a line is the one the specification forbids. The rule did not start in the parent. It lived in the C++ Output specification for several turns, binding C++ alone, and the cross-document review promoted it to Spec_TextFinder.md §3.4 with the C# case as its argument - before any C# code existed. This component is where that prediction gets tested, and it held. The sink owns a StreamWriter over Console.OpenStandardOutput() with three properties fixed by the specification rather than left to the code:
  • NewLine is "\n", so WriteLine emits one LF on every platform.
  • AutoFlush is false, per Section 5.
  • The encoding is a UTF8Encoding constructed encoderShouldEmitUTF8Identifier: false, so no byte-order mark precedes the first line. Encoding.UTF8 is rejected by name: that property's encoding emits a BOM when a StreamWriter opens a stream with it, which would put three bytes on stdout that §3.4 does not fix and that a fixture comparing bytes would see.
Console.Out is not used at all, and neither is Console.Write. The writer this library constructs wraps the standard output stream directly, so nothing the framework configures on Console.Out - its encoding, its newline, its autoflush - reaches this program's output. That is a stronger position than configuring Console.Out would give, since a library loaded later could reconfigure it. One requirement, three implementations, three mechanisms: C++ calls _setmode(1, _O_BINARY) and throws if it fails; Rust does nothing at all, its standard output performing no translation on any platform; C# replaces the writer. The integration suite checks the bytes rather than the characters, and finds no CR and no BOM. Output adds the terminator and nothing else - no prefix, no separator, no trailing content - because the string arrives fully formatted.

3.  One Sink, One Buffer

The constructor throws InvalidOperationException when a StdoutSink already exists, so the process holds one sink and one only. It also lets an IOException from Console.OpenStandardOutput propagate, which is the failure of a process started with no usable standard output handle. CSharp_TextFinder_Entry catches both and exits 2, the code §3.4 fixes for a failure that is not about the command line. The reason for the rule is the buffer of Section 5. Two sinks would wrap the same standard output stream with two independent buffers, and their contents would reach the stream in the order the buffers happened to fill rather than the order the lines were written - which would break the emission order §3.4 fixes, silently and only under load. A rule against a second sink is cheaper to enforce than a defect that appears only on long searches. The rule is held by a private static bool, cleared by Dispose, so a sink disposed before another is created releases the right to make one. A static mutable field is a smell in general and the specification argues that it is right here: the thing being guarded is a process-wide resource, one process holds one standard output handle, and the field is read and written on one thread before any other could exist. No lock is taken. Rust reaches the same conclusion through a thread_local! Cell, which it needs because a plain static there must be Sync. C# places no such bound on a static field, so the same rule costs one field here and two constructs there. C++ has no equivalent rule at all, its sink being a local of main that nothing else constructs. CSharp_TextFinder_Entry builds exactly one sink, so its own code cannot provoke the exception. It handles the case because a constructor with a failure mode that callers ignore is a constructor whose failure mode will eventually be reached, and because that exception is what gives exit code 2 a path in this implementation at all.

4.  Error Handling

A write that fails - a closed pipe, a full disk - sets an internal failed state. On the first such failure the library flushes stdout and then writes the single line output failed to stderr. Thereafter it discards every string it is given and writes nothing more, to stdout or stderr. Three properties of that sequence are stated in the specification rather than left to the code.
  • The flush comes first, so every line already buffered reaches the stream ahead of the notice explaining why the lines stop. It is best-effort: whatever broke the write may break it too, and that inner flush is itself wrapped.
  • The failed state is permanent. One notice, not one per discarded line, which is what keeps a broken pipe from turning a long search into a long stderr transcript.
  • The failure reaches no caller. The library never throws after construction, never returns a status, and never lets the failure reach CSharp_TextFinder_Dirnav, which goes on traversing.
Every write is wrapped against IOException and ObjectDisposedException, the two the framework's stream writes document, and the specification refuses a broader catch in as many words: an OutOfMemoryException or a NullReferenceException from this library is a defect in it rather than a stream failure, and swallowing one would hide that defect behind a notice about output. The two types are named in one predicate, IsWriteFailure, used from every catch filter in the file. A method returning void where the operation returns a status is usually worth questioning, and here it is the interface the structure document asked for. The IOutput method returns nothing, so absorbing the failure is the contract; the alternative is an interface method returning a status and a traversal that must decide what to do about a sink it was given rather than chose. The flush performed on Dispose obeys the same rule, and the specification names that case separately for a reason: the final flush is the write most likely to be the first one that fails, being the only one that must reach the stream and, on a short search, the only one that reaches it at all. Nothing follows it, so nothing is left to discard. A failed write does not affect the exit code, which the Entry specification reserves for command-line and startup failures. A run whose output went nowhere still exits 0: the exit code answers whether TextFinder could do what it was asked, not whether the reader received it. The notice is written with Console.Error.Write and an explicit \n, so stderr carries LF here as it does everywhere else in this implementation.

5.  Buffering and Disposal

AutoFlush is false, so the StreamWriter defers a write until its buffer fills. With AutoFlush set the writer would flush on every call, and a search emitting 79 lines - the count invocation 2 of the demonstration reports - would pay 79 system calls for nothing. No flush is performed per line. The stream is flushed when the sink is disposed, and Flush performs the same write on demand, for the one case that needs the buffer drained before the process ends. Disposal is the whole of the mechanism, and C# leaves no alternative. The language has no destructor that runs at scope exit, so the C++ implementation's reliance on one and the Rust implementation's on Drop have no counterpart here. A sink that flushed from a finalizer would flush at a time the runtime chooses, and the runtime is free to run no finalizer at all before the process exits - which on a short search would mean losing the whole output. That moves the guarantee out of this type and into its caller, and both specifications say so on their own side. This one states the requirement and records that the type cannot enforce it alone; Spec_CSharp_TextFinder_Entry.md §4 discharges it with a using statement covering the rest of Main. The Entry page covers why the construction sits outside that statement. Dispose is written to be safe to call twice, since a using statement on a sink already disposed by other means would otherwise fault. A second call flushes nothing and clears nothing. The unit suite asserts it, because the property is one a later edit could remove without any other test noticing. One rule keeps the deferral from reordering the output: stdout is flushed before any write to stderr. This library applies the rule to its own output failed notice, and CSharp_TextFinder_Entry applies it to the diagnostic that follows the option listing on an invalid /r. Invocation 10 of the demonstration is that case: nine listing lines on stdout, then two diagnostic lines on stderr, in that order. Because this object owns the only writer over standard output in the process, the binary's help text and option listing pass through WriteText rather than through a writer of their own. They therefore share this buffer and reach the stream in the order written, and the ordering rule above is the only coordination needed.

6.  Source

StdoutSink.cs in full. At 137 lines it is the second-smallest source in the project, and the ratio to its 115-line specification is the point of the component: a sink that does one thing can be specified exhaustively.
src/StdoutSink.cs
// StdoutSink.cs - the process's one stdout writer, per Spec_CSharp_TextFinder_Output.md

using System.Text;
using CSharp_TextFinder_Dirnav;

namespace CSharp_TextFinder_Output;

// §4: sealed, so no subclass can override the emission order Spec_TextFinder.md §3.4 fixes.
public sealed class StdoutSink : IOutput, IDisposable
{
    // §4: the one-sink rule. A static field needs no lock here: one process holds one
    // standard output handle, and this is read and written on one thread before any
    // other could exist. Rust needs a thread_local Cell for the same rule because a
    // plain static there must be Sync; C# places no such bound on a static field.
    private static bool _taken;

    private readonly StreamWriter _writer;
    private bool _failed;
    private bool _disposed;

    public StdoutSink()
    {
        if (_taken)
        {
            throw new InvalidOperationException("a StdoutSink already exists");
        }

        // §5: Console.WriteLine terminates with Environment.NewLine, CRLF on Windows, so
        // this writer is built over the standard output stream with NewLine fixed to LF.
        // Encoding.UTF8 would emit a byte-order mark; this encoding does not.
        Stream standardOutput = Console.OpenStandardOutput();
        _writer = new StreamWriter(standardOutput, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
        {
            AutoFlush = false,
            NewLine = "\n",
        };

        _taken = true;
    }

    // §4: the interface method. The string, then the single LF §3.4 fixes.
    public void Output(string text) => Emit(text, terminate: true);

    // §4: declared on the class and not on IOutput, so Dirnav cannot emit unterminated text.
    public void WriteText(string text) => Emit(text, terminate: false);

    // §7: for the one case that needs the buffer drained before the process ends.
    public void Flush()
    {
        if (_failed || _disposed) return;

        try
        {
            _writer.Flush();
        }
        catch (Exception e) when (IsWriteFailure(e))
        {
            Fail();
        }
    }

    // §7: disposal is the whole of the flush mechanism. C# has no scope-exit destructor,
    // and a finalizer may never run, so the caller's using statement carries the guarantee.
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        if (!_failed)
        {
            try
            {
                _writer.Flush();
            }
            catch (Exception e) when (IsWriteFailure(e))
            {
                // §6: the final flush is the write most likely to be the first that fails.
                _failed = true;
                Console.Error.Write("output failed\n");
            }
        }

        try
        {
            _writer.Dispose();
        }
        catch (Exception e) when (IsWriteFailure(e))
        {
            // Dispose flushes again; the notice above already reported the failure.
        }

        _taken = false;
    }

    private void Emit(string text, bool terminate)
    {
        if (_failed || _disposed) return;

        try
        {
            if (terminate)
            {
                _writer.WriteLine(text);
            }
            else
            {
                _writer.Write(text);
            }
        }
        catch (Exception e) when (IsWriteFailure(e))
        {
            Fail();
        }
    }

    // §6: flush first, then one notice, then discard everything after. The flush is
    // best-effort, since whatever broke the write may break it too.
    private void Fail()
    {
        _failed = true;

        try
        {
            _writer.Flush();
        }
        catch (Exception e) when (IsWriteFailure(e))
        {
        }

        Console.Error.Write("output failed\n");
    }

    // §6: the two the framework's stream writes document. A broader catch would hide a
    // defect in this library behind a notice about output.
    private static bool IsWriteFailure(Exception e) =>
        e is IOException or ObjectDisposedException;
}
Dispose repeats the failure handling rather than calling Fail, because Fail flushes first and the flush is what has just been attempted. The duplication is four lines and the alternative is a flush of a buffer that was reported unwritable one statement earlier. The _writer.Dispose() that follows is wrapped for the same reason: it flushes again, and by then the notice has already been written.

7.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_CSharp_TextFinder_Output.md and its Fix companion, and neither was written: this thread produced one record covering every turn, and it sits on the Process page, whose Section 3.2 traces the terminator rule from the C++ specification through the parent to this component.