Skip to content
ConsoleMonster

Threads and concurrency

Any thread can update a live view: updates go through one queue, one thread draws, console output is captured, and CI gets whole, ordered lines.

Parallel work and a console do not mix on their own: every thread that writes to the console writes over the others. ConsoleMonster gives the screen to one thread and lets every other thread send it updates, so a live view stays whole however many workers report to it, and output that is not a terminal gets whole lines in order. The guarantees table at the end names the test behind each concurrency guarantee.

Twelve threads, one console

Each worker writes its progress with Write-Host as it goes. The pieces of different workers land in each other's lines.

ForEach-Object -Parallel 100×2424-bitFull
PowerShellthreads-naive.ps1View on GitHub
# Twelve servers patched in parallel, each writing its progress straight to the console (PowerShell 7).
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$servers | ForEach-Object -ThrottleLimit 12 -Parallel {
    foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
        Write-Host "$_ $step" -NoNewline
        Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
        Write-Host ' ... done'
    }
}

The same work through a dashboard

Each runspace updates its own lane by name and Write-Host goes to the log panel. The session draws the screen about 15 times a second from one thread.

Start-ConsoleDashboard -Parallel 100×2224-bitFull
PowerShellthreads-dashboard.ps1View on GitHub
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
    New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}

$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
    param($Server)
    foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
        Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
        Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
        Update-ConsoleLane -Name servers -Key $Server -Result Pass
    }
    Write-Host "$Server done"
    Update-ConsoleLane -Name servers -Key $Server -Complete
}
Full program
Import-Module ConsoleMonster

# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
    New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}

$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
    param($Server)
    foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
        Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
        Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
        Update-ConsoleLane -Name servers -Key $Server -Result Pass
    }
    Write-Host "$Server done"
    Update-ConsoleLane -Name servers -Key $Server -Complete
}

Run it

PowerShell 5.1 and 7
Install-Module ConsoleMonster -Scope CurrentUser
./threads-dashboard.ps1

See also: Start-ConsoleDashboard

The same script in CI

With CI detected, the same dashboard writes a whole line for each lane start and finish and each log entry instead of redrawing, in the order the updates were applied, then prints its final state.

CI=true 100×3024-bitFull
PowerShellthreads-dashboard.ps1View on GitHub
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
    New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}

$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
    param($Server)
    foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
        Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
        Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
        Update-ConsoleLane -Name servers -Key $Server -Result Pass
    }
    Write-Host "$Server done"
    Update-ConsoleLane -Name servers -Key $Server -Complete
}
Full program
Import-Module ConsoleMonster

# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
    New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}

$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
    param($Server)
    foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
        Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
        Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
        Update-ConsoleLane -Name servers -Key $Server -Result Pass
    }
    Write-Host "$Server done"
    Update-ConsoleLane -Name servers -Key $Server -Complete
}

How it works

