Fixes from issues and Discord: a full drive's partial file, stopping an install early, 0x80B21104, VPN

- A single-file upload that fills the drive keeps <file>.ava-part so
  Retry resumes it, but nothing said so: the failed row now names it and
  offers Delete partial file (the next attempt starts fresh).
- Stopping a stream install before the engine had its job dropped the
  row and left the install running unseen; Stop now waits for the job
  and stops it.
- 0x80B21104 explains that the console will not take this package and
  links its FAQ entry, instead of the generic PlayGo text.
- The Connection screen's "port not open" step suggests turning a VPN
  off (reported twice on Discord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-09 11:29:37 -07:00
1 parent 7fd748cb03
commit 5f93259bf5
31 files changed
+305 -5

No files matched your search

+6
View File
@@ -3747,6 +3747,12 @@ activity_showing_console: "يُعرض {name}",
activity_show_this_console: "اعرض هذا الجهاز",
game_open_page: "افتح صفحة اللعبة",
game_already_on: "{name} لديه هذا بالفعل",
queue_partial_kept: "يُحتفظ بما أُرسل على PS5 باسم {path}، لكي تُكمل إعادة المحاولة من حيث توقفت. يشغل مساحة: احذفه إن لم تعد المحاولة.",
queue_partial_delete: "احذف الملف الجزئي",
queue_partial_delete_title: "حذف الملف الجزئي؟",
queue_partial_delete_body: "سيُحذف {path} من PS5، ثم يبدأ الرفع من البداية.",
queue_partial_deleted: "حُذف الملف الجزئي. إعادة المحاولة تبدأ الرفع من جديد.",
connection_vpn_hint: "إن كانت شبكة VPN تعمل، أوقفها (أو اسمح فيها بالوصول إلى الشبكة المحلية) ثم تحقق مجددًا.",
};
export default ar;
+6
View File
@@ -3747,6 +3747,12 @@ activity_showing_console: "{name} দেখানো হচ্ছে",
activity_show_this_console: "এই কনসোল দেখান",
game_open_page: "গেম পেজ খুলুন",
game_already_on: "{name}-এ এটি আগে থেকেই আছে",
queue_partial_kept: "যা পাঠানো হয়েছে তা PS5-এ {path} হিসেবে রাখা আছে, যাতে আবার চেষ্টা থামার জায়গা থেকে চালিয়ে যায়। এটি জায়গা নেয়: আবার চেষ্টা না করলে মুছে ফেলুন।",
queue_partial_delete: "অসম্পূর্ণ ফাইল মুছুন",
queue_partial_delete_title: "অসম্পূর্ণ ফাইল মুছবেন?",
queue_partial_delete_body: "{path} PS5 থেকে মুছে ফেলা হবে। তারপর আপলোড শুরু থেকে হবে।",
queue_partial_deleted: "অসম্পূর্ণ ফাইল মুছে ফেলা হয়েছে। আবার চেষ্টা শুরু থেকে আপলোড করবে।",
connection_vpn_hint: "VPN চালু থাকলে বন্ধ করুন (বা এতে লোকাল নেটওয়ার্ক অ্যাক্সেস দিন), তারপর আবার যাচাই করুন।",
};
export default bn;
+6
View File
@@ -3755,6 +3755,12 @@ activity_showing_console: "{name} wird angezeigt",
activity_show_this_console: "Diese Konsole anzeigen",
game_open_page: "Spielseite öffnen",
game_already_on: "{name} hat es bereits",
queue_partial_kept: "Das bereits Gesendete bleibt auf der PS5 als {path}, damit „Erneut versuchen“ dort weitermacht. Es belegt Speicher: Lösche es, wenn du es nicht erneut versuchst.",
queue_partial_delete: "Teildatei löschen",
queue_partial_delete_title: "Teildatei löschen?",
queue_partial_delete_body: "{path} wird von der PS5 gelöscht. Der Upload beginnt dann von vorn.",
queue_partial_deleted: "Teildatei gelöscht. „Erneut versuchen“ beginnt den Upload von vorn.",
connection_vpn_hint: "Wenn ein VPN aktiv ist, schalte es aus (oder erlaube darin den Zugriff aufs lokale Netzwerk) und prüfe erneut.",
};
export default de;
+6
View File
@@ -4229,6 +4229,12 @@ activity_showing_console: "Showing {name}",
activity_show_this_console: "Show this console",
game_open_page: "Open game page",
game_already_on: "{name} has it already",
queue_partial_kept: "What was sent is kept on the PS5 as {path}, so Retry continues where it stopped. It takes up space: delete it if you won't retry.",
queue_partial_delete: "Delete partial file",
queue_partial_delete_title: "Delete the partial file?",
queue_partial_delete_body: "{path} will be deleted from the PS5. The upload then starts over from the beginning.",
queue_partial_deleted: "Partial file deleted. Retry starts the upload over.",
connection_vpn_hint: "If a VPN is on, turn it off (or allow local network access in it), then check again.",
};
export default en;
+6
View File
@@ -3694,6 +3694,12 @@ activity_showing_console: "Mostrando {name}",
activity_show_this_console: "Mostrar esta consola",
game_open_page: "Abrir la página del juego",
game_already_on: "{name} ya lo tiene",
queue_partial_kept: "Lo enviado se guarda en la PS5 como {path}, para que Reintentar continúe donde se detuvo. Ocupa espacio: bórralo si no vas a reintentar.",
queue_partial_delete: "Borrar archivo parcial",
queue_partial_delete_title: "¿Borrar el archivo parcial?",
queue_partial_delete_body: "{path} se borrará de la PS5. La subida empezará de nuevo desde el principio.",
queue_partial_deleted: "Archivo parcial borrado. Reintentar empieza la subida de nuevo.",
connection_vpn_hint: "Si hay una VPN activa, desactívala (o permite en ella el acceso a la red local) y vuelve a comprobar.",
};
export default es;
+6
View File
@@ -4123,6 +4123,12 @@ activity_showing_console: "نمایش {name}",
activity_show_this_console: "نمایش این کنسول",
game_open_page: "باز کردن صفحه بازی",
game_already_on: "{name} این را از قبل دارد",
queue_partial_kept: "بخشِ ارسال‌شده روی PS5 با نام {path} نگه داشته می‌شود تا «تلاش دوباره» از همان‌جا ادامه دهد. فضا می‌گیرد؛ اگر دوباره تلاش نمی‌کنید حذفش کنید.",
queue_partial_delete: "حذف فایل ناقص",
queue_partial_delete_title: "فایل ناقص حذف شود؟",
queue_partial_delete_body: "{path} از PS5 حذف می‌شود و آپلود از ابتدا شروع می‌شود.",
queue_partial_deleted: "فایل ناقص حذف شد. «تلاش دوباره» آپلود را از ابتدا شروع می‌کند.",
connection_vpn_hint: "اگر VPN روشن است، آن را خاموش کنید (یا در آن دسترسی به شبکه محلی را مجاز کنید) و دوباره بررسی کنید.",
};
export default fa;
+6
View File
@@ -3781,6 +3781,12 @@ activity_showing_console: "Affichage de {name}",
activity_show_this_console: "Afficher cette console",
game_open_page: "Ouvrir la page du jeu",
game_already_on: "{name} l'a déjà",
queue_partial_kept: "Ce qui a été envoyé est gardé sur la PS5 sous {path}, pour que Réessayer reprenne là où ça s'est arrêté. Il prend de la place : supprimez-le si vous ne réessayez pas.",
queue_partial_delete: "Supprimer le fichier partiel",
queue_partial_delete_title: "Supprimer le fichier partiel ?",
queue_partial_delete_body: "{path} sera supprimé de la PS5. L'envoi reprendra alors depuis le début.",
queue_partial_deleted: "Fichier partiel supprimé. Réessayer recommence l'envoi.",
connection_vpn_hint: "Si un VPN est actif, désactivez-le (ou autorisez-y l'accès au réseau local), puis vérifiez à nouveau.",
};
export default fr;
+6
View File
@@ -3780,6 +3780,12 @@ activity_showing_console: "{name} दिखाया जा रहा है",
activity_show_this_console: "यह कंसोल दिखाएँ",
game_open_page: "गेम पेज खोलें",
game_already_on: "{name} पर यह पहले से है",
queue_partial_kept: "भेजा गया हिस्सा PS5 पर {path} के रूप में रखा गया है, ताकि फिर से कोशिश वहीं से जारी रखे। यह जगह घेरता है: अगर फिर से कोशिश नहीं करनी तो इसे हटा दें।",
queue_partial_delete: "अधूरी फ़ाइल हटाएँ",
queue_partial_delete_title: "अधूरी फ़ाइल हटाएँ?",
queue_partial_delete_body: "{path} PS5 से हटा दी जाएगी। फिर अपलोड शुरू से होगा।",
queue_partial_deleted: "अधूरी फ़ाइल हटा दी गई। फिर से कोशिश अपलोड शुरू से करेगा।",
connection_vpn_hint: "अगर VPN चालू है तो उसे बंद करें (या उसमें लोकल नेटवर्क की अनुमति दें), फिर दोबारा जाँचें।",
};
export default hi;
+6
View File
@@ -4100,6 +4100,12 @@ activity_showing_console: "{name} látható",
activity_show_this_console: "Ennek a konzolnak a megjelenítése",
game_open_page: "Játékoldal megnyitása",
game_already_on: "A(z) {name} már tartalmazza",
queue_partial_kept: "Az elküldött rész a PS5-ön marad {path} néven, így az Újra onnan folytatja, ahol abbamaradt. Helyet foglal: töröld, ha nem próbálod újra.",
queue_partial_delete: "Részleges fájl törlése",
queue_partial_delete_title: "Törlöd a részleges fájlt?",
queue_partial_delete_body: "A(z) {path} törlődik a PS5-ről. A feltöltés ezután elölről kezdődik.",
queue_partial_deleted: "Részleges fájl törölve. Az Újra elölről kezdi a feltöltést.",
connection_vpn_hint: "Ha VPN van bekapcsolva, kapcsold ki (vagy engedélyezd benne a helyi hálózat elérését), majd ellenőrizd újra.",
};
export default hu;
+6
View File
@@ -3756,6 +3756,12 @@ activity_showing_console: "Menampilkan {name}",
activity_show_this_console: "Tampilkan konsol ini",
game_open_page: "Buka halaman game",
game_already_on: "{name} sudah memilikinya",
queue_partial_kept: "Yang sudah terkirim disimpan di PS5 sebagai {path}, agar Coba lagi melanjutkan dari titik berhenti. File ini memakan ruang: hapus jika tidak akan mencoba lagi.",
queue_partial_delete: "Hapus file parsial",
queue_partial_delete_title: "Hapus file parsial?",
queue_partial_delete_body: "{path} akan dihapus dari PS5. Unggahan lalu dimulai dari awal.",
queue_partial_deleted: "File parsial dihapus. Coba lagi memulai unggahan dari awal.",
connection_vpn_hint: "Jika VPN aktif, matikan (atau izinkan akses jaringan lokal di dalamnya), lalu periksa lagi.",
};
export default id;
+6
View File
@@ -3694,6 +3694,12 @@ activity_showing_console: "Mostra {name}",
activity_show_this_console: "Mostra questa console",
game_open_page: "Apri la pagina del gioco",
game_already_on: "{name} ce l'ha già",
queue_partial_kept: "Quanto inviato resta sulla PS5 come {path}, così Riprova continua da dove si era fermato. Occupa spazio: eliminalo se non riproverai.",
queue_partial_delete: "Elimina file parziale",
queue_partial_delete_title: "Eliminare il file parziale?",
queue_partial_delete_body: "{path} verrà eliminato dalla PS5. Il caricamento ripartirà dall'inizio.",
queue_partial_deleted: "File parziale eliminato. Riprova ricomincia il caricamento.",
connection_vpn_hint: "Se è attiva una VPN, disattivala (o consenti in essa l'accesso alla rete locale), poi controlla di nuovo.",
};
export default it;
+6
View File
@@ -3785,6 +3785,12 @@ activity_showing_console: "{name} を表示中",
activity_show_this_console: "この本体を表示",
game_open_page: "ゲームページを開く",
game_already_on: "{name} にはすでにあります",
queue_partial_kept: "送信済みの部分は PS5 上に {path} として残り、再試行で続きから送れます。容量を使うので、再試行しない場合は削除してください。",
queue_partial_delete: "途中のファイルを削除",
queue_partial_delete_title: "途中のファイルを削除しますか?",
queue_partial_delete_body: "{path} を PS5 から削除します。アップロードは最初からやり直しになります。",
queue_partial_deleted: "途中のファイルを削除しました。再試行すると最初からアップロードします。",
connection_vpn_hint: "VPN がオンならオフにして (または VPN でローカルネットワークへのアクセスを許可して)、もう一度確認してください。",
};
export default ja;
+6
View File
@@ -3782,6 +3782,12 @@ activity_showing_console: "{name} 표시 중",
activity_show_this_console: "이 콘솔 표시",
game_open_page: "게임 페이지 열기",
game_already_on: "{name}에 이미 있습니다",
queue_partial_kept: "보낸 부분은 PS5에 {path}(으)로 남아 있어 다시 시도하면 멈춘 곳부터 이어집니다. 공간을 차지하니 다시 시도하지 않을 거라면 삭제하세요.",
queue_partial_delete: "부분 파일 삭제",
queue_partial_delete_title: "부분 파일을 삭제할까요?",
queue_partial_delete_body: "{path}이(가) PS5에서 삭제됩니다. 업로드는 처음부터 다시 시작됩니다.",
queue_partial_deleted: "부분 파일을 삭제했습니다. 다시 시도하면 처음부터 업로드합니다.",
connection_vpn_hint: "VPN이 켜져 있다면 끄거나(또는 VPN에서 로컬 네트워크 접근을 허용하고) 다시 확인하세요.",
};
export default ko;
+6
View File
@@ -4033,6 +4033,12 @@ activity_showing_console: "Wyświetlono: {name}",
activity_show_this_console: "Pokaż tę konsolę",
game_open_page: "Otwórz stronę gry",
game_already_on: "{name} już to ma",
queue_partial_kept: "Wysłana część zostaje na PS5 jako {path}, aby Ponów kontynuowało od miejsca przerwania. Zajmuje miejsce: usuń ją, jeśli nie ponowisz.",
queue_partial_delete: "Usuń częściowy plik",
queue_partial_delete_title: "Usunąć częściowy plik?",
queue_partial_delete_body: "{path} zostanie usunięty z PS5. Przesyłanie zacznie się od początku.",
queue_partial_deleted: "Częściowy plik usunięty. Ponów zaczyna przesyłanie od nowa.",
connection_vpn_hint: "Jeśli VPN jest włączony, wyłącz go (lub zezwól w nim na dostęp do sieci lokalnej) i sprawdź ponownie.",
};
export default pl;
+6
View File
@@ -3779,6 +3779,12 @@ activity_showing_console: "Mostrando {name}",
activity_show_this_console: "Mostrar este console",
game_open_page: "Abrir a página do jogo",
game_already_on: "O {name} já tem",
queue_partial_kept: "O que foi enviado fica no PS5 como {path}, para que Tentar novamente continue de onde parou. Ele ocupa espaço: apague-o se não for tentar de novo.",
queue_partial_delete: "Apagar arquivo parcial",
queue_partial_delete_title: "Apagar o arquivo parcial?",
queue_partial_delete_body: "{path} será apagado do PS5. O envio recomeça do início.",
queue_partial_deleted: "Arquivo parcial apagado. Tentar novamente recomeça o envio.",
connection_vpn_hint: "Se houver uma VPN ativa, desligue-a (ou permita nela o acesso à rede local) e verifique de novo.",
};
export default pt_BR;
+6
View File
@@ -3694,6 +3694,12 @@ activity_showing_console: "Показано: {name}",
activity_show_this_console: "Показать эту консоль",
game_open_page: "Открыть страницу игры",
game_already_on: "На {name} это уже есть",
queue_partial_kept: "Отправленное сохранено на PS5 как {path}, чтобы «Повторить» продолжило с места остановки. Оно занимает место: удалите его, если не будете повторять.",
queue_partial_delete: "Удалить частичный файл",
queue_partial_delete_title: "Удалить частичный файл?",
queue_partial_delete_body: "{path} будет удалён с PS5. Загрузка начнётся заново.",
queue_partial_deleted: "Частичный файл удалён. «Повторить» начнёт загрузку заново.",
connection_vpn_hint: "Если включён VPN, выключите его (или разрешите в нём доступ к локальной сети) и проверьте снова.",
};
export default ru;
+6
View File
@@ -3771,6 +3771,12 @@ activity_showing_console: "กำลังแสดง {name}",
activity_show_this_console: "แสดงคอนโซลนี้",
game_open_page: "เปิดหน้าเกม",
game_already_on: "{name} มีอยู่แล้ว",
queue_partial_kept: "ส่วนที่ส่งไปแล้วถูกเก็บไว้บน PS5 เป็น {path} เพื่อให้ลองอีกครั้งทำต่อจากจุดที่หยุด ไฟล์นี้ใช้พื้นที่ ลบได้หากจะไม่ลองอีก",
queue_partial_delete: "ลบไฟล์ที่ยังไม่ครบ",
queue_partial_delete_title: "ลบไฟล์ที่ยังไม่ครบหรือไม่?",
queue_partial_delete_body: "{path} จะถูกลบจาก PS5 แล้วการอัปโหลดจะเริ่มใหม่ตั้งแต่ต้น",
queue_partial_deleted: "ลบไฟล์ที่ยังไม่ครบแล้ว ลองอีกครั้งจะเริ่มอัปโหลดใหม่",
connection_vpn_hint: "หากเปิด VPN อยู่ ให้ปิด (หรืออนุญาตการเข้าถึงเครือข่ายภายในใน VPN) แล้วตรวจอีกครั้ง",
};
export default th;
+6
View File
@@ -3754,6 +3754,12 @@ activity_showing_console: "{name} gösteriliyor",
activity_show_this_console: "Bu konsolu göster",
game_open_page: "Oyun sayfasını aç",
game_already_on: "{name} üzerinde zaten var",
queue_partial_kept: "Gönderilen kısım PS5'te {path} olarak tutulur, böylece Yeniden dene kaldığı yerden devam eder. Yer kaplar: yeniden denemeyeceksen sil.",
queue_partial_delete: "Kısmi dosyayı sil",
queue_partial_delete_title: "Kısmi dosya silinsin mi?",
queue_partial_delete_body: "{path} PS5'ten silinecek. Yükleme baştan başlar.",
queue_partial_deleted: "Kısmi dosya silindi. Yeniden dene yüklemeyi baştan başlatır.",
connection_vpn_hint: "Bir VPN açıksa kapat (ya da içinde yerel ağ erişimine izin ver), sonra yeniden kontrol et.",
};
export default tr;
+6
View File
@@ -3749,6 +3749,12 @@ activity_showing_console: "Đang hiển thị {name}",
activity_show_this_console: "Hiển thị máy này",
game_open_page: "Mở trang trò chơi",
game_already_on: "{name} đã có rồi",
queue_partial_kept: "Phần đã gửi được giữ trên PS5 dưới tên {path}, để Thử lại tiếp tục từ chỗ dừng. Nó chiếm dung lượng: hãy xóa nếu bạn không thử lại.",
queue_partial_delete: "Xóa tệp dở dang",
queue_partial_delete_title: "Xóa tệp dở dang?",
queue_partial_delete_body: "{path} sẽ bị xóa khỏi PS5. Việc tải lên sẽ bắt đầu lại từ đầu.",
queue_partial_deleted: "Đã xóa tệp dở dang. Thử lại sẽ tải lên lại từ đầu.",
connection_vpn_hint: "Nếu đang bật VPN, hãy tắt nó (hoặc cho phép truy cập mạng cục bộ trong VPN), rồi kiểm tra lại.",
};
export default vi;
+6
View File
@@ -3700,6 +3700,12 @@ activity_showing_console: "正在显示 {name}",
activity_show_this_console: "显示此主机",
game_open_page: "打开游戏页面",
game_already_on: "{name} 上已经有了",
queue_partial_kept: "已发送的部分以 {path} 保留在 PS5 上,重试时会从中断处继续。它会占用空间:如果不打算重试,请删除它。",
queue_partial_delete: "删除未完成的文件",
queue_partial_delete_title: "删除未完成的文件?",
queue_partial_delete_body: "将从 PS5 删除 {path}。之后上传会从头开始。",
queue_partial_deleted: "已删除未完成的文件。重试会从头开始上传。",
connection_vpn_hint: "如果开着 VPN,请关闭它(或在 VPN 中允许访问本地网络),然后再检查一次。",
};
export default zh_CN;
+6
View File
@@ -3694,6 +3694,12 @@ activity_showing_console: "正在顯示 {name}",
activity_show_this_console: "顯示此主機",
game_open_page: "開啟遊戲頁面",
game_already_on: "{name} 上已經有了",
queue_partial_kept: "已傳送的部分以 {path} 保留在 PS5 上,重試時會從中斷處繼續。它會佔用空間:如果不打算重試,請刪除它。",
queue_partial_delete: "刪除未完成的檔案",
queue_partial_delete_title: "刪除未完成的檔案?",
queue_partial_delete_body: "將從 PS5 刪除 {path}。之後上傳會從頭開始。",
queue_partial_deleted: "已刪除未完成的檔案。重試會從頭開始上傳。",
connection_vpn_hint: "如果開著 VPN,請關閉它(或在 VPN 中允許存取本地網路),然後再檢查一次。",
};
export default zh_TW;
+6
View File
@@ -349,3 +349,9 @@ describe("humanizePs5Error", () => {
});
});
});
describe("0x80B21104", () => {
it("says the console would not take this package, not a generic PlayGo error", () => {
expect(humanizePs5Error("The PS5 declined the install. (0x80b21104)")).toContain("would not accept this package");
});
});
+5
View File
@@ -420,6 +420,11 @@ export function humanizePs5Error(
if (/0x80B22101/i.test(raw)) {
return te("err_install_2101");
}
// ─── 0x80B2_1104 — the installer would not take this package ──────
// Before the wildcard: it is the package, not PlayGo (FAQ "0x80B21104").
if (/0x80B2_?1104/i.test(raw)) {
return te("pkg.sony_80b21104");
}
if (/\b0x80B2[0-9A-Fa-f]{4}\b/i.test(raw)) {
// Other 0x80B2_xxxx — PlayGo errors, not pkg-format errors.
// Don't blame the file.
+2
View File
@@ -24,6 +24,8 @@ describe("installErrorDoc", () => {
["missing base entitlement", undefined, DOC_ANCHORS.entitlement],
["shows View product instead of Play", undefined, DOC_ANCHORS.viewProduct],
["This PS4 game isn't playable on PS5", undefined, DOC_ANCHORS.ps4OnPs5],
["The PS5 declined the install. (0x80b21104)", undefined, DOC_ANCHORS.declined1104],
["x", 0x80b21104, DOC_ANCHORS.declined1104],
];
for (const [raw, code, query] of cases) {
expect(installErrorDoc(raw, code)?.query, `${raw} / ${code}`).toBe(query);
+2
View File
@@ -25,9 +25,11 @@ export const DOC_ANCHORS = {
ce: "CE-108255-1",
wontLaunch: "won't launch",
matrix: "support matrix",
declined1104: "0x80B21104",
} as const;
const RULES: Array<[RegExp, string]> = [
[/0x80b21104/i, DOC_ANCHORS.declined1104],
[/0x80b2116f|0x80b2150f|staged route|refused this package from its own storage/i, DOC_ANCHORS.stagedRefused],
[/0x80431084|proxy server|proxy setting/i, DOC_ANCHORS.proxy],
[
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { partialUploadPath } from "./partialUpload";
const item = (over: Record<string, unknown>) =>
({
status: "failed",
sourceKind: "image",
resolvedDest: "/data/homebrew/PPSA03671 Wolverine.ffpfsc",
errorReason: "ava1_no_space",
...over,
}) as Parameters<typeof partialUploadPath>[0];
describe("partialUploadPath", () => {
it("names the partial file a single-file upload left when the drive filled", () => {
expect(partialUploadPath(item({}))).toBe("/data/homebrew/PPSA03671 Wolverine.ffpfsc.ava-part");
expect(partialUploadPath(item({ sourceKind: "file", errorReason: "preflight_insufficient_space" }))).toBe(
"/data/homebrew/PPSA03671 Wolverine.ffpfsc.ava-part",
);
});
it("is null for other failures, folders and archives", () => {
expect(partialUploadPath(item({ errorReason: "ava1_connect" }))).toBeNull();
expect(partialUploadPath(item({ sourceKind: "folder" }))).toBeNull();
expect(partialUploadPath(item({ sourceKind: "archive" }))).toBeNull();
expect(partialUploadPath(item({ status: "done" }))).toBeNull();
});
});
+17
View File
@@ -0,0 +1,17 @@
// A single-file upload writes `<destination>.ava-part` on the console and renames it when it
// is complete. When the drive fills up the partial file is kept so Retry resumes it, but it
// holds its whole size: a user who gives up (or wants to free space another way) has to know
// it is there to delete it.
import type { QueueItem } from "../state/uploadQueue";
const NO_SPACE = new Set(["ava1_no_space", "preflight_insufficient_space"]);
/** The partial file a failed single-file upload left on the console, or null. */
export function partialUploadPath(
it: Pick<QueueItem, "status" | "sourceKind" | "resolvedDest" | "errorReason">,
): string | null {
if (it.status !== "failed" || !it.errorReason || !NO_SPACE.has(it.errorReason)) return null;
if (it.sourceKind !== "file" && it.sourceKind !== "image") return null;
return it.resolvedDest ? `${it.resolvedDest}.ava-part` : null;
}
+3 -1
View File
@@ -507,7 +507,9 @@ export default function ConnectionScreen() {
)
: looksLikeMacLocalNetworkBlock(probe.error, ua)
? `${detail}. ${tr("connection_mac_local_network_hint", undefined, "macOS may be blocking this app from your local network. Open System Settings → Privacy & Security → Local Network, allow PS5Upload, then check again.")}`
: detail,
: // A VPN on this computer sends the console's address out through the tunnel: the
// likeliest cause when everything else is right (reported twice on Discord).
`${detail}. ${tr("connection_vpn_hint", undefined, "If a VPN is on, turn it off (or allow local network access in it), then check again.")}`,
target,
);
}
+63
View File
@@ -55,6 +55,7 @@ import { BottleneckLine, JobLiveNotes, UnsettledLine } from "./Bottleneck";
import { WhySlowPanel } from "./WhySlow";
import { RarPasswordPrompt } from "./RarPasswordPrompt";
import { rarPasswordProblem } from "../../lib/rarPassword";
import { partialUploadPath } from "../../lib/partialUpload";
import { isStreamUnreachableError } from "../../lib/networkFix";
import { NetworkFixActions } from "../InstallPackage/NetworkFixActions";
@@ -1111,6 +1112,7 @@ export function QueueRow({
detail={item.errorDetail}
/>
)}
{partialUploadPath(item) && <PartialFileNote id={item.id} path={partialUploadPath(item)!} />}
{item.status === "failed" && isStreamUnreachableError(item.error) && (
<NetworkFixActions diag={null} />
)}
@@ -1396,6 +1398,67 @@ function FailedRowErrorCard({
);
}
/** A single-file upload that filled the drive keeps what it sent as `<file>.ava-part`, so
* Retry continues it; that file holds the space, so it is named here with a way to delete it. */
function PartialFileNote({ id, path }: { id: string; path: string }) {
const tr = useTr();
const { confirm, dialog } = useConfirm();
const [state, setState] = useState<"idle" | "busy" | "gone">("idle");
const [failed, setFailed] = useState<string | null>(null);
if (state === "gone") {
return (
<p className="mt-2 text-xs text-[var(--color-good)]">
{tr("queue_partial_deleted", undefined, "Partial file deleted. Retry starts the upload over.")}
</p>
);
}
const remove = async () => {
const ok = await confirm({
title: tr("queue_partial_delete_title", undefined, "Delete the partial file?"),
message: tr(
"queue_partial_delete_body",
{ path },
"{path} will be deleted from the PS5. The upload then starts over from the beginning.",
),
confirmLabel: tr("queue_partial_delete", undefined, "Delete partial file"),
destructive: true,
});
if (!ok) return;
setState("busy");
setFailed(null);
try {
await useUploadQueueStore.getState().discardPartial(id);
setState("gone");
} catch (e) {
setState("idle");
setFailed(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="mt-2 text-xs text-[var(--color-muted)]" data-testid="queue-partial-file">
{dialog}
<p>
{tr(
"queue_partial_kept",
{ path },
"What was sent is kept on the PS5 as {path}, so Retry continues where it stopped. It takes up space: delete it if you won't retry.",
)}
</p>
<Button
className="mt-1.5"
variant="secondary"
size="sm"
leftIcon={<Trash2 size={12} />}
loading={state === "busy"}
onClick={() => void remove()}
>
{tr("queue_partial_delete", undefined, "Delete partial file")}
</Button>
{failed && <p className="mt-1 text-[var(--color-bad)]">{failed}</p>}
</div>
);
}
function StatusIcon({ status }: { status: QueueItemStatus }) {
switch (status) {
case "pending":
+19
View File
@@ -1300,6 +1300,25 @@ describe("install items", () => {
await waitFor(() => !useUploadQueueStore.getState().items.some((i) => i.id === q.id));
});
it("an install stopped before the engine has its job is stopped once the job comes", async () => {
const api = await import("../api/ps5");
vi.mocked(api.pkgInstallStop).mockClear();
const q = useUploadQueueStore.getState().enqueueInstall({
host, request: { via: "stream", source: "/early.pkg" }, displayName: "E",
});
await waitFor(() => calls.length === 1);
useUploadQueueStore.getState().cancelItem(q.id);
expect(vi.mocked(api.pkgInstallStop)).not.toHaveBeenCalled();
// The engine answers with its job a moment later: that job is the one stopped.
useUploadQueueStore.setState((st) => ({
items: st.items.map((i) => (i.id === q.id ? { ...i, installJobId: "job-late" } : i)),
}));
await waitFor(() => vi.mocked(api.pkgInstallStop).mock.calls.length > 0);
expect(vi.mocked(api.pkgInstallStop)).toHaveBeenCalledWith("job-late");
gate.get("/early.pkg")!({ ok: false, message: "stopped" });
await waitFor(() => !useUploadQueueStore.getState().items.some((i) => i.id === q.id));
});
it("an install leaves uploads waiting for Start alone (#410)", async () => {
const s = useUploadQueueStore.getState();
s.add({
+35 -4
View File
@@ -63,6 +63,7 @@ import { withConsolePrefix } from "./roster";
import { hostOf, mgmtAddr } from "../lib/addr";
import { log } from "./logs";
import { isRemotePath } from "../lib/remotePath";
import { partialUploadPath } from "../lib/partialUpload";
import { materializeRemote, releaseCopy } from "../lib/materialize";
/** Local copies of server archives, by queue item: made when the item's turn comes (not at
@@ -423,6 +424,9 @@ interface QueueState {
* the person just typed. The password lives on the in-memory item only (the save redacts
* it) and is never logged. False when the password is empty or the row is not a failed one. */
retryWithPassword: (id: string, password: string) => boolean;
/** Deletes the partial file a single-file upload left when the drive filled up, and gives
* the row a new job id so its next attempt starts fresh. Throws with the console's reason. */
discardPartial: (id: string) => Promise<void>;
/** Re-drive one console's uploads that FAILED on a recoverable
* (connection-class) error, then restart that console's drain loop.
* This is the "slept past the in-loop recovery budget" case: a standby
@@ -2077,13 +2081,23 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
if (!item) return;
// A running install: ask the engine to stop it (it stops serving the package; the PS5's
// download then fails, and a download already cancelled on the PS5 is let go at once
// instead of after the 5-minute stall bound). The row goes once the job has ended. Before
// the engine has a job there is nothing on the wire yet: fall through to the plain cancel.
if (item.sourceKind === "install" && item.status === "running" && item.installJobId) {
// instead of after the 5-minute stall bound). The row goes once the job has ended.
if (item.sourceKind === "install" && item.status === "running") {
if (item.stopping) return;
set((s) => ({ items: patchItem(s.items, id, { stopping: true }) }));
void pkgInstallStop(item.installJobId).catch(() => undefined);
void (async () => {
// Stopped while the engine was still starting the install: its job id comes in a
// moment, and only that stops it. Dropping the row alone left the install running
// on the engine with nothing showing it.
let job = item.installJobId ?? null;
for (let i = 0; !job && i < 30; i++) {
await new Promise((r) => setTimeout(r, 500));
const now = get().items.find((it) => it.id === id);
if (!now || now.status !== "running") break;
job = now.installJobId ?? null;
}
if (job) await pkgInstallStop(job).catch(() => undefined);
else get().stopHost(hostOf(item.addr));
for (let i = 0; i < 60; i++) {
const now = get().items.find((it) => it.id === id);
if (!now || now.status !== "running") break;
@@ -2092,6 +2106,9 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
settle(id, { ok: false, message: "Stopped." });
set((s) => ({ items: removeItem(s.items, id) }));
scheduleSave();
// Stopping the loop held the console's other waiting items: let them go on.
const h = hostOf(item.addr);
if (!job && nextPendingForHost(get().items, h)) void get().startHost(h);
})();
return;
}
@@ -2236,6 +2253,19 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
return true;
},
async discardPartial(id) {
const item = get().items.find((candidate) => candidate.id === id);
const path = item ? partialUploadPath(item) : null;
if (!item || !path) return;
await fsDelete(mgmtAddr(hostOf(item.addr)), path);
set((s) => ({
items: s.items.map((candidate) =>
candidate.id === id ? { ...candidate, txIdHex: generateTxIdHex() } : candidate,
),
}));
scheduleSave();
},
async resumeFailedRecoverable(host) {
if (!useUploadSettingsStore.getState().autoResume) return 0;
const h = hostOf(host);
@@ -2447,6 +2477,7 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
retryFailed: () => undefined,
retryItem: () => false,
retryWithPassword: () => false,
discardPartial: () => Promise.resolve(),
resumeFailedRecoverable: () => Promise.resolve(0),
setContinueOnFailure: () => undefined,
start: () => Promise.resolve(),