diff --git a/scripts/build-windows-release.ps1 b/scripts/build-windows-release.ps1 index 7c339dbcf..f85e16586 100644 --- a/scripts/build-windows-release.ps1 +++ b/scripts/build-windows-release.ps1 @@ -32,6 +32,112 @@ function Invoke-Checked { } } +# `Arguments` is mandatory *and* `[AllowEmptyCollection()]` rather than defaulted +# to `@()`, so that an explicitly empty list binds while an omitted or null one +# stays a hard binding error. A default would silently turn "forwarded nothing" +# into "forwarded an empty list", which is the confusion #512 came from, so both +# halves of that design are pinned here. +function Invoke-CheckedBindingContractTests { + $recorder = { param([string]$Program, [string[]]$Arguments) return 0 } + + $nullRejected = $false + try { + Invoke-Checked "fake-null.exe" $null -Runner $recorder + } catch { + $nullRejected = $true + } + if (-not $nullRejected) { + throw "checked invocation bound a null argument list" + } + + # An omitted mandatory parameter *prompts* in an interactive console host, so + # asserting the omission in-process would hang a developer's terminal. An API + # runspace has a host that cannot prompt and reports the binding failure + # instead. The function under test is rebuilt from the live definition's own + # source text, so any edit to the real parameter block is what gets asserted. + $runspace = [powershell]::Create() + $omissionRejected = $false + try { + $null = $runspace.AddScript(@' +param([string]$Body) +Set-Item -LiteralPath function:Invoke-Checked -Value ([scriptblock]::Create($Body)) +Invoke-Checked "fake-omitted.exe" -Runner { param([string]$Program, [string[]]$Arguments) return 0 } +'@).AddArgument(${function:Invoke-Checked}.ToString()) + try { + $null = $runspace.Invoke() + } catch { + $omissionRejected = + $_.Exception.InnerException -is [System.Management.Automation.ParameterBindingException] + if (-not $omissionRejected) { throw } + } + $omissionRejected = $omissionRejected -or @($runspace.Streams.Error | Where-Object { + $_.Exception -is [System.Management.Automation.ParameterBindingException] + }).Count -gt 0 + } finally { + $runspace.Dispose() + } + if (-not $omissionRejected) { + throw "omitting the argument list was not a mandatory-parameter binding error" + } +} + +# The fake-runner arm below never executes `& $Program @Arguments`, so on its own +# it cannot catch an edit that stops propagating the child's exit status or stops +# forwarding argv. This arm drives the real branch end to end. +# +# The program it drives is the PowerShell host executing this script. That is the +# one executable guaranteed to exist wherever this script can run, so the same +# assertions execute on the Windows runners and on POSIX developer boxes with no +# platform branch that could silently no-op on one of them (#512). +function Invoke-CheckedRealProcessContractTests { + $pwshPath = (Get-Process -Id $PID).Path + if (-not $pwshPath) { + throw "real-process contract test could not resolve the running PowerShell host" + } + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ` + ("vllm-cpp-checked-" + [guid]::NewGuid().ToString("n")) + New-Item -ItemType Directory -Force -Path $scratch | Out-Null + try { + Invoke-Checked $pwshPath @("-NoProfile", "-Command", "exit 0") + + $nonzeroRejected = $false + try { + Invoke-Checked $pwshPath @("-NoProfile", "-Command", "exit 3") + } catch { + $nonzeroRejected = $true + if ($_.Exception.Message -notmatch 'exited with status 3$') { + throw "real-process failure did not report the child's own exit status: $($_.Exception.Message)" + } + } + if (-not $nonzeroRejected) { + throw "real-process nonzero exit status was accepted" + } + + # Exits 0 only for three *distinct* argv entries, the first of which holds + # a space: joining, re-quoting, truncating or reordering the forwarded + # list all land on a different exit status. + $argvProbe = Join-Path $scratch "argv-probe.ps1" + @' +if ($args.Count -ne 3) { exit 21 } +if ($args[0] -ne 'one two' -or $args[1] -ne 'three' -or $args[2] -ne 'four') { exit 22 } +exit 0 +'@ | Set-Content -LiteralPath $argvProbe -Encoding utf8NoBOM + Invoke-Checked $pwshPath @("-NoProfile", "-File", $argvProbe, "one two", "three", "four") + + # The production calls this branch exists for forward an explicitly empty + # list to a program that takes no arguments, so drive that shape for real + # rather than only through the fake runner (#512). + $emptyProbe = Join-Path $scratch "empty-probe.ps1" + @' +if ($args.Count -ne 0) { exit 23 } +exit 0 +'@ | Set-Content -LiteralPath $emptyProbe -Encoding utf8NoBOM + Invoke-Checked $emptyProbe @() + } finally { + Remove-Item -Recurse -Force -LiteralPath $scratch -ErrorAction SilentlyContinue + } +} + # Most of this script's checked invocations run a test executable that takes no # arguments, so `Invoke-Checked` must bind an explicitly empty argument list and # still forward it verbatim (#512). @@ -80,6 +186,9 @@ function Invoke-CheckedContractTests { throw "nonzero $rejectedName-argument exit status was accepted" } } + + Invoke-CheckedBindingContractTests + Invoke-CheckedRealProcessContractTests } function Assert-CrtPolicy {