From 0613be1d149bcb201e6b26c26bc6ab24421ebdd9 Mon Sep 17 00:00:00 2001 From: Harmon Date: Wed, 29 Apr 2026 00:28:49 +0800 Subject: [PATCH] Fix Sentinel UI shutdown flow --- Start-SentinelUI.ps1 | 107 ++++++++++++++---- Stop-SentinelUI.ps1 | 95 ++++++++++++++-- src/sentinel_orbit_downloader/api/app.py | 7 +- .../api/routes_health.py | 32 +++++- src/sentinel_orbit_downloader/core/config.py | 7 ++ 5 files changed, 211 insertions(+), 37 deletions(-) diff --git a/Start-SentinelUI.ps1 b/Start-SentinelUI.ps1 index 40fd212..5d36ac1 100644 --- a/Start-SentinelUI.ps1 +++ b/Start-SentinelUI.ps1 @@ -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,14 +158,16 @@ function Save-State { [string]$Path, [int]$ProcessId, [int]$Port, - [string]$Url + [string]$Url, + [string]$ShutdownToken ) $payload = [ordered]@{ - pid = $ProcessId - port = $Port - url = $Url - started_at = (Get-Date).ToString("s") + pid = $ProcessId + port = $Port + url = $Url + shutdown_token = $ShutdownToken + started_at = (Get-Date).ToString("s") } $payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8 @@ -157,11 +204,21 @@ if ($state -and -not $ForceRestart) { } if ($ForceRestart -and $state) { - $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 + $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,14 +283,19 @@ $arguments = @( ) Write-Info "Starting UI at $url" -$process = Start-Process ` - -FilePath $pythonPath ` - -ArgumentList $arguments ` - -WorkingDirectory $projectRoot ` - -WindowStyle Hidden ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath ` - -PassThru +try { + $env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken + $process = Start-Process ` + -FilePath $pythonPath ` + -ArgumentList $arguments ` + -WorkingDirectory $projectRoot ` + -WindowStyle Hidden ` + -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" diff --git a/Stop-SentinelUI.ps1 b/Stop-SentinelUI.ps1 index 6d6c624..92d2e50 100644 --- a/Stop-SentinelUI.ps1 +++ b/Stop-SentinelUI.ps1 @@ -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,23 +98,49 @@ 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) { - Write-Info "Stopping UI process $($process.Id)" - Stop-Process -Id $process.Id -Force -} else { - $listenerProcessId = 0 - if ($state.port) { - $listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port) +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 ($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 { - 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 Write-Info "Done." diff --git a/src/sentinel_orbit_downloader/api/app.py b/src/sentinel_orbit_downloader/api/app.py index 3e65c8a..3a67272 100644 --- a/src/sentinel_orbit_downloader/api/app.py +++ b/src/sentinel_orbit_downloader/api/app.py @@ -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") diff --git a/src/sentinel_orbit_downloader/api/routes_health.py b/src/sentinel_orbit_downloader/api/routes_health.py index 99cbdb7..d5c9c33 100644 --- a/src/sentinel_orbit_downloader/api/routes_health.py +++ b/src/sentinel_orbit_downloader/api/routes_health.py @@ -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.") diff --git a/src/sentinel_orbit_downloader/core/config.py b/src/sentinel_orbit_downloader/core/config.py index aaf6659..e25f593 100644 --- a/src/sentinel_orbit_downloader/core/config.py +++ b/src/sentinel_orbit_downloader/core/config.py @@ -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