Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 47 additions & 34 deletions build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -81,11 +80,14 @@ OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the i
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
Expand All @@ -107,7 +109,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
Expand All @@ -116,18 +118,32 @@ 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\provision.ps1"
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:
Expand All @@ -144,25 +160,15 @@ Section
nemotron_gpu_found:
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 "$INSTDIR\resources\nemotron"'
${EnableX64FSRedirection}
Pop $1
StrCmp $1 "0" 0 nemotron_provision_failed
IfFileExists "$INSTDIR\resources\nemotron\.ready" 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:
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:
Expand All @@ -177,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
Expand All @@ -203,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"
Expand Down
36 changes: 36 additions & 0 deletions internal/buildcheck/subsystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ 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"`,
`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`,
Expand All @@ -50,4 +56,34 @@ 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)
}
}

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`,
`$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)
}
}
}
9 changes: 9 additions & 0 deletions internal/stt/process_tree_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package stt

import "os"

func superviseProcessTree(_ *os.Process) (func(), error) {
return func() {}, nil
}
49 changes: 49 additions & 0 deletions internal/stt/process_tree_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//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
}
processHandle, err := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE,
false,
uint32(process.Pid),
)
if err != nil {
closeJob()
return nil, err
}
defer windows.CloseHandle(processHandle)
if err = windows.AssignProcessToJobObject(job, processHandle); err != nil {
closeJob()
return nil, err
}
return closeJob, nil
}
33 changes: 33 additions & 0 deletions internal/stt/process_tree_windows_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
13 changes: 13 additions & 0 deletions internal/stt/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package stt
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 6 additions & 13 deletions meeting_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading