# Ensure MySQL + Redis are ready. Returns: docker | native # Dot-sourced from start.ps1 function Get-DevInfraModeFromEnv { param([string]$Root) $envFile = Join-Path $Root ".env" if (-not (Test-Path $envFile)) { return "auto" } foreach ($line in Get-Content $envFile) { if ($line -match '^\s*DEV_INFRA_MODE\s*=\s*(\S+)\s*$') { return $Matches[1].Trim().ToLower() } } return "auto" } function Get-EnvDbEndpoints { param([string]$Root) $envFile = Join-Path $Root ".env" $mysqlHost = "localhost" $mysqlPort = 3307 $redisHost = "localhost" $redisPort = 6379 if (Test-Path $envFile) { foreach ($line in Get-Content $envFile) { if ($line -match '^\s*DATABASE_URL\s*=\s*mysql://[^@]+@([^:/]+):(\d+)/') { $mysqlHost = $Matches[1] $mysqlPort = [int]$Matches[2] } if ($line -match '^\s*REDIS_URL\s*=\s*redis://([^:/]+):(\d+)') { $redisHost = $Matches[1] $redisPort = [int]$Matches[2] } if ($line -match '^\s*MYSQL_HOST_PORT\s*=\s*(\d+)') { $mysqlPort = [int]$Matches[1] } } } return @{ MysqlHost = $mysqlHost MysqlPort = $mysqlPort RedisHost = $redisHost RedisPort = $redisPort } } function Test-TcpPortOpen { param( [string]$HostName, [int]$Port, [int]$TimeoutMs = 2000 ) $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $connect = $client.BeginConnect($HostName, $Port, $null, $null) $ok = $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false) if (-not $ok) { return $false } $client.EndConnect($connect) return $true } catch { return $false } finally { if ($client) { $client.Close() } } } function Get-WslHostIp { $distro = Get-DevWslDistro if (-not $distro) { return $null } $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $raw = & wsl.exe -d $distro -e hostname -I 2>&1 | Out-String $ErrorActionPreference = $prev $text = ($raw -replace "`0", "").Trim() $ip = ($text -split '\s+')[0] if ($ip -match '^\d+\.\d+\.\d+\.\d+$') { return $ip } return $null } function Resolve-InfraHost { param( [string]$Root, [int]$MysqlPort, [int]$RedisPort ) $localRedisUrl = "redis://127.0.0.1:${RedisPort}" if ((Test-TcpPortOpen -HostName "127.0.0.1" -Port $MysqlPort) -and (Test-TcpPortOpen -HostName "127.0.0.1" -Port $RedisPort) -and (Test-RedisPingFromNode -RedisUrl $localRedisUrl)) { return "127.0.0.1" } $wslIp = Get-WslHostIp if ($wslIp) { $wslRedisUrl = "redis://${wslIp}:${RedisPort}" if ((Test-TcpPortOpen -HostName $wslIp -Port $MysqlPort) -and (Test-TcpPortOpen -HostName $wslIp -Port $RedisPort) -and (Test-RedisPingFromNode -RedisUrl $wslRedisUrl)) { return $wslIp } } if ((Test-TcpPortOpen -HostName "127.0.0.1" -Port $MysqlPort) -and (Test-TcpPortOpen -HostName "127.0.0.1" -Port $RedisPort)) { return "127.0.0.1" } if ($wslIp -and (Test-TcpPortOpen -HostName $wslIp -Port $MysqlPort) -and (Test-TcpPortOpen -HostName $wslIp -Port $RedisPort)) { return $wslIp } return "127.0.0.1" } function Apply-DevInfraEnv { param([string]$Root) $ep = Get-EnvDbEndpoints -Root $Root $infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort if ($infraHost -ne "127.0.0.1" -and $infraHost -ne "localhost") { Write-DevLog "WSL localhost forward unavailable, using $infraHost" "Warn" } $envFile = Join-Path $Root ".env" if (Test-Path $envFile) { foreach ($line in Get-Content $envFile) { if ($line -match '^\s*DATABASE_URL\s*=\s*(.+)$') { $env:DATABASE_URL = ($Matches[1] -replace '@([^:/]+):', "@${infraHost}:") } if ($line -match '^\s*REDIS_URL\s*=\s*(.+)$') { $env:REDIS_URL = ($Matches[1] -replace 'redis://([^:/]+):', "redis://${infraHost}:") } } } Ensure-DevDirs $infraEnv = Join-Path $script:DevConfig.DevDir "infra.env" $wslHostIp = Get-WslHostIp $lines = @( "DATABASE_URL=$($env:DATABASE_URL)" "REDIS_URL=$($env:REDIS_URL)" ) if ($wslHostIp) { $lines += "WSL_HOST_IP=$wslHostIp" } $lines | Set-Content -Path $infraEnv -Encoding ascii } function Test-NativeInfraPorts { param([string]$Root) $ep = Get-EnvDbEndpoints -Root $Root $infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort $mysqlOk = Test-TcpPortOpen -HostName $infraHost -Port $ep.MysqlPort $redisOk = Test-TcpPortOpen -HostName $infraHost -Port $ep.RedisPort return @{ Ok = ($mysqlOk -and $redisOk) Mysql = $mysqlOk Redis = $redisOk Host = $infraHost Endpoints = $ep } } function Test-NativeInfraReady { param([string]$Root) $ports = Test-NativeInfraPorts -Root $Root if (-not $ports.Ok) { return $false } Apply-DevInfraEnv -Root $Root if (-not $env:REDIS_URL) { return $false } return Test-RedisPingFromNode -RedisUrl $env:REDIS_URL } function Get-DockerComposeArgs { $files = $script:DevConfig.ComposeFiles $args = @() foreach ($f in $files) { $args += "-f" $args += (Join-Path $script:DevConfig.ProjectRoot $f) } return $args } function Invoke-DockerComposeProject { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$ComposeArgs) $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $base = Get-DockerComposeArgs docker compose @base @ComposeArgs 2>&1 | Out-Null $code = $LASTEXITCODE $ErrorActionPreference = $prev return $code } function Invoke-DockerInfoSafe { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = "docker" $psi.Arguments = "info" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $proc = New-Object System.Diagnostics.Process $proc.StartInfo = $psi [void]$proc.Start() $finished = $proc.WaitForExit(12000) if (-not $finished) { try { $proc.Kill() } catch {} return @{ Code = -1; Output = "docker info timeout (engine likely stuck)" } } $output = $proc.StandardOutput.ReadToEnd() + $proc.StandardError.ReadToEnd() return @{ Code = $proc.ExitCode; Output = $output } } function Get-DockerEngineStatus { $info = Invoke-DockerInfoSafe $output = $info.Output $code = $info.Code if ($code -eq 0 -and $output -match "(?m)^Server:") { return "ok" } if ($output -match "500|Internal Server Error|request returned 500|engine likely stuck") { return "engine_500" } if ($output -match "pipe|cannot find the file|connection refused|not running") { return "not_running" } return "error" } function Get-WslInfraScriptPath { param([string]$Root) $drive = $Root.Substring(0, 1).ToLower() $rest = $Root.Substring(2) -replace '\\', '/' return "/mnt/$drive$rest/scripts/dev/wsl-infra.sh" } function Start-WslInfra { param([string]$Root) $scriptPath = Get-WslInfraScriptPath -Root $Root $code = Invoke-DevWsl -BashCommand "bash '$scriptPath' status" if ($code -ne 0) { Write-DevLog "starting mysql+redis in WSL (no Docker Desktop)..." "Warn" $code = Invoke-DevWsl -BashCommand "export MYSQL_HOST_PORT=3307 MYSQL_ROOT_PASSWORD=changeme; bash '$scriptPath' start" } if ($code -eq 0) { $ep = Get-EnvDbEndpoints -Root $Root $infraHost = Resolve-InfraHost -Root $Root -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort $redisUrl = "redis://${infraHost}:$($ep.RedisPort)" if (Wait-InfraReadyFromWindows -HostName $infraHost -MysqlPort $ep.MysqlPort -RedisPort $ep.RedisPort -RedisUrl $redisUrl) { Write-DevLog "wsl mysql:3307 + redis:6379 ready" "Ok" } else { Write-DevLog "wsl infra started; Windows probe still waiting (will retry)" "Warn" } $distro = $env:WSL_DISTRO if (-not $distro) { $distro = "Ubuntu-22.04" } Start-WslKeepAlive -Distro $distro } return ($code -eq 0) } function Repair-DockerEngine { param([int]$MaxAttempts = 1) Write-DevLog "docker repair: quit desktop + wsl shutdown + restart (NO SwitchDaemon)" "Warn" $desktopExe = Join-Path ${env:ProgramFiles} "Docker\Docker\Docker Desktop.exe" if (Test-Path $desktopExe) { $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" Start-Process -FilePath $desktopExe -ArgumentList "--quit" -ErrorAction SilentlyContinue | Out-Null $ErrorActionPreference = $prev Start-Sleep -Seconds 8 } @( "Docker Desktop", "com.docker.backend", "com.docker.proxy", "vpnkit", "wslrelay" ) | ForEach-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" wsl --shutdown 2>&1 | Out-Null $ErrorActionPreference = $prev Start-Sleep -Seconds 8 if (Test-Path $desktopExe) { Start-Process -FilePath $desktopExe | Out-Null } $deadline = (Get-Date).AddSeconds(120) while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 if ((Get-DockerEngineStatus) -eq "ok") { Write-DevLog "docker engine recovered" "Ok" return $true } } Write-DevLog "docker repair failed - use WSL infra instead: npm run dev:infra:setup" "Err" return $false } function Wait-DockerDaemonQuick { param([int]$TimeoutSec = 25) $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { if ((Get-DockerEngineStatus) -eq "ok") { Write-DevLog "docker daemon ready" "Ok" return $true } Start-Sleep -Seconds 3 } Write-DevLog "docker not ready in ${TimeoutSec}s (skip repair on start)" "Warn" return $false } function Wait-DockerHealthy { param( [string[]]$Services, [int]$TimeoutSec = 90 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) Push-Location $script:DevConfig.ProjectRoot try { $composeBase = Get-DockerComposeArgs while ((Get-Date) -lt $deadline) { $allOk = $true foreach ($svc in $Services) { $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $json = docker compose @composeBase ps --format json $svc 2>$null $ErrorActionPreference = $prev if (-not $json) { $allOk = $false break } $status = $json | ConvertFrom-Json if (-not $status -or $status.Health -ne "healthy") { $allOk = $false break } } if ($allOk) { Write-DevLog "docker healthy: $($Services -join ', ')" "Ok" return $true } Start-Sleep -Seconds 2 } throw "docker not healthy within ${TimeoutSec}s" } finally { Pop-Location } } function Start-DockerInfra { param([string]$Root) Push-Location $Root try { if (-not (Wait-DockerDaemonQuick -TimeoutSec $script:DevConfig.DockerDaemonWaitSec)) { return $false } Write-DevLog "docker compose up mysql redis (dev profile)..." $code = Invoke-DockerComposeProject up -d mysql redis if ($code -ne 0) { return $false } Wait-DockerHealthy -Services $script:DevConfig.DockerServices -TimeoutSec $script:DevConfig.DockerWaitSec return $true } finally { Pop-Location } } function Test-RedisPingFromNode { param([string]$RedisUrl) if (-not $RedisUrl) { return $false } $escaped = $RedisUrl.Replace('"', '\"') $nodeScript = @" const Redis=require('ioredis'); (async()=>{ const r=new Redis('$escaped',{lazyConnect:true,connectTimeout:1500,maxRetriesPerRequest:1,retryStrategy:()=>null}); try{ await r.connect(); const pong=await r.ping(); await r.quit(); process.exit(pong==='PONG'?0:1); }catch{ await r.quit().catch(()=>{}); process.exit(1); } })(); "@ $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $null = node -e $nodeScript 2>&1 $ok = ($LASTEXITCODE -eq 0) $ErrorActionPreference = $prev return $ok } function Wait-InfraReadyFromWindows { param( [string]$HostName, [int]$MysqlPort, [int]$RedisPort, [string]$RedisUrl, [int]$TimeoutSec = 30 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) $attempt = 0 Wake-WslRedisForward while ((Get-Date) -lt $deadline) { $attempt += 1 $mysqlOk = Test-TcpPortOpen -HostName $HostName -Port $MysqlPort -TimeoutMs 800 $redisOk = Test-RedisPingFromNode -RedisUrl $RedisUrl if ($mysqlOk -and $redisOk) { return $true } if ($attempt -eq 1 -or ($attempt % 2) -eq 0) { Wake-WslRedisForward } $sleepMs = if ($attempt -le 6) { 350 } elseif ($attempt -le 12) { 700 } else { 1200 } Start-Sleep -Milliseconds $sleepMs } return $false } function Ensure-DevInfra { param( [string]$Root, [string]$Mode = "auto" ) $mode = $Mode.ToLower() if ($mode -eq "auto") { $mode = Get-DevInfraModeFromEnv -Root $Root } Write-DevLog "infra mode: $mode" if ($mode -eq "native") { if (Test-NativeInfraReady -Root $Root) { Apply-DevInfraEnv -Root $Root $distro = $env:WSL_DISTRO if (-not $distro) { $distro = "Ubuntu-22.04" } Start-WslKeepAlive -Distro $distro Write-DevLog "using native mysql/redis" "Ok" return "native" } Write-DevLog "native ports not ready, trying WSL mysql/redis..." "Warn" if (Start-WslInfra -Root $Root) { if (Test-NativeInfraReady -Root $Root) { Apply-DevInfraEnv -Root $Root Write-DevLog "using wsl mysql/redis" "Ok" return "wsl" } } $ports = Test-NativeInfraPorts -Root $Root throw @" native mode: mysql/redis not ready. mysql $($ports.Endpoints.MysqlPort): $(if ($ports.Mysql) { 'open' } else { 'closed' }) redis $($ports.Endpoints.RedisPort): $(if ($ports.Redis) { 'open' } else { 'closed' }) Run once: npm run dev:infra:setup Then: npm run dev:start "@ } if ($mode -eq "docker") { if (-not (Start-DockerInfra -Root $Root)) { throw "docker mode: failed to start mysql/redis. Run: npm run dev:repair-docker" } Write-DevLog "using docker mysql/redis" "Ok" return "docker" } # auto: docker quick try -> wsl infra -> native ports if (Start-DockerInfra -Root $Root) { Write-DevLog "using docker mysql/redis" "Ok" return "docker" } Write-DevLog "docker unavailable, trying WSL mysql/redis..." "Warn" if (Start-WslInfra -Root $Root) { if (Test-NativeInfraReady -Root $Root) { Apply-DevInfraEnv -Root $Root Write-DevLog "using wsl mysql/redis" "Ok" return "wsl" } } Write-DevLog "wsl infra failed, trying native ports..." "Warn" if (Test-NativeInfraReady -Root $Root) { Apply-DevInfraEnv -Root $Root Write-DevLog "using native mysql/redis (fallback)" "Ok" return "native" } $ports = Test-NativeInfraPorts -Root $Root throw @" Cannot start dev infrastructure. Root cause: Docker Desktop WSL engine IPC deadlock (engine 500 / context deadline exceeded). Do NOT use DockerCli -SwitchDaemon - it makes this worse. Recommended permanent fix: 1. npm run dev:infra:setup # install mysql+redis in WSL (no Docker Desktop) 2. Set DEV_INFRA_MODE=native in .env 3. npm run dev:start Or diagnose Docker: npm run dev:diagnose-docker Current ports: mysql $($ports.Endpoints.MysqlPort): $(if ($ports.Mysql) { 'open' } else { 'closed' }) redis $($ports.Endpoints.RedisPort): $(if ($ports.Redis) { 'open' } else { 'closed' }) "@ }