One queue, one thread that drawsWorker threads and runspaces post updates into one first-in first-out queue. The render thread applies them between frames, about 15 times a second, and draws the console. Console.WriteLine calls are captured as whole lines and become log updates.worker threadworker threadrunspaceupdate queue (FIFO)render threadConsole.WriteLinecaptured linepost from any threadapplies, then drawsabout 15 frames a seconda log entry
  • One thread draws. A live session starts its own render thread. It applies updates to the widgets, composes the frame and writes only the cells that changed. Your work runs on your threads and never touches the screen.
  • Any thread posts. Handles (lanes.Handle(session), table.Handle(session)), session.Post and the PowerShell Update-Console* commands put an update into one lock-free first-in, first-out queue. Each thread's updates keep their order; updates from different threads have no set order relative to each other, and none is merged into a later one. The queue has no size limit, so posting does not wait for the screen; a flood that outpaces the render thread for long grows the queue in memory instead.
  • Updates land between frames. About 15 times a second (LiveSessionOptions.FramesPerSecond, 1 to 60) the render thread applies the waiting updates in order, a time-boxed batch per frame (about 40 ms at 15 fps; the rest go into the next one), then draws once. Every update accepted before the session stops is applied by the end; one addressed to a widget name the view does not have is dropped and counted in EventsDropped.
  • Console output is captured. While a session draws on the console, Console.Out and Console.Error are replaced by writers that collect whole lines from any thread (unless CaptureConsole = false; a second capturing session fails to start). Console.Write fragments are kept per thread until that thread ends the line, so two threads writing pieces never share a line; a message that continues on another thread should go out as one WriteLine. With a log region the lines become log entries. Without one, an inline area prints them above itself, and a full-screen view shows the latest in a short tail at the bottom (at most six rows and a third of the screen) and prints them all to the scrollback when it ends.
  • One write is one block. Each Terminal.Write, Terminal.WriteLines or Terminal.WriteMarkupLine call is written whole, so two threads writing widgets never mix their lines. While a session owns the console, the block is queued whole and printed above an inline area, after a full-screen view ends, or in stream mode in apply order; if the session is already stopping, the caller writes the block itself, whole and once.
  • Stopping. When the work ends or throws, the session stops admitting updates, applies the ones already queued, draws the final frame and restores the terminal (console writers, cursor and the Windows console mode) before the exception reaches you. The first Ctrl+C does the same for the view and cancels session.Cancellation; your work keeps running until it checks that token, and what it posts afterwards is dropped and counted in EventsDropped. HandleCancelKey = false leaves Ctrl+C to your program. A widget callback that blocks the render thread past StopTimeout delays only the final drain and screen cleanup; console capture is restored on time. A normal run ends with GetCounters() showing nothing dropped.
  • Not a terminal. Redirected output and CI get stream mode: each change worth a line becomes one whole line, in the order the updates were applied: every lane start and finish, status changes, table rows, finished progress and log entries, with a heartbeat summary every ten seconds. Console output is written as it arrives, in the same order, with or without a log region.

In PowerShell

The smallest case: eight runspaces at once, each writing with Write-Host. The lines go to the log panel, never over the screen:

Import-Module ConsoleMonster

$dashboard = New-ConsoleDashboard -Title 'Workers' {
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
1..8 | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 8 {
    param($Number)
    Start-Sleep -Milliseconds (Get-Random -Maximum 800)
    Write-Host "worker $Number finished"
}

Start-ConsoleDashboard -Parallel runs a script block per input item in a runspace pool (-ThrottleLimit). Each runspace gets $ConsoleSession, so Update-ConsoleLane, Update-ConsoleStatus, Update-ConsoleTable, Write-ConsoleLog and the other update commands reach the dashboard by widget name. Write-Host, warnings and errors from those runspaces (and verbose and debug messages when their preference is on) go to the dashboard's log (a New-ConsoleLog; without one they take the same path as console output: above an -Inline area, into the short tail at the bottom of a full-screen dashboard and the scrollback after it, or as lines in stream mode); progress records go to its activity list.

Import-Module ConsoleMonster

$servers = 'web01', 'web02', 'web03', 'web04'
$dashboard = New-ConsoleDashboard -Title 'Patching' {
    New-ConsolePanel -Title 'Servers' { New-ConsoleStatusList -Name servers -Item $servers }
    New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}

$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 4 {
    param($Server)
    Update-ConsoleStatus -Name servers -Key $Server -Status Running -Detail 'patching'
    Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 900)
    Write-Warning "$Server needs a reboot"
    Update-ConsoleStatus -Name servers -Key $Server -Status Pass -Detail 'patched'
}
  • Variables of the calling script are not visible inside a runspace, and $using: does not work here: pass values with -ArgumentList and a param() block, or as the input items.
  • Change widgets through the update commands, never by calling a widget object's own methods from a runspace.
  • Invoke-ConsoleRun (also called ForEach-ConsoleItem) with -Parallel draws its own run view: a summary bar, a cell per item and the failures. While it shows, host messages, warnings, information, verbose and debug messages and errors from the workers appear in its log, and their Write-Progress in an activity list; each worker's progress IDs are its own, so every worker can use -Id 0. Results keep input order (unless -Unordered), and the native stream records (warnings, errors, information, verbose and debug messages) are also delivered when the view ends, so -WarningVariable and the pipeline still get them. Its workers receive the caller's variables with $using:name. Use Start-ConsoleDashboard -Parallel when workers should update widgets of your own.

