From eaced700bde69a53061a7d9751fc058a3d3066cd Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 13 Oct 2026 13:18:55 +0330 Subject: [PATCH] fix: harden runtime updates and shutdown handling - Upgrade frontend and Tauri dependencies; use process-group kills for yt-dlp descendants. - Refresh verified Windows/Linux FFmpeg sources and add HLS byte-range coverage. - Fence Aria2 handoffs across shutdown, retry, seeding, recovery, and user controls; fix expired queue-hold wakeups. - Preserve Linux AppImage autostart for install paths containing '='. - Refs #44 --- .github/workflows/ci.yml | 4 + engine-sources.lock.json | 12 +- package-lock.json | 242 +++++++++++----------- package.json | 22 +- scripts/smoke-ffmpeg-media.js | 156 ++++++++++++++- src-tauri/Cargo.lock | 199 +++++++++--------- src-tauri/Cargo.toml | 20 +- src-tauri/src/lib.rs | 260 +++++++++++++++++++++--- src-tauri/src/login_start.rs | 35 ++-- src-tauri/src/process.rs | 24 --- src-tauri/src/queue.rs | 367 ++++++++++++++++++++++++++++++---- 11 files changed, 971 insertions(+), 370 deletions(-) delete mode 100644 src-tauri/src/process.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3013b7d..4743614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,10 @@ jobs: if: runner.os == 'Windows' working-directory: src-tauri run: cargo test --lib --no-run --target ${{ matrix.target }} + - name: Test Windows yt-dlp process-group cancellation + if: runner.os == 'Windows' + working-directory: src-tauri + run: cargo test --lib tests::shell_process_group_kills_descendant_after_launcher_exit --target ${{ matrix.target }} -- --nocapture - name: Run Windows headless production contracts if: runner.os == 'Windows' working-directory: src-tauri diff --git a/engine-sources.lock.json b/engine-sources.lock.json index 1268e76..91597b8 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -13,9 +13,9 @@ "sha256": "a0c3101b4158d1dfb7d6a78a7bf0f3de80c96bb423c152beec8beb22786f2238" }, "ffmpeg": { - "version": "9.0.2-24-gfd5d616c29", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-09-14-16/ffmpeg-n9.0.2-24-gfd5d616c29-win64-gpl-9.0.zip", - "sha256": "9486304d0d6be88eade8715180e0cb198d45e22d10cf66d9a412c45df9a87157" + "version": "9.0.2-27-g7e93014c5b", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-12-13-12/ffmpeg-n9.0.2-27-g7e93014c5b-win64-gpl-9.0.zip", + "sha256": "c4da71f5a7593802cbd16419def340c37beeb90d20d04f7dd02f0d4d08adb9e2" }, "aria2c": { "version": "1.37.0-firelink-native-dns-v1", @@ -45,9 +45,9 @@ "sha256": "c6527f24f4b16031d3ae4fa9f658d5f11534c8d84ce7dc8502420280919c3490" }, "ffmpeg": { - "version": "9.0.2-24-gfd5d616c29", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-09-14-16/ffmpeg-n9.0.2-24-gfd5d616c29-linux64-gpl-9.0.tar.xz", - "sha256": "0f792547d955e8f13fd80dfafb41002236492ddc3961fbd0ec680c6a8609e868" + "version": "9.0.2-27-g7e93014c5b", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-12-13-12/ffmpeg-n9.0.2-27-g7e93014c5b-linux64-gpl-9.0.tar.xz", + "sha256": "99ac3849c1910e8a2e0dc16da11bbacb2bd6e8c445b5d180e5540cb57871a8bf" }, "aria2c": { "version": "1.37.0-firelink-native-dns-v1", diff --git a/package-lock.json b/package-lock.json index ce8debb..71cdac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,33 +11,33 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/noto-sans-hebrew": "^5.3.0", - "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@fontsource-variable/noto-sans-sc": "^5.3.1", "@fontsource-variable/outfit": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", "@fontsource-variable/vazirmatn": "^5.3.0", "@tailwindcss/vite": "^4.3.3", - "@tauri-apps/api": "^2.12.2", - "@tauri-apps/plugin-clipboard-manager": "^2.4.1", - "@tauri-apps/plugin-dialog": "^2.8.1", - "@tauri-apps/plugin-log": "^2.10.0", - "@tauri-apps/plugin-notification": "^2.5.1", - "@tauri-apps/plugin-opener": "^2.7.0", - "i18next": "^26.4.2", - "lucide-react": "^1.54.0", + "@tauri-apps/api": "^2.12.3", + "@tauri-apps/plugin-clipboard-manager": "^2.4.2", + "@tauri-apps/plugin-dialog": "^2.8.2", + "@tauri-apps/plugin-log": "^2.10.1", + "@tauri-apps/plugin-notification": "^2.6.0", + "@tauri-apps/plugin-opener": "^2.7.1", + "i18next": "^26.4.4", + "lucide-react": "^1.56.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-i18next": "^17.0.16", "zustand": "^5.0.15" }, "devDependencies": { - "@tauri-apps/cli": "^2.12.1", + "@tauri-apps/cli": "^2.12.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.3.4", - "vitest": "^5.0.3" + "vitest": "^5.0.4" }, "engines": { "node": ">=22.12" @@ -71,9 +71,9 @@ } }, "node_modules/@fontsource-variable/noto-sans-sc": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz", - "integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.1.tgz", + "integrity": "sha512-UB96JJ17HyD5ehEU7Em0hDdh7fvTMB4B4Hzmq5c4QaNwwSLqTrdM2S+coySeBqCkXwh0DxqXT0b0wMrmzorFpQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -754,9 +754,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.2.tgz", - "integrity": "sha512-cr0FYEsbSQIeEuIIoC8bwdiHW3YlziMYOZ0j3R7F6kkIHSVrrZnV6CSjv/VQvFoyuERGxFc8NMtLJDXi5wYOYg==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz", + "integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -764,9 +764,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz", - "integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz", + "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -780,23 +780,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.12.1", - "@tauri-apps/cli-darwin-x64": "2.12.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", - "@tauri-apps/cli-linux-arm64-musl": "2.12.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", - "@tauri-apps/cli-linux-x64-gnu": "2.12.1", - "@tauri-apps/cli-linux-x64-musl": "2.12.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", - "@tauri-apps/cli-win32-x64-msvc": "2.12.1" + "@tauri-apps/cli-darwin-arm64": "2.12.2", + "@tauri-apps/cli-darwin-x64": "2.12.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.2", + "@tauri-apps/cli-linux-arm64-musl": "2.12.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-musl": "2.12.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.2", + "@tauri-apps/cli-win32-x64-msvc": "2.12.2" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz", - "integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz", + "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==", "cpu": [ "arm64" ], @@ -811,9 +811,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz", - "integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz", + "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==", "cpu": [ "x64" ], @@ -828,9 +828,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz", - "integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz", + "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==", "cpu": [ "arm" ], @@ -845,9 +845,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz", - "integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz", + "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==", "cpu": [ "arm64" ], @@ -865,9 +865,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz", - "integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz", + "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==", "cpu": [ "arm64" ], @@ -885,9 +885,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz", - "integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz", + "integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==", "cpu": [ "riscv64" ], @@ -905,9 +905,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz", - "integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz", + "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==", "cpu": [ "x64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz", - "integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz", + "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==", "cpu": [ "x64" ], @@ -945,9 +945,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz", - "integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz", + "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==", "cpu": [ "arm64" ], @@ -962,9 +962,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz", - "integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz", + "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==", "cpu": [ "ia32" ], @@ -979,9 +979,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz", - "integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz", + "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==", "cpu": [ "x64" ], @@ -996,45 +996,45 @@ } }, "node_modules/@tauri-apps/plugin-clipboard-manager": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.4.1.tgz", - "integrity": "sha512-5Q9hWWnsE7UF1xABWHpRwqGiyORrP9o/rJift/7V8woP/kSVF21W+bP9G7ch8Qd7pneDWUMk6hkfpLpjcC/4Ag==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.4.2.tgz", + "integrity": "sha512-bpQ6ONK2jcCVraBVtazdQ2X7bRK6P/vdi+D4jFDOJ9IbqR4cC8D6K3YGHR+c+cgZf2HQOE7hBEkRlybEVnCNPg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.1.tgz", - "integrity": "sha512-/DtE3B62JgpYuWFL5ATXM6KOXUq7pca7cJWOgSqULhZsgyjLk0PWKek1dbRDvhNBW6flNsOfWphsKnb+738Dmw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.2.tgz", + "integrity": "sha512-LFqp8OTx0bHOwhKRhMMDyRDlo/2IO5XxoOclpf8+yvjAfM2ytVdbB6tk63dBlicsR8pRw38maj/GxrU6cW8mXw==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-log": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.10.0.tgz", - "integrity": "sha512-IHMspJx/fDA+8q8O6MTbrKffkemSDa1I5iXRhnLrLVnOd7xK4bAosyQbWcjnpPA7E+c6BMkpsE2kESHhkiBrDg==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.10.1.tgz", + "integrity": "sha512-QYgxDunUZP+Cd3ev+8ItOc3Q6uUFkEhRHdTolxUtlC/8SGsc5IyVjGgFclprkAx736HUCUYfh2vqUr3qwNfTTw==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-notification": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.5.1.tgz", - "integrity": "sha512-i0nD48YsLov18siENevC3X7SmJpQE1cImq+JUsjrUPdu73J8cTIOyNIS8H0qxftFAc1k2w6s1E74DoFE02Jirw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.6.0.tgz", + "integrity": "sha512-QvpsQ6L6uSGT/7AmIrEUrfOiqjP1S8HtBU1ieaX6bvOFXGjnoxbNMR9V+8PVhLqRdOntqevpUJ3c52S+1afsIw==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-opener": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.0.tgz", - "integrity": "sha512-mBorYfVKh9Lt7s4ZFtKbMUTxBw+Y/sVmSlKCtkkoBgMTC/DIbt/kaPm67L/2djAUqDI1wbixy5OHHVmipjuGMw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.1.tgz", + "integrity": "sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.12.0" @@ -1436,22 +1436,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.3", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1464,9 +1464,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1474,9 +1474,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", "funding": { @@ -1606,9 +1606,9 @@ } }, "node_modules/i18next": { - "version": "26.4.2", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", - "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "version": "26.4.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.4.tgz", + "integrity": "sha512-O+rqmQUi54OwAgnZ4SAwO6aRrbXZt36VZXWt6zvq0FpC3vJbH46oQRs6u4Uwm1vf8ZI7UqOXQiJhQVTor7vRiQ==", "funding": [ { "type": "individual", @@ -1904,9 +1904,9 @@ } }, "node_modules/lucide-react": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.54.0.tgz", - "integrity": "sha512-kVmnVCBoLztTGe3S76xu8utwxijRLOinknNq9e8btZ0R2fm6WCgxY+weaLiA50odo/yi+apm/4B2A/LsFZhTrw==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.56.0.tgz", + "integrity": "sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==", "license": "ISC", "peerDependencies": { "@types/react": "*", @@ -1946,9 +1946,9 @@ } }, "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2128,9 +2128,9 @@ } }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -2546,23 +2546,23 @@ } }, "node_modules/vitest": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.3", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, @@ -2579,12 +2579,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.3", - "@vitest/browser-preview": "5.0.3", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.3", - "@vitest/coverage-v8": "5.0.3", - "@vitest/ui": "5.0.3", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -2629,9 +2629,9 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 08d6ef8..475cf6e 100644 --- a/package.json +++ b/package.json @@ -47,32 +47,32 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/noto-sans-hebrew": "^5.3.0", - "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@fontsource-variable/noto-sans-sc": "^5.3.1", "@fontsource-variable/outfit": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", "@fontsource-variable/vazirmatn": "^5.3.0", "@tailwindcss/vite": "^4.3.3", - "@tauri-apps/api": "^2.12.2", - "@tauri-apps/plugin-clipboard-manager": "^2.4.1", - "@tauri-apps/plugin-dialog": "^2.8.1", - "@tauri-apps/plugin-log": "^2.10.0", - "@tauri-apps/plugin-notification": "^2.5.1", - "@tauri-apps/plugin-opener": "^2.7.0", - "i18next": "^26.4.2", - "lucide-react": "^1.54.0", + "@tauri-apps/api": "^2.12.3", + "@tauri-apps/plugin-clipboard-manager": "^2.4.2", + "@tauri-apps/plugin-dialog": "^2.8.2", + "@tauri-apps/plugin-log": "^2.10.1", + "@tauri-apps/plugin-notification": "^2.6.0", + "@tauri-apps/plugin-opener": "^2.7.1", + "i18next": "^26.4.4", + "lucide-react": "^1.56.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-i18next": "^17.0.16", "zustand": "^5.0.15" }, "devDependencies": { - "@tauri-apps/cli": "^2.12.1", + "@tauri-apps/cli": "^2.12.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.3.4", - "vitest": "^5.0.3" + "vitest": "^5.0.4" } } diff --git a/scripts/smoke-ffmpeg-media.js b/scripts/smoke-ffmpeg-media.js index 30b4d05..a15a2f3 100644 --- a/scripts/smoke-ffmpeg-media.js +++ b/scripts/smoke-ffmpeg-media.js @@ -3,7 +3,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; +import { createReadStream } from 'node:fs'; +import { createServer } from 'node:http'; import { fileURLToPath } from 'node:url'; import { resolveTargetTriple } from './engine-workspace.js'; @@ -47,6 +49,121 @@ const run = (label, args) => { } }; +const runAsync = (label, args) => new Promise((resolve, reject) => { + const child = spawn(ffmpegPath, [ + '-hide_banner', + '-nostdin', + '-loglevel', 'error', + '-nostats', + '-y', + ...args, + ], { + cwd: workspace, + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + const stderr = []; + let stderrBytes = 0; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, 30_000); + + child.stderr.on('data', chunk => { + stderrBytes += chunk.length; + if (stderrBytes <= 8 * 1024 * 1024) stderr.push(chunk); + else child.kill('SIGKILL'); + }); + child.once('error', error => { + clearTimeout(timeout); + reject(new Error(`${label} could not start: ${error.message}`)); + }); + child.once('close', (code, signal) => { + clearTimeout(timeout); + if (code === 0) { + resolve(); + return; + } + const output = Buffer.concat(stderr).toString('utf8').trim(); + const reason = timedOut ? 'timed out after 30 seconds' : signal || code || 'unknown error'; + reject(new Error(`${label} failed (${reason})${output ? `:\n${output}` : '.'}`)); + }); +}); + +const startByteRangeHttpServer = async (playlistName, streamName) => { + const playlistBody = fs.readFileSync(path.join(workspace, playlistName)); + const streamPath = path.join(workspace, streamName); + const server = createServer((request, response) => { + const requestPath = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; + if (requestPath === `/${playlistName}`) { + response.writeHead(200, { + 'content-type': 'application/vnd.apple.mpegurl', + 'content-length': playlistBody.length, + connection: 'close', + }); + response.end(playlistBody); + return; + } + if (requestPath !== `/${streamName}`) { + response.writeHead(404, { connection: 'close' }); + response.end(); + return; + } + + const size = fs.statSync(streamPath).size; + const rangeHeader = request.headers.range; + if (rangeHeader === undefined) { + response.writeHead(200, { + 'accept-ranges': 'bytes', + 'content-length': size, + connection: 'close', + }); + createReadStream(streamPath).pipe(response); + return; + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader); + if (!match || (!match[1] && !match[2])) { + response.writeHead(416, { 'content-range': `bytes */${size}`, connection: 'close' }); + response.end(); + return; + } + let start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2])); + const end = match[1] + ? (match[2] ? Math.min(Number(match[2]), size - 1) : size - 1) + : size - 1; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= size || end < start) { + response.writeHead(416, { 'content-range': `bytes */${size}`, connection: 'close' }); + response.end(); + return; + } + + response.writeHead(206, { + 'accept-ranges': 'bytes', + 'content-range': `bytes ${start}-${end}/${size}`, + 'content-length': end - start + 1, + connection: 'close', + }); + createReadStream(streamPath, { start, end }).pipe(response); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await new Promise(resolve => server.close(resolve)); + throw new Error('The local byte-range server did not bind an IP address.'); + } + + return { + url: `http://127.0.0.1:${address.port}/${playlistName}`, + close: () => new Promise(resolve => server.close(resolve)), + }; +}; + const assertDecodesAudioAndVideo = (label, input) => { run(`${label} audio/video stream check`, [ '-i', input, @@ -165,6 +282,41 @@ try { assertNonEmpty('HLS input remux', hlsRemux); assertDecodesAudioAndVideo('HLS remux', hlsRemux); + const byteRangePlaylist = 'stream-byterange.m3u8'; + const byteRangeStream = 'stream-byterange.ts'; + run('HLS byte-range packaging', [ + '-i', source, + '-map', '0:v:0', + '-map', '0:a:0', + '-c', 'copy', + '-f', 'hls', + '-hls_time', '1', + '-hls_playlist_type', 'vod', + '-hls_flags', 'single_file', + '-hls_segment_filename', byteRangeStream, + byteRangePlaylist, + ]); + assertNonEmpty('HLS byte-range packaging', byteRangePlaylist); + if (!fs.readFileSync(path.join(workspace, byteRangePlaylist), 'utf8').includes('#EXT-X-BYTERANGE:')) { + throw new Error('HLS byte-range packaging produced no byte-range segments.'); + } + + const byteRangeRemux = 'hls-byterange-remux.mkv'; + const byteRangeServer = await startByteRangeHttpServer(byteRangePlaylist, byteRangeStream); + try { + await runAsync('HLS byte-range input remux', [ + '-i', byteRangeServer.url, + '-map', '0:v:0', + '-map', '0:a:0', + '-c', 'copy', + byteRangeRemux, + ]); + } finally { + await byteRangeServer.close(); + } + assertNonEmpty('HLS byte-range input remux', byteRangeRemux); + assertDecodesAudioAndVideo('HLS byte-range remux', byteRangeRemux); + const dashManifest = 'stream.mpd'; run('DASH packaging', [ '-i', source, @@ -193,7 +345,7 @@ try { assertNonEmpty('DASH input remux', dashRemux); assertDecodesAudioAndVideo('DASH remux', dashRemux); - console.log(`FFmpeg media smoke passed for ${target} (Vorbis encode/remux, split/merge, HLS, and DASH).`); + console.log(`FFmpeg media smoke passed for ${target} (Vorbis encode/remux, split/merge, HLS byte ranges, HLS, and DASH).`); } finally { fs.rmSync(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f467963..74bdcb8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -200,7 +200,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -235,7 +235,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -578,9 +578,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "shlex", @@ -869,7 +869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 3.0.6", + "syn 2.0.119", ] [[package]] @@ -931,7 +931,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -953,7 +953,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1006,9 +1006,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -1114,7 +1114,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1137,7 +1137,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1324,7 +1324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1438,7 +1438,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2 0.11.0", + "sha2 0.11.1", "smappservice-rs", "sysinfo", "system_shutdown", @@ -1520,7 +1520,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1591,7 +1591,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2590,9 +2590,9 @@ dependencies = [ [[package]] name = "keyring-core" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" dependencies = [ "log", ] @@ -2804,7 +2804,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3338,7 +3338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3787,7 +3787,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3956,7 +3956,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3975,9 +3975,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4012,7 +4012,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4091,9 +4091,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -4134,7 +4134,7 @@ dependencies = [ "num", "once_cell", "serde", - "sha2 0.11.0", + "sha2 0.11.1", "zbus", ] @@ -4229,7 +4229,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4245,9 +4245,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4275,7 +4275,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4322,7 +4322,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -4338,7 +4338,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4396,9 +4396,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4412,7 +4412,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" dependencies = [ "libc", - "sigchld", "windows-sys 0.61.2", ] @@ -4422,27 +4421,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "sigchld" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4489,9 +4467,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smappservice-rs" @@ -4512,7 +4490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4619,9 +4597,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" dependencies = [ "base64 0.21.7", "serde", @@ -4651,9 +4629,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4677,7 +4655,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4777,9 +4755,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.12.2" +version = "2.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d061d042ccd2df9c9f1508c1e63d835278c0a4c8801eb41764080e51f2eaaf34" +checksum = "c404ecf956ff241bf6b39dd688614724e1bf1f63255abb4fc46d1c40371672ea" dependencies = [ "anyhow", "bytes", @@ -4829,9 +4807,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" +checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc" dependencies = [ "anyhow", "cargo_toml", @@ -4850,11 +4828,11 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80" +checksum = "02f468f9a9bcd404f57e5c191d91024e3de953d4b610f78c2907e7110615cb1c" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "brotli", "ico", "json-patch", @@ -4877,9 +4855,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c" +checksum = "131bffec939b20642380317f8403a33c8888489925e14bb965ceaff0d949b1cb" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4907,9 +4885,9 @@ dependencies = [ [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a" +checksum = "640d0789c9db02265a800fded60520df5a3baa4a1b5f40715b83d58842c24fcb" dependencies = [ "arboard", "log", @@ -4943,9 +4921,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf" +checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51" dependencies = [ "log", "raw-window-handle", @@ -4961,9 +4939,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +checksum = "213fae2d85d4b76b06de5915d1862db9fa74541171f9d2c15da6ac9bd6e788c6" dependencies = [ "anyhow", "dunce", @@ -4985,9 +4963,9 @@ dependencies = [ [[package]] name = "tauri-plugin-log" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "850d1e8f27d96203b7e6e6da1a2bf034c10f465b64a17c97e06940c9006385bf" +checksum = "d83980f6765feb86cf2224959246be28b0488e14a23204a719f7f9deecfc64c9" dependencies = [ "android_logger", "fern", @@ -5006,9 +4984,9 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.5.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533770889abb60a0407d5a32096182f0e6463f5a3dfaa675be94580aa14070e1" +checksum = "8cf3cce3ea492b3a8a7f431a4e5dd5d31dc41b2a4a243faa660cbe8095a577bd" dependencies = [ "log", "notify-rust", @@ -5021,13 +4999,14 @@ dependencies = [ "thiserror 2.0.21", "time", "url", + "zbus", ] [[package]] name = "tauri-plugin-opener" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345" +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8" dependencies = [ "dunce", "glob", @@ -5047,11 +5026,12 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd8f131731087b2608ad604969af43b06c5411d094d635a81fc8c852dda5634" +checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" dependencies = [ "encoding_rs", + "libc", "log", "open", "os_pipe", @@ -5064,13 +5044,14 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.21", "tokio", + "windows-sys 0.60.2", ] [[package]] name = "tauri-plugin-single-instance" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" dependencies = [ "serde", "serde_json", @@ -5204,7 +5185,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5262,7 +5243,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5360,7 +5341,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5626,7 +5607,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5705,14 +5686,14 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -5774,9 +5755,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5899,7 +5880,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -6142,7 +6123,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6163,7 +6144,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "windows-version", ] @@ -6557,7 +6538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6691,7 +6672,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6750,7 +6731,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -6813,7 +6794,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6853,7 +6834,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6907,7 +6888,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6920,6 +6901,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 97a98a6..ccc4c41 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,13 +20,13 @@ name = "firelink_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "2.7.1", features = [] } +tauri-build = { version = "2.7.2", features = [] } [dependencies] -tauri = { version = "2.12.2", features = ["tray-icon", "image-png", "test"] } -tauri-plugin-opener = "2.7.0" -tauri-plugin-dialog = "2.8.1" -tauri-plugin-shell = "2.4.1" +tauri = { version = "2.12.3", features = ["tray-icon", "image-png", "test"] } +tauri-plugin-opener = "2.7.1" +tauri-plugin-dialog = "2.8.2" +tauri-plugin-shell = "2.5.0" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } @@ -35,15 +35,15 @@ reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] } uuid = { version = "1", features = ["v4"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } -tauri-plugin-notification = "2.5.1" -tauri-plugin-clipboard-manager = "2.4.1" +tauri-plugin-notification = "2.6.0" +tauri-plugin-clipboard-manager = "2.4.2" sysinfo = "0.39.6" hmac = "0.13" sha2 = "0.11" sha1 = "0.11" base64 = { version = "0.23.1", default-features = false, features = ["std"] } tauri-plugin-deep-link = "2.6.1" -tauri-plugin-single-instance = { version = "2.5.2", features = ["deep-link"] } +tauri-plugin-single-instance = { version = "2.5.3", features = ["deep-link"] } tempfile = "3" thiserror = "2.0.21" axum = "0.8.9" @@ -57,10 +57,10 @@ chrono = "0.4.45" url = "2" rusqlite = { version = "0.40.2", features = ["bundled"] } log = "0.4.34" -tauri-plugin-log = "2.10.0" +tauri-plugin-log = "2.10.1" trash = "5" async-trait = "0.1" -keyring-core = "1.0.0" +keyring-core = "1.0.1" auto-launch = "0.6.0" [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a481a0..33fc4e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2758,17 +2758,27 @@ struct ShellCommandOutput { stderr: Vec, } -fn terminate_shell_process_tree(child: tauri_plugin_shell::process::CommandChild) { - crate::process::kill_process_tree(child.pid()); - // The process-tree helper may already have signalled the root. The - // direct kill is still needed when the root was not visible in the - // snapshot, but its result is cleanup telemetry, not the operation's - // primary error. +fn terminate_shell_process_group(child: tauri_plugin_shell::process::CommandChild) { + // Firelink enables process-group ownership on every yt-dlp command. This + // reaches PyInstaller workers even after the wrapper exits, unlike a PID + // tree snapshot taken during cancellation. if let Err(error) = child.kill() { log::warn!("failed to signal cancelled media process: {error}"); } } +fn ytdlp_shell_command( + app_handle: &tauri::AppHandle, + executable: impl AsRef, +) -> tauri_plugin_shell::process::Command { + use tauri_plugin_shell::ShellExt; + + app_handle + .shell() + .command(executable) + .set_process_group(true) +} + async fn receive_media_termination_after_cancel( rx: &mut tauri::async_runtime::Receiver, stdout_buffer: &mut String, @@ -2944,15 +2954,15 @@ async fn shell_command_output_with_timeout( match tokio::time::timeout(timeout, collect_output).await { Ok(Ok(output)) if output.status_code.is_some() => Ok(output), Ok(Ok(_)) => { - terminate_shell_process_tree(child); + terminate_shell_process_group(child); Err(format!("{operation} ended without a process exit status")) } Ok(Err(error)) => { - terminate_shell_process_tree(child); + terminate_shell_process_group(child); Err(error) } Err(_) => { - terminate_shell_process_tree(child); + terminate_shell_process_group(child); Err(format!( "{operation} timed out after {}s", timeout.as_secs() @@ -3509,11 +3519,11 @@ async fn fetch_media_playlist_metadata_uncached( let deno_runtime = format!("deno:{}", deno_path.to_string_lossy()); let trusted_path = crate::platform::trusted_system_path()?; - use tauri_plugin_shell::ShellExt; let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?; - let mut cmd = app_handle - .shell() - .command(ytdlp_path.to_string_lossy().to_string()); + let mut cmd = ytdlp_shell_command( + &app_handle, + ytdlp_path.to_string_lossy().to_string(), + ); cmd = cmd .env("PATH", trusted_path) .arg("--ffmpeg-location") @@ -3624,11 +3634,11 @@ async fn fetch_media_metadata_uncached( let deno_runtime = format!("deno:{}", deno_path.to_string_lossy()); let trusted_path = crate::platform::trusted_system_path()?; - use tauri_plugin_shell::ShellExt; let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?; - let mut cmd = app_handle - .shell() - .command(ytdlp_path.to_string_lossy().to_string()); + let mut cmd = ytdlp_shell_command( + &app_handle, + ytdlp_path.to_string_lossy().to_string(), + ); cmd = cmd .env("PATH", trusted_path) .arg("--ffmpeg-location") @@ -4971,7 +4981,6 @@ mod login_start; pub use platform::atomic_write_replace; mod properties_window; pub mod queue; -pub mod process; pub mod retry; mod torrent_probe; pub mod torrent; @@ -6528,8 +6537,6 @@ pub(crate) async fn start_media_download_internal( 1.0 }; - use tauri_plugin_shell::ShellExt; - let mut config_file = tempfile::Builder::new() .prefix("ytdlp-") .suffix(".conf") @@ -6602,7 +6609,7 @@ pub(crate) async fn start_media_download_internal( } let mut processing_started = false; let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?; - let mut cmd = app_handle.shell().command(&ytdlp_path); + let mut cmd = ytdlp_shell_command(&app_handle, &ytdlp_path); for arg in media_progress_args() { cmd = cmd.arg(arg); } @@ -6717,7 +6724,7 @@ pub(crate) async fn start_media_download_internal( Duration::from_secs(1), || { if let Some(child) = child.take() { - terminate_shell_process_tree(child); + terminate_shell_process_group(child); } }, ) @@ -7003,6 +7010,9 @@ async fn pause_download( properties_window::ensure_main_window(&caller)?; log::info!("pause_download called for id: {}", id); + let Some(_aria2_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let mut control_guard = state.queue_manager.acquire_aria2_control(&id).await; removal_jobs::ensure_not_removing(&app_handle, &id)?; let active_kind = state.queue_manager.active_kind(&id).await; @@ -7330,6 +7340,9 @@ async fn resume_download( if queue_id.is_empty() { return Err("Queue id cannot be empty".to_string()); } + let Some(aria2_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; removal_jobs::ensure_not_removing(&app_handle, &id)?; let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else { @@ -7423,8 +7436,10 @@ async fn resume_download( let gid_clone = gid.clone(); let app_handle_clone = app_handle.clone(); + let handoff_guard = aria2_handoff_guard; drop(control_guard); tauri::async_runtime::spawn(async move { + let _handoff_guard = handoff_guard; let had_permit = queue_manager.has_active_permit(&id_clone).await; let permit_candidate = if had_permit { None @@ -7777,7 +7792,9 @@ async fn resume_download( let queue_id_clone = queue_id.clone(); let app_handle_clone = app_handle.clone(); let status_for_log = status.to_string(); + let handoff_guard = aria2_handoff_guard; tauri::async_runtime::spawn(async move { + let _handoff_guard = handoff_guard; let had_permit = queue_manager.has_active_permit(&id_clone).await; let permit_candidate = if had_permit { None @@ -7909,6 +7926,9 @@ async fn remove_download( ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; removal_jobs::ensure_not_removing(&app_handle, &id)?; + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; remove_download_inner(app_handle, state, id, delete_assets, preserve_resumable, asset_removal_policy, expected_lifecycle_generation, false).await } @@ -9857,6 +9877,9 @@ async fn detach_download_for_reconfigure( ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; log::info!("detach_download_for_reconfigure called for id: {}", id); + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let mut control_guard = state.queue_manager.acquire_aria2_control(&id).await; removal_jobs::ensure_not_removing(&app_handle, &id)?; detach_download_for_reconfigure_locked( @@ -12878,6 +12901,9 @@ async fn set_torrent_file_selection( selected_indices: Option>, ) -> Result { properties_window::ensure_properties_or_main(&caller, &properties, &id)?; + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let mut control_guard = state.queue_manager.acquire_aria2_control(&id).await; removal_jobs::ensure_not_removing(&app_handle, &id)?; let item = load_persisted_torrent_item(database.inner(), &id)?; @@ -14235,6 +14261,9 @@ async fn verify_torrent_data( id: String, ) -> Result<(), String> { properties_window::ensure_properties_or_main(&caller, &properties, &id)?; + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; // Verification replaces the current Aria2 lifecycle. Serialize that // replacement with pause/resume/remove so a paused GID cannot reject the // maintenance enqueue as a duplicate task or race it with a late event. @@ -14592,6 +14621,9 @@ async fn set_torrent_web_seeds( seeds: Vec, ) -> Result, String> { properties_window::ensure_main_window(&caller)?; + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; removal_jobs::ensure_not_removing(&state.queue_manager.app_handle(), &id)?; let active = state.queue_manager.is_registered(&id).await @@ -14863,6 +14895,9 @@ async fn set_torrent_max_open_files( properties_window::ensure_main_window(&caller)?; let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)?; let guard = caller.app_handle().state::(); + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _restart_guard = guard.restart_lock.lock().await; rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -14887,6 +14922,9 @@ async fn set_torrent_overall_upload_limit( let normalized_limit = normalize_torrent_overall_upload_limit(limit.as_deref())?; let limit_str = normalized_limit.as_deref().unwrap_or("0"); let guard = caller.app_handle().state::(); + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _restart_guard = guard.restart_lock.lock().await; rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -14917,6 +14955,9 @@ async fn set_global_speed_limit( }; let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string()); let guard = caller.app_handle().state::(); + let Some(_handoff_guard) = state.queue_manager.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _restart_guard = guard.restart_lock.lock().await; rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -16830,6 +16871,152 @@ mod tests { std::process::exit(0); } + #[test] + fn shell_process_group_descendant_fixture() { + match std::env::var("FIRELINK_SHELL_PROCESS_GROUP_FIXTURE").as_deref() { + Ok("launcher") => { + let executable = std::env::current_exe().expect("test executable path"); + let descendant = std::process::Command::new(executable) + .args([ + "--exact", + "tests::shell_process_group_descendant_fixture", + "--nocapture", + ]) + .env("FIRELINK_SHELL_PROCESS_GROUP_FIXTURE", "descendant") + .spawn() + .expect("spawn long-running descendant"); + + use std::io::Write; + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "FIRELINK_DESCENDANT_PID={}", descendant.id()) + .expect("write descendant PID"); + stdout.flush().expect("flush descendant PID"); + } + Ok("descendant") => loop { + std::thread::park_timeout(std::time::Duration::from_secs(60)); + }, + _ => {} + } + } + + fn process_is_running(pid: u32) -> bool { + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + system.process(sysinfo::Pid::from_u32(pid)).is_some() + } + + async fn wait_for_process_exit(pid: u32, timeout: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + loop { + if !process_is_running(pid) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn shell_process_group_kills_descendant_after_launcher_exit() { + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_shell::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let executable = std::env::current_exe().expect("test executable path"); + let (mut events, child) = super::ytdlp_shell_command(app.handle(), executable) + .args([ + "--exact", + "tests::shell_process_group_descendant_fixture", + "--nocapture", + ]) + .env("FIRELINK_SHELL_PROCESS_GROUP_FIXTURE", "launcher") + .spawn() + .expect("spawn launcher with process-group ownership"); + let launcher_pid = child.pid(); + let mut output = String::new(); + let descendant_pid = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match events.recv().await { + Some(tauri_plugin_shell::process::CommandEvent::Stdout(bytes)) => { + output.push_str(&String::from_utf8_lossy(&bytes)); + if let Some(pid) = output + .split("FIRELINK_DESCENDANT_PID=") + .nth(1) + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse::().ok()) + { + return Ok(pid); + } + } + Some(tauri_plugin_shell::process::CommandEvent::Terminated(_)) => { + return Err("launcher exited before reporting its descendant".to_string()); + } + Some(tauri_plugin_shell::process::CommandEvent::Error(error)) => { + return Err(format!("launcher process error: {error}")); + } + None => { + return Err( + "launcher event stream closed before reporting its descendant" + .to_string(), + ); + } + _ => {} + } + } + }) + .await; + let descendant_pid = match descendant_pid { + Ok(Ok(pid)) => pid, + Ok(Err(error)) => { + super::terminate_shell_process_group(child); + panic!("{error}"); + } + Err(_) => { + super::terminate_shell_process_group(child); + panic!("launcher did not report its descendant within 10 seconds"); + } + }; + + if !wait_for_process_exit(launcher_pid, std::time::Duration::from_secs(10)).await { + super::terminate_shell_process_group(child); + panic!("launcher did not exit after spawning its descendant"); + } + if !process_is_running(descendant_pid) { + super::terminate_shell_process_group(child); + panic!("descendant exited before its process group was terminated"); + } + + super::terminate_shell_process_group(child); + let terminated = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match events.recv().await { + Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => { + return Ok(payload); + } + Some(tauri_plugin_shell::process::CommandEvent::Error(error)) => { + return Err(format!("process-group termination error: {error}")); + } + None => { + return Err( + "process-group event stream closed before termination".to_string() + ); + } + _ => {} + } + } + }) + .await + .expect("process group should terminate within 10 seconds") + .expect("process group should report termination"); + assert_eq!(terminated.code, Some(0), "launcher exit status"); + assert!( + wait_for_process_exit(descendant_pid, std::time::Duration::from_secs(10)).await, + "descendant should exit when its process group is killed" + ); + } + #[tokio::test] async fn shell_fast_exit_preserves_final_output_and_reported_path() { use tauri_plugin_shell::ShellExt; @@ -16845,7 +17032,7 @@ mod tests { // Repeated fast exits exercise the reader/termination race without // timing sleeps. The media consumer intentionally stops at Terminated, - // so this asserts the ordering guarantee supplied by shell 2.4.1. + // so this asserts the ordering guarantee supplied by shell 2.5.0. for _ in 0..8 { let (mut events, _child) = app .shell() @@ -16859,6 +17046,7 @@ mod tests { "FIRELINK_SHELL_TEST_OUTPUT_PATH", output_path.as_os_str(), ) + .set_process_group(true) .spawn() .expect("spawn fast-exiting shell child"); @@ -24755,6 +24943,10 @@ pub fn run() { if !guard.exit_allowed() { api.prevent_exit(); if guard.begin_shutdown() { + app_handle + .state::() + .queue_manager + .close_aria2_handoffs_for_shutdown(); let app = app_handle.clone(); tauri::async_runtime::spawn(async move { let exit_started = Instant::now(); @@ -24841,25 +25033,26 @@ pub fn run() { ); return; } - // Stop any addUri handoff admitted just before the - // shutdown fence. If one cannot settle promptly, - // reject this exit while the existing daemon is - // still available to finish the transfer. - let dispatch_drain = tokio::time::timeout( + // Drain Aria2 mutations admitted before the + // shutdown fence. This includes dispatch, resume, + // retry, seed, recovery, and user control work. If + // any cannot settle promptly, reject this exit + // while the existing daemon is still available. + let handoff_drain = tokio::time::timeout( Duration::from_secs(15), app.state::() .queue_manager - .wait_for_dispatch_handoffs(), + .wait_for_aria2_handoffs(), ) .await; - if !matches!(dispatch_drain, Ok(true)) { + if !matches!(handoff_drain, Ok(true)) { log::warn!( - "app_shutdown_phase=aria2_dispatch_drain outcome=timeout-or-incomplete" + "app_shutdown_phase=aria2_handoff_drain outcome=timeout-or-incomplete" ); reject_app_exit_request( &app, request, - "aria2-dispatch-drain-failed", + "aria2-handoff-drain-failed", &[], ) .await; @@ -25010,6 +25203,9 @@ pub fn run() { app.state::().clear_shutdown_journal_failure(); } app.state::().allow_exit(); + app.state::() + .queue_manager + .notify_aria2_exit_allowed(); let _ = extension_server_shutdown.send(true); log::info!( "app_shutdown_phase=total elapsed_ms={}", diff --git a/src-tauri/src/login_start.rs b/src-tauri/src/login_start.rs index 7e6b282..c688ba1 100644 --- a/src-tauri/src/login_start.rs +++ b/src-tauri/src/login_start.rs @@ -217,18 +217,12 @@ fn launch_path(path: String) -> String { } #[cfg(any(target_os = "linux", test))] -fn encode_linux_exec_path(path: &str) -> Result { +fn encode_linux_exec_argument(path: &str) -> Result { if path.chars().any(char::is_control) { return Err( "Linux executable path contains an unsupported control character".to_string(), ); } - if path.contains('=') { - return Err( - "Linux executable path contains '=' which Desktop Entry Exec does not support" - .to_string(), - ); - } // First encode the argument according to the Exec quoting rules. Percent // signs are doubled so the launcher does not treat them as field codes. @@ -262,6 +256,18 @@ fn encode_linux_exec_path(path: &str) -> Result { Ok(encoded) } +#[cfg(any(target_os = "linux", test))] +fn encode_linux_exec_path(path: &str) -> Result { + // Desktop Entry Exec forbids '=' in its executable token. Keep that token + // fixed at env, then pass the AppImage path as a quoted argument so valid + // install paths such as "Work=Home" remain launchable. The absolute path + // also prevents env from interpreting it as an environment assignment. + Ok(format!( + "\"/usr/bin/env\" -- {}", + encode_linux_exec_argument(path)? + )) +} + #[cfg(target_os = "linux")] fn launch_path(path: String) -> Result { encode_linux_exec_path(&path) @@ -546,7 +552,16 @@ mod tests { assert_eq!( encode_linux_exec_path(path).unwrap(), - r#""/opt/Firelink app/quoted\\"/back\\\\slash/\\$cost/\\`tick\\`/100%%""# + r#""/usr/bin/env" -- "/opt/Firelink app/quoted\\"/back\\\\slash/\\$cost/\\`tick\\`/100%%""# + ); + } + + #[test] + fn passes_equals_in_linux_appimage_path_as_a_launcher_argument() { + assert_eq!( + encode_linux_exec_path("/home/user/Apps/Work=Home/Firelink AppImage%2.AppImage") + .unwrap(), + r#""/usr/bin/env" -- "/home/user/Apps/Work=Home/Firelink AppImage%%2.AppImage""# ); } @@ -558,8 +573,4 @@ mod tests { } } - #[test] - fn rejects_equals_in_linux_executable_path() { - assert!(encode_linux_exec_path("/opt/Fire=link").is_err()); - } } diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs deleted file mode 100644 index 023fe94..0000000 --- a/src-tauri/src/process.rs +++ /dev/null @@ -1,24 +0,0 @@ -use sysinfo::System; - -pub fn kill_process_tree(pid: u32) { - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - let mut to_kill = vec![sysinfo::Pid::from_u32(pid)]; - let mut i = 0; - while i < to_kill.len() { - let current_pid = to_kill[i]; - for (p, process) in sys.processes() { - if process.parent() == Some(current_pid) && !to_kill.contains(p) { - to_kill.push(*p); - } - } - i += 1; - } - - for p in to_kill.into_iter().rev() { - if let Some(process) = sys.process(p) { - process.kill(); - } - } -} diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 1a34750..ad8a90c 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1115,19 +1115,31 @@ struct PendingOrderEntry { ordinal: u64, } -struct DispatchHandoffGuard { +pub(crate) struct Aria2HandoffGuard { count: Arc, notify: Arc, } -impl DispatchHandoffGuard { - fn new(count: Arc, notify: Arc) -> Self { +impl Aria2HandoffGuard { + fn try_new( + accepting: Arc>, + count: Arc, + notify: Arc, + ) -> Option { + let is_accepting = accepting.lock().unwrap_or_else(|error| error.into_inner()); + if !*is_accepting { + return None; + } count.fetch_add(1, Ordering::AcqRel); - Self { count, notify } + drop(is_accepting); + Some(Self { + count, + notify, + }) } } -impl Drop for DispatchHandoffGuard { +impl Drop for Aria2HandoffGuard { fn drop(&mut self) { self.count.fetch_sub(1, Ordering::AcqRel); self.notify.notify_waiters(); @@ -1342,10 +1354,12 @@ pub struct QueueManager { /// dispatch has not yet been accepted by the sidecar. pending_order_ordinals: Mutex>, next_pending_order_ordinal: Mutex>, - /// Selection-to-dispatch handoffs that shutdown must wait for before - /// finalizing Aria2 exit. - dispatch_handoff_count: Arc, - dispatch_handoff_notify: Arc, + /// Aria2 operations admitted before shutdown that must finish or cancel + /// before the daemon can stop. The gate and count make close/admission a + /// single transition, including resume workers waiting for a permit. + aria2_handoff_accepting: Arc>, + aria2_handoff_count: Arc, + aria2_handoff_notify: Arc, /// Serializes queue-slot selection with global permit acquisition and /// ownership transitions. admission_gate: Mutex<()>, @@ -1484,8 +1498,9 @@ impl QueueManager { queue_permit_ownership: Mutex::new(HashMap::new()), pending_order_ordinals: Mutex::new(HashMap::new()), next_pending_order_ordinal: Mutex::new(HashMap::new()), - dispatch_handoff_count: Arc::new(AtomicUsize::new(0)), - dispatch_handoff_notify: Arc::new(Notify::new()), + aria2_handoff_accepting: Arc::new(StdMutex::new(true)), + aria2_handoff_count: Arc::new(AtomicUsize::new(0)), + aria2_handoff_notify: Arc::new(Notify::new()), admission_gate: Mutex::new(()), aria2_dispatch_hold: AtomicBool::new(false), system_action_pending: AtomicBool::new(false), @@ -1790,6 +1805,69 @@ impl QueueManager { self.notify.notify_waiters(); } + fn aria2_handoff_admission_closed(&self) -> bool { + self.aria2_handoff_accepting + .lock() + .map(|accepting| !*accepting) + .unwrap_or(true) + } + + fn aria2_shutdown_requested(&self) -> bool { + self.aria2_handoff_admission_closed() + || self + .app_handle + .try_state::() + .is_some_and(|guard| guard.is_shutting_down()) + } + + pub(crate) fn try_begin_aria2_handoff(&self) -> Option { + if self + .app_handle + .try_state::() + .is_some_and(|guard| guard.is_shutting_down()) + { + return None; + } + Aria2HandoffGuard::try_new( + Arc::clone(&self.aria2_handoff_accepting), + Arc::clone(&self.aria2_handoff_count), + Arc::clone(&self.aria2_handoff_notify), + ) + } + + pub(crate) fn close_aria2_handoffs_for_shutdown(&self) { + *self + .aria2_handoff_accepting + .lock() + .unwrap_or_else(|error| error.into_inner()) = false; + self.aria2_handoff_notify.notify_waiters(); + self.notify.notify_waiters(); + } + + pub(crate) async fn wait_for_aria2_handoff(&self) -> Option { + loop { + let notified = self.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(handoff) = self.try_begin_aria2_handoff() { + return Some(handoff); + } + if self + .app_handle + .try_state::() + .is_some_and(|guard| guard.exit_allowed()) + { + return None; + } + notified.await; + } + } + + pub(crate) fn notify_aria2_exit_allowed(&self) { + self.aria2_handoff_notify.notify_waiters(); + self.notify.notify_waiters(); + } + async fn record_seed_started(&self, id: &str) { let remaining_minutes = self .aria2_payloads @@ -1924,22 +2002,24 @@ impl QueueManager { } async fn try_start_waiting_seed(self: &Arc) -> bool { - if self.app_handle.try_state::() - .is_some_and(|guard| guard.is_shutting_down()) - { + let Some(handoff_guard) = self.try_begin_aria2_handoff() else { return false; - } + }; let Some(waiter) = self.reserve_seed_waiter() else { return false; }; let manager = Arc::clone(self); tauri::async_runtime::spawn(async move { - manager.resume_waiting_seed(waiter).await; + manager.resume_waiting_seed(waiter, handoff_guard).await; }); true } - async fn resume_waiting_seed(self: Arc, waiter: SeedWaiter) { + async fn resume_waiting_seed( + self: Arc, + waiter: SeedWaiter, + _handoff_guard: Aria2HandoffGuard, + ) { let id = waiter.id.clone(); let Some(gid) = self.aria2_gid_for_download(&id) else { self.abandon_seed_start(&id); @@ -1965,6 +2045,23 @@ impl QueueManager { ) .await else { + if self.aria2_shutdown_requested() { + let _control_guard = self.acquire_aria2_control(&id).await; + let is_removing = self.removal_requested(&id); + let still_waiting = self + .is_registered_generation_or_legacy(&id, waiter.lifecycle_generation) + .await + && self.seed_waiting(&id) + && matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) + && !is_removing; + self.abandon_seed_start(&id); + if still_waiting { + self.add_seed_waiter(waiter); + } else if is_removing { + self.release_seed_tracking(&id); + } + return; + } self.abandon_seed_start(&id); return; }; @@ -2113,17 +2210,17 @@ impl QueueManager { continue; } + let Some(_handoff_guard) = self.wait_for_aria2_handoff().await else { + return; + }; + let terminal_work = match self.enter_shutdown_terminal_work().await { Ok(terminal_work) => terminal_work, Err(()) => return, }; let _control_guard = self.acquire_aria2_control(&waiter.id).await; - if self - .app_handle - .try_state::() - .is_some_and(|guard| guard.is_shutting_down()) - { + if self.aria2_shutdown_requested() { drop(_control_guard); drop(terminal_work); continue; @@ -2770,11 +2867,12 @@ impl QueueManager { async fn next_queue_dispatch_hold_delay(&self) -> Option { let now = Instant::now(); - let mut holds = self.queue_dispatch_holds.lock().await; - holds.retain(|_, queue_holds| { - queue_holds.retain(|_, expires_at| *expires_at > now); - !queue_holds.is_empty() - }); + // Do not prune expired leases here. `try_admit_next_task` already + // prunes them while checking eligibility. A lease can expire between + // that check and this deadline calculation; retaining its deadline + // yields a zero-duration wake so the dispatcher retries admission + // instead of waiting forever with no notification pending. + let holds = self.queue_dispatch_holds.lock().await; holds .values() .flat_map(HashMap::values) @@ -3249,6 +3347,9 @@ impl QueueManager { ), String, > { + let Some(_handoff_guard) = self.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let control_guard = self.acquire_aria2_control(id).await; self.set_aria2_torrent_web_seeds_locked(id, seeds, &control_guard) .await @@ -3542,6 +3643,9 @@ impl QueueManager { .ok_or_else(|| "invalid download speed limit".to_string())?, ), }; + let Some(_handoff_guard) = self.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _control_guard = self.acquire_aria2_control(id).await; if !self.is_registered(id).await @@ -3603,6 +3707,9 @@ impl QueueManager { .ok_or_else(|| "invalid torrent upload limit".to_string())?, ), }; + let Some(_handoff_guard) = self.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _control_guard = self.acquire_aria2_control(id).await; if !self.is_registered(id).await @@ -3674,6 +3781,9 @@ impl QueueManager { let rpc_peer_speed_limit = normalized_peer_speed_limit .as_deref() .unwrap_or(ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT); + let Some(_handoff_guard) = self.try_begin_aria2_handoff() else { + return Err("Firelink is preparing to exit".to_string()); + }; let _control_guard = self.acquire_aria2_control(id).await; if !self.is_registered(id).await @@ -4079,7 +4189,8 @@ impl QueueManager { control_epoch: u64, ) -> Option { loop { - if !self + if self.aria2_shutdown_requested() + || !self .is_registered_generation_or_legacy(id, lifecycle_generation) .await || self.is_aria2_retry_cancelled(id).await @@ -4094,7 +4205,8 @@ impl QueueManager { .try_reserve_queue_slot(id, queue_id, lifecycle_generation) .await { - if self + if !self.aria2_shutdown_requested() + && self .is_registered_generation_or_legacy(id, lifecycle_generation) .await && !self.is_aria2_retry_cancelled(id).await @@ -4136,7 +4248,9 @@ impl QueueManager { lifecycle_generation: u64, ) -> Option { let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { + if self.aria2_shutdown_requested() + || self.system_action_pending.load(Ordering::Acquire) + { return None; } let mut ownership = self.queue_permit_ownership.lock().await; @@ -4361,7 +4475,7 @@ impl QueueManager { usize, Option, u64, - DispatchHandoffGuard, + Aria2HandoffGuard, )> { let _admission_gate = self.admission_gate.lock().await; if self.app_handle.try_state::() @@ -4425,6 +4539,9 @@ impl QueueManager { drop(queue_limits); drop(ownership); + let Some(handoff_guard) = self.try_begin_aria2_handoff() else { + return None; + }; let permit = self.try_acquire_permit_after_retirement()?; let previous_cursor = cursor; let task = pending.remove(task_index)?; @@ -4438,10 +4555,6 @@ impl QueueManager { ); *self.dispatch_cursor.lock().await = Some(selected_queue); let pending_ordinal = self.ensure_pending_order_ordinal(&task).await; - let handoff_guard = DispatchHandoffGuard::new( - Arc::clone(&self.dispatch_handoff_count), - Arc::clone(&self.dispatch_handoff_notify), - ); Some(( permit, task, @@ -4504,6 +4617,23 @@ impl QueueManager { permit: OwnedSemaphorePermit, ) -> bool { let _admission_gate = self.admission_gate.lock().await; + if self.aria2_shutdown_requested() { + let remove_reservation = self + .queue_permit_ownership + .lock() + .await + .get(id) + .is_some_and(|entry| { + entry.queue_id == queue_id + && entry.lifecycle_generation == lifecycle_generation + && !entry.active + }); + if remove_reservation { + self.queue_permit_ownership.lock().await.remove(id); + self.notify.notify_waiters(); + } + return false; + } let registered_generation = self .registered_lifecycle_generations .lock() @@ -5036,17 +5166,22 @@ impl QueueManager { } pub(crate) fn wake_after_exit_rejected(&self) { + *self + .aria2_handoff_accepting + .lock() + .unwrap_or_else(|error| error.into_inner()) = true; + self.aria2_handoff_notify.notify_waiters(); self.notify.notify_waiters(); } - pub(crate) async fn wait_for_dispatch_handoffs(&self) -> bool { + pub(crate) async fn wait_for_aria2_handoffs(&self) -> bool { loop { - let notified = self.dispatch_handoff_notify.notified(); + let notified = self.aria2_handoff_notify.notified(); tokio::pin!(notified); notified.as_mut().enable(); let admission_gate = self.admission_gate.lock().await; - if self.dispatch_handoff_count.load(Ordering::Acquire) == 0 { + if self.aria2_handoff_count.load(Ordering::Acquire) == 0 { return true; } drop(admission_gate); @@ -5061,7 +5196,7 @@ impl QueueManager { pending_index: usize, previous_cursor: Option, pending_ordinal: u64, - _handoff_guard: DispatchHandoffGuard, + _handoff_guard: Aria2HandoffGuard, ) { let id = task.id.clone(); let lifecycle_generation = task.lifecycle_generation; @@ -6029,6 +6164,9 @@ impl QueueManager { } async fn remove_aria2_magnet_parent_result(&self, gid: &str) { + let Some(_handoff_guard) = self.wait_for_aria2_handoff().await else { + return; + }; let Some(state) = self.app_handle.try_state::() else { return; }; @@ -6602,8 +6740,12 @@ impl QueueManager { gid: &str, observed_epoch: u64, ) -> Result<(), String> { + let Some(_handoff_guard) = self.try_begin_aria2_handoff() else { + return Ok(()); + }; let _control_guard = self.acquire_aria2_control(id).await; - if self.aria2_gid_for_download(id).as_deref() != Some(gid) + if self.aria2_shutdown_requested() + || self.aria2_gid_for_download(id).as_deref() != Some(gid) || !self.is_registered(id).await || !self.has_active_permit(id).await || self.is_aria2_retry_cancelled(id).await @@ -7028,6 +7170,12 @@ impl QueueManager { return; } + let Some(_handoff_guard) = this.wait_for_aria2_handoff().await else { + this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch) + .await; + return; + }; + // Serialize the payload snapshot and addUri with live speed // changes. Without this guard, a speed update could change the // old GID and payload just before this worker re-added a new GID @@ -10681,6 +10829,40 @@ mod tests { .contains("queue")); } + #[tokio::test] + async fn hold_expiring_after_dispatch_eligibility_check_wakes_immediately() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); + + let token = manager.begin_queue_dispatch_hold("queue").await.unwrap(); + assert!(manager + .active_queue_dispatch_holds() + .await + .contains("queue")); + + // Model expiry after admission observed the live hold but before the + // dispatcher computes how long it should wait for the next lease. + manager + .queue_dispatch_holds + .lock() + .await + .get_mut("queue") + .unwrap() + .insert(token, Instant::now() - Duration::from_millis(1)); + + assert_eq!( + manager.next_queue_dispatch_hold_delay().await, + Some(Duration::ZERO), + "an expired deadline must trigger another admission pass" + ); + assert!(!manager + .active_queue_dispatch_holds() + .await + .contains("queue")); + } + #[tokio::test] async fn explicit_allocation_telemetry_ends_without_payload_and_rejects_old_epoch() { let app = tauri::test::mock_builder() @@ -10885,6 +11067,7 @@ mod tests { .await .expect("task is admitted before shutdown starts"); assert!(guard.begin_shutdown()); + manager.close_aria2_handoffs_for_shutdown(); Arc::clone(&manager) .dispatch_one( permit, @@ -10957,13 +11140,14 @@ mod tests { .await .expect("dispatch should reach the gated addUri call"); assert!(guard.begin_shutdown()); + manager.close_aria2_handoffs_for_shutdown(); let waiting_manager = Arc::clone(&manager); let shutdown_waiter = tokio::spawn(async move { - waiting_manager.wait_for_dispatch_handoffs().await; + waiting_manager.wait_for_aria2_handoffs().await; }); tokio::task::yield_now().await; - assert_eq!(manager.dispatch_handoff_count.load(Ordering::Acquire), 1); + assert_eq!(manager.aria2_handoff_count.load(Ordering::Acquire), 1); assert!(!shutdown_waiter.is_finished(), "shutdown must wait for addUri"); spawner.release_add_uri.notify_one(); @@ -10976,12 +11160,109 @@ mod tests { .expect("shutdown handoff wait should drain") .expect("shutdown waiter should not panic"); - assert_eq!(manager.dispatch_handoff_count.load(Ordering::Acquire), 0); + assert_eq!(manager.aria2_handoff_count.load(Ordering::Acquire), 0); assert_eq!(manager.pending_order(None).await, ["gated-shutdown"]); assert_eq!(manager.registered_lifecycle_generation("gated-shutdown").await, Some(13)); assert_eq!(manager.available_permits(), 1); } + #[tokio::test] + async fn shutdown_closes_admission_and_wakes_resume_permit_waiters() { + let app = tauri::test::mock_builder() + .manage(crate::Aria2DaemonGuard::new()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let guard = app.handle().state::(); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 1, + Arc::new(TestSpawner), + )); + enqueue_test_task(&manager, "permit-owner", "occupied", TaskKind::Aria2, 1).await; + let (permit, task, _, _, _, handoff_guard) = manager + .try_admit_next_task() + .await + .expect("active transfer should take the only permit"); + assert!(manager + .park_aria2_permit_if_missing_for_queue( + &task.id, + &task.queue_id, + task.lifecycle_generation, + permit, + ) + .await); + drop(handoff_guard); + manager + .reserve_enqueue_generation("resume-waiter", 2) + .await + .expect("resume lifecycle registration"); + + let resume_handoff = manager + .try_begin_aria2_handoff() + .expect("resume is accepted before shutdown"); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let resume_manager = Arc::clone(&manager); + let resume_worker = tokio::spawn(async move { + let _handoff = resume_handoff; + let _ = started_tx.send(()); + let candidate = resume_manager + .acquire_aria2_permit_candidate_for_queue( + "resume-waiter", + "resume-queue", + 2, + 0, + ) + .await; + if let Some(permit) = candidate { + resume_manager + .park_aria2_permit_if_missing_for_queue( + "resume-waiter", + "resume-queue", + 2, + permit, + ) + .await + } else { + false + } + }); + started_rx.await.expect("resume worker started"); + tokio::task::yield_now().await; + + assert!(guard.begin_shutdown()); + manager.close_aria2_handoffs_for_shutdown(); + let waiting_manager = Arc::clone(&manager); + let shutdown_drain = tokio::spawn(async move { + waiting_manager.wait_for_aria2_handoffs().await + }); + manager.release_permit("permit-owner").await; + + let acquired = tokio::time::timeout(Duration::from_secs(1), resume_worker) + .await + .expect("shutdown notification should release the waiting resume") + .expect("resume worker should not panic"); + assert!(!acquired, "a closing handoff gate must reject the permit"); + assert!(!manager.has_active_permit("resume-waiter").await); + assert!(!manager + .queue_permit_ownership + .lock() + .await + .contains_key("resume-waiter")); + assert!(tokio::time::timeout(Duration::from_secs(1), shutdown_drain) + .await + .expect("shutdown should drain the canceled resume") + .expect("shutdown waiter should not panic")); + assert_eq!(manager.aria2_handoff_count.load(Ordering::Acquire), 0); + assert!(manager.try_begin_aria2_handoff().is_none()); + + assert!(guard.cancel_shutdown()); + manager.wake_after_exit_rejected(); + let reopened = manager + .try_begin_aria2_handoff() + .expect("rejected shutdown should reopen handoff admission"); + drop(reopened); + } + #[tokio::test] async fn unavailable_add_uri_requeues_selected_task_without_terminal_failure() { use tauri::Listener;