From bb40a0dfc2cc8c9e7e8cfffa30e41e4e89071a6f Mon Sep 17 00:00:00 2001 From: Tom Vokac Date: Wed, 15 Jul 2026 01:27:20 -0400 Subject: [PATCH 1/3] fix: reuse Nemotron and clean up model processes --- build/windows/nsis/project.nsi | 61 +++++++--- internal/buildcheck/subsystem_test.go | 26 +++++ internal/stt/process_tree_other.go | 9 ++ internal/stt/process_tree_windows.go | 46 ++++++++ internal/stt/process_tree_windows_test.go | 33 ++++++ internal/stt/server.go | 13 +++ meeting_service.go | 19 +--- paths.go | 72 ++++++++++++ paths_test.go | 72 ++++++++++++ resources/nemotron/setup.ps1 | 132 +++++++++++++++++++++- 10 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 internal/stt/process_tree_other.go create mode 100644 internal/stt/process_tree_windows.go create mode 100644 internal/stt/process_tree_windows_test.go create mode 100644 paths_test.go diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi index ddd2d28..bc87db5 100644 --- a/build/windows/nsis/project.nsi +++ b/build/windows/nsis/project.nsi @@ -81,6 +81,7 @@ OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the i ShowInstDetails show # This will always show the installation details. Var IsUpgrade +Var NemotronRoot Function .onInit !insertmacro wails.checkArchitecture @@ -107,7 +108,7 @@ Section # When updating, close any running instance so its locked files (Parley.exe, # whisper DLLs) can be overwritten. Harmless no-op on a fresh install. - nsExec::Exec 'cmd /C taskkill /F /IM "${PRODUCT_EXECUTABLE}" >NUL 2>&1' + nsExec::Exec 'cmd /C taskkill /T /F /IM "${PRODUCT_EXECUTABLE}" >NUL 2>&1' Pop $0 !insertmacro wails.webview2runtime @@ -116,18 +117,31 @@ Section !insertmacro wails.files - # Bundle the CPU whisper transcription engine + model so every installation - # remains standalone. Replace shipped Whisper artifacts on upgrade, but do - # not remove resources/nemotron: that optional multi-GB install is persistent. - # resolveResource() in paths.go looks for resources/ next to the exe at runtime. + # Bundle only CPU Whisper plus the small Nemotron provisioner. Generated + # Nemotron model/runtime/cache directories in a developer checkout can total + # more than 10 GiB and must never be swept into a locally-built installer. + # Replace shipped Whisper artifacts on upgrade, but preserve the legacy + # resources/nemotron directory for users provisioned by older releases. RMDir /r "$INSTDIR\resources\whisper" - File /r "..\..\..\resources" - - # A complete Nemotron install is persistent and automatically preferred by - # the app. Fresh NVIDIA installs provision it automatically. On upgrades - # where it is missing (including an earlier partial download), ask before - # downloading several GB. Silent upgrades never start a surprise download. - # Every failure/decline remains non-fatal because CPU Whisper is bundled. + SetOutPath "$INSTDIR\resources\whisper" + File /r "..\..\..\resources\whisper\*" + SetOutPath "$INSTDIR\resources\nemotron" + File "..\..\..\resources\nemotron\download_model.py" + File "..\..\..\resources\nemotron\server.py" + File "..\..\..\resources\nemotron\setup.ps1" + File "..\..\..\resources\nemotron\validate_install.py" + + # New releases keep the multi-GB installation in per-user LocalAppData so + # installed and development builds can share it. Continue recognizing the + # legacy beside-the-exe location. A .source-root marker may point at a valid + # older checkout installation without copying or downloading it again. + StrCpy $NemotronRoot "$LOCALAPPDATA\Parley\nemotron" + IfFileExists "$NemotronRoot\.ready" nemotron_present nemotron_check_shared_source + + nemotron_check_shared_source: + IfFileExists "$NemotronRoot\.source-root" nemotron_present nemotron_check_legacy + + nemotron_check_legacy: IfFileExists "$INSTDIR\resources\nemotron\.ready" nemotron_present nemotron_probe_gpu nemotron_probe_gpu: @@ -142,6 +156,20 @@ Section StrCmp $1 "0" nemotron_gpu_found nemotron_no_gpu nemotron_gpu_found: + # Before offering a multi-GB download, search common repository folders + # for the complete model used by pre-shared-storage development builds. + # Reuse is recorded as a tiny pointer under the stable per-user root. + DetailPrint "Checking for an existing Nemotron development installation..." + ${DisableX64FSRedirection} + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1" -InstallRoot "$NemotronRoot" -DiscoverExisting -ReuseOnly' + ${EnableX64FSRedirection} + Pop $1 + IfFileExists "$NemotronRoot\.ready" nemotron_present nemotron_check_discovered_source + + nemotron_check_discovered_source: + IfFileExists "$NemotronRoot\.source-root" nemotron_present nemotron_missing + + nemotron_missing: StrCmp $IsUpgrade "0" nemotron_provision IfSilent nemotron_silent_skip MessageBox MB_YESNO|MB_ICONQUESTION "Parley found an NVIDIA GPU, but Nemotron 3.5 ASR Streaming is not installed.$\r$\n$\r$\nDownload and install it now? This can download several gigabytes and may take several minutes. CPU Whisper remains available if you choose No." /SD IDNO IDYES nemotron_provision IDNO nemotron_declined @@ -151,18 +179,21 @@ Section # Launch 64-bit PowerShell so setup.ps1 can also resolve nvidia-smi and # validate VRAM/compute capability without WOW64 redirection. ${DisableX64FSRedirection} - nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1" -InstallRoot "$INSTDIR\resources\nemotron"' + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1" -InstallRoot "$NemotronRoot"' ${EnableX64FSRedirection} Pop $1 StrCmp $1 "0" 0 nemotron_provision_failed - IfFileExists "$INSTDIR\resources\nemotron\.ready" nemotron_provisioned nemotron_not_ready + IfFileExists "$NemotronRoot\.ready" nemotron_provisioned nemotron_check_provisioned_source + + nemotron_check_provisioned_source: + IfFileExists "$NemotronRoot\.source-root" nemotron_provisioned nemotron_not_ready nemotron_provisioned: DetailPrint "Nemotron provisioning completed; Parley will use it automatically." Goto nemotron_done nemotron_present: - DetailPrint "Existing Nemotron installation detected; Parley will use it automatically." + DetailPrint "Existing Nemotron installation detected; Parley will reuse it automatically." Goto nemotron_done nemotron_no_gpu: diff --git a/internal/buildcheck/subsystem_test.go b/internal/buildcheck/subsystem_test.go index 180b304..1cbed9e 100644 --- a/internal/buildcheck/subsystem_test.go +++ b/internal/buildcheck/subsystem_test.go @@ -36,7 +36,11 @@ func TestInstallerOffersMissingNemotronOnUpgrade(t *testing.T) { } installer := string(data) for _, required := range []string{ + `taskkill /T /F /IM "${PRODUCT_EXECUTABLE}"`, `IfFileExists "$INSTDIR\resources\nemotron\.ready"`, + `StrCpy $NemotronRoot "$LOCALAPPDATA\Parley\nemotron"`, + `IfFileExists "$NemotronRoot\.source-root"`, + `-DiscoverExisting -ReuseOnly`, `StrCmp $IsUpgrade "0" nemotron_provision`, `MessageBox MB_YESNO|MB_ICONQUESTION`, `IfSilent nemotron_silent_skip`, @@ -50,4 +54,26 @@ func TestInstallerOffersMissingNemotronOnUpgrade(t *testing.T) { if strings.Contains(installer, "cmd /C nvidia-smi") { t.Fatalf("%s probes nvidia-smi through 32-bit cmd; WOW64 redirection hides the System32 executable", path) } + if strings.Contains(installer, `File /r "..\..\..\resources"`) { + t.Fatalf("%s recursively bundles all generated Nemotron assets from a developer checkout", path) + } +} + +func TestNemotronSetupUsesPrivatePythonAndSharedStorage(t *testing.T) { + path := filepath.Join("..", "..", "resources", "nemotron", "setup.ps1") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + setup := string(data) + for _, required := range []string{ + `Join-Path $env:LOCALAPPDATA "Parley/nemotron"`, + `Invoke-Checked $uvExe python install 3.11 --no-bin`, + `Test-CompleteNemotronInstall`, + `Write-SourceRoot`, + } { + if !strings.Contains(setup, required) { + t.Fatalf("%s no longer contains %q", path, required) + } + } } diff --git a/internal/stt/process_tree_other.go b/internal/stt/process_tree_other.go new file mode 100644 index 0000000..3e43d20 --- /dev/null +++ b/internal/stt/process_tree_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package stt + +import "os" + +func superviseProcessTree(_ *os.Process) (func(), error) { + return func() {}, nil +} diff --git a/internal/stt/process_tree_windows.go b/internal/stt/process_tree_windows.go new file mode 100644 index 0000000..81d0487 --- /dev/null +++ b/internal/stt/process_tree_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package stt + +import ( + "os" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// superviseProcessTree places a model server in a Windows Job Object configured +// with KILL_ON_JOB_CLOSE. Windows closes Parley's job handle on graceful exit, +// forced termination, or a crash, which also terminates descendants the Python +// sidecar may have created. +func superviseProcessTree(process *os.Process) (func(), error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + closeJob := sync.OnceFunc(func() { _ = windows.CloseHandle(job) }) + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err = windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + closeJob() + return nil, err + } + var assignErr error + if err = process.WithHandle(func(handle uintptr) { + assignErr = windows.AssignProcessToJobObject(job, windows.Handle(handle)) + }); err != nil { + closeJob() + return nil, err + } + if assignErr != nil { + closeJob() + return nil, assignErr + } + return closeJob, nil +} diff --git a/internal/stt/process_tree_windows_test.go b/internal/stt/process_tree_windows_test.go new file mode 100644 index 0000000..e11a578 --- /dev/null +++ b/internal/stt/process_tree_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +package stt + +import ( + "os/exec" + "testing" + "time" +) + +func TestSuperviseProcessTreeKillsProcessWhenJobCloses(t *testing.T) { + cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Sleep -Seconds 30") + hideWindow(cmd) + if err := cmd.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + release, err := superviseProcessTree(cmd.Process) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("superviseProcessTree: %v", err) + } + release() + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + t.Fatal("closing the process job did not terminate its process") + } +} diff --git a/internal/stt/server.go b/internal/stt/server.go index 8659428..56e7752 100644 --- a/internal/stt/server.go +++ b/internal/stt/server.go @@ -4,6 +4,7 @@ package stt import ( "context" "fmt" + "log" "net/http" "os" "os/exec" @@ -26,6 +27,7 @@ type Server struct { cmd *exec.Cmd logFile *os.File done chan error + releaseTree func() } // HasNVIDIAGPU reports whether Windows can see at least one working NVIDIA GPU. @@ -104,6 +106,13 @@ func (s *Server) Start(ctx context.Context) error { s.closeLog() return fmt.Errorf("start %s server: %w", s.name, err) } + if release, err := superviseProcessTree(s.cmd.Process); err != nil { + // Direct-process termination below remains available if Windows refuses + // job assignment (for example under an unusually restrictive host job). + log.Printf("[stt] could not attach %s to kill-on-close process job: %v", s.name, err) + } else { + s.releaseTree = release + } s.done = make(chan error, 1) go func() { s.done <- s.cmd.Wait() @@ -149,6 +158,10 @@ func (s *Server) waitReady(ctx context.Context) error { // Stop terminates the subprocess if running. func (s *Server) Stop() { + if s.releaseTree != nil { + s.releaseTree() + s.releaseTree = nil + } if s.cmd != nil && s.cmd.Process != nil { _ = s.cmd.Process.Kill() if s.done != nil { diff --git a/meeting_service.go b/meeting_service.go index 6cae738..6e17d12 100644 --- a/meeting_service.go +++ b/meeting_service.go @@ -323,22 +323,15 @@ func (m *MeetingService) start(resumeID int64) error { // The .ready marker is deliberately required so an interrupted installer download // is never mistaken for a usable model installation. func newNemotronServer() (*stt.Server, error) { - ready, err := resolveResource(filepath.Join("resources", "nemotron", ".ready")) - if err != nil { - return nil, err - } - python, err := resolveResource(filepath.Join("resources", "nemotron", "runtime", "Scripts", "python.exe")) - if err != nil { - return nil, err - } - script, err := resolveResource(filepath.Join("resources", "nemotron", "server.py")) - if err != nil { - return nil, err - } - modelDir, err := resolveResource(filepath.Join("resources", "nemotron", "model")) + install, err := resolveNemotronInstall() if err != nil { return nil, err } + root := install.root + ready := filepath.Join(root, ".ready") + python := filepath.Join(root, "runtime", "Scripts", "python.exe") + script := install.script + modelDir := filepath.Join(root, "model") config := filepath.Join(modelDir, "config.json") weights := filepath.Join(modelDir, "model.safetensors") args := []string{ diff --git a/paths.go b/paths.go index 9e1744e..02dcae1 100644 --- a/paths.go +++ b/paths.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // resolveResource locates a local transcription resource by checking @@ -27,6 +28,77 @@ func resolveResource(rel string) (string, error) { return "", fmt.Errorf("could not locate %q (searched %v)", rel, bases) } +type nemotronInstall struct { + root string + script string +} + +// resolveNemotronInstall finds a complete Nemotron installation. New installs use +// a stable per-user location so development and packaged builds share the same +// multi-GB model. The resource lookup remains as a compatibility path for older +// checkouts/installers that provisioned next to the executable. +func resolveNemotronInstall() (nemotronInstall, error) { + var roots []string + if configured := strings.TrimSpace(os.Getenv("PARLEY_NEMOTRON_HOME")); configured != "" { + roots = append(roots, configured) + } + if localAppData, err := os.UserCacheDir(); err == nil { + roots = append(roots, filepath.Join(localAppData, "Parley", "nemotron")) + } + if ready, err := resolveResource(filepath.Join("resources", "nemotron", ".ready")); err == nil { + roots = append(roots, filepath.Dir(ready)) + } + + seen := make(map[string]bool) + var searched []string + for _, candidate := range roots { + root, err := filepath.Abs(candidate) + if err != nil || seen[root] { + continue + } + seen[root] = true + searched = append(searched, root) + + provisionerRoot := root + if source, err := os.ReadFile(filepath.Join(root, ".source-root")); err == nil { + redirected := strings.TrimSpace(strings.TrimPrefix(string(source), "\ufeff")) + if redirected != "" { + root = redirected + searched = append(searched, root) + } + } + if completeNemotronRoot(root) { + script := filepath.Join(provisionerRoot, "server.py") + if _, err := os.Stat(script); err != nil { + script = filepath.Join(root, "server.py") + } + if _, err := os.Stat(script); err == nil { + return nemotronInstall{root: root, script: script}, nil + } + } + } + return nemotronInstall{}, fmt.Errorf("could not locate a complete Nemotron installation (searched %v)", searched) +} + +func resolveNemotronRoot() (string, error) { + install, err := resolveNemotronInstall() + return install.root, err +} + +func completeNemotronRoot(root string) bool { + for _, rel := range []string{ + ".ready", + filepath.Join("runtime", "Scripts", "python.exe"), + filepath.Join("model", "config.json"), + filepath.Join("model", "model.safetensors"), + } { + if _, err := os.Stat(filepath.Join(root, rel)); err != nil { + return false + } + } + return true +} + // dataDir is the per-user app data directory. func dataDir() string { if cfg, err := os.UserConfigDir(); err == nil { diff --git a/paths_test.go b/paths_test.go new file mode 100644 index 0000000..281f091 --- /dev/null +++ b/paths_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeCompleteNemotronRoot(t *testing.T, root string) { + t.Helper() + for _, rel := range []string{ + ".ready", + filepath.Join("runtime", "Scripts", "python.exe"), + filepath.Join("model", "config.json"), + filepath.Join("model", "model.safetensors"), + "server.py", + } { + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", path, err) + } + if err := os.WriteFile(path, []byte("test"), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + } +} + +func TestResolveNemotronRootUsesConfiguredInstall(t *testing.T) { + root := filepath.Join(t.TempDir(), "nemotron") + writeCompleteNemotronRoot(t, root) + t.Setenv("PARLEY_NEMOTRON_HOME", root) + + got, err := resolveNemotronRoot() + if err != nil { + t.Fatalf("resolveNemotronRoot: %v", err) + } + if got != root { + t.Fatalf("resolveNemotronRoot = %q, want %q", got, root) + } +} + +func TestResolveNemotronRootFollowsSharedSourceMarker(t *testing.T) { + source := filepath.Join(t.TempDir(), "checkout", "resources", "nemotron") + writeCompleteNemotronRoot(t, source) + shared := filepath.Join(t.TempDir(), "shared") + if err := os.MkdirAll(shared, 0o755); err != nil { + t.Fatal(err) + } + sharedServer := filepath.Join(shared, "server.py") + if err := os.WriteFile(sharedServer, []byte("current provisioner"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shared, ".source-root"), append([]byte{0xef, 0xbb, 0xbf}, []byte(source+"\r\n")...), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PARLEY_NEMOTRON_HOME", shared) + + got, err := resolveNemotronRoot() + if err != nil { + t.Fatalf("resolveNemotronRoot: %v", err) + } + if got != source { + t.Fatalf("resolveNemotronRoot = %q, want redirected %q", got, source) + } + install, err := resolveNemotronInstall() + if err != nil { + t.Fatalf("resolveNemotronInstall: %v", err) + } + if install.script != sharedServer { + t.Fatalf("server script = %q, want current shared provisioner %q", install.script, sharedServer) + } +} diff --git a/resources/nemotron/setup.ps1 b/resources/nemotron/setup.ps1 index 887b82e..9d72a76 100644 --- a/resources/nemotron/setup.ps1 +++ b/resources/nemotron/setup.ps1 @@ -10,8 +10,10 @@ #> [CmdletBinding()] param( - [string]$InstallRoot = $PSScriptRoot, - [string]$UvVersion = "0.11.28" + [string]$InstallRoot = "", + [string]$UvVersion = "0.11.28", + [switch]$DiscoverExisting, + [switch]$ReuseOnly ) $ErrorActionPreference = "Stop" @@ -28,19 +30,137 @@ function Invoke-Checked { } } +function Test-CompleteNemotronInstall { + param([string]$Root) + if ([string]::IsNullOrWhiteSpace($Root)) { return $false } + foreach ($relativePath in @( + ".ready", + "runtime/Scripts/python.exe", + "model/config.json", + "model/model.safetensors", + "server.py" + )) { + if (-not (Test-Path (Join-Path $Root $relativePath))) { return $false } + } + return $true +} + +function Write-SourceRoot { + param( + [Parameter(Mandatory = $true)][string]$TargetRoot, + [Parameter(Mandatory = $true)][string]$SourceRoot + ) + New-Item -ItemType Directory -Force -Path $TargetRoot | Out-Null + $marker = Join-Path $TargetRoot ".source-root" + [System.IO.File]::WriteAllText( + $marker, + [System.IO.Path]::GetFullPath($SourceRoot), + [System.Text.UTF8Encoding]::new($false) + ) +} + +function Find-ExistingNemotronInstall { + param( + [string]$TargetRoot, + [switch]$SearchUserRepositories + ) + + $directCandidates = @($env:PARLEY_NEMOTRON_HOME, $PSScriptRoot) + foreach ($candidate in $directCandidates) { + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + $candidate = [System.IO.Path]::GetFullPath($candidate) + if ($candidate -ne $TargetRoot -and (Test-CompleteNemotronInstall $candidate)) { + return $candidate + } + } + if (-not $SearchUserRepositories -or [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + return $null + } + + # Older development builds provisioned directly inside a checkout. Search + # common repository locations once during interactive installer setup so a + # valid multi-GB model can be reused in place instead of downloaded again. + $searchRoots = [System.Collections.Generic.List[string]]::new() + foreach ($path in @( + (Join-Path $env:USERPROFILE "source/repos"), + (Join-Path $env:USERPROFILE "Documents") + )) { + if (Test-Path $path) { $searchRoots.Add($path) } + } + Get-ChildItem -LiteralPath $env:USERPROFILE -Directory -Filter "OneDrive*" -ErrorAction SilentlyContinue | + ForEach-Object { + $documents = Join-Path $_.FullName "Documents" + if (Test-Path $documents) { $searchRoots.Add($documents) } + } + + $seen = @{} + foreach ($searchRoot in $searchRoots) { + $fullSearchRoot = [System.IO.Path]::GetFullPath($searchRoot) + if ($seen.ContainsKey($fullSearchRoot)) { continue } + $seen[$fullSearchRoot] = $true + Write-Host "Checking $fullSearchRoot for an existing Nemotron installation..." + foreach ($marker in Get-ChildItem -LiteralPath $fullSearchRoot -File -Filter ".ready" -Recurse -Force -ErrorAction SilentlyContinue) { + $candidate = $marker.Directory.FullName + if ($candidate -ne $TargetRoot -and (Test-CompleteNemotronInstall $candidate)) { + return $candidate + } + } + } + return $null +} + +if ([string]::IsNullOrWhiteSpace($InstallRoot)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $InstallRoot = Join-Path $env:LOCALAPPDATA "Parley/nemotron" + } + else { + $InstallRoot = $PSScriptRoot + } +} + $InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) $readyMarker = Join-Path $InstallRoot ".ready" +$sourceRootMarker = Join-Path $InstallRoot ".source-root" $venvDir = Join-Path $InstallRoot "runtime" $pythonExe = Join-Path $venvDir "Scripts/python.exe" $modelDir = Join-Path $InstallRoot "model" $toolsDir = Join-Path $InstallRoot "tools" $uvExe = Join-Path $toolsDir "uv.exe" -if ((Test-Path $readyMarker) -and (Test-Path $pythonExe) -and (Test-Path (Join-Path $modelDir "model.safetensors"))) { +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +foreach ($supportFile in @("download_model.py", "server.py", "validate_install.py")) { + $source = Join-Path $PSScriptRoot $supportFile + $destination = Join-Path $InstallRoot $supportFile + if ((Test-Path $source) -and ([System.IO.Path]::GetFullPath($source) -ne [System.IO.Path]::GetFullPath($destination))) { + Copy-Item $source $destination -Force + } +} + +if (Test-CompleteNemotronInstall $InstallRoot) { Write-Host "Nemotron 3.5 ASR is already provisioned; keeping the existing installation." exit 0 } +if (Test-Path $sourceRootMarker) { + $sourceRoot = (Get-Content $sourceRootMarker -Raw).Trim().TrimStart([char]0xFEFF) + if (Test-CompleteNemotronInstall $sourceRoot) { + Write-Host "Nemotron 3.5 ASR is already provisioned at $sourceRoot; reusing it." + exit 0 + } + Remove-Item $sourceRootMarker -Force -ErrorAction SilentlyContinue +} + +$existingRoot = Find-ExistingNemotronInstall -TargetRoot $InstallRoot -SearchUserRepositories:$DiscoverExisting +if ($existingRoot) { + Write-SourceRoot -TargetRoot $InstallRoot -SourceRoot $existingRoot + Write-Host "Reusing existing Nemotron 3.5 ASR installation at $existingRoot." + exit 0 +} +if ($ReuseOnly) { + Write-Host "No reusable Nemotron installation was found." + exit 0 +} + $gpu = & nvidia-smi -L 2>$null if ($LASTEXITCODE -ne 0 -or -not ($gpu -match "GPU ")) { Write-Host "No usable NVIDIA GPU detected; Parley will use bundled CPU Whisper." @@ -81,6 +201,7 @@ Write-Host "Eligible NVIDIA GPU detected: $eligibleGPU" New-Item -ItemType Directory -Force -Path $InstallRoot, $toolsDir, $modelDir | Out-Null Remove-Item $readyMarker -Force -ErrorAction SilentlyContinue +Remove-Item $sourceRootMarker -Force -ErrorAction SilentlyContinue if (-not (Test-Path $uvExe)) { $asset = "uv-x86_64-pc-windows-msvc.zip" @@ -109,7 +230,10 @@ $env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1" if (-not (Test-Path $pythonExe)) { Write-Host "Installing private Python 3.11 runtime..." - Invoke-Checked $uvExe python install 3.11 + # The runtime is private to Parley; do not also create a user-profile Python + # shim under ~/.local/bin. A pre-existing shim is what produced the alarming + # but otherwise harmless "Executable already exists" warning in setup. + Invoke-Checked $uvExe python install 3.11 --no-bin Invoke-Checked $uvExe venv $venvDir --python 3.11 --managed-python } From adbc3ff6ff66c0343a789f882d394b7714d1e3f4 Mon Sep 17 00:00:00 2001 From: Tom Vokac Date: Wed, 15 Jul 2026 01:32:32 -0400 Subject: [PATCH 2/3] fix: support Go 1.25 process supervision --- internal/stt/process_tree_windows.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/stt/process_tree_windows.go b/internal/stt/process_tree_windows.go index 81d0487..5087d41 100644 --- a/internal/stt/process_tree_windows.go +++ b/internal/stt/process_tree_windows.go @@ -31,16 +31,19 @@ func superviseProcessTree(process *os.Process) (func(), error) { closeJob() return nil, err } - var assignErr error - if err = process.WithHandle(func(handle uintptr) { - assignErr = windows.AssignProcessToJobObject(job, windows.Handle(handle)) - }); err != nil { + processHandle, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, + false, + uint32(process.Pid), + ) + if err != nil { closeJob() return nil, err } - if assignErr != nil { + defer windows.CloseHandle(processHandle) + if err = windows.AssignProcessToJobObject(job, processHandle); err != nil { closeJob() - return nil, assignErr + return nil, err } return closeJob, nil } From db21a3a169f3b423898d2921b47dfe7c4a8ed578 Mon Sep 17 00:00:00 2001 From: Tom Vokac Date: Wed, 15 Jul 2026 01:43:10 -0400 Subject: [PATCH 3/3] fix: make Nemotron provisioning cancellable --- build/windows/nsis/project.nsi | 58 +++++++++--------------- internal/buildcheck/subsystem_test.go | 12 ++++- resources/nemotron/provision.ps1 | 64 +++++++++++++++++++++++++++ resources/nemotron/setup.ps1 | 34 +++++++++++--- 4 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 resources/nemotron/provision.ps1 diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi index bc87db5..dc88fab 100644 --- a/build/windows/nsis/project.nsi +++ b/build/windows/nsis/project.nsi @@ -54,7 +54,6 @@ ManifestDPIAware true !define MUI_ICON "..\icon.ico" !define MUI_UNICON "..\icon.ico" # !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 -!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps !define MUI_ABORTWARNING # This will warn the user if they exit from the installer. !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. @@ -82,11 +81,13 @@ ShowInstDetails show # This will always show the installation details. Var IsUpgrade Var NemotronRoot +Var NemotronProvisionRequested Function .onInit !insertmacro wails.checkArchitecture StrCpy $IsUpgrade "0" + StrCpy $NemotronProvisionRequested "0" # On update, honor the location of a previously installed copy (recorded below # as InstallLocation) so we overwrite it in place instead of installing a @@ -127,6 +128,7 @@ Section File /r "..\..\..\resources\whisper\*" SetOutPath "$INSTDIR\resources\nemotron" File "..\..\..\resources\nemotron\download_model.py" + File "..\..\..\resources\nemotron\provision.ps1" File "..\..\..\resources\nemotron\server.py" File "..\..\..\resources\nemotron\setup.ps1" File "..\..\..\resources\nemotron\validate_install.py" @@ -156,40 +158,13 @@ Section StrCmp $1 "0" nemotron_gpu_found nemotron_no_gpu nemotron_gpu_found: - # Before offering a multi-GB download, search common repository folders - # for the complete model used by pre-shared-storage development builds. - # Reuse is recorded as a tiny pointer under the stable per-user root. - DetailPrint "Checking for an existing Nemotron development installation..." - ${DisableX64FSRedirection} - nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1" -InstallRoot "$NemotronRoot" -DiscoverExisting -ReuseOnly' - ${EnableX64FSRedirection} - Pop $1 - IfFileExists "$NemotronRoot\.ready" nemotron_present nemotron_check_discovered_source - - nemotron_check_discovered_source: - IfFileExists "$NemotronRoot\.source-root" nemotron_present nemotron_missing - - nemotron_missing: StrCmp $IsUpgrade "0" nemotron_provision IfSilent nemotron_silent_skip - MessageBox MB_YESNO|MB_ICONQUESTION "Parley found an NVIDIA GPU, but Nemotron 3.5 ASR Streaming is not installed.$\r$\n$\r$\nDownload and install it now? This can download several gigabytes and may take several minutes. CPU Whisper remains available if you choose No." /SD IDNO IDYES nemotron_provision IDNO nemotron_declined + MessageBox MB_YESNO|MB_ICONQUESTION "Parley found an NVIDIA GPU, but Nemotron 3.5 ASR Streaming is not installed.$\r$\n$\r$\nFind or download it now? After Parley finishes installing, a separate progress window will open. You can close that window at any time to cancel; partial downloads are retained so a later attempt can resume. CPU Whisper remains available if you choose No." /SD IDNO IDYES nemotron_provision IDNO nemotron_declined nemotron_provision: - DetailPrint "NVIDIA GPU detected; provisioning Nemotron 3.5 ASR Streaming..." - # Launch 64-bit PowerShell so setup.ps1 can also resolve nvidia-smi and - # validate VRAM/compute capability without WOW64 redirection. - ${DisableX64FSRedirection} - nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1" -InstallRoot "$NemotronRoot"' - ${EnableX64FSRedirection} - Pop $1 - StrCmp $1 "0" 0 nemotron_provision_failed - IfFileExists "$NemotronRoot\.ready" nemotron_provisioned nemotron_check_provisioned_source - - nemotron_check_provisioned_source: - IfFileExists "$NemotronRoot\.source-root" nemotron_provisioned nemotron_not_ready - - nemotron_provisioned: - DetailPrint "Nemotron provisioning completed; Parley will use it automatically." + StrCpy $NemotronProvisionRequested "1" + DetailPrint "Nemotron setup queued in a separate cancellable progress window." Goto nemotron_done nemotron_present: @@ -208,13 +183,6 @@ Section DetailPrint "Silent upgrade detected; skipping optional Nemotron download and using bundled CPU Whisper." Goto nemotron_done - nemotron_not_ready: - DetailPrint "Nemotron was not provisioned for this GPU; Parley will use bundled CPU Whisper." - Goto nemotron_done - - nemotron_provision_failed: - DetailPrint "Nemotron provisioning did not complete (exit $1). Parley will use bundled CPU Whisper." - nemotron_done: SetOutPath $INSTDIR @@ -234,6 +202,20 @@ Section !else WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR" !endif + + # Never hold the installer UI hostage to Python/PyTorch/Hugging Face work. + # The visible asynchronous window carries progress and may be closed to + # cancel. setup.ps1 retains caches/partial files so rerunning can resume. + StrCmp $NemotronProvisionRequested "1" 0 nemotron_launch_done + DetailPrint "Opening cancellable Nemotron setup window..." + ${DisableX64FSRedirection} + ClearErrors + Exec '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\provision.ps1" -InstallRoot "$NemotronRoot"' + ${EnableX64FSRedirection} + IfErrors 0 nemotron_launch_done + DetailPrint "Could not launch Nemotron setup; Parley will use bundled CPU Whisper." + + nemotron_launch_done: SectionEnd Section "uninstall" diff --git a/internal/buildcheck/subsystem_test.go b/internal/buildcheck/subsystem_test.go index 1cbed9e..a6e0e28 100644 --- a/internal/buildcheck/subsystem_test.go +++ b/internal/buildcheck/subsystem_test.go @@ -40,7 +40,9 @@ func TestInstallerOffersMissingNemotronOnUpgrade(t *testing.T) { `IfFileExists "$INSTDIR\resources\nemotron\.ready"`, `StrCpy $NemotronRoot "$LOCALAPPDATA\Parley\nemotron"`, `IfFileExists "$NemotronRoot\.source-root"`, - `-DiscoverExisting -ReuseOnly`, + `Var NemotronProvisionRequested`, + `Exec '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe"`, + `resources\nemotron\provision.ps1`, `StrCmp $IsUpgrade "0" nemotron_provision`, `MessageBox MB_YESNO|MB_ICONQUESTION`, `IfSilent nemotron_silent_skip`, @@ -54,6 +56,12 @@ func TestInstallerOffersMissingNemotronOnUpgrade(t *testing.T) { if strings.Contains(installer, "cmd /C nvidia-smi") { t.Fatalf("%s probes nvidia-smi through 32-bit cmd; WOW64 redirection hides the System32 executable", path) } + if strings.Contains(installer, "MUI_FINISHPAGE_NOAUTOCLOSE") { + t.Fatalf("%s forces users to remain on the installer details page after setup completes", path) + } + if strings.Contains(installer, `nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\nemotron\setup.ps1"`) { + t.Fatalf("%s blocks the installer UI on Nemotron provisioning", path) + } if strings.Contains(installer, `File /r "..\..\..\resources"`) { t.Fatalf("%s recursively bundles all generated Nemotron assets from a developer checkout", path) } @@ -71,6 +79,8 @@ func TestNemotronSetupUsesPrivatePythonAndSharedStorage(t *testing.T) { `Invoke-Checked $uvExe python install 3.11 --no-bin`, `Test-CompleteNemotronInstall`, `Write-SourceRoot`, + `$env:HF_TOKEN_PATH = $existingToken`, + `$env:HF_XET_HIGH_PERFORMANCE = "1"`, } { if !strings.Contains(setup, required) { t.Fatalf("%s no longer contains %q", path, required) diff --git a/resources/nemotron/provision.ps1 b/resources/nemotron/provision.ps1 new file mode 100644 index 0000000..3abf060 --- /dev/null +++ b/resources/nemotron/provision.ps1 @@ -0,0 +1,64 @@ +<# +.SYNOPSIS + Runs Nemotron provisioning in a visible, cancellable progress window. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InstallRoot +) + +$ErrorActionPreference = "Stop" +$InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +$logPath = Join-Path $InstallRoot "provision.log" +$transcribing = $false +$succeeded = $false + +try { + try { + $Host.UI.RawUI.WindowTitle = "Parley - Nemotron 3.5 ASR Setup" + } + catch {} + + Write-Host "Parley - Nemotron 3.5 ASR Setup" -ForegroundColor Cyan + Write-Host "" + Write-Host "This window searches for an existing model before downloading anything." + Write-Host "Close this window at any time to cancel. A later attempt will resume cached and partial downloads." + Write-Host "Progress is also saved to $logPath" + Write-Host "" + + try { + Start-Transcript -Path $logPath -Append | Out-Null + $transcribing = $true + } + catch { + Write-Warning "Could not start the provisioning transcript: $_" + } + + & (Join-Path $PSScriptRoot "setup.ps1") -InstallRoot $InstallRoot -DiscoverExisting + if (-not ((Test-Path (Join-Path $InstallRoot ".ready")) -or (Test-Path (Join-Path $InstallRoot ".source-root")))) { + throw "Nemotron setup ended without a complete installation. CPU Whisper remains available." + } + $succeeded = $true + Write-Host "" + Write-Host "Nemotron setup completed. Parley will select it the next time the app starts." -ForegroundColor Green +} +catch { + Write-Host "" + Write-Host "Nemotron setup did not complete: $_" -ForegroundColor Red + Write-Host "CPU Whisper remains available. Run the Parley installer again later to resume." +} +finally { + if ($transcribing) { + try { Stop-Transcript | Out-Null } catch {} + } +} + +if ($succeeded) { + Write-Host "This window will close in 5 seconds." + Start-Sleep -Seconds 5 + exit 0 +} + +Read-Host "Press Enter to close" +exit 1 diff --git a/resources/nemotron/setup.ps1 b/resources/nemotron/setup.ps1 index 9d72a76..37011ee 100644 --- a/resources/nemotron/setup.ps1 +++ b/resources/nemotron/setup.ps1 @@ -138,14 +138,14 @@ foreach ($supportFile in @("download_model.py", "server.py", "validate_install.p if (Test-CompleteNemotronInstall $InstallRoot) { Write-Host "Nemotron 3.5 ASR is already provisioned; keeping the existing installation." - exit 0 + return } if (Test-Path $sourceRootMarker) { $sourceRoot = (Get-Content $sourceRootMarker -Raw).Trim().TrimStart([char]0xFEFF) if (Test-CompleteNemotronInstall $sourceRoot) { Write-Host "Nemotron 3.5 ASR is already provisioned at $sourceRoot; reusing it." - exit 0 + return } Remove-Item $sourceRootMarker -Force -ErrorAction SilentlyContinue } @@ -154,17 +154,17 @@ $existingRoot = Find-ExistingNemotronInstall -TargetRoot $InstallRoot -SearchUse if ($existingRoot) { Write-SourceRoot -TargetRoot $InstallRoot -SourceRoot $existingRoot Write-Host "Reusing existing Nemotron 3.5 ASR installation at $existingRoot." - exit 0 + return } if ($ReuseOnly) { Write-Host "No reusable Nemotron installation was found." - exit 0 + return } $gpu = & nvidia-smi -L 2>$null if ($LASTEXITCODE -ne 0 -or -not ($gpu -match "GPU ")) { Write-Host "No usable NVIDIA GPU detected; Parley will use bundled CPU Whisper." - exit 0 + return } # Each checkpoint instance is about 1.3 GB in FP16 and Parley's two concurrent @@ -195,7 +195,7 @@ foreach ($row in $gpuRows) { } if (-not $eligibleGPU) { Write-Host "No NVIDIA GPU with at least 6 GiB VRAM and compute capability 7.0+ was found; using CPU Whisper." - exit 0 + return } Write-Host "Eligible NVIDIA GPU detected: $eligibleGPU" @@ -225,8 +225,30 @@ if (-not (Test-Path $uvExe)) { $env:UV_PYTHON_INSTALL_DIR = Join-Path $InstallRoot "python" $env:UV_CACHE_DIR = Join-Path $InstallRoot "cache/uv" $env:UV_LINK_MODE = "copy" + +# Keep the large cache private to Parley without hiding a token created by a +# previous `hf auth login`. HF_TOKEN still takes precedence when explicitly set. +if ([string]::IsNullOrWhiteSpace($env:HF_TOKEN) -and [string]::IsNullOrWhiteSpace($env:HF_TOKEN_PATH)) { + $userCache = if ([string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) { + Join-Path $HOME ".cache" + } + else { + $env:XDG_CACHE_HOME + } + $existingToken = Join-Path $userCache "huggingface/token" + if (Test-Path $existingToken) { + $env:HF_TOKEN_PATH = $existingToken + Write-Host "Using the existing Hugging Face login for authenticated downloads." + } +} $env:HF_HOME = Join-Path $InstallRoot "cache/huggingface" $env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1" +if ([string]::IsNullOrWhiteSpace($env:HF_XET_HIGH_PERFORMANCE)) { + $env:HF_XET_HIGH_PERFORMANCE = "1" +} +if ([string]::IsNullOrWhiteSpace($env:HF_HUB_DOWNLOAD_TIMEOUT)) { + $env:HF_HUB_DOWNLOAD_TIMEOUT = "60" +} if (-not (Test-Path $pythonExe)) { Write-Host "Installing private Python 3.11 runtime..."