Files

219 lines
11 KiB
PowerShell
Raw Permalink Normal View History

2026-10-19 17:56:36 +08:00
[CmdletBinding()]
param(
[string[]]$Command,
[string]$WorkingDirectory = (Get-Location).Path,
[string]$Label = 'local check',
[ValidateRange(0, 86400)][int]$WaitSeconds = 900,
[ValidateRange(1, 86400)][int]$TimeoutSeconds = 1200,
[switch]$Status
)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) {
throw 'local-check requires Windows and PowerShell 7 (pwsh).'
}
2026-10-20 15:48:38 +08:00
# 所有工作目录共用两个用户级锁;首槽保留原锁名,运行中的旧入口仍计入容量。
2026-10-19 17:56:36 +08:00
$stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks'
[IO.Directory]::CreateDirectory($stateDirectory) | Out-Null
2026-10-20 15:48:38 +08:00
$slotDefinitions = @(
[pscustomobject]@{ number = 1; lock = (Join-Path $stateDirectory 'heavy.lock'); owner = (Join-Path $stateDirectory 'owner.json'); result = (Join-Path $stateDirectory 'last-result.json') },
[pscustomobject]@{ number = 2; lock = (Join-Path $stateDirectory 'heavy-2.lock'); owner = (Join-Path $stateDirectory 'owner-2.json'); result = (Join-Path $stateDirectory 'last-result-2.json') }
)
function Read-Owner($definition) {
try { return [IO.File]::ReadAllText($definition.owner) } catch { return 'Owner information is being updated.' }
2026-10-19 17:56:36 +08:00
}
2026-10-20 15:48:38 +08:00
function Open-Slot($definition) {
try { return [IO.File]::Open($definition.lock, 'OpenOrCreate', 'ReadWrite', 'None') }
2026-10-19 17:56:36 +08:00
catch [IO.IOException] {
if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw }
return $null
}
}
2026-10-20 15:48:38 +08:00
function Open-AvailableSlot {
foreach ($definition in $slotDefinitions) {
$handle = Open-Slot $definition
if ($handle) { return [pscustomobject]@{ definition = $definition; handle = $handle } }
}
return $null
}
2026-10-19 17:56:36 +08:00
if ($Status) {
2026-10-20 15:48:38 +08:00
$busyCount = 0
foreach ($definition in $slotDefinitions) {
$probe = Open-Slot $definition
if ($probe) { $probe.Dispose(); Write-Host "IDLE: slot $($definition.number)/2 is free." }
else { $busyCount++; Write-Host "BUSY: slot $($definition.number)/2"; Write-Host (Read-Owner $definition) }
if (Test-Path -LiteralPath $definition.result) {
Write-Host "Last result for slot $($definition.number):"
try { Write-Host ([IO.File]::ReadAllText($definition.result)) } catch { Write-Host 'Result is being updated.' }
}
}
Write-Host "Capacity: $busyCount/2 slots occupied."
2026-10-19 17:56:36 +08:00
exit 0
}
if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' }
$directory = (Resolve-Path -LiteralPath $WorkingDirectory).Path
if (-not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'WorkingDirectory must be a directory.' }
if ($env:CHONGCHONG_LOCAL_CHECK_OWNER) {
# 拒绝嵌套申请,避免外层等待内层、内层等待同一锁。
throw 'Already inside local-check. Run the original command directly inside this checked batch.'
}
$timer = [Diagnostics.Stopwatch]::StartNew()
$slot = $null
$job = $null
$child = $null
$gatePath = Join-Path $stateDirectory ("gate-{0}.tmp" -f [guid]::NewGuid())
$owner = $null
$exitCode = 1
try {
$nextNotice = 0
2026-10-20 15:48:38 +08:00
while (-not ($lease = Open-AvailableSlot)) {
2026-10-19 17:56:36 +08:00
if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) {
Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started."
exit 75
}
if ($timer.Elapsed.TotalSeconds -ge $nextNotice) {
2026-10-20 15:48:38 +08:00
$owners = @($slotDefinitions | ForEach-Object { "slot $($_.number)/2: $(Read-Owner $_)" }) -join ' | '
Write-Host "WAITING for either shared heavy check slot: $owners"
2026-10-19 17:56:36 +08:00
$nextNotice = $timer.Elapsed.TotalSeconds + 15
}
Start-Sleep -Milliseconds 250
}
2026-10-20 15:48:38 +08:00
$slot = $lease.handle
$ownerPath = $lease.definition.owner
$resultPath = $lease.definition.result
$owner = [ordered]@{ label = $Label; pid = $PID; slot = $lease.definition.number; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
2026-10-19 17:56:36 +08:00
[IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress))
2026-10-20 15:48:38 +08:00
Write-Host "RUNNING [$Label] in $directory (shared slot $($lease.definition.number)/2; timeout ${TimeoutSeconds}s)"
2026-10-19 17:56:36 +08:00
# Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。
if (-not ('LocalCheckJob' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class LocalCheckJob {
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)] struct IoCounters {
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
public BasicLimits BasicLimitInformation;
public IoCounters IoInfo;
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
}
[StructLayout(LayoutKind.Sequential)] struct Accounting {
public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime;
public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses;
}
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int infoClass, out Accounting info, uint length, IntPtr returnedLength);
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
public static IntPtr Create() {
var job = CreateJobObject(IntPtr.Zero, null);
if (job == IntPtr.Zero) throw new Win32Exception();
var limits = new ExtendedLimits();
limits.BasicLimitInformation.LimitFlags = 0x2000; // KILL_ON_JOB_CLOSE
if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf<ExtendedLimits>())) {
int error = Marshal.GetLastWin32Error(); CloseHandle(job); throw new Win32Exception(error);
}
return job;
}
public static void Attach(IntPtr job, IntPtr process) {
if (!AssignProcessToJobObject(job, process)) throw new Win32Exception();
}
public static void Stop(IntPtr job) {
if (!TerminateJobObject(job, 124)) throw new Win32Exception();
// 后代全部退出再归还执行名额,避免退出瞬间下一任务与遗留 worker 重叠。
Accounting info;
do {
if (!QueryInformationJobObject(job, 1, out info, (uint)Marshal.SizeOf<Accounting>(), IntPtr.Zero)) throw new Win32Exception();
if (info.ActiveProcesses != 0) System.Threading.Thread.Sleep(50);
} while (info.ActiveProcesses != 0);
}
}
'@
}
$job = [LocalCheckJob]::Create()
$payload = @{ command = $Command; gate = $gatePath } | ConvertTo-Json -Compress
$payload64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload))
$bootstrap = @'
$ErrorActionPreference = 'Stop'
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:CHONGCHONG_LOCAL_CHECK_PAYLOAD)) | ConvertFrom-Json
while (-not (Test-Path -LiteralPath $payload.gate)) { Start-Sleep -Milliseconds 50 }
$env:CHONGCHONG_LOCAL_CHECK_PAYLOAD = $null
$commandArgs = @($payload.command | Select-Object -Skip 1)
$executable = [string]$payload.command[0]
if ([IO.Path]::GetFileNameWithoutExtension($executable) -in @('mvn', 'mvnw')) {
if (@($commandArgs | Where-Object { $_ -match '^-T|^--threads|^-D(forkCount|reuseForks|parallel)=' }).Count) {
throw 'Do not override Maven concurrency in local-check; -T 1 and forkCount=1 are supplied automatically.'
}
$commandArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true') + $commandArgs
}
if ([IO.Path]::GetFileNameWithoutExtension($executable) -eq 'node' -and $commandArgs -contains '--test') {
if (@($commandArgs | Where-Object { $_ -match '^--test-concurrency' }).Count) { throw 'local-check supplies --test-concurrency=2.' }
$commandArgs = @('--test-concurrency=2') + $commandArgs
}
$global:LASTEXITCODE = 0
& $executable @commandArgs
if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }
exit $LASTEXITCODE
'@
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = (Get-Process -Id $PID).Path
foreach ($arg in @('-NoLogo', '-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)))) { $startInfo.ArgumentList.Add($arg) }
$startInfo.WorkingDirectory = $directory
$startInfo.UseShellExecute = $false
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_OWNER'] = [string]$PID
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_PAYLOAD'] = $payload64
$startInfo.Environment['VITEST_MAX_WORKERS'] = '2'
$startInfo.Environment['NODE_OPTIONS'] = (($env:NODE_OPTIONS -replace '--max-old-space-size(?:=|\s+)\S+', '') + ' --max-old-space-size=2048').Trim()
$startInfo.Environment['JAVA_TOOL_OPTIONS'] = (($env:JAVA_TOOL_OPTIONS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
# Maven 启动器可能在 JAVA_TOOL_OPTIONS 之后应用 MAVEN_OPTS,末尾再限定一次。
$startInfo.Environment['MAVEN_OPTS'] = (($env:MAVEN_OPTS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
$child = [Diagnostics.Process]::Start($startInfo)
[LocalCheckJob]::Attach($job, $child.Handle)
# 先纳入 Job 再放行,防止短命包装器提前产生未受管理的后代。
[IO.File]::WriteAllText($gatePath, '')
$running = [Diagnostics.Stopwatch]::StartNew()
while (-not $child.WaitForExit(250)) {
if ($running.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
Write-Host "EXECUTION TIMEOUT [$Label]; stopping only this check's process tree."
$exitCode = 124
break
}
}
if ($exitCode -ne 124) { $exitCode = $child.ExitCode }
} finally {
if ($job) {
try { [LocalCheckJob]::Stop($job) }
finally { [LocalCheckJob]::CloseHandle($job) | Out-Null }
}
if ($child) {
if (-not $child.HasExited) { $child.Kill($true); $child.WaitForExit() }
$child.Dispose()
}
if (Test-Path -LiteralPath $gatePath) { Remove-Item -LiteralPath $gatePath -Force }
if ($slot) {
try {
if ($owner) {
$owner['finishedAt'] = [DateTimeOffset]::Now.ToString('o')
$owner['exitCode'] = $exitCode
[IO.File]::WriteAllText($resultPath, ($owner | ConvertTo-Json -Compress))
}
} finally { $slot.Dispose() }
}
}
Write-Host "FINISHED [$Label] exit=$exitCode"
exit $exitCode