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
This commit is contained in:
NimBold
2026-10-13 13:18:55 +03:30
parent d91728bebb
commit eaced700bd
11 changed files with 971 additions and 370 deletions
+4
View File
@@ -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
+6 -6
View File
@@ -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",
+121 -121
View File
@@ -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": {
+11 -11
View File
@@ -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"
}
}
+154 -2
View File
@@ -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 });
}
+90 -109
View File
@@ -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",
]
+10 -10
View File
@@ -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"] }
+228 -32
View File
@@ -2758,17 +2758,27 @@ struct ShellCommandOutput {
stderr: Vec<u8>,
}
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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
executable: impl AsRef<std::ffi::OsStr>,
) -> 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<tauri_plugin_shell::process::CommandEvent>,
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<Vec<u32>>,
) -> Result<crate::ipc::TorrentFileSelectionSnapshot, 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());
};
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<crate::ipc::TorrentWebSeed>,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, 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::<Aria2DaemonGuard>();
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::<Aria2DaemonGuard>();
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::<Aria2DaemonGuard>();
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::<u32>().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::<AppState>()
.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::<AppState>()
.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::<Aria2DaemonGuard>().clear_shutdown_journal_failure();
}
app.state::<Aria2DaemonGuard>().allow_exit();
app.state::<AppState>()
.queue_manager
.notify_aria2_exit_allowed();
let _ = extension_server_shutdown.send(true);
log::info!(
"app_shutdown_phase=total elapsed_ms={}",
+23 -12
View File
@@ -217,18 +217,12 @@ fn launch_path(path: String) -> String {
}
#[cfg(any(target_os = "linux", test))]
fn encode_linux_exec_path(path: &str) -> Result<String, String> {
fn encode_linux_exec_argument(path: &str) -> Result<String, String> {
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<String, String> {
Ok(encoded)
}
#[cfg(any(target_os = "linux", test))]
fn encode_linux_exec_path(path: &str) -> Result<String, String> {
// 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<String, String> {
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());
}
}
-24
View File
@@ -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();
}
}
}
+324 -43
View File
@@ -1115,19 +1115,31 @@ struct PendingOrderEntry {
ordinal: u64,
}
struct DispatchHandoffGuard {
pub(crate) struct Aria2HandoffGuard {
count: Arc<AtomicUsize>,
notify: Arc<Notify>,
}
impl DispatchHandoffGuard {
fn new(count: Arc<AtomicUsize>, notify: Arc<Notify>) -> Self {
impl Aria2HandoffGuard {
fn try_new(
accepting: Arc<StdMutex<bool>>,
count: Arc<AtomicUsize>,
notify: Arc<Notify>,
) -> Option<Self> {
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<R: tauri::Runtime = tauri::Wry> {
/// dispatch has not yet been accepted by the sidecar.
pending_order_ordinals: Mutex<HashMap<(String, u64), PendingOrderEntry>>,
next_pending_order_ordinal: Mutex<HashMap<String, u64>>,
/// Selection-to-dispatch handoffs that shutdown must wait for before
/// finalizing Aria2 exit.
dispatch_handoff_count: Arc<AtomicUsize>,
dispatch_handoff_notify: Arc<Notify>,
/// 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<StdMutex<bool>>,
aria2_handoff_count: Arc<AtomicUsize>,
aria2_handoff_notify: Arc<Notify>,
/// Serializes queue-slot selection with global permit acquisition and
/// ownership transitions.
admission_gate: Mutex<()>,
@@ -1484,8 +1498,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
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::<crate::Aria2DaemonGuard>()
.is_some_and(|guard| guard.is_shutting_down())
}
pub(crate) fn try_begin_aria2_handoff(&self) -> Option<Aria2HandoffGuard> {
if self
.app_handle
.try_state::<crate::Aria2DaemonGuard>()
.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<Aria2HandoffGuard> {
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::<crate::Aria2DaemonGuard>()
.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<R: tauri::Runtime> QueueManager<R> {
}
async fn try_start_waiting_seed(self: &Arc<Self>) -> bool {
if self.app_handle.try_state::<crate::Aria2DaemonGuard>()
.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<Self>, waiter: SeedWaiter) {
async fn resume_waiting_seed(
self: Arc<Self>,
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<R: tauri::Runtime> QueueManager<R> {
)
.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<R: tauri::Runtime> QueueManager<R> {
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::<crate::Aria2DaemonGuard>()
.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<R: tauri::Runtime> QueueManager<R> {
async fn next_queue_dispatch_hold_delay(&self) -> Option<Duration> {
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<R: tauri::Runtime> QueueManager<R> {
),
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<R: tauri::Runtime> QueueManager<R> {
.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<R: tauri::Runtime> QueueManager<R> {
.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<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
control_epoch: u64,
) -> Option<OwnedSemaphorePermit> {
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<R: tauri::Runtime> QueueManager<R> {
.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<R: tauri::Runtime> QueueManager<R> {
lifecycle_generation: u64,
) -> Option<OwnedSemaphorePermit> {
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<R: tauri::Runtime> QueueManager<R> {
usize,
Option<String>,
u64,
DispatchHandoffGuard,
Aria2HandoffGuard,
)> {
let _admission_gate = self.admission_gate.lock().await;
if self.app_handle.try_state::<crate::Aria2DaemonGuard>()
@@ -4425,6 +4539,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
);
*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<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
}
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<R: tauri::Runtime> QueueManager<R> {
pending_index: usize,
previous_cursor: Option<String>,
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<R: tauri::Runtime> QueueManager<R> {
}
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::<crate::AppState>() else {
return;
};
@@ -6602,8 +6740,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
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::<crate::Aria2DaemonGuard>();
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;