Fix Sentinel UI shutdown flow
This commit is contained in:
+85
-22
@@ -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,14 +158,16 @@ 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
|
||||||
started_at = (Get-Date).ToString("s")
|
shutdown_token = $ShutdownToken
|
||||||
|
started_at = (Get-Date).ToString("s")
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8
|
$payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8
|
||||||
@@ -157,11 +204,21 @@ if ($state -and -not $ForceRestart) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($ForceRestart -and $state) {
|
if ($ForceRestart -and $state) {
|
||||||
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
|
$existingStopped = $false
|
||||||
if ($oldProcess) {
|
if ($state.url -and $state.shutdown_token) {
|
||||||
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
|
Write-Info "Requesting shutdown for previous tracked UI"
|
||||||
Stop-Process -Id $oldProcess.Id -Force
|
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
|
||||||
Start-Sleep -Seconds 1
|
$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
|
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,14 +283,19 @@ $arguments = @(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Write-Info "Starting UI at $url"
|
Write-Info "Starting UI at $url"
|
||||||
$process = Start-Process `
|
try {
|
||||||
-FilePath $pythonPath `
|
$env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken
|
||||||
-ArgumentList $arguments `
|
$process = Start-Process `
|
||||||
-WorkingDirectory $projectRoot `
|
-FilePath $pythonPath `
|
||||||
-WindowStyle Hidden `
|
-ArgumentList $arguments `
|
||||||
-RedirectStandardOutput $stdoutPath `
|
-WorkingDirectory $projectRoot `
|
||||||
-RedirectStandardError $stderrPath `
|
-WindowStyle Hidden `
|
||||||
-PassThru
|
-RedirectStandardOutput $stdoutPath `
|
||||||
|
-RedirectStandardError $stderrPath `
|
||||||
|
-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"
|
||||||
|
|
||||||
|
|||||||
+83
-12
@@ -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,23 +98,49 @@ if (-not (Test-Path -LiteralPath $statePath)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
|
$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 "Stopping UI process $($process.Id)"
|
Write-Info "Requesting UI shutdown via API"
|
||||||
Stop-Process -Id $process.Id -Force
|
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
|
||||||
} else {
|
$stopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
|
||||||
$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
|
if (-not $stopped) {
|
||||||
|
$process = Get-RunningProcess -Id ([int]$state.pid)
|
||||||
|
|
||||||
|
if ($process) {
|
||||||
|
Write-Info "Stopping UI process $($process.Id)"
|
||||||
|
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 {
|
||||||
Write-Info "Tracked process is not running."
|
$listenerProcessId = 0
|
||||||
|
if ($state.port) {
|
||||||
|
$listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port)
|
||||||
|
}
|
||||||
|
if ($listenerProcessId -gt 0) {
|
||||||
|
Write-Info "Stopping listener process $listenerProcessId"
|
||||||
|
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
|
Remove-Item -LiteralPath $statePath -Force
|
||||||
Write-Info "Done."
|
Write-Info "Done."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user