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
+69 -6
View File
@@ -94,6 +94,51 @@ function Get-ListeningProcessId {
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 {
param([string]$Path)
@@ -113,13 +158,15 @@ function Save-State {
[string]$Path,
[int]$ProcessId,
[int]$Port,
[string]$Url
[string]$Url,
[string]$ShutdownToken
)
$payload = [ordered]@{
pid = $ProcessId
port = $Port
url = $Url
shutdown_token = $ShutdownToken
started_at = (Get-Date).ToString("s")
}
@@ -157,12 +204,22 @@ if ($state -and -not $ForceRestart) {
}
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)
if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1
}
}
Remove-State -Path $statePath
}
@@ -183,7 +240,7 @@ foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$url = "http://127.0.0.1:$port/"
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) {
Start-Process $url | Out-Null
}
@@ -206,6 +263,7 @@ if (-not (Test-Path -LiteralPath $pythonPath)) {
$portToUse = Get-FreePort -Candidates $PreferredPorts
$url = "http://127.0.0.1:$portToUse/"
$shutdownToken = [guid]::NewGuid().ToString("N")
if (Test-Path -LiteralPath $stdoutPath) {
Remove-Item -LiteralPath $stdoutPath -Force
@@ -225,7 +283,9 @@ $arguments = @(
)
Write-Info "Starting UI at $url"
$process = Start-Process `
try {
$env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken
$process = Start-Process `
-FilePath $pythonPath `
-ArgumentList $arguments `
-WorkingDirectory $projectRoot `
@@ -233,6 +293,9 @@ $process = Start-Process `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-PassThru
} finally {
Remove-Item Env:S1DL_UI_SHUTDOWN_TOKEN -ErrorAction SilentlyContinue
}
$started = $false
for ($i = 0; $i -lt 30; $i++) {
@@ -261,12 +324,12 @@ if (-not $started) {
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
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 {
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"
+76 -5
View File
@@ -44,6 +44,51 @@ function Get-ListeningProcessId {
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
$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json"
@@ -53,22 +98,48 @@ if (-not (Test-Path -LiteralPath $statePath)) {
}
$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
$process = Get-RunningProcess -Id ([int]$state.pid)
$stopped = $false
if ($process) {
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)
if ($process) {
Write-Info "Stopping UI process $($process.Id)"
Stop-Process -Id $process.Id -Force
} else {
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 {
$listenerProcessId = 0
if ($state.port) {
$listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port)
}
if ($listenerProcessId -gt 0) {
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 {
Write-Info "Tracked process is not running."
$stopped = $true
}
}
}
if (-not $stopped) {
throw "UI shutdown did not complete."
}
Remove-Item -LiteralPath $statePath -Force
+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_search import router as search_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:
@@ -23,6 +27,7 @@ def create_app() -> FastAPI:
version="0.1.0",
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"
app.mount("/static", StaticFiles(directory=web_root), name="static")
@@ -1,14 +1,42 @@
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.schemas import HealthResponse
from sentinel_orbit_downloader.api.schemas import HealthResponse, MessageResponse
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)
def health(db_path=Depends(get_db_path)) -> HealthResponse:
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:
return DEFAULT_API_PORT
return int(raw)
def configured_ui_shutdown_token() -> str | None:
raw = os.getenv("S1DL_UI_SHUTDOWN_TOKEN")
if not raw:
return None
return raw