Fix Sentinel UI shutdown flow

This commit is contained in:
2026-04-29 00:28:49 +08:00
parent a4fd1cff8e
commit 0613be1d14
5 changed files with 211 additions and 37 deletions
+68 -5
View File
@@ -94,6 +94,51 @@ function Get-ListeningProcessId {
return 0 return 0
} }
function Invoke-UiShutdown {
param(
[string]$Url,
[string]$ShutdownToken
)
if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) {
return $false
}
try {
$headers = @{
"X-S1DL-Shutdown-Token" = $ShutdownToken
}
$shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown"
$null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-ForUiShutdown {
param(
[int]$ProcessId,
[int]$Port,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$running = Get-RunningProcess -Id $ProcessId
$listenerProcessId = 0
if ($Port -gt 0) {
$listenerProcessId = Get-ListeningProcessId -Port $Port
}
if (-not $running -and $listenerProcessId -le 0) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
function Load-State { function Load-State {
param([string]$Path) param([string]$Path)
@@ -113,13 +158,15 @@ function Save-State {
[string]$Path, [string]$Path,
[int]$ProcessId, [int]$ProcessId,
[int]$Port, [int]$Port,
[string]$Url [string]$Url,
[string]$ShutdownToken
) )
$payload = [ordered]@{ $payload = [ordered]@{
pid = $ProcessId pid = $ProcessId
port = $Port port = $Port
url = $Url url = $Url
shutdown_token = $ShutdownToken
started_at = (Get-Date).ToString("s") started_at = (Get-Date).ToString("s")
} }
@@ -157,12 +204,22 @@ if ($state -and -not $ForceRestart) {
} }
if ($ForceRestart -and $state) { if ($ForceRestart -and $state) {
$existingStopped = $false
if ($state.url -and $state.shutdown_token) {
Write-Info "Requesting shutdown for previous tracked UI"
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
$existingStopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
}
}
if (-not $existingStopped) {
$oldProcess = Get-RunningProcess -Id ([int]$state.pid) $oldProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($oldProcess) { if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)" Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
} }
}
Remove-State -Path $statePath Remove-State -Path $statePath
} }
@@ -183,7 +240,7 @@ foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) { if (Test-Health -Port $port) {
$url = "http://127.0.0.1:$port/" $url = "http://127.0.0.1:$port/"
Write-Info "Found an existing running UI at $url" Write-Info "Found an existing running UI at $url"
Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url -ShutdownToken ""
if (-not $NoBrowser) { if (-not $NoBrowser) {
Start-Process $url | Out-Null Start-Process $url | Out-Null
} }
@@ -206,6 +263,7 @@ if (-not (Test-Path -LiteralPath $pythonPath)) {
$portToUse = Get-FreePort -Candidates $PreferredPorts $portToUse = Get-FreePort -Candidates $PreferredPorts
$url = "http://127.0.0.1:$portToUse/" $url = "http://127.0.0.1:$portToUse/"
$shutdownToken = [guid]::NewGuid().ToString("N")
if (Test-Path -LiteralPath $stdoutPath) { if (Test-Path -LiteralPath $stdoutPath) {
Remove-Item -LiteralPath $stdoutPath -Force Remove-Item -LiteralPath $stdoutPath -Force
@@ -225,6 +283,8 @@ $arguments = @(
) )
Write-Info "Starting UI at $url" Write-Info "Starting UI at $url"
try {
$env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken
$process = Start-Process ` $process = Start-Process `
-FilePath $pythonPath ` -FilePath $pythonPath `
-ArgumentList $arguments ` -ArgumentList $arguments `
@@ -233,6 +293,9 @@ $process = Start-Process `
-RedirectStandardOutput $stdoutPath ` -RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath ` -RedirectStandardError $stderrPath `
-PassThru -PassThru
} finally {
Remove-Item Env:S1DL_UI_SHUTDOWN_TOKEN -ErrorAction SilentlyContinue
}
$started = $false $started = $false
for ($i = 0; $i -lt 30; $i++) { for ($i = 0; $i -lt 30; $i++) {
@@ -261,12 +324,12 @@ if (-not $started) {
throw "UI failed to start." throw "UI failed to start."
} }
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken
$listenerProcessId = Get-ListeningProcessId -Port $portToUse $listenerProcessId = Get-ListeningProcessId -Port $portToUse
if ($listenerProcessId -gt 0) { if ($listenerProcessId -gt 0) {
Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url -ShutdownToken $shutdownToken
} else { } else {
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken
} }
Write-Info "UI is ready: $url" Write-Info "UI is ready: $url"
+73 -2
View File
@@ -44,6 +44,51 @@ function Get-ListeningProcessId {
return 0 return 0
} }
function Invoke-UiShutdown {
param(
[string]$Url,
[string]$ShutdownToken
)
if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) {
return $false
}
try {
$headers = @{
"X-S1DL-Shutdown-Token" = $ShutdownToken
}
$shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown"
$null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-ForUiShutdown {
param(
[int]$ProcessId,
[int]$Port,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$running = Get-RunningProcess -Id $ProcessId
$listenerProcessId = 0
if ($Port -gt 0) {
$listenerProcessId = Get-ListeningProcessId -Port $Port
}
if (-not $running -and $listenerProcessId -le 0) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
$projectRoot = Split-Path -Parent $PSCommandPath $projectRoot = Split-Path -Parent $PSCommandPath
$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json" $statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json"
@@ -53,11 +98,26 @@ if (-not (Test-Path -LiteralPath $statePath)) {
} }
$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
$stopped = $false
if ($state.url -and $state.shutdown_token) {
Write-Info "Requesting UI shutdown via API"
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
$stopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
}
}
if (-not $stopped) {
$process = Get-RunningProcess -Id ([int]$state.pid) $process = Get-RunningProcess -Id ([int]$state.pid)
if ($process) { if ($process) {
Write-Info "Stopping UI process $($process.Id)" Write-Info "Stopping UI process $($process.Id)"
Stop-Process -Id $process.Id -Force try {
Stop-Process -Id $process.Id -Force -ErrorAction Stop
$stopped = $true
} catch {
throw "Failed to stop UI process $($process.Id). It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)"
}
} else { } else {
$listenerProcessId = 0 $listenerProcessId = 0
if ($state.port) { if ($state.port) {
@@ -65,11 +125,22 @@ if ($process) {
} }
if ($listenerProcessId -gt 0) { if ($listenerProcessId -gt 0) {
Write-Info "Stopping listener process $listenerProcessId" Write-Info "Stopping listener process $listenerProcessId"
Stop-Process -Id $listenerProcessId -Force try {
Stop-Process -Id $listenerProcessId -Force -ErrorAction Stop
$stopped = $true
} catch {
throw "Failed to stop UI listener process $listenerProcessId. It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)"
}
} else { } else {
Write-Info "Tracked process is not running." Write-Info "Tracked process is not running."
$stopped = $true
} }
} }
}
if (-not $stopped) {
throw "UI shutdown did not complete."
}
Remove-Item -LiteralPath $statePath -Force Remove-Item -LiteralPath $statePath -Force
Write-Info "Done." Write-Info "Done."
+6 -1
View File
@@ -14,7 +14,11 @@ from sentinel_orbit_downloader.api.routes_projects import router as projects_rou
from sentinel_orbit_downloader.api.routes_regions import router as regions_router from sentinel_orbit_downloader.api.routes_regions import router as regions_router
from sentinel_orbit_downloader.api.routes_search import router as search_router from sentinel_orbit_downloader.api.routes_search import router as search_router
from sentinel_orbit_downloader.api.routes_settings import router as settings_router from sentinel_orbit_downloader.api.routes_settings import router as settings_router
from sentinel_orbit_downloader.core.config import configured_api_host, configured_api_port from sentinel_orbit_downloader.core.config import (
configured_api_host,
configured_api_port,
configured_ui_shutdown_token,
)
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -23,6 +27,7 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
description="Local API for Sentinel-1 search, orbit matching, and task management.", description="Local API for Sentinel-1 search, orbit matching, and task management.",
) )
app.state.shutdown_token = configured_ui_shutdown_token()
web_root = Path(__file__).resolve().parents[1] / "web" web_root = Path(__file__).resolve().parents[1] / "web"
app.mount("/static", StaticFiles(directory=web_root), name="static") app.mount("/static", StaticFiles(directory=web_root), name="static")
@@ -1,14 +1,42 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends import os
import signal
import time
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from sentinel_orbit_downloader.api.deps import get_db_path from sentinel_orbit_downloader.api.deps import get_db_path
from sentinel_orbit_downloader.api.schemas import HealthResponse from sentinel_orbit_downloader.api.schemas import HealthResponse, MessageResponse
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
def _terminate_current_process() -> None:
time.sleep(0.2)
try:
os.kill(os.getpid(), signal.SIGTERM)
except Exception:
os._exit(0)
@router.get("/health", response_model=HealthResponse) @router.get("/health", response_model=HealthResponse)
def health(db_path=Depends(get_db_path)) -> HealthResponse: def health(db_path=Depends(get_db_path)) -> HealthResponse:
return HealthResponse(status="ok", db_path=str(db_path)) return HealthResponse(status="ok", db_path=str(db_path))
@router.post("/shutdown", response_model=MessageResponse, include_in_schema=False)
def shutdown(
background_tasks: BackgroundTasks,
request: Request,
x_s1dl_shutdown_token: str | None = Header(default=None, alias="X-S1DL-Shutdown-Token"),
) -> MessageResponse:
expected_token = getattr(request.app.state, "shutdown_token", None)
if not expected_token:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Shutdown is disabled.")
if x_s1dl_shutdown_token != expected_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid shutdown token.")
background_tasks.add_task(_terminate_current_process)
return MessageResponse(message="Shutdown requested.")
@@ -25,3 +25,10 @@ def configured_api_port() -> int:
if not raw: if not raw:
return DEFAULT_API_PORT return DEFAULT_API_PORT
return int(raw) return int(raw)
def configured_ui_shutdown_token() -> str | None:
raw = os.getenv("S1DL_UI_SHUTDOWN_TOKEN")
if not raw:
return None
return raw