perf(ci): 迁移自托管运行器并优化并行验收 (#634)

* perf(ci): 切换现有自托管运行器并复用本机缓存

* perf(ci): 将主要 Linux 任务迁至 Mac ARM64 运行器

* fix(ci): 使用动态端口执行 Windows 安装验收

* test(cpa): 等待异步处理完成后校验 WebSocket 行为

* perf(ci): 并行数据库测试并隔离容器验收端口

* ci: 为数据库合同测试启用内存盘

* test(cpa): 固定断连日志脱敏测试的执行顺序

* fix(ci): 修复自托管验收中断和架构兼容问题

* fix(ci): 原子准备 Windows 安装验收配置
This commit is contained in:
tbphp authored and GitHub committed 2026-09-13 08:31:56 +08:00
1 parent 9d38793dd0
commit d01656a1d8
30 files changed
+1115 -161

No files matched your search

+2 -2
View File
@@ -17,8 +17,8 @@ runs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.18.0
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
# 自托管 Mac 保留 pnpm store,不再恢复远端缓存。
package-manager-cache: false
- name: Install web dependencies
shell: bash
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# 两组互补:增量迁移契约单独运行,其余(包括未来新增测试)自动归入另一组。
migrations='^TestExternal(AccessKeyPrefix|CustomAccessKey|PriceMultiplier|ModelCooldown|ValidationProtocol)MigrationContract$'
run='^TestExternal'
skip='^$'
case "${1:-all}" in
all | postgres) ;;
mysql-migrations) run="${migrations}" ;;
mysql-rest) skip="${migrations}" ;;
mysql)
: "${GPT_LOAD_DATABASE_TEST_DSN:?primary MySQL DSN is required}"
: "${GPT_LOAD_DATABASE_SECONDARY_DSN:?secondary MySQL DSN is required}"
logs="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/mysql-contract.XXXXXX")"
cleanup() {
for pid in $(jobs -pr); do
kill "${pid}" 2>/dev/null || true
done
wait || true
rm -rf "${logs}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
bash "$0" mysql-migrations >"${logs}/migrations.log" 2>&1 &
migrations_pid=$!
GPT_LOAD_DATABASE_TEST_DSN="${GPT_LOAD_DATABASE_SECONDARY_DSN}" \
bash "$0" mysql-rest >"${logs}/rest.log" 2>&1 &
rest_pid=$!
result=0
wait "${migrations_pid}" || result=1
wait "${rest_pid}" || result=1
for shard in migrations rest; do
echo "::group::MySQL ${shard}"
cat "${logs}/${shard}.log"
echo '::endgroup::'
done
exit "${result}"
;;
*) echo "unknown database test shard: $1" >&2; exit 2 ;;
esac
exec go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog \
-run "${run}" -skip "${skip}"
+19 -8
View File
@@ -17,14 +17,23 @@ $dataDir = Join-Path $configDir "data"
$envFile = Join-Path $configDir ".env"
$dataOwnerMarker = Join-Path $configDir ".service-smoke-owner"
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
throw "refusing pre-existing Windows service: $serviceName"
}
if (Test-Path $installDir) {
throw "refusing pre-existing installation directory: $installDir"
}
if (Test-Path $configDir) {
throw "refusing pre-existing ProgramData directory: $configDir"
. "$PSScriptRoot/windows-smoke-recovery.ps1"
$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir
try {
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
throw "refusing pre-existing Windows service: $serviceName"
}
if (Test-Path $installDir) {
throw "refusing pre-existing installation directory: $installDir"
}
if (Test-Path $configDir) {
throw "refusing pre-existing ProgramData directory: $configDir"
}
} catch {
$smokeMutex.ReleaseMutex()
$smokeMutex.Dispose()
throw
}
function Assert-ServiceAcl {
@@ -131,4 +140,6 @@ try {
((Get-Content $dataOwnerMarker -Raw).Trim() -eq $installOwnerToken)) {
Remove-Item -Recurse -Force $configDir -ErrorAction SilentlyContinue
}
$smokeMutex.ReleaseMutex()
$smokeMutex.Dispose()
}
+9 -3
View File
@@ -38,8 +38,7 @@ fake_container="gpt-load-release-fake-${suffix}"
fake_alias="fake-upstream"
volume="gpt-load-release-smoke-${suffix}"
network="gpt-load-release-network-${suffix}"
app_port="${RELEASE_SMOKE_APP_PORT:-39413}"
base_url="http://127.0.0.1:${app_port}"
app_port="${RELEASE_SMOKE_APP_PORT:-0}"
task_tmp="$(mktemp -d)"
smoke_stage="preflight"
@@ -55,6 +54,8 @@ cleanup_temp() {
fi
}
trap cleanup_temp EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
for target in "${container}" "${probe}" "${fake_container}"; do
if docker container inspect "${target}" >/dev/null 2>&1; then
@@ -207,9 +208,14 @@ start_container() {
docker run -d \
--name "${container}" \
--network "${network}" \
--publish "${app_port}:3001" \
--publish "127.0.0.1:${app_port}:3001" \
--volume "${volume}:/app/data" \
"${image}" >/dev/null
# Docker 原子分配可用端口;重建容器后也重新查询,避免多个 Runner 抢占端口。
local binding
binding="$(docker port "${container}" 3001/tcp)"
[[ "${binding}" =~ ^127\.0\.0\.1:[0-9]+$ ]]
base_url="http://${binding}"
}
wait_for_health() {
@@ -28,8 +28,11 @@ $installOwnerToken = $suffix
$programData = [Environment]::GetFolderPath("CommonApplicationData")
$configDir = Join-Path $programData "GPT-Load"
$dataDir = Join-Path $configDir "data"
$envFile = Join-Path $configDir ".env"
$dataOwnerMarker = Join-Path $configDir ".installer-smoke-owner"
$failureDataMarker = Join-Path $dataDir "installer-smoke-failure.txt"
$preparedConfig = Join-Path $programData ".gpt-load-installer-smoke-$suffix"
$ownsPreparedConfig = $false
$upgradeMarker = Join-Path $dataDir "installer-smoke-upgrade.txt"
$installedBinary = Join-Path $installDir "gpt-load.exe"
$uninstaller = Join-Path $installDir "unins000.exe"
@@ -43,19 +46,28 @@ if ([System.IO.Path]::GetFullPath($configDir) -ne
[System.IO.Path]::GetFullPath((Join-Path $programData "GPT-Load"))) {
throw "refusing unexpected ProgramData cleanup target: $configDir"
}
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
throw "refusing pre-existing Windows service: $serviceName"
}
if (Test-Path $installDir) {
throw "refusing pre-existing installation directory: $installDir"
}
if (Test-Path $configDir) {
throw "refusing pre-existing ProgramData directory: $configDir"
}
foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) {
if (Test-Path $path) {
throw "refusing pre-existing installer smoke path: $path"
. "$PSScriptRoot/windows-smoke-recovery.ps1"
$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir
try {
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
throw "refusing pre-existing Windows service: $serviceName"
}
if (Test-Path $installDir) {
throw "refusing pre-existing installation directory: $installDir"
}
if (Test-Path $configDir) {
throw "refusing pre-existing ProgramData directory: $configDir"
}
foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) {
if (Test-Path $path) {
throw "refusing pre-existing installer smoke path: $path"
}
}
} catch {
$smokeMutex.ReleaseMutex()
$smokeMutex.Dispose()
throw
}
function Invoke-CheckedProcess {
@@ -112,17 +124,28 @@ function Assert-ServiceAcl {
try {
[System.IO.File]::WriteAllText($installOwnerMarker, $installOwnerToken)
New-Item -ItemType Directory -Path $configDir | Out-Null
[System.IO.File]::WriteAllText($dataOwnerMarker, $installOwnerToken)
New-Item -ItemType Directory -Path $dataDir | Out-Null
[System.IO.File]::WriteAllText($failureDataMarker, $installOwnerToken)
# 同盘准备完整归属凭据后再公开固定目录,避免中断留下半初始化状态。
New-Item -ItemType Directory -Path $preparedConfig | Out-Null
$ownsPreparedConfig = $true
[System.IO.File]::WriteAllText((Join-Path $preparedConfig ".installer-smoke-owner"), $installOwnerToken)
New-Item -ItemType Directory -Path (Join-Path $preparedConfig "data") | Out-Null
[System.IO.File]::WriteAllText((Join-Path $preparedConfig "data/installer-smoke-failure.txt"), $installOwnerToken)
[System.IO.Directory]::Move($preparedConfig, $configDir)
$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Loopback,
3001
0
)
# 由系统分配可用端口,避开自托管 Windows 的端口排除范围。
$listener.ExclusiveAddressUse = $true
$listener.Start()
try {
$port = $listener.LocalEndpoint.Port
# 测试服务读取同一端口;独占监听保持到安装失败回滚验收结束。
@(
"HOST=127.0.0.1",
"PORT=$port"
) | Set-Content -Path $envFile -Encoding utf8NoBOM
Invoke-CheckedProcess -Path $setup -Arguments @(
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
@@ -179,7 +202,7 @@ try {
$health = $null
for ($attempt = 0; $attempt -lt 80; $attempt++) {
try {
$health = Invoke-RestMethod "http://127.0.0.1:3001/health"
$health = Invoke-RestMethod "http://127.0.0.1:$port/health"
break
} catch {
Start-Sleep -Milliseconds 250
@@ -196,7 +219,7 @@ try {
}
$authKey = (Get-Content $authFile -Raw).Trim()
$headers = @{ Authorization = "Bearer $authKey" }
Invoke-RestMethod "http://127.0.0.1:3001/api/system/info" -Headers $headers | Out-Null
Invoke-RestMethod "http://127.0.0.1:$port/api/system/info" -Headers $headers | Out-Null
[System.IO.File]::WriteAllText($upgradeMarker, $suffix)
Invoke-CheckedProcess -Path $setup -Arguments @(
@@ -227,6 +250,9 @@ try {
$afterHash = (Get-FileHash -Algorithm SHA256 $setup).Hash.ToLowerInvariant()
if ($afterHash -ne $expectedHash) { throw "Windows setup checksum mismatch after execution" }
} finally {
if ($ownsPreparedConfig -and (Test-Path -LiteralPath $preparedConfig)) {
Remove-Item -LiteralPath $preparedConfig -Recurse -Force
}
$ownsInstall = (Test-Path $installOwnerMarker) -and
((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken)
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
@@ -257,4 +283,6 @@ try {
((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken)) {
Remove-Item -Force $installOwnerMarker -ErrorAction SilentlyContinue
}
$smokeMutex.ReleaseMutex()
$smokeMutex.Dispose()
}
@@ -0,0 +1,93 @@
function Enter-WindowsSmoke {
param([string]$InstallDir, [string]$ConfigDir)
# 固定服务名只能由一个 smoke 使用;进程退出后系统自动释放互斥锁。
$mutex = [System.Threading.Mutex]::new($false, "Global\GPTLoad-Windows-Smoke")
$acquired = $false
try {
try { $acquired = $mutex.WaitOne(0) }
catch [System.Threading.AbandonedMutexException] { $acquired = $true }
if (-not $acquired) { throw "another Windows smoke is running" }
$owners = @()
foreach ($directory in @($InstallDir, $ConfigDir)) {
if (-not (Test-Path -LiteralPath $directory)) { continue }
if ((Get-Item -LiteralPath $directory -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw "refusing linked Windows smoke directory: $directory"
}
foreach ($name in @('.service-smoke-owner', '.installer-smoke-owner')) {
$path = Join-Path $directory $name
if (-not (Test-Path -LiteralPath $path)) { continue }
$item = Get-Item -LiteralPath $path -Force
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw "refusing invalid Windows smoke marker: $path"
}
$token = (Get-Content -LiteralPath $path -Raw).Trim()
if ($token -notmatch '^[0-9a-fA-F]{32}$') { throw "invalid Windows smoke owner: $path" }
$owners += @{ Name = $name; Token = $token }
}
}
# 无标记时保留现场,让调用方的原有 preflight 拒绝真实安装。
if ($owners.Count -eq 0) { return $mutex }
$owner = $owners[0]
foreach ($other in $owners) {
if ($other.Name -ne $owner.Name -or $other.Token -ne $owner.Token) {
throw "conflicting Windows smoke owners"
}
}
$installer = $owner.Name -eq '.installer-smoke-owner'
if ($installer) {
# 安装器会删除、重建安装目录;其归属凭据保存在卸载时保留的 ProgramData。
$proof = Join-Path $ConfigDir 'data/installer-smoke-failure.txt'
if (-not (Test-Path -LiteralPath $proof) -or
((Get-Content -LiteralPath $proof -Raw).Trim() -ne $owner.Token)) {
throw "missing installer smoke ownership proof"
}
} else {
foreach ($directory in @($InstallDir, $ConfigDir)) {
if ((Test-Path -LiteralPath $directory) -and
-not (Test-Path -LiteralPath (Join-Path $directory $owner.Name))) {
throw "unmarked Windows smoke directory: $directory"
}
}
}
$binary = Join-Path $InstallDir 'gpt-load.exe'
$service = Get-CimInstance Win32_Service -Filter "Name='gpt-load'" -ErrorAction Stop
if ($null -ne $service) {
if ($service.PathName -ine ('"' + $binary + '" service run') -or
$service.StartName -ine 'NT AUTHORITY\LocalService') {
throw "refusing Windows service with unexpected configuration"
}
$controller = Get-Service -Name 'gpt-load' -ErrorAction Stop
try {
if ($controller.Status -ne 'Stopped') {
$controller.Stop()
$controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
}
} finally { $controller.Dispose() }
& sc.exe delete 'gpt-load' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "failed to remove stale Windows smoke service" }
}
# 只回收已确认归属的固定目标,不扫描或清理其他任务、服务和目录。
if (Test-Path -LiteralPath $InstallDir) { Remove-Item -LiteralPath $InstallDir -Recurse -Force }
if ($installer) {
$paths = @(
([IO.Path]::Combine([Environment]::GetFolderPath('CommonDesktopDirectory'), 'GPT-Load.url')),
([IO.Path]::Combine([Environment]::GetFolderPath('CommonPrograms'), 'GPT-Load/GPT-Load.url')),
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{E5A127DE-2676-4F6C-B763-CF53C6271883}_is1'
)
foreach ($path in $paths) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
}
# 最后删除归属标记所在目录;中途失败时保留下一次恢复所需的证据。
if (Test-Path -LiteralPath $ConfigDir) { Remove-Item -LiteralPath $ConfigDir -Recurse -Force }
return $mutex
} catch {
if ($acquired) { $mutex.ReleaseMutex() }
$mutex.Dispose()
throw
}
}
+49 -41
View File
@@ -10,6 +10,8 @@ permissions:
# 竖线分隔在任何错误(含网络错误)时回退到下一个源;默认的逗号只在
# 404/410 时回退,proxy.golang.org 瞬时故障会直接让整个 job 失败。
# 自托管 Runner 保留本机 Go 缓存;代码与编译参数变化由 Go 自动判定失效。
# 不再下载/上传 setup-go 缓存,也无需 main push 预热。
env:
GOPROXY: https://proxy.golang.org|direct
@@ -19,7 +21,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-24.04
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -34,7 +36,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Check Go formatting
run: |
@@ -62,7 +64,7 @@ jobs:
run: git diff --check
race-tests:
runs-on: ubuntu-24.04
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -70,27 +72,17 @@ jobs:
with:
persist-credentials: false
- name: Mark race-instrumented Go cache scope
# race-tests、test、database-contract(*) 默认共用同一个基于 go.sum 的
# setup-go 缓存 key;命中即不保存,race-instrumented GOCACHE 永远无法
# 写回,每次都要重新编译。加入这个 marker 文件参与 cache-dependency-path
# 哈希,让 race-tests 拥有独立 key 命名空间。
run: echo race-tests > .go-cache-scope
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: |
go.sum
.go-cache-scope
cache: false
- name: Run race-enabled tests
run: go test -race -count=1 -timeout=15m . ./internal/...
race-cpa:
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -102,8 +94,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: third_party/cpaembedded/go.sum
cache: false
- name: Run embedded CPA bridge race-enabled tests
working-directory: third_party/cpaembedded
@@ -111,7 +102,7 @@ jobs:
database-contract:
name: Database contract (${{ matrix.driver }})
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 15
strategy:
fail-fast: false
@@ -119,34 +110,48 @@ jobs:
matrix:
include:
- driver: mysql
dsn: mysql://root:root@127.0.0.1:3306/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27
image: mysql:8.4
data-dir: /var/lib/mysql
port: "3306"
health: mysqladmin ping -h 127.0.0.1 -proot
dsn: mysql://root:root@127.0.0.1:{0}/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27
- driver: postgres
dsn: postgres://postgres:postgres@127.0.0.1:5432/gpt_load?sslmode=disable
image: postgres:18
data-dir: /var/lib/postgresql
port: "5432"
health: pg_isready -U postgres -d gpt_load
dsn: postgres://postgres:postgres@127.0.0.1:{0}/gpt_load?sslmode=disable
# 仅临时合同测试数据库使用内存盘;每个实例按实际使用量占用,最多 2 GiB。
services:
mysql:
image: mysql:8.4
database:
image: ${{ matrix.image }}
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gpt_load
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gpt_load
ports:
- ${{ matrix.port }}
options: >-
--tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g
--health-cmd="${{ matrix.health }}"
--health-interval=5s
--health-timeout=10s
--health-retries=20
# MySQL 两组使用独立实例,避免不同测试数据库争用同名 GET_LOCK。
secondary-mysql:
image: ${{ matrix.driver == 'mysql' && 'mysql:8.4' || '' }}
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gpt_load
ports:
- 3306:3306
- 3306
options: >-
--tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=5s
--health-timeout=10s
--health-retries=20
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gpt_load
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d gpt_load"
--health-interval=5s
--health-timeout=10s
--health-retries=20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -157,16 +162,16 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Run external database contract
env:
GPT_LOAD_DATABASE_TEST_DSN: ${{ matrix.dsn }}
run: |
go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog -run '^TestExternal'
GPT_LOAD_DATABASE_TEST_DSN: ${{ format(matrix.dsn, job.services.database.ports[format('{0}', matrix.port)]) }}
GPT_LOAD_DATABASE_SECONDARY_DSN: ${{ matrix.driver == 'mysql' && format(matrix.dsn, job.services.secondary-mysql.ports['3306']) || '' }}
run: .github/scripts/ci-database-contract.sh "${{ matrix.driver }}"
windows-encryption-acl:
runs-on: windows-2025
runs-on: [self-hosted, Windows, X64]
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -178,7 +183,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Test Windows secure file and storage ACLs
run: go test -v -count=1 ./internal/platform/securefile ./internal/platform/encryption ./internal/storage
@@ -186,6 +191,9 @@ jobs:
- name: Test Windows service lifecycle
run: go test -v -count=1 .
- name: Test Windows smoke recovery
run: go test -count=1 ./internal/webui -run '^TestWindowsSmokeRecovery$'
- name: Smoke Windows service lifecycle
shell: pwsh
run: |
+136 -69
View File
@@ -10,6 +10,8 @@ permissions:
# 竖线分隔在任何错误(含网络错误)时回退到下一个源;默认的逗号只在
# 404/410 时回退,proxy.golang.org 瞬时故障会直接让整个 job 失败。
# 自托管 Runner 保留本机 Go 缓存;代码与编译参数变化由 Go 自动判定失效。
# 不再下载/上传 setup-go 缓存,也无需 main push 预热。
env:
GOPROXY: https://proxy.golang.org|direct
@@ -19,7 +21,7 @@ concurrency:
jobs:
validate-tag:
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 5
permissions:
contents: read
@@ -141,7 +143,7 @@ jobs:
verify-and-build-web:
needs: validate-tag
runs-on: ubuntu-24.04
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -163,7 +165,7 @@ jobs:
static-checks:
needs: validate-tag
runs-on: ubuntu-24.04
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- name: Checkout tag commit
@@ -175,7 +177,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Check Go formatting
run: |
@@ -206,7 +208,7 @@ jobs:
needs: validate-tag
# 同一 commit 的 CI 已全绿时跳过;publication-preflight 接受 skipped 结果。
if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- name: Checkout tag commit
@@ -214,21 +216,11 @@ jobs:
with:
persist-credentials: false
- name: Mark race-instrumented Go cache scope
# race-tests、test、database-contract(*) 默认共用同一个基于 go.sum 的
# setup-go 缓存 key;命中即不保存,race-instrumented GOCACHE 永远无法
# 写回,每次都要重新编译。加入这个 marker 文件参与 cache-dependency-path
# 哈希,让 race-tests 拥有独立 key 命名空间。
run: echo race-tests > .go-cache-scope
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: |
go.sum
.go-cache-scope
cache: false
- name: Run race-enabled tests
run: go test -race -count=1 -timeout=15m . ./internal/...
@@ -236,7 +228,7 @@ jobs:
race-cpa:
needs: validate-tag
if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -248,8 +240,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: third_party/cpaembedded/go.sum
cache: false
- name: Run embedded CPA bridge race-enabled tests
working-directory: third_party/cpaembedded
@@ -259,7 +250,7 @@ jobs:
name: Database contract (${{ matrix.driver }})
needs: validate-tag
if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 15
strategy:
fail-fast: false
@@ -267,34 +258,48 @@ jobs:
matrix:
include:
- driver: mysql
dsn: mysql://root:root@127.0.0.1:3306/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27
image: mysql:8.4
data-dir: /var/lib/mysql
port: "3306"
health: mysqladmin ping -h 127.0.0.1 -proot
dsn: mysql://root:root@127.0.0.1:{0}/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27
- driver: postgres
dsn: postgres://postgres:postgres@127.0.0.1:5432/gpt_load?sslmode=disable
image: postgres:18
data-dir: /var/lib/postgresql
port: "5432"
health: pg_isready -U postgres -d gpt_load
dsn: postgres://postgres:postgres@127.0.0.1:{0}/gpt_load?sslmode=disable
# 仅临时合同测试数据库使用内存盘;每个实例按实际使用量占用,最多 2 GiB。
services:
mysql:
image: mysql:8.4
database:
image: ${{ matrix.image }}
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gpt_load
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gpt_load
ports:
- ${{ matrix.port }}
options: >-
--tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g
--health-cmd="${{ matrix.health }}"
--health-interval=5s
--health-timeout=10s
--health-retries=20
# MySQL 两组使用独立实例,避免不同测试数据库争用同名 GET_LOCK。
secondary-mysql:
image: ${{ matrix.driver == 'mysql' && 'mysql:8.4' || '' }}
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gpt_load
ports:
- 3306:3306
- 3306
options: >-
--tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=5s
--health-timeout=10s
--health-retries=20
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gpt_load
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d gpt_load"
--health-interval=5s
--health-timeout=10s
--health-retries=20
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -305,17 +310,17 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Run external database contract
env:
GPT_LOAD_DATABASE_TEST_DSN: ${{ matrix.dsn }}
run: |
go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog -run '^TestExternal'
GPT_LOAD_DATABASE_TEST_DSN: ${{ format(matrix.dsn, job.services.database.ports[format('{0}', matrix.port)]) }}
GPT_LOAD_DATABASE_SECONDARY_DSN: ${{ matrix.driver == 'mysql' && format(matrix.dsn, job.services.secondary-mysql.ports['3306']) || '' }}
run: .github/scripts/ci-database-contract.sh "${{ matrix.driver }}"
build-binaries:
needs: verify-and-build-web
runs-on: ubuntu-24.04
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: true
@@ -323,18 +328,23 @@ jobs:
matrix:
include:
- goos: linux
runner: [self-hosted, Linux, ARM64]
goarch: amd64
filename: gpt-load-linux-amd64
- goos: linux
runner: [self-hosted, Linux, ARM64]
goarch: arm64
filename: gpt-load-linux-arm64
- goos: darwin
runner: [self-hosted, macOS, ARM64]
goarch: amd64
filename: gpt-load-macos-amd64
- goos: darwin
runner: [self-hosted, macOS, ARM64]
goarch: arm64
filename: gpt-load-macos-arm64
- goos: windows
runner: [self-hosted, Windows, X64]
goarch: amd64
filename: gpt-load-windows-amd64.exe
steps:
@@ -353,9 +363,10 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Build release binary
shell: bash
env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }}
@@ -378,7 +389,7 @@ jobs:
build-windows-setup:
needs: build-binaries
runs-on: windows-2025
runs-on: [self-hosted, Windows, X64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -422,7 +433,7 @@ jobs:
package-metadata:
needs: validate-tag
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -434,7 +445,7 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache: false
- name: Generate release SBOM and license assets
run: |
@@ -462,7 +473,7 @@ jobs:
- build-binaries
- build-windows-setup
- package-metadata
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -522,15 +533,15 @@ jobs:
max-parallel: 5
matrix:
include:
- runner: ubuntu-24.04
- runner: [self-hosted, Linux, X64]
filename: gpt-load-linux-amd64
- runner: ubuntu-24.04-arm
- runner: [self-hosted, Linux, ARM64]
filename: gpt-load-linux-arm64
- runner: macos-15-intel
filename: gpt-load-macos-amd64
- runner: macos-15
- runner: [self-hosted, macOS, ARM64]
filename: gpt-load-macos-arm64
- runner: windows-2025
- runner: [self-hosted, Windows, X64]
filename: gpt-load-windows-amd64.exe
runs-on: ${{ matrix.runner }}
steps:
@@ -573,7 +584,7 @@ jobs:
windows-installer-smoke:
needs: package-checksums
runs-on: windows-2025
runs-on: [self-hosted, Windows, X64]
timeout-minutes: 10
steps:
- name: Checkout tag commit
@@ -612,9 +623,16 @@ jobs:
docker-smoke:
needs: validate-tag
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 30
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -624,7 +642,7 @@ jobs:
shell: bash
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}
RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_SMOKE_TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f
run: .github/scripts/release-docker-smoke.sh
@@ -642,12 +660,19 @@ jobs:
# 路径 COPY 的是不同的二进制,必须各自验证。使用原生 arm64 runner 而非
# QEMU:QEMU 模拟下的启动行为不能代表真实 arm64 运行时。
- arch: amd64
runner: ubuntu-24.04
runner: [self-hosted, Linux, X64]
- arch: arm64
runner: ubuntu-24.04-arm
runner: [self-hosted, Linux, ARM64]
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -663,7 +688,7 @@ jobs:
shell: bash
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_SMOKE_SUFFIX: prebuilt-${{ matrix.arch }}-${{ github.run_id }}
RELEASE_SMOKE_SUFFIX: prebuilt-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_SMOKE_TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f
RELEASE_SMOKE_BUILD_TARGET: prebuilt
run: .github/scripts/release-docker-smoke.sh
@@ -689,7 +714,7 @@ jobs:
# race-tests、race-cpa 与 database-contract 在 CI 已验证同一 commit 时为 skipped,
# 这里只拒绝真正失败的 gate,不把 skipped 当作缺失的验证。
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 20
permissions:
# Draft Releases are visible only to identities with push access.
@@ -710,6 +735,13 @@ jobs:
ghcr_latest_digest: ${{ steps.inventory.outputs.ghcr_latest_digest }}
dockerhub_latest_digest: ${{ steps.inventory.outputs.dockerhub_latest_digest }}
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -945,12 +977,19 @@ jobs:
# 间接祖先因 CI 结论复用而 skipped,publish-images 也会被默认条件连带跳过,
# 即便这里列出的直接 needs 全部成功。显式条件只看直接 needs,绕开这个传播。
if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -968,7 +1007,7 @@ jobs:
if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' }}
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
with:
platforms: arm64
platforms: amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
@@ -1054,7 +1093,7 @@ jobs:
- publish-images
# 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。
if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' && needs.publish-images.result == 'success' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
permissions:
contents: write
@@ -1141,12 +1180,19 @@ jobs:
- publish-images
# 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。
if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publish-images.result == 'success' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 30
permissions:
contents: read
packages: read
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -1164,7 +1210,7 @@ jobs:
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_SMOKE_SOURCE_IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}
RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-ghcr
RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }}-ghcr
# 同一 commit 的镜像已在 docker-smoke 扫描过,这里不重复扫描。
RELEASE_SMOKE_SKIP_SCAN: "true"
run: .github/scripts/release-docker-smoke.sh
@@ -1177,13 +1223,20 @@ jobs:
- publish-images
# 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。
if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' && needs.publish-github.result == 'success' && needs.publish-images.result == 'success' }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 45
permissions:
# Draft Releases are visible only to identities with push access.
contents: write
packages: read
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -1313,7 +1366,7 @@ jobs:
group: gpt-load-v2-image-channels
cancel-in-progress: false
queue: max
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 20
permissions:
contents: read
@@ -1322,6 +1375,13 @@ jobs:
beta_current: ${{ steps.promote.outputs.beta_current }}
major_current: ${{ steps.promote.outputs.major_current }}
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -1376,7 +1436,7 @@ jobs:
environment:
name: render
url: ${{ vars.RENDER_SERVICE_URL }}
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 30
permissions:
contents: read
@@ -1386,7 +1446,7 @@ jobs:
RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}
IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}
RENDER_CLI_VERSION: "2.25.0"
RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"
RENDER_CLI_SHA256: "7c4dc66ded7acc75e2f828b02d3d6901e9f2e4175d25c4fe5fd6f3aaf66b071b"
steps:
- name: Validate deployment inputs
shell: bash
@@ -1401,7 +1461,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
archive="cli_${RENDER_CLI_VERSION}_linux_amd64.zip"
archive="cli_${RENDER_CLI_VERSION}_linux_arm64.zip"
archive_path="${RUNNER_TEMP}/${archive}"
extract_dir="${RUNNER_TEMP}/render-cli-${RENDER_CLI_VERSION}"
url="https://github.com/render-oss/cli/releases/download/v${RENDER_CLI_VERSION}/${archive}"
@@ -1496,13 +1556,20 @@ jobs:
- post-publish-image-smoke
- post-publish-verify
- promote-image-channels
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, ARM64]
timeout-minutes: 10
permissions:
# Draft Releases are visible only to identities with push access.
contents: write
packages: read
steps:
- name: Isolate Docker credentials
shell: bash
run: |
# 使用临时登录配置,避免覆盖宿主机 Docker 凭据。
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")"
echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"
- name: Checkout tag commit for read-only reconciliation
if: ${{ always() }}
continue-on-error: true
@@ -9,6 +9,7 @@ import (
)
func TestAccessKeyPrefixMigrationContract(t *testing.T) {
t.Parallel()
testAccessKeyPrefixMigration(t, openInternalMigrationTestDatabase)
}
@@ -24,6 +25,9 @@ func testAccessKeyPrefixMigration(t *testing.T, open func(*testing.T) *gorm.DB)
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if db.Dialector.Name() == "sqlite" {
t.Parallel()
}
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:11]); err != nil {
t.Fatal(err)
@@ -11,6 +11,7 @@ import (
)
func TestCustomAccessKeyMigrationContract(t *testing.T) {
t.Parallel()
testCustomAccessKeyMigration(t, openInternalMigrationTestDatabase)
}
@@ -26,6 +27,9 @@ func testCustomAccessKeyMigration(t *testing.T, open func(*testing.T) *gorm.DB)
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if db.Dialector.Name() == "sqlite" {
t.Parallel()
}
var key models.AccessKey
var rule models.AccessKeyCostLimitRule
var deletedID uint
+10
View File
@@ -205,6 +205,7 @@ func TestOpenCreatesSQLiteDatabase(t *testing.T) {
}
func TestOpenWithSourceManagedRejectsMissingParentWithoutCreation(t *testing.T) {
t.Parallel()
root := t.TempDir()
parent := filepath.Join(root, "startup-must-create")
dsn := filepath.Join(parent, "managed.db")
@@ -225,6 +226,7 @@ func TestOpenWithSourceManagedRejectsMissingParentWithoutCreation(t *testing.T)
}
func TestOpenWithSourceExternalRejectsMissingParentWithoutCreation(t *testing.T) {
t.Parallel()
root := t.TempDir()
parent := filepath.Join(root, "operator-must-create")
dsn := filepath.Join(parent, "external.db")
@@ -245,6 +247,7 @@ func TestOpenWithSourceExternalRejectsMissingParentWithoutCreation(t *testing.T)
}
func TestOpenAllowsUnversionedFileWithExternalTables(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "external-tables.db")
db, err := storage.Open(path)
if err != nil {
@@ -369,6 +372,7 @@ func TestOpenWithSourceExternalHintDoesNotExposeDSN(t *testing.T) {
}
func TestOpenWithSourceFileModeMemoryURIStaysInMemory(t *testing.T) {
t.Parallel()
databasePath := filepath.Join(t.TempDir(), "named-memory.db")
dsn := "file:" + filepath.ToSlash(databasePath) + "?mode=memory&cache=shared"
@@ -398,6 +402,7 @@ func TestOpenWithSourceFileModeMemoryURIStaysInMemory(t *testing.T) {
}
func TestOpenWithSourceColonMemoryQueryStaysInMemory(t *testing.T) {
t.Parallel()
db, err := storage.OpenWithSource(
":memory:?cache=shared",
config.DatabaseSourceExternal,
@@ -450,6 +455,7 @@ func assertSQLiteMemoryDatabase(t *testing.T, db *gorm.DB) {
}
func TestOpenOverridesSQLiteRuntimeOptions(t *testing.T) {
t.Parallel()
dsn := filepath.Join(t.TempDir(), "runtime.db") +
"?_txlock=deferred&_pragma=foreign_keys(0)" +
"&_pragma=busy_timeout(1)&_pragma=journal_mode(DELETE)"
@@ -487,6 +493,7 @@ func TestOpenOverridesSQLiteRuntimeOptions(t *testing.T) {
}
func TestOpenDoesNotForceWALForMemoryDatabase(t *testing.T) {
t.Parallel()
db, err := storage.Open(":memory:")
if err != nil {
t.Fatalf("Open() error = %v", err)
@@ -510,6 +517,7 @@ func TestOpenDoesNotForceWALForMemoryDatabase(t *testing.T) {
}
func TestOpenUsesImmediateTransactions(t *testing.T) {
t.Parallel()
dsn := filepath.Join(t.TempDir(), "immediate.db")
appDB, err := storage.Open(dsn)
if err != nil {
@@ -681,6 +689,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
}
func TestAutoMigrateRejectsRetiredV2MigrationLedgers(t *testing.T) {
t.Parallel()
for _, retiredID := range []string{"0001_initial_v2", "0001_final_v2"} {
t.Run(retiredID, func(t *testing.T) {
db, err := storage.Open(":memory:")
@@ -718,6 +727,7 @@ func TestAutoMigrateRejectsRetiredV2MigrationLedgers(t *testing.T) {
}
func TestAutoMigrateRejectsAppliedMigrationWithIncompleteSchema(t *testing.T) {
t.Parallel()
db, err := storage.Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:) error = %v", err)
@@ -15,9 +15,11 @@ import (
)
func TestApplyMySQLMigrationRecoversEveryInitialDDLBoundary(t *testing.T) {
t.Parallel()
models := migrationfiles.SchemaModels0001()
for boundary := 0; boundary <= len(models); boundary++ {
t.Run(migrationfiles.TableNames0001()[boundaryOrLast(boundary, len(models))], func(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatalf("create migration ledger: %v", err)
@@ -41,9 +43,11 @@ func TestApplyMySQLMigrationRecoversEveryInitialDDLBoundary(t *testing.T) {
}
func TestApplyMySQLMigrationRecoversEveryAccessKeyCostLimitDDLBoundary(t *testing.T) {
t.Parallel()
models := migrationfiles.SchemaModels0002()
for boundary := 0; boundary <= len(models); boundary++ {
t.Run(migrationfiles.TableNames0002()[boundaryOrLast(boundary, len(models))], func(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatalf("create migration ledger: %v", err)
@@ -75,8 +79,10 @@ func TestApplyMySQLMigrationRecoversEveryAccessKeyCostLimitDDLBoundary(t *testin
}
func TestApplyMySQLMigrationRecoversObservationFreshnessRemoval(t *testing.T) {
t.Parallel()
for _, alreadyDropped := range []bool{false, true} {
t.Run(fmt.Sprintf("already_dropped_%t", alreadyDropped), func(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatalf("create migration ledger: %v", err)
@@ -111,8 +117,10 @@ func TestApplyMySQLMigrationRecoversObservationFreshnessRemoval(t *testing.T) {
}
func TestApplyMySQLMigrationRecoversAccessKeyLifecycleAddition(t *testing.T) {
t.Parallel()
for _, columnAlreadyAdded := range []bool{false, true} {
t.Run(fmt.Sprintf("column_already_added_%t", columnAlreadyAdded), func(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatalf("create migration ledger: %v", err)
@@ -150,6 +158,7 @@ func TestApplyMySQLMigrationRecoversAccessKeyLifecycleAddition(t *testing.T) {
}
func TestApplyMySQLMigrationRejectsUnsafeResumeState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setup func(*testing.T, internalMigrationDB)
@@ -190,6 +199,7 @@ func TestApplyMySQLMigrationRejectsUnsafeResumeState(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatal(err)
@@ -22,6 +22,7 @@ type initialColumn struct {
}
func TestBeta1InitialMigrationCreatesCompleteFinalSchema(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatalf("Up0001() error = %v", err)
@@ -105,6 +106,7 @@ func insertBeta1CredentialStage(db *gorm.DB, id, method string) error {
}
func TestBeta1InitialMigrationCascadesCredentialObservations(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
if err := db.Exec(`INSERT INTO groups (
id, name, channel_id, connection_type, params, models, enabled, created_at_ms, updated_at_ms
@@ -136,6 +138,7 @@ func TestBeta1InitialMigrationCascadesCredentialObservations(t *testing.T) {
}
func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := storage.AutoMigrate(db); err != nil {
t.Fatal(err)
@@ -178,6 +181,7 @@ func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) {
}
func TestAutoMigrateCreatesNormalizedRequestLogInitialSchema(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := storage.AutoMigrate(db); err != nil {
t.Fatal(err)
@@ -235,6 +239,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialSchema(t *testing.T) {
}
func TestAutoMigrateAllowsUnrelatedSchemaInfoTable(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := db.Exec("CREATE TABLE schema_info (version integer PRIMARY KEY)").Error; err != nil {
t.Fatal(err)
@@ -251,6 +256,7 @@ func TestAutoMigrateAllowsUnrelatedSchemaInfoTable(t *testing.T) {
}
func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := storage.AutoMigrate(db); err != nil {
t.Fatal(err)
@@ -321,6 +327,7 @@ func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
}
func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
priceColumns := []string{
"input_price_nano_usd_per_million_tokens",
@@ -341,6 +348,7 @@ func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) {
}
func TestModelPriceValidatesAndNormalizesContextTiersBeforePersistence(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
empty := models.ModelPrice{
@@ -405,6 +413,7 @@ func TestModelPriceValidatesAndNormalizesContextTiersBeforePersistence(t *testin
}
func TestModelPriceValidatesContextTiersAcrossGORMWritePaths(t *testing.T) {
t.Parallel()
type writePath struct {
name string
write func(*gorm.DB, models.ModelPrice, models.JSON) error
@@ -469,6 +478,7 @@ func TestModelPriceValidatesContextTiersAcrossGORMWritePaths(t *testing.T) {
}
func TestFinalRequestLogEnforcesStateContract(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
valid := []requestStateFixture{
{id: "valid-priced-complete", status: "success", usage: "complete", cost: "priced", pricing: "complete", estimatedCost: 1},
@@ -501,6 +511,7 @@ func TestFinalRequestLogEnforcesStateContract(t *testing.T) {
}
func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
for table, names := range map[string][]string{
@@ -633,6 +644,7 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
}
func TestAutoMigrateRejectsChangedTableForCompletedMigration(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
if err := db.Exec(`ALTER TABLE usage_stats RENAME TO usage_stats_checked`).Error; err != nil {
t.Fatal(err)
@@ -651,6 +663,7 @@ func TestAutoMigrateRejectsChangedTableForCompletedMigration(t *testing.T) {
}
func TestAutoMigrateRejectsMissingIndexForCompletedMigration(t *testing.T) {
t.Parallel()
db := openMigratedInitialTestDatabase(t)
if err := db.Exec("DROP INDEX idx_model_prices_channel_model").Error; err != nil {
t.Fatal(err)
@@ -3,6 +3,7 @@ package migrations
import "testing"
func TestMySQLRequiresCheckDropSyntax0003(t *testing.T) {
t.Parallel()
for _, test := range []struct {
serverVersion string
want bool
@@ -8,6 +8,7 @@ import (
)
func TestRemoveObservationFreshUntilMigrationDropsColumnAndPreservesRows(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatalf("Up0001() error = %v", err)
@@ -61,6 +62,7 @@ func TestRemoveObservationFreshUntilMigrationDropsColumnAndPreservesRows(t *test
}
func TestRemoveObservationFreshUntilRecoveryAcceptsDroppedCheckBeforeColumn(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatalf("Up0001() error = %v", err)
@@ -7,6 +7,7 @@ import (
)
func TestUsageStatsGroupActivityIndexMigrationCreatesAndValidatesIndex(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
@@ -38,6 +39,7 @@ func TestUsageStatsGroupActivityIndexMigrationCreatesAndValidatesIndex(t *testin
}
func TestUsageStatsGroupActivityIndexMigrationRejectsMissingIndex(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
@@ -7,6 +7,7 @@ import (
)
func TestProxyConfigMigrationAddsNullableEncryptedColumnsAndPreservesRows(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
for _, migrate := range []func() error{
func() error { return migrations.Up0001(db) },
@@ -69,6 +70,7 @@ func TestProxyConfigMigrationAddsNullableEncryptedColumnsAndPreservesRows(t *tes
}
func TestProxyConfigMigrationRecoverableValidationAcceptsPartialColumns(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
statement string
@@ -8,6 +8,7 @@ import (
)
func TestErrorDecisionMigrationPreservesAttemptsAndAddsDecisionContract(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatalf("Up0001() error = %v", err)
@@ -83,6 +84,7 @@ func TestErrorDecisionMigrationPreservesAttemptsAndAddsDecisionContract(t *testi
}
func TestErrorDecisionMigrationPreservesAttemptIndexesAndForeignKey(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
@@ -8,6 +8,7 @@ import (
)
func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
@@ -44,6 +45,7 @@ func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testin
}
func TestAccessKeyLifecycleMigrationValidationRejectsMissingColumn(t *testing.T) {
t.Parallel()
db := openInitialTestDatabase(t)
if err := migrations.Up0001(db); err != nil {
t.Fatal(err)
@@ -12,6 +12,7 @@ import (
)
func TestModelCooldownMigrationContract(t *testing.T) {
t.Parallel()
testModelCooldownMigration(t, openInternalMigrationTestDatabase)
}
@@ -62,6 +63,9 @@ func testModelCooldownMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
for _, scenario := range []string{"fresh", "upgrade", "interrupted", "column_added"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if db.Dialector.Name() == "sqlite" {
t.Parallel()
}
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:9]); err != nil {
t.Fatal(err)
@@ -13,6 +13,7 @@ import (
)
func TestPriceMultiplierMigrationAddsConfigurationColumns(t *testing.T) {
t.Parallel()
db := openInternalMigrationTestDatabase(t)
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
@@ -25,6 +26,7 @@ func TestPriceMultiplierMigrationAddsConfigurationColumns(t *testing.T) {
}
func TestPriceMultiplierMigrationUpgradeAndRecovery(t *testing.T) {
t.Parallel()
testPriceMultiplierMigrationContract(t, func(t *testing.T) *gorm.DB { return openInternalMigrationTestDatabase(t) })
}
@@ -41,6 +43,9 @@ func testPriceMultiplierMigrationContract(t *testing.T, open func(*testing.T) *g
for _, mode := range []string{"fresh", "upgrade", "interrupted", "columns_added"} {
t.Run(mode, func(t *testing.T) {
db := open(t)
if db.Dialector.Name() == "sqlite" {
t.Parallel()
}
if mode != "fresh" {
if err := applyMigrationRegistry(db, migrations[:8]); err != nil {
t.Fatal(err)
@@ -9,6 +9,7 @@ import (
)
func TestValidationProtocolMigrationContract(t *testing.T) {
t.Parallel()
testValidationProtocolMigration(t, openInternalMigrationTestDatabase)
}
@@ -24,6 +25,9 @@ func testValidationProtocolMigration(t *testing.T, open func(*testing.T) *gorm.D
for _, scenario := range []string{"fresh", "upgrade", "interrupted"} {
t.Run(scenario, func(t *testing.T) {
db := open(t)
if db.Dialector.Name() == "sqlite" {
t.Parallel()
}
if scenario != "fresh" {
if err := applyMigrationRegistry(db, migrations[:12]); err != nil {
t.Fatal(err)
@@ -19,7 +19,7 @@ func TestWindowsInstallerKeepsPortableBinaryAndAddsSetupAsset(t *testing.T) {
workflow := readRepositoryFile(t, ".github/workflows/release.yml")
setupJob := workflowJobBlock(t, workflow, "build-windows-setup")
for _, required := range []string{
"windows-2025",
"runs-on: [self-hosted, Windows, X64]",
"binary-gpt-load-windows-amd64.exe",
"packaging/windows/gpt-load.iss",
"ISCC.exe",
@@ -172,6 +172,43 @@ func TestWindowsInstallerCleansFreshInstallFailureWithoutDeletingProgramData(t *
}
}
func TestWindowsInstallerSmokeUsesAnAssignedPortForConflictAndHealth(t *testing.T) {
script := readRepositoryFile(t, ".github/scripts/release-windows-installer-smoke.ps1")
for _, required := range []string{
"[System.Net.IPAddress]::Loopback,\n 0",
"$listener.ExclusiveAddressUse = $true",
"$port = $listener.LocalEndpoint.Port",
`$envFile = Join-Path $configDir ".env"`,
`"HOST=127.0.0.1"`,
`"PORT=$port"`,
"Set-Content -Path $envFile -Encoding utf8NoBOM",
"http://127.0.0.1:$port/health",
"http://127.0.0.1:$port/api/system/info",
} {
if !strings.Contains(script, required) {
t.Fatalf("installer smoke does not use the assigned port: missing %q", required)
}
}
if strings.Contains(script, "3001") {
t.Fatal("installer smoke still depends on the host's fixed application port")
}
previous := -1
for _, step := range []string{
"$listener.Start()",
"$port = $listener.LocalEndpoint.Port",
"Set-Content -Path $envFile -Encoding utf8NoBOM",
"Invoke-CheckedProcess -Path $setup",
"-ExpectedExitCode 10",
"$listener.Stop()",
} {
index := strings.Index(script, step)
if index <= previous {
t.Fatalf("installer smoke must hold its configured port until the conflict check finishes: %s", step)
}
previous = index
}
}
func TestPullRequestWindowsCIBuildsAndSmokesInstaller(t *testing.T) {
workflow := readRepositoryFile(t, ".github/workflows/ci.yml")
windowsJob := workflowJobBlock(t, workflow, "windows-encryption-acl")
@@ -201,7 +238,7 @@ func TestWindowsInstallerSmokeCoversInstallHealthStopAndUninstall(t *testing.T)
"RELEASE_WINDOWS_BINARY",
"/VERYSILENT",
"Get-Service",
"http://127.0.0.1:3001/health",
"http://127.0.0.1:$port/health",
"auth.key",
"Authorization",
"service stop",
@@ -262,6 +299,8 @@ func TestWindowsSmokesOwnTheirFixedInstallationDirectory(t *testing.T) {
"refusing pre-existing installation directory",
"installOwnerMarker",
"installOwnerToken",
"Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir",
"$smokeMutex.ReleaseMutex()",
} {
if !strings.Contains(script, required) {
t.Fatalf("%s does not contain fixed install ownership contract %q", path, required)
@@ -270,5 +309,8 @@ func TestWindowsSmokesOwnTheirFixedInstallationDirectory(t *testing.T) {
if strings.Contains(script, `"/DIR=`) {
t.Fatalf("%s overrides the fixed installer directory", path)
}
if strings.Index(script, "Enter-WindowsSmoke -InstallDir") > strings.Index(script, "refusing pre-existing Windows service") {
t.Fatalf("%s rejects old smoke installations before recovering them", path)
}
}
}
@@ -0,0 +1,156 @@
package webui
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestWindowsSmokeRecovery(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
t.Skip("PowerShell recovery tests run in the Windows CI job")
}
script := `
$ErrorActionPreference = 'Stop'
. $env:RECOVERY_SCRIPT
$script:record = $null
$script:stops = 0
$script:deletes = 0
function Get-CimInstance { [CmdletBinding()] param($ClassName, $Filter); return $script:record }
function Get-Service {
[CmdletBinding()] param($Name)
$controller = [pscustomobject]@{ Status = 'Running' }
$controller | Add-Member ScriptMethod Stop { $script:stops++ }
$controller | Add-Member ScriptMethod WaitForStatus { param($Status, $Timeout) }
$controller | Add-Member ScriptMethod Dispose { }
return $controller
}
function sc.exe {
param($Action, $Name)
if ($Action -ne 'delete' -or $Name -ne 'gpt-load') { throw 'unexpected service operation' }
$script:deletes++
$global:LASTEXITCODE = 0
}
# Windows 系统目录与注册表只在真实 smoke 中使用;此测试只操作临时目录。
function Test-Path {
[CmdletBinding()] param([string]$LiteralPath)
if (-not $LiteralPath.StartsWith($env:TEST_ROOT)) { return $false }
return Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath
}
foreach ($case in @('unmarked', 'invalid', 'mismatch', 'foreign-service', 'service', 'partial', 'installer', 'linked')) {
$root = Join-Path $env:TEST_ROOT $case
$install = Join-Path $root 'install'
$config = Join-Path $root 'config'
New-Item -ItemType Directory -Path $install, $config | Out-Null
$token = [guid]::NewGuid().ToString('N')
$marker = if ($case -eq 'installer') { '.installer-smoke-owner' } else { '.service-smoke-owner' }
if ($case -ne 'unmarked') {
$value = if ($case -eq 'invalid') { 'not-a-test-owner' } else { $token }
[IO.File]::WriteAllText((Join-Path $config $marker), $value)
if ($case -ne 'installer') {
$value = if ($case -eq 'mismatch') { [guid]::NewGuid().ToString('N') } else { $value }
[IO.File]::WriteAllText((Join-Path $install $marker), $value)
} else {
New-Item -ItemType Directory -Path (Join-Path $config 'data') | Out-Null
[IO.File]::WriteAllText((Join-Path $config 'data/installer-smoke-failure.txt'), $token)
}
}
if ($case -eq 'partial') { Microsoft.PowerShell.Management\Remove-Item -LiteralPath $config -Recurse -Force }
if ($case -eq 'linked') {
Microsoft.PowerShell.Management\Remove-Item -LiteralPath $install -Recurse -Force
New-Item -ItemType SymbolicLink -Path $install -Target $config | Out-Null
}
$script:record = if ($case -in @('service', 'foreign-service')) {
$binary = if ($case -eq 'service') { Join-Path $install 'gpt-load.exe' } else { Join-Path $root 'real-app.exe' }
[pscustomobject]@{ PathName = '"' + $binary + '" service run'; StartName = 'NT AUTHORITY\LocalService' }
} else { $null }
$lock = $null
$failed = $false
try { $lock = Enter-WindowsSmoke -InstallDir $install -ConfigDir $config }
catch { $failed = $true }
finally { if ($null -ne $lock) { $lock.ReleaseMutex(); $lock.Dispose() } }
$wantFailure = $case -in @('invalid', 'mismatch', 'foreign-service', 'linked')
if ($failed -ne $wantFailure) { throw "$case failure = $failed, want $wantFailure" }
$wantPreserved = $wantFailure -or $case -eq 'unmarked'
if ((Test-Path $install) -ne $wantPreserved) { throw "$case installation preservation failed" }
if ((Test-Path $config) -ne $wantPreserved) { throw "$case data preservation failed" }
}
if ($script:stops -ne 1 -or $script:deletes -ne 1) { throw 'stopped or deleted an unowned service' }
# 第二个进程不能清理第一个进程仍在使用的安装目录。
$script:record = $null
$root = Join-Path $env:TEST_ROOT 'busy'
$lock = Enter-WindowsSmoke -InstallDir $root -ConfigDir $root
try {
& ([Environment]::ProcessPath) -NoLogo -NoProfile -Command '
$ErrorActionPreference = "Stop"
. $env:RECOVERY_SCRIPT
try {
$lock = Enter-WindowsSmoke -InstallDir $env:TEST_ROOT -ConfigDir $env:TEST_ROOT
$lock.ReleaseMutex(); $lock.Dispose()
exit 2
} catch {
if ($_.Exception.Message -ne "another Windows smoke is running") { throw }
exit 0
}
'
if ($LASTEXITCODE -ne 0) { throw 'active smoke was not protected' }
} finally { $lock.ReleaseMutex(); $lock.Dispose() }
# 在初始化的每条完整语句后模拟强制中断:固定目录只能以完整状态出现。
$source = [IO.File]::ReadAllText($env:INSTALLER_SCRIPT)
$start = $source.IndexOf(' [System.IO.File]::WriteAllText($installOwnerMarker, $installOwnerToken)')
$end = $source.IndexOf(' $listener =', $start)
if ($start -lt 0 -or $end -le $start) { throw 'missing installer initialization block' }
$lines = $source.Substring($start, $end - $start).Trim().Split([char]10)
foreach ($cut in 1..($lines.Count + 1)) {
$programData = Join-Path $env:TEST_ROOT "init-$cut"
New-Item -ItemType Directory -Path $programData | Out-Null
$suffix = [guid]::NewGuid().ToString('N')
$installOwnerToken = $suffix
$installOwnerMarker = Join-Path $programData 'install.owner'
$configDir = Join-Path $programData 'GPT-Load'
$dataDir = Join-Path $configDir 'data'
$dataOwnerMarker = Join-Path $configDir '.installer-smoke-owner'
$failureDataMarker = Join-Path $dataDir 'installer-smoke-failure.txt'
$preparedConfig = Join-Path $programData ".gpt-load-installer-smoke-$suffix"
$ownsPreparedConfig = $false
$conflict = $cut -gt $lines.Count
if ($conflict) {
New-Item -ItemType Directory -Path $configDir | Out-Null
[IO.File]::WriteAllText((Join-Path $configDir 'real-data'), 'keep')
}
$count = [Math]::Min($cut, $lines.Count)
$failed = $false
try { . ([scriptblock]::Create(($lines[0..($count - 1)] -join [char]10))) }
catch { $failed = $true }
if ($failed -ne $conflict) { throw "unexpected initialization failure at $cut" }
if ($conflict) {
if ([IO.File]::ReadAllText((Join-Path $configDir 'real-data')) -ne 'keep') { throw 'overwrote existing configuration' }
} elseif ($cut -lt $lines.Count) {
if (Test-Path $configDir) { throw "exposed partially initialized directory after statement $cut" }
} else {
if ([IO.File]::ReadAllText($dataOwnerMarker) -ne $suffix -or
[IO.File]::ReadAllText($failureDataMarker) -ne $suffix) { throw 'published incomplete ownership evidence' }
}
}
`
dir := t.TempDir()
path := filepath.Join(dir, "recovery-test.ps1")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
recovery, err := filepath.Abs("../../.github/scripts/windows-smoke-recovery.ps1")
if err != nil {
t.Fatal(err)
}
command := exec.Command(pwsh, "-NoLogo", "-NoProfile", "-File", path)
command.Env = append(os.Environ(), "RECOVERY_SCRIPT="+recovery,
"INSTALLER_SCRIPT="+filepath.Join(filepath.Dir(recovery), "release-windows-installer-smoke.ps1"), "TEST_ROOT="+dir)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("Windows smoke recovery: %v\n%s", err, output)
}
}
@@ -0,0 +1,150 @@
package webui
import (
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestDatabaseShardsCoverEveryExternalTestExactlyOnce(t *testing.T) {
script := readRepositoryFile(t, ".github/scripts/ci-database-contract.sh")
dir := t.TempDir()
path := filepath.Join(dir, "contract.sh")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o700); err != nil {
t.Fatal(err)
}
type selection struct{ run, skip *regexp.Regexp }
filters := make(map[string]selection)
for _, shard := range []string{"all", "mysql-migrations", "mysql-rest"} {
command := exec.Command("bash", path, shard)
command.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"))
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("%s: %v: %s", shard, err, output)
}
args := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, pkg := range []string{"storage", "control", "requestlog"} {
if !strings.Contains(string(output), "./internal/"+pkg+"\n") {
t.Fatalf("%s omits the %s package", shard, pkg)
}
}
var run, skip string
for i := 0; i+1 < len(args); i++ {
if args[i] == "-run" {
run = args[i+1]
}
if args[i] == "-skip" {
skip = args[i+1]
}
}
if run == "" || skip == "" || !strings.Contains(string(output), "-count=1\n") {
t.Fatalf("%s has incomplete test arguments: %s", shard, output)
}
filters[shard] = selection{regexp.MustCompile(run), regexp.MustCompile(skip)}
}
// 从源码发现测试,新增外部数据库测试也必须被分组覆盖。
names := []string{"TestExternalFutureContract"}
for _, pkg := range []string{"storage", "control", "requestlog"} {
files, err := filepath.Glob(filepath.Join("..", pkg, "*_test.go"))
if err != nil {
t.Fatal(err)
}
for _, name := range files {
file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
if err != nil {
t.Fatal(err)
}
for _, decl := range file.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && strings.HasPrefix(fn.Name.Name, "TestExternal") {
names = append(names, fn.Name.Name)
}
}
}
}
for _, name := range names {
count := 0
for _, shard := range []string{"mysql-migrations", "mysql-rest"} {
filter := filters[shard]
if filter.run.MatchString(name) && !filter.skip.MatchString(name) {
count++
}
}
if count != 1 || !filters["all"].run.MatchString(name) || filters["all"].skip.MatchString(name) {
t.Errorf("%s is not covered exactly once for each driver", name)
}
}
}
func TestMySQLShardsRunConcurrentlyAndPropagateFailures(t *testing.T) {
for _, fail := range []string{"", "migrations", "rest"} {
t.Run("failure="+fail, func(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "contract.sh")
if err := os.WriteFile(script, []byte(readRepositoryFile(t, ".github/scripts/ci-database-contract.sh")), 0o600); err != nil {
t.Fatal(err)
}
fakeGo := `#!/bin/bash
case "$*" in
*"-skip ^TestExternal("*) shard=rest ;;
*) shard=migrations ;;
esac
printf '%s' "$GPT_LOAD_DATABASE_TEST_DSN" > "$MARKERS/$shard"
for ((i=0; i<100; i++)); do
if [[ -f "$MARKERS/rest" && -f "$MARKERS/migrations" ]]; then
[[ "$FAIL_SHARD" != "$shard" ]]
exit $?
fi
sleep 0.01
done
exit 91
`
if err := os.WriteFile(filepath.Join(dir, "go"), []byte(fakeGo), 0o700); err != nil {
t.Fatal(err)
}
command := exec.Command("bash", script, "mysql")
command.Env = append(os.Environ(),
"PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"),
"MARKERS="+dir, "FAIL_SHARD="+fail, "RUNNER_TEMP="+dir,
"GPT_LOAD_DATABASE_TEST_DSN=primary", "GPT_LOAD_DATABASE_SECONDARY_DSN=secondary")
output, err := command.CombinedOutput()
if (err != nil) != (fail != "") {
t.Fatalf("mysql result = %v, failing shard = %q: %s", err, fail, output)
}
for shard, want := range map[string]string{"migrations": "primary", "rest": "secondary"} {
got, err := os.ReadFile(filepath.Join(dir, shard))
if err != nil || string(got) != want {
t.Fatalf("%s did not use its isolated database: %s, %v", shard, got, err)
}
}
})
}
}
func TestDatabaseMatrixStartsOnlyRequiredServers(t *testing.T) {
for _, workflow := range []string{"ci.yml", "release.yml"} {
job := workflowJobBlock(t, readRepositoryFile(t, ".github/workflows/"+workflow), "database-contract")
for _, required := range []string{
"max-parallel: 2", "secondary-mysql:", "matrix.driver == 'mysql'",
"image: ${{ matrix.image }}", ".github/scripts/ci-database-contract.sh", "GPT_LOAD_DATABASE_SECONDARY_DSN",
"data-dir: /var/lib/mysql", "data-dir: /var/lib/postgresql",
"--tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g",
"--tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g",
} {
if !strings.Contains(job, required) {
t.Errorf("%s database matrix is missing %s", workflow, required)
}
}
if strings.Contains(job, " mysql:") || strings.Contains(job, " postgres:") {
t.Errorf("%s still unconditionally starts both database types", workflow)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package webui
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDockerSmokeCancellationCleansOnlyOwnedResources(t *testing.T) {
source := readRepositoryFile(t, ".github/scripts/release-docker-smoke.sh")
preflight, _, found := strings.Cut(source, "cat >\"${task_tmp}/fake-response.json\"")
if !found {
t.Fatal("missing Docker smoke work boundary")
}
for _, conflict := range []string{"false", "true"} {
t.Run(conflict, func(t *testing.T) {
dir := t.TempDir()
log := filepath.Join(dir, "cleanup.log")
script := `docker() {
if [[ "$2" == inspect ]]; then [[ "$CONFLICT" == true ]]; return; fi
printf '%s\n' "$*" >> "$CLEANUP_LOG"
}
` + preflight + "\nkill -s TERM $$\n"
path := filepath.Join(dir, "cancel.sh")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command("bash", path)
command.Env = append(os.Environ(), "CONFLICT="+conflict, "CLEANUP_LOG="+log, "RELEASE_SMOKE_SUFFIX=review-1-2")
output, err := command.CombinedOutput()
var exit *exec.ExitError
wantCode := 143
if conflict == "true" {
wantCode = 1
}
if !errors.As(err, &exit) || exit.ExitCode() != wantCode {
t.Fatalf("cancellation exit = %v, want %d: %s", err, wantCode, output)
}
cleanup, err := os.ReadFile(log)
if conflict == "true" {
if !os.IsNotExist(err) {
t.Fatalf("removed pre-existing Docker resources: %s, %v", cleanup, err)
}
return
}
if err != nil {
t.Fatal(err)
}
for _, owned := range []string{"rm -f gpt-load-release-smoke-review-1-2", "volume rm gpt-load-release-smoke-review-1-2", "network rm gpt-load-release-network-review-1-2", "image rm gpt-load-release-smoke:review-1-2"} {
if !strings.Contains(string(cleanup), owned) {
t.Fatalf("cancellation did not clean %s: %s", owned, cleanup)
}
}
})
}
}
func TestReleaseDockerSmokeSeparatesRerunResources(t *testing.T) {
workflow := readRepositoryFile(t, ".github/workflows/release.yml")
for _, job := range []string{"docker-smoke", "prebuilt-image-smoke", "post-publish-image-smoke"} {
block := workflowJobBlock(t, workflow, job)
found := false
for _, line := range strings.Split(block, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "RELEASE_SMOKE_SUFFIX:") {
continue
}
found = true
if !strings.Contains(line, "${{ github.run_id }}") || !strings.Contains(line, "${{ github.run_attempt }}") {
t.Fatalf("%s reuses Docker resource names across attempts: %s", job, line)
}
}
if !found {
t.Fatalf("%s has no task-specific Docker resource suffix", job)
}
}
}
func TestDockerSmokeDiscoversLoopbackPortOnEveryContainerStart(t *testing.T) {
source := readRepositoryFile(t, ".github/scripts/release-docker-smoke.sh")
start := strings.Index(source, "start_container() {\n")
if start < 0 {
t.Fatal("missing container startup function")
}
end := strings.Index(source[start:], "\n}\n")
if end < 0 {
t.Fatal("unterminated container startup function")
}
var initialization string
for _, line := range strings.Split(source[:start], "\n") {
if strings.HasPrefix(line, "app_port=") || strings.HasPrefix(line, "base_url=") {
initialization += line + "\n"
}
}
for _, test := range []struct{ name, requested, binding, wantFirst, wantSecond string }{
{"dynamic", "", "", "http://127.0.0.1:47001", "http://127.0.0.1:47002"},
{"explicit", "40123", "", "http://127.0.0.1:40123", "http://127.0.0.1:40123"},
{"unexpected binding", "", "0.0.0.0:47001", "", ""},
} {
t.Run(test.name, func(t *testing.T) {
script := `set -euo pipefail
container=container
network=network
volume=volume
image=image
starts=0
docker() {
if [[ "$1" == run ]]; then
starts=$((starts + 1))
[[ " $* " == *" --publish 127.0.0.1:${RELEASE_SMOKE_APP_PORT:-0}:3001 "* ]]
elif [[ "$1" == port && "$2" == "$container" && "$3" == 3001/tcp ]]; then
if [[ -n "$TEST_BINDING" ]]; then
printf '%s\n' "$TEST_BINDING"
elif [[ -n "$RELEASE_SMOKE_APP_PORT" ]]; then
printf '127.0.0.1:%s\n' "$RELEASE_SMOKE_APP_PORT"
else
printf '127.0.0.1:%s\n' "$((47000 + starts))"
fi
else
return 1
fi
}
` + initialization + source[start:start+end+3] + `
start_container
[[ "$base_url" == "$TEST_FIRST_URL" ]]
start_container
[[ "$base_url" == "$TEST_SECOND_URL" ]]
`
path := filepath.Join(t.TempDir(), "port-test.sh")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command("bash", path)
command.Env = append(os.Environ(), "RELEASE_SMOKE_APP_PORT="+test.requested,
"TEST_BINDING="+test.binding, "TEST_FIRST_URL="+test.wantFirst, "TEST_SECOND_URL="+test.wantSecond)
output, err := command.CombinedOutput()
if (err != nil) != (test.binding != "") {
t.Fatalf("port discovery failed: %v: %s", err, output)
}
})
}
}
@@ -0,0 +1,77 @@
package webui
import (
"strings"
"testing"
)
func TestSelfHostedCIKeepsPlatformGatesAndLocalCaches(t *testing.T) {
ci := readRepositoryFile(t, ".github/workflows/ci.yml")
for job, runner := range map[string]string{
"test": "[self-hosted, macOS, ARM64]",
"race-tests": "[self-hosted, macOS, ARM64]",
"race-cpa": "[self-hosted, Linux, ARM64]",
"database-contract": "[self-hosted, Linux, ARM64]",
"windows-encryption-acl": "[self-hosted, Windows, X64]",
} {
block := workflowJobBlock(t, ci, job)
if !strings.Contains(block, "runs-on: "+runner) {
t.Errorf("%s is not assigned to %s", job, runner)
}
}
for _, file := range []string{"ci.yml", "release.yml"} {
content := readRepositoryFile(t, ".github/workflows/"+file)
if strings.Contains(content, "cache: true") || strings.Contains(content, ".go-cache-scope") {
t.Errorf("%s still restores remote Go caches over persistent local caches", file)
}
database := workflowJobBlock(t, content, "database-contract")
for _, fixedPort := range []string{"3306:3306", "5432:5432"} {
if strings.Contains(database, fixedPort) {
t.Errorf("%s database gate can collide with local services: %s", file, fixedPort)
}
}
if !strings.Contains(database, "job.services.database.ports[format('{0}', matrix.port)]") {
t.Errorf("%s database DSN does not use the assigned service port", file)
}
}
}
func TestSelfHostedReleaseIsolatesDockerCredentials(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/release.yml")
for _, job := range []string{
"docker-smoke", "prebuilt-image-smoke", "publication-preflight", "publish-images",
"post-publish-image-smoke", "post-publish-verify", "promote-image-channels", "reconcile-publication",
} {
block := workflowJobBlock(t, content, job)
setup := workflowStepBlock(t, block, "Isolate Docker credentials")
if !strings.Contains(setup, `mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX"`) ||
!strings.Contains(setup, `echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"`) {
t.Errorf("%s can overwrite the host Docker credentials", job)
}
}
build := workflowJobBlock(t, content, "build-binaries")
if !strings.Contains(build, "runs-on: ${{ matrix.runner }}") ||
!strings.Contains(workflowStepBlock(t, build, "Build release binary"), "shell: bash") {
t.Fatal("cross-platform binary build must select its runner and use an explicit bash shell")
}
}
func TestReleaseKeepsX64OnlyForNativeAMD64Validation(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/release.yml")
const x64 = "[self-hosted, Linux, X64]"
if strings.Count(content, x64) != 2 {
t.Fatal("Linux X64 must only run the native binary and prebuilt image AMD64 gates")
}
for _, job := range []string{"native-artifact-smoke", "prebuilt-image-smoke"} {
block := workflowJobBlock(t, content, job)
if !strings.Contains(block, x64) || !strings.Contains(block, "[self-hosted, Linux, ARM64]") {
t.Errorf("%s must retain both native Linux architectures", job)
}
}
publish := workflowJobBlock(t, content, "publish-images")
qemu := workflowStepBlock(t, publish, "Set up QEMU")
if !strings.Contains(publish, "runs-on: [self-hosted, Linux, ARM64]") ||
!strings.Contains(qemu, "platforms: amd64") {
t.Fatal("ARM64 image publisher must enable AMD64 emulation for the other target")
}
}
+8 -8
View File
@@ -244,7 +244,7 @@ func TestBranchAndReleaseWorkflowsRunRaceInParallelGates(t *testing.T) {
func TestWindowsCIExecutesManagedStorageACLTests(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/ci.yml")
job := workflowJobBlock(t, content, "windows-encryption-acl")
if count := strings.Count(job, "runs-on: windows-2025"); count != 1 {
if count := strings.Count(job, "runs-on: [self-hosted, Windows, X64]"); count != 1 {
t.Fatalf("Windows ACL job contains runs-on declaration %d times, want exactly once", count)
}
assertWorkflowGateStep(
@@ -291,7 +291,7 @@ func TestWorkflowsPinExternalActionsAndHostedRunners(t *testing.T) {
}
ci := readRepositoryFile(t, ".github/workflows/ci.yml")
for _, required := range []string{"runs-on: ubuntu-24.04", "runs-on: windows-2025"} {
for _, required := range []string{"runs-on: [self-hosted, Linux, ARM64]", "runs-on: [self-hosted, macOS, ARM64]", "runs-on: [self-hosted, Windows, X64]"} {
if !strings.Contains(ci, required) {
t.Errorf("branch CI does not contain %q", required)
}
@@ -1394,7 +1394,7 @@ func TestReleaseWorkflowDeploysCurrentMajorChannelToRender(t *testing.T) {
"RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}",
"IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}",
`RENDER_CLI_VERSION: "2.25.0"`,
`RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"`,
`RENDER_CLI_SHA256: "7c4dc66ded7acc75e2f828b02d3d6901e9f2e4175d25c4fe5fd6f3aaf66b071b"`,
} {
if !strings.Contains(job, required) {
t.Fatalf("Render deployment job does not contain %q:\n%s", required, job)
@@ -1411,7 +1411,7 @@ func TestReleaseWorkflowDeploysCurrentMajorChannelToRender(t *testing.T) {
"RENDER_CLI_VERSION",
"RENDER_CLI_SHA256",
"sha256sum --check",
"cli_${RENDER_CLI_VERSION}_linux_amd64.zip",
"cli_${RENDER_CLI_VERSION}_linux_arm64.zip",
"--retry-all-errors",
} {
if !strings.Contains(install, required) {
@@ -2144,11 +2144,11 @@ func TestReleaseWorkflowPostPublishVerifiesDraftAssetsAgainstCurrentRun(t *testi
// 发布前的五平台原生 smoke 仍然是必须的门禁。
nativeJob := workflowJobBlock(t, content, "native-artifact-smoke")
for _, required := range []string{
"ubuntu-24.04",
"ubuntu-24.04-arm",
"[self-hosted, Linux, X64]",
"[self-hosted, Linux, ARM64]",
"macos-15-intel",
"macos-15",
"windows-2025",
"[self-hosted, macOS, ARM64]",
"[self-hosted, Windows, X64]",
"gpt-load-linux-amd64",
"gpt-load-linux-arm64",
"gpt-load-macos-amd64",
@@ -10,20 +10,27 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
)
type codexWSLogBuffer struct {
mu sync.Mutex
buf bytes.Buffer
mu sync.Mutex
buf bytes.Buffer
updated chan struct{}
}
func (b *codexWSLogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
n, err := b.buf.Write(p)
select {
case b.updated <- struct{}{}:
default:
}
return n, err
}
func (b *codexWSLogBuffer) String() string {
@@ -38,11 +45,16 @@ func TestCodexWSSessionRedactsCloseReason(t *testing.T) {
defer logrus.SetOutput(output)
for _, code := range []int{websocket.ClosePolicyViolation, 4001} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
var logs codexWSLogBuffer
logs := codexWSLogBuffer{updated: make(chan struct{}, 1)}
logrus.SetOutput(&logs)
const marker = "private-close-reason-marker"
headersReady := make(chan struct{})
releaseClose := sync.OnceFunc(func() { close(headersReady) })
defer releaseClose()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, http.Header{
"X-Request-Id": {"close-reason-redaction-test"},
})
if err != nil {
t.Error(err)
return
@@ -51,21 +63,43 @@ func TestCodexWSSessionRedactsCloseReason(t *testing.T) {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
<-headersReady
if err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, marker)); err != nil {
t.Error(err)
}
}))
defer server.Close()
session := wsTestSession(t, server.URL)
headersObserved := false
session.options.ObserveHeaders = func(http.Header, time.Time) {
headersObserved = true
releaseClose()
// SDK 先交付错误再记录断连;若封装先清理连接,SDK 会跳过日志。
// 在握手交接处控制这个合法时序,确保本用例实际经过日志脱敏路径。
timeout := time.NewTimer(time.Second)
defer timeout.Stop()
for !strings.Contains(logs.String(), "upstream disconnected session="+session.id+" ") {
select {
case <-logs.updated:
case <-timeout.C:
t.Errorf("missing SDK close diagnostics before cleanup: %s", logs.String())
return
}
}
}
_, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil)
if err == nil {
t.Fatal("upstream close must fail the turn")
}
if strings.Contains(logs.String(), marker) {
if !headersObserved {
t.Fatal("handshake callback did not exercise the controlled log ordering")
}
output := logs.String()
if strings.Contains(output, marker) {
t.Fatal("upstream close reason leaked into default logs")
}
if !strings.Contains(logs.String(), fmt.Sprintf("ws_close_code=%d", code)) || !strings.Contains(logs.String(), "error_class=websocket_closed") {
t.Fatalf("missing safe close diagnostics: %s", logs.String())
if !strings.Contains(output, fmt.Sprintf("ws_close_code=%d", code)) || !strings.Contains(output, "error_class=websocket_closed") {
t.Fatalf("missing safe close diagnostics: %s", output)
}
})
}
+28 -1
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -505,13 +506,23 @@ func (lifecycle *closeFirstWSBinding) End(reason string) { lifecycle.resource.En
func TestCodexWSSessionGuardsSDKSendRetry(t *testing.T) {
var frames, handshakes atomic.Int32
var handlersMu sync.Mutex
var handlers []chan struct{}
recordAfterSend := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
done := make(chan struct{})
handlersMu.Lock()
handlers = append(handlers, done)
handlersMu.Unlock()
defer close(done)
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
t.Error(err)
return
}
defer conn.Close()
// 握手响应已发出,但服务端统计尚未更新,复现客户端先返回的调度顺序。
<-recordAfterSend
handshakes.Add(1)
if _, _, err := conn.ReadMessage(); err == nil {
frames.Add(1)
@@ -529,11 +540,27 @@ func TestCodexWSSessionGuardsSDKSendRetry(t *testing.T) {
Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: session.id},
ExecutionLifecycle: lifecycle,
})
// 请求结束后立即撤销上下文,收尾统计仍必须有自己的等待时间。
cancel()
close(recordAfterSend)
waitCtx, cancelWait := context.WithTimeout(context.Background(), time.Second)
defer cancelWait()
if err == nil {
t.Fatal("failed send succeeded")
}
// 客户端返回不代表服务端已统计完连接和业务帧;等待真实处理结束再断言。
handlersMu.Lock()
pendingHandlers := append([]chan struct{}(nil), handlers...)
handlersMu.Unlock()
for _, done := range pendingHandlers {
select {
case <-done:
case <-waitCtx.Done():
t.Fatal("server handlers did not finish after SDK send failure")
}
}
if frames.Load() != 0 || handshakes.Load() < 1 || handshakes.Load() > 2 {
t.Fatal("SDK send failure escaped the lifecycle guard")
t.Fatalf("SDK send failure escaped the lifecycle guard: frames=%d handshakes=%d binds=%d", frames.Load(), handshakes.Load(), lifecycle.binds.Load())
}
if lifecycle.binds.Load() < 1 || lifecycle.binds.Load() > 2 {
t.Fatal("unexpected SDK binding attempts")