In C#

Workers update widgets through handles taken from the session. Everything below is safe from any thread while the session runs:

  • LanesHandle, StatusListHandle, LiveTableHandle, ProgressHandle and the other handles from widget.Handle(session);
  • session.Post, session.Write (a log line) and session.Region(name).Set(widget), which swaps a Slot's content between two frames;
  • Console.WriteLine, captured as described above;
  • the ConsoleMonster.Extensions.Logging provider, which writes into the log region of the running session (an entry with an exception is written as ordinary output instead: above an inline area, or after a full-screen view).

The C# tab of the dashboard demo above uses Parallel.ForEachAsync with a LanesHandle. Code deeper down does not need the session at all: an ambient TerminalScope flows with the code it wraps, across await and Task.Run, and TerminalScope.Report, Advance and SetState go to the widget and key the scope names (TerminalScope.Log to the session's log). Outside a scope they do nothing, so the same code runs without a dashboard.

using ConsoleMonster.Building;
using ConsoleMonster.Live;
using ConsoleMonster.Widgets;

string[] servers = { "web01", "web02", "web03" };
Dashboard dashboard = new DashboardBuilder("Patching")
    .Panel("Servers", new Lanes(), out Lanes lanes)
    .BuildDashboard();
foreach (string server in servers) {
    lanes.Add(server, server, 2);
}

using (LiveSession session = new LiveSession(dashboard)) {
    session.Run(s => Parallel.ForEach(servers, server => {
        // Patch knows nothing about the dashboard: the scope says where its reports go.
        using (s.Scope(lanes.Name, server)) {
            Patch(server);
        }
    }));
}

static void Patch(string server) {
    TerminalScope.Report("copying files");
    Thread.Sleep(300);
    TerminalScope.Advance();
    TerminalScope.Report("restarting");
    Thread.Sleep(300);
    TerminalScope.Advance();
    TerminalScope.SetState("pass");
}

ConsoleRun.ForEachAsync is the ready-made form: a throttle (RunOptions.ThrottleLimit), a per-item timeout the body observes through its token, failures kept per item, and the run view; see Live runs.

Under load

The stress test runs 32 workers posting updates in bursts while four more threads write to Console.Out and Console.Error at random. When it ends it fails unless every posted update was applied, nothing was dropped and the log holds exactly the stray lines that were written, then prints its counters; the recording below is that run. The benchmarks measure what one frame costs to compose and write.

dotnet run -- 12 120×4024-bitFull
Updates posted in 13.0 s
1,011,657
Average update rate
77,934/s
Frames drawn
195 (15.0 fps)
Dropped updates and errors
0 / 0
CPU, share of one core
21.1%
Result line
STRESS seconds=13.0 workers=32 posted=1011657 avgRate=77934/s peakRate=86921/s frames=195 fps=15.0 cpuOneCore=21.1% cpuMachine=0.7% captured=209 dropped=0 errors=0 runtime=.NET 8.0.32
C#Program.cs
// A stress test: 32 workers post thousands of updates a second while a noisy "library" writes to Console.Out and
// Console.Error from four threads. Workers never touch the console; the session draws about 15 frames a second and
// captures the stray lines into the output panel. Run it for N seconds: dotnet run -- 12
int seconds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 12;
const int Workers = 32;

Lanes lanes = new Lanes("workers") { MaxLabelWidth = 10, ShowDetail = true };
for (int i = 0; i < Workers; i++) {
    lanes.Add("w" + i, "worker " + (i + 1).ToString("00", CultureInfo.InvariantCulture), 100000);
}
KeyValueList stats = new KeyValueList("stats")
    .Add("Updates posted", "0")
    .Add("Updates applied", "0")
    .Add("Update rate", "-")
    .Add("Frames", "0")
    .Add("Frame rate", "-")
    .Add("CPU", "-")
    .Add("CPU share", "-")
    .Add("Captured lines", "0")
    .Add("Dropped / errors", "0 / 0");
