fix(dev): 启动器输出改文件重定向, 修复长时间运行内存叠加

dev.ps1 用 Start-Job + 每 150ms Receive-Job 中转子进程输出。Start-Job 会把
每行原生输出包装成带 job/runspace 元数据的 PSObject 存进 job 输出集合,
生产快于消费 (窗口最小化/RDP 断开/日志量突增) 时持续堆积且不及时释放 ——
实测开盘跑到中午, 启动器单个 powershell.exe 可占十几 GB。

- dev.ps1: 改用 Start-Process + 重定向到 data/logs/*.log, PowerShell 只持
  PID; 控制台改为有界 StreamReader tail, 内存占用与日志总量无关
- dev.ps1: 新增单实例守卫 (默认拒绝启动, -Force 才结束) 与 -NoReload;
  退出时 taskkill /T 结束整棵树, 覆盖 reloader 父进程与 server 子进程
- dev.sh: free_port 只杀监听者会让 reloader 父进程重生子进程 —— 改为先按
  仓库 venv 路径结束同端口整组后端; cleanup 改递归 kill_tree, 不再留孤儿
- dev.sh: 只处理同端口实例, 换端口调试的实例只提示不误杀

验证: dev.sh bash -n 通过; kill_repo_backends / kill_tree 用真实进程做了
隔离测试 (8888/9999 端口不误杀、三级进程树无孤儿); dev.ps1 花括号平衡与
无残留 Start-Job 调用, 本机无 PowerShell 未做实际运行验证。
This commit is contained in:
shy3130
2026-10-20 16:15:20 +08:00
parent fc4a4e7a33
commit 529dc8bf20
2 changed files with 238 additions and 75 deletions
+182 -74
View File
@@ -3,9 +3,12 @@
# Usage:
# .\dev.ps1
# .\dev.ps1 -BackendPort 8000 -FrontendPort 5173
# .\dev.ps1 -NoReload # 内测/长时间运行: 不启用 uvicorn --reload
# .\dev.ps1 -Force # 已有实例时先结束它再启动
# $env:BACKEND_PORT='8000'; .\dev.ps1
#
# Ctrl-C closes both processes.
# 进程输出直接重定向到 data\logs\*.log, 启动器只做有界 tail, 内存占用恒定。
# 停止请用 Ctrl-C (会结束整棵进程树); 直接点窗口 X 不会走到清理逻辑。
#
# If you see "running scripts is disabled":
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
@@ -13,7 +16,12 @@
[CmdletBinding()]
param(
[int]$BackendPort = 0,
[int]$FrontendPort = 0
[int]$FrontendPort = 0,
# 检测到已有后端实例时强制结束它再启动。默认拒绝启动, 避免多实例叠加。
[switch]$Force,
# 不启用 uvicorn --reload。内测/长时间运行时建议开启: 改文件不再触发
# 进程重启, 也就不会反复做 1GB+ 的 enriched 全量预热。
[switch]$NoReload
)
$ErrorActionPreference = 'Stop'
@@ -56,6 +64,38 @@ if ($BackendPort -le 0) {
}
if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } }
# 单实例守卫: 重复运行 dev.ps1 会各自拉起一套后端, 每个实例常驻 1GB+;
# 靠端口检测杀不干净 (--reload 的父进程持有 socket 并会重生子进程), 因此改为
# 启动前显式识别已有实例: 默认拒绝启动, -Force 时才整树结束。
# 只匹配本仓库的 venv 解释器, 避免误伤其他项目里同名模块 app.main:app 的进程。
function Stop-ExistingInstance {
$venvPython = Join-Path $BackendDir '.venv\Scripts\python.exe'
$existing = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.ProcessId -ne $PID -and
$_.CommandLine -and
$_.CommandLine -like '*uvicorn*app.main:app*' -and
($_.CommandLine -like "*$venvPython*" -or
($_.CommandLine -like '*.venv\Scripts\python*' -and
$_.ExecutablePath -like "$BackendDir*"))
})
if ($existing.Count -eq 0) { return $true }
$ids = @($existing | ForEach-Object { $_.ProcessId })
if (-not $Force) {
Log-Warn "检测到已有后端实例 (PID: $($ids -join ', ')), 本次启动取消"
Log-Warn '如需重启, 重新运行并加 -Force, 或先执行:'
Log-Warn " Stop-Process -Id $($ids -join ',') -Force"
return $false
}
Log-Warn "结束已有后端实例 (PID: $($ids -join ', '))"
foreach ($p in $ids) {
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
}
Start-Sleep -Milliseconds 500
return $true
}
# Force UTF-8 console output so child process logs aren't garbled
try {
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
@@ -135,6 +175,8 @@ function Free-Port($name, $port) {
}
}
if (-not (Stop-ExistingInstance)) { exit 1 }
Free-Port 'backend' $BackendPort
Free-Port 'frontend' $FrontendPort
@@ -185,51 +227,46 @@ Write-Host '| Ctrl-C closes both |' -ForegroundColor B
Write-Host '+----------------------------------------------+' -ForegroundColor Blue
Write-Host ''
# ===== 5. Launch jobs =====
# Each job writes its $PID to a temp file so the main thread can find the
# child powershell.exe and taskkill /T the whole process tree on exit.
$backendPidFile = [System.IO.Path]::GetTempFileName()
$frontendPidFile = [System.IO.Path]::GetTempFileName()
# ===== 5. Launch processes =====
# 输出由操作系统直接重定向到日志文件, PowerShell 只持有 PID, 不参与数据搬运。
#
# 旧实现用 Start-Job + 每 150ms Receive-Job 轮询: Start-Job 会把每一行原生输出
# 包装成带 job/runspace 元数据的 PSObject 存进 job 输出集合。当生产快于消费
# (控制台滚动缓冲、窗口最小化、RDP 断开、日志音量突增) 时对象持续堆积且不及时
# 释放 —— 实测开盘跑到中午, 启动器单个 powershell.exe 可占十几 GB。改为文件
# 重定向后, 启动器内存与日志总量无关; 控制台只做「读新增字节」的有界 tail。
$LogDir = Join-Path $Root 'data\logs'
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$BackendOutLog = Join-Path $LogDir 'backend.out.log'
$BackendErrLog = Join-Path $LogDir 'backend.err.log'
$FrontendOutLog = Join-Path $LogDir 'frontend.out.log'
$FrontendErrLog = Join-Path $LogDir 'frontend.err.log'
$backendJob = Start-Job -Name 'backend' -ScriptBlock {
param($pidFile, $dir, $envFile, $bindAddress, $port)
# Start-Job 开的是全新 powershell.exe 子进程, 不继承主进程的 UTF-8 设置,
# 默认用系统 ANSI (中文 Windows = GBK/cp936) 解码后端 UTF-8 输出 → 中文乱码。
# 这里强制子进程用 UTF-8, 与 app/__init__.py 的 stdout/stderr 编码对齐。
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
$env:PYTHONUNBUFFERED = '1'
Set-Location $dir
$envArgs = if (Test-Path $envFile) { @('--env-file', $envFile) } else { @() }
& .\.venv\Scripts\python.exe -m uvicorn app.main:app @envArgs --reload --host $bindAddress --port $port 2>&1
} -ArgumentList $backendPidFile, $BackendDir, $EnvFile, $BindAddress, $BackendPort
# 每次启动重置本会话日志 (后端自身的 data/backend.log 已有轮转, 这里只放转发副本)
foreach ($f in @($BackendOutLog, $BackendErrLog, $FrontendOutLog, $FrontendErrLog)) {
Remove-Item $f -Force -ErrorAction SilentlyContinue
}
$frontendJob = Start-Job -Name 'frontend' -ScriptBlock {
param($pidFile, $dir, $bindAddress, $backendPort, $port)
# 同上: job 子进程默认 GBK, pnpm/前端工具链也是 UTF-8 输出, 需对齐。
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
Set-Location $dir
$env:BACKEND_HOST = $bindAddress
$env:BACKEND_PORT = [string]$backendPort
& pnpm dev --host $bindAddress --port $port 2>&1
} -ArgumentList $frontendPidFile, $FrontendDir, $BindAddress, $BackendPort, $FrontendPort
$env:PYTHONUNBUFFERED = '1'
# Start-Process 的 -ArgumentList 按空格拼接, 含空格的路径必须自带引号
function Quote-Arg($value) {
if ($value -match '[\s"]') { return '"' + ($value -replace '"', '\"') + '"' }
return $value
}
# Wait up to 5 seconds for the PID files to materialise
function Read-JobPid($file) {
for ($i = 0; $i -lt 50; $i++) {
try {
$c = (Get-Content $file -ErrorAction SilentlyContinue) -as [string]
if ($c -and $c.Trim()) { return [int]$c.Trim() }
} catch {}
Start-Sleep -Milliseconds 100
}
return $null
$BackendArgs = @('-m', 'uvicorn', 'app.main:app')
if (Test-Path $EnvFile) { $BackendArgs += @('--env-file', (Quote-Arg $EnvFile)) }
if (-not $NoReload) { $BackendArgs += '--reload' }
$BackendArgs += @('--host', $BindAddress, '--port', [string]$BackendPort)
$env:BACKEND_HOST = $BindAddress
$env:BACKEND_PORT = [string]$BackendPort
$pnpmExe = (Get-Command 'pnpm.cmd' -ErrorAction SilentlyContinue).Source
if (-not $pnpmExe) { $pnpmExe = (Get-Command 'pnpm' -ErrorAction SilentlyContinue).Source }
if (-not $pnpmExe) {
Log-Err 'pnpm not found'
exit 1
}
$backendChildPid = Read-JobPid $backendPidFile
$frontendChildPid = Read-JobPid $frontendPidFile
# ===== 6. Cleanup =====
$script:cleaning = $false
@@ -239,28 +276,110 @@ function Cleanup-All {
Write-Host ''
Log-Info 'shutting down...'
foreach ($p in @($backendChildPid, $frontendChildPid)) {
if ($p) {
# /T kills the whole process tree (the job's powershell + uvicorn/vite)
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
# /T 结束整棵树 (uvicorn --reload 的父进程 + server 子进程 / pnpm + vite + esbuild)
foreach ($proc in @($backendProc, $frontendProc)) {
if ($proc -and -not $proc.HasExited) {
$null = & cmd /c "taskkill /F /T /PID $($proc.Id) 2>nul"
}
}
foreach ($j in @($backendJob, $frontendJob)) {
if ($j) {
Stop-Job $j -ErrorAction SilentlyContinue
Remove-Job $j -Force -ErrorAction SilentlyContinue
if ($script:tails) {
foreach ($state in @($script:tails.Values)) {
if ($state -and $state.Reader) {
try { $state.Reader.Dispose() } catch {}
}
}
}
foreach ($f in @($backendPidFile, $frontendPidFile)) {
Remove-Item $f -Force -ErrorAction SilentlyContinue
}
Log-Ok 'bye'
Log-Ok "bye (logs: $LogDir)"
}
# ===== 7. Main loop - pump output, handle Ctrl-C =====
# Treat Ctrl-C as input so try/finally is guaranteed to run.
# ===== 7. Bounded log tail =====
# 每个日志文件一个 StreamReader, 每轮只消费「上次之后新增的内容」。
# 内存占用 = StreamReader 解码缓冲 + 一行待拼接文本, 与日志总量无关。
# StreamReader 负责 UTF-8 解码, 多字节字符跨读边界也不会被截断成乱码。
$script:tails = @{}
foreach ($p in @($BackendOutLog, $BackendErrLog, $FrontendOutLog, $FrontendErrLog)) {
$script:tails[$p] = @{ Reader = $null; LastLength = 0L; Pending = '' }
}
$MaxPending = 64KB
function Open-LogTail($path) {
$fs = [System.IO.File]::Open($path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
[System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)
return New-Object System.IO.StreamReader(
$fs, (New-Object System.Text.UTF8Encoding($false)))
}
function Read-NewLogLines($path, $prefix, $color) {
$state = $script:tails[$path]
if (-not $state) { return }
try {
if ($null -eq $state.Reader) {
if (-not (Test-Path $path)) { return }
$state.Reader = Open-LogTail $path
} elseif ($state.Reader.BaseStream.Length -lt $state.LastLength) {
# 文件被重置/替换 → 从头重新跟随
$state.Reader.Dispose()
$state.Reader = Open-LogTail $path
$state.Pending = ''
}
$state.LastLength = $state.Reader.BaseStream.Length
# ReadToEnd: 从当前位置读到末尾, 只取新增部分
$chunk = $state.Reader.ReadToEnd()
if ([string]::IsNullOrEmpty($chunk)) { return }
$text = $state.Pending + $chunk
$complete = $text.EndsWith("`n")
$lines = $text -split "`n"
if ($complete) {
$state.Pending = ''
if ($lines.Count -gt 0) { $lines = $lines[0..($lines.Count - 2)] }
} else {
# 末尾没有换行 = 这行可能还没写完, 留到下一轮
$state.Pending = $lines[-1]
$lines = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() }
if ($state.Pending.Length -gt $MaxPending) {
Write-Host $prefix -NoNewline -ForegroundColor $color
Write-Host ($state.Pending.Substring(0, $MaxPending) + ' ...[truncated]')
$state.Pending = ''
}
}
foreach ($line in $lines) {
Write-Host $prefix -NoNewline -ForegroundColor $color
Write-Host $line.TrimEnd("`r")
}
} catch {
# 日志读取失败绝不能拖垮启动器
}
}
# ===== 8. Launch + main loop =====
# 启动与主循环同处一个 try/finally: 任一 Start-Process 失败或 Ctrl-C 都会走
# Cleanup-All, 不留下半套进程。
$prevCtrlC = [Console]::TreatControlCAsInput
try {
$backendProc = Start-Process `
-FilePath (Join-Path $BackendDir '.venv\Scripts\python.exe') `
-ArgumentList $BackendArgs `
-WorkingDirectory $BackendDir `
-RedirectStandardOutput $BackendOutLog `
-RedirectStandardError $BackendErrLog `
-NoNewWindow -PassThru
# .cmd 不是可执行文件: 关闭 shell 执行 (重定向输出时必须) 后要走 cmd.exe /c。
# 整条命令作为一个参数交给 cmd 解析, 避免 Start-Process 自行拼接时加错引号。
$cmdExe = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' }
$frontendCmd = '"{0}" dev --host {1} --port {2}' -f $pnpmExe, $BindAddress, $FrontendPort
$frontendProc = Start-Process `
-FilePath $cmdExe `
-ArgumentList @('/c', $frontendCmd) `
-WorkingDirectory $FrontendDir `
-RedirectStandardOutput $FrontendOutLog `
-RedirectStandardError $FrontendErrLog `
-NoNewWindow -PassThru
# Treat Ctrl-C as input so try/finally is guaranteed to run.
[Console]::TreatControlCAsInput = $true
while ($true) {
@@ -271,28 +390,17 @@ try {
}
}
$bOut = Receive-Job $backendJob -ErrorAction SilentlyContinue
if ($bOut) {
foreach ($line in $bOut) {
Write-Host '[backend ] ' -NoNewline -ForegroundColor Blue
Write-Host $line
}
}
Read-NewLogLines $BackendOutLog '[backend ] ' Blue
Read-NewLogLines $BackendErrLog '[backend ] ' Blue
Read-NewLogLines $FrontendOutLog '[frontend] ' Green
Read-NewLogLines $FrontendErrLog '[frontend] ' Green
$fOut = Receive-Job $frontendJob -ErrorAction SilentlyContinue
if ($fOut) {
foreach ($line in $fOut) {
Write-Host '[frontend] ' -NoNewline -ForegroundColor Green
Write-Host $line
}
}
if ($backendJob.State -ne 'Running' -or $frontendJob.State -ne 'Running') {
if ($backendProc.HasExited -or $frontendProc.HasExited) {
Log-Warn 'one of the processes exited; closing the other...'
break
}
Start-Sleep -Milliseconds 150
Start-Sleep -Milliseconds 300
}
}
finally {
+56 -1
View File
@@ -117,6 +117,51 @@ free_port() {
fi
ok "端口 $port 已释放"
}
# 按本仓库 venv 的绝对路径识别整组后端进程。
# uvicorn --reload 的监听 socket 由 reloader 父进程创建并传给 server 子进程:
# 只 kill 监听者会让父进程立刻重生一个新子进程, 旧实例根本没退出 —— 每次重启
# 就多叠一套常驻 1GB+ 的后端。这里把父、子一起结束。
# 只处理同一端口的实例: 换端口再起一个 (如临时开 8000 调试) 不该被顺手杀掉。
kill_repo_backends() {
local pattern="$BACKEND_DIR/.venv/bin/python -m uvicorn app.main:app"
local pids others=""
pids=$(pgrep -f "$pattern" 2>/dev/null || true)
if [ -z "$pids" ]; then
return 0
fi
local to_kill=""
local pid cmd
for pid in $pids; do
cmd=$(ps -o command= -p "$pid" 2>/dev/null || true)
if [[ "$cmd" == *"--port $BACKEND_PORT"* ]]; then
to_kill="$to_kill $pid"
else
others="$others $pid"
fi
done
if [ -n "$others" ]; then
warn "检测到其他端口上的本项目后端实例 (PID:$others), 未处理。"
warn "如需一并结束: kill$others"
fi
if [ -z "$to_kill" ]; then
return 0
fi
warn "结束本项目同端口后端实例 PID:$(echo "$to_kill" | xargs)"
echo "$to_kill" | xargs kill 2>/dev/null || true
sleep 1
local left
left=$(echo "$to_kill" | xargs -n1 sh -c 'kill -0 "$0" 2>/dev/null && echo "$0"' || true)
if [ -n "$left" ]; then
echo "$left" | xargs kill -9 2>/dev/null || true
sleep 1
fi
return 0
}
# 先按仓库路径结束整组后端, 再查端口:
# 反过来的话 free_port 会先杀掉监听者 (reload 的子进程), 父进程随即重生一个,
# 端口检查看到的又是新进程。
kill_repo_backends
free_port backend "$BACKEND_PORT"
free_port frontend "$FRONTEND_PORT"
@@ -142,12 +187,22 @@ fi
# ===== 4. 启动 + 日志前缀 =====
PIDS=()
# 结束整棵子树: 只 kill 子 shell 自己会把 uvicorn/vite 留在原地变成孤儿
# (实测存在存活数天的残留 vite)。递归先杀后代再杀父进程。
kill_tree() {
local pid="$1" child
for child in $(pgrep -P "$pid" 2>/dev/null || true); do
kill_tree "$child"
done
kill "$pid" 2>/dev/null || true
}
cleanup() {
echo
info "关闭服务..."
for pid in "${PIDS[@]:-}"; do
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null || true
kill_tree "$pid"
fi
done
# 等子进程退出,避免孤儿