Fix Windows shell lifecycle recovery / 修复 Windows 壳生命周期恢复 (#10115)

* docs: record Windows shell recovery baseline and acceptance gates

Record the unmodified 1.38.5 portable failure on Windows ARM64 x64 emulation. Describe the shared lifecycle and handoff contract, evidence boundaries, and outstanding native release acceptance.

* fix(desktop): fence shutdown and expose recoverable shell lifecycle

Make resource cleanup independent, prevent late startup and restart results from reviving a quitting shell, and bound final exit after service termination. Keep failure recovery visible on repeated launches, expose a read-only Windows status pipe, and re-resolve the stable launcher after activation. Add deterministic lifecycle, service, protocol, and recovery tests.

* fix(windows): coordinate process recovery across launch and activation

Share verified process handles, same-user installation ownership, default-cancel recovery, and target readiness checks across the launcher, installer activator, and updater. Serialize recovery before activation and recheck occupancy before publishing current.json. Preserve blocked silent-install codes and delay retention until startup succeeds. Add native ownership tests and transactional race coverage; rebuild portable ZIPs without stale version entries.

* fix(windows): verify product metadata before process recovery

Require Reasonix product metadata in addition to same-user process handles and installation layout. Fail closed on access-denied ownership probes. Verify legacy 1.38.5 metadata through the native isolated Windows fixture.

* test(windows): complete startup recovery acceptance harness

Problem: noninteractive recovery outcomes lacked explicit exit-code coverage and the acceptance script could not run in PowerShell because its local home variable shadowed the read-only HOME variable. Root cause: the script used a case-insensitive reserved variable name and the new coordinator path was untested for wrapped blockers. Fix: preserve typed coordinator exit codes and isolate the acceptance data-home variable. Verification: go test ./internal/desktopinstance ./internal/desktoplauncher.

* fix(windows): guard installer handoff against locked executables

Problem: the installer could begin extracting a replacement while a stale shell still held an executable open. Root cause: the NSIS normal-install path lacked the required unlock gate. Fix: probe stable, versioned, launcher, CLI, guard, and portable entry files before payload extraction and fail closed on locked paths. Verification: go test ./desktop/... -run TestWindowsInstallerScriptWaitsBeforeCopyingExecutable.

* fix(windows): enforce installer executable unlock timeout

Problem: the installer unlock probe ignored FileOpen failures and continued extraction. Root cause: each error branch skipped to the next path without recording a blocker. Fix: retry all entry points for 20 seconds, offer interactive retry or cancel, and return 1618 in silent mode when locks remain. Verification: focused Windows installer handoff test.

* fix(desktop): resolve lifecycle typecheck and lint gates

Problem: CI rejected the shell lifecycle wiring with recursive inference errors and new complexity findings. Root cause: mutually-referential construction lacked explicit declarations, while the intentional coordinator extraction was absent from the lint baseline. Fix: add explicit Electron owner types and record the deliberate refactoring baseline. Verification: pnpm --dir desktop/electron typecheck; pnpm --dir desktop/electron test; go run ./tools/repolint.

* chore: refresh lint baseline after main-v2 merge

Carry forward the repository size baseline for files changed by the latest main-v2 integration.

* fix(windows): route recovery commands through hidden process constructors

Problem: Windows CI rejected direct exec.Command calls in the recovery coordinator and Linux lint rejected the Windows-only diagnostics helpers. Root cause: recovery launches bypassed the shared process constructor and the helper file lacked a platform build tag. Fix: use internal/proc.Command for all recovery launches and mark diagnostics Windows-only. Verification: go run ./tools/repolint; cross-compiled Windows test binaries; local diff check.

* chore: normalize lint baseline for main-v2 settings growth

Problem: CI lint on the pull_request merge result failed with
SettingsPanel.tsx file-size 6534 over its 6533 budget.

Root cause: main-v2 landed #10117 (DeepSeek Flash reasoning effort
settings) after this branch refreshed its baseline in 846327385. The
merge result carries that reviewed +1 line, while the branch baseline
still pinned the pre-merge value. This branch does not touch
SettingsPanel.tsx.

Fix: merge main-v2 (35785505c) and re-record the owning baselines.
provider_presets.go (375 -> 325) and provider_presets_test.go
(158 -> 112) shrank because #10117 extracted the Token Rhythm presets;
SettingsPanel.tsx (6533 -> 6534) only acknowledges the merged base
growth.

Verification: go run ./tools/repolint reports clean
(1217 baselined findings).

* fix(windows): fall back to direct launch on shell-less layouts

Problem: TestLauncherDoesNotCreateConsoleWindow failed on Windows CI in
all four legs: the launcher exited 1 and the desktop child probe
reported a visible console window.

Root cause: the new coordinated launch path engaged whenever
current.json existed. The verification then canonicalized the expected
shell image versions/<v>/app/Reasonix.exe, which does not exist in
shell-less versioned layouts (pre-shell releases and the test's minimal
fixture), so filepath.EvalSymlinks failed hard. The coordinated start
also omitted proc.HideConsole, so a console child of the GUI launcher
could create a visible console window.

Fix: gate coordination on installlayout.HasActiveShell so shell-less
layouts keep the pre-existing direct launch path, and add
proc.HideConsole to the coordinated start to match the direct path.

Verification: go test ./internal/installlayout ./internal/desktoplauncher
./internal/desktopinstance; GOOS=windows go vet and test-compile of the
launcher package. The Windows console-window regression test itself runs
on CI.

* ci(windows): exclude build dirs from Defender scanning

Problem: the main-v2 push CI crashed once in reasonix/internal/bot with
'Exception 0xc0000005 0x8 0x0 0x0 PC=0x0' inside the Windows syscall
trampoline (run 34568911109), and the same Windows legs intermittently
report flaky sharing violations on freshly written test files.

Root cause: the DEP fault at PC=0 during CreateFileW matches the known
environmental fault class golang/go#67139 (Defender/EDR hook injection
into Go test binaries on GitHub Windows runners); the std library's
LazyProc resolution provably panics rather than jumping to a null proc,
so the crash is not reachable from repo code, and the identical commit
passed on the next run. Real-time scanning also briefly holds newly
written test files open, racing test readers (ERROR_SHARING_VIOLATION).

Fix: add a non-fatal pwsh step to every Windows job (test, sdk,
windows-control, windows-isolated, desktop-windows) that adds Defender
path exclusions for the workspace, runner/user temp, and the Go build
cache before any build or test step. Failures print a warning and do
not fail the job.

Verification: node scripts/ci-workflow.test.mjs passes (5/5).

* fix(windows): satisfy windows lint gates in shell coordination

Problem: PR lint failed at the golangci-lint GOOS=windows step with ten
findings in the new shell coordination code; earlier runs never reached
it because repolint failed first.

Fix: check cmd.Wait() results in the coordinated start/quit paths using
the repo's go func() { _ = cmd.Wait() }() idiom, wrap the process-user
error with %w (splitting the not-owned case), modernize the read
deadline clamp with max(), explicitly discard CancelIoEx,
WaitForSingleObject, and ReleaseMutex results, and build the confirm
dialog text with strings.Builder.

Verification: GOOS=windows golangci-lint run ./... (pinned v2.12.2)
reports 0 issues; go test ./internal/desktopinstance
./internal/desktoplauncher ./internal/installlayout passes; Windows
cross-compile and vet pass.
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-11 18:04:50 +08:00
1 parent 75727f227b
commit 20c6b86774
38 files changed
+2206 -206

No files matched your search

