fix(errors): don't claim the backend crashed with no evidence that it did

Two Apple Silicon reporters were told "it most likely crashed or was killed
mid-request" while generating. Neither bug report carried a crash marker,
because none had been recorded — the app had no evidence for the one thing
it asserted, and the advice that follows that sentence is Retry and Clean &
Retry, which rebuilds the whole Python environment to fix a backend that had
not died.

Two causes, both fixed here.

The desktop shell learns the backend died from a ~2 s poll: it has to notice
the child exit before it can write the marker. `apiFetch` asked for that
marker exactly once, at the instant the transport gave up, so it raced the
poll and lost either way round — a backend that really died was reported
with the vague sentence instead of its exit code and crash notice, and one
that never died was reported as dead anyway. `streamDropError` already waits
that poll out (#1119); the request path never did. The loop is now a shared
`awaitBackendCrashMarker`, used by both, with a shorter budget here because
the transport cascade has already cost the user a few seconds.

And the copy itself overshot what it could know. By construction it is
reached only once a crash has been looked for and not found, so it no longer
names one: it says the backend stopped answering with no crash recorded, and
that a heavy job holding the engine is the likelier story — which on a
memory-pressured Mac mid-generation it is. Updated in all 21 locales, since
a translation still asserting a crash would be the same bug in another
language.

The #1337 test that required the crash wording is updated with it: #1337
established that the backend had answered seconds earlier, not what silenced
it, and requiring the stronger claim is what pinned this in place.

Fixes #1802.
Fixes #1805.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
This commit is contained in:
Palash Debnath
2026-09-04 18:03:23 +05:30
co-authored by Claude Opus 5
parent f2302e8c95
commit 31d90def83
27 changed files with 201 additions and 53 deletions
+1
View File
@@ -58,6 +58,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- The "can't reach the backend" error no longer claims the backend crashed when nothing recorded a crash: the app now waits out the shell's death poll, so a real crash is reported with its exit code and crash notice, and a backend that is merely wedged on a heavy job is described as such instead of sending you to rebuild the Python environment (#1802, #1805)
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
+15 -7
View File
@@ -12,10 +12,10 @@
// under `node --experimental-strip-types`, whose ESM resolver requires real
// file extensions (tsconfig has allowImportingTsExtensions for tsc).
import {
awaitBackendCrashMarker,
getUnacknowledgedBackendCrash,
describeCrashExit,
crashAge,
type BackendCrashMarker,
} from '../utils/backendCrash.ts';
import { backendLifecycleStage, type BackendLifecycle } from '../utils/backendLifecycle.ts';
import { scrubText } from '../utils/scrub.js';
@@ -429,12 +429,20 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
// watcher, or (browser/dev/Docker) by the backend's own run sentinel —
// tell the honest story instead of the vague "can't reach" and let
// BackendCrashNotice raise its "View crash details" affordance.
let crash: BackendCrashMarker | null = null;
try {
crash = await getUnacknowledgedBackendCrash();
} catch {
/* forensics unavailable — fall through to the generic message */
}
//
// #1802/#1805: asking ONCE, right now, races the shell's ~2 s death
// poll and loses — the marker for a backend that just died has usually
// not been written yet. We then concluded "no crash" and said so, which
// is how a give-up came to assert "it most likely crashed or was killed
// mid-request" while carrying no forensics to back it. `streamDropError`
// already waits out that poll (#1119); this path never did. The budget
// is shorter than the stream path's 8 s because the retry cascade above
// has already cost the user a few seconds — two poll intervals is enough
// to stop losing the race without turning every failure into a stall.
const { crash } = await awaitBackendCrashMarker(getUnacknowledgedBackendCrash, {
waitMs: 4_000,
intervalMs: 1_000,
});
if (crash) {
try {
window.dispatchEvent(new CustomEvent('ov:backend-crashed', { detail: crash }));
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "سيؤدي هذا إلى حذف جميع مشاريع القصص والكتب الصوتية المحفوظة وأي مسودة غير محفوظة نهائيًا حتى يتمكن VoiceStudio من الفتح مجددًا. لا يمكن التراجع عن هذا الإجراء. هل تريد المتابعة؟"
},
"backendUnreachable": {
"contact_recent": "كان يستجيب قبل {{ago}} ثم توقف عن الاستجابة — على الأرجح أنه انهار أو تم إنهاؤه في منتصف الطلب.",
"contact_recent": "كان يستجيب قبل {{ago}} ثم توقّف عن الاستجابة، ولم يُسجَّل أي تعطُّل — لذا قد يكون ما زال يعمل لكنه عالق في مهمة ثقيلة تحتجز المحرّك.",
"contact_never": "لم يستجب إطلاقًا في هذه الجلسة — ربما لم يبدأ التشغيل أصلًا.",
"dev": "لا يمكن الوصول إلى خلفية VoiceStudio المحلية. {{contact}} في `bun run dev` تعمل الخلفية بإعادة التحميل التلقائي، فأي تغيير في ملف — بما في ذلك الحفظ أثناء تنفيذ طلب — يعيد تشغيلها ويقطع الاتصال. أعد المحاولة أولًا؛ فإن كان الأمر إعادة تحميل فسينجح. وإن استمر الفشل، فافحص الطرفية التي تشغّل `bun run dev` بحثًا عن تتبّع Python أو رسالة خروج، وكذلك omnivoice.log في مجلد بيانات VoiceStudio.",
"server": "تعذّر الوصول إلى خادم الواجهة الخلفية لـ VoiceStudio. {{contact}} راجع سجلات الخادم لمعرفة السبب (مثل `docker logs <container>` أو `journalctl`) — ولاحظ أنه إذا كان Docker يقدّم هذه الصفحة، فقد تتوقف الصفحة نفسها مع الواجهة الخلفية.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Dadurch werden alle gespeicherten Story- und Hörbuchprojekte sowie jeder nicht gespeicherte Entwurf dauerhaft gelöscht, damit VoiceStudio wieder geöffnet werden kann. Dies kann nicht rückgängig gemacht werden. Fortfahren?"
},
"backendUnreachable": {
"contact_recent": "Es hat vor {{ago}} noch geantwortet und dann aufgehört zu reagieren — höchstwahrscheinlich ist es mitten in einer Anfrage abgestürzt oder wurde beendet.",
"contact_recent": "Es hat vor {{ago}} noch geantwortet und reagiert seitdem nicht mehr, und es wurde kein Absturz aufgezeichnet — es läuft also möglicherweise noch, hängt aber an einer aufwendigen Aufgabe, die die Engine blockiert.",
"contact_never": "Es hat in dieser Sitzung überhaupt nicht geantwortet — möglicherweise ist es nie gestartet.",
"dev": "Der lokale VoiceStudio-Dienst ist nicht erreichbar. {{contact}} Bei `bun run dev` läuft das Backend mit Auto-Reload: jede Dateiänderung — auch ein Speichern während einer laufenden Anfrage — startet es neu und bricht die Verbindung ab. Wiederholen Sie die Aktion zuerst; war es ein Reload, funktioniert es einfach. Bleibt es fehlerhaft, prüfen Sie das Terminal mit `bun run dev` auf einen Python-Traceback oder eine Exit-Meldung und omnivoice.log in Ihrem VoiceStudio-Datenordner.",
"server": "Der VoiceStudio-Backend-Server ist nicht erreichbar. {{contact}} Prüfe die Server-Logs auf die Ursache (z. B. `docker logs <container>` oder `journalctl`) — und beachte: Wenn Docker diese Seite ausliefert, kann die Seite selbst zusammen mit dem Backend ausfallen.",
+1 -1
View File
@@ -2227,7 +2227,7 @@
"retry_hint": "Try \"Retry\" in Settings → Logs → Backend. If it fails again, \"Clean & Retry\" rebuilds the environment from scratch."
},
"backendUnreachable": {
"contact_recent": "It was answering {{ago}} ago and then stopped responding — it most likely crashed or was killed mid-request.",
"contact_recent": "It was answering {{ago}} ago and then stopped responding, and no crash was recorded — so it may still be alive but wedged on a heavy job that is holding the engine.",
"contact_never": "It has not answered at all this session — it may never have started.",
"dev": "Can't reach the local VoiceStudio backend. {{contact}} In `bun run dev` the backend runs with auto-reload, so any file change — including a save while a request was in flight — restarts it and drops the connection. Retry the action first; if it was a reload, it just works. If it keeps failing, check the terminal running `bun run dev` for a Python traceback or an exit banner, and omnivoice.log in your VoiceStudio data folder for the last thing the backend logged.",
"server": "Can't reach the VoiceStudio backend server. {{contact}} Check the server logs for the cause (e.g. `docker logs <container>` or `journalctl`) — and note that if Docker serves this page, the page itself can go down with the backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Esto elimina permanentemente todos los proyectos guardados de Historias y Audiolibros y cualquier borrador sin guardar para que VoiceStudio pueda volver a abrirse. No se puede deshacer. ¿Continuar?"
},
"backendUnreachable": {
"contact_recent": "Estaba respondiendo hace {{ago}} y dejó de responder — lo más probable es que se haya bloqueado o haya sido terminado en mitad de una petición.",
"contact_recent": "Respondía hace {{ago}} y luego dejó de responder, y no se registró ningún fallo — así que puede seguir vivo pero atascado en una tarea pesada que mantiene ocupado el motor.",
"contact_never": "No ha respondido en toda esta sesión — puede que nunca haya llegado a iniciarse.",
"dev": "No se puede contactar con el backend local de VoiceStudio. {{contact}} Con `bun run dev` el backend se ejecuta con recarga automática: cualquier cambio de archivo — incluido guardar mientras había una petición en curso — lo reinicia y corta la conexión. Reintente la acción primero; si fue una recarga, funcionará. Si sigue fallando, revise la terminal con `bun run dev` por un traceback de Python o un mensaje de salida, y omnivoice.log en su carpeta de datos de VoiceStudio.",
"server": "No se puede conectar con el servidor backend de VoiceStudio. {{contact}} Revisa los registros del servidor para encontrar la causa (p. ej. `docker logs <contenedor>` o `journalctl`) — y ten en cuenta que si Docker sirve esta página, la propia página puede caerse junto con el backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Cette action supprime définitivement tous les projets Histoires et Livres audio enregistrés ainsi que tout brouillon non enregistré afin que VoiceStudio puisse souvrir à nouveau. Elle est irréversible. Continuer ?"
},
"backendUnreachable": {
"contact_recent": "Il répondait il y a {{ago}} puis a cessé de répondre — il a très probablement planté ou a été arrêté en pleine requête.",
"contact_recent": "Il répondait il y a {{ago}} puis a cessé de répondre, et aucun plantage n'a été enregistré — il est donc peut-être toujours actif, mais bloqué sur une tâche lourde qui monopolise le moteur.",
"contact_never": "Il n'a pas répondu du tout durant cette session — il n'a peut-être jamais démarré.",
"dev": "Impossible de joindre le backend VoiceStudio local. {{contact}} Avec `bun run dev`, le backend tourne en rechargement automatique : toute modification de fichier — y compris une sauvegarde pendant une requête en cours — le redémarre et coupe la connexion. Réessayez d'abord l'action ; s'il s'agissait d'un rechargement, cela fonctionnera. Si l'échec persiste, consultez le terminal exécutant `bun run dev` pour un traceback Python ou un message de sortie, et omnivoice.log dans votre dossier de données VoiceStudio.",
"server": "Impossible de joindre le serveur backend VoiceStudio. {{contact}} Consultez les journaux du serveur pour en trouver la cause (p. ex. `docker logs <conteneur>` ou `journalctl`) — et notez que si Docker sert cette page, la page elle-même peut tomber avec le backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "इससे सभी सहेजे गए स्टोरी और ऑडियोबुक प्रोजेक्ट और कोई भी न सहेजा गया ड्राफ़्ट स्थायी रूप से मिट जाएँगे ताकि VoiceStudio फिर से खुल सके। इसे पूर्ववत नहीं किया जा सकता। जारी रखें?"
},
"backendUnreachable": {
"contact_recent": "यह {{ago}} पहले तक जवाब दे रहा था और फिर जवाब देना बंद कर दिया — बहुत संभव है कि यह अनुरोध के बीच में क्रैश हो गया या बंद कर दिया गया।",
"contact_recent": "यह {{ago}} पहले तक जवाब दे रहा था और फिर जवाब देना बंद कर दिया, और कोई क्रैश दर्ज नहीं हुआ — इसलिए यह अब भी चल रहा हो सकता है, पर किसी भारी काम में अटका है जो इंजन को रोके हुए है।",
"contact_never": "इस सत्र में इसने अभी तक कोई जवाब नहीं दिया — हो सकता है कि यह कभी शुरू ही न हुआ हो।",
"dev": "स्थानीय VoiceStudio बैकएंड से संपर्क नहीं हो पा रहा। {{contact}} `bun run dev` में बैकएंड ऑटो-रीलोड के साथ चलता है, इसलिए कोई भी फ़ाइल बदलाव — अनुरोध चलते समय सेव करना भी — उसे पुनः आरंभ कर देता है और कनेक्शन टूट जाता है। पहले क्रिया दोबारा करें; यदि रीलोड था तो यह चल जाएगा। यदि फिर भी विफल हो, तो `bun run dev` चला रहे टर्मिनल में Python ट्रेसबैक या exit संदेश देखें, और अपने VoiceStudio डेटा फ़ोल्डर की omnivoice.log देखें।",
"server": "VoiceStudio बैकएंड सर्वर से संपर्क नहीं हो पा रहा है। {{contact}} कारण जानने के लिए सर्वर लॉग देखें (जैसे `docker logs <container>` या `journalctl`) — और ध्यान रखें कि अगर Docker यह पेज सर्व कर रहा है, तो बैकएंड के साथ यह पेज भी बंद हो सकता है।",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Tindakan ini menghapus permanen semua proyek Cerita dan Buku Audio yang tersimpan serta draf yang belum disimpan agar VoiceStudio dapat dibuka kembali. Tindakan ini tidak dapat dibatalkan. Lanjutkan?"
},
"backendUnreachable": {
"contact_recent": "Backend masih merespons {{ago}} yang lalu lalu berhenti merespons — kemungkinan besar crash atau dihentikan di tengah permintaan.",
"contact_recent": "Backend masih menjawab {{ago}} lalu dan kemudian berhenti merespons, dan tidak ada crash yang tercatat — jadi kemungkinan masih hidup tetapi tersangkut pada pekerjaan berat yang menahan engine.",
"contact_never": "Backend sama sekali belum merespons pada sesi ini — mungkin tidak pernah berhasil dijalankan.",
"dev": "Tidak dapat menghubungi backend VoiceStudio lokal. {{contact}} Pada `bun run dev` backend berjalan dengan muat-ulang otomatis: setiap perubahan berkas — termasuk menyimpan saat ada permintaan berjalan — membuatnya dimulai ulang dan memutus koneksi. Coba ulangi tindakan dulu; jika itu muat-ulang, akan langsung berhasil. Jika terus gagal, periksa terminal `bun run dev` untuk traceback Python atau pesan keluar, dan omnivoice.log di folder data VoiceStudio Anda.",
"server": "Tidak dapat menghubungi server backend VoiceStudio. {{contact}} Periksa log server untuk penyebabnya (mis. `docker logs <container>` atau `journalctl`) — dan perlu diketahui, jika Docker menyajikan halaman ini, halaman ini sendiri bisa ikut mati bersama backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Questa operazione elimina definitivamente tutti i progetti Storie e Audiolibri salvati e qualsiasi bozza non salvata, così VoiceStudio potrà riaprirsi. Non può essere annullata. Continuare?"
},
"backendUnreachable": {
"contact_recent": "Rispondeva {{ago}} fa e poi ha smesso di rispondere — molto probabilmente è andato in crash o è stato terminato durante una richiesta.",
"contact_recent": "Rispondeva {{ago}} fa e poi ha smesso di rispondere, e non è stato registrato alcun crash — quindi potrebbe essere ancora attivo ma bloccato su un lavoro pesante che tiene occupato il motore.",
"contact_never": "Non ha risposto affatto in questa sessione — forse non si è mai avviato.",
"dev": "Impossibile raggiungere il backend VoiceStudio locale. {{contact}} Con `bun run dev` il backend gira con ricarica automatica: qualsiasi modifica a un file — incluso un salvataggio mentre una richiesta era in corso — lo riavvia e interrompe la connessione. Riprovi prima l'operazione; se era una ricarica, funzionerà. Se continua a fallire, controlli il terminale con `bun run dev` per un traceback Python o un messaggio di uscita, e omnivoice.log nella sua cartella dati VoiceStudio.",
"server": "Impossibile raggiungere il server backend di VoiceStudio. {{contact}} Controlla i log del server per la causa (es. `docker logs <container>` o `journalctl`) — e tieni presente che se Docker serve questa pagina, la pagina stessa può cadere insieme al backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "VoiceStudio を再び開けるように、保存済みのストーリーとオーディオブックのプロジェクト、および未保存の下書きをすべて完全に削除します。この操作は元に戻せません。続行しますか?"
},
"backendUnreachable": {
"contact_recent": "{{ago}}前までは応答していましたが、応答が止まりました — リクエストの途中でクラッシュしたか、強制終了された可能性が高いです。",
"contact_recent": "{{ago}}前までは応答していましたが、その後応答しなくなりました。クラッシュは記録されていないため、まだ動作していて、エンジンを占有する重い処理で止まっているだけかもしれません。",
"contact_never": "このセッションでは一度も応答していません — そもそも起動していない可能性があります。",
"dev": "ローカルの VoiceStudio バックエンドに接続できません。{{contact}} `bun run dev` ではバックエンドが自動リロードで動作するため、ファイルの変更(リクエスト中の保存も含む)で再起動し、接続が切れます。まず操作をやり直してください。リロードが原因ならそれで動きます。それでも失敗する場合は、`bun run dev` を実行しているターミナルの Python トレースバックや終了メッセージ、および VoiceStudio データフォルダーの omnivoice.log を確認してください。",
"server": "VoiceStudio バックエンドサーバーに接続できません。{{contact}} サーバーログで原因を確認してください(例: `docker logs <container>` や `journalctl`)。なお、このページを Docker が配信している場合、バックエンドと一緒にページ自体も落ちることがあります。",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "VoiceStudio를 다시 열 수 있도록 저장된 스토리 및 오디오북 프로젝트와 저장하지 않은 초안을 모두 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?"
},
"backendUnreachable": {
"contact_recent": "{{ago}} 전까지는 응답하다가 응답을 멈췄습니다 — 요청 처리 중에 크래시했거나 강제 종료되었을 가능성이 큽니다.",
"contact_recent": "{{ago}} 전까지는 응답하다가 응답을 멈췄으며, 크래시는 기록되지 않았습니다 — 따라서 아직 살아 있지만 엔진을 붙잡고 있는 무거운 작업에서 멈춰 있을 수 있습니다.",
"contact_never": "이번 세션에서 한 번도 응답하지 않았습니다 — 아예 시작되지 않았을 수 있습니다.",
"dev": "로컬 VoiceStudio 백엔드에 연결할 수 없습니다. {{contact}} `bun run dev`에서는 백엔드가 자동 리로드로 실행되므로, 파일 변경(요청 도중 저장 포함)이 백엔드를 재시작하고 연결을 끊습니다. 먼저 작업을 다시 시도하세요. 리로드였다면 바로 됩니다. 계속 실패하면 `bun run dev`를 실행한 터미널의 Python 트레이스백이나 종료 메시지, 그리고 VoiceStudio 데이터 폴더의 omnivoice.log를 확인하세요.",
"server": "VoiceStudio 백엔드 서버에 연결할 수 없습니다. {{contact}} 서버 로그에서 원인을 확인하세요(예: `docker logs <container>` 또는 `journalctl`) — Docker가 이 페이지를 제공하는 경우 백엔드와 함께 페이지 자체가 중단될 수 있습니다.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Hiermee worden alle opgeslagen Verhalen- en Luisterboekprojecten en niet-opgeslagen concepten permanent verwijderd, zodat VoiceStudio opnieuw kan openen. Dit kan niet ongedaan worden gemaakt. Doorgaan?"
},
"backendUnreachable": {
"contact_recent": "Het reageerde {{ago}} geleden nog en stopte toen met reageren — waarschijnlijk is het midden in een verzoek gecrasht of beëindigd.",
"contact_recent": "Het antwoordde {{ago}} geleden nog en reageert sindsdien niet meer, en er is geen crash geregistreerd — het draait dus mogelijk nog, maar hangt op een zware taak die de engine bezet houdt.",
"contact_never": "Het heeft deze sessie helemaal niet gereageerd — mogelijk is het nooit gestart.",
"dev": "Kan de lokale VoiceStudio-backend niet bereiken. {{contact}} Bij `bun run dev` draait de backend met automatisch herladen: elke bestandswijziging — ook opslaan terwijl er een verzoek liep — herstart hem en verbreekt de verbinding. Probeer de actie eerst opnieuw; als het een herlaad was, werkt het gewoon. Blijft het mislukken, kijk dan in de terminal met `bun run dev` naar een Python-traceback of een exit-melding, en in omnivoice.log in uw VoiceStudio-gegevensmap.",
"server": "Kan de VoiceStudio-backendserver niet bereiken. {{contact}} Controleer de serverlogs voor de oorzaak (bijv. `docker logs <container>` of `journalctl`) — en let op: als Docker deze pagina serveert, kan de pagina zelf samen met de backend uitvallen.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Spowoduje to trwałe usunięcie wszystkich zapisanych projektów Opowieści i Audiobooków oraz niezapisanych wersji roboczych, aby VoiceStudio mogło się ponownie otworzyć. Tej operacji nie można cofnąć. Kontynuować?"
},
"backendUnreachable": {
"contact_recent": "Jeszcze {{ago}} temu odpowiadał, a potem przestał — najprawdopodobniej uległ awarii lub został zakończony w trakcie żądania.",
"contact_recent": "Odpowiadał {{ago}} temu, a potem przestał odpowiadać i nie zarejestrowano żadnej awarii — więc może nadal działać, ale utknął na ciężkim zadaniu blokującym silnik.",
"contact_never": "W tej sesji nie odpowiedział ani razu — możliwe, że w ogóle się nie uruchomił.",
"dev": "Nie można połączyć się z lokalnym backendem VoiceStudio. {{contact}} W `bun run dev` backend działa z automatycznym przeładowaniem: każda zmiana pliku — w tym zapis w trakcie żądania — restartuje go i przerywa połączenie. Najpierw ponów akcję; jeśli to było przeładowanie, po prostu zadziała. Jeśli nadal się nie udaje, sprawdź terminal z `bun run dev` pod kątem tracebacku Pythona lub komunikatu wyjścia oraz omnivoice.log w folderze danych VoiceStudio.",
"server": "Nie można połączyć się z serwerem backendu VoiceStudio. {{contact}} Sprawdź logi serwera, aby znaleźć przyczynę (np. `docker logs <kontener>` lub `journalctl`) — pamiętaj też, że jeśli Docker serwuje tę stronę, sama strona może przestać działać razem z backendem.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Isto elimina permanentemente todos os projetos guardados de Histórias e Audiolivros e quaisquer rascunhos não guardados para que o VoiceStudio possa voltar a abrir. Não é possível desfazer. Continuar?"
},
"backendUnreachable": {
"contact_recent": "Estava respondendo há {{ago}} e depois parou de responder — muito provavelmente travou ou foi encerrado no meio de uma requisição.",
"contact_recent": "Respondia há {{ago}} e depois parou de responder, e nenhuma falha foi registada — por isso pode continuar ativo, mas preso numa tarefa pesada que está a ocupar o motor.",
"contact_never": "Não respondeu nenhuma vez nesta sessão — pode ser que nunca tenha iniciado.",
"dev": "Não é possível contactar o backend local do VoiceStudio. {{contact}} Com `bun run dev` o backend corre com recarga automática: qualquer alteração de ficheiro — incluindo gravar enquanto havia um pedido em curso — reinicia-o e corta a ligação. Tente a ação novamente primeiro; se foi uma recarga, funciona. Se continuar a falhar, verifique o terminal com `bun run dev` para um traceback de Python ou uma mensagem de saída, e omnivoice.log na sua pasta de dados do VoiceStudio.",
"server": "Não foi possível conectar ao servidor backend do VoiceStudio. {{contact}} Verifique os logs do servidor para encontrar a causa (ex.: `docker logs <container>` ou `journalctl`) — e note que, se o Docker serve esta página, a própria página pode cair junto com o backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Все сохранённые проекты «Истории» и «Аудиокниги», а также несохранённые черновики будут безвозвратно удалены, чтобы VoiceStudio смог снова открыться. Это действие нельзя отменить. Продолжить?"
},
"backendUnreachable": {
"contact_recent": "Он отвечал {{ago}} назад, а затем перестал — скорее всего, он аварийно завершился или был остановлен посреди запроса.",
"contact_recent": "Он отвечал {{ago}} назад, а затем перестал отвечать, и сбой не зафиксирован — значит, он, возможно, ещё работает, но завис на тяжёлой задаче, удерживающей движок.",
"contact_never": "За эту сессию он не ответил ни разу — возможно, он вообще не запустился.",
"dev": "Не удаётся связаться с локальным бэкендом VoiceStudio. {{contact}} В `bun run dev` бэкенд работает с автоперезагрузкой: любое изменение файла — включая сохранение во время запроса — перезапускает его и обрывает соединение. Сначала повторите действие; если это была перезагрузка, всё заработает. Если ошибка повторяется, посмотрите в терминале с `bun run dev` трассировку Python или сообщение о выходе, а также omnivoice.log в папке данных VoiceStudio.",
"server": "Не удаётся подключиться к серверу бэкенда VoiceStudio. {{contact}} Проверьте логи сервера, чтобы найти причину (например, `docker logs <container>` или `journalctl`) — и учтите: если эта страница обслуживается Docker, она может упасть вместе с бэкендом.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Detta tar permanent bort alla sparade Berättelse- och Ljudboksprojekt och alla osparade utkast så att VoiceStudio kan öppnas igen. Det går inte att ångra. Fortsätta?"
},
"backendUnreachable": {
"contact_recent": "Den svarade för {{ago}} sedan och slutade sedan svara — den kraschade eller avslutades troligen mitt i en förfrågan.",
"contact_recent": "Den svarade för {{ago}} sedan och slutade sedan svara, och ingen krasch registrerades — den kan alltså fortfarande vara igång men fast i ett tungt jobb som håller motorn upptagen.",
"contact_never": "Den har inte svarat alls under den här sessionen — den kanske aldrig startade.",
"dev": "Kan inte nå den lokala VoiceStudio-backend. {{contact}} Med `bun run dev` körs backend med automatisk omladdning: varje filändring — även en sparning medan en förfrågan pågick — startar om den och bryter anslutningen. Försök med åtgärden igen först; var det en omladdning fungerar det. Om det fortsätter att misslyckas, titta i terminalen med `bun run dev` efter en Python-traceback eller ett avslutsmeddelande, och i omnivoice.log i din VoiceStudio-datamapp.",
"server": "Kan inte nå VoiceStudio-backendservern. {{contact}} Kontrollera serverloggarna för orsaken (t.ex. `docker logs <container>` eller `journalctl`) — och observera att om Docker serverar den här sidan kan själva sidan gå ner tillsammans med backenden.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "การดำเนินการนี้จะลบโปรเจกต์เรื่องราวและหนังสือเสียงที่บันทึกไว้ รวมถึงฉบับร่างที่ยังไม่ได้บันทึกทั้งหมดอย่างถาวร เพื่อให้ VoiceStudio เปิดได้อีกครั้ง ไม่สามารถย้อนกลับได้ ดำเนินการต่อหรือไม่"
},
"backendUnreachable": {
"contact_recent": "เมื่อ {{ago}} ก่อนยังตอบสนองอยู่ แล้วก็หยุดตอบสนอง — มีแนวโน้มสูงว่าแครชหรือถูกปิดกลางคันระหว่างคำขอ",
"contact_recent": "ระบบยังตอบสนองเมื่อ {{ago}} ที่แล้วแล้วจึงหยุดตอบสนอง และไม่มีการบันทึกการล่ม — จึงอาจยังทำงานอยู่แต่ค้างอยู่กับงานหนักที่ครองเอนจินไว้",
"contact_never": "ยังไม่เคยตอบสนองเลยในเซสชันนี้ — อาจไม่เคยเริ่มทำงานเลย",
"dev": "ไม่สามารถติดต่อแบ็กเอนด์ VoiceStudio ในเครื่องได้ {{contact}} ใน `bun run dev` แบ็กเอนด์ทำงานแบบรีโหลดอัตโนมัติ การเปลี่ยนไฟล์ใด ๆ — รวมถึงการบันทึกขณะมีคำขอค้างอยู่ — จะรีสตาร์ตและตัดการเชื่อมต่อ ลองทำอีกครั้งก่อน หากเป็นการรีโหลดก็จะใช้งานได้ หากยังล้มเหลว ให้ดูเทอร์มินัลที่รัน `bun run dev` ว่ามี Python traceback หรือข้อความออกจากโปรแกรม และดู omnivoice.log ในโฟลเดอร์ข้อมูล VoiceStudio",
"server": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์แบ็กเอนด์ VoiceStudio ได้ {{contact}} ตรวจสอบล็อกของเซิร์ฟเวอร์เพื่อหาสาเหตุ (เช่น `docker logs <container>` หรือ `journalctl`) — และโปรดทราบว่าถ้า Docker เป็นผู้ให้บริการหน้านี้ ตัวหน้าเว็บเองก็อาจล่มไปพร้อมกับแบ็กเอนด์",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "VoiceStudio'nun yeniden açılabilmesi için kayıtlı tüm Hikâyeler ve Sesli Kitap projeleri ile kaydedilmemiş tüm taslaklar kalıcı olarak silinecek. Bu işlem geri alınamaz. Devam edilsin mi?"
},
"backendUnreachable": {
"contact_recent": "{{ago}} önce yanıt veriyordu, sonra yanıt vermeyi bıraktı — büyük olasılıkla bir istek sırasında çöktü veya sonlandırıldı.",
"contact_recent": "{{ago}} önce yanıt veriyordu, sonra yanıt vermeyi bıraktı ve herhangi bir çökme kaydedilmedi — yani hâlâ çalışıyor ama motoru meşgul eden ağır bir işte takılmış olabilir.",
"contact_never": "Bu oturumda hiç yanıt vermedi — hiç başlamamış olabilir.",
"dev": "Yerel VoiceStudio arka ucuna ulaşılamıyor. {{contact}} `bun run dev` ile arka uç otomatik yeniden yükleme ile çalışır: herhangi bir dosya değişikliği — bir istek sürerken kaydetmek dâhil — onu yeniden başlatır ve bağlantıyı koparır. Önce işlemi yeniden deneyin; yeniden yükleme olduysa çalışacaktır. Hata sürerse `bun run dev` çalıştıran terminalde bir Python izini veya çıkış mesajını ve VoiceStudio veri klasörünüzdeki omnivoice.log dosyasını kontrol edin.",
"server": "VoiceStudio arka uç sunucusuna ulaşılamıyor. {{contact}} Nedenini görmek için sunucu günlüklerini kontrol edin (ör. `docker logs <container>` veya `journalctl`) — ve bu sayfayı Docker sunuyorsa sayfanın kendisinin de arka uçla birlikte kapanabileceğini unutmayın.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Усі збережені проєкти «Історії» та «Аудіокниги», а також незбережені чернетки буде назавжди видалено, щоб VoiceStudio міг знову відкритися. Цю дію неможливо скасувати. Продовжити?"
},
"backendUnreachable": {
"contact_recent": "Він відповідав {{ago}} тому, а потім перестав — найімовірніше, він аварійно завершився або був примусово зупинений посеред запиту.",
"contact_recent": "Він відповідав {{ago}} тому, а потім перестав відповідати, і збою не зафіксовано — тож він, можливо, ще працює, але завис на важкому завданні, яке утримує рушій.",
"contact_never": "У цій сесії він не відповів жодного разу — можливо, він узагалі не запустився.",
"dev": "Не вдається зв'язатися з локальним бекендом VoiceStudio. {{contact}} У `bun run dev` бекенд працює з автоперезавантаженням: будь-яка зміна файлу — зокрема збереження під час запиту — перезапускає його й обриває з'єднання. Спершу повторіть дію; якщо це було перезавантаження, усе спрацює. Якщо помилка повторюється, перевірте термінал із `bun run dev` на трасування Python або повідомлення про вихід, а також omnivoice.log у теці даних VoiceStudio.",
"server": "Не вдається з'єднатися із сервером бекенда VoiceStudio. {{contact}} Перегляньте логи сервера, щоб знайти причину (наприклад, `docker logs <container>` або `journalctl`) — і майте на увазі: якщо цю сторінку обслуговує Docker, сама сторінка може впасти разом із бекендом.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "Thao tác này sẽ xóa vĩnh viễn tất cả dự án Truyện và Sách nói đã lưu cùng mọi bản nháp chưa lưu để VoiceStudio có thể mở lại. Không thể hoàn tác. Tiếp tục?"
},
"backendUnreachable": {
"contact_recent": "Nó vẫn phản hồi cách đây {{ago}} rồi ngừng phản hồi — rất có thể nó đã gặp sự cố hoặc bị buộc dừng giữa một yêu cầu.",
"contact_recent": "Nó n phản hồi {{ago}} trước và sau đó ngừng phản hồi, và không có sự cố nào được ghi lại — nên có thể nó vẫn đang chạy nhưng bị kẹt ở một tác vụ nặng đang chiếm giữ engine.",
"contact_never": "Nó chưa hề phản hồi trong phiên này — có thể nó chưa bao giờ khởi động.",
"dev": "Không thể kết nối tới backend VoiceStudio cục bộ. {{contact}} Với `bun run dev`, backend chạy ở chế độ tự tải lại: bất kỳ thay đổi tệp nào — kể cả lưu khi đang có yêu cầu — sẽ khởi động lại nó và làm mất kết nối. Hãy thử lại thao tác trước; nếu là do tải lại thì sẽ chạy được. Nếu vẫn lỗi, xem terminal đang chạy `bun run dev` để tìm traceback Python hoặc thông báo thoát, và omnivoice.log trong thư mục dữ liệu VoiceStudio.",
"server": "Không thể kết nối tới máy chủ backend VoiceStudio. {{contact}} Hãy kiểm tra log máy chủ để tìm nguyên nhân (ví dụ `docker logs <container>` hoặc `journalctl`) — và lưu ý nếu Docker phục vụ trang này thì chính trang này cũng có thể sập cùng backend.",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "这将永久删除所有已保存的故事和有声书项目以及所有未保存的草稿,以便 VoiceStudio 能够重新打开。此操作无法撤销。是否继续?"
},
"backendUnreachable": {
"contact_recent": "它在 {{ago}}前还响应,后停止响应——很可能是在处理请求时崩溃或被强制终止。",
"contact_recent": "它在 {{ago}} 前还响应,后停止响应,并且没有记录到崩溃——因此它可能仍在运行,只是卡在了占用引擎的繁重任务上。",
"contact_never": "本次会话中它从未响应过——可能根本没有启动。",
"dev": "无法连接本地 VoiceStudio 后端。{{contact}} 在 `bun run dev` 下后端以自动重载方式运行,任何文件改动(包括请求进行中的保存)都会重启它并中断连接。请先重试该操作;如果是重载导致的,重试即可成功。若仍然失败,请查看运行 `bun run dev` 的终端是否有 Python 回溯或退出信息,以及 VoiceStudio 数据文件夹中的 omnivoice.log。",
"server": "无法连接 VoiceStudio 后端服务器。{{contact}} 请查看服务器日志以找到原因(例如 `docker logs <容器>` 或 `journalctl`)——另外请注意:如果此页面由 Docker 提供,页面本身也可能随后端一起不可用。",
+1 -1
View File
@@ -6,7 +6,7 @@
"clear_confirm": "這將永久刪除所有已儲存的故事與有聲書專案以及所有未儲存的草稿,讓 VoiceStudio 能重新開啟。此操作無法復原。是否繼續?"
},
"backendUnreachable": {
"contact_recent": "它在 {{ago}}前還有回應,之後停止回應——很可能是在處理請求時當機或被強制終止。",
"contact_recent": "它在 {{ago}} 前還有回應,之後停止回應,而且沒有記錄到當機——因此它可能仍在執行,只是卡在佔用引擎的繁重工作上。",
"contact_never": "本次工作階段中它從未回應——可能根本沒有啟動。",
"dev": "無法連線本機 VoiceStudio 後端。{{contact}} 在 `bun run dev` 下後端以自動重新載入方式執行,任何檔案變動(包括請求進行中的儲存)都會重啟它並中斷連線。請先重試該操作;若是重新載入造成的,重試即可成功。若仍然失敗,請查看執行 `bun run dev` 的終端機是否有 Python 追蹤訊息或結束訊息,以及 VoiceStudio 資料夾中的 omnivoice.log。",
"server": "無法連線 VoiceStudio 後端伺服器。{{contact}} 請查看伺服器日誌以找出原因(例如 `docker logs <容器>` 或 `journalctl`)——另請注意:如果此頁面由 Docker 提供,頁面本身也可能隨後端一起停止運作。",
+11 -2
View File
@@ -66,14 +66,23 @@ describe('desktop unreachable copy tells the last-contact story (#1337)', () =>
return mod;
};
it('says it crashed rather than "still starting up" when it answered seconds ago', async () => {
it('tells the last-contact story rather than "still starting up" when it answered seconds ago', async () => {
// The exact shape of #1337 / #1378.
const mod = await withContact(2);
const msg = mod.unreachableBackendMessage('desktop');
expect(msg).toMatch(/answering .* ago and then stopped/i);
expect(msg).toMatch(/crashed or was killed/i);
// The claim its own data contradicts must be gone.
expect(msg).not.toMatch(/still be starting up/i);
// #1802/#1805: and so must the claim in the OTHER direction. This used to
// require "crashed or was killed" here, which overshot what #1337 had
// actually established: that the backend answered seconds ago, not what
// silenced it. `apiFetch` reaches this copy only after waiting out the
// shell's death poll and finding no crash marker, so asserting a crash
// was wrong exactly when it mattered — two Apple Silicon reporters were
// sent to Clean & Retry, which rebuilds the Python environment, for a
// backend that had not died at all.
expect(msg).not.toMatch(/crashed or was killed/i);
expect(msg).toMatch(/no crash was recorded/i);
});
it('says it may never have started when it never answered', async () => {
@@ -0,0 +1,95 @@
/**
* #1802/#1805 a give-up must not invent a crash it has no evidence for.
*
* Two reporters on Apple Silicon hit "it most likely crashed or was killed
* mid-request" while generating. Neither bug report carried a crash marker,
* because none had been recorded the shell learns the backend died from a
* ~2 s poll, and `apiFetch` asked for the marker exactly ONCE, at the instant
* the transport gave up. That ask races the poll and loses both ways round:
*
* - the backend really died no marker yet the user gets the vague
* sentence instead of the exit code and "View crash details";
* - the backend never died no marker ever the user is told it crashed
* anyway, and sent to Retry and Clean & Retry (which rebuilds the Python
* environment) to fix a process that was merely wedged.
*
* `streamDropError` already waits that poll out (#1119). This pins the same
* behaviour for the request path, and that the fallback copy stops asserting
* a cause.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiError } from '../api/client';
import {
recordBackendContact,
unreachableBackendMessage,
_resetBackendContactForTests,
} from '../utils/backendContact';
const CASCADE_MS = 400 + 900 + 1600;
const invokeMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}));
const MARKER = {
ts: 1_700_000_000,
exit_code: null,
signal: 9,
exit_desc: 'signal: 9 (SIGKILL)',
backend_version: '0.5.2',
uptime_s: 42,
last_stderr: 'MPS backend out of memory',
acknowledged: false,
};
beforeEach(() => {
invokeMock.mockReset();
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
});
describe('apiFetch give-up vs. the shell death poll (#1802/#1805)', () => {
it('reports the real crash when the marker lands after the first ask', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
// The shell needs two poll intervals to notice the child exit and write
// the marker — exactly the race that produced the unevidenced sentence.
let asks = 0;
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'get_last_backend_crash') {
asks += 1;
return asks >= 3 ? MARKER : null;
}
// The shell has already given up on this backend, so the retry loop
// above ends at the cascade and the give-up path runs immediately.
if (cmd === 'bootstrap_status') return { stage: 'failed' };
return null;
});
const { apiFetch } = await import('../api/client');
const settled = apiFetch('/generate').catch((e: ApiError) => e);
await vi.advanceTimersByTimeAsync(CASCADE_MS + 6_000);
const err = (await settled) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.message).toMatch(/backend crashed/i);
expect(err.message).toContain('signal 9');
expect(err.message).not.toMatch(/most likely crashed/);
});
it('stops asserting a crash when the wait ends with no marker at all', async () => {
// Nothing recorded a death, so the one thing we actually know is that the
// backend went quiet — the copy has to stop naming a cause.
_resetBackendContactForTests();
recordBackendContact(10_000);
const msg = unreachableBackendMessage('desktop', 14_000);
expect(msg).not.toMatch(/most likely crashed|was killed/i);
expect(msg).toMatch(/no crash was recorded/i);
});
});
+15 -2
View File
@@ -75,7 +75,7 @@ export function contactAge(thenMs: number, nowMs: number = Date.now()): string {
// values — used only when i18next has not been initialized (see module doc).
const EN = {
contact_recent:
'It was answering {{ago}} ago and then stopped responding — it most likely crashed or was killed mid-request.',
'It was answering {{ago}} ago and then stopped responding, and no crash was recorded — so it may still be alive but wedged on a heavy job that is holding the engine.',
contact_never: 'It has not answered at all this session — it may never have started.',
dev:
"Can't reach the local VoiceStudio backend. {{contact}} In `bun run dev` the backend runs " +
@@ -108,7 +108,20 @@ function tr(key: string, vars: Record<string, string>, fallback: string): string
return fallback.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k] ?? ''));
}
/** The honest last-contact phrase for error messages and bug reports. */
/** The honest last-contact phrase for error messages and bug reports.
*
* #1802/#1805: this used to end "it most likely crashed or was killed
* mid-request" a cause, asserted, on a path that by construction has already
* looked for a crash and found none. `apiFetch` reaches here only after
* `awaitBackendCrashMarker` has waited out the shell's death poll: a backend
* that really died is reported as a crash, with its exit code and a "View
* crash details" affordance, several branches earlier. So the one thing we
* know when we get here is that nothing recorded a death and on an Apple
* Silicon machine mid-generation the far likelier story is a process alive and
* stalled under memory pressure, not a dead one. Naming a crash sent those
* users to Retry and Clean & Retry, which rebuilds the Python environment, for
* a backend that needed neither.
*/
export function describeLastContact(nowMs: number = Date.now()): string {
const last = lastBackendContact();
if (last == null) return tr('backendUnreachable.contact_never', {}, EN.contact_never);
+43 -21
View File
@@ -189,6 +189,47 @@ export async function getUnacknowledgedBackendCrash(): Promise<BackendCrashMarke
return marker && !marker.acknowledged ? marker : null;
}
/** Wait, briefly, for the shell to record a crash marker.
*
* #1119: the desktop shell learns the backend died from a ~2 s POLL it has
* to notice the child exit and write the marker. Asking for that marker ONCE,
* at the instant a request fails, races that poll and loses: we find nothing
* and conclude "no crash". That conclusion then gets stated as fact to the
* user, which is how #1802/#1805 came to read "it most likely crashed or was
* killed mid-request" on a backend that had left no evidence of dying at all.
*
* Outside the Tauri shell there is no death watcher to wait for the
* run-sentinel record only appears after the backend RESTARTS so one
* immediate ask is all the information there is, and a browser/Docker user is
* never stalled to learn nothing more.
*
* `getCrash` and `sleep` are injectable seams so the branch logic is
* unit-testable without a shell or real time.
*/
export async function awaitBackendCrashMarker(
getCrash: () => Promise<BackendCrashMarker | null> = getUnacknowledgedBackendCrash,
opts: { waitMs?: number; intervalMs?: number; sleep?: (ms: number) => Promise<void> } = {},
): Promise<{ crash: BackendCrashMarker | null; unavailable: boolean }> {
const waitMs = opts.waitMs ?? 8_000;
const intervalMs = opts.intervalMs ?? 1_000;
const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
const deadline = Date.now() + waitMs;
for (;;) {
let crash: BackendCrashMarker | null = null;
try {
crash = await getCrash();
} catch {
// Forensics unavailable — "no marker" would be a lie, so say so and let
// the caller keep its own wording rather than assert anything.
return { crash: null, unavailable: true };
}
if (crash) return { crash, unavailable: false };
if (!inTauri()) return { crash: null, unavailable: false };
if (Date.now() >= deadline) return { crash: null, unavailable: false };
await sleep(intervalMs);
}
}
/** Mark the newest crash as seen (the marker itself is retained for reports). */
export async function acknowledgeBackendCrash(): Promise<void> {
if (!inTauri()) {
@@ -458,27 +499,8 @@ export async function streamDropError(
// the backend had in fact just died. That's the same race #1102 fixed for
// apiFetch, which this path never got. Give the shell time to catch up before
// believing there was no crash.
const waitMs = opts.waitMs ?? 8_000;
const intervalMs = opts.intervalMs ?? 1_000;
const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
let crash: BackendCrashMarker | null = null;
const deadline = Date.now() + waitMs;
for (;;) {
try {
crash = await getCrash();
} catch {
return new Error(fallbackMessage); // forensics unavailable — don't mask the caller
}
if (crash) break;
// Outside the Tauri shell there is no death watcher to wait for — the
// run-sentinel record (#1164) only appears after the backend RESTARTS,
// so one immediate ask is all the information there is; don't stall a
// browser/Docker user for 8 s to learn nothing more.
if (!inTauri()) break;
if (Date.now() >= deadline) break;
await sleep(intervalMs);
}
const { crash, unavailable } = await awaitBackendCrashMarker(getCrash, opts);
if (unavailable) return new Error(fallbackMessage); // don't mask the caller
if (!crash) {
// No crash marker — but "no marker" is not "the backend died and we missed
// it". Outside the Tauri shell there is no death watcher at all, so this