Environment, Process and Stopwatch: Environment Info, Running External Commands and Measuring Time
Running an external command safely from .NET comes down to three rules: pass arguments with ArgumentList, read stdout and stderr at the same time, and check the exit code and a timeout. Using Bookstore's nightly report tool, we show Environment and RuntimeInformation, environment variables, SpecialFolder and AppContext switches, the deadlock and injection traps of Process, the Process.RunAsync APIs of .NET 11 and measuring time with Stopwatch, all with real output.
Version note: The code in this post was built and run with .NET 10; the examples in the .NET 11 section were run with the .NET 11 SDK (11.0.100). The output was captured in a Linux x64 container whose machine name is app1 and whose user is app.
Guess First
The console tool that produces the nightly report for Bookstore, the online bookstore used as the shared example across this site's series, reads the folder for its reports from the BOOKSTORE_REPORT_DIR environment variable. The variable is set and its value is /tmp/reports. The code runs on Linux; what do the three lines print, and what changes on Windows?
// BOOKSTORE_REPORT_DIR is set in the environment: /tmp/reports
Console.WriteLine(Environment.NewLine.Length);
Console.WriteLine(Path.Combine("reports", "2026-11-14.csv"));
Console.WriteLine(Environment.GetEnvironmentVariable("bookstore_report_dir") ?? "(null)");
1
reports/2026-11-14.csv
(null)
On Linux the line ending is a single character (\n), the path separator is /, and environment variable names are case-sensitive; the name written in lowercase was not found. On Windows the same code prints 2 (\r\n), reports\2026-11-14.csv and the value of the variable, because Windows compares environment variable names case-insensitively. This is one of the first surprises when a developer puts code written and tested on Windows into a Linux container: the code does not fail, it just does not "see" the setting and runs with the default.
In this post we look at the environment the application runs in: platform information with Environment and RuntimeInformation, environment variables, special folders and AppContext switches; then running an external command with Process, capturing its output and avoiding two common mistakes (deadlock and argument injection); the new Process APIs in .NET 11; and finally measuring time with Stopwatch. The examples come from Bookstore's nightly report tool:
dotnet run --project samples/Bookstore.Bcl.Samples -- env-info
Environment and RuntimeInformation
Knowing which operating system the application runs on, with which runtime and how many CPUs it sees is needed both for the first line of the logs and for choosing behavior per platform:
Console.WriteLine($"OperatingSystem.IsWindows() : {OperatingSystem.IsWindows()}");
Console.WriteLine($"OperatingSystem.IsLinux() : {OperatingSystem.IsLinux()}");
Console.WriteLine($"OSDescription : {RuntimeInformation.OSDescription}");
Console.WriteLine($"FrameworkDescription : {RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"RuntimeIdentifier : {RuntimeInformation.RuntimeIdentifier}");
Console.WriteLine($"OS / Process architecture : {RuntimeInformation.OSArchitecture} / {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"Is64BitProcess : {Environment.Is64BitProcess}");
Console.WriteLine($"ProcessorCount : {Environment.ProcessorCount}");
Console.WriteLine($"MachineName / UserName : {Environment.MachineName} / {Environment.UserName}");
Console.WriteLine($"ProcessId : {Environment.ProcessId}");
Console.WriteLine($"Version : {Environment.Version}");
OperatingSystem.IsWindows() : False
OperatingSystem.IsLinux() : True
OSDescription : Ubuntu 24.04.5 LTS
FrameworkDescription : .NET 10.0.15
RuntimeIdentifier : linux-x64
OS / Process architecture : X64 / X64
Is64BitProcess : True
ProcessorCount : 4
MachineName / UserName : app1 / app
ProcessId : 14
Version : 10.0.15
For platform checks, use methods such as OperatingSystem.IsWindows(), IsLinux(), IsMacOS() and the version-comparing IsWindowsVersionAtLeast(10, 0, 19041). These are the checks the CA1416 analyzer recognizes; when you call a Windows-specific API inside an if (OperatingSystem.IsWindows()) block, the warning goes away. RuntimeInformation.IsOSPlatform(OSPlatform.Windows) is an older way that does the same job. OSDescription is for logs only; do not parse its text to make decisions.
ProcessorCount has an important detail in containers: this output comes from a container started with --cpus 4, and it shows the CPU limit given to the container, not the number of physical cores on the machine. On Linux, .NET reads cgroup limits; thread pool and Parallel defaults are tuned to this number. MachineName is the container's hostname and changes when a pod restarts; it is a good value to log and a bad one to store as the identity of a resource.
Special Folders
Instead of hard-coding a path for reports, cache or settings files, you use Environment.GetFolderPath. But there is a detail on Linux:
Console.WriteLine($"CurrentDirectory : {Environment.CurrentDirectory}");
Console.WriteLine($"AppContext.BaseDirectory: {AppContext.BaseDirectory}");
Console.WriteLine($"GetTempPath() : {Path.GetTempPath()}");
Environment.SpecialFolder[] folders =
[
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolder.CommonApplicationData,
Environment.SpecialFolder.ProgramFiles,
];
foreach (Environment.SpecialFolder folder in folders)
{
string verified = Environment.GetFolderPath(folder);
string doNotVerify = Environment.GetFolderPath(folder, Environment.SpecialFolderOption.DoNotVerify);
Console.WriteLine($"{folder,-22}: \"{verified}\" / DoNotVerify: \"{doNotVerify}\"");
}
CurrentDirectory : /app
AppContext.BaseDirectory: /app/
GetTempPath() : /tmp/
UserProfile : "/home/app" / DoNotVerify: "/home/app"
ApplicationData : "" / DoNotVerify: "/home/app/.config"
LocalApplicationData : "" / DoNotVerify: "/home/app/.local/share"
CommonApplicationData : "/usr/share" / DoNotVerify: "/usr/share"
ProgramFiles : "" / DoNotVerify: ""
By default GetFolderPath checks whether the folder exists and returns an empty string if it does not. In a fresh container ~/.config and ~/.local/share have not been created yet, so ApplicationData and LocalApplicationData came back empty; Path.Combine("", "bookstore") then silently produces a relative path and the file is written to the working directory. SpecialFolderOption.DoNotVerify returns the path without checking, and Create creates the folder. On Windows the same values are C:\Users\user\AppData\Roaming and C:\Users\user\AppData\Local and usually exist already, so the bug never shows up there. Folders with no Linux equivalent, such as ProgramFiles, are empty with both options. There is also the difference between CurrentDirectory and AppContext.BaseDirectory: the first is the folder of whoever started the process, which for a Windows service or a scheduled task is usually C:\Windows\System32; if you read a file that ships next to the application, use AppContext.BaseDirectory.
Environment Variables and AppContext Switches
The nightly report tool reads two settings from environment variables: the report folder and how many days reports are kept.
string reportDir = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_DIR") ?? Path.Combine(Path.GetTempPath(), "bookstore-reports");
Console.WriteLine($"BOOKSTORE_REPORT_DIR : {reportDir}");
string? rawRetention = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_KEEP_DAYS");
int keepDays = int.TryParse(rawRetention, out int parsed) && parsed > 0 ? parsed : 30;
Console.WriteLine($"BOOKSTORE_REPORT_KEEP_DAYS: raw={rawRetention ?? "(null)"} -> {keepDays}");
Environment.SetEnvironmentVariable("BOOKSTORE_RUN_ID", "nightly-2026-11-14");
Console.WriteLine($"After Set (process) : {Environment.GetEnvironmentVariable("BOOKSTORE_RUN_ID")}");
int bookstoreVariables = Environment.GetEnvironmentVariables().Keys.Cast<string>().Count(k => k.StartsWith("BOOKSTORE_", StringComparison.Ordinal));
Console.WriteLine($"BOOKSTORE_* variables : {bookstoreVariables}");
try
{
Environment.SetEnvironmentVariable("BOOKSTORE_RUN_ID", "x", EnvironmentVariableTarget.User);
Console.WriteLine($"Target=User on Unix : no exception, read back: {Environment.GetEnvironmentVariable("BOOKSTORE_RUN_ID", EnvironmentVariableTarget.User) ?? "(null)"}");
}
catch (Exception ex)
{
Console.WriteLine($"Target=User : {ex.GetType().Name}");
}
BOOKSTORE_REPORT_DIR : /tmp/reports
BOOKSTORE_REPORT_KEEP_DAYS: raw=(null) -> 30
After Set (process) : nightly-2026-11-14
BOOKSTORE_* variables : 2
Target=User on Unix : no exception, read back: (null)
An environment variable is always a string or null; when converting it to a number, use TryParse and decide what happens with an invalid value. Here we silently fell back to the default; for a production setting it is usually better to stop the application at startup instead, because nobody notices a job that runs with "30 days" while everyone believes it is 7. SetEnvironmentVariable changes only the running process's copy; child processes started by this process see the new value, the shell and other applications do not. EnvironmentVariableTarget.User and Machine write to the registry only on Windows; on Unix the call is ignored without an exception, as the (null) in the output shows.
In an application that uses ASP.NET Core or the Generic Host, it is better to leave environment variables to the configuration system than to read them directly: names like Bookstore__Reports__Dir bind automatically to the Bookstore:Reports:Dir key and take part in the same precedence order as appsettings and the command line. That order is covered in the ASP.NET Core Web API series post "Configuration and the Options Pattern in ASP.NET Core". Environment.GetEnvironmentVariable suits small tools without a host and the few values that must be read before the application starts.
AppContext Switches
.NET's own behavior settings (globalization invariant mode, server GC and so on) are written to the <app>.runtimeconfig.json file at build time and read at run time through AppContext. You can use the same mechanism for your own settings. I added one line to the csproj of the samples project:
<ItemGroup>
<!-- Ends up in Bookstore.Bcl.Samples.runtimeconfig.json; read with AppContext.TryGetSwitch. -->
<RuntimeHostConfigurationOption Include="Bookstore.Reports.UseLegacyCsv" Value="true" />
</ItemGroup>
bool found = AppContext.TryGetSwitch("Bookstore.Reports.UseLegacyCsv", out bool useLegacyCsv);
Console.WriteLine($"Bookstore.Reports.UseLegacyCsv : found={found}, value={useLegacyCsv}");
found = AppContext.TryGetSwitch("System.Globalization.Invariant", out bool invariant);
Console.WriteLine($"System.Globalization.Invariant : found={found}, value={invariant}");
Console.WriteLine($"System.GC.Server (GetData) : {AppContext.GetData("System.GC.Server") ?? "(null)"}");
AppContext.SetSwitch("Bookstore.Reports.IncludeCancelled", true);
AppContext.TryGetSwitch("Bookstore.Reports.IncludeCancelled", out bool includeCancelled);
Console.WriteLine($"Set in code : {includeCancelled}");
Bookstore.Reports.UseLegacyCsv : found=True, value=True
System.Globalization.Invariant : found=False, value=False
System.GC.Server (GetData) : (null)
Set in code : True
The return value of TryGetSwitch and the value of the switch are different things: found=False means "nobody set this, the default applies". Invariant mode and server GC were not set in this project, so neither was found. AppContext switches were designed as a compatibility mechanism for turning off a behavior change in a new version of a library; .NET's own breaking change notes mention them as "set this switch to get the old behavior back". The configuration system suits an application's business settings better; use a switch in your own library when you want to leave its users a way back.
Process.Start and ProcessStartInfo
The nightly report tool calls an external tool to compress the CSV files it produces. tar is a good candidate because it exists both on Linux and on Windows 10 and later (tar.exe):
ProcessStartInfo startInfo = new("tar")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = dir,
};
startInfo.ArgumentList.Add("-czf");
startInfo.ArgumentList.Add("nightly-2026-11-14.tar.gz");
startInfo.ArgumentList.Add("orders.csv");
using Process process = Process.Start(startInfo)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Console.WriteLine($"tar -czf: exit={process.ExitCode} stdout=\"{await stdout}\" stderr=\"{await stderr}\"");
ToolResult list = await ExternalTool.RunAsync("tar", ["-tzf", "nightly-2026-11-14.tar.gz"], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"tar -tzf: exit={list.ExitCode} files={list.StandardOutput.Trim()}");
ToolResult missing = await ExternalTool.RunAsync("tar", ["-czf", "broken.tar.gz", "missing.csv"], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"missing : exit={missing.ExitCode}");
Console.Write($"stderr : {missing.StandardError}");
tar -czf: exit=0 stdout="" stderr=""
tar -tzf: exit=0 files=orders.csv
missing : exit=2
stderr : tar: missing.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
The only reliable sign of success is the exit code. tar returned 2 for the missing file and wrote the error message to stderr; stdout is empty. Code that does not check the exit code carries on with "tar ran" and nobody notices the archive is incomplete. Because tar.exe on Windows is a different program (bsdtar), the messages differ, but the exit code contract is the same: 0 is success, anything else is a failure.
Three settings should be chosen deliberately on every call. UseShellExecute has been false by default since .NET Core (it was true in .NET Framework): the process is started directly and its output can be redirected. true is needed only to open a file or URL with the operating system's default application, and then the output cannot be redirected. ArgumentList passes each argument as a separate element and quotes it correctly for the platform; we will see shortly why the Arguments property, which takes a single string, is dangerous. WaitForExitAsync waits for the process to finish without blocking a thread and takes a CancellationToken.
Reading stdout and stderr at the Same Time
Every redirected output is a pipe, and a pipe has a buffer (64 KB by default on Linux). When the buffer is full, the child process waits to write. If the parent reads only stdout and never looks at stderr while the child writes a lot to stderr, each waits for the other. To show this, the sample application runs itself again with the proc-noisy argument; that mode writes 1 MB of warnings to stderr and a single line to stdout:
using (Process child = Process.Start(SelfStartInfo("proc-noisy"))!)
{
try
{
// Wrong: read stdout to the end first, look at stderr later.
string output = await child.StandardOutput.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5));
Console.WriteLine($"stdout: {output}");
}
catch (TimeoutException)
{
Console.WriteLine($"stdout-first: no result after 5 s, child exited: {child.HasExited}");
child.Kill(entireProcessTree: true);
await child.WaitForExitAsync();
}
}
ProcessStartInfo self = SelfStartInfo("proc-noisy");
ToolResult result = await ExternalTool.RunAsync(self.FileName, self.ArgumentList, TimeSpan.FromSeconds(30));
Console.WriteLine($"both pipes : exit={result.ExitCode} stdout=\"{result.StandardOutput.Trim()}\" stderr={result.StandardError.Length:N0} chars");
stdout-first: no result after 5 s, child exited: False
both pipes : exit=0 stdout="report done" stderr=1,048,576 chars
I set the 5-second limit in the first attempt myself; without it the process would hang forever and the nightly job would still look "running" in the morning. This bug usually stays hidden in the test environment, because there the tool produces few warnings and the buffer never fills. The fix is to read both pipes at the same time. ExternalTool in the Bookstore library does that and also applies a timeout:
long started = Stopwatch.GetTimestamp();
using Process process = Process.Start(startInfo)
?? throw new InvalidOperationException($"Could not start '{fileName}'.");
// Read both pipes at the same time; reading one of them to the end first can deadlock.
Task<string> stdout = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
Task<string> stderr = process.StandardError.ReadToEndAsync(CancellationToken.None);
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
bool timedOut = false;
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
cancellationToken.ThrowIfCancellationRequested();
timedOut = true;
}
string[] output = await Task.WhenAll(stdout, stderr);
return new ToolResult(process.ExitCode, output[0], output[1], Stopwatch.GetElapsedTime(started), timedOut);
On timeout or cancellation, Kill(entireProcessTree: true) stops not only the process we started but also the processes it started; processes that a script leaves running in the background do not become orphans. After the kill you also need to wait for the process to really finish, otherwise ExitCode cannot be read. To tell the caller's cancellation apart from our own timeout we used a linked CancellationTokenSource: if the cancellation came from the caller we throw, and a timeout comes back in the result as TimedOut=true. For very large output, stream it with the OutputDataReceived event or by reading line by line instead of collecting everything into a string.
Arguments, the Shell and Injection
Joining the arguments into a single string is the most common mistake. The Q4 report's file name has a space in it:
// Wrong: joining the arguments into one string.
ProcessStartInfo joined = new("tar", $"-czf q4.tar.gz {fileName}") { WorkingDirectory = dir, RedirectStandardError = true };
using (Process process = Process.Start(joined)!)
{
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Console.WriteLine($"Arguments string: exit={process.ExitCode}");
Console.Write(error);
}
ToolResult listed = await ExternalTool.RunAsync("tar", ["-czf", "q4.tar.gz", fileName], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"ArgumentList : exit={listed.ExitCode}");
Arguments string: exit=2
tar: Q4: Cannot stat: No such file or directory
tar: report.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
ArgumentList : exit=0
"Q4 report.csv" was split into two separate arguments. Here the result is only a failed archive; but if the user supplies the file name, a name starting with - can reach tar as a new option. ArgumentList passes each element as a single argument and does the quoting Windows needs by itself.
The more dangerous case is running the command through a shell. If we embed the book title in a command built with sh -c:
string title = "Dune; echo INJECTED";
// Wrong: embedding user data in a shell command.
ToolResult viaShell = await ExternalTool.RunAsync("sh", ["-c", $"echo Archiving {title}"], TimeSpan.FromSeconds(10));
Console.Write($"sh -c (concatenated):\n{viaShell.StandardOutput}");
ToolResult asArgument = await ExternalTool.RunAsync("sh", ["-c", "echo Archiving \"$1\"", "sh", title], TimeSpan.FromSeconds(10));
Console.Write($"sh -c (positional \"$1\"):\n{asArgument.StandardOutput}");
sh -c (concatenated):
Archiving Dune
INJECTED
sh -c (positional "$1"):
Archiving Dune; echo INJECTED
The ; in the title is a command separator for the shell, and the second command ran. Instead of echo it could have been rm or a curl. On Windows, the same problem appears with cmd.exe /c and the & and | characters. This is OS command injection, the operating system command version of the injection family covered in the Web Security series. The best fix is not to use a shell at all: start the tool directly and pass the arguments with ArgumentList. If you really need a shell (pipes, redirection), pass user data as a separate argument ("$1") as in the second example, not inside the command text. This example needs sh, so it runs on Linux and macOS.
.NET 11: Process.RunAsync and RunAndCaptureTextAsync
Almost every team writes a class like the ExternalTool above one day. .NET 11 moved this job into the BCL. The example below lives in the samples/Bookstore.Bcl.Net11 project and ran in a Linux container with the .NET 11 runtime:
ProcessStartInfo tar = new("tar", ["-czf", "nightly.tar.gz", "orders.csv", "missing.csv"])
{
WorkingDirectory = dir,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
ProcessTextOutput result = await Process.RunAndCaptureTextAsync(tar);
Console.WriteLine($"RunAndCaptureTextAsync: exit={result.ExitStatus.ExitCode} canceled={result.ExitStatus.Canceled}");
Console.Write($"stderr: {result.StandardError}");
ProcessExitStatus version = await Process.RunAsync("tar", ["--version"], silent: true);
Console.WriteLine($"RunAsync(silent): exit={version.ExitCode}");
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(1));
ProcessExitStatus slow = await Process.RunAsync("sleep", ["30"], cancellationToken: cts.Token);
Console.WriteLine($"sleep 30 with 1 s token: exit={slow.ExitCode} canceled={slow.Canceled} signal={slow.Signal}");
using Process sleeper = Process.Start("sleep", "30");
sleeper.Signal(PosixSignal.SIGTERM);
ProcessExitStatus status = await sleeper.WaitForExitStatusAsync();
Console.WriteLine($"SIGTERM: exit={status.ExitCode} signal={status.Signal}");
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
ProcessStartInfo child = new("sleep", ["1"]) { KillOnParentExit = true };
using Process process = Process.Start(child)!;
await process.WaitForExitAsync();
Console.WriteLine($"KillOnParentExit: child exit={process.ExitCode}");
}
RunAndCaptureTextAsync: exit=2 canceled=False
stderr: tar: missing.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
RunAsync(silent): exit=0
sleep 30 with 1 s token: exit=137 canceled=True signal=SIGKILL
SIGTERM: exit=143 signal=SIGTERM
KillOnParentExit: child exit=0
RunAndCaptureTextAsync reads both pipes together and returns the result as a ProcessTextOutput; the deadlock risk is now solved inside the API. In my first attempt I had not set the Redirect* properties and the method threw InvalidOperationException: the overload that takes a ProcessStartInfo requires both redirections to be on, while the overload that takes a file name and arguments does that itself. tar --version run with silent: true printed nothing to the screen.
The cancellation behavior matters: when the token was canceled, RunAsync did not throw; it killed the child with SIGKILL and returned a result with Canceled=true. Exit code 137 is the Unix convention (128 + signal number 9). Thanks to ProcessExitStatus.Signal you no longer need to guess from the exit code whether the process "exited with 1 on its own" or "was killed from outside". Process.Signal is new too: with SIGTERM you can give a process the chance to shut down cleanly.
KillOnParentExit makes sure the child is killed when the parent process dies; if the nightly job crashes, no orphaned tar is left behind. The what's new page describes this property as "Windows only", but the API in the GA package marks Linux and Android as supported too; the CA1416 warning I got when I compiled without a platform check also said "'ProcessStartInfo.KillOnParentExit' is supported on: 'android', 'linux', 'windows'". I also tried it separately in a Linux container: after the parent exited, no sleep 30 was left with the property on, and one was left with it off. According to that marking, macOS is not supported. These new APIs require .NET 11; in a project targeting .NET 10 you keep using your own wrapper such as ExternalTool.
Measuring Time with Stopwatch
Measuring how long an operation takes from the difference of two DateTime.Now values is wrong for two reasons. First, the wall clock can change: NTP synchronization or a manual correction makes the clock jump forward or back, and the measured duration can even be negative. Second, DateTime.Now performs a zone conversion on every call. Stopwatch uses a monotonic counter instead; it only moves forward and is designed for measuring durations.
Console.WriteLine($"Stopwatch.IsHighResolution: {Stopwatch.IsHighResolution}, Frequency: {Stopwatch.Frequency:N0} ticks/s");
Stopwatch stopwatch = Stopwatch.StartNew();
await Task.Delay(200);
stopwatch.Stop();
Console.WriteLine($"Stopwatch : {stopwatch.Elapsed.TotalMilliseconds:F1} ms");
long started = Stopwatch.GetTimestamp();
await Task.Delay(200);
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
Console.WriteLine($"GetElapsedTime : {elapsed.TotalMilliseconds:F1} ms");
stopwatch.Restart();
await Task.Delay(50);
TimeSpan lap = stopwatch.Elapsed;
await Task.Delay(50);
Console.WriteLine($"Lap / total : {lap.TotalMilliseconds:F0} ms / {stopwatch.Elapsed.TotalMilliseconds:F0} ms (still running: {stopwatch.IsRunning})");
Stopwatch.IsHighResolution: True, Frequency: 1,000,000,000 ticks/s
Stopwatch : 211.7 ms
GetElapsedTime : 205.8 ms
Lap / total : 57 ms / 109 ms (still running: True)
Durations vary from machine to machine; a 200 ms Task.Delay taking 205–212 ms is normal because of timer resolution and thread pool scheduling. Stopwatch.Frequency is one billion ticks per second on Linux and a different value on Windows, so do not mix ElapsedTicks up with TimeSpan.Ticks (one is a Stopwatch tick, the other is 100 ns). Always use Elapsed or GetElapsedTime.
Stopwatch is a class, so every StartNew is an allocation. The Stopwatch.GetTimestamp and GetElapsedTime pair that arrived with .NET 7 works with just a long:
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
Stopwatch stopwatch = Stopwatch.StartNew();
stopwatch.Stop();
}
long withStopwatch = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
TimeSpan total = TimeSpan.Zero;
for (int i = 0; i < 1_000; i++)
{
long started = Stopwatch.GetTimestamp();
total += Stopwatch.GetElapsedTime(started);
}
long withTimestamp = GC.GetAllocatedBytesForCurrentThread() - before;
1,000 x Stopwatch.StartNew() : 40,000 bytes allocated
1,000 x GetTimestamp/GetElapsedTime: 0 bytes allocated
Each Stopwatch is 40 bytes. That does not matter for a single measurement, but in a middleware that times every HTTP request or every message it can drop to zero. Comparing how fast a piece of code really is, however, is not a job for Stopwatch: you need BenchmarkDotNet to separate out JIT, warm-up and GC effects; the BenchmarkDotNet post of the Performance and Load Testing series covers this.
Bookstore: The Nightly Report
Let us put the pieces together. The nightly report tool takes the folder from an environment variable, writes the CSV, archives it with tar and logs every step with its duration:
string reportDir = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_DIR")
?? Path.Combine(Path.GetTempPath(), "bookstore-reports");
Directory.CreateDirectory(reportDir);
string day = "2026-11-14";
long started = Stopwatch.GetTimestamp();
string csvName = $"orders-{day}.csv";
await File.WriteAllLinesAsync(Path.Combine(reportDir, csvName),
[
"OrderNumber,Total",
.. Enumerable.Range(1001, 250).Select(n => string.Create(CultureInfo.InvariantCulture, $"BK-{n},{n % 7 * 50 + 99.90m}")),
]);
logger.LogInformation("Wrote {File} in {ElapsedMs:F1} ms", csvName, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
string archive = $"nightly-{day}.tar.gz";
ToolResult result = await ExternalTool.RunAsync("tar", ["-czf", archive, csvName], TimeSpan.FromSeconds(30), reportDir);
if (result.TimedOut || result.ExitCode != 0)
{
logger.LogError("tar failed: exit={ExitCode} timedOut={TimedOut} stderr={StdErr}", result.ExitCode, result.TimedOut, result.StandardError.Trim());
return;
}
long size = new FileInfo(Path.Combine(reportDir, archive)).Length;
logger.LogInformation("Archived {Archive} ({Size} bytes), tar exit={ExitCode} in {ElapsedMs:F1} ms", archive, size, result.ExitCode, result.Elapsed.TotalMilliseconds);
logger.LogInformation("Nightly report finished in {TotalMs:F1} ms", Stopwatch.GetElapsedTime(started).TotalMilliseconds);
info: Bookstore.NightlyReport[0] Wrote orders-2026-11-14.csv in 44.2 ms
info: Bookstore.NightlyReport[0] Archived nightly-2026-11-14.tar.gz (791 bytes), tar exit=0 in 55.0 ms
info: Bookstore.NightlyReport[0] Nightly report finished in 111.8 ms
Writing the duration to the log as a separate field (ElapsedMs) is more useful than embedding it in the message text: you can query the log system for "did tar take longer than 5 seconds?". On failure the exit code, the timeout flag and stderr are logged together; whoever looks at the log in the morning can see the problem without running the tool again. Which exit code the tool itself should return is the topic of the next post.
In Production
Calling an external tool adds a dependency to how the application is deployed. If the tool is missing from the container image (chiseled or distroless images usually have no tar and no sh), Process.Start throws Win32Exception; despite its Windows-sounding name, this type is used on Linux too. Running the tool once at startup (for example with --version) to confirm it exists is better than finding out at midnight. If an API in .NET does the same job, prefer it: for archives there are System.Formats.Tar and System.IO.Compression, and the "Compression and Archives" post of the I/O in .NET series covers both.
Put a timeout on every external call and kill the process tree on timeout. Limit the size of the output before you log it; sending 1 MB of stderr to the log system as a single line creates a different problem. Log the settings read from environment variables and the platform information once at startup; finding out later which container ran with which settings is hard. Do not write secret values (connection strings, tokens) to the log.
If you also want to publish timings as metrics, use a histogram with System.Diagnostics.Metrics; computing percentiles from individual log lines is expensive. The OpenTelemetry side is covered in the Observability series.
Common Pitfalls
Deadlock from not reading stdout and stderr at the same time. Reading one to the end and looking at the other later locks the two processes together when the child writes a lot. Read both pipes together, or use RunAndCaptureTextAsync on .NET 11.
Joining arguments into a single string. File names with spaces are split, and user data can become a new option or, through a shell, a new command. Use ArgumentList and avoid the shell.
Not knowing the UseShellExecute differences. Code ported from .NET Framework that does not know the default changed from true to false gets Win32Exception from file-opening calls; conversely, a call with true cannot redirect output.
Not checking the exit code. Process.Start succeeds even if the tool fails; only ExitCode tells you the result.
Measuring durations from DateTime.Now differences. Clock corrections break the measurement; use Stopwatch or GetTimestamp/GetElapsedTime.
Forgetting that environment variable names are case-sensitive on Linux, and not allowing for GetFolderPath returning an empty string.
A Small Experiment
★☆☆ Run the env-info example in a container started with --cpus 2 and on your own machine; compare the ProcessorCount values.
★★☆ Add a maxOutputChars parameter to ExternalTool: keep only the first N characters of stdout and stderr, and say in the result whether they were cut. Try it with proc-noisy.
★★★ Replace the tar call in the nightly report tool with System.Formats.Tar and GZipStream. Run both versions with the same CSV, compare the archive contents with tar -tzf, and confirm in the container image that the external tool dependency is gone.
The nightly report tool now does its job, but it is still a console application that reads args by hand and exits with 0 in every case. In the next post we turn it into a real CLI tool with subcommands such as bookstore-admin import-prices prices.csv --dry-run, help text, correct exit codes and Ctrl+C support.