scripts/internal/continuous-co-review/review-macos-runtime-port.ps1

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if (-not (Get-Command -Name 'New-ReviewPosixRuntimePort' -ErrorAction SilentlyContinue)) {
    . (Join-Path $PSScriptRoot 'review-posix-runtime-common.ps1')
}

function Initialize-ReviewMacSignalType {
    if ('SpecrewReviewMacSignalNative' -as [type]) { return }
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
 
public static class SpecrewReviewMacSignalNative
{
    [DllImport("libc", SetLastError = true)]
    public static extern int killpg(int pgrp, int sig);
}
'@

}

function Get-ReviewMacProcessGroupPids {
    param([Parameter(Mandatory)][int]$ProcessGroupId)
    if ($ProcessGroupId -lt 1) { return [pscustomobject]@{ ok = $false; pids = @() } }
    $pids = [Collections.Generic.List[int]]::new()
    try {
        $lines = @(& ps -axo 'pid=,pgid=' 2>$null)
        if ($LASTEXITCODE -ne 0) { return [pscustomobject]@{ ok = $false; pids = @() } }
        foreach ($line in $lines) {
            if ([string]$line -match '^\s*(?<pid>\d+)\s+(?<pgid>\d+)\s*$' -and [int]$Matches['pgid'] -eq $ProcessGroupId) {
                $observed = [int]$Matches['pid']
                if (-not $pids.Contains($observed)) { $pids.Add($observed) }
            }
        }
    }
    catch { return [pscustomobject]@{ ok = $false; pids = @() } }
    return [pscustomobject]@{ ok = $true; pids = @($pids) }
}

function Wait-ReviewMacProcessGroupEmpty {
    param([Parameter(Mandatory)]$Descriptor, [ValidateRange(100, 30000)][int]$TimeoutMilliseconds = 5000)
    $watch = [Diagnostics.Stopwatch]::StartNew()
    while ($watch.ElapsedMilliseconds -lt $TimeoutMilliseconds) {
        $observed = Get-ReviewMacProcessGroupPids -ProcessGroupId ([int]$Descriptor.pgid)
        if (-not $observed.ok) { return $false }
        if (@($observed.pids).Count -eq 0) { return $true }
        [Threading.Thread]::Sleep(25)
    }
    return $false
}

function Stop-ReviewMacProcessGroup {
    param([Parameter(Mandatory)]$Descriptor, [ValidateRange(0, 10)][int]$GraceSeconds = 5)
    $pgid = [int]$Descriptor.pgid
    if ($pgid -lt 1) { return }
    Initialize-ReviewMacSignalType
    try { $null = [SpecrewReviewMacSignalNative]::killpg($pgid, 15) } catch { $null = $_ }
    if ($GraceSeconds -gt 0) { Start-Sleep -Seconds $GraceSeconds }
    try { $null = [SpecrewReviewMacSignalNative]::killpg($pgid, 9) } catch { $null = $_ }
}

function Test-ReviewMacProcessGroupIdentity {
    param([Parameter(Mandatory)]$Descriptor, [Parameter(Mandatory)]$Ready, [Parameter(Mandatory)][int]$ProcessId)
    return ([int]$Descriptor.pgid -eq $ProcessId -and [int]$Ready.containment_id -eq $ProcessId)
}

function Test-ReviewMacProcessGroupLiveMembership {
    param([Parameter(Mandatory)]$Descriptor, [Parameter(Mandatory)]$Ready, [Parameter(Mandatory)][int]$ProcessId)
    $observed = Get-SpecrewProcessGroupId -TargetPid $ProcessId
    return $null -ne $observed -and [int]$observed -eq $ProcessId
}

function Test-ReviewMacProcessGroupMembership {
    param([Parameter(Mandatory)]$Descriptor, [Parameter(Mandatory)]$Ready, [Parameter(Mandatory)][int]$ProcessId)
    if (-not (Test-ReviewMacProcessGroupIdentity -Descriptor $Descriptor -Ready $Ready -ProcessId $ProcessId)) { return $false }
    return Test-ReviewMacProcessGroupLiveMembership -Descriptor $Descriptor -Ready $Ready -ProcessId $ProcessId
}

