feat: sync recent call and channel fixes
This commit is contained in:
parent
e5e0080216
commit
866a87583e
65 changed files with 6680 additions and 229 deletions
146
scripts/analyze-media-download-log.ps1
Normal file
146
scripts/analyze-media-download-log.ps1
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Summarizes upload.getFile requests in telesrv logs.
|
||||
|
||||
.DESCRIPTION
|
||||
Use this after opening media history in TDesktop or Android. It groups
|
||||
upload.getFile RPCs by client_type/app_version and reports request count and
|
||||
duration percentiles. Pass -SinceLine from a previous baseline if desired.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerLogPath,
|
||||
[int]$SinceLine = 0,
|
||||
[int]$Tail = 0,
|
||||
[switch]$ShowSamples
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Field {
|
||||
param([string]$Line, [string]$Name, [string]$Default = "")
|
||||
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*"([^"]*)"')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*([^,}]+)')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
return $Default
|
||||
}
|
||||
|
||||
function Convert-DurationMs([string]$Text) {
|
||||
if (-not $Text) { return 0.0 }
|
||||
if ($Text -match '^([0-9.]+)ms$') { return [double]$Matches[1] }
|
||||
if ($Text -match '^([0-9.]+)s$') { return [double]$Matches[1] * 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)µs$') { return [double]$Matches[1] / 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)us$') { return [double]$Matches[1] / 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)ns$') { return [double]$Matches[1] / 1000000.0 }
|
||||
return 0.0
|
||||
}
|
||||
|
||||
function Percentile {
|
||||
param([double[]]$Values, [double]$P)
|
||||
if ($Values.Count -eq 0) { return 0.0 }
|
||||
$sorted = @($Values | Sort-Object)
|
||||
$idx = [int][Math]::Ceiling($P * $sorted.Count) - 1
|
||||
if ($idx -lt 0) { $idx = 0 }
|
||||
if ($idx -ge $sorted.Count) { $idx = $sorted.Count - 1 }
|
||||
return [double]$sorted[$idx]
|
||||
}
|
||||
|
||||
$allLines = @(Read-SharedLogLines)
|
||||
$lineCount = $allLines.Count
|
||||
$lines = $allLines
|
||||
if ($SinceLine -gt 0) {
|
||||
$lines = @($lines | Select-Object -Skip $SinceLine)
|
||||
}
|
||||
if ($Tail -gt 0) {
|
||||
$lines = @($lines | Select-Object -Last $Tail)
|
||||
}
|
||||
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($line in $lines) {
|
||||
if ($line -notlike "*upload.getFile*") {
|
||||
continue
|
||||
}
|
||||
$method = Get-Field $line "method"
|
||||
if ($method -notlike "upload.getFile*") {
|
||||
continue
|
||||
}
|
||||
$client = Get-Field $line "client_type" "unknown"
|
||||
$app = Get-Field $line "app_version" ""
|
||||
$dur = Convert-DurationMs (Get-Field $line "dur" "0ms")
|
||||
$items.Add([pscustomobject]@{
|
||||
Client = $client
|
||||
AppVersion = $app
|
||||
DurationMs = $dur
|
||||
Line = $line
|
||||
}) | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "log=$ServerLogPath"
|
||||
Write-Host "total_lines=$lineCount analyzed_lines=$($lines.Count) since_line=$SinceLine upload_get_file=$($items.Count)"
|
||||
Write-Host ""
|
||||
|
||||
if ($items.Count -eq 0) {
|
||||
Write-Host "No upload.getFile entries found."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$groups = $items | Group-Object Client, AppVersion
|
||||
foreach ($group in $groups) {
|
||||
$values = @($group.Group | ForEach-Object { [double]$_.DurationMs })
|
||||
$sum = 0.0
|
||||
foreach ($v in $values) { $sum += $v }
|
||||
$avg = $sum / [Math]::Max(1, $values.Count)
|
||||
[pscustomobject]@{
|
||||
Client = ($group.Group[0].Client)
|
||||
AppVersion = ($group.Group[0].AppVersion)
|
||||
Count = $values.Count
|
||||
AvgMs = [Math]::Round($avg, 3)
|
||||
P50Ms = [Math]::Round((Percentile $values 0.50), 3)
|
||||
P95Ms = [Math]::Round((Percentile $values 0.95), 3)
|
||||
P99Ms = [Math]::Round((Percentile $values 0.99), 3)
|
||||
MaxMs = [Math]::Round((($values | Measure-Object -Maximum).Maximum), 3)
|
||||
}
|
||||
}
|
||||
|
||||
if ($ShowSamples) {
|
||||
Write-Host ""
|
||||
Write-Host "Samples:"
|
||||
$items | Select-Object -Last 20 | ForEach-Object { Write-Host $_.Line }
|
||||
}
|
||||
257
scripts/check-local-runtime.ps1
Normal file
257
scripts/check-local-runtime.ps1
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Checks the local telesrv runtime state.
|
||||
|
||||
.DESCRIPTION
|
||||
Reports the listening PID/process, git commit, schema version, MTProto port,
|
||||
Android connection/package status, and recent server log errors. The script is
|
||||
read-only and is intended to run before/after Android and TDesktop validation.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 2398,
|
||||
[string]$ServerLogPath,
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
[int]$RecentLogLines = 1200,
|
||||
[switch]$SkipAdb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Add-Failure([string]$Message) {
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||
}
|
||||
|
||||
function Invoke-PsqlScalar([string]$Sql) {
|
||||
$result = Invoke-External "docker" @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-c", $Sql
|
||||
) -AllowFailure
|
||||
if ($result.ExitCode -ne 0) {
|
||||
Add-Failure "PostgreSQL query failed: $($result.Output)"
|
||||
return ""
|
||||
}
|
||||
return $result.Output.Trim()
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AdbArgs([string[]]$Arguments) {
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Get-JsonFieldFromLog {
|
||||
param([string[]]$Lines, [string]$Field)
|
||||
for ($i = $Lines.Count - 1; $i -ge 0; $i--) {
|
||||
if ($Lines[$i] -match ('"' + [regex]::Escape($Field) + '"\s*:\s*"?([^",}]+)"?')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Write-Step "Git"
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$head = (Invoke-External "git" @("rev-parse", "HEAD") -AllowFailure).Output.Trim()
|
||||
$branch = (Invoke-External "git" @("branch", "--show-current") -AllowFailure).Output.Trim()
|
||||
$dirty = (Invoke-External "git" @("status", "--porcelain", "--untracked-files=no") -AllowFailure).Output.Trim()
|
||||
Write-Host "branch=$branch"
|
||||
Write-Host "head=$head"
|
||||
if ($dirty) {
|
||||
Write-Host "tree_state=dirty"
|
||||
} else {
|
||||
Write-Host "tree_state=clean"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Step "Process and Port"
|
||||
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($listeners.Count -eq 0) {
|
||||
Add-Failure "no process is listening on port $Port"
|
||||
} else {
|
||||
foreach ($ownerPid in @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)) {
|
||||
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$path = $null
|
||||
try { $path = $proc.Path } catch { $path = "" }
|
||||
Write-Host ("pid={0} name={1} start={2} path={3}" -f $proc.Id, $proc.ProcessName, $proc.StartTime, $path)
|
||||
} else {
|
||||
Write-Host "pid=$ownerPid"
|
||||
}
|
||||
}
|
||||
Write-Ok "port $Port is listening"
|
||||
}
|
||||
$established = @(Get-NetTCPConnection -LocalPort $Port -State Established -ErrorAction SilentlyContinue)
|
||||
Write-Host "established_connections=$($established.Count)"
|
||||
foreach ($conn in $established | Select-Object -First 12) {
|
||||
Write-Host (" {0}:{1} -> {2}:{3} pid={4}" -f $conn.LocalAddress, $conn.LocalPort, $conn.RemoteAddress, $conn.RemotePort, $conn.OwningProcess)
|
||||
}
|
||||
|
||||
Write-Step "PostgreSQL Schema"
|
||||
$schema = Invoke-PsqlScalar "SELECT version::text || '|' || dirty::text FROM schema_migrations ORDER BY version DESC LIMIT 1;"
|
||||
if ($schema) {
|
||||
$parts = $schema -split "\|", 2
|
||||
Write-Host "schema_version=$($parts[0])"
|
||||
Write-Host "schema_dirty=$($parts[1])"
|
||||
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
|
||||
Write-Ok "schema is clean"
|
||||
} else {
|
||||
Add-Failure "schema_migrations is dirty"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Server Log"
|
||||
Write-Host "log=$ServerLogPath"
|
||||
$lines = @(Read-SharedLogLines)
|
||||
if ($lines.Count -eq 0) {
|
||||
Add-Failure "server log is missing or empty"
|
||||
} else {
|
||||
$readyLines = @($lines | Where-Object { $_ -like "*telesrv 服务就绪*" })
|
||||
if ($readyLines.Count -gt 0) {
|
||||
$ready = @($readyLines)[-1]
|
||||
Write-Host $ready
|
||||
$runtimeCommit = Get-JsonFieldFromLog @($ready) "git_commit"
|
||||
$runtimeSchema = Get-JsonFieldFromLog @($ready) "schema_version"
|
||||
$runtimePID = Get-JsonFieldFromLog @($ready) "pid"
|
||||
Write-Host "runtime_git_commit=$runtimeCommit"
|
||||
Write-Host "runtime_schema_version=$runtimeSchema"
|
||||
Write-Host "runtime_pid=$runtimePID"
|
||||
if ($head -and $runtimeCommit -and $runtimeCommit -ne $head) {
|
||||
Add-Failure "runtime git_commit $runtimeCommit != HEAD $head"
|
||||
} elseif ($runtimeCommit) {
|
||||
Write-Ok "runtime commit matches HEAD"
|
||||
}
|
||||
} else {
|
||||
Add-Failure "server log has no 'telesrv 服务就绪' line"
|
||||
}
|
||||
$recent = @($lines | Select-Object -Last $RecentLogLines)
|
||||
$bad = @($recent | Where-Object {
|
||||
$_ -cmatch "INTERNAL_SERVER_ERROR|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||
})
|
||||
if ($bad.Count -eq 0) {
|
||||
Write-Ok "recent log has no internal/unhandled/bad_msg errors"
|
||||
} else {
|
||||
Add-Failure "recent log has $($bad.Count) suspicious error lines"
|
||||
$bad | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Android"
|
||||
if ($SkipAdb) {
|
||||
Write-Host "adb checks skipped"
|
||||
} else {
|
||||
$adb = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if (-not $adb) {
|
||||
Add-Failure "adb is not available"
|
||||
} else {
|
||||
$devices = Invoke-Adb @("devices") -AllowFailure
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Write-Host "adb_devices=$($deviceLines.Count)"
|
||||
if ($deviceLines.Count -lt 1) {
|
||||
Add-Failure "no adb device connected"
|
||||
} elseif ($deviceLines.Count -gt 1 -and -not $DeviceSerial) {
|
||||
Add-Failure "multiple adb devices; pass -DeviceSerial"
|
||||
} else {
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model") -AllowFailure).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk") -AllowFailure).Output.Trim()
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage) -AllowFailure
|
||||
Write-Host "device_model=$model sdk=$sdk"
|
||||
if ($pkg.Output -match "versionName=([^\r\n]+)") {
|
||||
Write-Ok "Android package $AndroidPackage installed version=$($Matches[1])"
|
||||
} else {
|
||||
Add-Failure "Android package $AndroidPackage not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host "Runtime check failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runtime check passed."
|
||||
347
scripts/restart-local-server.ps1
Normal file
347
scripts/restart-local-server.ps1
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Builds and restarts the local telesrv process with explicit runtime logs.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper is meant for Windows development loops. It builds the current
|
||||
workspace into a staging executable, stops the repo-local process currently
|
||||
listening on the configured MTProto port, promotes the new executable, starts it
|
||||
hidden, and verifies that the port is listening again.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Listen = "0.0.0.0:2398",
|
||||
[string]$AdvertiseIP,
|
||||
[string]$ExePath,
|
||||
[string]$LogDir,
|
||||
[int]$HealthTimeoutSeconds = 20,
|
||||
[int]$Tail = 80,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$NoStart
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ExePath) {
|
||||
$ExePath = Join-Path $RepoRoot "bin\telesrv.exe"
|
||||
}
|
||||
if (-not $LogDir) {
|
||||
$LogDir = Join-Path $RepoRoot "logs"
|
||||
}
|
||||
|
||||
$ExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||
$LogDir = [System.IO.Path]::GetFullPath($LogDir)
|
||||
$BinDir = Split-Path -Parent $ExePath
|
||||
$NextExePath = Join-Path $BinDir "telesrv.next.exe"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GitOutput {
|
||||
param([string[]]$Arguments, [string]$Default = "unknown")
|
||||
$res = Invoke-External "git" $Arguments -AllowFailure
|
||||
if ($res.ExitCode -ne 0) {
|
||||
return $Default
|
||||
}
|
||||
$text = $res.Output.Trim()
|
||||
if ($text.Length -eq 0) {
|
||||
return $Default
|
||||
}
|
||||
return $text
|
||||
}
|
||||
|
||||
function Get-ListenPort {
|
||||
param([string]$Address)
|
||||
if ($Address -match '^\[.+\]:(\d+)$') {
|
||||
return [int]$Matches[1]
|
||||
}
|
||||
if ($Address -match ':(\d+)$') {
|
||||
return [int]$Matches[1]
|
||||
}
|
||||
throw "Cannot parse listen port from '$Address'"
|
||||
}
|
||||
|
||||
function Test-PathUnderRepo {
|
||||
param([string]$Path)
|
||||
if (-not $Path) {
|
||||
return $false
|
||||
}
|
||||
$full = [System.IO.Path]::GetFullPath($Path)
|
||||
return $full.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Test-RepoTelesrvProcess {
|
||||
param(
|
||||
[object]$Process,
|
||||
[string]$ExePath
|
||||
)
|
||||
if (-not $Process) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$path = $null
|
||||
try {
|
||||
$path = $Process.Path
|
||||
} catch {
|
||||
$path = $null
|
||||
}
|
||||
|
||||
if ($path) {
|
||||
$fullPath = [System.IO.Path]::GetFullPath($path)
|
||||
$fullExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||
$binDir = [System.IO.Path]::GetFullPath((Split-Path -Parent $fullExePath))
|
||||
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||
|
||||
if ($fullPath.Equals($fullExePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
if ($fullPath.StartsWith($binDir, [System.StringComparison]::OrdinalIgnoreCase) -and ($fileName -like "telesrv*.exe*")) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Path can be unavailable for protected or already-exiting processes. Only
|
||||
# take ownership of telesrv-looking processes in that ambiguous state.
|
||||
return (($Process.ProcessName -eq "telesrv") -or ($Process.ProcessName -like "telesrv*"))
|
||||
}
|
||||
|
||||
function Get-RepoTelesrvProcesses {
|
||||
param([int]$Port, [string]$ExePath)
|
||||
# 按 PID 去重,合并两条发现路径:
|
||||
# 1) 端口监听者——主路径,但 Get-NetTCPConnection 偶发返回空(曾漏判成 "no listener",
|
||||
# 导致旧进程没被停、promote 复制撞文件锁)。
|
||||
# 2) 按进程名/路径 + 仓库 bin 下 telesrv* 可执行文件——兜底覆盖端口漏报,并能抓到
|
||||
# “持有 telesrv.exe / telesrv.exe~ 文件锁但端口尚未就绪”的实例(promote 复制前必须停掉)。
|
||||
$foundByPid = @{}
|
||||
|
||||
$listenerPids = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||
foreach ($ownerPid in $listenerPids) {
|
||||
if (-not $ownerPid) {
|
||||
continue
|
||||
}
|
||||
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
continue
|
||||
}
|
||||
$path = $null
|
||||
try {
|
||||
$path = $proc.Path
|
||||
} catch {
|
||||
$path = $null
|
||||
}
|
||||
$procId = [int]$proc.Id
|
||||
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||
if ($isRepoProcess) {
|
||||
$foundByPid[$procId] = $proc
|
||||
} else {
|
||||
throw "Port $Port is held by non-repo process PID $($proc.Id) ($($proc.ProcessName)) at '$path'"
|
||||
}
|
||||
}
|
||||
|
||||
$candidateProcesses = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq "telesrv" -or $_.ProcessName -like "telesrv*" })
|
||||
foreach ($proc in $candidateProcesses) {
|
||||
$procId = [int]$proc.Id
|
||||
if ($foundByPid.ContainsKey($procId)) {
|
||||
continue
|
||||
}
|
||||
# 只接管仓库内的实例:路径在 repo/bin 下,或路径不可读但进程名看起来就是 telesrv(兜底)。
|
||||
# 仓库外的同名进程(用户在别处跑的)一律不动。
|
||||
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||
if ($isRepoProcess) {
|
||||
$foundByPid[$procId] = $proc
|
||||
}
|
||||
}
|
||||
|
||||
return @($foundByPid.Values)
|
||||
}
|
||||
|
||||
function Wait-ProcessesExited {
|
||||
param([int[]]$Pids)
|
||||
if (-not $Pids -or $Pids.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$alive = @($Pids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
||||
if ($alive.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "Timed out waiting for old telesrv process(es) to exit: $($Pids -join ', ')"
|
||||
}
|
||||
|
||||
function Wait-PortFree {
|
||||
param([int]$Port, [int[]]$Pids)
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$stillListening = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $Pids -contains $_.OwningProcess })
|
||||
if ($stillListening.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "Timed out waiting for old telesrv listener on port $Port to stop"
|
||||
}
|
||||
|
||||
$ListenPort = Get-ListenPort $Listen
|
||||
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
if (-not $SkipBuild) {
|
||||
Write-Step "Build telesrv"
|
||||
$commit = Get-GitOutput @("rev-parse", "HEAD")
|
||||
$branch = Get-GitOutput @("branch", "--show-current")
|
||||
$dirty = Get-GitOutput @("status", "--porcelain", "--untracked-files=no") -Default ""
|
||||
$treeState = "clean"
|
||||
if ($dirty.Length -gt 0) {
|
||||
$treeState = "dirty"
|
||||
}
|
||||
$buildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$ldflags = "-X main.gitCommit=$commit -X main.gitBranch=$branch -X main.gitTreeState=$treeState -X main.buildTime=$buildTime"
|
||||
|
||||
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||
Invoke-External "go" @("build", "-ldflags", $ldflags, "-o", $NextExePath, ".\cmd\telesrv") | Out-Null
|
||||
Write-Host "[ok] built $NextExePath"
|
||||
Write-Host "[ok] commit=$commit branch=$branch tree=$treeState build_time=$buildTime"
|
||||
}
|
||||
|
||||
Write-Step "Stop old telesrv processes"
|
||||
$oldProcesses = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||
if ($oldProcesses.Count -eq 0) {
|
||||
Write-Host "[ok] no existing repo-local listener on port $ListenPort"
|
||||
} else {
|
||||
$oldPids = @($oldProcesses | Select-Object -ExpandProperty Id)
|
||||
foreach ($proc in $oldProcesses) {
|
||||
Write-Host "[stop] PID $($proc.Id) $($proc.ProcessName) $($proc.Path)"
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
}
|
||||
Wait-ProcessesExited $oldPids
|
||||
Wait-PortFree $ListenPort $oldPids
|
||||
Write-Host "[ok] stopped old listener(s): $($oldPids -join ', ')"
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Write-Step "Promote executable"
|
||||
# telesrv.exe 可能被外部 watcher/watchdog 抢先重生的实例占用文件锁;停掉持有者后短暂重试,
|
||||
# 避免直接撞 "being used by another process" 复制失败(曾因此 promote 失败)。
|
||||
$promoted = $false
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
try {
|
||||
Copy-Item -LiteralPath $NextExePath -Destination $ExePath -Force -ErrorAction Stop
|
||||
$promoted = $true
|
||||
break
|
||||
} catch {
|
||||
$holders = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||
foreach ($holder in $holders) {
|
||||
Write-Host "[stop] PID $($holder.Id) holding $ExePath; retry $attempt/10"
|
||||
Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
}
|
||||
if (-not $promoted) {
|
||||
throw "Failed to promote $ExePath after retries (file kept locked; an external watcher may be respawning telesrv)"
|
||||
}
|
||||
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||
Write-Host "[ok] promoted $ExePath"
|
||||
} elseif (-not (Test-Path -LiteralPath $ExePath)) {
|
||||
throw "Executable not found: $ExePath"
|
||||
}
|
||||
|
||||
if ($NoStart) {
|
||||
Write-Host "[ok] NoStart requested; executable is ready but not running"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Step "Start telesrv"
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$stdoutPath = Join-Path $LogDir "telesrv-$stamp.out.log"
|
||||
$stderrPath = Join-Path $LogDir "telesrv-$stamp.err.log"
|
||||
|
||||
$env:TELESRV_LISTEN = $Listen
|
||||
if ($AdvertiseIP) {
|
||||
$env:TELESRV_ADVERTISE_IP = $AdvertiseIP
|
||||
}
|
||||
|
||||
$proc = Start-Process -FilePath $ExePath `
|
||||
-WorkingDirectory $RepoRoot `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($HealthTimeoutSeconds)
|
||||
$listening = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$proc.Refresh()
|
||||
if ($proc.HasExited) {
|
||||
$errTail = ""
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
$errTail = (Get-Content -LiteralPath $stderrPath -Tail $Tail -ErrorAction SilentlyContinue) -join "`n"
|
||||
}
|
||||
throw "telesrv exited during startup with code $($proc.ExitCode):`n$errTail"
|
||||
}
|
||||
$conn = @(Get-NetTCPConnection -LocalPort $ListenPort -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.OwningProcess -eq $proc.Id })
|
||||
if ($conn.Count -gt 0) {
|
||||
$listening = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
if (-not $listening) {
|
||||
throw "telesrv PID $($proc.Id) did not listen on port $ListenPort within ${HealthTimeoutSeconds}s"
|
||||
}
|
||||
|
||||
Write-Host "[ok] started PID $($proc.Id), listening on $Listen"
|
||||
Write-Host "[ok] stdout: $stdoutPath"
|
||||
Write-Host "[ok] stderr: $stderrPath"
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
Get-Content -LiteralPath $stderrPath -Tail $Tail
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Pid = $proc.Id
|
||||
Listen = $Listen
|
||||
AdvertiseIP = $env:TELESRV_ADVERTISE_IP
|
||||
Exe = $ExePath
|
||||
Stdout = $stdoutPath
|
||||
Stderr = $stderrPath
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
515
scripts/validate-android-big-video-upload.ps1
Normal file
515
scripts/validate-android-big-video-upload.ps1
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Validates Android big video upload and interruption recovery.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper covers the stable parts of the big-upload loop:
|
||||
fixture generation/push, baseline snapshots, optional server restart when
|
||||
upload.saveBigFilePart appears, post-send media assertions, and upload temp
|
||||
cleanup checks. It intentionally does not automate Android media picker taps.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "WatchRestart", "AfterSend", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$SenderUserId = 1780269504,
|
||||
[long]$RecipientUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
[string]$VideoFixture,
|
||||
[string]$BlobDir,
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
[int64]$MinBigFileBytes = 12MB,
|
||||
[int]$RestartAfterParts = 2,
|
||||
[int]$WatchTimeoutSeconds = 90,
|
||||
[string]$RestartScript,
|
||||
|
||||
[switch]$SkipAdb,
|
||||
[switch]$AllowMissingThumb,
|
||||
[switch]$BuildOnRestart
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-big-video-upload-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
if (-not $BlobDir) {
|
||||
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||
}
|
||||
if (-not $RestartScript) {
|
||||
$RestartScript = Join-Path $RepoRoot "scripts\restart-local-server.ps1"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn([string]$Message) {
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure([string]$Message) {
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check([bool]$Condition, [string]$Message) {
|
||||
if ($Condition) { Write-Ok $Message } else { Add-Failure $Message }
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows([string]$Sql) {
|
||||
$result = Invoke-External "docker" @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Read-SharedLogLines([string]$Path = $ServerLogPath) {
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
return @(Read-SharedLogLines).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince([int]$Skip, [string]$Path = $ServerLogPath) {
|
||||
return @(Read-SharedLogLines $Path | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Get-AdbArgs([string[]]$Arguments) {
|
||||
if ($DeviceSerial) { return @("-s", $DeviceSerial) + $Arguments }
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Save-State([pscustomobject]$State) {
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
||||
$State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-PrivateMessageMaxId {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COALESCE(MAX(id), 0)
|
||||
FROM private_messages
|
||||
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||
"@)
|
||||
if ($rows.Count -eq 0) { return 0 }
|
||||
return [long]$rows[0]
|
||||
}
|
||||
|
||||
function Get-UploadPartUsage {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM upload_parts
|
||||
WHERE owner_user_id = $SenderUserId;
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
return [pscustomobject]@{ Parts = [int]$parts[0]; Bytes = [long]$parts[1] }
|
||||
}
|
||||
|
||||
function Get-UploadTempStats {
|
||||
$root = Join-Path (Join-Path $BlobDir "upload_parts") ([string]$SenderUserId)
|
||||
if (-not (Test-Path -LiteralPath $root)) {
|
||||
return [pscustomobject]@{ Files = 0; Bytes = 0L }
|
||||
}
|
||||
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
|
||||
$bytes = 0L
|
||||
foreach ($file in $files) { $bytes += [long]$file.Length }
|
||||
return [pscustomobject]@{ Files = $files.Count; Bytes = $bytes }
|
||||
}
|
||||
|
||||
function Get-NewVideoMessages([long]$AfterMessageId) {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(media->>'kind', ''),
|
||||
COALESCE(media->>'video', 'false'),
|
||||
COALESCE(media->'document'->>'id', '0'),
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE(media->'document'->>'size', '0'),
|
||||
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||
FROM private_messages
|
||||
WHERE id > $AfterMessageId
|
||||
AND sender_user_id = $SenderUserId
|
||||
AND recipient_user_id = $RecipientUserId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 7
|
||||
$items += [pscustomobject]@{
|
||||
MessageId = [long]$parts[0]
|
||||
Kind = $parts[1]
|
||||
Video = $parts[2]
|
||||
DocumentId = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
Size = [long]$parts[5]
|
||||
ThumbCount = [int]$parts[6]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-DocumentRows([long[]]$DocumentIds) {
|
||||
if ($DocumentIds.Count -eq 0) { return @() }
|
||||
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||
FROM documents
|
||||
WHERE id IN ($ids)
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 4
|
||||
$items += [pscustomobject]@{
|
||||
DocumentId = [long]$parts[0]
|
||||
MimeType = $parts[1]
|
||||
Size = [long]$parts[2]
|
||||
ThumbCount = [int]$parts[3]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-FileBlobRows([long[]]$DocumentIds) {
|
||||
if ($DocumentIds.Count -eq 0) { return @() }
|
||||
$keys = @()
|
||||
foreach ($docId in $DocumentIds) {
|
||||
$keys += "doc:$docId"
|
||||
$keys += "doc:${docId}:m"
|
||||
}
|
||||
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT location_key, backend, object_key, size, mime_type
|
||||
FROM file_blobs
|
||||
WHERE location_key IN ($quoted)
|
||||
ORDER BY location_key;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
LocationKey = $parts[0]
|
||||
Backend = $parts[1]
|
||||
ObjectKey = $parts[2]
|
||||
Size = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-BlobFilePath([string]$ObjectKey) {
|
||||
if ($ObjectKey.Length -lt 4) {
|
||||
return Join-Path $BlobDir $ObjectKey
|
||||
}
|
||||
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||
}
|
||||
|
||||
function New-BigVideoFixture([string]$Path) {
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Add-Failure "ffmpeg is available or -VideoFixture points at an existing >10MB mp4"
|
||||
return
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc2=size=1280x720:rate=30",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=660:sample_rate=44100",
|
||||
"-t", "16",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-b:v", "8M",
|
||||
"-maxrate", "8M",
|
||||
"-bufsize", "16M",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
$Path
|
||||
) | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-BigVideoFixture {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
if (-not $VideoFixture) {
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-big-video-$stamp.mp4"
|
||||
New-BigVideoFixture $script:VideoFixture
|
||||
}
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||
$size = (Get-Item -LiteralPath $VideoFixture).Length
|
||||
Write-Host "fixture_size=$size"
|
||||
Assert-Check ($size -ge $MinBigFileBytes) "fixture is large enough to trigger upload.saveBigFilePart"
|
||||
}
|
||||
}
|
||||
|
||||
function Push-BigVideoFixture {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
$leaf = Split-Path -Leaf $VideoFixture
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||
Write-Ok "big video pushed to Android: $RemoteMovieDir/$leaf"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable"
|
||||
if (-not $SkipAdb) {
|
||||
Assert-Check ([bool](Get-Command adb -ErrorAction SilentlyContinue)) "adb is available"
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has a connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare big video"
|
||||
Run-Preflight
|
||||
Ensure-BigVideoFixture
|
||||
Push-BigVideoFixture
|
||||
Write-Host "Manual step: send the pushed >10MB video from Android/Alice to Bob."
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$usage = Get-UploadPartUsage
|
||||
$temp = Get-UploadTempStats
|
||||
$state = [pscustomobject]@{
|
||||
SenderUserId = $SenderUserId
|
||||
RecipientUserId = $RecipientUserId
|
||||
BaselinePrivateMessageId = Get-PrivateMessageMaxId
|
||||
BaselineUploadParts = $usage.Parts
|
||||
BaselineUploadPartBytes = $usage.Bytes
|
||||
BaselineTempFiles = $temp.Files
|
||||
BaselineTempBytes = $temp.Bytes
|
||||
BaselineLogLineCount = Get-LogLineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
BlobDir = $BlobDir
|
||||
RestartTriggered = $false
|
||||
RestartedAt = ""
|
||||
ObservedBigPart = $false
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
VideoFixture = $VideoFixture
|
||||
}
|
||||
Write-Host "private_messages max id before send: $($state.BaselinePrivateMessageId)"
|
||||
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
Write-Host "temp upload files before send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-WatchRestart {
|
||||
Write-Step "Watch upload.saveBigFilePart and restart"
|
||||
$state = Load-State
|
||||
$deadline = (Get-Date).AddSeconds($WatchTimeoutSeconds)
|
||||
$restartDone = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||
$hits = @($lines | Where-Object {
|
||||
$_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
if ($hits.Count -ge $RestartAfterParts) {
|
||||
Write-Host "observed upload.saveBigFilePart lines=$($hits.Count); restarting server"
|
||||
$state.ObservedBigPart = $true
|
||||
$state.RestartTriggered = $true
|
||||
$state.RestartedAt = (Get-Date -Format o)
|
||||
Save-State $state
|
||||
$args = @()
|
||||
if (-not $BuildOnRestart) {
|
||||
$args += "-SkipBuild"
|
||||
}
|
||||
Invoke-External "powershell" (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $RestartScript) + $args) | Out-Null
|
||||
$restartDone = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Assert-Check $restartDone "server restarted after upload.saveBigFilePart was observed"
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||
}
|
||||
$videos = @($messages | Where-Object {
|
||||
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0 -and $_.Size -ge $MinBigFileBytes
|
||||
})
|
||||
Assert-Check ($videos.Count -ge 1) "new private message includes a >10MB uploaded video/mp4 document"
|
||||
|
||||
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||
$documents = @(Get-DocumentRows $docIds)
|
||||
$blobs = @(Get-FileBlobRows $docIds)
|
||||
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -ge $MinBigFileBytes }).Count -ge 1) "documents row persisted for big video"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "big video document has thumbnail metadata"
|
||||
}
|
||||
$bodyBlobs = @($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -ge $MinBigFileBytes })
|
||||
Assert-Check ($bodyBlobs.Count -ge 1) "big video body file_blobs row exists"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "big video thumbnail file_blobs row exists"
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||
Assert-Check (Test-Path -LiteralPath (Get-BlobFilePath $blob.ObjectKey)) "localfs blob exists: $($blob.LocationKey)"
|
||||
}
|
||||
}
|
||||
|
||||
$usage = Get-UploadPartUsage
|
||||
$temp = Get-UploadTempStats
|
||||
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
Write-Host "temp upload files after send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts metadata cleaned after successful big upload"
|
||||
Assert-Check ($temp.Files -le [int]$state.BaselineTempFiles) "upload temp files cleaned after successful big upload"
|
||||
|
||||
$oldLines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||
$newLines = @(Read-SharedLogLines)
|
||||
$combined = @($oldLines + $newLines)
|
||||
$bigHits = @($combined | Where-Object { $_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*' })
|
||||
$sendMediaHits = @($combined | Where-Object { $_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*' })
|
||||
$bad = @($combined | Where-Object { $_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t" })
|
||||
Assert-Check (($bigHits.Count -ge 1) -or [bool]$state.ObservedBigPart) "server log has Android upload.saveBigFilePart"
|
||||
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||
Assert-Check ($bad.Count -eq 0) "server logs have no big-upload-era internal errors or unhandled RPCs"
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) { Write-Host " - $failure" }
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" { Run-Preflight }
|
||||
"Prepare" { Run-Prepare }
|
||||
"BeforeSend" { Run-BeforeSend }
|
||||
"WatchRestart" { Run-WatchRestart }
|
||||
"AfterSend" { Run-AfterSend }
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Write-Host "Start sending the pushed video from Android/Alice now."
|
||||
Run-WatchRestart
|
||||
Read-Host "After Android finishes sending the video, press Enter"
|
||||
Run-AfterSend
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Semi-automates the Android -> TDesktop offline channel media recovery check.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper covers the stable parts of the local validation loop:
|
||||
preflight checks, fixture generation/push, PostgreSQL state snapshots, server
|
||||
log checks, and pass/fail assertions. It intentionally does not drive the
|
||||
Android media picker or Telegram Desktop UI; those remain manual steps because
|
||||
their coordinates and cached state are device/client dependent.
|
||||
|
||||
Typical flow:
|
||||
1. Run -Phase Prepare to generate and push fixtures to Android.
|
||||
2. Close DebugBob/TDesktop, then run -Phase BeforeSend.
|
||||
3. Send photo, video, and document from Android/Alice.
|
||||
4. Run -Phase AfterSend.
|
||||
5. Start DebugBob/TDesktop, open the channel, confirm media is visible.
|
||||
6. Run -Phase AfterBobOpen.
|
||||
|
||||
Use -Phase All for the same flow with interactive pauses.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "AfterBobOpen", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$ChannelId = 24,
|
||||
[long]$AndroidUserId = 1780269504,
|
||||
[long]$BobUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
|
||||
[string]$PhotoFixture,
|
||||
[string]$VideoFixture,
|
||||
[string]$DocumentFixture,
|
||||
|
||||
[string]$RemotePictureDir = "/sdcard/Pictures/telesrv",
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
[string]$RemoteDocumentDir = "/sdcard/Download/telesrv",
|
||||
|
||||
[int]$ExpectedNewMessages = 3,
|
||||
|
||||
[switch]$AllowMissingVideo,
|
||||
[switch]$AllowMissingGenericDocument,
|
||||
[switch]$SkipAdb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\app-version-observe-20260609-165537.err.log"
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-offline-media-recovery-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok {
|
||||
param([string]$Message)
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Message)
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure {
|
||||
param([string]$Message)
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check {
|
||||
param(
|
||||
[bool]$Condition,
|
||||
[string]$Message
|
||||
)
|
||||
if ($Condition) {
|
||||
Write-Ok $Message
|
||||
} else {
|
||||
Add-Failure $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
Assert-Check ([bool]$cmd) "$Name is available"
|
||||
return [bool]$cmd
|
||||
}
|
||||
|
||||
function Get-AdbArgs {
|
||||
param([string[]]$Arguments)
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb {
|
||||
param([string[]]$Arguments)
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments)
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows {
|
||||
param([string]$Sql)
|
||||
$args = @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
$result = Invoke-External "docker" $args
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return 0
|
||||
}
|
||||
return @((Get-Content -LiteralPath $ServerLogPath)).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince {
|
||||
param([int]$Skip)
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
Add-Failure "server log exists at $ServerLogPath"
|
||||
return @()
|
||||
}
|
||||
return @(Get-Content -LiteralPath $ServerLogPath | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Get-DialogState {
|
||||
param([long]$UserId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT top_message_id, read_inbox_max_id, unread_count
|
||||
FROM channel_dialogs
|
||||
WHERE channel_id = $ChannelId AND user_id = $UserId;
|
||||
"@)
|
||||
if ($rows.Count -ne 1) {
|
||||
Add-Failure "channel_dialogs has one row for channel_id=$ChannelId user_id=$UserId"
|
||||
return $null
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
[pscustomobject]@{
|
||||
UserId = $UserId
|
||||
TopMessageId = [int]$parts[0]
|
||||
ReadInboxMaxId = [int]$parts[1]
|
||||
UnreadCount = [int]$parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ChannelPts {
|
||||
$rows = @(Invoke-PsqlRows "SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $ChannelId;")
|
||||
if ($rows.Count -eq 0) {
|
||||
return 0
|
||||
}
|
||||
return [int]$rows[0]
|
||||
}
|
||||
|
||||
function Get-NewMessages {
|
||||
param([int]$AfterMessageId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
sender_user_id,
|
||||
media->>'kind',
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE((
|
||||
SELECT attr->>'file_name'
|
||||
FROM jsonb_array_elements(COALESCE(media->'document'->'attributes', '[]'::jsonb)) attr
|
||||
WHERE attr->>'kind' = 'filename'
|
||||
LIMIT 1
|
||||
), '')
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $ChannelId AND id > $AfterMessageId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
Id = [int]$parts[0]
|
||||
SenderUserId = [long]$parts[1]
|
||||
Kind = $parts[2]
|
||||
MimeType = $parts[3]
|
||||
FileName = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-NewEventSummary {
|
||||
param([int]$AfterPts)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(MAX(pts), 0)
|
||||
FROM channel_update_events
|
||||
WHERE channel_id = $ChannelId
|
||||
AND pts > $AfterPts
|
||||
AND event_type = 'new_channel_message';
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Count = 0; MaxPts = 0 }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
[pscustomobject]@{
|
||||
Count = [int]$parts[0]
|
||||
MaxPts = [int]$parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
param([pscustomobject]$State)
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
}
|
||||
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function New-PhotoFixture {
|
||||
param([string]$Path)
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bitmap = New-Object System.Drawing.Bitmap 900, 520
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.Clear([System.Drawing.Color]::FromArgb(34, 137, 116))
|
||||
$fontLarge = New-Object System.Drawing.Font("Arial", 36, [System.Drawing.FontStyle]::Bold)
|
||||
$fontSmall = New-Object System.Drawing.Font("Arial", 22, [System.Drawing.FontStyle]::Regular)
|
||||
$brushWhite = [System.Drawing.Brushes]::White
|
||||
$brushYellow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(245, 184, 60))
|
||||
$graphics.DrawString("telesrv offline photo", $fontLarge, $brushWhite, 55, 85)
|
||||
$graphics.DrawString((Get-Date -Format "yyyyMMdd-HHmmss"), $fontSmall, $brushWhite, 58, 150)
|
||||
$graphics.FillRectangle($brushYellow, 58, 300, 780, 42)
|
||||
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Jpeg)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
function New-DocumentFixture {
|
||||
param([string]$Path)
|
||||
$content = @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<telesrv-offline-media-recovery generated_at="$(Get-Date -Format o)">
|
||||
<purpose>Android to TDesktop offline media recovery validation</purpose>
|
||||
</telesrv-offline-media-recovery>
|
||||
"@
|
||||
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function New-VideoFixture {
|
||||
param([string]$Path)
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Write-Warn "ffmpeg not found; video fixture was not generated"
|
||||
return $false
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=size=640x360:rate=30",
|
||||
"-t", "1",
|
||||
"-pix_fmt", "yuv420p",
|
||||
$Path
|
||||
) | Out-Null
|
||||
return $true
|
||||
}
|
||||
|
||||
function Ensure-Fixtures {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
if (-not $PhotoFixture) {
|
||||
$script:PhotoFixture = Join-Path $FixtureDir "telesrv-offline-photo-$stamp.jpg"
|
||||
New-PhotoFixture $script:PhotoFixture
|
||||
}
|
||||
if (-not $DocumentFixture) {
|
||||
$script:DocumentFixture = Join-Path $FixtureDir "telesrv-offline-doc-$stamp.xml"
|
||||
New-DocumentFixture $script:DocumentFixture
|
||||
}
|
||||
if (-not $VideoFixture) {
|
||||
$candidate = Join-Path $FixtureDir "telesrv-offline-video-$stamp.mp4"
|
||||
if (New-VideoFixture $candidate) {
|
||||
$script:VideoFixture = $candidate
|
||||
}
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $PhotoFixture) "photo fixture exists: $PhotoFixture"
|
||||
Assert-Check (Test-Path -LiteralPath $DocumentFixture) "document fixture exists: $DocumentFixture"
|
||||
if (-not $AllowMissingVideo) {
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
} elseif ($VideoFixture) {
|
||||
Assert-Check (Test-Path -LiteralPath $VideoFixture) "video fixture exists: $VideoFixture"
|
||||
}
|
||||
}
|
||||
|
||||
function Push-Fixtures {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemotePictureDir, $RemoteMovieDir, $RemoteDocumentDir) | Out-Null
|
||||
Invoke-Adb @("push", $PhotoFixture, "$RemotePictureDir/") | Out-Null
|
||||
Invoke-Adb @("push", $DocumentFixture, "$RemoteDocumentDir/") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemotePictureDir/$(Split-Path -Leaf $PhotoFixture)") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteDocumentDir/$(Split-Path -Leaf $DocumentFixture)") | Out-Null
|
||||
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$(Split-Path -Leaf $VideoFixture)") | Out-Null
|
||||
}
|
||||
Write-Ok "fixtures pushed to Android media folders"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
if (-not $SkipAdb) {
|
||||
if (Assert-Command "adb") {
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||
Write-Host "Android device: model=$model sdk=$sdk"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
if (Assert-Command "docker") {
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare fixtures"
|
||||
Run-Preflight
|
||||
Ensure-Fixtures
|
||||
Push-Fixtures
|
||||
Write-Host "Manual step: close DebugBob/TDesktop, then send these files from Android/Alice:"
|
||||
Write-Host " Photo: $PhotoFixture"
|
||||
if ($VideoFixture) {
|
||||
Write-Host " Video: $VideoFixture"
|
||||
}
|
||||
Write-Host " Document: $DocumentFixture"
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$bob = Get-DialogState $BobUserId
|
||||
$alice = Get-DialogState $AndroidUserId
|
||||
$pts = Get-ChannelPts
|
||||
$lineCount = Get-LogLineCount
|
||||
Assert-Check ($null -ne $bob) "Bob dialog state can be read"
|
||||
Assert-Check ($null -ne $alice) "Android/Alice dialog state can be read"
|
||||
if ($bob) {
|
||||
Write-Host "Bob before send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
}
|
||||
if ($alice) {
|
||||
Write-Host "Alice before send: top=$($alice.TopMessageId) read=$($alice.ReadInboxMaxId) unread=$($alice.UnreadCount)"
|
||||
}
|
||||
Write-Host "Channel pts before send: $pts"
|
||||
$state = [pscustomobject]@{
|
||||
ChannelId = $ChannelId
|
||||
AndroidUserId = $AndroidUserId
|
||||
BobUserId = $BobUserId
|
||||
BaselineTopMessageId = if ($bob) { $bob.TopMessageId } else { 0 }
|
||||
BaselineBobReadInboxMaxId = if ($bob) { $bob.ReadInboxMaxId } else { 0 }
|
||||
BaselineBobUnreadCount = if ($bob) { $bob.UnreadCount } else { 0 }
|
||||
BaselineChannelPts = $pts
|
||||
BaselineLogLineCount = $lineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
PhotoFixture = $PhotoFixture
|
||||
VideoFixture = $VideoFixture
|
||||
DocumentFixture = $DocumentFixture
|
||||
}
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$bob = Get-DialogState $BobUserId
|
||||
$messages = @(Get-NewMessages ([int]$state.BaselineTopMessageId))
|
||||
$events = Get-NewEventSummary ([int]$state.BaselineChannelPts)
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new message id={0} sender={1} kind={2} mime={3} file={4}" -f $message.Id, $message.SenderUserId, $message.Kind, $message.MimeType, $message.FileName)
|
||||
}
|
||||
if ($bob) {
|
||||
Write-Host "Bob after send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
}
|
||||
Write-Host "new channel events after baseline: count=$($events.Count) max_pts=$($events.MaxPts)"
|
||||
Assert-Check ($messages.Count -ge $ExpectedNewMessages) "at least $ExpectedNewMessages new channel messages were written"
|
||||
Assert-Check (@($messages | Where-Object { $_.SenderUserId -eq $AndroidUserId }).Count -ge $ExpectedNewMessages) "new channel messages are from Android/Alice"
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "photo" }).Count -ge 1) "new messages include uploaded photo"
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" }).Count -ge 1) "new messages include uploaded document"
|
||||
if (-not $AllowMissingVideo) {
|
||||
Assert-Check (@($messages | Where-Object { $_.MimeType -eq "video/mp4" }).Count -ge 1) "new messages include video/mp4 document"
|
||||
}
|
||||
if (-not $AllowMissingGenericDocument) {
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" -and $_.MimeType -ne "video/mp4" }).Count -ge 1) "new messages include a generic non-video document"
|
||||
}
|
||||
if ($bob) {
|
||||
Assert-Check ($bob.TopMessageId -gt [int]$state.BaselineTopMessageId) "Bob top_message_id advanced while offline"
|
||||
Assert-Check ($bob.ReadInboxMaxId -eq [int]$state.BaselineBobReadInboxMaxId) "Bob read_inbox_max_id did not advance before opening TDesktop"
|
||||
Assert-Check ($bob.UnreadCount -ge $ExpectedNewMessages) "Bob unread_count reflects offline messages"
|
||||
}
|
||||
Assert-Check ($events.Count -ge $ExpectedNewMessages) "durable channel_update_events exist for new messages"
|
||||
}
|
||||
|
||||
function Run-AfterBobOpen {
|
||||
Write-Step "AfterBobOpen assertions"
|
||||
$state = Load-State
|
||||
$bob = Get-DialogState $BobUserId
|
||||
if ($bob) {
|
||||
Write-Host "Bob after open: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
Assert-Check ($bob.ReadInboxMaxId -ge $bob.TopMessageId) "Bob read_inbox_max_id catches up to current top"
|
||||
Assert-Check ($bob.UnreadCount -eq 0) "Bob unread_count is cleared after opening the channel"
|
||||
}
|
||||
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount))
|
||||
$bad = @($lines | Where-Object { $_ -match "Unhandled RPC|NOT_IMPLEMENTED|bad_msg" })
|
||||
Assert-Check ($bad.Count -eq 0) "server log has no new Unhandled RPC / NOT_IMPLEMENTED / bad_msg entries"
|
||||
$expectedTDesktop = @(
|
||||
"messages.getHistory",
|
||||
"messages.getPeerDialogs",
|
||||
"upload.getFile",
|
||||
"channels.readHistory",
|
||||
"updates.getChannelDifference"
|
||||
)
|
||||
foreach ($method in $expectedTDesktop) {
|
||||
$hits = @($lines | Where-Object { $_ -like "*$method*" -and $_ -like '*client_type": "tdesktop"*' })
|
||||
Assert-Check ($hits.Count -ge 1) "TDesktop issued $method after Bob opened the channel"
|
||||
}
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation phase '$Phase' passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" {
|
||||
Run-Preflight
|
||||
}
|
||||
"Prepare" {
|
||||
Run-Prepare
|
||||
}
|
||||
"BeforeSend" {
|
||||
Run-BeforeSend
|
||||
}
|
||||
"AfterSend" {
|
||||
Run-AfterSend
|
||||
}
|
||||
"AfterBobOpen" {
|
||||
Run-AfterBobOpen
|
||||
}
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Read-Host "Close DebugBob/TDesktop if needed, send photo/video/document from Android, then press Enter"
|
||||
Run-AfterSend
|
||||
Read-Host "Start DebugBob/TDesktop, open the channel, confirm media renders, then press Enter"
|
||||
Run-AfterBobOpen
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
586
scripts/validate-android-video-upload.ps1
Normal file
586
scripts/validate-android-video-upload.ps1
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Semi-automates Android video upload validation against local telesrv.
|
||||
|
||||
.DESCRIPTION
|
||||
The script handles the stable parts of the Android upload regression loop:
|
||||
preflight checks, optional video fixture generation/push, baseline snapshots,
|
||||
server log scanning, PostgreSQL media/message assertions, upload_parts cleanup,
|
||||
and local blob existence checks.
|
||||
|
||||
It intentionally does not drive the Android media picker. Send the prepared
|
||||
video manually from Android/Alice, then run -Phase AfterSend.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$SenderUserId = 1780269504,
|
||||
[long]$RecipientUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
[string]$VideoFixture,
|
||||
[string]$BlobDir,
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
|
||||
[switch]$SkipAdb,
|
||||
[switch]$AllowMissingThumb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-video-upload-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
if (-not $BlobDir) {
|
||||
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok {
|
||||
param([string]$Message)
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Message)
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure {
|
||||
param([string]$Message)
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check {
|
||||
param(
|
||||
[bool]$Condition,
|
||||
[string]$Message
|
||||
)
|
||||
if ($Condition) {
|
||||
Write-Ok $Message
|
||||
} else {
|
||||
Add-Failure $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
Assert-Check ([bool]$cmd) "$Name is available"
|
||||
return [bool]$cmd
|
||||
}
|
||||
|
||||
function Get-AdbArgs {
|
||||
param([string[]]$Arguments)
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb {
|
||||
param([string[]]$Arguments, [switch]$AllowFailure)
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows {
|
||||
param([string]$Sql)
|
||||
$args = @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
$result = Invoke-External "docker" $args
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return 0
|
||||
}
|
||||
return @(Read-SharedLogLines).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince {
|
||||
param([int]$Skip)
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
Add-Failure "server log exists at $ServerLogPath"
|
||||
return @()
|
||||
}
|
||||
return @(Read-SharedLogLines | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PrivateMessageMaxId {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COALESCE(MAX(id), 0)
|
||||
FROM private_messages
|
||||
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return 0
|
||||
}
|
||||
return [long]$rows[0]
|
||||
}
|
||||
|
||||
function Get-UploadPartUsage {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM upload_parts
|
||||
WHERE owner_user_id = $SenderUserId;
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
return [pscustomobject]@{
|
||||
Parts = [int]$parts[0]
|
||||
Bytes = [long]$parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NewVideoMessages {
|
||||
param([long]$AfterMessageId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
sender_user_id,
|
||||
recipient_user_id,
|
||||
COALESCE(media->>'kind', ''),
|
||||
COALESCE(media->>'video', 'false'),
|
||||
COALESCE(media->'document'->>'id', '0'),
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE(media->'document'->>'size', '0'),
|
||||
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||
FROM private_messages
|
||||
WHERE id > $AfterMessageId
|
||||
AND sender_user_id = $SenderUserId
|
||||
AND recipient_user_id = $RecipientUserId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 9
|
||||
$items += [pscustomobject]@{
|
||||
MessageId = [long]$parts[0]
|
||||
SenderUserId = [long]$parts[1]
|
||||
RecipientUserId = [long]$parts[2]
|
||||
Kind = $parts[3]
|
||||
Video = $parts[4]
|
||||
DocumentId = [long]$parts[5]
|
||||
MimeType = $parts[6]
|
||||
Size = [long]$parts[7]
|
||||
ThumbCount = [int]$parts[8]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-DocumentRows {
|
||||
param([long[]]$DocumentIds)
|
||||
if ($DocumentIds.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||
FROM documents
|
||||
WHERE id IN ($ids)
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 4
|
||||
$items += [pscustomobject]@{
|
||||
DocumentId = [long]$parts[0]
|
||||
MimeType = $parts[1]
|
||||
Size = [long]$parts[2]
|
||||
ThumbCount = [int]$parts[3]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-FileBlobRows {
|
||||
param([long[]]$DocumentIds)
|
||||
if ($DocumentIds.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
$keys = @()
|
||||
foreach ($docId in $DocumentIds) {
|
||||
$keys += "doc:$docId"
|
||||
$keys += "doc:${docId}:m"
|
||||
}
|
||||
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT location_key, backend, object_key, size, mime_type
|
||||
FROM file_blobs
|
||||
WHERE location_key IN ($quoted)
|
||||
ORDER BY location_key;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
LocationKey = $parts[0]
|
||||
Backend = $parts[1]
|
||||
ObjectKey = $parts[2]
|
||||
Size = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Wait-FileBlobRows {
|
||||
param(
|
||||
[long[]]$DocumentIds,
|
||||
[int]$TimeoutSeconds = 10
|
||||
)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$last = @()
|
||||
while ($true) {
|
||||
$last = @(Get-FileBlobRows $DocumentIds)
|
||||
$hasBody = @($last | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -gt 0 }).Count -ge 1
|
||||
$hasThumb = $AllowMissingThumb -or (@($last | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1)
|
||||
if ($hasBody -and $hasThumb) {
|
||||
return $last
|
||||
}
|
||||
if ((Get-Date) -ge $deadline) {
|
||||
return $last
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EffectiveLogSkip {
|
||||
param([pscustomobject]$State)
|
||||
$stateLog = ""
|
||||
if ($State.PSObject.Properties.Name -contains "ServerLogPath") {
|
||||
$stateLog = [string]$State.ServerLogPath
|
||||
}
|
||||
if ($stateLog) {
|
||||
$stateFull = [System.IO.Path]::GetFullPath($stateLog)
|
||||
$currentFull = [System.IO.Path]::GetFullPath($ServerLogPath)
|
||||
if ($stateFull.Equals($currentFull, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return [int]$State.BaselineLogLineCount
|
||||
}
|
||||
Write-Warn "server log changed since baseline; scanning current log from the beginning"
|
||||
return 0
|
||||
}
|
||||
return [int]$State.BaselineLogLineCount
|
||||
}
|
||||
|
||||
function Get-BlobFilePath {
|
||||
param([string]$ObjectKey)
|
||||
if ($ObjectKey.Length -lt 4) {
|
||||
return Join-Path $BlobDir $ObjectKey
|
||||
}
|
||||
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
param([pscustomobject]$State)
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
}
|
||||
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function New-VideoFixture {
|
||||
param([string]$Path)
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Add-Failure "ffmpeg is available or -VideoFixture is supplied"
|
||||
return
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=size=568x1280:rate=30",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=880:sample_rate=44100",
|
||||
"-t", "3",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
$Path
|
||||
) | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-VideoFixture {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
if (-not $VideoFixture) {
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-video-upload-$stamp.mp4"
|
||||
New-VideoFixture $script:VideoFixture
|
||||
}
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
}
|
||||
|
||||
function Push-VideoFixture {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
$leaf = Split-Path -Leaf $VideoFixture
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||
Write-Ok "video pushed to Android: $RemoteMovieDir/$leaf"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
if (-not $SkipAdb) {
|
||||
if (Assert-Command "adb") {
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||
Write-Host "Android device: model=$model sdk=$sdk"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
if (Assert-Command "docker") {
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
Write-Host "server log: $ServerLogPath"
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare video fixture"
|
||||
Run-Preflight
|
||||
Ensure-VideoFixture
|
||||
Push-VideoFixture
|
||||
Write-Host "Manual step: send this video from Android/Alice to Bob:"
|
||||
Write-Host " $VideoFixture"
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$maxMessageId = Get-PrivateMessageMaxId
|
||||
$usage = Get-UploadPartUsage
|
||||
$lineCount = Get-LogLineCount
|
||||
Write-Host "private_messages max id before send: $maxMessageId"
|
||||
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
$state = [pscustomobject]@{
|
||||
SenderUserId = $SenderUserId
|
||||
RecipientUserId = $RecipientUserId
|
||||
BaselinePrivateMessageId = $maxMessageId
|
||||
BaselineUploadParts = $usage.Parts
|
||||
BaselineUploadPartBytes = $usage.Bytes
|
||||
BaselineLogLineCount = $lineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
BlobDir = $BlobDir
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
VideoFixture = $VideoFixture
|
||||
}
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||
}
|
||||
$videos = @($messages | Where-Object {
|
||||
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0
|
||||
})
|
||||
Assert-Check ($videos.Count -ge 1) "new private message includes uploaded video/mp4 document"
|
||||
|
||||
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||
$documents = @(Get-DocumentRows $docIds)
|
||||
$blobs = @(Wait-FileBlobRows $docIds)
|
||||
foreach ($doc in $documents) {
|
||||
Write-Host ("document id={0} mime={1} size={2} thumbs={3}" -f $doc.DocumentId, $doc.MimeType, $doc.Size, $doc.ThumbCount)
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
Write-Host ("blob key={0} backend={1} object={2} size={3} mime={4}" -f $blob.LocationKey, $blob.Backend, $blob.ObjectKey, $blob.Size, $blob.MimeType)
|
||||
}
|
||||
|
||||
Assert-Check ($documents.Count -ge $docIds.Count) "documents rows exist for uploaded video"
|
||||
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "uploaded video document metadata is persisted"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "uploaded video document has thumbnail metadata"
|
||||
}
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "video body file_blobs row exists"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "video thumbnail file_blobs row exists"
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||
$path = Get-BlobFilePath $blob.ObjectKey
|
||||
Assert-Check (Test-Path -LiteralPath $path) "localfs blob exists: $($blob.LocationKey)"
|
||||
}
|
||||
}
|
||||
|
||||
$usage = Get-UploadPartUsage
|
||||
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
if ([int]$state.BaselineUploadParts -eq 0) {
|
||||
Assert-Check ($usage.Parts -eq 0) "upload_parts cleaned after successful upload"
|
||||
} else {
|
||||
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts did not grow after successful upload"
|
||||
}
|
||||
|
||||
$lines = @(Get-LogLinesSince (Get-EffectiveLogSkip $state))
|
||||
$savePartHits = @($lines | Where-Object {
|
||||
($_ -like "*upload.saveFilePart*" -or $_ -like "*upload.saveBigFilePart*") -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
$sendMediaHits = @($lines | Where-Object {
|
||||
$_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
$bad = @($lines | Where-Object {
|
||||
$_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||
})
|
||||
Assert-Check ($savePartHits.Count -ge 1) "server log has Android upload.saveFilePart/saveBigFilePart"
|
||||
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||
Assert-Check ($bad.Count -eq 0) "server log has no upload-era internal errors or unhandled RPCs"
|
||||
|
||||
if (-not $SkipAdb) {
|
||||
$logcat = Invoke-Adb @("logcat", "-d", "-t", "1200") -AllowFailure
|
||||
if ($logcat.ExitCode -eq 0) {
|
||||
$androidErrors = @($logcat.Output -split "`r?`n" | Where-Object {
|
||||
$_ -match "INTERNAL_SERVER_ERROR|rpc error 500|saveFilePart|saveBigFilePart|FileUploadOperation"
|
||||
})
|
||||
if ($androidErrors.Count -gt 0) {
|
||||
Write-Host "Recent Android upload log lines:"
|
||||
$androidErrors | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
$fatalAndroidErrors = @($androidErrors | Where-Object { $_ -match "INTERNAL_SERVER_ERROR|rpc error 500" })
|
||||
Assert-Check ($fatalAndroidErrors.Count -eq 0) "recent Android logcat has no upload 500"
|
||||
} else {
|
||||
Write-Warn "adb logcat scan failed: $($logcat.Output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" { Run-Preflight }
|
||||
"Prepare" { Run-Prepare }
|
||||
"BeforeSend" { Run-BeforeSend }
|
||||
"AfterSend" { Run-AfterSend }
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Read-Host "Send the prepared video from Android/Alice to Bob, then press Enter"
|
||||
Run-AfterSend
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
Loading…
Add table
Add a link
Reference in a new issue