Sparkline rate = new Sparkline("rate").WithHeight(3).WithUnit("/s");
LineChart cpu = new LineChart("cpu").WithHeight(6).WithMin(0).WithUnit("%");
cpu.AddSeries("cpu", ThemeToken.Accent2);

Dashboard dashboard = new DashboardBuilder("[bold]Stress test[/]")
    .Subtitle(Workers + " workers · random Console.WriteLine · " + seconds + "s")
    .Columns(c => c
        .Panel("Workers", lanes, 2.2)
        .Panel("Session", new Rows(stats, new Rule("updates/s"), rate, new Rule("CPU % (100% = one core)"), cpu), 1))
    .Panel("Captured output", new LogRegion { PreferredHeight = 6 }, fill: true)
    .BuildDashboard();

string result = string.Empty;
using (LiveSession session = new LiveSession(dashboard)) {
    session.Run(s => result = Drive(s));
}
Console.WriteLine(result);

string Drive(LiveSession session) {
// … 105 more lines in the full program
Full program
using System.Diagnostics;
using System.Globalization;
using ConsoleMonster.Building;
using ConsoleMonster.Layout;
using ConsoleMonster.Live;
using ConsoleMonster.Styling;
using ConsoleMonster.Widgets;

// A stress test: 32 workers post thousands of updates a second while a noisy "library" writes to Console.Out and
// Console.Error from four threads. Workers never touch the console; the session draws about 15 frames a second and
// captures the stray lines into the output panel. Run it for N seconds: dotnet run -- 12
int seconds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 12;
const int Workers = 32;

Lanes lanes = new Lanes("workers") { MaxLabelWidth = 10, ShowDetail = true };
for (int i = 0; i < Workers; i++) {
    lanes.Add("w" + i, "worker " + (i + 1).ToString("00", CultureInfo.InvariantCulture), 100000);
}
KeyValueList stats = new KeyValueList("stats")
    .Add("Updates posted", "0")
    .Add("Updates applied", "0")
    .Add("Update rate", "-")
    .Add("Frames", "0")
    .Add("Frame rate", "-")
    .Add("CPU", "-")
    .Add("CPU share", "-")
    .Add("Captured lines", "0")
    .Add("Dropped / errors", "0 / 0");
Sparkline rate = new Sparkline("rate").WithHeight(3).WithUnit("/s");
LineChart cpu = new LineChart("cpu").WithHeight(6).WithMin(0).WithUnit("%");
cpu.AddSeries("cpu", ThemeToken.Accent2);

Dashboard dashboard = new DashboardBuilder("[bold]Stress test[/]")
    .Subtitle(Workers + " workers · random Console.WriteLine · " + seconds + "s")
    .Columns(c => c
        .Panel("Workers", lanes, 2.2)
        .Panel("Session", new Rows(stats, new Rule("updates/s"), rate, new Rule("CPU % (100% = one core)"), cpu), 1))
    .Panel("Captured output", new LogRegion { PreferredHeight = 6 }, fill: true)
    .BuildDashboard();

string result = string.Empty;
using (LiveSession session = new LiveSession(dashboard)) {
    session.Run(s => result = Drive(s));
}
Console.WriteLine(result);

string Drive(LiveSession session) {
    LanesHandle lanesHandle = lanes.Handle(session);
    KeyValueHandle statsHandle = stats.Handle(session);
    SparklineHandle rateHandle = rate.Handle(session);
    SparklineHandle cpuHandle = cpu.Handle(session);
    long captured = 0;
    using CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(session.Cancellation);
    Thread[] workers = new Thread[Workers];
    for (int i = 0; i < Workers; i++) {
        int id = i;
        workers[i] = new Thread(() => Worker(id, lanesHandle, stop.Token)) { IsBackground = true };
        workers[i].Start();
    }
    Thread[] noisy = new Thread[4];
    for (int i = 0; i < noisy.Length; i++) {
        int id = i;
        noisy[i] = new Thread(() => FakeLibrary(id, stop.Token, ref captured)) { IsBackground = true };
        noisy[i].Start();
    }

    Process process = Process.GetCurrentProcess();
    Stopwatch wall = Stopwatch.StartNew();
    TimeSpan cpuStart = process.TotalProcessorTime;
    TimeSpan lastCpu = cpuStart;
    double lastWall = 0;
    long lastPosted = 0;
    double peakRate = 0;
    while (wall.Elapsed.TotalSeconds < seconds && !session.Cancellation.IsCancellationRequested) {
        Thread.Sleep(500);
        process.Refresh();
        double now = wall.Elapsed.TotalSeconds;
        TimeSpan cpuNow = process.TotalProcessorTime;
        double interval = now - lastWall;
        double core = (cpuNow - lastCpu).TotalMilliseconds / (interval * 1000) * 100;
        long posted = session.EventsPosted;
        double perSecond = (posted - lastPosted) / interval;
        peakRate = Math.Max(peakRate, perSecond);
        lastCpu = cpuNow;
        lastWall = now;
        lastPosted = posted;
        rateHandle.Add(perSecond);
        cpuHandle.Add(core, "cpu");
        statsHandle.Set("Updates posted", NumberFormat.Grouped(posted));
        statsHandle.Set("Updates applied", NumberFormat.Grouped(session.EventsApplied));
        statsHandle.Set("Update rate", NumberFormat.Grouped(perSecond) + "/s");
        statsHandle.Set("Frames", NumberFormat.Grouped(session.FramesRendered));
        statsHandle.Set("Frame rate", (session.FramesRendered / now).ToString("0.0", CultureInfo.InvariantCulture) + " fps");
        statsHandle.Set("CPU", core.ToString("0", CultureInfo.InvariantCulture) + "% (" + (core / 100).ToString("0.0", CultureInfo.InvariantCulture) + " cores)");
        statsHandle.Set("CPU share", (core / Environment.ProcessorCount).ToString("0.0", CultureInfo.InvariantCulture) + "% of " + Environment.ProcessorCount + " cores");
        statsHandle.Set("Captured lines", NumberFormat.Grouped(Interlocked.Read(ref captured)));
        statsHandle.Set("Dropped / errors", session.EventsDropped + " / " + session.ApplyErrors);
    }
    stop.Cancel();
    foreach (Thread t in workers) {
        t.Join();
    }
    foreach (Thread t in noisy) {
        t.Join();
    }
    for (int i = 0; i < Workers; i++) {
        lanesHandle.Complete("w" + i, StatusKind.Pass);
    }
    Thread.Sleep(300);
    process.Refresh();
    double elapsed = wall.Elapsed.TotalSeconds;
    double avgCore = (process.TotalProcessorTime - cpuStart).TotalMilliseconds / (elapsed * 1000) * 100;
    return string.Format(CultureInfo.InvariantCulture,
        "STRESS seconds={0:0.0} workers={1} posted={2} avgRate={3:0}/s peakRate={4:0}/s frames={5} fps={6:0.0} cpuOneCore={7:0.0}% cpuMachine={8:0.0}% captured={9} dropped={10} errors={11} runtime={12}",
        elapsed, Workers, session.EventsPosted, session.EventsPosted / elapsed, peakRate, session.FramesRendered, session.FramesRendered / elapsed,
        avgCore, avgCore / Environment.ProcessorCount, Interlocked.Read(ref captured), session.EventsDropped, session.ApplyErrors,
        System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
}

void Worker(int id, LanesHandle handle, CancellationToken token) {
    string key = "w" + id;
    Random random = new Random(id * 7919);
    string[] steps = { "parsing", "resolving", "evaluating", "scoring", "writing" };
    handle.Start(key, "starting");
    int n = 0;
    while (!token.IsCancellationRequested) {
        for (int burst = 0; burst < 40; burst++) {
            double roll = random.NextDouble();
            StatusKind outcome = roll < 0.03 ? StatusKind.Fail : roll < 0.1 ? StatusKind.Warn : StatusKind.Pass;
            handle.Advance(key, outcome, (n++ % 97 == 0) ? steps[random.Next(steps.Length)] + " item " + n : null);
        }
        Thread.Sleep(1);
    }
}

void FakeLibrary(int id, CancellationToken token, ref long captured) {
    Random random = new Random(1000 + id);
    string[] messages = {
        "FakeLib: cache miss for key {0}", "FakeLib: retrying request {0} (attempt 2)", "FakeLib: connection pool at {0}% capacity",
        "FakeLib: GC pause {0} ms", "FakeLib: flushed {0} records"
    };
    while (!token.IsCancellationRequested) {
        Thread.Sleep(random.Next(40, 400));
        string text = string.Format(CultureInfo.InvariantCulture, messages[random.Next(messages.Length)], random.Next(1, 999));
        if (random.NextDouble() < 0.15) {
            Console.Error.WriteLine("ERROR " + text);
        } else {
            Console.WriteLine(text);
        }
        Interlocked.Increment(ref captured);
    }
}
The stress test: 32 workers and stray console writes, recorded from the program below it.

What is and is not thread-safe

Safe from any thread while a session runsNot safe from several threads
Handles from widget.Handle(session), session.Post, session.Write, session.Region(name).SetCalling a widget object's own methods (table.Set, lanes.Add, log.Write) while the session or another thread uses it
Console.WriteLine, Console.Error.WriteLine and Console.Write fragments (kept per thread until the line ends)One message split across several threads (send it as one WriteLine)
Terminal.Write, Terminal.WriteLines and Terminal.WriteMarkupLine (each call one whole block)Drawing your own InlineArea from several threads
TerminalScope reports in any task or thread that inherited the scope
The Update-Console* commands in Start-ConsoleDashboard -Parallel runspacesWidget objects changed from a runspace instead of through the update commands

Build a widget fully before the session starts, then change it only through handles or update commands while it runs. A handle used after the session stopped is harmless: the update is dropped and counted in EventsDropped.

Guarantees and their tests

Each concurrency guarantee, the tests that prove it and where those tests run. The table is generated from the repository's guarantees document on every build, so the page and the document always match.

GuaranteeProved byRuns on
Complete concurrent one-shot blocksTerminalWrite_ManyProducersKeepEveryMultilineBlockWhole.NET Framework 4.7.2.NET 8.NET 10
One admission per block and preserved final inline lineSessionWrites_AdmitWholeMultilineBlocksBeforeAnotherProducer, SessionWrite_PreservesTheFinalInlineLine in the same file.NET Framework 4.7.2.NET 8.NET 10
Exactly-once shutdown fallback to actual OS stdoutStopDuringWritePreparation_PrintsTheWholeFallbackExactlyOnceToNativeStdout.NET Framework 4.7.2.NET 8.NET 10
Shutdown fallback cannot split draining blocks; restoration/summary writes flush onceShutdownFallback_KeepsItsBlockWholeWhileAnAcceptedBlockIsStillDraining, ShutdownFallback_FromRestorationAndSummaryIsFlushedExactlyOnce.NET Framework 4.7.2.NET 8.NET 10
A late block does not wait for user rendering after Stop or StopTimeout, and flushes once afterwardStopping_LateBlockDoesNotWaitForBlockedWidgetAndFlushesOnce in the same file.NET Framework 4.7.2.NET 8.NET 10
Default output from a frame/summary callback cannot deadlock with a concurrent Snapshot requestRenderCallback_DefaultOutputAndConcurrentSnapshotCannotDeadlock, forced frame and summary schedules in isolated processes.NET Framework 4.7.2.NET 8.NET 10
Whole stream lines in apply order, many producersStream_ManyProducersWriteWholeLinesInExactApplyOrder.NET Framework 4.7.2.NET 8.NET 10
Posting proceeds with blocked Apply; every accepted flood event drainsFlood_PostingFinishesWhileApplyIsBlocked_ThenEveryAcceptedUpdateDrains in the same file.NET Framework 4.7.2.NET 8.NET 10
Producer order and serialized normal application under stressConcurrentProducersPreserveEveryEventAndProducerOrderOnTheRenderThread.NET Framework 4.7.2.NET 8.NET 10
ThrottleLimit bounds ForEachAsync bodiesForEachAsync_ThrottleBoundsActiveBodiesUnderPressure, limits 1, 3, and 8.NET Framework 4.7.2.NET 8.NET 10
Concurrent handles, SummaryBar partial updates, and queued Slot swapsHandlesAndSlotSet_KeepEveryUpdateAndEachProducersLastValue.NET Framework 4.7.2.NET 8.NET 10
Per-thread fragments, CR/LF, and exactly-once tail flushingConcurrentFragments_StayWithTheirProducerUntilNewline, CarriageReturnState_BelongsToTheProducer, Complete_FlushesEveryProducerTailExactlyOnce.NET Framework 4.7.2.NET 8.NET 10
Progressive no-log output and bounded full-screen tail with scrollbackStreamWithoutLog_WritesCapturedAndPostedLinesBeforeStopInApplyOrder, FullScreenWithoutLog_ShowsABoundedCaptureTailAndRetainsScrollback.NET Framework 4.7.2.NET 8.NET 10
Strict malformed markup remains literal and later log events still arriveUnclaimedInvalidMarkup_IsLiteralAndDoesNotStopLaterEvents in the same file, stream and full screen.NET Framework 4.7.2.NET 8.NET 10
LF/CRLF host, warning, and colored messages retain lines without a logNoLog_MultilinePlainAndColouredMessagesKeepTheirLines in the same file; preserves multiline host and warning messages without a log in <Mode> with <LineEnding>.NET Framework 4.7.2.NET 8.NET 10PowerShell 5.1 and 7
Concurrent log entries retain exception details and scopesConcurrentExceptionEntries_KeepMessagesDetailsAndScopesTogether.NET Framework 4.7.2.NET 8.NET 10
All session log entries arrive; exceptions reach the configured explicit sinkConcurrentSessionLogging_DeliversEveryEntryAndAnExceptionToItsExplicitSink in the same file.NET Framework 4.7.2.NET 8.NET 10
Parallel dashboard/run host, warning, nested progress, and native resultsroutes host, warning and nested progress from <CommandName> without changing result records, all three command namesPowerShell 5.1 and 7
Progress whose parent has not arrived contributes as a top-level activityActivities_OrphansContributeProgressUntilTheirParentArrives, including a child updating after parent completion.NET Framework 4.7.2.NET 8.NET 10
First actual Ctrl+C rejects later posts and restores terminal/writersFirstActualCtrlC_RejectsLaterPostsAndRestoresNativeTerminalAndWriters, isolated Windows console; FirstCancellation_DropsLaterPostsAndRestoresWritersExactlyOnce, portable cancellation.NET Framework 4.7.2.NET 8.NET 10
Stress proves zero drops and actual captured line identitiesStressRun_VerifiesZeroDropsAndCapturedLineIdentitiesAfterDrain and the real stress example's delivered-line assertions.NET Framework 4.7.2.NET 8.NET 10
Native Format-Wide fittingmatches native Wide column fitting for <LayoutName>, including boundary widths and groups; direct-host proof in Assert-ConcurrencyHost.ps1PowerShell 5.1 and 7

These tests assert identities, output, ordering, and synchronization. Watchdogs bound a deadlock, not throughput. Core tests run on net472, net8.0 and net10.0; PowerShell tests run on 5.1 and 7. Source: Docs/Concurrency-Guarantees.md on GitHub.

Edit this page on GitHub