diff --git a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js index 24e15ef3ad..28fb8c2d86 100644 --- a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js +++ b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js @@ -614,7 +614,7 @@ function sameRuntimeNodeLeaseExists(runtimeFile, key) { function cleanupLastDetach(runtimeFile, key, cleanupScript) { if (sameRuntimeNodeLeaseExists(runtimeFile, key)) return; const script = cleanupScript || path.join(__dirname, "start-sympp-mcp.ps1"); - const args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-CleanupRuntimeKey", key, "-PreserveCurrentArtifactRuntime"]; + const args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-CleanupRuntimeKey", key]; let result = spawnSync("pwsh.exe", args, { stdio: ["ignore", "ignore", "inherit"] }); if (result.error && result.error.code === "ENOENT") result = spawnSync("powershell.exe", args, { stdio: ["ignore", "ignore", "inherit"] }); if (result.error || result.status !== 0) diagnostic(`Symphony++ last-detach cleanup failed: ${result.error ? result.error.message : `exit ${result.status}`}`); diff --git a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1 b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1 index fbc9fb41c7..db44803c17 100644 --- a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1 +++ b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1 @@ -3,7 +3,6 @@ param( [switch]$ValidateOnly, [switch]$PrepareRuntimeOnly, [switch]$CleanupPreparedRuntime, - [switch]$PreserveCurrentArtifactRuntime, [string]$CleanupRuntimeKey ) @@ -1304,10 +1303,9 @@ function Test-RuntimeStateExternalLoopback($RuntimeState) { [string]$RuntimeState.frontend.status -eq "external_loopback" } -function Test-BackendShouldShutdownOnIdle($BackendPlan, $DashboardPlan, [string]$RuntimeMode) { +function Test-BackendShouldShutdownOnIdle($BackendPlan, $DashboardPlan) { return ($BackendPlan.managed -eq $true) -and - ($DashboardPlan.managed -ne $true) -and - -not ($RuntimeMode -eq "artifact" -and [string]$DashboardPlan.status -eq "artifact_static") + ($DashboardPlan.managed -ne $true) } function Test-RuntimeEntryEndpointMatches([string]$Role, $Entry, [string]$Endpoint) { @@ -1610,7 +1608,7 @@ function Get-ManagedListenerPid([string]$Role, [int]$Port) { return $null } -function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey, [bool]$PreserveCurrentArtifactRuntime = $false) { +function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey) { $lock = Enter-FileLock (Resolve-StartupLockFile $RuntimeFile) 30 try { $activeLeases = @(Get-ActiveBridgeLeases $RuntimeFile) @@ -1628,11 +1626,7 @@ function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey, if ((Test-ActiveLegacyBridgeLease $activeLeases) -or (Test-ActiveBridgeLeaseForRuntimeKey $activeLeases $RuntimeKey)) { return } - $preserveCurrent = $PreserveCurrentArtifactRuntime -and - [string]$state.runtime_kind -eq "artifact" -and - [string]$state.runtime_mode -eq "artifact" -and - [string]$state.frontend.status -eq "artifact_static" - if ([System.StringComparer]::OrdinalIgnoreCase.Equals($stateKey, $RuntimeKey) -and -not $preserveCurrent) { + if ([System.StringComparer]::OrdinalIgnoreCase.Equals($stateKey, $RuntimeKey)) { [void](Stop-CurrentManagedRuntimeStateEntries $state $activeLeases) } $supersededStates = Stop-SupersededRuntimeStatesIfUnused $RuntimeFile (Get-SupersededRuntimeStates $state) @@ -2075,7 +2069,7 @@ function Invoke-WarmAttachFromRuntimeState { } finally { Remove-BridgeLease $leasePath if ($attached) { - Stop-ManagedServersIfUnused $RuntimeFile $identity.runtime_key $true + Stop-ManagedServersIfUnused $RuntimeFile $identity.runtime_key } } } @@ -2242,7 +2236,7 @@ if ($Help) { } if (-not [string]::IsNullOrWhiteSpace($CleanupRuntimeKey)) { - Stop-ManagedServersIfUnused (Resolve-RuntimeFile) $CleanupRuntimeKey ([bool]$PreserveCurrentArtifactRuntime) + Stop-ManagedServersIfUnused (Resolve-RuntimeFile) $CleanupRuntimeKey exit 0 } if ($CleanupPreparedRuntime) { @@ -2549,7 +2543,7 @@ try { [void](Initialize-ElixirRuntime $elixirDir $launcher $mix $mise $logDir $elixirSetupTimeout) } $backendDashboardOrigin = if ($runtimeMode -eq "artifact" -and [string]$dashboardPlan.status -eq "artifact_static") { $null } else { $dashboardPlan.origin } - $backendShutdownOnIdle = Test-BackendShouldShutdownOnIdle $backendPlan $dashboardPlan $runtimeMode + $backendShutdownOnIdle = Test-BackendShouldShutdownOnIdle $backendPlan $dashboardPlan $startingRuntimeRoot = if ($runtimeMode -eq "artifact") { [string]$artifactRuntime.root } else { [string]$repoRoot } if ($installedHttpCold) { $startingState.runtime_mode = $runtimeMode; $startingState.runtime_kind = $runtimeMode @@ -2695,5 +2689,5 @@ try { Invoke-HttpMcpBridge $backendPlan.mcp_url $bridgeTimeout (New-McpClientLeaseId) $clientHeartbeatInterval } finally { Remove-BridgeLease $bridgeLeasePath - Stop-ManagedServersIfUnused $runtimeFile $runtimeKey $true + Stop-ManagedServersIfUnused $runtimeFile $runtimeKey } diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js b/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js index a920fd2ab9..b8d489cce0 100644 --- a/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js +++ b/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js @@ -66,6 +66,14 @@ async function freePort() { return port; } +function portAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} + async function barrierClient() { const [, , , barrier, launcher] = process.argv; process.stderr.write("BARRIER_READY\n"); @@ -297,21 +305,21 @@ async function runCase(clientCount, shell, mode = "normal") { } finally { clearTimeout(readyTimeout); } + const activeBackend = readJson(backendState); + const ownersResult = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", "@(Get-NetTCPConnection -LocalPort $env:FIXTURE_PORT -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique) | ConvertTo-Json -Compress"], { env: { ...process.env, FIXTURE_PORT: String(backendPort) }, encoding: "utf8", windowsHide: true }); + assert.equal(ownersResult.status, 0, ownersResult.stderr); + assert.deepEqual([].concat(JSON.parse(ownersResult.stdout.trim())), [activeBackend.pid]); for (const client of clients) { try { client.child.stdin.end(); } catch (_) { } } const results = await Promise.all(clients.map((client) => client.result)); const expectedFailures = mode.endsWith("_death") ? 1 : 0; assert.equal(results.filter((result) => result.code !== 0).length, expectedFailures, results.map((result) => result.stderr).join("\n")); assert.equal(results.filter((result) => result.code === 0).length, clientCount); await waitFor(() => readJson(backendState)?.active_leases === 0, "Backend leases did not drain."); - const state = readJson(runtimeFile); const backend = readJson(backendState); backendPid = backend.pid; - const ownersResult = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", "@(Get-NetTCPConnection -LocalPort $env:FIXTURE_PORT -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique) | ConvertTo-Json -Compress"], { env: { ...process.env, FIXTURE_PORT: String(backendPort) }, encoding: "utf8", windowsHide: true }); - assert.equal(ownersResult.status, 0, ownersResult.stderr); - const owners = JSON.parse(ownersResult.stdout.trim()); - assert.deepEqual([].concat(owners), [backendPid]); + await waitFor(() => portAvailable(backendPort), "Backend listener did not stop after the final client exited."); + const state = await waitFor(() => { const value = readJson(runtimeFile); return value?.backend?.status === "stopped" && value.backend.pid === null && value; }, "Runtime state did not record zero-client backend shutdown."); assert.equal(state.publication.status, "ready"); - assert.equal(state.backend.pid, backendPid); assert.equal(backend.starts, 1); assert.equal(backend.initialize, clientCount); assert.equal(backend.tools_list, clientCount); @@ -336,7 +344,7 @@ async function runCase(clientCount, shell, mode = "normal") { for (const directory of [symppHome, environment.TEMP]) if (fs.existsSync(directory)) for (const entry of fs.readdirSync(directory, { recursive: true })) if (/artifact\.zip\.tmp-|\.extracting-|codex-plugin\.json\.tmp-/.test(String(entry))) leftovers.push(entry); assert.deepEqual(leftovers, []); assert.ok(percentile(latencies, 0.95) < 60000 && Math.max(...latencies) < 90000); - return { mode, shell: path.basename(shell), clients: clientCount, p95_ms: percentile(latencies, 0.95), max_ms: Math.max(...latencies), manifest: channel.counts.manifest_successes, manifest_attempts: channel.counts.manifest_attempts, artifact: channel.counts.archive_successes, artifact_attempts: channel.counts.archive_attempts, preparations: traceCount(traceDir, "artifact_prepare_end"), backends: backend.starts, pids: 1, listeners: 1, initializes: backend.initialize, tools_list: backend.tools_list, lease_peak: backend.lease_peak, leases_after: backend.active_leases, adopted: traceCount(traceDir, "backend_adopted") }; + return { mode, shell: path.basename(shell), clients: clientCount, p95_ms: percentile(latencies, 0.95), max_ms: Math.max(...latencies), manifest: channel.counts.manifest_successes, manifest_attempts: channel.counts.manifest_attempts, artifact: channel.counts.archive_successes, artifact_attempts: channel.counts.archive_attempts, preparations: traceCount(traceDir, "artifact_prepare_end"), backends: backend.starts, pids: 1, listeners: 0, initializes: backend.initialize, tools_list: backend.tools_list, lease_peak: backend.lease_peak, leases_after: backend.active_leases, adopted: traceCount(traceDir, "backend_adopted") }; } finally { terminateTrees(clients.filter((client) => client.child.exitCode === null).map((client) => client.child.pid)); if (!backendPid) backendPid = readJson(backendState)?.pid || 0; diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1 b/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1 index e36d1003bd..ddafeab4d2 100644 --- a/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1 +++ b/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1 @@ -1,4 +1,3 @@ -param([int]$IdleMilliseconds = 1500) $ErrorActionPreference = "Stop" $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "../../../..")) $pluginRoot = Join-Path $repoRoot "plugins/symphony-plus-plus-mcp" @@ -29,7 +28,7 @@ function Start-IsolatedProcess([string]$FilePath, [string[]]$Arguments, [hashtab $psi.RedirectStandardError = $RedirectStreams foreach ($key in @($psi.Environment.Keys)) { if ([string]$key -match "(?i)(TOKEN|SECRET|API_KEY|AUTHORIZATION|GITHUB|LINEAR|OPENAI)" -or - [string]$key -in @("SYMPP_REPO_ROOT", "SYMPP_BACKEND_PORT", "SYMPP_DASHBOARD_PORT", "SYMPP_BACKEND_URL", "SYMPP_DASHBOARD_ORIGIN")) { + [string]$key -in @("SYMPP_REPO_ROOT", "SYMPP_DATABASE", "SYMPP_SOURCE_FALLBACK", "SYMPP_ARTIFACT_RUNTIME", "SYMPP_BACKEND_PORT", "SYMPP_DASHBOARD_PORT", "SYMPP_BACKEND_URL", "SYMPP_DASHBOARD_ORIGIN")) { [void]$psi.Environment.Remove([string]$key) } } @@ -63,8 +62,15 @@ function Invoke-McpBridge([string]$FilePath, [string[]]$Arguments, [hashtable]$E $process.StandardInput.Flush() $line = $process.StandardOutput.ReadLineAsync() if (-not $line.Wait(60000)) { throw "Timed out waiting for MCP response from $FilePath" } + if ($null -eq $line.Result) { + [void]$process.WaitForExit(5000) + throw "$FilePath closed before its MCP response: $($stderr.GetAwaiter().GetResult())" + } $line.Result | ConvertFrom-Json } + $activeState = Get-Content -LiteralPath $Environment.SYMPP_RUNTIME_FILE -Raw | ConvertFrom-Json + $activeBackend = Get-Process -Id ([int]$activeState.backend.pid) -ErrorAction Stop + $activeBackendStartTicks = $activeBackend.StartTime.ToUniversalTime().Ticks $process.StandardInput.Close() if (-not $process.WaitForExit(60000)) { $process.Kill($true); throw "Bridge did not exit after stdin closed: $FilePath" } $errorText = $stderr.GetAwaiter().GetResult() @@ -72,7 +78,12 @@ function Invoke-McpBridge([string]$FilePath, [string[]]$Arguments, [hashtable]$E if ($responses[0].result.protocolVersion -ne "2025-03-26" -or @($responses[1].result.tools).Count -eq 0) { throw "Bridge did not complete real initialize plus tools/list." } - return $errorText + return [pscustomobject]@{ + backend_pid = [int]$activeState.backend.pid + backend_start_ticks = $activeBackendStartTicks + runtime_mode = [string]$activeState.runtime_mode + artifact_root = [string]$activeState.artifact.root + } } finally { if (-not $process.HasExited) { $process.Kill($true) } $process.Dispose() @@ -83,10 +94,18 @@ function Get-Sha256([string]$Value) { return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Value))).ToLowerInvariant() } -function Wait-ProcessStopped([int]$ProcessIdValue) { +function Test-PortAvailable([int]$Port) { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) + try { $listener.Start(); return $true } catch { return $false } finally { $listener.Stop() } +} + +function Wait-ManagedRuntimeStopped([int]$ProcessIdValue, [int]$Port) { $deadline = [DateTime]::UtcNow.AddSeconds(30) - while ((Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 } - if (Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) { throw "Managed backend pid=$ProcessIdValue did not stop." } + while ([DateTime]::UtcNow -lt $deadline) { + if (-not (Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) -and (Test-PortAvailable $Port)) { return } + Start-Sleep -Milliseconds 100 + } + throw "Managed runtime pid=$ProcessIdValue or listener port=$Port did not stop." } try { @@ -116,7 +135,7 @@ try { $backendStartTicks = (Get-Process -Id $backendProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks $frontendProcessId = [int]$state.frontend.pid Stop-Process -Id $frontendProcessId -Force -ErrorAction Stop - Wait-ProcessStopped $frontendProcessId + Wait-ManagedRuntimeStopped $frontendProcessId $dashboardPort $frontendProcessId = $null $contract = [string]$state.backend.contract_fingerprint $backend = ([string]$state.backend.url).TrimEnd("/") @@ -131,8 +150,41 @@ try { } $revision = "b" * 40 New-Item -ItemType Directory -Path (Join-Path $sourceRoot "elixir/priv/symphony_plus_plus") -Force | Out-Null + "[]" | Set-Content -LiteralPath (Join-Path $sourceRoot "elixir/mix.exs") -NoNewline @{ revision = $revision } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $sourceRoot ".codex-marketplace-install.json") @{ mcp_contract_fingerprint = $contract } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $sourceRoot "elixir/priv/symphony_plus_plus/mcp_contract.json") + $artifactPayload = Join-Path $tempRoot "artifact-payload" + $artifactArchive = Join-Path $tempRoot "artifact.zip" + $dashboardHtml = "Symphony++ Dashboard" + New-Item -ItemType Directory -Path (Join-Path $artifactPayload "dashboard") -Force | Out-Null + @' +"use strict"; +const http=require("http"),a=process.argv.slice(2),arg=n=>a[a.indexOf(n)+1],port=Number(arg("--port")),contract=arg("--contract"),revision=arg("--revision"),session="artifact-fixture"; +const body=r=>new Promise(q=>{const c=[];r.on("data",x=>c.push(x));r.on("end",()=>q(Buffer.concat(c).toString("utf8")));}); +const send=(r,s,v,h={})=>{const b=typeof v==="string"?v:JSON.stringify(v);r.writeHead(s,{"Content-Type":"application/json","Content-Length":Buffer.byteLength(b),...h});r.end(b);}; +const server=http.createServer(async(req,res)=>{ + if(req.url==="/shutdown"){send(res,200,{status:"stopping"});return server.close(()=>process.exit(0));} + if(req.url==="/mcp/readiness")return send(res,200,{status:"ok",ledger:{reachable:true},dashboard:{ready:true},source:{revision,mcp_contract:{fingerprint:contract}}}); + if(req.url==="/sympp/board")return send(res,200,"Symphony++ Dashboard",{"Content-Type":"text/html"}); + if(req.url==="/mcp/client-lease"){await body(req);return send(res,200,{stale_after_ms:600000});} + if(req.url==="/mcp"){const p=JSON.parse(await body(req)),result=p.method==="initialize"?{protocolVersion:"2025-03-26",capabilities:{},serverInfo:{name:"artifact-fixture",version:"1"}}:p.method==="tools/list"?{tools:[{name:"fixture",description:"fixture",inputSchema:{type:"object"}}]}:null;return result?send(res,200,{jsonrpc:"2.0",id:p.id,result},{"Mcp-Session-Id":session}):send(res,404,{error:"missing"});} + send(res,404,{error:"not found"}); +}); +server.listen(port,"127.0.0.1"); +'@ | Set-Content -LiteralPath (Join-Path $artifactPayload "backend.js") -NoNewline + "@echo off`r`nnode `"%~dp0backend.js`" %*`r`n" | Set-Content -LiteralPath (Join-Path $artifactPayload "start-runtime.cmd") -NoNewline + $dashboardHtml | Set-Content -LiteralPath (Join-Path $artifactPayload "dashboard/index.html") -NoNewline + Compress-Archive -Path (Join-Path $artifactPayload "*") -DestinationPath $artifactArchive + $artifactSha = (Get-FileHash -LiteralPath $artifactArchive -Algorithm SHA256).Hash.ToLowerInvariant() + $dashboardFingerprint = Get-Sha256 "index.html $(Get-Sha256 $dashboardHtml)" + New-Item -ItemType Directory -Path (Join-Path $installedRoot "assets") -Force | Out-Null + @{ + schema_version = 1; source_revision = $revision; launcher_contract = @{ mcp_contract_fingerprint = $contract } + artifacts = @(@{ platform = "windows-x86_64"; source_revision = $revision; mcp_contract_fingerprint = $contract + path = $artifactArchive; sha256 = $artifactSha; entrypoint = "start-runtime.cmd" + runtime_args = @("--port", "{port}", "--contract", $contract, "--revision", $revision, "start-runtime.cmd") + dashboard = @{ asset_root = "dashboard"; fingerprint = $dashboardFingerprint } }) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $installedRoot "assets/sympp-runtime-artifacts.json") $installedPath = [System.IO.Path]::GetFullPath($installedRoot).ToLowerInvariant() $generationKey = Get-Sha256 "$installedPath`n$revision`n$contract" $validationFile = Join-Path $sourceEnvironment.SYMPP_HOME ("runtime/launcher-validation/" + (Get-Sha256 $installedPath).Substring(0, 12) + ".json") @@ -158,22 +210,34 @@ try { SYMPP_LOG_DIR = $sourceEnvironment.SYMPP_LOG_DIR; SYMPP_LAUNCHER_TRACE_DIR = Join-Path $tempRoot "trace" SYMPP_STARTUP_LOCK_TIMEOUT_SEC = "30"; SYMPP_MCP_HTTP_TIMEOUT_SEC = "60" } - $node = (Get-Command node.exe -ErrorAction Stop).Source - $bridge = Join-Path $installedRoot "scripts/start-sympp-mcp-bridge.js" - $firstBridgeLog = Invoke-McpBridge $node @($bridge) $installedEnvironment - Start-Sleep -Milliseconds $IdleMilliseconds - $afterIdle = Get-Process -Id $backendProcessId -ErrorAction SilentlyContinue - if (-not $afterIdle) { throw "Artifact backend stopped after idle detach. Bridge diagnostics: $firstBridgeLog" } - if ($afterIdle.StartTime.ToUniversalTime().Ticks -ne $backendStartTicks) { throw "Artifact backend PID identity changed after last detach." } - Invoke-McpBridge $node @($bridge) $installedEnvironment - $afterSecondWave = Get-Process -Id $backendProcessId -ErrorAction Stop - if ($afterSecondWave.StartTime.ToUniversalTime().Ticks -ne $backendStartTicks) { throw "Second bridge wave did not reuse the artifact backend PID." } + $cmd = (Get-Command cmd.exe -ErrorAction Stop).Source + $installedCommand = Join-Path $installedRoot "scripts/start-sympp-mcp.cmd" + $firstWave = Invoke-McpBridge $cmd @("/d", "/c", "call $installedCommand") $installedEnvironment + if ($firstWave.backend_pid -ne $backendProcessId) { throw "Installed command attached to unexpected backend pid=$($firstWave.backend_pid)." } + Wait-ManagedRuntimeStopped $backendProcessId $backendPort + $firstBackendProcessId = $backendProcessId + $backendProcessId = $null - $installedLauncher = Join-Path $installedRoot "scripts/start-sympp-mcp.ps1" - [void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $installedLauncher, "-CleanupRuntimeKey", "superseded-$runtimeKey") $installedEnvironment) - [void](Get-Process -Id $backendProcessId -ErrorAction Stop) - [void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $installedLauncher, "-CleanupRuntimeKey", $runtimeKey) $installedEnvironment) - Wait-ProcessStopped $backendProcessId + $laterInstalledEnvironment = $installedEnvironment.Clone() + $laterInstalledEnvironment.SYMPP_BACKEND_PORT = [string]$backendPort + $laterInstalledEnvironment.SYMPP_AUTOSTART_FRONTEND = "0" + $laterInstalledEnvironment.SYMPP_ELIXIR_SETUP_TIMEOUT_SEC = "30" + $laterInstalledEnvironment.SYMPP_BACKEND_STARTUP_TIMEOUT_SEC = "60" + $laterInstalledEnvironment.SYMPP_BACKEND_PORT_RELEASE_TIMEOUT_SEC = "1" + $laterInstalledEnvironment.SYMPP_STARTUP_LOCK_TIMEOUT_SEC = "180" + $laterInstalledEnvironment.SYMPP_COLD_START_TIMEOUT_SEC = "90" + $laterInstalledEnvironment.SYMPP_POWERSHELL = $pwsh + $laterInstalledEnvironment.TEMP = $sourceEnvironment.TEMP + $laterInstalledEnvironment.TMP = $sourceEnvironment.TMP + $secondWave = Invoke-McpBridge $cmd @("/d", "/c", "call $installedCommand") $laterInstalledEnvironment + $backendProcessId = $secondWave.backend_pid + $backendStartTicks = $secondWave.backend_start_ticks + if ($backendProcessId -eq $firstBackendProcessId) { throw "Later installed command wave reused backend pid=$backendProcessId." } + $artifactCacheRoot = [System.IO.Path]::GetFullPath((Join-Path $sourceEnvironment.SYMPP_HOME "artifacts/mcp")) + if ($secondWave.runtime_mode -ne "artifact" -or -not ([System.IO.Path]::GetFullPath($secondWave.artifact_root).StartsWith($artifactCacheRoot, [StringComparison]::OrdinalIgnoreCase))) { + throw "Later installed command wave was not artifact-backed." + } + Wait-ManagedRuntimeStopped $backendProcessId $backendPort $backendProcessId = $null [void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $launcher, "-PrepareRuntimeOnly") $sourceEnvironment) @@ -182,14 +246,15 @@ try { $frontendProcessId = [int]$sourceState.frontend.pid $backendProcessId = $sourceProcessId $backendStartTicks = (Get-Process -Id $sourceProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks - Invoke-McpBridge $pwsh @("-NoProfile", "-File", $launcher) $sourceEnvironment - Wait-ProcessStopped $sourceProcessId + [void](Invoke-McpBridge $pwsh @("-NoProfile", "-File", $launcher) $sourceEnvironment) + Wait-ManagedRuntimeStopped $sourceProcessId $backendPort + Wait-ManagedRuntimeStopped $frontendProcessId $dashboardPort $backendProcessId = $null $frontendProcessId = $null [pscustomobject]@{ - artifact_waves = 2; initialize_and_tools_list = 2; artifact_pid_reused = $true - stale_cleanup_preserved_current = $true; explicit_cleanup_stopped_exact = $true + installed_waves = 2; initialize_and_tools_list = 3; installed_pids_distinct = $true + artifact_last_detach_stopped = $true; listeners_closed = $true source_last_detach_stopped = $true; isolated_runtime_ledger_ports = $true } | ConvertTo-Json -Compress } finally { diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1 b/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1 index c493add584..dc6449346c 100644 --- a/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1 +++ b/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1 @@ -75,8 +75,7 @@ Assert-True ($null -eq (Resolve-LocalWarmAttachIdentity $staleState $pluginRoot $health = [pscustomobject]@{ healthy = $true; source_revision = $state.backend.source_revision; contract_fingerprint = $fingerprint } $plan = Resolve-FastAttachRuntimePlan $state $state.backend.source_revision $fingerprint 0 0 $false $false $null $null $health $true $true Assert-True ($null -ne $plan -and -not $plan.dashboard_plan.managed) "Artifact-static runtime should produce an unmanaged-dashboard fallback plan" -Assert-True (-not (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend "artifact")) "Artifact-static backends must remain resident after transient bridge churn" -Assert-True (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend "source") "Source backends without a managed dashboard must remain idle-disposable" +Assert-True (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend) "Managed backends without a managed dashboard must shut down on idle in source and artifact modes" Assert-True (Test-SymppBackendCommandLine 'cmd.exe /c C:\cache\artifacts\mcp\windows-x86_64\abc\runtime\start-runtime.cmd') "Supported artifact command wrappers must remain recoverable before binding" $sourceState = [pscustomobject]@{ runtime_kind = "managed"; backend = [pscustomobject]@{ url = "http://127.0.0.1:20000" } } $reusedSourcePlan = [pscustomobject]@{ reused = $true; should_start = $false; url = "http://127.0.0.1:20000" } @@ -182,7 +181,7 @@ try { Assert-True ([int]$pendingState.publication.backend.pid -eq $ownedPid -and [int]$pendingState.publication.backend.pid -ne $unrelated.Id) "Pending recovery must not adopt an unrelated loopback process" } finally { foreach ($processId in @($(if ($unrelated) { $unrelated.Id }), $ownedPid, $wrapperPid, $(if ($leader) { $leader.Id }))) { - if ($processId) { & taskkill.exe /PID $processId /T /F 2>$null | Out-Null } + if ($processId -and (Get-Process -Id $processId -ErrorAction SilentlyContinue)) { & taskkill.exe /PID $processId /T /F 2>$null | Out-Null } } Remove-Item Env:SYMPP_TEST_WRAPPER_READY,Env:SYMPP_TEST_WRAPPER_RELEASE,Env:SYMPP_TEST_RUNTIME_CMD,Env:SYMPP_TEST_RUNTIME_ROOT -ErrorAction SilentlyContinue Remove-Item -LiteralPath $pendingBase -Recurse -Force -ErrorAction SilentlyContinue @@ -205,7 +204,7 @@ Assert-True ($node -match '(?s)function generationStillValid\(identity\).*?gener Assert-True ([regex]::Matches($node, 'setTimeout\(resolve, GENERATION_SETTLE_MS\)').Count -eq 2) "Generation validation must retain the post-scan and final exact-generation settle waits" Assert-True ([regex]::Matches($bridgeSource, 'generationValidForAttachment\(identity\)').Count -eq 2 -and [regex]::Matches($bridgeSource, 'generationValidAtAttachment\(identity\)').Count -eq 1) "Warm bridge attachment must validate its generation before shared readiness and after lease attachment" Assert-True ($cmd.Contains('cd /d "%SystemRoot%"') -and $cmd.IndexOf('cd /d "%SystemRoot%"') -lt $cmd.IndexOf('start-sympp-mcp-bridge.js') -and -not $cmd.Contains('cd /d "%TEMP%"')) "Shipped launcher must leave the installed plugin working directory without relying on TEMP" -Assert-True ($node.IndexOf('cleanupScript = prepareCleanupScript(identity);') -lt $watchRelease -and $node.Contains('cleanupLastDetach(runtimeFile, identity.runtimeKey, cleanupScript)')) "Node warm attach must preserve last-detach cleanup outside the invalidatable plugin cache" +Assert-True ($node.IndexOf('cleanupScript = prepareCleanupScript(identity);') -lt $watchRelease -and $node.Contains('cleanupLastDetach(runtimeFile, identity.runtimeKey, cleanupScript)') -and -not $node.Contains('PreserveCurrentArtifactRuntime')) "Node warm attach must preserve last-detach cleanup outside the invalidatable plugin cache without a residency exception" Assert-True (-not $node.Contains('confirmedCleanupScript')) "Warm bridge attachment must stage and hash its exact cleanup generation only once" Assert-True ($node -match '(?s)function prepareCleanupScript\(identity\).*?try \{\s*const names = fs\.readdirSync\(__dirname\).*?\} catch \(_\) \{\s*return null;') "Cleanup staging must fail closed when the invalidatable plugin cache disappears" Assert-True ((@([regex]::Matches($node, 'require\("([^./][^"]*)"\)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) -join ",") -eq "child_process,crypto,fs,http,net,os,path,readline") "Node bridge must use standard-library modules only" @@ -541,11 +540,11 @@ $coldSmokeJson = & (Get-Command node.exe -ErrorAction Stop).Source (Join-Path $P $coldExitCode = $LASTEXITCODE Assert-True ($coldExitCode -eq 0) "Installed cold-herd matrix and leader-death suite must pass" $coldSmoke = $coldSmokeJson | ConvertFrom-Json -Assert-True ((@($coldSmoke.matrix.clients) -join ",") -eq "30,100,200" -and @($coldSmoke.matrix | Where-Object { $_.manifest -ne 1 -or $_.artifact -ne 1 -or $_.backends -ne 1 }).Count -eq 0) "30/100/200 shipped-command matrices must preserve singleton manifest, artifact, and backend work" +Assert-True ((@($coldSmoke.matrix.clients) -join ",") -eq "30,100,200" -and @($coldSmoke.matrix | Where-Object { $_.manifest -ne 1 -or $_.artifact -ne 1 -or $_.backends -ne 1 -or $_.listeners -ne 0 }).Count -eq 0) "30/100/200 shipped-command matrices must preserve singleton cold work and release the backend listener after the final client" Assert-True ($coldSmoke.powershell_5_1 -and $coldSmoke.pwsh -and $coldSmoke.cleanup -and @($coldSmoke.leader_death).Count -eq 4) "Cold-herd coverage must prove both PowerShell shells, cleanup, and all leader-death phases" Assert-True ($coldSmoke.powershell_fallback.clients -eq 30 -and $coldSmoke.powershell_fallback.preparations -eq 1 -and $coldSmoke.powershell_fallback.backends -eq 1) "Direct PowerShell fallback must elect one cold leader before installed identity and runtime work" $persistentRuntime = @(& (Join-Path $PSScriptRoot "persistent-artifact-runtime-smoke.ps1"))[-1] | ConvertFrom-Json -Assert-True ($persistentRuntime.artifact_waves -eq 2 -and $persistentRuntime.initialize_and_tools_list -eq 2 -and $persistentRuntime.artifact_pid_reused) "Installed artifact-static runtime must survive idle detach and serve a second real MCP bridge wave on the same backend PID" -Assert-True ($persistentRuntime.stale_cleanup_preserved_current -and $persistentRuntime.explicit_cleanup_stopped_exact -and $persistentRuntime.source_last_detach_stopped -and $persistentRuntime.isolated_runtime_ledger_ports) "Cleanup must remain exact, explicit, disposable for source runtimes, and isolated from the main runtime" +Assert-True ($persistentRuntime.installed_waves -eq 2 -and $persistentRuntime.initialize_and_tools_list -eq 3 -and $persistentRuntime.installed_pids_distinct) "Installed command must stop the artifact-static runtime and start a new backend PID for the next wave" +Assert-True ($persistentRuntime.artifact_last_detach_stopped -and $persistentRuntime.listeners_closed -and $persistentRuntime.source_last_detach_stopped -and $persistentRuntime.isolated_runtime_ledger_ports) "Source and installed artifact cleanup must stop their managed listeners and remain isolated from the main runtime" Write-Host "Launcher bootstrap, contract freshness, cold singleton, and lease identity regressions passed."