function Wait-ReviewMacProcessGroupMembership {
    param(
        [Parameter(Mandatory)]$Descriptor,
        [Parameter(Mandatory)]$Ready,
        [Parameter(Mandatory)][int]$ProcessId,
        [ValidateRange(25, 5000)][int]$TimeoutMilliseconds = 1000,
        [ValidateRange(1, 100)][int]$PollMilliseconds = 25,
        [scriptblock]$MembershipProbe
    )
    if (-not (Test-ReviewMacProcessGroupIdentity -Descriptor $Descriptor -Ready $Ready -ProcessId $ProcessId)) { return $false }
    if (-not $MembershipProbe) { $MembershipProbe = ${function:Test-ReviewMacProcessGroupLiveMembership} }
    $watch = [Diagnostics.Stopwatch]::StartNew()
    do {
        if (& $MembershipProbe -Descriptor $Descriptor -Ready $Ready -ProcessId $ProcessId) { return $true }
        [Threading.Thread]::Sleep($PollMilliseconds)
    } while ($watch.ElapsedMilliseconds -lt $TimeoutMilliseconds)
    return $false
}

function Test-ReviewMacProcessGroupAvailability {
    if (-not $IsMacOS) { return [pscustomobject]@{ ok = $false; reason = 'macos-process-group-wrong-platform' } }
    foreach ($name in @('ps')) {
        if ($null -eq (Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue)) {
            return [pscustomobject]@{ ok = $false; reason = "macos-process-group-command-missing:$name" }
        }
    }
    $process = $null; $readyPath = Join-Path ([IO.Path]::GetTempPath()) ('specrew-macos-pgid-probe-' + [guid]::NewGuid().ToString('N') + '.json')
    try {
        $startInfo = [Diagnostics.ProcessStartInfo]::new(); $startInfo.FileName = [IO.Path]::GetFullPath((Get-Process -Id $PID).Path)
        foreach ($argument in @('-NoProfile', '-File', (Join-Path $PSScriptRoot 'review-posix-process-host.ps1'), '-Mode', 'process-group', '-ReadyPath', $readyPath)) { [void]$startInfo.ArgumentList.Add($argument) }
        $startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true
        $startInfo.RedirectStandardInput = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true
        $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo
        if (-not $process.Start()) { throw 'macos-process-group-probe-start-failed' }
        $stdoutDrain = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null); $stderrDrain = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
        $ready = Wait-ReviewPosixReadyFile -Path $readyPath -ExpectedMode process-group -TimeoutMilliseconds $script:ReviewPosixContainmentHandshakeTimeoutMilliseconds
        $descriptor = [pscustomobject]@{ pgid = $process.Id; mode = 'process-group' }
        if ($null -eq $ready -or -not (Wait-ReviewMacProcessGroupMembership -Descriptor $descriptor -Ready $ready -ProcessId $process.Id)) { throw 'macos-process-group-probe-membership-failed' }
        $process.StandardInput.Close(); [void]$process.WaitForExit(5000)
        [void]$stdoutDrain.Wait(5000); [void]$stderrDrain.Wait(5000)
        if (-not (Wait-ReviewMacProcessGroupEmpty -Descriptor $descriptor -TimeoutMilliseconds 5000)) { throw 'macos-process-group-probe-cleanup-failed' }
        return [pscustomobject]@{ ok = $true; reason = 'macos-process-group-ready' }
    }
    catch { return [pscustomobject]@{ ok = $false; reason = ('macos-process-group-unavailable:' + $_.Exception.Message) } }
    finally {
        if ($null -ne $process) {
            try { if (-not $process.HasExited) { $process.Kill($true); [void]$process.WaitForExit(5000) } } catch { $null = $_ }
            try { $process.Dispose() } catch { $null = $_ }
        }
        try { Remove-Item -LiteralPath $readyPath -Force -ErrorAction SilentlyContinue } catch { $null = $_ }
    }
}