+97
View File
@@ -98,6 +98,26 @@ jobs:
with:
node-version: "22"
# Defender real-time scanning on GitHub Windows runners intermittently
# crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
# syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
# written test files open, causing flaky sharing violations. Exclude the
# ephemeral build/test locations; failures stay non-fatal warnings.
- name: Exclude build dirs from Defender scanning
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
shell: pwsh
run: |
$paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($p in $paths) {
try {
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
Write-Host "Defender exclusion added: $p"
} catch {
Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
}
}
- name: Install and verify Linux sandbox backend
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: |
@@ -219,6 +239,25 @@ jobs:
with:
node-version: "22"
# Defender real-time scanning on GitHub Windows runners intermittently
# crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
# syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
# written test files open, causing flaky sharing violations. Exclude the
# ephemeral build/test locations; failures stay non-fatal warnings.
- name: Exclude build dirs from Defender scanning
shell: pwsh
run: |
$paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($p in $paths) {
try {
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
Write-Host "Defender exclusion added: $p"
} catch {
Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
}
}
- name: test
if: env.RUN_STEPS == 'true'
timeout-minutes: 10
@@ -244,6 +283,25 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: "22"
# Defender real-time scanning on GitHub Windows runners intermittently
# crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
# syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
# written test files open, causing flaky sharing violations. Exclude the
# ephemeral build/test locations; failures stay non-fatal warnings.
- name: Exclude build dirs from Defender scanning
shell: pwsh
run: |
$paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($p in $paths) {
try {
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
Write-Host "Defender exclusion added: $p"
} catch {
Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
}
}
- name: test
timeout-minutes: 10
env:
@@ -319,6 +377,26 @@ jobs:
with:
go-version-file: sdk/go/go.mod
# Defender real-time scanning on GitHub Windows runners intermittently
# crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
# syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
# written test files open, causing flaky sharing violations. Exclude the
# ephemeral build/test locations; failures stay non-fatal warnings.
- name: Exclude build dirs from Defender scanning
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
shell: pwsh
run: |
$paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($p in $paths) {
try {
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
Write-Host "Defender exclusion added: $p"
} catch {
Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
}
}
- name: gofmt
if: env.RUN_STEPS == 'true'
shell: bash
@@ -684,6 +762,25 @@ jobs:
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
# Defender real-time scanning on GitHub Windows runners intermittently
# crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
# syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
# written test files open, causing flaky sharing violations. Exclude the
# ephemeral build/test locations; failures stay non-fatal warnings.
- name: Exclude build dirs from Defender scanning
shell: pwsh
run: |
$paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($p in $paths) {
try {
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
Write-Host "Defender exclusion added: $p"
} catch {
Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
}
}
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
- name: Build frontend
+17 -2
View File
@@ -19,6 +19,8 @@ import (
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/desktopinstance"
"reasonix/internal/desktoplauncher"
"reasonix/internal/fileutil"
"reasonix/internal/installlayout"
@@ -52,8 +54,11 @@ func run(args []string) int {
activeVersion := strings.TrimSpace(version)
activateStaging := ""
relaunch := true
interactive := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "--interactive-recovery":
interactive = true
case "--install-root":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
@@ -103,9 +108,9 @@ func run(args []string) int {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
if err := activateInstallerStagingWithRecovery(installRoot, activeVersion, activateStaging, interactive); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
return desktopinstance.ExitCode(err)
}
if relaunch {
_ = startLauncher(installRoot)
@@ -251,6 +256,10 @@ func normalizeActiveVersion(activeVersion string) (string, error) {
}
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
return activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoot, false)
}
func activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoot string, interactive bool) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
@@ -298,10 +307,16 @@ func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) er
requiredRootNames = append(requiredRootNames, alias)
}
release, err := desktopinstance.PrepareInstall(installRoot, config.ReasonixHomeDir(), interactive)
if err != nil {
return err
}
defer release()
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
CheckProcesses: func() error { return desktopinstance.CheckInstallVacant(installRoot, config.ReasonixHomeDir()) },
Members: members,
RequiredNames: requiredNames,
RootMembers: rootMembers,
+67 -67
View File
@@ -205,7 +205,6 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"
!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
@@ -394,84 +393,74 @@ Function reasonix.skipFinishPageForUpdate
reasonix_show_finish_page:
FunctionEnd
# Check every stable entry point before extracting a replacement. A running
# shell may have already exited its Go service while still holding one of
# these files open; treating that as an installable state recreates the
# "installed but does not open" failure. Silent installs fail closed.
Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
StrCpy $3 40
reasonix_unlock_check:
StrCpy $2 0
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
IfErrors reasonix_unlock_stable_locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_electron_shell
ClearErrors
Goto reasonix_unlock_versioned
reasonix_unlock_stable_locked:
StrCpy $2 1
reasonix_unlock_versioned:
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
IfErrors reasonix_unlock_versioned_locked
FileClose $1
check_electron_shell:
; The Electron shell executable inside the app tree stays locked while running.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\app\${REASONIX_ELECTRON_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\app\${REASONIX_ELECTRON_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
Goto reasonix_unlock_guard
reasonix_unlock_versioned_locked:
StrCpy $2 1
reasonix_unlock_guard:
FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a
IfErrors locked
IfErrors reasonix_unlock_guard_locked
FileClose $1
check_launcher:
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_cli
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors locked
FileClose $1
check_cli:
IfFileExists "$INSTDIR\${REASONIX_CLI}" 0 check_portable_entry
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
IfErrors locked
FileClose $1
check_portable_entry:
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 done
ClearErrors
Goto reasonix_unlock_launcher
reasonix_unlock_guard_locked:
StrCpy $2 1
reasonix_unlock_launcher:
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors reasonix_unlock_launcher_locked
FileClose $1
Goto reasonix_unlock_cli
reasonix_unlock_launcher_locked:
StrCpy $2 1
reasonix_unlock_cli:
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
IfErrors reasonix_unlock_cli_locked
FileClose $1
Goto reasonix_unlock_portable
reasonix_unlock_cli_locked:
StrCpy $2 1
reasonix_unlock_portable:
FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a
IfErrors locked
IfErrors reasonix_unlock_portable_locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
Goto reasonix_unlock_result
reasonix_unlock_portable_locked:
StrCpy $2 1
reasonix_unlock_result:
StrCmp $2 0 reasonix_unlock_ok
IntOp $3 $3 - 1
IntCmp $3 0 reasonix_unlock_failed reasonix_unlock_retry reasonix_unlock_retry
reasonix_unlock_retry:
Sleep 500
Goto reasonix_unlock_check
reasonix_unlock_failed:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
IfSilent reasonix_unlock_abort reasonix_unlock_prompt
reasonix_unlock_prompt:
MessageBox MB_ICONEXCLAMATION|MB_RETRYCANCEL "Reasonix is still running. Close it and click Retry, or cancel this installation." IDRETRY reasonix_unlock_check
reasonix_unlock_abort:
Abort
reasonix_unlock_ok:
FunctionEnd
Section
!insertmacro reasonix.setShellContext
@@ -483,6 +472,7 @@ Section
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
; The signed activator coordinates all installed versions before committing.
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
@@ -543,12 +533,22 @@ reasonix_normal_install:
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
DetailPrint "Reasonix layout activator output:"
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch'
StrCpy $R7 ""
IfSilent +2 0
StrCpy $R7 "--interactive-recovery"
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch $R7'
Pop $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
StrCmp $0 "1618" 0 +3
SetErrorLevel 1618
Goto reasonix_activation_abort
StrCmp $0 "1602" 0 +3
SetErrorLevel 1602
Goto reasonix_activation_abort
SetErrorLevel 1
reasonix_activation_abort:
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
@@ -5,6 +5,7 @@ package main
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
@@ -77,6 +78,11 @@ func desktopEndpointImage(id string) (string, error) {
}
func notifyHandoffBlocked(installed bool) {
// Background/silent update execution has no interactive recovery owner.
// Its caller records the installed-versus-started result in the update log.
if os.Getenv("REASONIX_INTERACTIVE_RECOVERY") != "1" {
return
}
message := "Reasonix could not finish the update restart. Choose Quit in the old Reasonix window, then open Reasonix again. Your running instance was preserved."
if installed {
message = "The new version is installed, but restart is incomplete. Choose Quit in the old Reasonix window, then open Reasonix again. Your running instance was preserved."
@@ -10,6 +10,7 @@ import (
"time"
"reasonix/desktop/internal/instanceidentity"
"reasonix/internal/desktopinstance"
"reasonix/internal/installlayout"
)
@@ -36,6 +37,9 @@ func verifyDesktopHandoff(installDir string, started bool) error {
if err != nil {
return err
}
if started {
return desktopinstance.VerifyCurrent(installDir, home)
}
}
deadline := handoffNowFn().Add(desktopHandoffTimeout)
for {
@@ -317,6 +317,7 @@ func relaunchPublishedInstall(logger *log.Logger, relaunch, installDir, failVerb
notifyHandoffBlockedFn(true)
return 1
}
_ = installlayout.RetainPreviousVersions(installDir, 0)
return 0
}
@@ -592,5 +593,6 @@ func startRelaunch(relaunch, installDir string) error {
cmd := proc.VisibleCommand(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
cmd.Env = append(os.Environ(), "REASONIX_NONINTERACTIVE=1")
return cmd.Start()
}
+13 -6
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/config"
"reasonix/internal/desktopinstance"
"strings"
"reasonix/desktop/internal/update"
@@ -68,12 +70,18 @@ func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stag
if requestID == "" {
requestID = "helper-" + version
}
release, err := desktopinstance.PrepareInstall(installRoot, config.ReasonixHomeDir(), false)
if err != nil {
return err
}
defer release()
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: members,
RequiredNames: versionNames,
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
CheckProcesses: func() error { return desktopinstance.CheckInstallVacant(installRoot, config.ReasonixHomeDir()) },
Members: members,
RequiredNames: versionNames,
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
@@ -94,7 +102,6 @@ func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stag
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
+2 -2
View File
@@ -21,7 +21,7 @@ export function renderFailurePage(failure: HandshakeFailure, logsPath: string):
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
<title>Reasonix cannot start</title>
<title>${failure.name === "starting" ? "Reasonix is starting" : "Reasonix cannot start"}</title>
<style>
html, body { margin: 0; height: 100%; background: #1a1a2e; color: #f4f4f3; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
main { max-width: 640px; margin: 0 auto; padding: 64px 32px; }
@@ -40,7 +40,7 @@ export function renderFailurePage(failure: HandshakeFailure, logsPath: string):
<div class="code">${escapeHTML(code)}</div>
<pre>${escapeHTML(failure.detail)}</pre>
<div class="actions">
<a class="primary" href="${SHELL_ACTION_PREFIX}restart">Restart service</a>
${failure.name === "starting" ? "" : failure.name === "build_mismatch" || failure.name === "contract_mismatch" ? '<p>Install the full Reasonix package to repair this installation. / 请安装完整包恢复启动,无需清理会话。</p>' : `<a class="primary" href="${SHELL_ACTION_PREFIX}restart">Retry / 重试</a>`}
<a href="${SHELL_ACTION_PREFIX}open-logs">Open logs folder</a>
<a href="${SHELL_ACTION_PREFIX}quit">Quit</a>
</div>
+5 -1
View File
@@ -110,5 +110,9 @@ test("failure page actions ride the reasonix://app/__shell/ prefix and the page
assert.match(html, /Mixed &lt;install&gt;/);
assert.match(html, /digest &quot;a&quot; != &#39;b&#39;/);
assert.match(html, /contract_mismatch \(-32003\)/);
assert.match(html, /reasonix:\/\/app\/__shell\/restart/);
assert.doesNotMatch(html, /reasonix:\/\/app\/__shell\/restart/);
for (const name of ["build_mismatch", "contract_mismatch"]) {
assert.doesNotMatch(renderFailurePage({ code: -32003, name, title: "Mismatch", detail: "Install the complete package" }, "/logs"), /reasonix:\/\/app\/__shell\/restart/);
}
assert.match(renderFailurePage({ code: -1, name: "service_failed", title: "Failed", detail: "Retry" }, "/logs"), /reasonix:\/\/app\/__shell\/restart/);
});
+109 -20
View File
@@ -10,7 +10,7 @@ import { ElectronGuestViewFactory } from "./browser/electronGuestViews.js";
import { GrantRegistry } from "./browser/grants.js";
import { buildBrowserHostCalls } from "./browser/hostCalls.js";
import { browserLayoutInDIP } from "./browser/layout.js";
import { BrowserSurfaceManager, SHARED_PARTITION } from "./browser/surfaceManager.js";
import { BrowserSurfaceManager, SHARED_PARTITION, type BrowserTab } from "./browser/surfaceManager.js";
import { BrowserControlStore, loadBrowserControlBootstrap, type BrowserSession } from "./browserControl.js";
import { BrowserControlHost } from "./browserControlHost.js";
import { loadBuildIdentity } from "./buildIdentity.js";
@@ -18,7 +18,7 @@ import type { CookieSink } from "./chromeImport.js";
import { emptyContract, loadContract, type LoadedContract } from "./contract.js";
import { DialogHost } from "./dialogs.js";
import { renderFailurePage, type ShellAction } from "./failurePage.js";
import { buildHelloParams, describeHandshakeFailure, validateHelloResult, type HelloResult } from "./handshake.js";
import { buildHelloParams, describeHandshakeFailure, validateHelloResult, type HelloResult, type HandshakeFailure } from "./handshake.js";
import { reasonixHome } from "./home.js";
import { buildHostCallTable, dispatchHostCall, type ScreenInfo } from "./hostCalls.js";
import { firstExisting, iconCandidates } from "./icons.js";
@@ -37,6 +37,8 @@ import { TrayHost } from "./tray.js";
import { DEFAULT_GEOMETRY, MainWindow } from "./window.js";
import { AppZoomStore } from "./zoomStore.js";
import { GraphicsSettingsStore, loadGraphicsBootstrap } from "./graphics.js";
import { initialShellStatus, listenShellStatus, QUIT_REQUEST } from "./shellStatus.js";
import { supersededLauncher } from "./recovery.js";
const MAIN_WINDOW_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write", "fullscreen", "notifications"]);
const TAKEOVER_KINDS = new Set<string>(["mousedown", "keydown", "wheel", "touchstart", "pointerdown"]);
@@ -54,6 +56,8 @@ if (home === "") {
app.exit(1);
} else if (!claimShellInstance(app, home, dev)) {
app.quit();
} else if (process.argv.includes(QUIT_REQUEST)) {
app.quit();
} else {
const graphics = loadGraphicsBootstrap(app.getPath("userData"), process.env, process.argv);
if (graphics.shouldDisable) app.disableHardwareAcceleration();
@@ -70,6 +74,14 @@ function bootstrap(dataHome: string): void {
try { log.info(`graphics feature status: ${JSON.stringify(app.getGPUFeatureStatus())}`); } catch (error) { log.warn(`graphics status unavailable: ${errorText(error)}`); }
});
const serviceLog = new RotatingFile(join(logsDir, "service.log"));
let buildVersion = "unknown";
try { buildVersion = loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env).version; } catch { /* Handshake owns the visible metadata error. */ }
const status = initialShellStatus(app.getPath("userData"), buildVersion);
let firstHeartbeat = 0;
const startingPage = { code: null, name: "starting", title: "Reasonix is starting / 正在启动", detail: "Please wait. / 请稍候。" };
let lastFailure: HandshakeFailure = startingPage;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
log.info(`startup ${status.generation}: shell pid=${process.pid} version=${buildVersion}`);
process.on("uncaughtException", (error) => log.error(`uncaught exception: ${errorText(error)}`));
process.on("unhandledRejection", (reason) => log.error(`unhandled rejection: ${errorText(reason)}`));
@@ -96,13 +108,21 @@ function bootstrap(dataHome: string): void {
let domReadyGeneration = "";
const mainWindow = new MainWindow({
let mainWindow: MainWindow;
let browser: BrowserSurfaceManager;
let lifecycle: QuitSequencer;
let guestViews: ElectronGuestViewFactory;
mainWindow = new MainWindow({
isQuitting: () => lifecycle.isQuitting,
preloadPath: join(__dirname, "preload.cjs"),
appURL,
platform: process.platform,
icon: windowIcon,
log,
onRendererLost: (reason) => {
status.healthy = false;
status.rendererVersion = "";
firstHeartbeat = 0;
browser.pauseForRendererLoss(reason);
for (const tab of browser.all()) documents.invalidateTab(tab.id);
},
@@ -125,7 +145,12 @@ function bootstrap(dataHome: string): void {
onCloseAllowed: () => lifecycle.approve(),
onShellAction: (action: ShellAction) => {
if (action === "open-logs") void shell.openPath(logsDir);
else if (action === "restart") void service.restart().catch(() => undefined);
else if (action === "restart") {
if (lifecycle.isQuitting) return;
const launcher = process.platform === "win32" ? supersededLauncher(process.execPath, buildVersion) : undefined;
if (launcher) lifecycle.relaunch(process.argv.slice(1), launcher);
else void service.restart().catch(() => undefined);
}
else lifecycle.approve();
},
zoomStore,
@@ -153,14 +178,14 @@ function bootstrap(dataHome: string): void {
const downloads = new DownloadTracker({
tabForWebContents: (id) => {
const tab = browser.all().find((entry) => entry.view.page.id === id);
const tab = browser.all().find((entry: BrowserTab) => entry.view.page.id === id);
return tab ? { id: tab.id, taskId: tab.taskId } : undefined;
},
defaultDirectory: (taskId) => join(app.getPath("userData"), "downloads", safeDirName(taskId)),
onUpdate: (download) => mainWindow.send(IPC.browserDownload, download),
log,
});
const guestViews = new ElectronGuestViewFactory({
guestViews = new ElectronGuestViewFactory({
window: () => mainWindow.browserWindow,
preloadPath: join(__dirname, "guest-preload.cjs"),
log,
@@ -169,7 +194,7 @@ function bootstrap(dataHome: string): void {
guestSession.on("will-download", (_event, item, contents) => downloads.handleWillDownload(item, contents.id));
},
});
const browser = new BrowserSurfaceManager({
browser = new BrowserSurfaceManager({
views: guestViews,
contentSize: () => mainWindow.contentSize(),
onTakeover: (tab, reason) => void service.hostEvent("browser.takeover", { tabId: tab.id, epoch: tab.epoch, reason }),
@@ -199,13 +224,14 @@ function bootstrap(dataHome: string): void {
});
const dialogs = new DialogHost(dialog, () => mainWindow.browserWindow ?? undefined);
const lifecycle = new QuitSequencer({
lifecycle = new QuitSequencer({
service: {
beforeClose: async (reason) => record(await service.request("desktop/beforeClose", { reason })).prevent === true,
shutdown: () => service.shutdown(),
},
app: {
quit: () => app.quit(),
exit: (code) => app.exit(code),
relaunch: (args: string[], execPath?: string) => {
if (execPath) delete process.env.REASONIX_DESKTOP_SERVICE;
app.relaunch({ args, ...(execPath ? { execPath } : {}) });
@@ -213,12 +239,14 @@ function bootstrap(dataHome: string): void {
},
// Website views go first: a WebContents closing after its window is
// gone is the ordering that left orphaned renderers in the prototype.
onCloseAllowed: () => {
browser.destroyAll();
mainWindow.allowClose();
remote.closeAll();
tray.destroy();
},
onCloseAllowed: () => mainWindow.allowClose(),
cleanup: [
{ name: "browser views", run: () => browser.destroyAll() },
{ name: "remote windows", run: () => remote.closeAll() },
{ name: "main window", run: () => mainWindow.close() },
{ name: "tray", run: () => tray.destroy() },
{ name: "startup deadline", run: () => clearTimeout(startupTimer) },
],
log,
});
@@ -272,9 +300,30 @@ function bootstrap(dataHome: string): void {
home: dataHome,
dev,
}), 10_000)),
onRequest: (method, params) => dispatchHostCall(hostCalls, method, params),
onRequest: (method, params) => {
if (lifecycle.isQuitting) return Promise.reject(new Error("Reasonix is shutting down"));
return dispatchHostCall(hostCalls, method, params);
},
onEvent: (frame) => mainWindow.send(IPC.event, frame),
onState: (state) => {
log.info(`startup ${status.generation}: service=${state.phase} generation=${state.generation}`);
status.service = state.phase;
status.healthy = false;
status.rendererVersion = "";
firstHeartbeat = 0;
if (state.phase === "starting" || state.phase === "restarting") {
status.lifecycle = "starting";
clearTimeout(startupTimer);
startupTimer = setTimeout(() => {
if (lifecycle.isQuitting || status.healthy || status.lifecycle === "failed") return;
lastFailure = { code: null, name: "startup_timeout", title: "Startup incomplete / 启动未完成", detail: "Reasonix did not become ready within 30 seconds. Open logs or retry. / 30 秒内未完成启动,请打开日志或重试。" };
status.lifecycle = "failed";
log.error(`startup ${status.generation}: readiness timeout`);
if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
}, 30_000);
startupTimer.unref();
}
mainWindow.send(IPC.serviceState, state);
grants.observeGeneration(state.generation);
if (state.phase !== "ready") {
@@ -283,7 +332,13 @@ function bootstrap(dataHome: string): void {
}
},
onReady: async (hello: HelloResult) => {
if (lifecycle.isQuitting) return;
status.lifecycle = "ready";
status.servicePID = hello.service.pid;
log.info(`desktop service ready: generation ${hello.runtimeGeneration}, pid ${hello.service.pid}`);
try { await zoomStore.load(); } catch (error) { log.warn(`app zoom initialization failed: ${errorText(error)}`); }
if (lifecycle.isQuitting || service.generation !== hello.runtimeGeneration) return;
mainWindow.prepareApp(hello.window);
// A restarted service starts with the capability on, so the persisted
// switch is replayed before any session can be built.
void service.request("desktop/browserControl", { enabled: browserControl.state().controlEnabled })
@@ -302,7 +357,10 @@ function bootstrap(dataHome: string): void {
if (!mainWindow.reattachApp()) void mainWindow.loadApp();
},
onFailed: (error) => {
if (lifecycle.isQuitting) return;
const failure = describeHandshakeFailure(error);
lastFailure = failure;
status.lifecycle = "failed";
log.error(`desktop service failed: ${failure.name}: ${failure.detail}`);
if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
void mainWindow.showFailure(renderFailurePage(failure, logsDir));
@@ -314,18 +372,35 @@ function bootstrap(dataHome: string): void {
// sequence as the menu so Go snapshots sessions before the process ends.
process.on("SIGTERM", () => lifecycle.requestQuit());
app.on("second-instance", (_event, argv) => {
mainWindow.focusForSecondInstance();
void service.hostEvent("secondInstance", { argv });
if (argv.includes(QUIT_REQUEST)) { lifecycle.requestQuit(); return; }
presentInstance(argv);
});
app.on("activate", () => mainWindow.show("activate"));
function presentInstance(argv: string[] = []): void {
if (lifecycle.isQuitting) return;
if (!app.isReady()) { void app.whenReady().then(() => presentInstance(argv)); return; }
if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
if (!service.ready) void mainWindow.showFailure(renderFailurePage(status.lifecycle === "starting" ? startingPage : lastFailure, logsDir));
mainWindow.focusForSecondInstance();
if (service.ready) void service.hostEvent("secondInstance", { argv });
}
app.on("activate", () => presentInstance());
app.on("before-quit", (event) => {
if (!lifecycle.onBeforeQuit()) event.preventDefault();
});
app.on("window-all-closed", () => {
// Go decides when the process ends; a hidden main window keeps running.
if (!service.ready) lifecycle.requestQuit();
});
const statusServer = process.platform === "win32" ? listenShellStatus(() => ({
...status,
lifecycle: lifecycle.currentPhase === "done" ? "done" : lifecycle.isQuitting ? "quitting" : status.lifecycle,
visible: mainWindow.browserWindow?.isVisible() ?? false,
}), log) : undefined;
app.on("will-quit", () => statusServer?.close());
void app.whenReady().then(() => {
if (lifecycle.isQuitting) return;
mainWindow.create(DEFAULT_GEOMETRY);
void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
if (process.platform === "darwin") {
const dockIcon = firstExisting(icons.window);
if (dockIcon && app.dock) app.dock.setIcon(dockIcon);
@@ -352,7 +427,21 @@ function bootstrap(dataHome: string): void {
ipcMain,
contract,
window: mainWindow,
invoke: (method, args) => service.invoke(method, args),
invoke: async (method, args) => {
const generation = service.generation;
const result = await service.invoke(method, args);
if (generation !== service.generation || lifecycle.isQuitting) return result;
if (method === "Version" && typeof result === "string") status.rendererVersion = result;
if (method === "ReportDesktopWebViewReady") {
if (!firstHeartbeat) firstHeartbeat = Date.now();
else if (Date.now() - firstHeartbeat >= 2000 && status.lifecycle === "ready") {
status.healthy = true;
clearTimeout(startupTimer);
}
if (status.rendererVersion === "") void mainWindow.browserWindow?.webContents.executeJavaScript('window.reasonixDesktop.invoke("Version", [])').catch(() => undefined);
}
return result;
},
serviceState: () => service.current,
clipboard,
graphics,
@@ -101,3 +101,33 @@ test("relaunch waits for shutdown then starts the committed stable launcher", as
assert.deepEqual(log, ["shutdown", "closeAllowed"]);
assert.deepEqual(calls, ["quit", "relaunch:--after-update@/opt/reasonix/reasonix-launcher", "quit", "exit"]);
});
test("cleanup failure cannot skip later cleanup or the final quit deadline", async () => {
const events: string[] = [];
let deadline: (() => void) | undefined;
let q!: QuitSequencer;
q = new QuitSequencer({
service: { beforeClose: async () => false, shutdown: async () => { events.push("stopped"); } },
app: { quit: () => { if (q.onBeforeQuit()) events.push("quit"); }, exit: () => events.push("forced"), relaunch() {} },
onCloseAllowed: () => { throw new Error("window cleanup"); },
cleanup: [{ name: "tray", run: () => { events.push("tray"); } }],
schedule: (run, ms) => { assert.equal(ms, 5000); deadline = run; }, log: silent,
});
q.approve(); await tick();
assert.deepEqual(events, ["stopped", "tray", "quit"]);
deadline?.(); assert.equal(events.at(-1), "forced");
});
test("service termination failure withholds shell exit and permits an exit retry", async () => {
let attempts = 0;
let exits = 0;
let q!: QuitSequencer;
q = new QuitSequencer({
service: { beforeClose: async () => false, shutdown: async () => { if (++attempts === 1) throw new Error("child alive"); } },
app: { quit: () => { if (q.onBeforeQuit()) exits++; }, relaunch() {} },
onCloseAllowed() {}, log: silent,
});
q.approve(); await tick();
assert.equal(exits, 0); assert.equal(q.isQuitting, false);
q.approve(); await tick(); assert.equal(exits, 1);
});
+30 -5
View File
@@ -1,4 +1,5 @@
import { errorText, type Logger } from "./log.js";
import { randomUUID } from "node:crypto";
export type QuitPhase = "idle" | "asking" | "shutting-down" | "done";
@@ -9,6 +10,7 @@ export interface LifecycleService {
export interface LifecycleApp {
quit(): void;
exit?(code: number): void;
relaunch(args: string[], execPath?: string): void;
}
@@ -16,6 +18,8 @@ export interface QuitSequencerDeps {
service: LifecycleService;
app: LifecycleApp;
onCloseAllowed(): void;
cleanup?: Array<{ name: string; run(): void }>;
schedule?: (run: () => void, milliseconds: number) => void;
log: Logger;
}
@@ -26,6 +30,7 @@ export class QuitSequencer {
private approved = false;
private relaunchArgs: string[] | null = null;
private relaunchExecPath: string | undefined;
private attempt = "";
constructor(private readonly deps: QuitSequencerDeps) {}
@@ -33,9 +38,13 @@ export class QuitSequencer {
return this.phase;
}
get isQuitting(): boolean { return this.approved || this.phase === "shutting-down" || this.phase === "done"; }
onBeforeQuit(): boolean {
if (this.phase === "done") return true;
if (this.phase !== "idle") return false;
if (!this.attempt) this.attempt = randomUUID();
this.deps.log.info(`exit ${this.attempt}: ${this.approved ? "shutting-down" : "asking"}`);
if (!this.approved) {
this.phase = "asking";
void this.ask();
@@ -69,7 +78,7 @@ export class QuitSequencer {
this.deps.log.warn(`beforeClose(quit) failed, quitting anyway: ${errorText(error)}`);
}
this.phase = "idle";
if (prevent) return;
if (prevent && !this.approved) { this.deps.log.info(`exit ${this.attempt}: cancelled`); this.attempt = ""; return; }
this.approved = true;
this.deps.app.quit();
}
@@ -78,11 +87,27 @@ export class QuitSequencer {
try {
await this.deps.service.shutdown();
} catch (error) {
this.deps.log.warn(`shutdown failed: ${errorText(error)}`);
this.deps.log.warn(`exit ${this.attempt}: shutdown failed: ${errorText(error)}`);
this.phase = "idle";
this.approved = false;
return;
}
for (const step of [{ name: "close permission", run: () => this.deps.onCloseAllowed() }, ...(this.deps.cleanup ?? [])]) {
try { step.run(); this.deps.log.info(`exit ${this.attempt}: cleanup ${step.name} complete`); } catch (error) { this.deps.log.warn(`exit ${this.attempt}: cleanup ${step.name} failed: ${errorText(error)}`); }
}
this.phase = "done";
this.deps.onCloseAllowed();
if (this.relaunchArgs) this.deps.app.relaunch(this.relaunchArgs, this.relaunchExecPath);
this.deps.app.quit();
this.deps.log.info(`exit ${this.attempt}: resources cleaned; requesting final shell exit`);
if (this.deps.app.exit) {
const schedule = this.deps.schedule ?? ((run, ms) => { setTimeout(run, ms).unref(); });
schedule(() => {
this.deps.log.error("shell exit deadline exceeded after service shutdown");
this.deps.app.exit?.(1);
}, 5000);
}
try {
if (this.relaunchArgs) this.deps.app.relaunch(this.relaunchArgs, this.relaunchExecPath);
} catch (error) {
this.deps.log.error(`relaunch failed: ${errorText(error)}`);
} finally { this.deps.app.quit(); }
}
}
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { supersededLauncher } from "./recovery.js";
test("recovery re-reads current.json and selects only this installation's stable entry", (t) => {
const root = mkdtempSync(join(tmpdir(), "reasonix-recovery-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, "versions", "v1.38.5", "app"), { recursive: true });
const shell = join(root, "versions", "v1.38.5", "app", "Reasonix.exe");
const launcher = join(root, "reasonix-launcher.exe");
writeFileSync(launcher, "fixture");
const activate = (v: string, dir = `versions/${v}`) => writeFileSync(join(root, "current.json"), JSON.stringify({schemaVersion:1,activeVersion:v,activeDir:dir}));
activate("v1.38.5"); assert.equal(supersededLauncher(shell,"v1.38.5"),undefined);
activate("v1.38.7"); assert.equal(supersededLauncher(shell,"v1.38.5"),launcher);
activate("v1.38.7","../other"); assert.equal(supersededLauncher(shell,"v1.38.5"),undefined);
});
+17
View File
@@ -0,0 +1,17 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
// Resolve only the stable entry in this shell's own versioned installation.
// Never relaunch a path supplied by a status client or keep an old service path.
export function supersededLauncher(shellPath: string, version: string): string | undefined {
const versions = dirname(dirname(dirname(shellPath)));
if (basename(versions).toLowerCase() !== "versions") return undefined;
const root = dirname(versions);
try {
const current = JSON.parse(readFileSync(join(root, "current.json"), "utf8"));
if (current.schemaVersion !== 1 || typeof current.activeVersion !== "string" || current.activeVersion === version) return undefined;
if (!/^v[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(current.activeVersion) || current.activeVersion.includes("..") || current.activeDir !== `versions/${current.activeVersion}`) return undefined;
const launcher = join(root, "reasonix-launcher.exe");
return existsSync(launcher) ? launcher : undefined;
} catch { return undefined; }
}
+34 -3
View File
@@ -87,7 +87,7 @@ class FakeChild extends EventEmitter {
}
}
function harness(options: { children?: FakeChild[]; budget?: RestartBudget } = {}) {
function harness(options: { children?: FakeChild[]; budget?: RestartBudget; onState?(state: ServiceState): void } = {}) {
const spawned: FakeChild[] = [];
const states: ServiceState[] = [];
const events: string[] = [];
@@ -113,8 +113,8 @@ function harness(options: { children?: FakeChild[]; budget?: RestartBudget } = {
hello: async (client) => validateHelloResult(await client.request("desktop/hello", {}, 1000)),
onRequest: async () => ({}),
onEvent: (frame) => events.push(`${frame.generation}:${frame.name}`),
onState: (state) => states.push(state),
onReady: (hello: HelloResult, restarted) => ready.push({ generation: hello.runtimeGeneration, restarted }),
onState: (state) => { states.push(state); options.onState?.(state); },
onReady: (hello: HelloResult, restarted) => { ready.push({ generation: hello.runtimeGeneration, restarted }); },
onFailed: (error) => failures.push(error instanceof Error ? error.message : String(error)),
},
);
@@ -200,3 +200,34 @@ test("a service that ignores stdin close is killed after the grace period", asyn
assert.equal(child.alive, false);
assert.equal(h.supervisor.current.phase, "exited");
});
test("shutdown fences a hello completion queued before shutdown", async () => {
const h = harness();
const starting = h.supervisor.start();
const rejected = assert.rejects(starting, /cancelled|exited/);
await h.supervisor.shutdown();
await rejected;
assert.deepEqual(h.ready, []);
assert.deepEqual(h.failures, []);
assert.equal(h.spawned.length, 1);
await assert.rejects(h.supervisor.start(), /shutting down/);
});
test("a destroyed renderer throwing during exit notification cannot prevent shutdown", async () => {
const h = harness({ onState: (state) => { if (state.phase === "exited") throw new Error("Object has been destroyed"); } });
await h.supervisor.start();
await h.supervisor.shutdown();
assert.equal(h.supervisor.current.phase, "exited");
assert.equal(h.spawned[0].alive, false);
});
test("concurrent restarts share one replacement and shutdown prevents its spawn", async () => {
const h = harness();
await h.supervisor.start();
const a = h.supervisor.restart();
const b = h.supervisor.restart();
const rejected = Promise.all([assert.rejects(a, /shutting down/), assert.rejects(b, /shutting down/)]);
await h.supervisor.shutdown();
await rejected;
assert.equal(h.spawned.length, 1);
});
+33 -12
View File
@@ -14,7 +14,7 @@ export interface ServiceHandlers {
onRequest(method: string, params: unknown): Promise<unknown>;
onEvent(frame: EventFrame): void;
onState(state: ServiceState): void;
onReady(hello: HelloResult, restarted: boolean): void;
onReady(hello: HelloResult, restarted: boolean): void | Promise<void>;
onFailed(error: unknown): void;
}
@@ -57,6 +57,9 @@ export class ServiceSupervisor {
private hello: HelloResult | null = null;
private launching: Promise<HelloResult> | null = null;
private stopping = false;
private shutdownPending: Promise<void> | null = null;
private revision = 0;
private restarting: Promise<HelloResult> | null = null;
private readonly budget: RestartBudget;
private readonly spawnFn: SpawnFn;
private readonly now: () => number;
@@ -90,10 +93,14 @@ export class ServiceSupervisor {
}
async restart(): Promise<HelloResult> {
if (this.stopping) throw new Error("desktop service is shutting down");
if (this.launching) return this.launching;
const old = this.session;
if (old?.alive) await this.terminate(old);
return this.begin(true);
if (!this.restarting) this.restarting = (async () => {
const old = this.session;
if (old?.alive) await this.terminate(old);
return this.begin(true);
})().finally(() => { this.restarting = null; });
return this.restarting;
}
async invoke(method: string, args: unknown[]): Promise<unknown> {
@@ -112,8 +119,14 @@ export class ServiceSupervisor {
}
}
async shutdown(): Promise<void> {
shutdown(): Promise<void> {
this.stopping = true;
this.revision++;
if (!this.shutdownPending) this.shutdownPending = this.finishShutdown().finally(() => { this.shutdownPending = null; });
return this.shutdownPending;
}
private async finishShutdown(): Promise<void> {
const session = this.session;
if (!session?.alive) {
this.setState({ phase: "exited", generation: "" });
@@ -131,11 +144,12 @@ export class ServiceSupervisor {
private live(): Session {
const session = this.session;
if (!session?.alive || !session.ready) throw new Error(`desktop service is not running (${this.state.phase})`);
if (this.stopping || !session?.alive || !session.ready) throw new Error(`desktop service is not running (${this.state.phase})`);
return session;
}
private begin(restarted: boolean): Promise<HelloResult> {
if (this.stopping) return Promise.reject(new Error("desktop service is shutting down"));
if (this.launching) return this.launching;
this.launching = this.launch(restarted).finally(() => {
this.launching = null;
@@ -144,23 +158,26 @@ export class ServiceSupervisor {
}
private async launch(restarted: boolean): Promise<HelloResult> {
const revision = ++this.revision;
this.setState({ phase: restarted ? "restarting" : "starting", generation: "" });
const session = this.spawnSession();
let session: Session | null = null;
try {
session = this.spawnSession();
const hello = await this.handlers.hello(session.client);
if (this.stopping || revision !== this.revision) throw new Error("desktop startup cancelled");
session.generation = hello.runtimeGeneration;
await session.client.request("desktop/start", {}, LIFECYCLE_TIMEOUT_MS);
if (!session.alive) throw new Error("desktop service exited during startup");
if (!session.alive || this.stopping || revision !== this.revision) throw new Error("desktop service exited during startup");
session.ready = true;
this.hello = hello;
this.setState({ phase: "ready", generation: hello.runtimeGeneration });
this.handlers.onReady(hello, restarted);
await this.handlers.onReady(hello, restarted);
return hello;
} catch (error) {
if (this.session === session) {
if ((!session || this.session === session) && !this.stopping && revision === this.revision) {
this.setState({ phase: "failed", generation: "", error: errorText(error) });
this.handlers.onFailed(error);
void this.terminate(session);
if (session) void this.terminate(session).catch((error) => this.options.log.error(`service termination failed: ${errorText(error)}`));
}
throw error;
}
@@ -267,10 +284,14 @@ export class ServiceSupervisor {
this.options.log.warn("desktop service did not exit after stdin close; killing it");
session.child.kill("SIGKILL");
await Promise.race([session.exited, delay(1000)]);
if (session.alive) throw new Error("desktop service did not terminate; shell exit withheld");
}
private setState(state: ServiceState): void {
this.state = state;
this.handlers.onState(state);
// A destroyed renderer or failing observer must not turn confirmed service
// exit into a failed shutdown, nor skip the shell's remaining cleanup.
try { this.handlers.onState(state); }
catch (error) { this.options.log.warn(`service state observer failed: ${errorText(error)}`); }
}
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createConnection } from "node:net";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { once } from "node:events";
import { initialShellStatus, listenShellStatus, homeKey } from "./shellStatus.js";
test("status survives a dead service and is bounded to a read-only snapshot", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "shell-status-"));
const address = process.platform === "win32" ? `\\\\.\\pipe\\reasonix-test-${process.pid}` : join(dir, "status.sock");
const status = initialShellStatus("C:\\Users\\Test\\desktop-shell", "v1.38.7");
status.lifecycle = "failed"; status.service = "exited";
const server = listenShellStatus(() => status, { info() {}, warn() {}, error() {} }, address);
t.after(() => { server.close(); rmSync(dir, { recursive: true, force: true }); });
await once(server, "listening");
const socket = createConnection(address);
let text = ""; socket.on("data", (data) => { text += data; });
await once(socket, "end");
assert.deepEqual(JSON.parse(text), status);
assert.equal(homeKey("C:\\Users\\TEST\\desktop-shell"), homeKey("c:/users/test/desktop-shell"));
assert.equal(Object.hasOwn(status, "token"), false);
});
+44
View File
@@ -0,0 +1,44 @@
import { createServer, type Server } from "node:net";
import { createHash, randomUUID } from "node:crypto";
import type { Logger } from "./log.js";
export const QUIT_REQUEST = "--reasonix-lifecycle-request=quit";
export const STATUS_LIMIT = 16 * 1024;
export interface ShellStatus {
schemaVersion: 1;
product: "com.reasonix.desktop";
pid: number;
version: string;
generation: string;
homeKey: string;
lifecycle: "starting" | "ready" | "failed" | "quitting" | "done";
service: string;
servicePID: number;
visible: boolean;
rendererVersion: string;
healthy: boolean;
}
export function homeKey(profile: string): string {
return createHash("sha256").update(profile.replaceAll("/", "\\").toLowerCase()).digest("hex");
}
export function initialShellStatus(profile: string, version: string): ShellStatus {
return { schemaVersion: 1, product: "com.reasonix.desktop", pid: process.pid, version, generation: randomUUID(), homeKey: homeKey(profile), lifecycle: "starting", service: "starting", servicePID: 0, visible: false, rendererVersion: "", healthy: false };
}
export function listenShellStatus(snapshot: () => ShellStatus, log: Logger, address = `\\\\.\\pipe\\reasonix-shell-v1-${process.pid}`): Server {
const server = createServer((socket) => {
socket.on("error", () => undefined);
socket.setTimeout(2000, () => socket.destroy());
try {
const data = JSON.stringify(snapshot()) + "\n";
if (Buffer.byteLength(data) > STATUS_LIMIT) { socket.destroy(); return; }
socket.end(data);
} catch { socket.destroy(); }
});
server.maxConnections = 8;
server.on("error", (error) => log.error(`shell status endpoint: ${error.message}`));
server.listen(address);
return server;
}
+15 -4
View File
@@ -17,6 +17,7 @@ export interface MainWindowDeps {
log: Logger;
onAppDomReady(rendererGeneration: number): void;
onRendererLost?(reason: string): void;
isQuitting?(): boolean;
onCloseRequested(): Promise<boolean>;
onCloseAllowed(): void;
onShellAction(action: ShellAction): void;
@@ -51,6 +52,14 @@ export class MainWindow {
return this.win && !this.win.isDestroyed() ? this.win : null;
}
prepareApp(geometry: HelloWindow): void {
if (this.content === "app" && this.browserWindow) return;
const previous = this.browserWindow;
this.win = null;
this.create(geometry);
previous?.destroy();
}
create(geometry: HelloWindow): void {
if (this.browserWindow) return;
const { deps } = this;
@@ -99,7 +108,7 @@ export class MainWindow {
win.webContents.on("render-process-gone", (_event, details) => {
deps.log.error(`renderer process gone: ${details.reason} (exit code ${details.exitCode})`);
deps.onRendererLost?.(`app renderer ${details.reason}`);
if (this.content === "app" && this.browserWindow) win.webContents.reload();
if (!this.deps.isQuitting?.() && this.content === "app" && this.browserWindow) win.webContents.reload();
});
win.on("close", (event) => {
if (this.closeAllowed) return;
@@ -107,7 +116,7 @@ export class MainWindow {
void this.requestClose();
});
win.on("closed", () => {
this.win = null;
if (this.win === win) this.win = null;
});
}
@@ -149,9 +158,11 @@ export class MainWindow {
try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
} catch (error) {
this.deps.log.error(`failed to load the failure page: ${errorText(error)}`);
// Successful startup replaces the provisional window while its data URL
// may still be loading. That cancellation is not a startup failure.
if (!win.isDestroyed() && this.browserWindow === win) this.deps.log.error(`failed to load the recovery page: ${errorText(error).slice(0, 300)}`);
}
win.show();
if (!win.isDestroyed() && !this.deps.isQuitting?.() && this.content === "failure") win.show();
}
allowClose(): void {
+55
View File
@@ -0,0 +1,55 @@
# Windows startup and update recovery
This change addresses a shell that survives a failed service startup and continues holding Electron's existing data-home singleton. It does not change build identity comparison, the singleton namespace, Studio, conversation storage, or published release bytes.
## Runtime behavior
The shell owns startup, usable, recoverable failure, quitting and final cleanup states. `QuitSequencer` asks the existing business close policy, stops the service, performs each cleanup independently, and requests final Electron exit. A five-second shell exit fallback is armed only after service termination succeeds. A service state observer throwing cannot prevent shutdown. Startup and restart completions are fenced once shutdown starts.
Second launches show the current window, including minimized/tray windows, or recreate the shell recovery page. Build/contract mismatch pages recommend a complete package and do not expose a retry loop. Startup readiness has a 30-second deadline. The existing business close policy still controls normal tray hiding.
On Windows, `\\.\pipe\reasonix-shell-v1-<pid>` returns one read-only JSON status, schema 1, at most 16 KiB. It remains available after the Go service exits. It includes product identity, shell PID/version/generation, hashed canonical profile identity, lifecycle, service PID/state, visibility and renderer health/version. It contains no configuration or session contents. Consumers verify the OS pipe server PID, process user, executable path and process lifetime. Unknown schemas are not evidence of absence.
The internal argument `--reasonix-lifecycle-request=quit` is consumed by the shell before business argument dispatch. It asks the existing owner to exit; it never executes a supplied path.
## Shared process coordination
`internal/desktopinstance` is used by the Windows stable launcher, the installer-contained activator, and the versioned update helper. Coordination locks are scoped by canonical installation directory and Windows user SID, and acquired before the existing activation lock. Windows mutex ownership is bound to the OS thread.
The coordinator identifies the executable's installation layout and same-user process handles. Ordinary launch is scoped to one canonical data home. Installation covers affected processes throughout the installation. Studio and unrelated installation paths are excluded. Another installation owning the same profile blocks handoff. Legacy shells are identified by their Chromium singleton message window; a missing shell pipe does not establish that they exited.
Recovery requests normal exit, waits up to 20 seconds, and requires explicit confirmation to terminate the displayed process identities. Cancellation is the default. Creation time and the held process handle protect against PID reuse; descendants require a live verified parent chain. Processes are re-inspected after the confirmation and after termination. New processes invalidate the previous consent. Forced exit verification has a 10-second limit. Silent execution never grants consent.
Activation reuses the existing `ActivateVersion` transaction and `current.json` schema. A process check runs before file replacement and immediately before pointer publication; a late conflict triggers rollback. New launchers share the coordination lock. Older launchers cannot be made to honor it, so checks and final target readiness remain mandatory.
Readiness requires the target shell executable/version, strict production handshake, a live service in that release, a visible window, a real renderer `Version` call and the existing later frontend heartbeat. Existing legacy windows can be displayed by ordinary double-click without claiming they passed this newer readiness protocol. Update acceptance separately verifies the current target. Activation success followed by startup failure is reported as installed but not started; no automatic downgrade is introduced and retention cleanup is postponed until successful verification.
Local `desktop-shell/logs/recovery.log` uses one approximately 1 MiB file plus one backup. Shell logs include startup/exit attempts, state changes, process IDs and cleanup outcomes. No automatic upload was added.
Silent installer codes: 1602 = user cancelled; 1618 = recovery/conflict/ownership/exit blocker; 1603 = startup failure when returned by the coordinator; 1 = other activation error. The update helper records installed-versus-started results in its log. Its optional interactive notification requires the internal `REASONIX_INTERACTIVE_RECOVERY=1` environment flag; background mode does not show a blocking dialog.
## Validation record (2026-09-11)
Official immutable baseline: `desktop-v1.38.5`, `Reasonix-windows-amd64.zip`, SHA-256 `9c9be1e44a0d8e8b7511eba43f8b74f34ba80221b6d9b832a7cead30e4a4f132`.
On Windows 11 build 26200.9445 ARM64 (Parallels), the unmodified x64 portable baseline was launched through `Reasonix.exe` with an isolated data home. Its production handshake reported `build_mismatch` (`1.38.5` versus `v1.38.5`). Normal window close removed the failure window while leaving the shell alive. Another launch reached that same owner, which logged `secondInstance` with an exited service. This reproduces the reported failure shape without fault injection. It is x64 emulation on ARM64, not x64-native acceptance. The precise internal exception/reentrancy path in the old binary is not established by these observations.
The new native coordinator identified the legacy shell's isolated profile through its message window. Windows ARM64 tests exercised actual process handles, rejection of a changed creation time before termination, and mutex serialization/release. Electron deterministic tests cover dead-service exit, cleanup failure, shutdown/restart races and state observer exceptions. Activation tests cover a process appearing before pointer commit.
Local candidate `v1.38.7-recovery.1` (unsigned, production identity, unmodified assembled portable package) started through its stable launcher on Windows ARM64; the launcher returned 0 after shell/service/frontend readiness verification. This is development acceptance evidence, not acceptance of a signed release artifact.
| Target / gate | Status |
| --- | --- |
| Windows ARM64 native process tests | Passed; detailed latest results recorded during implementation |
| Windows ARM64 initial portable candidate startup | Passed |
| Windows ARM64 final installer/recovery/exit matrix | Pending |
| Windows x64 native package matrix | Unverified: no x64-native runner in this local session |
| macOS package exit/tray/failure recovery | Pending |
| Linux native package exit/tray/failure recovery | Unverified: no Linux native runner in this local session |
| Authenticode and final signed package acceptance | Pending existing release pipeline; no release performed |
Private user videos/logs are not committed. Tests use synthetic cases and isolated locally created profiles. Forced test cleanup is never counted as normal-exit success. Do not label the complete incident fixed until the outstanding package matrix is accepted.
## Release note draft
Windows startup and installation can detect and recover a leftover Reasonix process from an older version. Recovery preserves configuration and does not require uninstalling Studio. Ending an unresponsive old process requires confirmation because unsaved work may be lost. Ship in a subsequent patch release; do not overwrite 1.38.5 or 1.38.6.
+27
View File
@@ -0,0 +1,27 @@
# Windows 启动、退出与升级恢复
本次处理服务退出后 Electron 壳残留并持续持有原数据目录单实例锁的问题。保持严格构建握手、单实例身份、Studio 共存、配置和会话格式不变,不修改已发布包。
## 实现约定
- `QuitSequencer` 统一退出检查、服务终止、逐项资源清理及最终退出。清理异常不跳过后续步骤;仅在服务已结束后设置 5 秒壳退出兜底。启动、重启异步完成结果在退出时失效。
- 再次启动恢复现有窗口或重建壳恢复页。正常托盘隐藏仍由业务关闭策略决定。构建不匹配提示安装完整包,不提供无效重试。启动就绪期限为 30 秒。
- 壳拥有按 PID 命名的本机管道,返回 schema 1、单条最多 16 KiB 的只读状态。Go 服务死亡后仍可识别壳;消费者核对实际管道服务端 PID、用户、可执行路径和进程句柄。未知协议不能当作进程不存在。
- 根模块 `internal/desktopinstance` 为稳定启动器、安装包内激活器、版本化更新助手提供共享协调。按规范化安装目录与用户 SID 加锁,先协调锁、后激活锁。普通启动限定当前数据目录;安装检查同一安装的受影响实例。跨安装同数据目录冲突只报告,不跨目录结束进程。
- 先正常退出并等待 20 秒,再由用户确认是否结束列出的旧进程,默认取消。用句柄与创建时间防止 PID 复用;确认期间新增进程需要重新检查。结束后最多等待 10 秒,并重新检查占用。静默模式不自动确认、不弹出恢复对话框。
- 激活沿用 `ActivateVersion` 与 `current.json` 原子事务,在文件替换前和提交指针前复查进程。失败时保留原指针;已安装但启动失败单独报告,不自动降级、不提前清理回退材料。
- 就绪验证覆盖实际目标壳、严格握手、同发行目录的存活服务、可见窗口、渲染器调用 `Version` 和后续健康心跳。普通双击可以显示健康状态无法验证的旧窗口;更新验收仍要求目标版本严格就绪。
本机恢复日志位于 `desktop-shell/logs/recovery.log`,约 1 MiB 加一份轮转备份,不自动上传。静默安装返回 1602(取消)、1618(恢复/归属/冲突/退出阻塞),其他激活错误为 1。自动更新日志区分安装完成与启动完成;后台模式不显示阻塞通知。详细协议和实现边界见 [英文实现说明](WINDOWS_STARTUP_RECOVERY.md)。
## 已得到的原生证据
2026-09-11,在 Windows 11 26200.9445 ARM64 虚拟机,通过原便携启动器运行未修改的官方 `desktop-v1.38.5` x64 包。严格握手出现 `1.38.5` 与 `v1.38.5` 不匹配;正常关闭错误窗口后,窗口消失但壳仍存活,再次启动向旧壳投递 `secondInstance`,服务状态为 exited。已复现用户报告的故障形态,未注入故障。此结果属于 ARM64 上的 x64 兼容运行,不能记为 x64 原生验收,也尚未证明旧二进制内部具体哪条异常路径导致残留。
官方基线 SHA-256:`9c9be1e44a0d8e8b7511eba43f8b74f34ba80221b6d9b832a7cead30e4a4f132`。新版协调模块已在原生环境正确识别该旧壳的数据目录。实际进程句柄、创建时间防护与互斥锁测试已通过。初始本地未签名候选 `v1.38.7-recovery.1` 从稳定便携启动器启动并通过目标版本就绪验证。
最终 Windows ARM64 安装/恢复/正常退出矩阵、Windows x64 原生、macOS/Linux 包回归及签名发行包验收仍需完成。当前不能宣称整个问题已经完成发行验收。具体平台状态见英文说明中的验证表。用户提供的视频、日志不进入仓库,强制清理测试进程不计为正常退出通过。
## 后续补丁版本发布说明草案
Windows 启动与安装现在可以识别并恢复旧版 Reasonix 残留进程,无需清理配置或卸载 Studio。结束未响应进程前会请求确认,因为可能丢失未保存内容。使用后续补丁版本交付,不覆盖 1.38.5/1.38.6;正式发布仍单独走既有流程。
@@ -0,0 +1,391 @@
//go:build windows
package desktopinstance
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/installlayout"
"reasonix/internal/proc"
)
const gracefulTimeout = 20 * time.Second
const terminateTimeout = 10 * time.Second
const startupTimeout = 30 * time.Second
func closeProcesses(list []*process) {
for _, p := range list {
p.close()
}
}
func aliveProcesses(list []*process) []*process {
var alive []*process
for _, p := range list {
if p.alive() {
alive = append(alive, p)
}
}
return alive
}
func waitProcesses(list []*process, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for len(aliveProcesses(list)) > 0 {
if time.Now().After(deadline) {
return false
}
time.Sleep(100 * time.Millisecond)
}
return true
}
func requestQuit(p *process, home string) {
if !p.alive() {
return
}
if p.status == nil {
closeWindows(p)
return
}
cmd := proc.Command(p.image, QuitRequest)
cmd.Env = withHome(os.Environ(), home)
if cmd.Start() == nil {
go func() { _ = cmd.Wait() }()
}
}
func withHome(env []string, home string) []string {
out := make([]string, 0, len(env)+1)
for _, e := range env {
key, _, _ := strings.Cut(e, "=")
if !strings.EqualFold(key, "REASONIX_HOME") && !strings.EqualFold(key, "REASONIX_DESKTOP_SERVICE") {
out = append(out, e)
}
}
return append(out, "REASONIX_HOME="+home)
}
func recoverProcesses(root, profile, home string, list []*process, all, interactive bool) error {
defer closeProcesses(list)
if len(list) == 0 {
return nil
}
// A whole-install operation may include services and renderers. Only shells
// with a known data home can receive a targeted lifecycle request.
for _, p := range list {
if p.status != nil && p.status.HomeKey == ProfileKey(profile) {
requestQuit(p, home)
} else if ImageRole(root, p.image) == "shell" {
closeWindows(p)
}
}
if waitProcesses(list, gracefulTimeout) {
return requireVacant(root, profile, all)
}
if !interactive {
return outcome(ConfirmationRequired, "old Reasonix processes have not exited; interactive recovery is required")
}
fresh, err := inspect(root, profile, all)
if err != nil {
return err
}
defer closeProcesses(fresh)
for _, p := range fresh {
matched := false
for _, old := range list {
if old.pid == p.pid && old.created == p.created {
matched = true
break
}
}
if !matched {
return outcome(ConfirmationRequired, "another process appeared during recovery; retry to inspect it")
}
}
live := aliveProcesses(list)
// Include only verified descendants, not unrelated instances sharing an exe.
if !all {
children, err := inspect(root, profile, true)
if err != nil {
return err
}
defer closeProcesses(children)
ids := map[uint32]*process{}
for _, p := range live {
ids[p.pid] = p
}
for changed := true; changed; {
changed = false
for _, p := range children {
parent := ids[p.parent]
if ids[p.pid] == nil && parent != nil && parent.alive() && parent.created.Nanoseconds() <= p.created.Nanoseconds() {
ids[p.pid] = p
live = append(live, p)
changed = true
}
}
}
}
if len(live) == 0 {
return nil
}
if !confirmProcesses(live) {
return outcome(Cancelled, "recovery cancelled; existing processes were preserved")
}
// Consent covers the displayed identities only, including when the dialog
// remains open while an old launcher starts another instance.
latest, err := inspect(root, profile, all)
if err != nil {
return err
}
defer closeProcesses(latest)
for _, p := range latest {
matched := false
for _, approved := range live {
if p.pid == approved.pid && p.created == approved.created {
matched = true
break
}
}
if !matched {
return outcome(ConfirmationRequired, "processes changed after confirmation; retry recovery")
}
}
for _, p := range live {
if err := p.terminate(); err != nil {
return outcome(UnknownOwner, "could not end verified process %d: %v", p.pid, err)
}
}
if !waitProcesses(live, terminateTimeout) {
return outcome(ExitTimeout, "old Reasonix processes did not exit")
}
return requireVacant(root, profile, all)
}
func requireVacant(root, profile string, all bool) error {
list, err := inspect(root, profile, all)
if err != nil {
return err
}
defer closeProcesses(list)
if len(list) != 0 {
return outcome(ConfirmationRequired, "another instance appeared during handoff; activation was stopped")
}
return nil
}
// CheckInstallVacant must be called while holding PrepareInstall's lease.
func CheckInstallVacant(root, home string) error {
root, profile, err := preparePaths(root, home)
if err != nil {
return err
}
return requireVacant(root, profile, true)
}
// PrepareInstall holds process coordination until the caller commits or aborts.
func PrepareInstall(root, home string, interactive bool) (release func(), resultErr error) {
finish := attemptLog(home, "prepare-install", root)
defer func() { finish(resultErr) }()
root, profile, err := preparePaths(root, home)
if err != nil {
return nil, err
}
unlock, err := lockInstall(root)
if err != nil {
return nil, err
}
list, err := inspect(root, profile, true)
if err == nil {
err = recoverProcesses(root, profile, home, list, true, interactive)
}
if err != nil {
unlock()
return nil, err
}
return unlock, nil
}
func target(root string) (string, string, error) {
current, err := installlayout.ReadCurrent(root)
if err != nil {
return "", "", err
}
desktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
return "", "", err
}
return filepath.Join(filepath.Dir(desktop), "app", "Reasonix.exe"), current.ActiveVersion, nil
}
func verify(root, profile, expected string) error {
expectedImage := ""
if expected != "" {
var err error
expectedImage, _, err = target(root)
if err != nil {
return err
}
expectedImage, err = canonical(expectedImage)
if err != nil {
return err
}
}
deadline := time.Now().Add(startupTimeout)
for {
list, err := inspect(root, profile, false)
if err != nil {
return err
}
ready := false
failed := false
for _, p := range list {
if p.status != nil {
if p.status.Ready(expected) && (expectedImage == "" || strings.EqualFold(p.image, expectedImage)) {
child, childErr := openProcess(p.status.ServicePID, 0)
if childErr == nil {
// A status response alone cannot prove that its advertised
// service is still alive or belongs to this release.
serviceImage := filepath.Join(filepath.Dir(p.image), "resources", "service", "reasonix-desktop.exe")
entryImage := filepath.Join(filepath.Dir(filepath.Dir(p.image)), "reasonix-desktop.exe")
ready = ready || strings.EqualFold(child.image, serviceImage) || strings.EqualFold(child.image, entryImage)
child.close()
}
}
failed = failed || p.status.Lifecycle == "failed"
}
}
closeProcesses(list)
if ready {
return nil
}
if failed {
return outcome(StartupFailed, "Reasonix startup failed; use the recovery window or desktop-shell/logs")
}
if time.Now().After(deadline) {
return outcome(StartupFailed, "startup was not verified within 30 seconds; inspect desktop-shell/logs")
}
time.Sleep(200 * time.Millisecond)
}
}
// LaunchAndVerify serializes handoff and never treats launcher exit as readiness.
func LaunchAndVerify(root, home string, interactive bool, start func() error, args ...string) (resultErr error) {
finish := attemptLog(home, "launch", root)
defer func() { finish(resultErr) }()
root, profile, err := preparePaths(root, home)
if err != nil {
return err
}
unlock, err := lockInstall(root)
if err != nil {
return err
}
defer unlock()
list, err := inspect(root, profile, false)
if err != nil {
return err
}
for _, p := range list {
writeRecoveryLog(home, fmt.Sprintf("inspect pid=%d created=%d image=%q status=%+v", p.pid, p.created.Nanoseconds(), p.image, p.status))
}
for _, p := range list {
if p.status == nil && p.legacyProfile != "" && focusLegacyWindow(p) {
// A legacy instance cannot prove readiness. Showing its existing
// window handles an ordinary double-click without interrupting work;
// update acceptance still uses VerifyCurrent's strict checks.
writeRecoveryLog(home, "existing legacy window displayed; health remains unverified")
closeProcesses(list)
return nil
}
}
for _, p := range list {
if p.status != nil && p.status.Lifecycle == "failed" {
// A responsive failed shell owns its recovery UI and may still hold
// an unsaved renderer. Let the user choose retry/exit there.
cmd := proc.Command(p.image, args...)
cmd.Env = withHome(os.Environ(), home)
err := cmd.Start()
if err == nil {
go func() { _ = cmd.Wait() }()
}
closeProcesses(list)
writeRecoveryLog(home, "existing recovery window requested; health remains unverified")
return err
}
if p.status != nil && p.status.Lifecycle == "ready" && p.status.Service == "ready" {
// Launch the running shell itself, preserving its existing service and drafts.
cmd := proc.Command(p.image, args...)
cmd.Env = withHome(os.Environ(), home)
err := cmd.Start()
if err == nil {
go func() { _ = cmd.Wait() }()
}
closeProcesses(list)
if err != nil {
return err
}
return verify(root, profile, "")
}
}
waiting := len(list) > 0
for _, p := range list {
if p.status == nil || (p.status.Lifecycle != "starting" && p.status.Lifecycle != "quitting" && p.status.Lifecycle != "done") {
waiting = false
}
}
if waiting {
closeProcesses(list)
deadline := time.Now().Add(startupTimeout)
for {
list, err = inspect(root, profile, false)
if err != nil {
return err
}
if len(list) == 0 {
break
}
ready := false
for _, p := range list {
ready = ready || (p.status != nil && p.status.Ready(""))
}
if ready {
closeProcesses(list)
return nil
}
if time.Now().After(deadline) {
closeProcesses(list)
return outcome(StartupFailed, "the existing instance is still starting or exiting")
}
closeProcesses(list)
time.Sleep(200 * time.Millisecond)
}
}
if err := recoverProcesses(root, profile, home, list, false, interactive); err != nil {
return err
}
_, version, err := target(root)
if err != nil {
return fmt.Errorf("resolve current release: %w", err)
}
if err := start(); err != nil {
return err
}
return verify(root, profile, version)
}
// VerifyCurrent is used after activation when the stable launcher owns recovery.
func VerifyCurrent(root, home string) error {
root, profile, err := preparePaths(root, home)
if err != nil {
return err
}
_, version, err := target(root)
if err != nil {
return err
}
return verify(root, profile, version)
}
+47
View File
@@ -0,0 +1,47 @@
//go:build windows
package desktopinstance
import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
"time"
)
// Diagnostics stay on this computer and contain no business configuration.
// Each recovery log and its single backup are bounded to approximately 1 MiB.
func attemptLog(home, action, root string) func(error) {
id := fmt.Sprintf("%x", randomID())
writeRecoveryLog(home, fmt.Sprintf("attempt=%s action=%s root=%q phase=begin", id, action, root))
return func(err error) {
result := "completed"
if err != nil {
result = err.Error()
}
writeRecoveryLog(home, fmt.Sprintf("attempt=%s action=%s result=%q", id, action, result))
}
}
func randomID() []byte { b := make([]byte, 16); _, _ = rand.Read(b); return b }
func writeRecoveryLog(home, message string) {
path := filepath.Join(home, "desktop-shell", "logs", "recovery.log")
if os.MkdirAll(filepath.Dir(path), 0700) != nil {
return
}
if info, err := os.Stat(path); err == nil && info.Size() >= 1024*1024 {
_ = os.Remove(path + ".1")
_ = os.Rename(path, path+".1")
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return
}
defer f.Close()
if len(message) > 16*1024 {
message = message[:16*1024]
}
_, _ = fmt.Fprintf(f, "%s %s\n", time.Now().UTC().Format(time.RFC3339Nano), message)
}
+432
View File
@@ -0,0 +1,432 @@
//go:build windows
package desktopinstance
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var user32 = windows.NewLazySystemDLL("user32.dll")
type process struct {
pid uint32
parent uint32
handle windows.Handle
image string
created windows.Filetime
status *Status
legacyProfile string
}
func canonical(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", err
}
return filepath.Clean(resolved), nil
}
func sameUser(handle windows.Handle) (bool, error) {
var token windows.Token
if err := windows.OpenProcessToken(handle, windows.TOKEN_QUERY, &token); err != nil {
return false, err
}
defer token.Close()
theirs, err := token.GetTokenUser()
if err != nil {
return false, err
}
ours, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return false, err
}
return theirs.User.Sid.Equals(ours.User.Sid), nil
}
func openProcess(pid, parent uint32) (*process, error) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, pid)
if err != nil {
return nil, err
}
p := &process{pid: pid, parent: parent, handle: h}
ok := false
defer func() {
if !ok {
windows.CloseHandle(h)
}
}()
own, err := sameUser(h)
if err != nil {
return nil, fmt.Errorf("cannot verify process user: %w", err)
}
if !own {
return nil, errors.New("cannot verify process user")
}
buffer := make([]uint16, 32768)
size := uint32(len(buffer))
if err := windows.QueryFullProcessImageName(h, 0, &buffer[0], &size); err != nil {
return nil, err
}
p.image, err = canonical(windows.UTF16ToString(buffer[:size]))
if err != nil {
return nil, err
}
var exited, kernel, user windows.Filetime
if err := windows.GetProcessTimes(h, &p.created, &exited, &kernel, &user); err != nil {
return nil, err
}
if !p.alive() {
return nil, errors.New("process exited during inspection")
}
ok = true
return p, nil
}
func (p *process) alive() bool {
result, err := windows.WaitForSingleObject(p.handle, 0)
return err == nil && result == uint32(windows.WAIT_TIMEOUT)
}
func (p *process) close() { windows.CloseHandle(p.handle) }
func ordinaryProduct(image string) bool {
size, err := windows.GetFileVersionInfoSize(image, nil)
if err != nil || size == 0 || size > 1024*1024 {
return false
}
data := make([]byte, size)
if windows.GetFileVersionInfo(image, 0, size, unsafe.Pointer(&data[0])) != nil {
return false
}
var translations *uint16
var count uint32
if windows.VerQueryValue(unsafe.Pointer(&data[0]), `\VarFileInfo\Translation`, unsafe.Pointer(&translations), &count) != nil || count < 4 {
return false
}
values := unsafe.Slice(translations, int(count/2))
for i := 0; i+1 < len(values); i += 2 {
var value *uint16
var length uint32
key := fmt.Sprintf(`\StringFileInfo\%04x%04x\ProductName`, values[i], values[i+1])
if windows.VerQueryValue(unsafe.Pointer(&data[0]), key, unsafe.Pointer(&value), &length) == nil && value != nil && length > 0 {
name := windows.UTF16ToString(unsafe.Slice(value, int(length)))
runtime.KeepAlive(data)
return name == "Reasonix"
}
}
return false
}
func (p *process) terminate() error {
if !p.alive() {
return nil
}
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, p.pid)
if err != nil {
return err
}
defer windows.CloseHandle(h)
var created, exited, kernel, user windows.Filetime
if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil {
return err
}
if created != p.created || !p.alive() {
return outcome(UnknownOwner, "process identity changed")
}
return windows.TerminateProcess(h, 1)
}
func readStatus(p *process) (Status, error) {
var zero Status
name, _ := windows.UTF16PtrFromString(fmt.Sprintf(`\\.\pipe\reasonix-shell-v1-%d`, p.pid))
pipe, err := windows.CreateFile(name, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0)
connectDeadline := time.Now().Add(2 * time.Second)
for errors.Is(err, windows.ERROR_PIPE_BUSY) && time.Now().Before(connectDeadline) {
time.Sleep(20 * time.Millisecond)
pipe, err = windows.CreateFile(name, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0)
}
if err != nil {
return zero, err
}
defer windows.CloseHandle(pipe)
var owner uint32
if err := windows.GetNamedPipeServerProcessId(pipe, &owner); err != nil {
return zero, err
}
if owner != p.pid || !p.alive() {
return zero, outcome(UnknownOwner, "status pipe owner changed")
}
event, err := windows.CreateEvent(nil, 1, 0, nil)
if err != nil {
return zero, err
}
defer windows.CloseHandle(event)
data := make([]byte, 0, StatusLimit+1)
deadline := time.Now().Add(2 * time.Second)
for len(data) <= StatusLimit {
buffer := make([]byte, StatusLimit+1-len(data))
var n uint32
if err := windows.ResetEvent(event); err != nil {
return zero, err
}
ov := windows.Overlapped{HEvent: event}
err = windows.ReadFile(pipe, buffer, &n, &ov)
if errors.Is(err, windows.ERROR_IO_PENDING) {
remaining := max(time.Until(deadline), 0)
wait, waitErr := windows.WaitForSingleObject(event, uint32(remaining.Milliseconds()))
if waitErr != nil || wait != windows.WAIT_OBJECT_0 {
_ = windows.CancelIoEx(pipe, &ov)
_, _ = windows.WaitForSingleObject(event, windows.INFINITE)
return zero, errors.New("shell status read timeout")
}
err = windows.GetOverlappedResult(pipe, &ov, &n, false)
}
if n > 0 {
data = append(data, buffer[:n]...)
}
if bytes.Contains(data, []byte{'\n'}) {
break
}
if err != nil {
return zero, err
}
if n == 0 {
return zero, errors.New("empty shell status")
}
}
if !p.alive() {
return zero, errors.New("shell exited during status read")
}
return DecodeStatus(bytes.TrimSpace(data), p.pid)
}
func processList() ([]windows.ProcessEntry32, error) {
h, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
defer windows.CloseHandle(h)
var entries []windows.ProcessEntry32
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
for err = windows.Process32First(h, &e); err == nil; err = windows.Process32Next(h, &e) {
entries = append(entries, e)
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, err
}
return entries, nil
}
func messageProfile(pid uint32) string {
class, _ := windows.UTF16PtrFromString("Chrome_MessageWindow")
find := user32.NewProc("FindWindowExW")
var after uintptr
for {
hwnd, _, _ := find.Call(^uintptr(2), after, uintptr(unsafe.Pointer(class)), 0)
if hwnd == 0 {
return ""
}
after = hwnd
var owner uint32
user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
if owner != pid {
continue
}
text := make([]uint16, 32768)
n, _, _ := user32.NewProc("GetWindowTextW").Call(hwnd, uintptr(unsafe.Pointer(&text[0])), uintptr(len(text)))
if n > 0 {
return windows.UTF16ToString(text[:n])
}
}
}
func closeWindows(p *process) {
callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
var owner uint32
user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
if owner == p.pid && p.alive() {
user32.NewProc("PostMessageW").Call(hwnd, 0x0010, 0, 0)
}
return 1
})
user32.NewProc("EnumWindows").Call(callback, 0)
}
func focusLegacyWindow(p *process) bool {
shown := false
callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
var owner uint32
user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
if owner != p.pid || !p.alive() {
return 1
}
n, _, _ := user32.NewProc("GetWindowTextLengthW").Call(hwnd)
if n == 0 {
return 1
}
user32.NewProc("ShowWindow").Call(hwnd, 9) // SW_RESTORE also unhides tray windows.
user32.NewProc("SetForegroundWindow").Call(hwnd)
shown = true
return 0
})
user32.NewProc("EnumWindows").Call(callback, 0)
return shown
}
func lockInstall(root string) (func(), error) {
token, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return nil, err
}
key := ProfileKey(root + "|" + token.User.Sid.String())
name, _ := windows.UTF16PtrFromString(`Local\Reasonix-Recovery-` + key)
h, err := windows.CreateMutex(nil, false, name)
if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
return nil, err
}
runtime.LockOSThread()
result, err := windows.WaitForSingleObject(h, 120000)
if err != nil || (result != windows.WAIT_OBJECT_0 && result != windows.WAIT_ABANDONED) {
windows.CloseHandle(h)
runtime.UnlockOSThread()
return nil, outcome(ExitTimeout, "another install or recovery is still running")
}
return func() { _ = windows.ReleaseMutex(h); windows.CloseHandle(h); runtime.UnlockOSThread() }, nil
}
func Notify(err error) {
title, _ := windows.UTF16PtrFromString("Reasonix 启动 / Startup")
text, _ := windows.UTF16PtrFromString("Reasonix 未能完成启动或更新,请查看日志后重试。\nReasonix could not finish startup or update.\n\n" + err.Error())
user32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)), 0x30)
}
func confirmProcesses(list []*process) bool {
var text strings.Builder
text.WriteString("旧版 Reasonix 尚未退出。结束进程可能丢失未保存内容。\n\nEnd these old Reasonix processes and continue? Unsaved work may be lost.\n")
for _, p := range list {
fmt.Fprintf(&text, "\nPID %d: %s", p.pid, p.image)
}
title, _ := windows.UTF16PtrFromString("Reasonix 恢复 / Recovery")
body, _ := windows.UTF16PtrFromString(text.String())
// Label the standard dialog's buttons explicitly; the negative action is
// still IDNO and remains the default even on non-Chinese Windows systems.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
continueText, _ := windows.UTF16PtrFromString("结束旧进程并继续")
cancelText, _ := windows.UTF16PtrFromString("取消")
callback := syscall.NewCallback(func(code int32, hwnd, param uintptr) uintptr {
if code == 5 { // HCBT_ACTIVATE
user32.NewProc("SetDlgItemTextW").Call(hwnd, 6, uintptr(unsafe.Pointer(continueText)))
user32.NewProc("SetDlgItemTextW").Call(hwnd, 7, uintptr(unsafe.Pointer(cancelText)))
}
next, _, _ := user32.NewProc("CallNextHookEx").Call(0, uintptr(code), hwnd, param)
return next
})
hook, _, _ := user32.NewProc("SetWindowsHookExW").Call(5, callback, 0, uintptr(windows.GetCurrentThreadId()))
if hook == 0 {
return false
}
defer user32.NewProc("UnhookWindowsHookEx").Call(hook)
result, _, _ := user32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(title)), 0x4|0x30|0x100)
return result == 6
}
func inspect(root, profile string, all bool) ([]*process, error) {
entries, err := processList()
if err != nil {
return nil, err
}
var found []*process
fail := func(err error) ([]*process, error) {
for _, p := range found {
p.close()
}
return nil, err
}
for _, e := range entries {
name := strings.ToLower(windows.UTF16ToString(e.ExeFile[:]))
if name != "reasonix.exe" && name != "reasonix-desktop.exe" {
continue
}
p, err := openProcess(e.ProcessID, e.ParentProcessID)
if err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return fail(outcome(UnknownOwner, "access denied while identifying candidate PID %d", e.ProcessID))
}
continue
}
role := ImageRole(root, p.image)
if role != "" && !ordinaryProduct(p.image) {
p.close()
return fail(outcome(UnknownOwner, "product identity could not be verified for PID %d", e.ProcessID))
}
if name == "reasonix.exe" {
status, statusErr := readStatus(p)
if statusErr == nil {
p.status = &status
if status.HomeKey == ProfileKey(profile) && role == "" {
p.close()
return fail(outcome(OtherInstallation, "another Reasonix installation owns this data home"))
}
} else {
if role != "" && !errors.Is(statusErr, windows.ERROR_FILE_NOT_FOUND) {
p.close()
return fail(outcome(UnknownOwner, "shell status could not be verified for PID %d: %v", e.ProcessID, statusErr))
}
p.legacyProfile = messageProfile(p.pid)
if p.legacyProfile != "" {
if real, err := canonical(p.legacyProfile); err == nil && strings.EqualFold(real, profile) && role == "" {
p.close()
return fail(outcome(OtherInstallation, "another legacy Reasonix installation owns this data home"))
}
}
}
}
if role == "" {
p.close()
continue
}
if !all {
matches := p.status != nil && p.status.HomeKey == ProfileKey(profile)
if !matches && p.legacyProfile != "" {
if real, err := canonical(p.legacyProfile); err == nil {
matches = strings.EqualFold(real, profile)
}
}
if !matches {
p.close()
continue
}
}
found = append(found, p)
}
return found, nil
}
func preparePaths(root, home string) (string, string, error) {
root, err := canonical(root)
if err != nil {
return "", "", err
}
profile := filepath.Join(home, "desktop-shell")
if err := os.MkdirAll(profile, 0700); err != nil {
return "", "", err
}
profile, err = canonical(profile)
return root, profile, err
}
@@ -0,0 +1,167 @@
//go:build windows
package desktopinstance
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestNativeStatusPipeValidatesIdentityAndProtocol(t *testing.T) {
p, err := openProcess(windows.GetCurrentProcessId(), 0)
if err != nil {
t.Fatal(err)
}
defer p.close()
for _, tc := range []struct {
name string
schema int
pid uint32
valid bool
}{
{"valid", 1, p.pid, true}, {"wrong-pid", 1, p.pid + 1, false}, {"future-schema", 2, p.pid, false},
} {
t.Run(tc.name, func(t *testing.T) {
name, _ := windows.UTF16PtrFromString(fmt.Sprintf(`\\.\pipe\reasonix-shell-v1-%d`, p.pid))
pipe, err := windows.CreateNamedPipe(name, windows.PIPE_ACCESS_OUTBOUND, windows.PIPE_TYPE_BYTE, 1, StatusLimit+1, 0, 2000, nil)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
defer windows.CloseHandle(pipe)
err := windows.ConnectNamedPipe(pipe, nil)
if err != nil && !errors.Is(err, windows.ERROR_PIPE_CONNECTED) {
done <- err
return
}
data, _ := json.Marshal(Status{SchemaVersion: tc.schema, Product: "com.reasonix.desktop", PID: tc.pid, Version: "v1.38.7", Generation: "test", HomeKey: ProfileKey(t.TempDir()), Lifecycle: "failed", Service: "exited"})
var n uint32
err = windows.WriteFile(pipe, append(data, '\n'), &n, nil)
if err == nil {
err = windows.FlushFileBuffers(pipe)
}
done <- err
}()
_, err = readStatus(p)
if (err == nil) != tc.valid {
t.Errorf("valid=%v error=%v", tc.valid, err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
})
}
}
func TestNativeProcessIdentityAndPIDReuseFence(t *testing.T) {
if os.Getenv("REASONIX_PROCESS_TEST_CHILD") == "1" {
time.Sleep(time.Minute)
return
}
cmd := exec.Command(os.Args[0], "-test.run=^TestNativeProcessIdentityAndPIDReuseFence$")
cmd.Env = append(os.Environ(), "REASONIX_PROCESS_TEST_CHILD=1")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Wait()
defer cmd.Process.Kill()
p, err := openProcess(uint32(cmd.Process.Pid), uint32(os.Getpid()))
if err != nil {
t.Fatal(err)
}
defer p.close()
if !p.alive() {
t.Fatal("child already dead")
}
saved := p.created
p.created.LowDateTime++
if err := p.terminate(); err == nil {
t.Fatal("accepted changed creation time")
}
if !p.alive() {
t.Fatal("killed identity that did not match")
}
p.created = saved
if err := p.terminate(); err != nil {
t.Fatal(err)
}
if !waitProcesses([]*process{p}, time.Second*10) {
t.Fatal("handle did not signal exit")
}
}
func TestNativeCoordinationMutexOwnsCorrectThread(t *testing.T) {
root := t.TempDir()
unlock, err := lockInstall(root)
if err != nil {
t.Fatal(err)
}
waiting := make(chan struct{})
acquired := make(chan error, 1)
go func() {
close(waiting)
release, err := lockInstall(root)
if err == nil {
release()
}
acquired <- err
}()
<-waiting
select {
case err := <-acquired:
unlock()
t.Fatalf("mutex did not serialize callers: %v", err)
case <-time.After(100 * time.Millisecond):
}
unlock()
select {
case err := <-acquired:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("mutex was not released on owning thread")
}
}
func TestNativeInspectCurrentUser(t *testing.T) {
p, err := openProcess(windows.GetCurrentProcessId(), 0)
if err != nil {
t.Fatal(err)
}
defer p.close()
if p.image == "" || !p.alive() {
t.Fatal("missing executable identity")
}
}
// Optional local diagnostic: never runs against the user's default profile.
func TestNativeExplicitFixtureInspection(t *testing.T) {
root, home := os.Getenv("REASONIX_TEST_INSPECT_ROOT"), os.Getenv("REASONIX_TEST_INSPECT_HOME")
if root == "" || home == "" {
t.Skip("explicit isolated installed fixture required")
}
root, profile, err := preparePaths(root, home)
if err != nil {
t.Fatal(err)
}
list, err := inspect(root, profile, false)
if err != nil {
t.Fatal(err)
}
defer closeProcesses(list)
if len(list) == 0 {
t.Fatal("isolated fixture shell was not identified")
}
for _, p := range list {
t.Logf("pid=%d legacyProfile=%q status=%+v", p.pid, p.legacyProfile, p.status)
}
}
@@ -0,0 +1,10 @@
//go:build !windows
package desktopinstance
func PrepareInstall(root, home string, interactive bool) (func(), error) { return func() {}, nil }
func LaunchAndVerify(root, home string, interactive bool, start func() error, args ...string) error {
return start()
}
func Notify(err error) {}
func CheckInstallVacant(root, home string) error { return nil }
+133
View File
@@ -0,0 +1,133 @@
// Package desktopinstance coordinates installed Desktop process ownership.
package desktopinstance
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
)
const StatusLimit = 16 * 1024
const QuitRequest = "--reasonix-lifecycle-request=quit"
type Code string
const (
UnknownOwner Code = "unknown_owner"
ConfirmationRequired Code = "confirmation_required"
Cancelled Code = "cancelled"
ExitTimeout Code = "exit_timeout"
StartupFailed Code = "startup_failed"
OtherInstallation Code = "other_installation"
)
type Error struct {
Code Code
Detail string
}
func (e *Error) Error() string { return string(e.Code) + ": " + e.Detail }
// ExitCode preserves a machine-readable distinction for silent installers.
func ExitCode(err error) int {
if err == nil {
return 0
}
var failure *Error
if errors.As(err, &failure) {
switch failure.Code {
case Cancelled:
return 1602
case ConfirmationRequired, OtherInstallation, UnknownOwner, ExitTimeout:
return 1618
case StartupFailed:
return 1603
}
}
return 1
}
type Status struct {
SchemaVersion int `json:"schemaVersion"`
Product string `json:"product"`
PID uint32 `json:"pid"`
Version string `json:"version"`
Generation string `json:"generation"`
HomeKey string `json:"homeKey"`
Lifecycle string `json:"lifecycle"`
Service string `json:"service"`
ServicePID uint32 `json:"servicePID"`
Visible bool `json:"visible"`
RendererVersion string `json:"rendererVersion"`
Healthy bool `json:"healthy"`
}
func DecodeStatus(data []byte, pid uint32) (Status, error) {
var s Status
if len(data) > StatusLimit {
return s, errors.New("shell status exceeds limit")
}
if err := json.Unmarshal(data, &s); err != nil {
return s, err
}
if s.SchemaVersion != 1 || s.Product != "com.reasonix.desktop" || s.PID != pid || s.Generation == "" || len(s.HomeKey) != 64 || s.Version == "" {
return s, errors.New("unverified shell status identity")
}
if _, err := hex.DecodeString(s.HomeKey); err != nil {
return s, err
}
switch s.Lifecycle {
case "starting", "ready", "failed", "quitting", "done":
default:
return s, errors.New("unknown shell lifecycle")
}
switch s.Service {
case "starting", "restarting", "ready", "failed", "exited":
default:
return s, errors.New("unknown service state")
}
return s, nil
}
func (s Status) Ready(version string) bool {
return s.Lifecycle == "ready" && s.Service == "ready" && s.ServicePID != 0 && s.Visible && s.Healthy && s.RendererVersion == s.Version && (version == "" || s.Version == version)
}
func ProfileKey(profile string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.ReplaceAll(profile, "/", `\`))))
return hex.EncodeToString(sum[:])
}
// ImageRole accepts only installed product paths, never a process-name match.
func ImageRole(root, image string) string {
rel, err := filepath.Rel(root, image)
if err != nil {
return ""
}
parts := strings.Split(strings.ToLower(filepath.ToSlash(rel)), "/")
if len(parts) >= 3 && parts[0] == "versions" {
if installlayout.ValidateVersionName(parts[1]) != nil {
return ""
}
parts = parts[2:]
}
switch strings.Join(parts, "/") {
case "app/reasonix.exe":
return "shell"
case "reasonix-desktop.exe":
return "service"
case "app/resources/service/reasonix-desktop.exe":
return "service"
}
return ""
}
func outcome(code Code, detail string, args ...any) error {
return &Error{code, fmt.Sprintf(detail, args...)}
}
+73
View File
@@ -0,0 +1,73 @@
package desktopinstance
import (
"encoding/json"
"fmt"
"path/filepath"
"testing"
)
func TestExitCodesPreserveWrappedBlockers(t *testing.T) {
for _, tc := range []struct {
code Code
exit int
}{
{ConfirmationRequired, 1618}, {Cancelled, 1602}, {UnknownOwner, 1618}, {OtherInstallation, 1618}, {ExitTimeout, 1618}, {StartupFailed, 1603},
} {
if got := ExitCode(fmt.Errorf("activation: %w", outcome(tc.code, "test"))); got != tc.exit {
t.Errorf("%s: got %d", tc.code, got)
}
}
}
func TestStatusRequiresRendererAndHealthyVisibleWindow(t *testing.T) {
s := Status{SchemaVersion: 1, Product: "com.reasonix.desktop", PID: 10, Version: "v1.38.7", Generation: "g", HomeKey: ProfileKey(`C:\Users\Test\reasonix\desktop-shell`), Lifecycle: "ready", Service: "ready", ServicePID: 11, Visible: true, RendererVersion: "v1.38.7", Healthy: true}
data, _ := json.Marshal(s)
got, err := DecodeStatus(data, 10)
if err != nil || !got.Ready("v1.38.7") {
t.Fatalf("%+v %v", got, err)
}
if _, err := DecodeStatus(data, 12); err == nil {
t.Fatal("accepted another PID")
}
s.Healthy = false
if s.Ready("") {
t.Fatal("accepted unconfirmed frontend")
}
s.Healthy = true
s.RendererVersion = "v1.38.5"
if s.Ready("") {
t.Fatal("accepted mismatched renderer")
}
s.RendererVersion = s.Version
s.Visible = false
if s.Ready("") {
t.Fatal("accepted invisible window")
}
}
func TestImageRoleExcludesOtherProductsAndInstallations(t *testing.T) {
root := t.TempDir()
for _, rel := range []string{"versions/v1.38.5/app/Reasonix.exe", "versions/v1.38.5/reasonix-desktop.exe"} {
if ImageRole(root, filepath.Join(root, rel)) == "" {
t.Fatal(rel)
}
}
for _, rel := range []string{"../Studio/app/Reasonix.exe", "Reasonix Studio.exe", "versions/.staging/app/Reasonix.exe", "versions/v1.38.5/reasonix-cli.exe"} {
if ImageRole(root, filepath.Join(root, rel)) != "" {
t.Fatal(rel)
}
}
}
func TestStatusRejectsFutureProtocolAndOversizedMessages(t *testing.T) {
if _, err := DecodeStatus(make([]byte, StatusLimit+1), 1); err == nil {
t.Fatal("unbounded status")
}
if _, err := DecodeStatus([]byte(`{"schemaVersion":2}`), 1); err == nil {
t.Fatal("future protocol")
}
if ProfileKey(`C:\Users\TEST\Profile`) != ProfileKey(`c:/users/test/profile`) {
t.Fatal("Windows aliases differ")
}
}
@@ -0,0 +1,5 @@
//go:build !windows
package desktoplauncher
func coordinatedLaunch(root string, args []string) (bool, int) { return false, 0 }
@@ -0,0 +1,44 @@
//go:build windows
package desktoplauncher
import (
"fmt"
"os"
"os/exec"
"reasonix/internal/config"
"reasonix/internal/desktopinstance"
"reasonix/internal/installlayout"
"reasonix/internal/proc"
)
func coordinatedLaunch(root string, args []string) (bool, int) {
// Shell-less versioned layouts (pre-shell releases, partial rollbacks)
// carry no shell lifecycle to coordinate or verify; launch directly.
if !installlayout.HasCurrent(root) || !installlayout.HasActiveShell(root) {
return false, 0
}
err := desktopinstance.LaunchAndVerify(root, config.ReasonixHomeDir(), os.Getenv("REASONIX_NONINTERACTIVE") != "1", func() error {
path, err := ResolveDesktopPath(root)
if err != nil {
return err
}
cmd := exec.Command(path, StripLegacyLaunchArgs(args)...)
proc.HideConsole(cmd)
cmd.Dir = root
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}, StripLegacyLaunchArgs(args)...)
if err != nil {
fmt.Fprintln(os.Stderr, err)
if os.Getenv("REASONIX_NONINTERACTIVE") != "1" {
desktopinstance.Notify(err)
}
return true, desktopinstance.ExitCode(err)
}
return true, 0
}
+4
View File
@@ -52,6 +52,10 @@ func Run(args []string, buildVersion string) int {
return 1
}
if handled, code := coordinatedLaunch(installRoot, args); handled {
return code
}
cmd := exec.Command(desktopPath, StripLegacyLaunchArgs(args)...)
proc.HideConsole(cmd)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+14
View File
@@ -49,6 +49,10 @@ type ActivationRequest struct {
// RequiredRootNames is the exact root-entry whitelist when RootMembers is
// non-empty. Callers must provide it explicitly.
RequiredRootNames []string
// CheckProcesses runs under the activation lock before file replacement and
// immediately before pointer publication. The caller owns its coordination
// lock first. A late failure rolls back the staged version and root entries.
CheckProcesses func() error
}
// AllowedVersionMembers returns the default files inside versions/<version>/.
@@ -154,6 +158,11 @@ func ActivateVersion(req ActivationRequest) error {
}
}
if req.CheckProcesses != nil {
if err := req.CheckProcesses(); err != nil {
return err
}
}
finalRel := VersionDirRelative(req.Version)
finalPath := filepath.Join(installRoot, filepath.FromSlash(finalRel))
var versionBackup string
@@ -201,6 +210,11 @@ func ActivateVersion(req ActivationRequest) error {
ActiveVersion: req.Version,
ActiveDir: finalRel,
}
if req.CheckProcesses != nil {
if err := req.CheckProcesses(); err != nil {
return errors.Join(err, rollbackRoots(), rollbackVersion())
}
}
if err := WriteCurrent(installRoot, ptr); err != nil {
rootErr := rollbackRoots()
versionErr := rollbackVersion()
+40
View File
@@ -1,6 +1,7 @@
package installlayout
import (
"errors"
"os"
"path/filepath"
"runtime"
@@ -8,6 +9,45 @@ import (
"time"
)
func TestActivateVersionRollsBackWhenProcessAppearsBeforeCommit(t *testing.T) {
root, src := t.TempDir(), t.TempDir()
members := []Member{}
for _, name := range AllowedVersionMembers() {
members = append(members, Member{Name: name, Path: writeTempMember(t, src, name, "payload")})
}
seed := ActivationRequest{InstallRoot: root, Version: "v1.38.5", RequestID: "seed", Members: members}
if err := ActivateVersion(seed); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(filepath.Join(root, "current.json"))
if err != nil {
t.Fatal(err)
}
checks := 0
seed.Version = "v1.38.7"
seed.RequestID = "race"
seed.CheckProcesses = func() error {
checks++
if checks == 2 {
return errors.New("old launcher started a process")
}
return nil
}
if err := ActivateVersion(seed); err == nil {
t.Fatal("committed despite a new process")
}
after, err := os.ReadFile(filepath.Join(root, "current.json"))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("pointer changed")
}
if _, err := os.Stat(filepath.Join(root, "versions", "v1.38.7")); !os.IsNotExist(err) {
t.Fatal("uncommitted version left behind", err)
}
}
func writeTempMember(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
+11
View File
@@ -310,6 +310,17 @@ func HasCurrent(installRoot string) bool {
return err == nil
}
// HasActiveShell reports whether the active version carries the app/ shell
// tree. Shell-less versioned layouts (pre-shell releases) return false.
func HasActiveShell(installRoot string) bool {
desktop, err := ActiveDesktopPath(installRoot)
if err != nil {
return false
}
info, err := os.Lstat(filepath.Join(filepath.Dir(desktop), AppShellDirName))
return err == nil && info.IsDir()
}
// ResolveInstallRoot walks upward from path (usually the running executable)
// and returns the InstallRoot that owns current.json. Flat installs return the
// directory containing the executable when no pointer is found.
+28
View File
@@ -115,3 +115,31 @@ func TestResolveInstallRootFromVersionedDesktop(t *testing.T) {
t.Fatalf("got %q err=%v want %q", got, err, root)
}
}
func TestHasActiveShell(t *testing.T) {
root := t.TempDir()
version := "v1.20.0"
dir := filepath.Join(root, "versions", version)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if HasActiveShell(root) {
t.Fatal("shell reported without a version pointer")
}
if err := os.WriteFile(filepath.Join(dir, DesktopBinaryName()), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
ptr := CurrentPointer{SchemaVersion: 1, ActiveVersion: version, ActiveDir: VersionDirRelative(version)}
if err := WriteCurrent(root, ptr); err != nil {
t.Fatal(err)
}
if HasActiveShell(root) {
t.Fatal("shell reported for a shell-less versioned layout")
}
if err := os.MkdirAll(filepath.Join(dir, AppShellDirName), 0o755); err != nil {
t.Fatal(err)
}
if !HasActiveShell(root) {
t.Fatal("shell tree not detected beside the active desktop")
}
}
+3
View File
@@ -172,6 +172,9 @@ if command -v powershell.exe >/dev/null 2>&1; then
elif command -v zip >/dev/null 2>&1; then
# macOS/Linux cross-builds do not ship powershell.exe; the portable layout
# is ordinary ZIP data, so use the host zip utility in that case.
# zip updates an existing archive and otherwise retains previous version
# directories. Always assemble a fresh distributable from this payload.
rm -f -- "$dist_portable"
(
cd "$portable_staging"
zip -q -r "$dist_portable" .
+77
View File
@@ -0,0 +1,77 @@
param(
[Parameter(Mandatory=$true)][string]$PortableZip,
[string]$EvidenceDirectory = (Join-Path $env:TEMP ('reasonix-recovery-' + [guid]::NewGuid().ToString('N')))
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force $EvidenceDirectory | Out-Null
$install = Join-Path $EvidenceDirectory 'install'
$dataHome = Join-Path $EvidenceDirectory 'home'
if (Test-Path $install) { throw 'Evidence install directory must be new; refusing to overwrite a running fixture.' }
Expand-Archive -LiteralPath $PortableZip -DestinationPath $install
$current = Get-Content (Join-Path $install 'current.json') -Raw | ConvertFrom-Json
$shellPath = Join-Path $install ($current.activeDir + '\app\Reasonix.exe')
$launcher = Join-Path $install 'Reasonix.exe'
$saved = @{}
foreach ($key in @('REASONIX_HOME','REASONIX_NONINTERACTIVE','REASONIX_DEV','REASONIX_DESKTOP_SERVICE','REASONIX_ELECTRON_DEV_URL')) {
$saved[$key] = [Environment]::GetEnvironmentVariable($key,'Process')
[Environment]::SetEnvironmentVariable($key,$null,'Process')
}
$env:REASONIX_HOME = $dataHome
$env:REASONIX_NONINTERACTIVE = '1'
function Read-ShellStatus($process) {
$pipe = [IO.Pipes.NamedPipeClientStream]::new('.', ('reasonix-shell-v1-' + $process.Id), [IO.Pipes.PipeDirection]::In)
try {
$pipe.Connect(500)
$reader = [IO.StreamReader]::new($pipe)
$read = $reader.ReadLineAsync()
if (-not $read.Wait(2000)) { return $null }
if ($read.Result.Length -gt 16384) { throw 'Oversized status response' }
return ($read.Result | ConvertFrom-Json)
} catch { return $null } finally { $pipe.Dispose() }
}
function Assert-Ready {
foreach ($process in @(Get-Process Reasonix -ErrorAction SilentlyContinue)) {
if ($process.Path -ne $shellPath) { continue }
$status = Read-ShellStatus $process
if ($null -eq $status) { continue }
if ($status.schemaVersion -ne 1 -or $status.product -ne 'com.reasonix.desktop' -or $status.pid -ne $process.Id) { throw 'Status identity mismatch' }
if ($status.lifecycle -ne 'ready' -or $status.service -ne 'ready' -or -not $status.visible -or -not $status.healthy) { throw ('Not ready: ' + ($status | ConvertTo-Json -Compress)) }
if ($status.version -ne $current.activeVersion -or $status.rendererVersion -ne $current.activeVersion) { throw 'Target renderer version mismatch' }
$service = Get-Process -Id $status.servicePID
$expectedService = Join-Path $install ($current.activeDir + '\reasonix-desktop.exe')
if ($service.Path -ne $expectedService) { throw 'Service is outside the active release' }
return @{ Shell=$process; Service=$service; Status=$status }
}
throw 'No verified target shell; inspect preserved logs'
}
try {
$attempt = Start-Process $launcher -PassThru
if (-not $attempt.WaitForExit(40000) -or $attempt.ExitCode -ne 0) { throw 'Stable launcher failed or timed out' }
$first = Assert-Ready
$again = Start-Process $launcher -PassThru
if (-not $again.WaitForExit(40000) -or $again.ExitCode -ne 0) { throw 'Second launch failed or timed out' }
$second = Assert-Ready
if ($first.Shell.Id -ne $second.Shell.Id -or $first.Service.Id -ne $second.Service.Id -or $first.Status.generation -ne $second.Status.generation) { throw 'Second launch replaced the healthy instance' }
Start-Process $shellPath -ArgumentList '--reasonix-lifecycle-request=quit' | Out-Null
if (-not $first.Shell.WaitForExit(15000) -or -not $first.Service.WaitForExit(1000)) { throw 'Normal exit left a shell or service alive' }
$shellLog = Get-Content (Join-Path $dataHome 'desktop-shell\logs\shell.log') -Raw
if ($shellLog -match 'exit deadline exceeded|killing it|termination failed') { throw 'Forced cleanup is not a normal-exit pass' }
$report = @{
artifact=(Get-FileHash -Algorithm SHA256 $PortableZip).Hash
version=$current.activeVersion
OS=[Environment]::OSVersion.VersionString
architecture=$env:PROCESSOR_ARCHITECTURE
shellPID=$first.Shell.Id
servicePID=$first.Service.Id
startup='passed'; secondLaunch='passed'; normalExit='passed'
signing='not checked'; installer='not exercised'; recoveryConsent='not exercised'
}
$report | ConvertTo-Json | Set-Content (Join-Path $EvidenceDirectory 'result.json')
$report | ConvertTo-Json
} finally {
foreach ($key in $saved.Keys) { [Environment]::SetEnvironmentVariable($key,$saved[$key],'Process') }
Write-Host "Evidence preserved at $EvidenceDirectory. No forced process cleanup was performed."
}
+77 -84
View File
@@ -2,15 +2,15 @@
"limits": {
"banner": 13,
"commented-code": 0,
"complexity": 1894,
"essay": 1891,
"file-size": 101568,
"function-size": 8257,
"complexity": 1862,
"essay": 1883,
"file-size": 99752,
"function-size": 8020,
"layering": 1,
"marker": 0,
"narrative": 64,
"struct-state": 103,
"test-file-size": 68583
"narrative": 63,
"struct-state": 102,
"test-file-size": 67978
},
"files": {
"cmd/e2ebench/main.go": {
@@ -23,15 +23,15 @@
"essay": 19
},
"desktop/app.go": {
"complexity": 57,
"complexity": 55,
"essay": 60,
"file-size": 10908,
"function-size": 354,
"file-size": 10771,
"function-size": 353,
"struct-state": 10
},
"desktop/app_test.go": {
"essay": 2,
"test-file-size": 10391
"test-file-size": 10353
},
"desktop/bot_bridge.go": {
"essay": 3
@@ -93,7 +93,7 @@
"file-size": 2643
},
"desktop/frontend/src/components/Composer.tsx": {
"file-size": 4002
"file-size": 3900
},
"desktop/frontend/src/components/ContextPanel.tsx": {
"file-size": 36
@@ -108,16 +108,16 @@
"file-size": 27
},
"desktop/frontend/src/components/ProjectTree.tsx": {
"file-size": 1467
"file-size": 1320
},
"desktop/frontend/src/components/RichComposerInput.tsx": {
"file-size": 141
},
"desktop/frontend/src/components/SettingsPanel.tsx": {
"file-size": 7284
"file-size": 6534
},
"desktop/frontend/src/components/StatusBar.tsx": {
"file-size": 25
"file-size": 20
},
"desktop/frontend/src/components/ThemeGallery.tsx": {
"file-size": 490
@@ -135,19 +135,19 @@
"file-size": 177
},
"desktop/frontend/src/lib/bridge.ts": {
"file-size": 4999
"file-size": 4802
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 212
},
"desktop/frontend/src/lib/transcriptStore.ts": {
"file-size": 264
"file-size": 244
},
"desktop/frontend/src/lib/types.ts": {
"file-size": 1634
"file-size": 1557
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 4267
"file-size": 4176
},
"desktop/heartbeat.go": {
"essay": 7
@@ -165,7 +165,7 @@
"test-file-size": 344
},
"desktop/history_test.go": {
"test-file-size": 1102
"test-file-size": 977
},
"desktop/internal/update/manifest.go": {
"essay": 1
@@ -186,10 +186,10 @@
"essay": 4
},
"desktop/provider_access_removal.go": {
"essay": 4
"essay": 3
},
"desktop/remote_app.go": {
"file-size": 745
"file-size": 732
},
"desktop/remote_markdown_image.go": {
"complexity": 12
@@ -199,7 +199,7 @@
},
"desktop/remote_tab.go": {
"essay": 8,
"file-size": 182
"file-size": 129
},
"desktop/remote_tab_commands.go": {
"essay": 1
@@ -231,12 +231,12 @@
"desktop/session_takeover.go": {
"complexity": 9,
"essay": 20,
"file-size": 514,
"function-size": 22
"file-size": 511,
"function-size": 19
},
"desktop/sessions.go": {
"essay": 4,
"file-size": 403
"file-size": 368
},
"desktop/sessions_test.go": {
"test-file-size": 751
@@ -244,10 +244,10 @@
"desktop/settings_app.go": {
"complexity": 29,
"essay": 6,
"file-size": 3051
"file-size": 2697
},
"desktop/settings_app_test.go": {
"test-file-size": 1814
"test-file-size": 1808
},
"desktop/shared_host.go": {
"essay": 1
@@ -262,10 +262,10 @@
"test-file-size": 151
},
"desktop/tabs.go": {
"complexity": 42,
"essay": 68,
"file-size": 7605,
"function-size": 364,
"complexity": 41,
"essay": 67,
"file-size": 7247,
"function-size": 363,
"struct-state": 23
},
"desktop/tabs_order_test.go": {
@@ -312,7 +312,7 @@
},
"desktop/updater.go": {
"essay": 2,
"file-size": 597
"file-size": 556
},
"desktop/updater_app.go": {
"essay": 4
@@ -325,7 +325,7 @@
"test-file-size": 553
},
"desktop/updater_test.go": {
"test-file-size": 769
"test-file-size": 752
},
"desktop/updater_windows.go": {
"essay": 1
@@ -372,7 +372,7 @@
"complexity": 27,
"essay": 25,
"file-size": 2040,
"function-size": 104,
"function-size": 88,
"struct-state": 2
},
"internal/agent/cache_shape_test.go": {
@@ -409,8 +409,8 @@
"test-file-size": 487
},
"internal/agent/execute_batch.go": {
"complexity": 27,
"function-size": 167
"complexity": 23,
"function-size": 155
},
"internal/agent/execute_one.go": {
"complexity": 1,
@@ -431,9 +431,6 @@
"essay": 3,
"function-size": 24
},
"internal/agent/loop_e2e_test.go": {
"test-file-size": 265
},
"internal/agent/migrate.go": {
"complexity": 32,
"essay": 12,
@@ -470,8 +467,7 @@
"essay": 1
},
"internal/agent/run_loop.go": {
"essay": 10,
"function-size": 2
"essay": 9
},
"internal/agent/run_usage.go": {
"complexity": 1
@@ -479,7 +475,7 @@
"internal/agent/save.go": {
"complexity": 30,
"essay": 79,
"file-size": 1419,
"file-size": 1393,
"function-size": 103
},
"internal/agent/save_test.go": {
@@ -527,7 +523,7 @@
"internal/agent/task.go": {
"complexity": 3,
"essay": 12,
"file-size": 1207,
"file-size": 1172,
"function-size": 18
},
"internal/agent/task_test.go": {
@@ -546,10 +542,10 @@
"test-file-size": 946
},
"internal/boot/boot.go": {
"complexity": 264,
"complexity": 261,
"essay": 77,
"file-size": 2072,
"function-size": 1726,
"file-size": 2015,
"function-size": 1713,
"narrative": 3
},
"internal/boot/boot_test.go": {
@@ -604,7 +600,7 @@
"internal/bot/gateway.go": {
"complexity": 60,
"essay": 19,
"file-size": 2276,
"file-size": 2274,
"function-size": 265
},
"internal/bot/gateway_lock_test.go": {
@@ -663,10 +659,10 @@
"essay": 6
},
"internal/cli/chat_tui.go": {
"complexity": 318,
"essay": 82,
"file-size": 4583,
"function-size": 1277
"complexity": 280,
"essay": 80,
"file-size": 4286,
"function-size": 1097
},
"internal/cli/chat_tui_paste.go": {
"essay": 6
@@ -676,10 +672,10 @@
"test-file-size": 3609
},
"internal/cli/cli.go": {
"complexity": 90,
"complexity": 88,
"essay": 22,
"file-size": 2000,
"function-size": 562
"file-size": 1961,
"function-size": 549
},
"internal/cli/cli_test.go": {
"essay": 1,
@@ -805,7 +801,7 @@
},
"internal/config/config.go": {
"essay": 22,
"file-size": 1552,
"file-size": 1524,
"narrative": 2
},
"internal/config/credentials.go": {
@@ -816,7 +812,7 @@
},
"internal/config/edit.go": {
"essay": 5,
"file-size": 1680
"file-size": 1616
},
"internal/config/edit_test.go": {
"test-file-size": 2409
@@ -826,7 +822,7 @@
},
"internal/config/load.go": {
"essay": 3,
"file-size": 1759,
"file-size": 1755,
"function-size": 45
},
"internal/config/main_test.go": {
@@ -845,10 +841,10 @@
"essay": 7
},
"internal/config/provider_presets.go": {
"file-size": 375
"file-size": 325
},
"internal/config/provider_presets_test.go": {
"test-file-size": 158
"test-file-size": 112
},
"internal/config/render.go": {
"complexity": 229,
@@ -866,10 +862,10 @@
"internal/control/controller.go": {
"complexity": 12,
"essay": 87,
"file-size": 5565,
"function-size": 83,
"file-size": 5111,
"function-size": 79,
"narrative": 4,
"struct-state": 17
"struct-state": 16
},
"internal/control/controller_test.go": {
"essay": 1,
@@ -913,7 +909,7 @@
"file-size": 554
},
"internal/control/refs_test.go": {
"test-file-size": 141
"test-file-size": 90
},
"internal/control/sessionpath.go": {
"essay": 1
@@ -926,7 +922,7 @@
},
"internal/control/turn_orchestrator.go": {
"essay": 10,
"function-size": 49
"function-size": 6
},
"internal/control/turn_orchestrator_test.go": {
"test-file-size": 387
@@ -934,6 +930,9 @@
"internal/control/yolo_test.go": {
"test-file-size": 341
},
"internal/desktopinstance/coordinator_windows.go": {
"complexity": 9
},
"internal/doctor/report.go": {
"essay": 3
},
@@ -950,19 +949,15 @@
"essay": 2
},
"internal/event/event.go": {
"essay": 6,
"file-size": 102,
"narrative": 1
"essay": 6
},
"internal/eventwire/wire.go": {
"complexity": 4,
"file-size": 31,
"function-size": 9
"complexity": 3
},
"internal/evidence/evidence.go": {
"complexity": 6,
"essay": 6,
"file-size": 1986
"file-size": 1900
},
"internal/evidence/evidence_test.go": {
"essay": 1,
@@ -1082,8 +1077,8 @@
"essay": 6
},
"internal/installlayout/activate.go": {
"complexity": 4,
"function-size": 24
"complexity": 8,
"function-size": 34
},
"internal/installsource/install_source.go": {
"essay": 2
@@ -1111,9 +1106,8 @@
"test-file-size": 238
},
"internal/jobs/jobs.go": {
"essay": 21,
"file-size": 1271,
"function-size": 12
"essay": 18,
"file-size": 1147
},
"internal/jobs/jobs_extra_test.go": {
"essay": 1
@@ -1205,7 +1199,7 @@
"internal/provider/openai/openai.go": {
"complexity": 53,
"essay": 25,
"file-size": 543,
"file-size": 481,
"function-size": 181,
"struct-state": 9
},
@@ -1217,12 +1211,12 @@
},
"internal/provider/provider.go": {
"essay": 7,
"file-size": 314
"file-size": 287
},
"internal/provider/responses/responses.go": {
"complexity": 49,
"essay": 2,
"file-size": 99,
"file-size": 15,
"function-size": 161,
"struct-state": 5
},
@@ -1348,10 +1342,10 @@
},
"internal/serve/serve.go": {
"essay": 22,
"file-size": 899
"file-size": 867
},
"internal/serve/serve_test.go": {
"test-file-size": 422
"test-file-size": 365
},
"internal/serve/session_ownership.go": {
"essay": 17,
@@ -1464,8 +1458,7 @@
"essay": 4
},
"internal/turnevent/ledger.go": {
"complexity": 3,
"file-size": 312,
"file-size": 283,
"struct-state": 3
},
"sdk/go/examples/fullsidecar/main.go": {