Out-String turns one stderr line into eight and injects your own script text into the log
streams · case
Symptom
Section titled “Symptom”A tool writes one line to stderr. Your saved log has eight, and six of
them are fragments of your own script — the command line, a row of ~~~~
squiggles pointing at it, and CategoryInfo / FullyQualifiedErrorId rows.
Line counts in CI become meaningless, log diffs are noise, and an agent reading the log sees its own source code echoed back as if it were program output.
A child that writes exactly one line to each stream and exits 0:
process.stdout.write("stdout-line\n");process.stderr.write("stderr-line\n");process.exit(Number(process.argv[2] ?? 0));node exit.mjs 0 2>&1 | Out-String -Width 200 | Set-Content run.log(Get-Content run.log | Measure-Object -Line).Lines# 8 <- expected 2What actually lands in the log:
stdout-linenode : stderr-lineAt line:2 char:6+ $o = node exit.mjs 0 2>&1; ($o | Out-String) -spli ...+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : NotSpecified: (stderr-line:String) [], RemoteException + FullyQualifiedErrorId : NativeCommandErrorNote the third and fourth lines: your script’s own text is now log content.
Under 5.1, 2>&1 wraps each native stderr line in an ErrorRecord. That object
is fine while it stays an object — -match filters it, Select-String finds it,
and Set-Content writes just the message. But Out-String renders it the way
the console would, and the console rendering of an ErrorRecord includes the
invocation context: position line, the offending source text, the squiggle
underline, and two trailing metadata rows.
So the corruption is introduced by the formatter, not by the redirection, which is why it appears only in some pipelines.
A second consequence, easy to hit while debugging this: the merged stream is not string-typed, so string methods fail on it.
$o = node exit.mjs 0 2>&1$o | ForEach-Object { $_.GetType().Name } # String, ErrorRecord$o[1].Substring(0,6)# Method invocation failed because [System.Management.Automation.ErrorRecord]# does not contain a method named 'Substring'.What does and does not corrupt
Section titled “What does and does not corrupt”| pipeline | result |
|---|---|
... 2>&1 | Set-Content log |
clean, 26 bytes, message only |
... 2>&1 | Select-String p |
clean, matches the message |
... 2>&1 | Where-Object { $_ -match p } |
works, stays ErrorRecord |
... 2>&1 | Out-String | Set-Content log |
8 lines, script text injected |
$LASTEXITCODE is not part of this problem — it propagated correctly as 3
through Select-String, Tee-Object, Out-String and Select-Object in the
same test run. The damage is confined to rendered text.
Workaround
Section titled “Workaround”# do not render objects you intend to store or grepnode exit.mjs 0 2>&1 | Set-Content run.log
# if you must flatten, project to the message firstnode exit.mjs 0 2>&1 | ForEach-Object { "$_" } | Set-Content run.logTreat Out-String as a display cmdlet. The moment its output is stored,
diffed, counted or fed to another program, it is the wrong tool.
native-stderr-errorrecord covers the ErrorRecord wrapping itself and its
interaction with $ErrorActionPreference = 'Stop'. This case is about what
happens after you have accepted the wrapping and merely try to save or count
the output: one line becomes eight, and your own script text ends up inside the
artifact. Neither Out-String nor the line-inflation effect appears anywhere in
the archive today.
Environment
Section titled “Environment”$PSVersionTable: 5.1.26100.7705, Edition Desktop, Windows 11, Node 22.14.