Compare commits

...

4 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek eb3c569078 chore: bump version to 0.0.10 2026-04-24 22:17:45 +09:00
Timothy Jaeryang Baek 3350da65ec feat: auto-recover from GPU process crashes by disabling GPU sandbox
When the GPU process crashes fatally (common with certain NVIDIA/Intel
driver versions on Windows), automatically write a marker file and
relaunch with --disable-gpu-sandbox so users don't have to manually
edit shortcut targets.

- Detect GPU crashes via child-process-gone event
- Persist .gpu-sandbox-disabled marker across restarts
- Apply --disable-gpu-sandbox preemptively on subsequent launches
- Call disableDomainBlockingFor3DAPIs() to prevent WebGL blacklisting
- Clean up marker on app reset so users can re-test after driver updates
- Expose gpuSandboxDisabled in app:info for diagnostics

Fixes #110
2026-04-24 22:15:21 +09:00
Timothy Jaeryang Baek 953327b9ef refac: styling 2026-04-20 16:21:59 +09:00
Timothy Jaeryang Baek 1a56df0c6e refac 2026-04-20 16:14:03 +09:00
6 changed files with 209 additions and 75 deletions
+14
View File
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.10] - 2026-04-24
### Added
- **Concurrent Model Downloads.** Multiple Hugging Face models can now be downloaded simultaneously, each with independent progress tracking and per-file cancel buttons.
### Changed
- **Models Settings UI.** Cleaner layout with inline progress bars, hover-reveal download buttons, and breadcrumb-style repo navigation.
### Fixed
- **GPU Process Crash Recovery.** The app now automatically detects GPU process crashes (common with certain NVIDIA/Intel drivers on Windows) and relaunches with the GPU sandbox disabled, instead of closing immediately. No manual shortcut edits required.
## [0.0.9] - 2026-04-20
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.9",
"version": "0.0.10",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+71 -3
View File
@@ -89,10 +89,31 @@ log.transports.file.resolvePathFn = () => getLogFilePath('main')
import icon from '../../resources/icon.png?asset'
import { existsSync, writeFileSync, unlinkSync } from 'fs'
if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox')
}
// ─── GPU Crash Recovery ─────────────────────────────────
// When the GPU process crashes fatally (common on certain NVIDIA/Intel
// driver + Windows combos), we write a marker file and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut properties. On the next launch the marker is detected and
// the switch is applied preemptively.
const gpuCrashMarkerPath = join(app.getPath('userData'), '.gpu-sandbox-disabled')
const gpuSandboxDisabled = existsSync(gpuCrashMarkerPath)
if (gpuSandboxDisabled) {
log.info('GPU sandbox disabled due to previous GPU process crash')
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
// Prevent Chromium from permanently blocking WebGL / 3-D APIs after
// repeated GPU process crashes within the same session.
app.disableDomainBlockingFor3DAPIs()
// ─── State ──────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null
@@ -948,6 +969,15 @@ const resetAppHandler = async () => {
} catch (e) {
log.warn('Failed to uninstall llama.cpp during reset:', e)
}
// Remove GPU crash marker so sandbox is re-tested on next launch
try {
if (existsSync(gpuCrashMarkerPath)) {
unlinkSync(gpuCrashMarkerPath)
log.info('GPU crash marker removed during reset')
}
} catch (e) {
log.warn('Failed to remove GPU crash marker during reset:', e)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
await resetApp()
CONFIG = await getConfig() // reload from defaults since config.json was deleted
@@ -998,6 +1028,43 @@ if (!gotTheLock) {
}
electronApp.setAppUserModelId('com.openwebui.desktop')
// ─── GPU Process Crash Recovery ──────────────────
// If the GPU process exits fatally (e.g. sandbox init failure on
// certain NVIDIA/Intel drivers), write a marker and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut targets (see issue #110).
app.on('child-process-gone', (_event, details) => {
if (details.type === 'GPU') {
log.error(
`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`
)
// Only auto-recover from fatal crashes, not normal/clean exits
if (
details.reason === 'crashed' ||
details.reason === 'launch-failed' ||
details.reason === 'abnormal-exit'
) {
if (!gpuSandboxDisabled) {
log.info('Writing GPU crash marker and relaunching with --disable-gpu-sandbox')
try {
writeFileSync(gpuCrashMarkerPath, new Date().toISOString(), 'utf-8')
} catch (e) {
log.warn('Failed to write GPU crash marker:', e)
}
app.relaunch({ args: [...process.argv.slice(1), '--disable-gpu-sandbox'] })
app.exit(0)
}
}
}
})
// If we previously set the GPU sandbox marker and this session
// started successfully, log it so it's visible in diagnostics.
if (gpuSandboxDisabled) {
log.info('Running with GPU sandbox disabled (marker file present)')
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
@@ -1010,7 +1077,8 @@ if (!gotTheLock) {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
username: require('os').userInfo().username
username: require('os').userInfo().username,
gpuSandboxDisabled
}))
ipcMain.handle('app:contentPreloadPath', () => {
@@ -1647,8 +1715,8 @@ if (!gotTheLock) {
ipcMain.handle('huggingface:models:delete', (_event, repo: string, filename: string) => {
return deleteModel(repo, filename)
})
ipcMain.handle('huggingface:models:cancel', () => {
cancelDownload()
ipcMain.handle('huggingface:models:cancel', (_event, repo?: string, filename?: string) => {
cancelDownload(repo, filename)
return true
})
ipcMain.handle('huggingface:search', async (_event, query: string, token?: string) => {
+28 -10
View File
@@ -62,15 +62,28 @@ const writeManifest = (models: HfModel[]): void => {
// ─── Public API ─────────────────────────────────────────
let activeDownloadAbort: AbortController | null = null
const activeDownloads = new Map<string, AbortController>()
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
/**
* Cancel the current download in progress.
* Cancel a specific download in progress.
* If no repo/filename given, cancels ALL active downloads.
*/
export const cancelDownload = (): void => {
if (activeDownloadAbort) {
activeDownloadAbort.abort()
activeDownloadAbort = null
export const cancelDownload = (repo?: string, filename?: string): void => {
if (repo && filename) {
const key = downloadKey(repo, filename)
const ctrl = activeDownloads.get(key)
if (ctrl) {
ctrl.abort()
activeDownloads.delete(key)
}
} else {
// Cancel all
for (const ctrl of activeDownloads.values()) {
ctrl.abort()
}
activeDownloads.clear()
}
}
@@ -136,8 +149,13 @@ export const downloadModel = async (
headers['Authorization'] = `Bearer ${token}`
}
activeDownloadAbort = new AbortController()
const { signal } = activeDownloadAbort
const key = downloadKey(repo, filename)
// Cancel any existing download for the same file
activeDownloads.get(key)?.abort()
const abortController = new AbortController()
activeDownloads.set(key, abortController)
const { signal } = abortController
// Use fetch for streaming download with progress
const response = await fetch(downloadUrl, {
@@ -183,7 +201,7 @@ export const downloadModel = async (
writeStream.end()
// Clean up partial download
try { fs.unlinkSync(tmpPath) } catch {}
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
throw err
} finally {
writeStream.end()
@@ -192,7 +210,7 @@ export const downloadModel = async (
// Rename tmp to final
fs.renameSync(tmpPath, destPath)
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
// Update manifest
const manifest = readManifest()
+2 -1
View File
@@ -155,7 +155,8 @@ const api = {
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
deleteHfModel: (repo: string, filename: string) =>
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
cancelHfDownload: () => ipcRenderer.invoke('huggingface:models:cancel'),
cancelHfDownload: (repo?: string, filename?: string) =>
ipcRenderer.invoke('huggingface:models:cancel', repo, filename),
searchHfModels: (query: string, token?: string) =>
ipcRenderer.invoke('huggingface:search', query, token),
getHfRepoFiles: (repo: string, token?: string) =>
@@ -41,8 +41,10 @@
let repoFiles = $state<HfFileInfo[]>([])
let loadingFiles = $state(false)
// Download state — track active download in the "Downloaded" section
let activeDownload = $state<{ repo: string; filename: string; percent: number } | null>(null)
// Download state — track active downloads in the "Downloaded" section
let activeDownloads = $state<Map<string, { repo: string; filename: string; percent: number }>>(new Map())
const dlKey = (repo: string, filename: string): string => `${repo}/${filename}`
onMount(async () => {
models = await window.electronAPI.listHfModels()
@@ -52,15 +54,22 @@
window.electronAPI.onData((data: any) => {
if (data.type === 'status:huggingface-download') {
const d = data.data
const key = dlKey(d.repo, d.filename)
if (d?.status === 'downloading') {
activeDownload = { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 }
const updated = new Map(activeDownloads)
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
activeDownloads = updated
}
if (d?.status === 'done') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
}
if (d?.status === 'failed') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
}
}
})
@@ -109,22 +118,29 @@
}
const startDownload = async (repo: string, filename: string, size?: number) => {
activeDownload = { repo, filename, percent: 0 }
const key = dlKey(repo, filename)
const updated = new Map(activeDownloads)
updated.set(key, { repo, filename, percent: 0 })
activeDownloads = updated
try {
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
} catch (e) {
console.error('Failed to download model:', e)
activeDownload = null
const cleaned = new Map(activeDownloads)
cleaned.delete(key)
activeDownloads = cleaned
}
}
const cancelDownload = async () => {
const cancelDownload = async (repo: string, filename: string) => {
try {
await window.electronAPI.cancelHfDownload()
await window.electronAPI.cancelHfDownload(repo, filename)
} catch (e) {
console.error('Failed to cancel download:', e)
}
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(dlKey(repo, filename))
activeDownloads = updated
}
const removeModel = async (repo: string, filename: string) => {
@@ -143,9 +159,15 @@
}
const isDownloading = (repo: string, filename: string): boolean => {
return activeDownload?.repo === repo && activeDownload?.filename === filename
return activeDownloads.has(dlKey(repo, filename))
}
const getDownloadPercent = (repo: string, filename: string): number => {
return activeDownloads.get(dlKey(repo, filename))?.percent ?? 0
}
const hasActiveDownloads = $derived(activeDownloads.size > 0)
const formatSize = (bytes: number): string => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
@@ -180,50 +202,50 @@
</button>
</div>
<!-- Downloaded models + active download -->
<!-- Downloaded models + active downloads -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
{#if models.length > 0 || activeDownload}
<div class="flex flex-col gap-1.5">
{#if models.length > 0 || hasActiveDownloads}
<div class="flex flex-col">
<!-- Active download in progress -->
{#if activeDownload}
<div class="px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center justify-between gap-2 mb-1.5">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{activeDownload.filename}</div>
<div class="text-[10px] opacity-25 truncate">{activeDownload.repo} · {$i18n.t('common.downloading')}</div>
<!-- Active downloads -->
{#each [...activeDownloads.values()] as dl (dlKey(dl.repo, dl.filename))}
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-[12px] opacity-60 truncate font-mono">{dl.filename}</span>
<span class="text-[10px] opacity-30 font-mono shrink-0">{dl.percent.toFixed(1)}%</span>
</div>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={cancelDownload}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {dl.percent}%"
></div>
</div>
<div class="text-[10px] opacity-20 mt-1 truncate">{dl.repo}</div>
</div>
<div class="w-full h-1 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/80 rounded-full"
style="width: {activeDownload.percent}%"
></div>
</div>
<div class="text-[10px] opacity-25 mt-1 text-right font-mono">{activeDownload.percent.toFixed(1)}%</div>
<button
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => cancelDownload(dl.repo, dl.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
{/each}
<!-- Completed downloads -->
{#each models as model}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
<div class="text-[10px] opacity-25 truncate">{model.repo} · {formatSize(model.size)}</div>
<div class="text-[10px] opacity-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
</div>
<button
class="opacity-20 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? 'pointer-events-none' : ''}"
class="opacity-0 group-hover:opacity-30 hover:!opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? '!opacity-30 pointer-events-none' : ''}"
onclick={() => removeModel(model.repo, model.filename)}
title={$i18n.t('settings.models.deleteModel')}
>
@@ -239,7 +261,7 @@
{/each}
</div>
{:else}
<div class="text-[11px] opacity-40 py-3">{$i18n.t('settings.models.noModels')}</div>
<div class="text-[11px] opacity-20 py-3">{$i18n.t('settings.models.noModels')}</div>
{/if}
</div>
@@ -248,13 +270,13 @@
<div class="text-[12px] opacity-50 mb-2">
{#if selectedRepo}
<button
class="opacity-50 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1"
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1 font-mono truncate"
onclick={backToSearch}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{$i18n.t('common.back')}
<span class="truncate">{selectedRepo}</span>
</button>
{:else}
{$i18n.t('settings.models.downloadFromHF')}
@@ -263,10 +285,6 @@
{#if selectedRepo}
<!-- Repo file browser -->
<div class="mb-2">
<div class="text-[12px] opacity-60 font-mono truncate mb-2">{selectedRepo}</div>
</div>
{#if loadingFiles}
<div class="flex items-center gap-2 py-3 justify-center">
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
@@ -275,27 +293,42 @@
{:else if repoFiles.length === 0}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noGgufFiles')}</div>
{:else}
<div class="flex flex-col gap-1">
<div class="flex flex-col">
{#each repoFiles as file}
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
{@const dlActive = isDownloading(selectedRepo, file.filename)}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
<div class="text-[10px] opacity-25">{formatSize(file.size)}</div>
<div class="text-[10px] opacity-20 mt-0.5">{formatSize(file.size)}</div>
{#if dlActive}
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {getDownloadPercent(selectedRepo, file.filename)}%"
></div>
</div>
{/if}
</div>
{#if downloaded}
<span class="text-[10px] opacity-30 shrink-0 px-2">{$i18n.t('settings.models.downloaded')}</span>
<span class="text-[10px] opacity-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
{:else if dlActive}
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
<span class="text-[10px] opacity-40 font-mono">{activeDownload?.percent?.toFixed(0) ?? 0}%</span>
<span class="text-[10px] opacity-40 font-mono">{getDownloadPercent(selectedRepo, file.filename).toFixed(0)}%</span>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5"
onclick={() => cancelDownload(selectedRepo, file.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {activeDownload ? 'pointer-events-none opacity-10' : ''}"
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
disabled={!!activeDownload}
title={$i18n.t('common.download')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
@@ -329,10 +362,10 @@
{#if searchError}
<div class="text-[11px] text-red-400/70 text-center py-2">{searchError}</div>
{:else if searchResults.length > 0}
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto">
<div class="flex flex-col max-h-[300px] overflow-y-auto">
{#each searchResults as repo}
<button
class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] hover:bg-black/[0.06] dark:hover:bg-white/[0.08] rounded-xl transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa]"
class="flex items-center justify-between gap-2 py-2 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] rounded-lg transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa] bg-transparent px-1"
onclick={() => selectRepo(repo.id)}
>
<div class="min-w-0 flex-1">
@@ -352,7 +385,7 @@
</span>
</div>
</div>
<svg class="w-3 h-3 opacity-20 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 opacity-15 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>