diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8a655e..cb875b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- Windows 安装预配只把 `openxyos-runtime.zip` **解压一次**到 `%LOCALAPPDATA%\\FreeOS\\openxyos`(先解到同卷暂存再 move),不再额外整包解到 `$INSTDIR\\openxyos` 与 `$INSTDIR\\openxyos-runtime`(这两处只留 README 桩)。解压后拍平并删除残留的嵌套 `openxyos\\openxyos`。Node 崩溃时用 `cmd /c` 把真实 stderr/stdout 落到 `start.out.log` / `start.err.log`;`.install-ready` 只在 livez 通过且 `start.pid` 仍存活时写入。Shell.Application 若声称成功但 start.log 已是 fail-fast / 空日志,改走直接启动或中文失败说明,不再假成功。 - Windows 安装预配不再把 Node 工作目录选到残留的嵌套 `openxyos\\openxyos`:顶层已有 `backend-dist/server.js` 与 `dist/index.html` 时必须用 live 根。嵌套 cwd 会让 `node backend-dist/server.js` 立刻退出且 stdout/stderr 为空,安装空等 90s 后以退出码 12 失败。`start-sidecar.ps1` / 预配 / 桌面 Go / Python 启动路径统一按此选择 cwd;每次启动截断 `start.log`;Shell.Application 若未刷新日志则改走 explorer / Start-Process,Node fail-fast 不再伪装成 livez 超时。 - Windows 安装详情不再把 openXYOS 预配的 UTF-8 Node/PowerShell 控制台(`[Error] POST /api/auth`、`[seed]`、中文 Server/WebSocket 状态)按系统 ANSI/GBK 打成乱码。NSIS 只用 `nsExec::Exec` 等退出码,详情页只显示本地化步骤结果;完整日志写入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log` / `start.log`(UTF-8)。解压前停止并等待旧的 FreeOS openXYOS Node 退出;livez 通过即成功,不把启动期 auth 日志当失败。 - 组织页互生长通道两端都落到可选用状态:推送到 openXYOS 时员工/人才写成列表页能看到的 `active` / `internal` / `available`;从 openXYOS 回流时即使没有技能字段也会登记为同事,技能/插件/MCP 写入 `org-skills` / `org-plugins` / `org-mcps`,并把聊天专家挂到当前用户(修复 `user_id=NULL` 导致「专家」页看不见)。导入后跳到「专家」页,推送/循环后关闭管理抽屉并刷新预览。 diff --git a/desktop/README.md b/desktop/README.md index 74118968..de29d9e0 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,12 +25,16 @@ Same precedence as the FreeOS CLI/server: - Local openXYOS workdir (FE+BE) → `%LOCALAPPDATA%\FreeOS\openxyos` on Windows, else `{home}/openxyos`. During Setup the FreeOS installer `nsExec`s a shipped **provisioner subprocess** (`provision-openxyos.ps1`) that expands - `openxyos-runtime.zip` with `tar.exe` into that live workdir (and keeps - `$INSTDIR\openxyos` as a sealed copy; the `openxyos-runtime` zip and folder - are deleted after a successful provision), starts FE/BE + `openxyos-runtime.zip` **once** with `tar.exe` into that live workdir + (`$INSTDIR\openxyos` and `$INSTDIR\openxyos-runtime` are README stubs only; + the `openxyos-runtime` zip and leftover staging folder are deleted after a + successful provision), starts FE/BE at medium integrity (Node inherits `CORS_ORIGIN` / `NODE_ENV`; stdout/stderr - go to UTF-8 `start.log`), and writes `.install-ready` only after - `http://127.0.0.1:3780/api/health/livez` is healthy. The Setup detail list + go to UTF-8 `start.log` / `start.out.log` / `start.err.log`, with a + `cmd /c` redirect fallback if PowerShell redirect swallows a crash), and + writes `.install-ready` only after + `http://127.0.0.1:3780/api/health/livez` is healthy **and** `start.pid` is + still alive. The Setup detail list shows localized step results only (`nsExec::Exec`, not `ExecToLog`); full PowerShell/Node console stays in `%LOCALAPPDATA%\FreeOS\openxyos\provision.log` and `start.log` so UTF-8 Chinese is not misread as GBK. Before extract it @@ -38,8 +42,10 @@ Same precedence as the FreeOS CLI/server: paths) and waits until that owned process is gone. After extract+heal, Node starts with cwd at the live root when `backend-dist/server.js` and `dist/index.html` exist there (a leftover nested `openxyos\openxyos` tree - must not win — that cwd exits immediately with an empty console). It unpacks into a LocalAppData - temp dir, logs tar stderr to `provision.log`, and treats a non-zero tar as + is removed after heal so it cannot win — that cwd exits immediately with an + empty console). It unpacks into a LocalAppData + temp dir, moves that tree into the live workdir (one extract), logs tar + stderr to `provision.log`, and treats a non-zero tar as success when the layout heals. Transient `[Error] POST /api/auth` / `[seed]` lines in `start.log` are not a provision failure when livez is healthy. If Node dies before livez, the provisioner exits 10 with a `start.log` excerpt @@ -70,8 +76,9 @@ waiting until it is gone. tar stdout/stderr go to UTF-8 `provision.log`, not the NSIS detail list. A non-zero tar is not exit 3 when the layout is complete after heal. It then starts Node at medium integrity (IShellDispatch2 — never High-IL Node from the elevated installer), waits -for livez, and writes `.install-ready` only when healthy. `$INSTDIR\openxyos` -is the sealed install copy. After success the provisioner removes +for livez, and writes `.install-ready` only when healthy and `start.pid` +is still alive. `$INSTDIR\openxyos` and `$INSTDIR\openxyos-runtime` are +README stubs, not full runtime copies. After success the provisioner removes `$INSTDIR\openxyos-runtime.zip` (and similarly named archives) plus the `$INSTDIR\openxyos-runtime` folder, and a sibling `openxyos-runtime` under `%LOCALAPPDATA%\FreeOS` if one was staged there. A failed provision leaves diff --git a/desktop/src/build/stage_openxyos_runtime.py b/desktop/src/build/stage_openxyos_runtime.py index 63149ca8..0dd6fc0d 100644 --- a/desktop/src/build/stage_openxyos_runtime.py +++ b/desktop/src/build/stage_openxyos_runtime.py @@ -161,7 +161,7 @@ def stage(source: Path, dest_zip: Path) -> Path: readme = dest_zip.with_name("openxyos-README.txt") readme.write_text( "FreeOS openXYOS runtime\n" - "Install dir: \\openxyos (FE+BE, read-only)\n" + "Install dir: \\openxyos (README stub only)\n" "Work dir: %LOCALAPPDATA%\\FreeOS\\openxyos or %USERPROFILE%\\.freeos\\openxyos\n" "URL: http://127.0.0.1:3780\n", encoding="utf-8", diff --git a/desktop/src/build/windows/nsis/provision-openxyos.ps1 b/desktop/src/build/windows/nsis/provision-openxyos.ps1 index a0199387..fbf8b7e5 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.ps1 +++ b/desktop/src/build/windows/nsis/provision-openxyos.ps1 @@ -1,7 +1,11 @@ # Install-time openXYOS provisioner (child process of the FreeOS NSIS Setup). # Stop owned FreeOS openXYOS Node (start.pid / live+install paths only), -# extract to a LocalAppData temp dir, heal into $LiveDir, start once at -# medium IL, wait livez, write .install-ready. +# extract the runtime zip ONCE (staging under LocalAppData, then one move +# into $LiveDir). Do not also extract full copies into $InstallDir\openxyos +# or $InstallDir\openxyos-runtime — those stay README stubs only. +# Heal nested openxyos\openxyos into a canonical top-level tree and delete +# the leftover nested payload. Start once at medium IL, wait livez, write +# .install-ready only when livez is healthy AND start.pid is still alive. # Exit 0 only when http://127.0.0.1:3780/api/health/livez is healthy. # Full logs stay UTF-8 in %LOCALAPPDATA%\FreeOS\openxyos\provision.log and # start.log. Host/NSIS detail gets OEM-safe (ACP/GBK) short status only — @@ -195,15 +199,39 @@ function Repair-OpenXYOSLayout { Write-ProvLog "Normalizing nested backend $nestedBe -> $Root" Copy-OpenXYOSTree (Join-Path $Root 'openxyos') $Root } - if (Test-OpenXYOSLayout $Root) { return $true } + if (Test-OpenXYOSLayout $Root) { + Remove-OpenXYOSNestedLeftover $Root + return $true + } $found = Find-OpenXYOSBundleRoot $Root if (-not $found) { return $false } $fullFound = [IO.Path]::GetFullPath($found) $fullRoot = [IO.Path]::GetFullPath($Root) - if ($fullFound -eq $fullRoot) { return $true } + if ($fullFound -eq $fullRoot) { + Remove-OpenXYOSNestedLeftover $Root + return $true + } Write-ProvLog "Normalizing nested layout $found -> $Root" Copy-OpenXYOSTree $found $Root - return (Test-OpenXYOSLayout $Root) + $ok = Test-OpenXYOSLayout $Root + if ($ok) { Remove-OpenXYOSNestedLeftover $Root } + return $ok +} + +# After flatten, leftover openxyos\openxyos still confuses cwd. Drop it +# only when the top-level tree already has dist + backend-dist. +function Remove-OpenXYOSNestedLeftover { + param([string]$Root) + if (-not (Test-OpenXYOSAppReady $Root)) { return } + $nested = Join-Path $Root 'openxyos' + if (-not (Test-Path -LiteralPath $nested)) { return } + $nestedFe = Join-Path $nested 'dist\index.html' + $nestedBe = Join-Path $nested 'backend-dist\server.js' + if (-not ((Test-Path -LiteralPath $nestedFe) -or (Test-Path -LiteralPath $nestedBe))) { + return + } + Write-ProvLog "Removing leftover nested payload $nested" + Remove-Item -LiteralPath $nested -Recurse -Force -ErrorAction SilentlyContinue } function Get-OpenXYOSAppDirs { @@ -341,15 +369,16 @@ function Read-OpenXYOSPidFile { return 0 } -function Test-OpenXYOSNodeAlive { +function Test-OpenXYOSPidAlive { $nid = Read-OpenXYOSPidFile $LiveDir - if ($nid -gt 0) { - $p = Get-Process -Id $nid -ErrorAction SilentlyContinue - if ($p -and -not $p.HasExited) { return $true } - } - $nodes = Get-Process -Name node -ErrorAction SilentlyContinue - if ($nodes) { return $true } - return $false + if ($nid -le 0) { return $false } + $p = Get-Process -Id $nid -ErrorAction SilentlyContinue + return ($p -and -not $p.HasExited) +} + +function Test-OpenXYOSNodeAlive { + if (Test-OpenXYOSPidAlive) { return $true } + return (Test-OpenXYOSOwnNode) } function Get-StartLogStamp { @@ -366,20 +395,35 @@ function Test-StartLogShowsFailFast { return ($text -match 'node exited .+ \(fail-fast\)') } +function Test-StartLogsEmpty { + $out = Join-Path $LiveDir 'start.out.log' + $err = Join-Path $LiveDir 'start.err.log' + $outEmpty = (-not (Test-Path -LiteralPath $out)) -or ((Get-Item -LiteralPath $out).Length -eq 0) + $errEmpty = (-not (Test-Path -LiteralPath $err)) -or ((Get-Item -LiteralPath $err).Length -eq 0) + return ($outEmpty -and $errEmpty) +} + +function Test-StartClaimUnreliable { + if (Test-StartLogShowsFailFast) { return $true } + if (Test-StartLogsEmpty -and -not (Test-OpenXYOSPidAlive) -and -not (Test-OpenXYOSLivez)) { + return $true + } + if (Test-OpenXYOSLivez -and (Test-OpenXYOSPidAlive -or (Test-OpenXYOSOwnNode))) { + return $false + } + if (Test-OpenXYOSPidAlive) { return $false } + return $true +} + function Wait-OpenXYOSStartEvidence { param($BeforeUtc, [int]$Seconds = 12) + # Do not treat a mere start.log rewrite (JWT/layout banner) as success. + # That races Shell.Application "started" against a process that then + # fail-fasts with empty stdout/stderr. for ($i = 0; $i -lt $Seconds; $i++) { - if (Test-OpenXYOSLivez) { return $true } - $nid = Read-OpenXYOSPidFile $LiveDir - if ($nid -gt 0) { - $p = Get-Process -Id $nid -ErrorAction SilentlyContinue - if ($p -and -not $p.HasExited) { return $true } - } - $log = Join-Path $LiveDir 'start.log' - if (Test-Path -LiteralPath $log) { - $stamp = (Get-Item -LiteralPath $log).LastWriteTimeUtc - if ($null -eq $BeforeUtc -or $stamp -gt $BeforeUtc) { return $true } - } + if (Test-OpenXYOSOwnLivez -and (Test-OpenXYOSPidAlive)) { return $true } + if (Test-StartLogShowsFailFast) { return $true } + if (Test-OpenXYOSPidAlive) { return $true } Start-Sleep -Seconds 1 } return $false @@ -390,7 +434,7 @@ function Wait-OpenXYOSLivez { # Success is livez only. Transient Node console (auth probe, seed banner, # websocket status) is startup noise — not a provision failure. for ($i = 0; $i -lt $Seconds; $i++) { - if (Test-OpenXYOSLivez) { return $true } + if (Test-OpenXYOSOwnLivez -and (Test-OpenXYOSPidAlive -or (Test-OpenXYOSOwnNode))) { return $true } if (Test-StartLogShowsFailFast) { Write-ProvLog 'Node exited fail-fast during livez wait (see start.log cwd)' Write-StartLogExcerpt @@ -425,13 +469,14 @@ function Start-OpenXYOSUnelevated { $shell.ShellExecute($pwsh, $arg, $Dir, 'open', 0) Write-ProvLog 'Started FE/BE via Shell.Application (IShellDispatch2 / medium IL)' if (Wait-OpenXYOSStartEvidence $before 12) { - if (Test-StartLogShowsFailFast) { - Write-ProvLog 'start helper ran but Node exited fail-fast' - return $false + if (Test-StartClaimUnreliable) { + Write-ProvLog 'Shell.Application 声称已启动,但 start.log 显示 fail-fast 或日志为空;改走直接启动' + } else { + return $true } - return $true + } else { + Write-ProvLog 'Shell.Application produced no fresh start.pid/livez' } - Write-ProvLog 'Shell.Application produced no fresh start.log/pid/livez' } catch { Write-ProvLog "Shell.Application failed: $($_.Exception.Message)" } @@ -443,13 +488,14 @@ function Start-OpenXYOSUnelevated { Start-Process -FilePath $explorer -ArgumentList "`"$cmd`"" Write-ProvLog 'Started FE/BE via explorer.exe (medium IL fallback)' if (Wait-OpenXYOSStartEvidence $before 12) { - if (Test-StartLogShowsFailFast) { - Write-ProvLog 'explorer start helper ran but Node exited fail-fast' - return $false + if (Test-StartClaimUnreliable) { + Write-ProvLog 'explorer 声称已启动,但 start.log 显示 fail-fast 或日志为空;改走直接启动' + } else { + return $true } - return $true + } else { + Write-ProvLog 'explorer fallback produced no fresh start.pid/livez' } - Write-ProvLog 'explorer fallback produced no fresh start.log/pid/livez' } catch { Write-ProvLog "explorer fallback failed: $($_.Exception.Message)" } @@ -459,13 +505,13 @@ function Start-OpenXYOSUnelevated { Start-Process -FilePath $pwsh -ArgumentList $arg -WorkingDirectory $Dir -WindowStyle Hidden Write-ProvLog 'Started FE/BE via Start-Process (direct powershell fallback)' if (Wait-OpenXYOSStartEvidence $before 12) { - if (Test-StartLogShowsFailFast) { - Write-ProvLog 'direct start helper ran but Node exited fail-fast' + if (Test-StartClaimUnreliable) { + Write-ProvLog '直接启动后 Node fail-fast 或日志为空' return $false } return $true } - Write-ProvLog 'direct Start-Process produced no fresh start.log/pid/livez' + Write-ProvLog 'direct Start-Process produced no fresh start.pid/livez' } catch { Write-ProvLog "direct Start-Process failed: $($_.Exception.Message)" } @@ -694,15 +740,21 @@ function Invoke-TarExtract { } New-Item -ItemType Directory -Force -Path $Dest | Out-Null $null = Repair-OpenXYOSLayout $extractDir + Remove-OpenXYOSNestedLeftover $extractDir $tempOk = Test-OpenXYOSLayout $extractDir if ($tempOk) { Clear-OpenXYOSPayload $Dest - Copy-OpenXYOSTree $extractDir $Dest + Write-ProvLog "moving staging $extractDir -> $Dest (single extract)" + if (-not (Move-OpenXYOSTree $extractDir $Dest)) { + Write-ProvLog "move failed; copying staging into $Dest" + Copy-OpenXYOSTree $extractDir $Dest + } } elseif (Test-Path -LiteralPath $extractDir) { Write-ProvLog "temp extract layout incomplete; merging into $Dest" Copy-OpenXYOSTree $extractDir $Dest } $null = Repair-OpenXYOSLayout $Dest + Remove-OpenXYOSNestedLeftover $Dest $ok = Test-OpenXYOSLayout $Dest if ($ok -and $code -ne 0) { Write-ProvLog "tar returned $code but layout is complete; treating extract as success" @@ -714,6 +766,25 @@ function Invoke-TarExtract { return $ok } +function Move-OpenXYOSTree { + param([string]$Src, [string]$Dest) + if (-not (Test-Path -LiteralPath $Src)) { return $false } + New-Item -ItemType Directory -Force -Path $Dest | Out-Null + try { + Get-ChildItem -LiteralPath $Src -Force -ErrorAction Stop | ForEach-Object { + $target = Join-Path $Dest $_.Name + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop + } + Move-Item -LiteralPath $_.FullName -Destination $target -Force -ErrorAction Stop + } + return $true + } catch { + Write-ProvLog "Move-OpenXYOSTree failed: $($_.Exception.Message)" + return $false + } +} + function Test-SamePath { param([string]$Left, [string]$Right) if (-not $Left -or -not $Right) { return $false } @@ -783,7 +854,21 @@ function Remove-OpenXYOSStaging { } } +function Test-OpenXYOSInstallReady { + if (-not (Test-OpenXYOSLivez)) { return $false } + if (Test-StartLogShowsFailFast -and -not (Test-OpenXYOSPidAlive) -and -not (Test-OpenXYOSOwnNode)) { + return $false + } + if (Test-OpenXYOSPidAlive) { return $true } + return (Test-OpenXYOSOwnNode) +} + function Complete-OpenXYOSSuccess { + if (-not (Test-OpenXYOSInstallReady)) { + Write-ProvLog '拒绝写入 .install-ready:livez 未通过或 start.pid 进程已退出。请查看 start.log。' + Write-StartLogExcerpt + exit 10 + } if (-not (Write-InstallReady $LiveDir)) { exit 13 } @@ -826,28 +911,23 @@ if (-not (Test-Path -LiteralPath $tar)) { Stop-OpenXYOSLockedProcesses if (-not (Invoke-TarExtract -Tar $tar -Zip $ZipPath -Dest $LiveDir)) { - Write-ProvLog 'extract into live dir returned incomplete layout (will try backup heal)' + Write-ProvLog 'extract into live dir returned incomplete layout' } $backupRuntime = Join-Path $InstallDir 'openxyos-runtime' $backupOpen = Join-Path $InstallDir 'openxyos' -foreach ($backup in @($backupRuntime, $backupOpen)) { - if (-not (Test-OpenXYOSLayout $backup)) { - if (-not (Invoke-TarExtract -Tar $tar -Zip $ZipPath -Dest $backup)) { - Write-ProvLog "backup extract skipped/failed: $backup" - } - } -} - +# Single zip extract only (into $LiveDir). $INSTDIR copies stay README stubs +# unless a leftover full tree from a previous install can heal the live dir. if (-not (Test-OpenXYOSLayout $LiveDir)) { foreach ($backup in @($backupRuntime, $backupOpen)) { if (Test-OpenXYOSLayout $backup) { - Write-ProvLog "Healing live dir from $backup" + Write-ProvLog "Healing live dir from leftover $backup (no zip extract)" Copy-OpenXYOSTree $backup $LiveDir break } } $null = Repair-OpenXYOSLayout $LiveDir + Remove-OpenXYOSNestedLeftover $LiveDir } if (-not (Test-OpenXYOSLayout $LiveDir)) { @@ -891,13 +971,21 @@ if (-not (Test-OpenXYOSCompiledSql $LiveDir)) { $readme = @( 'FreeOS local openXYOS environment' "Live workdir (writable): $LiveDir" - "Install copy: $backupOpen" + 'Install dir copies are README stubs only (runtime lives in LocalAppData).' 'URL: http://127.0.0.1:3780' ) -join "`r`n" Set-Content -LiteralPath (Join-Path $LiveDir 'README.txt') -Value $readme -Encoding ASCII -try { - Set-Content -LiteralPath (Join-Path $backupOpen 'README.txt') -Value $readme -Encoding ASCII -} catch { +$stub = @( + 'FreeOS openXYOS install stub (not a runtime copy).' + "Live workdir: $LiveDir" + 'URL: http://127.0.0.1:3780' +) -join "`r`n" +foreach ($stubDir in @($backupOpen, $backupRuntime)) { + try { + New-Item -ItemType Directory -Force -Path $stubDir | Out-Null + Set-Content -LiteralPath (Join-Path $stubDir 'README.txt') -Value $stub -Encoding ASCII + } catch { + } } if ($env:USERNAME) { @@ -915,7 +1003,7 @@ if (Test-OpenXYOSOwnLivez) { } if (-not (Start-OpenXYOSUnelevated $LiveDir)) { - Write-ProvLog 'failed to launch FE/BE at medium integrity' + Write-ProvLog '无法启动 openXYOS:Node 已退出且未留下可用日志。未写入 .install-ready。请查看 start.log 与 provision.log。' Write-StartLogExcerpt exit 10 } diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 81cb9563..04e6cee2 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -7,6 +7,12 @@ # (UTF-8). Do not Write-Host those lines — NSIS detail treats child stdout # as system ANSI (GBK on Chinese Windows) and would show mojibake plus # transient [Error] POST /api/auth / [seed] noise. +# Launch so OS-level redirects flush on crash; if Start-Process redirect +# swallows stdout/stderr, fall back to cmd /c "node ... >log 2>&1". +[CmdletBinding()] +param( + [switch]$CrashSelfTest +) $ErrorActionPreference = 'Continue' $live = Split-Path -Parent $MyInvocation.MyCommand.Path $livez = 'http://127.0.0.1:3780/api/health/livez' @@ -92,14 +98,35 @@ function Repair-OpenXYOSLayout { } elseif ((Test-Path -LiteralPath $nestedBe) -and -not (Test-Path -LiteralPath $flatBe)) { Copy-OpenXYOSTree (Join-Path $Root 'openxyos') $Root } - if (Test-OpenXYOSLayout $Root) { return $true } + if (Test-OpenXYOSLayout $Root) { + Remove-OpenXYOSNestedLeftover $Root + return $true + } $found = Find-OpenXYOSBundleRoot $Root if (-not $found) { return $false } $fullFound = [IO.Path]::GetFullPath($found) $fullRoot = [IO.Path]::GetFullPath($Root) - if ($fullFound -eq $fullRoot) { return $true } + if ($fullFound -eq $fullRoot) { + Remove-OpenXYOSNestedLeftover $Root + return $true + } Copy-OpenXYOSTree $found $Root - return (Test-OpenXYOSLayout $Root) + $ok = Test-OpenXYOSLayout $Root + if ($ok) { Remove-OpenXYOSNestedLeftover $Root } + return $ok +} + +function Remove-OpenXYOSNestedLeftover { + param([string]$Root) + if (-not (Test-OpenXYOSAppReady $Root)) { return } + $nested = Join-Path $Root 'openxyos' + if (-not (Test-Path -LiteralPath $nested)) { return } + $nestedFe = Join-Path $nested 'dist\index.html' + $nestedBe = Join-Path $nested 'backend-dist\server.js' + if (-not ((Test-Path -LiteralPath $nestedFe) -or (Test-Path -LiteralPath $nestedBe))) { + return + } + Remove-Item -LiteralPath $nested -Recurse -Force -ErrorAction SilentlyContinue } function Stop-OpenXYOSNode { @@ -169,7 +196,9 @@ function Reset-OpenXYOSStartLogs { if (Test-IsElevated) { $pwsh = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - $arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" + $crashArg = '' + if ($CrashSelfTest) { $crashArg = ' -CrashSelfTest' } + $arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"$crashArg" $before = $null if (Test-Path -LiteralPath $startLog) { $before = (Get-Item -LiteralPath $startLog).LastWriteTimeUtc @@ -250,7 +279,9 @@ $env:FREEOS_HOME = $freeosHome $env:OCTOP_HOME = $freeosHome $env:FREEOS_ORG_SIDECAR_PORT = '3780' $compiled = Join-Path $app 'backend-dist\server.js' -if (Test-Path -LiteralPath $compiled) { +if ($CrashSelfTest) { + $argv = @('-e', "process.stderr.write('forced-crash-openxyos\n'); process.exit(1)") +} elseif (Test-Path -LiteralPath $compiled) { $argv = @('backend-dist/server.js') } else { $argv = @('--import', 'tsx', 'backend/server.ts') @@ -305,6 +336,20 @@ foreach ($name in @( } } +function Get-OpenXYOSLogText { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return '' } + $item = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $item -or $item.Length -le 0) { return '' } + return ((Get-Content -LiteralPath $Path -Raw -ErrorAction SilentlyContinue) + '') +} + +function Test-OpenXYOSNodeLogsEmpty { + $outText = Get-OpenXYOSLogText $startOut + $errText = Get-OpenXYOSLogText $startErr + return ([string]::IsNullOrWhiteSpace($outText) -and [string]::IsNullOrWhiteSpace($errText)) +} + function Merge-NodeLogs { foreach ($f in @($startOut, $startErr)) { if (Test-Path -LiteralPath $f) { @@ -315,18 +360,80 @@ function Merge-NodeLogs { } } -# Redirect* + -NoNewWindow => UseShellExecute=$false, so $env:* is inherited. -# File redirects keep stdout/stderr after this helper exits. +function Invoke-OpenXYOSCmdCapture { + $cmdExe = Join-Path $env:SystemRoot 'System32\cmd.exe' + $inner = '"{0}" {1} >"{2}" 2>"{3}"' -f $node, $psi.Arguments, $startOut, $startErr + Add-Content -LiteralPath $startLog -Value "cmd /c capture: $inner" -Encoding UTF8 + try { + $cap = Start-Process -FilePath $cmdExe -ArgumentList @('/s', '/c', "`"$inner`"") ` + -WorkingDirectory $app -Wait -PassThru -NoNewWindow + return $cap.ExitCode + } catch { + Add-Content -LiteralPath $startLog -Value "cmd /c capture failed: $($_.Exception.Message)" -Encoding UTF8 + return -1 + } +} + +function Write-OpenXYOSFailFastReason { + param($ExitCode) + Merge-NodeLogs + $reason = "node exited $ExitCode (fail-fast) cwd=$app layout=$layout" + Add-Content -LiteralPath $startLog -Value $reason -Encoding UTF8 + if (Test-OpenXYOSNodeLogsEmpty) { + Add-Content -LiteralPath $startLog -Value 'redirect produced empty stdout/stderr; capturing via cmd /c' -Encoding UTF8 + $null = Invoke-OpenXYOSCmdCapture + Merge-NodeLogs + } + if (Test-OpenXYOSNodeLogsEmpty) { + Add-Content -LiteralPath $startLog -Value "node stdout/stderr still empty after cmd capture; exit=$ExitCode cwd=$app (typical of nested openxyos\openxyos cwd crash)" -Encoding UTF8 + } + if (($app -ne $live) -and (Test-OpenXYOSAppReady $live)) { + Add-Content -LiteralPath $startLog -Value "nested cwd crashed; top-level $live has backend-dist/server.js + dist/index.html" -Encoding UTF8 + } +} + +function Resolve-OpenXYOSNodeChildPid { + param($ParentProc) + if (-not $ParentProc) { return 0 } + for ($i = 0; $i -lt 20; $i++) { + try { + $child = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.ParentProcessId -eq $ParentProc.Id } | + Select-Object -First 1 + if ($child) { return [int]$child.ProcessId } + } catch { + } + if ($ParentProc.HasExited) { break } + Start-Sleep -Milliseconds 100 + } + return 0 +} + +# Prefer cmd /c file redirect so Node crash text is flushed to start.out/err. +# Start-Process -FilePath $node with Redirect* is kept as fallback (UseShellExecute=$false). +$cmdExe = Join-Path $env:SystemRoot 'System32\cmd.exe' +$inner = '"{0}" {1} >"{2}" 2>"{3}"' -f $node, $psi.Arguments, $startOut, $startErr $proc = $null $usedPsi = $false +$usedCmd = $false try { - $proc = Start-Process -FilePath $node -ArgumentList $argv -WorkingDirectory $app ` - -NoNewWindow ` - -RedirectStandardOutput $startOut ` - -RedirectStandardError $startErr ` - -PassThru + $proc = Start-Process -FilePath $cmdExe -ArgumentList @('/s', '/c', "`"$inner`"") ` + -WorkingDirectory $app -NoNewWindow -PassThru + $usedCmd = [bool]$proc } catch { - Add-Content -LiteralPath $startLog -Value "Start-Process failed: $($_.Exception.Message)" -Encoding UTF8 + Add-Content -LiteralPath $startLog -Value "cmd /c start failed: $($_.Exception.Message)" -Encoding UTF8 +} + +if (-not $proc) { + try { + $proc = Start-Process -FilePath $node -ArgumentList $argv -WorkingDirectory $app ` + -NoNewWindow ` + -RedirectStandardOutput $startOut ` + -RedirectStandardError $startErr ` + -PassThru + } catch { + Add-Content -LiteralPath $startLog -Value "Start-Process failed: $($_.Exception.Message)" -Encoding UTF8 + } } if (-not $proc) { @@ -344,29 +451,39 @@ if (-not $proc) { exit 10 } -$proc.Id | Set-Content -LiteralPath $pidFile -Encoding ASCII +$nodePid = 0 +if ($usedCmd) { + $nodePid = Resolve-OpenXYOSNodeChildPid $proc +} +if ($nodePid -le 0) { $nodePid = $proc.Id } +$nodePid | Set-Content -LiteralPath $pidFile -Encoding ASCII + +$waitProc = $proc +if ($nodePid -gt 0 -and $nodePid -ne $proc.Id) { + $childProc = Get-Process -Id $nodePid -ErrorAction SilentlyContinue + if ($childProc) { $waitProc = $childProc } +} # Fast crash (missing CORS_ORIGIN, module, etc.) must not look like a 90s livez timeout. -if ($proc.WaitForExit(5000)) { - Merge-NodeLogs +if ($waitProc.WaitForExit(5000)) { if ($usedPsi) { try { $tailOut = $proc.StandardOutput.ReadToEnd() $tailErr = $proc.StandardError.ReadToEnd() - if ($tailOut) { Add-Content -LiteralPath $startLog -Value $tailOut -Encoding UTF8 } - if ($tailErr) { Add-Content -LiteralPath $startLog -Value $tailErr -Encoding UTF8 } + if ($tailOut) { Add-Content -LiteralPath $startOut -Value $tailOut -Encoding UTF8 } + if ($tailErr) { Add-Content -LiteralPath $startErr -Value $tailErr -Encoding UTF8 } } catch { } } - Add-Content -LiteralPath $startLog -Value "node exited $($proc.ExitCode) (fail-fast) cwd=$app layout=$layout" -Encoding UTF8 - if (-not (Test-Path -LiteralPath $startOut) -and -not (Test-Path -LiteralPath $startErr)) { - Add-Content -LiteralPath $startLog -Value 'node stdout/stderr empty (typical of nested openxyos\openxyos cwd crash)' -Encoding UTF8 - } - if (($app -ne $live) -and (Test-OpenXYOSAppReady $live)) { - Add-Content -LiteralPath $startLog -Value "nested cwd crashed; top-level $live has backend-dist/server.js + dist/index.html" -Encoding UTF8 + $exitCode = $waitProc.ExitCode + Write-OpenXYOSFailFastReason $exitCode + if ($CrashSelfTest) { + $blob = (Get-OpenXYOSLogText $startLog) + (Get-OpenXYOSLogText $startOut) + (Get-OpenXYOSLogText $startErr) + if ($blob -match 'forced-crash-openxyos') { exit 0 } + exit 10 } exit 10 } Merge-NodeLogs -Add-Content -LiteralPath $startLog -Value "node still running pid=$($proc.Id) cwd=$app layout=$layout" -Encoding UTF8 +Add-Content -LiteralPath $startLog -Value "node still running pid=$nodePid cwd=$app layout=$layout" -Encoding UTF8 exit 0 diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index 9e3aba3f..d8eeeeeb 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -150,8 +150,11 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" # parent waits for exit 0. Child stdout is discarded (cmd >nul) so # UTF-8 Node/PowerShell logs do not mojibake in the ANSI detail list. # Localized DetailPrint LangStrings are the only NSIS-visible progress. -# The child extracts with tar.exe, starts FE/BE at medium IL, waits for -# livez, and writes .install-ready only when healthy. Do not FileWrite +# The child extracts the runtime zip ONCE with tar.exe into +# %LOCALAPPDATA%\FreeOS\openxyos (staging then one move). $INSTDIR\openxyos +# and $INSTDIR\openxyos-runtime stay empty README stubs, not full copies. +# It starts FE/BE at medium IL, waits for livez, and writes .install-ready +# only when healthy and start.pid is still alive. Do not FileWrite # goto-label .cmd scripts here. Do not register logon autostart. !macro wails.provisionOpenXYOS !insertmacro wails.userLocalAppData diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 01d16323..bd5886bb 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -2,10 +2,15 @@ from __future__ import annotations +import os import re +import shutil +import subprocess from pathlib import Path from urllib.parse import urlparse +import pytest + REPO = Path(__file__).resolve().parents[3] NSIS = REPO / "desktop" / "src" / "build" / "windows" / "nsis" PROVISION_PS1 = NSIS / "provision-openxyos.ps1" @@ -284,7 +289,8 @@ def test_provisioner_nonzero_tar_with_good_layout_is_not_exit_3() -> None: assert "Repair-OpenXYOSLayout" in extract_body assert "exit 3" not in extract_body main = text[text.index("# --- main ---") :] - assert "will try backup heal" in main + assert "extract into live dir returned incomplete layout" in main + assert "will try backup heal" not in main hard_fail = ( "if (-not (Invoke-TarExtract -Tar $tar -Zip $ZipPath -Dest $LiveDir)) {\n" " Write-ProvLog 'extract into live dir failed'\n" @@ -362,6 +368,132 @@ def test_provisioner_ignores_transient_auth_console_noise() -> None: assert "startup noise" in wait +def test_provisioner_extracts_zip_once_into_livedir() -> None: + """Runtime zip is extracted once into LocalAppData; INSTDIR stays stubs.""" + text = PROVISION_PS1.read_text(encoding="utf-8") + main = text[text.index("# --- main ---") :] + assert main.count("Invoke-TarExtract") == 1 + assert "Dest $LiveDir" in main + assert "Dest $backup" not in main + assert "backup extract" not in main + extract_body = text[ + text.index("function Invoke-TarExtract") : text.index("function Test-SamePath") + ] + assert "& $Tar -xf" in extract_body + assert extract_body.count("& $Tar -xf") == 1 + assert "single extract" in extract_body + assert "Move-OpenXYOSTree" in extract_body + assert "Remove-OpenXYOSNestedLeftover" in extract_body + nsh = (NSIS / "wails_tools.nsh").read_text(encoding="utf-8") + provision = nsh[ + nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.openxyosFailDetail") + ] + assert provision.count('File "/oname=openxyos-runtime.zip"') == 1 + assert "ONCE" in nsh or "once" in nsh + assert "README stubs" in nsh or "README stub" in nsh + assert "Set-Content -LiteralPath (Join-Path $stubDir 'README.txt')" in main + assert "not a runtime copy" in main + + +def test_provisioner_ready_gate_requires_livez_and_pid() -> None: + text = PROVISION_PS1.read_text(encoding="utf-8") + assert "function Test-OpenXYOSInstallReady" in text + assert "function Test-OpenXYOSPidAlive" in text + complete = text[ + text.index("function Complete-OpenXYOSSuccess") : text.index("# --- main ---") + ] + assert "Test-OpenXYOSInstallReady" in complete + assert complete.index("Test-OpenXYOSInstallReady") < complete.index("Write-InstallReady") + assert "拒绝写入 .install-ready" in complete + gate = text[ + text.index("function Test-OpenXYOSInstallReady") : text.index( + "function Complete-OpenXYOSSuccess" + ) + ] + assert "Test-OpenXYOSLivez" in gate + assert "Test-OpenXYOSPidAlive" in gate + assert "Test-StartLogShowsFailFast" in gate + start_fn = text[ + text.index("function Start-OpenXYOSUnelevated") : text.index("function Write-InstallReady") + ] + assert "Test-StartClaimUnreliable" in start_fn + assert "改走直接启动" in start_fn + assert "未写入 .install-ready" in text + + +def test_provisioner_removes_nested_leftover_after_heal() -> None: + text = PROVISION_PS1.read_text(encoding="utf-8") + start = START_PS1.read_text(encoding="utf-8") + for body in (text, start): + assert "function Remove-OpenXYOSNestedLeftover" in body + assert "leftover nested payload" in body or "Remove-OpenXYOSNestedLeftover $Root" in body + repair = body[ + body.index("function Repair-OpenXYOSLayout") : body.index( + "function Remove-OpenXYOSNestedLeftover" + ) + ] + assert "Remove-OpenXYOSNestedLeftover $Root" in repair + assert "Removing leftover nested payload" in text + + +def test_start_sidecar_records_nonempty_fail_fast_reason() -> None: + start = START_PS1.read_text(encoding="utf-8") + assert "cmd.exe" in start + assert "/c" in start + assert '2>"' in start + assert "function Write-OpenXYOSFailFastReason" in start + assert "function Invoke-OpenXYOSCmdCapture" in start + assert "redirect produced empty stdout/stderr; capturing via cmd /c" in start + assert "still empty after cmd capture" in start + assert "CrashSelfTest" in start + assert "forced-crash-openxyos" in start + assert "Start-Process -FilePath $node" in start + assert "$env:CORS_ORIGIN" in start + assert "JWT_SECRET set=" in start + + +@pytest.mark.skipif( + os.name != "nt" or shutil.which("powershell") is None or shutil.which("node") is None, + reason="forced-crash probe needs Windows powershell + node", +) +def test_start_sidecar_forced_crash_writes_reason(tmp_path: Path) -> None: + """On Windows with node+powershell, -CrashSelfTest must leave a non-empty reason.""" + live = tmp_path / "openxyos" + pwsh = shutil.which("powershell") + node = shutil.which("node") + assert pwsh and node + (live / "node").mkdir(parents=True) + shutil.copy(node, live / "node" / "node.exe") + (live / "dist").mkdir() + (live / "dist" / "index.html").write_text("", encoding="utf-8") + (live / "backend-dist").mkdir() + (live / "backend-dist" / "server.js").write_text("/* unused */", encoding="utf-8") + helper = live / "start-sidecar.ps1" + helper.write_text(START_PS1.read_text(encoding="utf-8"), encoding="utf-8") + completed = subprocess.run( + [ + pwsh, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(helper), + "-CrashSelfTest", + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + log = (live / "start.log").read_text(encoding="utf-8", errors="replace") + err = "" + if (live / "start.err.log").is_file(): + err = (live / "start.err.log").read_text(encoding="utf-8", errors="replace") + blob = log + err + assert "fail-fast" in blob or "forced-crash-openxyos" in blob + assert blob.strip(), f"empty start logs, exit={completed.returncode}" + + def test_wrappers_have_no_goto_labels() -> None: for path in (PROVISION_CMD, START_CMD): body = path.read_text(encoding="utf-8")