function New-ReviewMacOSRuntimePort {
    [CmdletBinding()]
    param(
        [ValidateRange(1, 7200)][int]$TimeoutSeconds = 900,
        [ValidateRange(0, 10)][int]$TerminationGraceSeconds = 5,
        [scriptblock]$CapabilityProbe
    )
    $membershipCommand = ${function:Wait-ReviewMacProcessGroupMembership}
    $stopCommand = ${function:Stop-ReviewMacProcessGroup}
    $waitCommand = ${function:Wait-ReviewMacProcessGroupEmpty}
    $getGroupCommand = ${function:Get-ReviewMacProcessGroupPids}
    if (-not $CapabilityProbe) { $CapabilityProbe = ${function:Test-ReviewMacProcessGroupAvailability} }
    $setup = { param($invocation) [pscustomobject]@{ pgid = 0; mode = 'process-group' } }
    $verify = {
        param($descriptor, $ready, $processId)
        $descriptor.pgid = $processId
        & $membershipCommand -Descriptor $descriptor -Ready $ready -ProcessId $processId
    }.GetNewClosure()
    $stop = { param($descriptor, $grace) & $stopCommand -Descriptor $descriptor -GraceSeconds $grace }.GetNewClosure()
    $wait = { param($descriptor, $timeout) & $waitCommand -Descriptor $descriptor -TimeoutMilliseconds $timeout }.GetNewClosure()
    $cleanup = { param($descriptor) & $waitCommand -Descriptor $descriptor -TimeoutMilliseconds 1000 }.GetNewClosure()
    $recover = {
        param($receipt)
        if ($null -eq $receipt -or [string]$receipt.runtime_id -cne 'macos-process-group-runtime' -or [string]$receipt.platform -cne 'macos' -or [string]$receipt.containment_kind -cne 'process-group') {
            return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'macos-recovery-receipt-mismatch' }
        }
        try {
            $pgid = 0
            if (-not [int]::TryParse([string]$receipt.containment_id, [ref]$pgid) -or $pgid -lt 1 -or $pgid -ne [int]$receipt.process_id) {
                return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'macos-recovery-process-group-invalid' }
            }
            $root = Get-Process -Id ([int]$receipt.process_id) -ErrorAction SilentlyContinue
            if ($null -ne $root) {
                $start = $root.StartTime.ToUniversalTime()
                $expectedStart = try { ([DateTimeOffset]$receipt.process_started_at).UtcDateTime } catch { $null }
                if ($null -eq $expectedStart) { return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'macos-recovery-process-identity-unreadable' } }
                if ($start.Ticks -ne $expectedStart.Ticks) {
                    return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'macos-recovery-process-identity-reused' }
                }
            }
            $observed = & $getGroupCommand -ProcessGroupId $pgid
            if (-not $observed.ok) { return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'macos-recovery-process-group-unreadable' } }
            if (@($observed.pids).Count -eq 0) { return [pscustomobject]@{ termination_verified = $true; containment = 'verified'; process_tree_live = $false; failure_reason = $null } }
            $descriptor = [pscustomobject]@{ pgid = $pgid; mode = 'process-group' }
            & $stopCommand -Descriptor $descriptor -GraceSeconds $TerminationGraceSeconds
            $empty = [bool](& $waitCommand -Descriptor $descriptor -TimeoutMilliseconds 5000)
            return [pscustomobject]@{
                termination_verified = $empty; containment = $(if ($empty) { 'verified' } else { 'unknown' })
                process_tree_live = (-not $empty); failure_reason = $(if ($empty) { $null } else { 'macos-recovery-process-group-still-live' })
            }
        }
        catch {
            return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = ('macos-recovery-failed:' + $_.Exception.Message) }
        }
    }.GetNewClosure()
    return New-ReviewPosixRuntimePort -RuntimeId 'macos-process-group-runtime' -Platform macos -Containment process-group -HostMode process-group `
        -TimeoutSeconds $TimeoutSeconds -TerminationGraceSeconds $TerminationGraceSeconds -CapabilityProbe $CapabilityProbe `
        -SetupContainment $setup -VerifyContainment $verify -StopContainment $stop -WaitContainmentEmpty $wait -CleanupContainment $cleanup -RecoverContainment $recover
}