From a99a976e056ec26d5e7591790e18a1124db5e659 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 29 May 2026 11:58:25 +1200 Subject: [PATCH 1/3] test: verify automatic flush on exit without Stop-Sentry Add a regression test for #38 that runs a child process which starts Sentry, captures a message, and exits WITHOUT calling Stop-Sentry. It asserts the process exits cleanly within a timeout (no hang, as in sentry-dotnet#3141) and that the captured event is still delivered. A file-writing transport (FileTransport in utils.ps1) is used so delivery can be observed from the parent after the child exits, without networking/ports in CI. Co-Authored-By: Claude Opus 4.7 --- tests/exit-flush-test-script.ps1 | 31 ++++++++++++++ tests/exit-flush.tests.ps1 | 73 ++++++++++++++++++++++++++++++++ tests/utils.ps1 | 20 +++++++++ 3 files changed, 124 insertions(+) create mode 100644 tests/exit-flush-test-script.ps1 create mode 100644 tests/exit-flush.tests.ps1 diff --git a/tests/exit-flush-test-script.ps1 b/tests/exit-flush-test-script.ps1 new file mode 100644 index 0000000..60376b3 --- /dev/null +++ b/tests/exit-flush-test-script.ps1 @@ -0,0 +1,31 @@ +# Regression script for https://github.com/getsentry/sentry-powershell/issues/38 +# +# Starts Sentry, captures a message, and exits WITHOUT calling Stop-Sentry to verify that +# events are still flushed/delivered automatically on process exit (and that the process +# exits cleanly without hanging). +# +# A file-writing transport (FileTransport, defined in utils.ps1) is used instead of a network +# call so that delivery can be observed from the parent process after this one exits, without +# relying on networking/open ports in CI. +# +# Usage: pwsh -File exit-flush-test-script.ps1 +param( + [Parameter(Mandatory)] + [string] $OutputFile +) + +Set-StrictMode -Version latest +$ErrorActionPreference = 'Stop' + +Import-Module "$PSScriptRoot/../modules/Sentry/Sentry.psd1" +. "$PSScriptRoot/utils.ps1" + +Start-Sentry { + $_.Dsn = 'https://key@127.0.0.1/1' + $_.Transport = [FileTransport]::new($OutputFile) +} + +$null = [Sentry.SentrySdk]::CaptureMessage('hello-from-exit-flush-test') + +# Intentionally NO Stop-Sentry call here - delivery must happen automatically on exit. +Write-Host 'CHILD_DONE' diff --git a/tests/exit-flush.tests.ps1 b/tests/exit-flush.tests.ps1 new file mode 100644 index 0000000..c885ed7 --- /dev/null +++ b/tests/exit-flush.tests.ps1 @@ -0,0 +1,73 @@ +# Regression tests for https://github.com/getsentry/sentry-powershell/issues/38 +# +# Verifies that a script which captures an event and exits WITHOUT calling Stop-Sentry: +# * exits cleanly (exit code 0) within a timeout (i.e. does not hang on exit), and +# * still delivers the captured event. +# +# Historically the .NET SDK's automatic flush-on-exit hook (AppDomain.ProcessExit) was disabled +# in this module because it could hang/crash the process (sentry-dotnet#3141). That was fixed and +# the workaround was removed in #85, so omitting Stop-Sentry must now Just Work. + +BeforeAll { + $script:childScript = "$PSScriptRoot$([IO.Path]::DirectorySeparatorChar)exit-flush-test-script.ps1" + + # Runs the exit-flush child script in a separate process and returns whether it exited cleanly + # within the timeout, its exit code, and the delivered envelope content. + function Invoke-ExitFlushChild { + param( + [Parameter(Mandatory)] [string] $Executable, + [int] $TimeoutSeconds = 60 + ) + + $outputFile = [IO.Path]::GetTempFileName() + $stdout = [IO.Path]::GetTempFileName() + $stderr = [IO.Path]::GetTempFileName() + # FileTransport appends; start from an empty file. + Remove-Item $outputFile -ErrorAction SilentlyContinue + + try { + $proc = Start-Process -FilePath $Executable ` + -ArgumentList @('-NoProfile', '-File', $script:childScript, $outputFile) ` + -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + $exited = $proc.WaitForExit($TimeoutSeconds * 1000) + if (-not $exited) { + try { $proc.Kill() } catch {} + return [PSCustomObject]@{ + Exited = $false + ExitCode = $null + Envelope = '' + StdOut = (Get-Content -Raw $stdout -ErrorAction SilentlyContinue) + StdErr = (Get-Content -Raw $stderr -ErrorAction SilentlyContinue) + } + } + + return [PSCustomObject]@{ + Exited = $true + ExitCode = $proc.ExitCode + Envelope = (Get-Content -Raw $outputFile -ErrorAction SilentlyContinue) + StdOut = (Get-Content -Raw $stdout -ErrorAction SilentlyContinue) + StdErr = (Get-Content -Raw $stderr -ErrorAction SilentlyContinue) + } + } finally { + Remove-Item $outputFile, $stdout, $stderr -ErrorAction SilentlyContinue + } + } +} + +Describe 'Automatic flush on exit (without Stop-Sentry)' { + It 'Windows PowerShell' -Skip:($env:OS -ne 'Windows_NT') { + $result = Invoke-ExitFlushChild -Executable 'powershell.exe' + $result.Exited | Should -BeTrue -Because "the process must not hang on exit. StdErr: $($result.StdErr)" + $result.ExitCode | Should -Be 0 -Because "the process must exit cleanly. StdErr: $($result.StdErr)" + $result.Envelope | Should -Match 'hello-from-exit-flush-test' -Because 'the captured event must be delivered even without Stop-Sentry' + } + + It 'PowerShell' { + $result = Invoke-ExitFlushChild -Executable 'pwsh' + $result.Exited | Should -BeTrue -Because "the process must not hang on exit. StdErr: $($result.StdErr)" + $result.ExitCode | Should -Be 0 -Because "the process must exit cleanly. StdErr: $($result.StdErr)" + $result.Envelope | Should -Match 'hello-from-exit-flush-test' -Because 'the captured event must be delivered even without Stop-Sentry' + } +} diff --git a/tests/utils.ps1 b/tests/utils.ps1 index da0da42..b8c7701 100644 --- a/tests/utils.ps1 +++ b/tests/utils.ps1 @@ -11,6 +11,26 @@ class RecordingTransport:Sentry.Extensibility.ITransport { } } +class FileTransport:Sentry.Extensibility.ITransport { + [string] $path + + FileTransport([string] $path) { + $this.path = $path + } + + # Serializes every envelope it's asked to send to a file on disk so that delivery can be + # observed from a parent process after the sending process exits (used by the exit-flush test). + [System.Threading.Tasks.Task]SendEnvelopeAsync([Sentry.Protocol.Envelopes.Envelope] $envelope, [System.Threading.CancellationToken] $cancellationToken) { + $stream = [System.IO.File]::Open($this.path, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) + try { + $envelope.Serialize($stream, $null) + } finally { + $stream.Dispose() + } + return [System.Threading.Tasks.Task]::CompletedTask + } +} + class TestLogger:Sentry.Infrastructure.DiagnosticLogger { TestLogger([Sentry.SentryLevel]$level) : base($level) {} From 2ba4fd1c5f77dfcb91d2fdd14eb9074b3e388348 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 29 May 2026 12:03:57 +1200 Subject: [PATCH 2/3] test: reset PSModulePath for cross-edition child launch When powershell.exe (Windows PowerShell) is spawned from a pwsh host, the inherited PSModulePath points only at PowerShell Core's module directories, preventing autoload of Microsoft.PowerShell.Utility (Import-PowerShellDataFile), which the module's psm1 uses during import. Reset to the machine default under Desktop edition so built-in modules are discoverable. Co-Authored-By: Claude Opus 4.7 --- tests/exit-flush-test-script.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/exit-flush-test-script.ps1 b/tests/exit-flush-test-script.ps1 index 60376b3..d1eca0c 100644 --- a/tests/exit-flush-test-script.ps1 +++ b/tests/exit-flush-test-script.ps1 @@ -17,6 +17,15 @@ param( Set-StrictMode -Version latest $ErrorActionPreference = 'Stop' +# When this script is launched cross-edition - e.g. powershell.exe (Windows PowerShell) spawned +# from a pwsh (PowerShell Core) host - the inherited $env:PSModulePath points only at the launching +# edition's module directories. That prevents Windows PowerShell from autoloading its built-in +# modules (notably Microsoft.PowerShell.Utility, which provides Import-PowerShellDataFile used while +# importing the Sentry module). Reset to the machine default so built-in modules are discoverable. +if ($PSVersionTable.PSEdition -eq 'Desktop') { + $env:PSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') +} + Import-Module "$PSScriptRoot/../modules/Sentry/Sentry.psd1" . "$PSScriptRoot/utils.ps1" From ec180847c42f84f150852e156e73fa0c04fb452b Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 29 May 2026 12:08:08 +1200 Subject: [PATCH 3/3] test: read child exit code via System.Diagnostics.Process Windows PowerShell 5.1 does not reliably populate .ExitCode on the object returned by `Start-Process -PassThru` after WaitForExit(timeout). Start the child via System.Diagnostics.Process instead, reading stdout/stderr async to avoid pipe-buffer deadlocks, so the exit code is available on both editions. Co-Authored-By: Claude Opus 4.7 --- tests/exit-flush.tests.ps1 | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/exit-flush.tests.ps1 b/tests/exit-flush.tests.ps1 index c885ed7..363c7ff 100644 --- a/tests/exit-flush.tests.ps1 +++ b/tests/exit-flush.tests.ps1 @@ -20,17 +20,28 @@ BeforeAll { ) $outputFile = [IO.Path]::GetTempFileName() - $stdout = [IO.Path]::GetTempFileName() - $stderr = [IO.Path]::GetTempFileName() # FileTransport appends; start from an empty file. Remove-Item $outputFile -ErrorAction SilentlyContinue - try { - $proc = Start-Process -FilePath $Executable ` - -ArgumentList @('-NoProfile', '-File', $script:childScript, $outputFile) ` - -PassThru -NoNewWindow ` - -RedirectStandardOutput $stdout -RedirectStandardError $stderr + # Use System.Diagnostics.Process directly rather than Start-Process: on Windows PowerShell 5.1 + # the object returned by `Start-Process -PassThru` does not reliably populate .ExitCode after + # WaitForExit(timeout), whereas reading it from a Process we started ourselves works on both + # editions. + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $Executable + # ArgumentList isn't available on .NET Framework (WinPS 5.1), so build the argument string. + $psi.Arguments = '-NoProfile -File "{0}" "{1}"' -f $script:childScript, $outputFile + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $proc = [System.Diagnostics.Process]::Start($psi) + # Read the streams asynchronously to avoid deadlocking if a pipe buffer fills. + $stdoutTask = $proc.StandardOutput.ReadToEndAsync() + $stderrTask = $proc.StandardError.ReadToEndAsync() + try { $exited = $proc.WaitForExit($TimeoutSeconds * 1000) if (-not $exited) { try { $proc.Kill() } catch {} @@ -38,8 +49,8 @@ BeforeAll { Exited = $false ExitCode = $null Envelope = '' - StdOut = (Get-Content -Raw $stdout -ErrorAction SilentlyContinue) - StdErr = (Get-Content -Raw $stderr -ErrorAction SilentlyContinue) + StdOut = $stdoutTask.Result + StdErr = $stderrTask.Result } } @@ -47,11 +58,12 @@ BeforeAll { Exited = $true ExitCode = $proc.ExitCode Envelope = (Get-Content -Raw $outputFile -ErrorAction SilentlyContinue) - StdOut = (Get-Content -Raw $stdout -ErrorAction SilentlyContinue) - StdErr = (Get-Content -Raw $stderr -ErrorAction SilentlyContinue) + StdOut = $stdoutTask.Result + StdErr = $stderrTask.Result } } finally { - Remove-Item $outputFile, $stdout, $stderr -ErrorAction SilentlyContinue + $proc.Dispose() + Remove-Item $outputFile -ErrorAction SilentlyContinue } } }