Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e2c369f5 | ||
|
|
20d5d2baab | ||
|
|
748d24b264 | ||
|
|
bb0b094002 | ||
|
|
386db4bdb5 | ||
|
|
f98dbf1592 | ||
|
|
48aff7550a | ||
|
|
c000e6a535 | ||
|
|
2ed2791316 | ||
|
|
d422f3aeaf | ||
|
|
a21fe3750b | ||
|
|
8a447f33f6 |
No files matched your search
Generated
+2
-2
@@ -6841,7 +6841,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ariadne",
|
"ariadne",
|
||||||
"async-minecraft-ping",
|
"async-minecraft-ping",
|
||||||
@@ -6945,7 +6945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@modrinth/app-frontend",
|
"name": "@modrinth/app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta15",
|
"version": "1.0.2-beta4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
|||||||
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
||||||
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
||||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
|
import AnniversaryCelebration from '@/components/ui/AnniversaryCelebration.vue'
|
||||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||||
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||||
@@ -2221,6 +2222,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AnniversaryCelebration />
|
||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||||
<InstancePlayerModal v-if="stateInitialized" />
|
<InstancePlayerModal v-if="stateInitialized" />
|
||||||
|
|||||||
@@ -32,6 +32,92 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.2-beta4',
|
||||||
|
version: '1.0.2-beta4',
|
||||||
|
publishedAt: '2026-10-08',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.2-beta4',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
changed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
|
||||||
|
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
|
||||||
|
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed instance group changes not saving for some instances.',
|
||||||
|
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.2-beta3',
|
||||||
|
version: '1.0.2-beta3',
|
||||||
|
publishedAt: '2026-10-04',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.2-beta3',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.2-beta3',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
|
||||||
|
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.2-beta2',
|
||||||
|
version: '1.0.2-beta2',
|
||||||
|
publishedAt: '2026-10-04',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.2-beta2',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.2-beta2',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
|
||||||
|
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.2-beta1',
|
||||||
|
version: '1.0.2-beta1',
|
||||||
|
publishedAt: '2026-10-04',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.2-beta1',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.2-beta1',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
|
||||||
|
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
|
||||||
|
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
|
||||||
|
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'launcher-1.0.1',
|
id: 'launcher-1.0.1',
|
||||||
version: '1.0.1',
|
version: '1.0.1',
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { anniversaryYear, ordinal, shouldCelebrate } from '@/helpers/anniversary'
|
||||||
|
import { useTheming } from '@/store/state'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const themeStore = useTheming()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
banner: {
|
||||||
|
id: 'app.anniversary.banner',
|
||||||
|
defaultMessage: 'Happy {ordinal} Anniversary, Starlight!',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Emoji that drift down from the top of the window. */
|
||||||
|
const CONFETTI_EMOJI = ['🎂', '🎉', '🎊', '🎈', '✨', '🧁', '🍰']
|
||||||
|
|
||||||
|
type Confetti = {
|
||||||
|
id: number
|
||||||
|
emoji: string
|
||||||
|
left: number
|
||||||
|
delay: number
|
||||||
|
duration: number
|
||||||
|
size: number
|
||||||
|
drift: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const years = ref(0)
|
||||||
|
const confetti = ref<Confetti[]>([])
|
||||||
|
let spawnTimer: number | undefined
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function spawnConfetti(): void {
|
||||||
|
const emoji = CONFETTI_EMOJI[Math.floor(Math.random() * CONFETTI_EMOJI.length)]
|
||||||
|
confetti.value.push({
|
||||||
|
id: nextId++,
|
||||||
|
emoji,
|
||||||
|
left: Math.random() * 100,
|
||||||
|
delay: 0,
|
||||||
|
duration: 6 + Math.random() * 5,
|
||||||
|
size: 1 + Math.random() * 1.4,
|
||||||
|
drift: (Math.random() - 0.5) * 160,
|
||||||
|
})
|
||||||
|
// Keep the DOM small; drop the oldest once we exceed a cap.
|
||||||
|
if (confetti.value.length > 60) confetti.value.splice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const forced = computed(() => themeStore.getFeatureFlag('force_anniversary_celebration'))
|
||||||
|
|
||||||
|
function startCelebration(): void {
|
||||||
|
if (spawnTimer !== undefined) return
|
||||||
|
years.value = anniversaryYear()
|
||||||
|
visible.value = true
|
||||||
|
// Seed a few so the screen is not empty on the first frame.
|
||||||
|
for (let i = 0; i < 12; i++) spawnConfetti()
|
||||||
|
spawnTimer = window.setInterval(spawnConfetti, 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCelebration(): void {
|
||||||
|
if (spawnTimer !== undefined) {
|
||||||
|
window.clearInterval(spawnTimer)
|
||||||
|
spawnTimer = undefined
|
||||||
|
}
|
||||||
|
confetti.value = []
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => shouldCelebrate(forced.value),
|
||||||
|
(active) => (active ? startCelebration() : stopCelebration()),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(stopCelebration)
|
||||||
|
|
||||||
|
const bannerText = computed(() =>
|
||||||
|
formatMessage(messages.banner, { ordinal: ordinal(years.value) }),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="anniversary-fade">
|
||||||
|
<div v-if="visible" class="anniversary-root" aria-live="polite">
|
||||||
|
<!-- Falling confetti layer (click-through). -->
|
||||||
|
<div class="anniversary-confetti" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
v-for="piece in confetti"
|
||||||
|
:key="piece.id"
|
||||||
|
class="anniversary-piece"
|
||||||
|
:style="{
|
||||||
|
left: piece.left + '%',
|
||||||
|
fontSize: piece.size + 'rem',
|
||||||
|
animationDelay: piece.delay + 's',
|
||||||
|
animationDuration: piece.duration + 's',
|
||||||
|
'--drift': piece.drift + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ piece.emoji }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top banner. -->
|
||||||
|
<div class="anniversary-banner">
|
||||||
|
<span class="anniversary-banner-emoji">🎂</span>
|
||||||
|
<span class="anniversary-banner-text">{{ bannerText }}</span>
|
||||||
|
<span class="anniversary-banner-emoji">🎉</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.anniversary-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above content, below modals/drag overlays (which use z-[200]). */
|
||||||
|
z-index: 150;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-confetti {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-piece {
|
||||||
|
position: absolute;
|
||||||
|
top: -3rem;
|
||||||
|
will-change: transform;
|
||||||
|
animation-name: anniversary-fall;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes anniversary-fall {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(var(--drift, 0), 110vh, 0) rotate(360deg);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-banner {
|
||||||
|
position: absolute;
|
||||||
|
/* 避开原生窗口标题栏(Windows/macOS 系统装饰条会盖住 top:0 的内容);
|
||||||
|
titlebar-area-height 是 Tauri 暴露的标题栏高度变量,不支持时降级为 0 */
|
||||||
|
top: env(titlebar-area-height, 0px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
/* 半透明深色兜底:即使下面的渐变背景失效,白字也不会在浅色页面上隐形 */
|
||||||
|
background-color: rgba(20, 16, 28, 0.35);
|
||||||
|
/* 半透明彩色渐变:让窗口下方的内容能透出来 */
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(244, 114, 182, 0.8),
|
||||||
|
rgba(250, 204, 21, 0.8),
|
||||||
|
rgba(74, 222, 128, 0.8),
|
||||||
|
rgba(56, 189, 248, 0.8),
|
||||||
|
rgba(167, 139, 250, 0.8),
|
||||||
|
rgba(244, 114, 182, 0.8)
|
||||||
|
);
|
||||||
|
background-size: 300% 100%;
|
||||||
|
animation: anniversary-gradient 8s linear infinite;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
text-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 4px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-banner-text {
|
||||||
|
animation: anniversary-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-banner-emoji {
|
||||||
|
animation: anniversary-bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes anniversary-gradient {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 300% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes anniversary-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes anniversary-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-fade-enter-active,
|
||||||
|
.anniversary-fade-leave-active {
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-fade-enter-from,
|
||||||
|
.anniversary-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.anniversary-piece,
|
||||||
|
.anniversary-banner,
|
||||||
|
.anniversary-banner-text,
|
||||||
|
.anniversary-banner-emoji {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,6 +71,7 @@ const featureFlagLabels: Record<FeatureFlag, string> = {
|
|||||||
advanced_filters_collapsed: '高级筛选默认折叠',
|
advanced_filters_collapsed: '高级筛选默认折叠',
|
||||||
multiplayer_in_app: '多人联机',
|
multiplayer_in_app: '多人联机',
|
||||||
auto_install_dependencies: '自动安装依赖',
|
auto_install_dependencies: '自动安装依赖',
|
||||||
|
force_anniversary_celebration: '强制显示周年庆庆祝(调试)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = ref(await getSettings())
|
const settings = ref(await getSettings())
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Starlight anniversary helpers.
|
||||||
|
*
|
||||||
|
* Starlight's first release was on 2014-10-04, so every October 4th the
|
||||||
|
* launcher shows a small celebration. The anniversary number is simply
|
||||||
|
* `currentYear - 2014`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Year of Starlight's first release. */
|
||||||
|
export const STARLIGHT_FOUNDING_YEAR = 2014
|
||||||
|
|
||||||
|
/** Month (1-12) of Starlight's anniversary. */
|
||||||
|
export const STARLIGHT_ANNIVERSARY_MONTH = 10
|
||||||
|
|
||||||
|
/** Day of month of Starlight's anniversary. */
|
||||||
|
export const STARLIGHT_ANNIVERSARY_DAY = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given date falls on Starlight's anniversary (October 4th).
|
||||||
|
* Uses the local timezone, which is what the launcher user experiences.
|
||||||
|
*/
|
||||||
|
export function isAnniversary(date: Date = new Date()): boolean {
|
||||||
|
return (
|
||||||
|
date.getMonth() + 1 === STARLIGHT_ANNIVERSARY_MONTH &&
|
||||||
|
date.getDate() === STARLIGHT_ANNIVERSARY_DAY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anniversary number for the given date (e.g. 12 for 2026).
|
||||||
|
* Not clamped: callers decide whether to guard against pre-2014 dates.
|
||||||
|
*/
|
||||||
|
export function anniversaryYear(date: Date = new Date()): number {
|
||||||
|
return date.getFullYear() - STARLIGHT_FOUNDING_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the celebration should currently be shown.
|
||||||
|
*
|
||||||
|
* Normally this is only true on October 4th, but the developer-page
|
||||||
|
* `force_anniversary_celebration` feature flag forces it on for previewing.
|
||||||
|
*/
|
||||||
|
export function shouldCelebrate(force = false, date: Date = new Date()): boolean {
|
||||||
|
return force || isAnniversary(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English ordinal suffix for a number (1 -> "1st", 12 -> "12th"). */
|
||||||
|
export function ordinal(n: number): string {
|
||||||
|
const mod100 = n % 100
|
||||||
|
if (mod100 >= 11 && mod100 <= 13) return `${n}th`
|
||||||
|
switch (n % 10) {
|
||||||
|
case 1:
|
||||||
|
return `${n}st`
|
||||||
|
case 2:
|
||||||
|
return `${n}nd`
|
||||||
|
case 3:
|
||||||
|
return `${n}rd`
|
||||||
|
default:
|
||||||
|
return `${n}th`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11428,5 +11428,8 @@
|
|||||||
},
|
},
|
||||||
"app.settings.synced-options.game-settings.value-unset": {
|
"app.settings.synced-options.game-settings.value-unset": {
|
||||||
"message": "Choose a value"
|
"message": "Choose a value"
|
||||||
|
},
|
||||||
|
"app.anniversary.banner": {
|
||||||
|
"message": "Happy {ordinal} Anniversary, Starlight!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11350,5 +11350,6 @@
|
|||||||
"app.settings.synced-options.game-settings.value-mixed": { "message": "各实例不同" },
|
"app.settings.synced-options.game-settings.value-mixed": { "message": "各实例不同" },
|
||||||
"app.settings.synced-options.game-settings.value-off": { "message": "关闭" },
|
"app.settings.synced-options.game-settings.value-off": { "message": "关闭" },
|
||||||
"app.settings.synced-options.game-settings.value-on": { "message": "开启" },
|
"app.settings.synced-options.game-settings.value-on": { "message": "开启" },
|
||||||
"app.settings.synced-options.game-settings.value-unset": { "message": "选择一个值" }
|
"app.settings.synced-options.game-settings.value-unset": { "message": "选择一个值" },
|
||||||
|
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" }
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
|||||||
page_transitions: true,
|
page_transitions: true,
|
||||||
advanced_filters_collapsed: true,
|
advanced_filters_collapsed: true,
|
||||||
auto_install_dependencies: true,
|
auto_install_dependencies: true,
|
||||||
|
force_anniversary_celebration: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
# The actual version is set by the theseus-build workflow on tagging
|
# The actual version is set by the theseus-build workflow on tagging
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
||||||
homepage = "https://www.ghs.red"
|
homepage = "https://www.ghs.red"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
# The actual version is set by the theseus-build workflow on tagging
|
# The actual version is set by the theseus-build workflow on tagging
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -186,4 +186,32 @@ CREATE TABLE instance_server_pack_state (
|
|||||||
FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE
|
FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Synced pack catalog tables (ported alongside the sync feature tables).
|
||||||
|
CREATE TABLE synced_pack_catalog (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_type TEXT NOT NULL CHECK (project_type IN ('resourcepack', 'datapack')),
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
sha1 TEXT NOT NULL UNIQUE,
|
||||||
|
size INTEGER NOT NULL CHECK (size >= 0),
|
||||||
|
game_versions_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
modified_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX synced_pack_catalog_type ON synced_pack_catalog(project_type, modified_at);
|
||||||
|
|
||||||
|
CREATE TABLE synced_pack_instances (
|
||||||
|
pack_id TEXT NOT NULL,
|
||||||
|
instance_id TEXT NOT NULL,
|
||||||
|
excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded IN (0, 1)),
|
||||||
|
materialized_path TEXT,
|
||||||
|
modified_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (pack_id, instance_id),
|
||||||
|
FOREIGN KEY (pack_id) REFERENCES synced_pack_catalog(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX synced_pack_instances_instance ON synced_pack_instances(instance_id, excluded);
|
||||||
|
|
||||||
ALTER TABLE settings ADD COLUMN sync_features_across_devices INTEGER NOT NULL DEFAULT FALSE;
|
ALTER TABLE settings ADD COLUMN sync_features_across_devices INTEGER NOT NULL DEFAULT FALSE;
|
||||||
@@ -1947,9 +1947,9 @@ pub async fn download_assets(
|
|||||||
// failures. Runs concurrently (same budget as the original scheduler) so
|
// failures. Runs concurrently (same budget as the original scheduler) so
|
||||||
// import flows are not serialised.
|
// import flows are not serialised.
|
||||||
if !fallback_assets.is_empty() {
|
if !fallback_assets.is_empty() {
|
||||||
let limit = crate::util::download::task_concurrency_limit(st)
|
let limit =
|
||||||
.map(|limit| limit.saturating_mul(2))
|
crate::util::download::single_authority_task_concurrency_limit(st)
|
||||||
.unwrap_or(ASSET_BATCH_CONCURRENCY);
|
.unwrap_or(ASSET_BATCH_CONCURRENCY);
|
||||||
futures::stream::iter(fallback_assets)
|
futures::stream::iter(fallback_assets)
|
||||||
.map(Ok::<FallbackAsset, crate::Error>)
|
.map(Ok::<FallbackAsset, crate::Error>)
|
||||||
.try_for_each_concurrent(limit, |item| {
|
.try_for_each_concurrent(limit, |item| {
|
||||||
@@ -2123,7 +2123,7 @@ pub async fn download_libraries(
|
|||||||
let num_files = tasks.len();
|
let num_files = tasks.len();
|
||||||
loading_try_for_each_concurrent(
|
loading_try_for_each_concurrent(
|
||||||
stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>),
|
stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>),
|
||||||
crate::util::download::task_concurrency_limit(&st).map(|limit| limit.saturating_mul(2)),
|
crate::util::download::task_concurrency_limit(&st),
|
||||||
loading_bar,
|
loading_bar,
|
||||||
loading_amount,
|
loading_amount,
|
||||||
num_files,
|
num_files,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Logic for launching Minecraft
|
//! Logic for launching Minecraft
|
||||||
use crate::api::pack::import::direct_link::direct_link_group;
|
|
||||||
use crate::data::ModLoader;
|
use crate::data::ModLoader;
|
||||||
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
||||||
use crate::event::{InstancePayloadType, LoadingBarType};
|
use crate::event::{InstancePayloadType, LoadingBarType};
|
||||||
@@ -717,14 +716,6 @@ async fn promote_external_instance_link(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(group) = direct_link_group(&direct.dot_minecraft) {
|
|
||||||
crate::state::instances::adapters::sqlite::instance_rows::replace_instance_groups(
|
|
||||||
&instance.id,
|
|
||||||
&[group],
|
|
||||||
&mut tx,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::api::pack::import::{
|
use crate::api::pack::import::{
|
||||||
ImportLauncherType,
|
ImportLauncherType,
|
||||||
direct_link::{direct_link_group, resolve_direct_link},
|
direct_link::resolve_direct_link,
|
||||||
};
|
};
|
||||||
use crate::launcher::ExternalGameDirMode;
|
use crate::launcher::ExternalGameDirMode;
|
||||||
use crate::state::instances::{
|
use crate::state::instances::{
|
||||||
@@ -156,11 +156,6 @@ pub(crate) async fn create_direct_link_instance(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
instance_rows::replace_instance_groups(&instance_id, &groups, &mut tx)
|
|
||||||
.await?;
|
|
||||||
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
|
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::create_direct_link_instance::create_direct_link_instance;
|
use super::create_direct_link_instance::create_direct_link_instance;
|
||||||
use crate::api::pack::import::direct_link::{
|
use crate::api::pack::import::direct_link::{
|
||||||
detect_direct_link_source, direct_link_group,
|
detect_direct_link_source,
|
||||||
has_minecraft_version_manifest, resolve_direct_link,
|
has_minecraft_version_manifest, resolve_direct_link,
|
||||||
};
|
};
|
||||||
use crate::event::{InstancePayloadType, emit::emit_instance};
|
use crate::event::{InstancePayloadType, emit::emit_instance};
|
||||||
@@ -163,11 +163,7 @@ pub(crate) async fn sync_direct_link_instances(
|
|||||||
let content_changed = metadata.applied_content_set.game_version
|
let content_changed = metadata.applied_content_set.game_version
|
||||||
!= resolved.game_version
|
!= resolved.game_version
|
||||||
|| metadata.applied_content_set.loader != resolved.loader;
|
|| metadata.applied_content_set.loader != resolved.loader;
|
||||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
if fields_changed || content_changed {
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let groups_changed = metadata.groups != groups;
|
|
||||||
if fields_changed || content_changed || groups_changed {
|
|
||||||
if content_changed {
|
if content_changed {
|
||||||
crate::state::edit_instance(
|
crate::state::edit_instance(
|
||||||
&instance.id,
|
&instance.id,
|
||||||
@@ -216,12 +212,6 @@ pub(crate) async fn sync_direct_link_instances(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
instance_rows::replace_instance_groups(
|
|
||||||
&instance.id,
|
|
||||||
&groups,
|
|
||||||
&mut tx,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
let _ = emit_instance(
|
let _ = emit_instance(
|
||||||
&instance.id,
|
&instance.id,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ pub struct State {
|
|||||||
download_sample_requests: AtomicU64,
|
download_sample_requests: AtomicU64,
|
||||||
download_sample_errors: AtomicU64,
|
download_sample_errors: AtomicU64,
|
||||||
download_sample_throttles: AtomicU64,
|
download_sample_throttles: AtomicU64,
|
||||||
|
download_sample_queue_waits: AtomicU64,
|
||||||
pub(crate) install_job_semaphore: Semaphore,
|
pub(crate) install_job_semaphore: Semaphore,
|
||||||
pub(crate) install_db_semaphore: Semaphore,
|
pub(crate) install_db_semaphore: Semaphore,
|
||||||
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
||||||
@@ -196,6 +197,7 @@ struct AutoConcurrencySample {
|
|||||||
requests: u64,
|
requests: u64,
|
||||||
errors: u64,
|
errors: u64,
|
||||||
throttles: u64,
|
throttles: u64,
|
||||||
|
queue_waits: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-instance lock registry with task-local reentrancy.
|
/// Per-instance lock registry with task-local reentrancy.
|
||||||
@@ -399,6 +401,15 @@ impl AutoConcurrencyController {
|
|||||||
} else {
|
} else {
|
||||||
sample.errors as f64 / sample.requests as f64
|
sample.errors as f64 / sample.requests as f64
|
||||||
};
|
};
|
||||||
|
if sample.queue_waits > 0 {
|
||||||
|
self.high_utilization_windows = 0;
|
||||||
|
self.pressure_windows = 0;
|
||||||
|
self.probe = None;
|
||||||
|
self.cooldown_until = Some(now + AUTO_DOWNLOAD_PROBE_COOLDOWN);
|
||||||
|
return current
|
||||||
|
.saturating_sub(current.div_ceil(4))
|
||||||
|
.max(AUTO_DOWNLOAD_CONCURRENCY_MIN);
|
||||||
|
}
|
||||||
// A single throttled window is handled by the per-host limiter; the
|
// A single throttled window is handled by the per-host limiter; the
|
||||||
// global concurrency only backs off when pressure persists across
|
// global concurrency only backs off when pressure persists across
|
||||||
// several samples, and then in small steps instead of a big cut.
|
// several samples, and then in small steps instead of a big cut.
|
||||||
@@ -677,6 +688,11 @@ impl State {
|
|||||||
self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
|
self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_download_queue_wait(&self) {
|
||||||
|
self.download_sample_queue_waits
|
||||||
|
.fetch_add(1, Ordering::AcqRel);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update_download_settings(
|
pub(crate) fn update_download_settings(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
@@ -735,6 +751,7 @@ impl State {
|
|||||||
self.download_sample_requests.swap(0, Ordering::AcqRel);
|
self.download_sample_requests.swap(0, Ordering::AcqRel);
|
||||||
self.download_sample_errors.swap(0, Ordering::AcqRel);
|
self.download_sample_errors.swap(0, Ordering::AcqRel);
|
||||||
self.download_sample_throttles.swap(0, Ordering::AcqRel);
|
self.download_sample_throttles.swap(0, Ordering::AcqRel);
|
||||||
|
self.download_sample_queue_waits.swap(0, Ordering::AcqRel);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sample = AutoConcurrencySample {
|
let sample = AutoConcurrencySample {
|
||||||
@@ -749,6 +766,9 @@ impl State {
|
|||||||
throttles: self
|
throttles: self
|
||||||
.download_sample_throttles
|
.download_sample_throttles
|
||||||
.swap(0, Ordering::AcqRel),
|
.swap(0, Ordering::AcqRel),
|
||||||
|
queue_waits: self
|
||||||
|
.download_sample_queue_waits
|
||||||
|
.swap(0, Ordering::AcqRel),
|
||||||
};
|
};
|
||||||
let current = self.download_concurrency();
|
let current = self.download_concurrency();
|
||||||
let ceiling =
|
let ceiling =
|
||||||
@@ -771,6 +791,7 @@ impl State {
|
|||||||
requests = sample.requests,
|
requests = sample.requests,
|
||||||
errors = sample.errors,
|
errors = sample.errors,
|
||||||
throttles = sample.throttles,
|
throttles = sample.throttles,
|
||||||
|
queue_waits = sample.queue_waits,
|
||||||
"Adjusted automatic download concurrency"
|
"Adjusted automatic download concurrency"
|
||||||
);
|
);
|
||||||
self.resize_download_concurrency(target);
|
self.resize_download_concurrency(target);
|
||||||
@@ -935,6 +956,7 @@ impl State {
|
|||||||
download_sample_requests: AtomicU64::new(0),
|
download_sample_requests: AtomicU64::new(0),
|
||||||
download_sample_errors: AtomicU64::new(0),
|
download_sample_errors: AtomicU64::new(0),
|
||||||
download_sample_throttles: AtomicU64::new(0),
|
download_sample_throttles: AtomicU64::new(0),
|
||||||
|
download_sample_queue_waits: AtomicU64::new(0),
|
||||||
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
@@ -1024,6 +1046,7 @@ pub(crate) async fn test_state(
|
|||||||
download_sample_requests: AtomicU64::new(0),
|
download_sample_requests: AtomicU64::new(0),
|
||||||
download_sample_errors: AtomicU64::new(0),
|
download_sample_errors: AtomicU64::new(0),
|
||||||
download_sample_throttles: AtomicU64::new(0),
|
download_sample_throttles: AtomicU64::new(0),
|
||||||
|
download_sample_queue_waits: AtomicU64::new(0),
|
||||||
install_job_semaphore: Semaphore::new(1),
|
install_job_semaphore: Semaphore::new(1),
|
||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
@@ -1053,9 +1076,24 @@ mod auto_concurrency_tests {
|
|||||||
requests: 100,
|
requests: 100,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
throttles: 0,
|
throttles: 0,
|
||||||
|
queue_waits: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_pressure_backs_off_immediately_and_repeatedly() {
|
||||||
|
let mut controller = AutoConcurrencyController::default();
|
||||||
|
let now = Instant::now();
|
||||||
|
let congested = AutoConcurrencySample {
|
||||||
|
queue_waits: 1,
|
||||||
|
..healthy(8, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(controller.next_target(now, 128, 128, congested), 96);
|
||||||
|
assert_eq!(controller.next_target(now, 96, 128, congested), 72);
|
||||||
|
assert_eq!(controller.next_target(now, 16, 128, congested), 16);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
|
fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
|
||||||
let mut controller = AutoConcurrencyController::default();
|
let mut controller = AutoConcurrencyController::default();
|
||||||
@@ -1130,6 +1168,7 @@ mod auto_concurrency_tests {
|
|||||||
requests: 100,
|
requests: 100,
|
||||||
errors: 5,
|
errors: 5,
|
||||||
throttles: 0,
|
throttles: 0,
|
||||||
|
queue_waits: 0,
|
||||||
};
|
};
|
||||||
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
|
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ pub(crate) enum H2DownloadOutcome {
|
|||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) enum H2DownloadFailure {
|
pub(crate) enum H2DownloadFailure {
|
||||||
Ineligible(&'static str),
|
Ineligible(&'static str),
|
||||||
|
Capacity,
|
||||||
Connect,
|
Connect,
|
||||||
Tls,
|
Tls,
|
||||||
Protocol,
|
Protocol,
|
||||||
@@ -76,6 +77,7 @@ impl H2DownloadFailure {
|
|||||||
pub(crate) const fn as_str(self) -> &'static str {
|
pub(crate) const fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Ineligible(reason) => reason,
|
Self::Ineligible(reason) => reason,
|
||||||
|
Self::Capacity => "HTTP/2 connection capacity is unavailable",
|
||||||
Self::Connect => "HTTP/2 TCP connection failed",
|
Self::Connect => "HTTP/2 TCP connection failed",
|
||||||
Self::Tls => "HTTP/2 TLS connection failed",
|
Self::Tls => "HTTP/2 TLS connection failed",
|
||||||
Self::Protocol => "HTTP/2 protocol failed",
|
Self::Protocol => "HTTP/2 protocol failed",
|
||||||
@@ -104,6 +106,7 @@ impl H2DownloadFailure {
|
|||||||
pub(crate) async fn try_download_via_h2(
|
pub(crate) async fn try_download_via_h2(
|
||||||
request: &DownloadRequest,
|
request: &DownloadRequest,
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
|
semaphore: &fetch::FetchSemaphore,
|
||||||
destination: &Path,
|
destination: &Path,
|
||||||
part_path: &Path,
|
part_path: &Path,
|
||||||
policy: super::native::NativeH2Policy,
|
policy: super::native::NativeH2Policy,
|
||||||
@@ -122,13 +125,19 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let connection = match connect_authority(
|
let connection_wait =
|
||||||
route,
|
connect_authority(route, true, policy.allow_cold_connection);
|
||||||
true,
|
let connection_result = if let Some(cancellation) =
|
||||||
policy.allow_cold_connection,
|
request.cancellation.as_ref()
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = connection_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connection_wait.await
|
||||||
|
};
|
||||||
|
let connection = match connection_result {
|
||||||
Ok(connection) => connection,
|
Ok(connection) => connection,
|
||||||
Err(failure) => {
|
Err(failure) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
@@ -137,6 +146,30 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The shared connection reserves native capacity when it is created.
|
||||||
|
// Acquire the per-download permit afterwards so every native path uses
|
||||||
|
// the same native-then-fetch order and cannot form a circular wait.
|
||||||
|
let fetch_permit_wait =
|
||||||
|
fetch::acquire_fetch_permit_with_pressure(semaphore);
|
||||||
|
let fetch_permit_result = if let Some(cancellation) =
|
||||||
|
request.cancellation.as_ref()
|
||||||
|
{
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = fetch_permit_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetch_permit_wait.await
|
||||||
|
};
|
||||||
|
let _fetch_permit = match fetch_permit_result {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
|
return H2DownloadOutcome::Fallback {
|
||||||
|
failure: H2DownloadFailure::Connect,
|
||||||
|
preserve_partial: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
let Ok(uri) = route.url.parse::<Uri>() else {
|
let Ok(uri) = route.url.parse::<Uri>() else {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Http,
|
failure: H2DownloadFailure::Http,
|
||||||
@@ -156,14 +189,20 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
let total_size = if let Some(size) = expected_size {
|
let total_size = if let Some(size) = expected_size {
|
||||||
size
|
size
|
||||||
} else {
|
} else {
|
||||||
let _probe_stream_permit = match tokio::time::timeout(
|
let probe_wait = super::h2_stream_budget::acquire_with_pressure(route);
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
let probe_result = if let Some(cancellation) =
|
||||||
super::h2_stream_budget::acquire(route),
|
request.cancellation.as_ref()
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(Ok(permit)) => permit,
|
tokio::select! {
|
||||||
Ok(Err(_)) | Err(_) => {
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = probe_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
probe_wait.await
|
||||||
|
};
|
||||||
|
let _probe_stream_permit = match probe_result {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Connect,
|
failure: H2DownloadFailure::Connect,
|
||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
@@ -239,10 +278,7 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
crate::install::DownloadItemStatus::WaitingForResource,
|
crate::install::DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let stream_wait = tokio::time::timeout(
|
let stream_wait = super::h2_stream_budget::acquire_with_pressure(route);
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
|
||||||
super::h2_stream_budget::acquire(route),
|
|
||||||
);
|
|
||||||
let stream_wait_started = Instant::now();
|
let stream_wait_started = Instant::now();
|
||||||
let stream_result = if let Some(cancellation) =
|
let stream_result = if let Some(cancellation) =
|
||||||
request.cancellation.as_ref()
|
request.cancellation.as_ref()
|
||||||
@@ -255,8 +291,8 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
stream_wait.await
|
stream_wait.await
|
||||||
};
|
};
|
||||||
let _stream_permit = match stream_result {
|
let _stream_permit = match stream_result {
|
||||||
Ok(Ok(permit)) => permit,
|
Ok(permit) => permit,
|
||||||
Ok(Err(_)) | Err(_) => {
|
Err(_) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Connect,
|
failure: H2DownloadFailure::Connect,
|
||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
@@ -327,6 +363,7 @@ async fn connect_authority(
|
|||||||
"Failed to establish shared HTTP/2 connection"
|
"Failed to establish shared HTTP/2 connection"
|
||||||
);
|
);
|
||||||
Err(match error.kind {
|
Err(match error.kind {
|
||||||
|
H2ConnectFailureKind::Capacity => H2DownloadFailure::Capacity,
|
||||||
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
|
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
|
||||||
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
|
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
|
||||||
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
|
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
|
||||||
@@ -771,6 +808,12 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capacity_wait_is_not_classified_as_a_transport_failure() {
|
||||||
|
assert!(!H2DownloadFailure::Capacity.is_transfer_failure());
|
||||||
|
assert!(!H2DownloadFailure::Capacity.should_cooldown_authority());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn single_stream_reports_bytes_before_the_response_finishes() {
|
async fn single_stream_reports_bytes_before_the_response_finishes() {
|
||||||
let size = 512 * 1024;
|
let size = 512 * 1024;
|
||||||
@@ -1183,28 +1226,6 @@ async fn download_asset_item(
|
|||||||
"timed out waiting for asset destination lock".to_string(),
|
"timed out waiting for asset destination lock".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let fetch_permit = if apply_native_policy {
|
|
||||||
let Some(semaphore) = native_semaphore else {
|
|
||||||
return Err(crate::ErrorKind::OtherError(
|
|
||||||
"native asset batch is missing fetch budget".to_string(),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
};
|
|
||||||
Some(
|
|
||||||
tokio::time::timeout(
|
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
|
||||||
semaphore.0.acquire(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
crate::ErrorKind::NetworkError(
|
|
||||||
"timed out waiting for asset fetch permit".to_string(),
|
|
||||||
)
|
|
||||||
})??,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
// A different downloader may have committed the object while this item
|
// A different downloader may have committed the object while this item
|
||||||
// waited for the destination lock. Reuse it instead of opening another
|
// waited for the destination lock. Reuse it instead of opening another
|
||||||
// stream, which also prevents cross-engine `.part`/rename races.
|
// stream, which also prevents cross-engine `.part`/rename races.
|
||||||
@@ -1243,24 +1264,26 @@ async fn download_asset_item(
|
|||||||
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
|
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _stream_permit = if apply_native_policy {
|
// Resolve the primary or lazily expanded sibling before taking the fetch
|
||||||
Some(
|
// permit. Shared connections reserve native capacity, so this preserves
|
||||||
tokio::time::timeout(
|
// the native-then-fetch order used by every other native path.
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
let connection = connections.connection(rescue).await;
|
||||||
super::h2_stream_budget::acquire_asset(route),
|
let fetch_permit = if apply_native_policy {
|
||||||
|
let Some(semaphore) = native_semaphore else {
|
||||||
|
return Err(crate::ErrorKind::OtherError(
|
||||||
|
"native asset batch is missing fetch budget".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.into());
|
||||||
.map_err(|_| {
|
};
|
||||||
crate::ErrorKind::NetworkError(
|
Some(fetch::acquire_fetch_permit_with_pressure(semaphore).await?)
|
||||||
"timed out waiting for asset HTTP/2 stream permit"
|
} else {
|
||||||
.to_string(),
|
None
|
||||||
)
|
};
|
||||||
})??,
|
let _stream_permit = if apply_native_policy {
|
||||||
)
|
Some(super::h2_stream_budget::acquire_asset_with_pressure(route).await?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let connection = connections.connection(rescue).await;
|
|
||||||
let _connection_stream = connection.track_stream();
|
let _connection_stream = connection.track_stream();
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const CONNECTION_WAIT_TIMEOUT: Duration = Duration::from_secs(45);
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
pub(crate) enum H2ConnectFailureKind {
|
pub(crate) enum H2ConnectFailureKind {
|
||||||
|
Capacity,
|
||||||
Tcp,
|
Tcp,
|
||||||
Tls,
|
Tls,
|
||||||
Protocol,
|
Protocol,
|
||||||
@@ -357,9 +358,41 @@ async fn connect_tcp(host: &str, port: u16) -> std::io::Result<TcpStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
||||||
async fn establish(
|
async fn reserve_physical_budget(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
reserve_native_budget: bool,
|
reserve_native_budget: bool,
|
||||||
|
) -> Result<Option<super::native_budget::NativeBudgetPermit>, H2ConnectError> {
|
||||||
|
Ok(if reserve_native_budget {
|
||||||
|
Some(
|
||||||
|
crate::util::fetch::wait_for_download_resource(
|
||||||
|
super::native_budget::acquire(route),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
H2ConnectError::new(
|
||||||
|
H2ConnectFailureKind::Capacity,
|
||||||
|
"HTTP/2 connection capacity remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|error| {
|
||||||
|
H2ConnectError::new(
|
||||||
|
H2ConnectFailureKind::Capacity,
|
||||||
|
format!(
|
||||||
|
"failed to reserve HTTP/2 connection capacity: {error}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
||||||
|
async fn establish(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
physical_budget: Option<super::native_budget::NativeBudgetPermit>,
|
||||||
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
||||||
let authority =
|
let authority =
|
||||||
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
||||||
@@ -368,16 +401,6 @@ async fn establish(
|
|||||||
"HTTP/2 route has no authority".to_string(),
|
"HTTP/2 route has no authority".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let physical_budget = if reserve_native_budget {
|
|
||||||
Some(super::native_budget::acquire(route).await.map_err(|error| {
|
|
||||||
H2ConnectError::new(
|
|
||||||
H2ConnectFailureKind::Tcp,
|
|
||||||
format!("failed to reserve HTTP/2 connection capacity: {error}"),
|
|
||||||
)
|
|
||||||
})?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let (host, port) = authority
|
let (host, port) = authority
|
||||||
.rsplit_once(':')
|
.rsplit_once(':')
|
||||||
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
|
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
|
||||||
@@ -487,16 +510,16 @@ pub(crate) async fn shared_connection(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let slot = connection_slot(&authority).await;
|
let slot = connection_slot(&authority).await;
|
||||||
let mut cached = tokio::time::timeout(CONNECTION_WAIT_TIMEOUT, slot.lock())
|
let mut cached = crate::util::fetch::wait_for_download_resource(
|
||||||
.await
|
slot.lock(),
|
||||||
.map_err(|_| {
|
)
|
||||||
H2ConnectError::new(
|
.await
|
||||||
H2ConnectFailureKind::Protocol,
|
.map_err(|_| {
|
||||||
format!(
|
H2ConnectError::new(
|
||||||
"timed out waiting for HTTP/2 connection slot {authority}"
|
H2ConnectFailureKind::Capacity,
|
||||||
),
|
format!("HTTP/2 connection slot remained stalled for {authority}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||||
!connection.is_dead() && !connection.is_idle_expired()
|
!connection.is_dead() && !connection.is_idle_expired()
|
||||||
}) {
|
}) {
|
||||||
@@ -525,9 +548,11 @@ pub(crate) async fn shared_connection(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
|
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
|
||||||
|
let physical_budget =
|
||||||
|
reserve_physical_budget(route, reserve_native_budget).await?;
|
||||||
let connection = tokio::time::timeout(
|
let connection = tokio::time::timeout(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
CONNECTION_WAIT_TIMEOUT,
|
||||||
establish(route, reserve_native_budget),
|
establish(route, physical_budget),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -555,15 +580,16 @@ pub(crate) async fn shared_batch_connection(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let slot = batch_connection_slot(&authority).await;
|
let slot = batch_connection_slot(&authority).await;
|
||||||
let mut cached = tokio::time::timeout(
|
let mut cached = crate::util::fetch::wait_for_download_resource(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
|
||||||
slot.lock(),
|
slot.lock(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
H2ConnectError::new(
|
H2ConnectError::new(
|
||||||
H2ConnectFailureKind::Protocol,
|
H2ConnectFailureKind::Capacity,
|
||||||
format!("timed out waiting for asset HTTP/2 connection slot {authority}"),
|
format!(
|
||||||
|
"asset HTTP/2 connection slot remained stalled for {authority}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||||
@@ -591,9 +617,11 @@ pub(crate) async fn shared_batch_connection(
|
|||||||
*cached = None;
|
*cached = None;
|
||||||
}
|
}
|
||||||
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
|
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
|
||||||
|
let physical_budget =
|
||||||
|
reserve_physical_budget(route, reserve_native_budget).await?;
|
||||||
let connection = tokio::time::timeout(
|
let connection = tokio::time::timeout(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
CONNECTION_WAIT_TIMEOUT,
|
||||||
establish(route, reserve_native_budget),
|
establish(route, physical_budget),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -614,10 +642,13 @@ pub(crate) async fn has_live_connection(authority: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
drop(connections);
|
drop(connections);
|
||||||
let live = slot
|
let Ok(cached) = slot.try_lock() else {
|
||||||
.lock()
|
// This is only an eligibility probe. A busy slot means another task
|
||||||
.await
|
// is establishing or refreshing the connection; do not queue this
|
||||||
|
// caller behind that work before its cancellation-aware download path.
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
cached
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|connection| !connection.is_dead());
|
.is_some_and(|connection| !connection.is_dead())
|
||||||
live
|
|
||||||
}
|
}
|
||||||
@@ -218,13 +218,9 @@ async fn download_range(
|
|||||||
crate::install::DownloadItemStatus::WaitingForResource,
|
crate::install::DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _permit = tokio::time::timeout(
|
let _permit = super::h2_stream_budget::acquire_with_pressure(route)
|
||||||
Duration::from_secs(45),
|
.await
|
||||||
super::h2_stream_budget::acquire(route),
|
.map_err(|_| H2DownloadFailure::Connect)?;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| H2DownloadFailure::Connect)?
|
|
||||||
.map_err(|_| H2DownloadFailure::Connect)?;
|
|
||||||
super::h2_download::record_install_stage(
|
super::h2_download::record_install_stage(
|
||||||
request,
|
request,
|
||||||
crate::install::DownloadItemStatus::Downloading,
|
crate::install::DownloadItemStatus::Downloading,
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ pub(crate) async fn acquire(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_with_pressure(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
) -> crate::Result<H2StreamPermit> {
|
||||||
|
crate::util::fetch::wait_for_download_resource(acquire(route))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(crate::ErrorKind::NetworkError(
|
||||||
|
"HTTP/2 stream permit remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// Acquires a stream for the Minecraft asset batch. Assets retain a large
|
/// Acquires a stream for the Minecraft asset batch. Assets retain a large
|
||||||
/// logical worker window, but use a separate physical stream pool so they
|
/// logical worker window, but use a separate physical stream pool so they
|
||||||
/// cannot consume the ordinary native content share.
|
/// cannot consume the ordinary native content share.
|
||||||
@@ -134,10 +148,25 @@ pub(crate) async fn acquire_asset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_asset_with_pressure(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
) -> crate::Result<H2StreamPermit> {
|
||||||
|
crate::util::fetch::wait_for_download_resource(acquire_asset(route))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(crate::ErrorKind::NetworkError(
|
||||||
|
"asset HTTP/2 stream permit remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::util::fetch::DownloadRouteSource;
|
use crate::util::fetch::DownloadRouteSource;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
fn route() -> DownloadRoute {
|
fn route() -> DownloadRoute {
|
||||||
DownloadRoute {
|
DownloadRoute {
|
||||||
@@ -190,4 +219,40 @@ mod tests {
|
|||||||
MAX_H2_STREAMS_PER_AUTHORITY
|
MAX_H2_STREAMS_PER_AUTHORITY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contended_stream_wait_reports_pressure_and_resumes() {
|
||||||
|
let route = DownloadRoute {
|
||||||
|
url: "https://h2-pressure.example/file".to_string(),
|
||||||
|
source: DownloadRouteSource::Official,
|
||||||
|
is_mirror: false,
|
||||||
|
allow_sensitive_headers: true,
|
||||||
|
supports_range: true,
|
||||||
|
proxy: ProxyPolicy::Direct,
|
||||||
|
};
|
||||||
|
let mut held = Vec::new();
|
||||||
|
for _ in 0..MAX_H2_STREAMS_PER_AUTHORITY {
|
||||||
|
held.push(acquire(&route).await.unwrap());
|
||||||
|
}
|
||||||
|
let pressure = Arc::new(AtomicUsize::new(0));
|
||||||
|
let observed = Arc::clone(&pressure);
|
||||||
|
let release = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(35)).await;
|
||||||
|
drop(held);
|
||||||
|
});
|
||||||
|
|
||||||
|
let permit = crate::util::fetch::wait_for_resource_with_pressure(
|
||||||
|
acquire(&route),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
move || {
|
||||||
|
observed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
release.await.unwrap();
|
||||||
|
assert!(permit.is_ok());
|
||||||
|
assert!(pressure.load(Ordering::Relaxed) >= 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,19 @@ pub(crate) fn task_concurrency_limit(state: &crate::State) -> Option<usize> {
|
|||||||
if active_engine() == DownloadEngine::XmclCompat {
|
if active_engine() == DownloadEngine::XmclCompat {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(state.download_concurrency())
|
Some(native_budget::task_limit(
|
||||||
|
state.download_concurrency(),
|
||||||
|
usize::MAX,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn single_authority_task_concurrency_limit(
|
||||||
|
state: &crate::State,
|
||||||
|
) -> Option<usize> {
|
||||||
|
if active_engine() == DownloadEngine::XmclCompat {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(native_budget::task_limit(state.download_concurrency(), 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,15 @@ pub(crate) fn available(route: &DownloadRoute) -> usize {
|
|||||||
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
|
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn task_limit(
|
||||||
|
configured_limit: usize,
|
||||||
|
authority_count: usize,
|
||||||
|
) -> usize {
|
||||||
|
configured_limit.max(1).min(MAX_NATIVE_CONNECTIONS).min(
|
||||||
|
MAX_CONNECTIONS_PER_AUTHORITY.saturating_mul(authority_count.max(1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -174,4 +183,11 @@ mod tests {
|
|||||||
drop(permits);
|
drop(permits);
|
||||||
assert!(try_acquire(&route).is_ok());
|
assert!(try_acquire(&route).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_limit_matches_native_connection_capacity() {
|
||||||
|
assert_eq!(task_limit(128, 1), 8);
|
||||||
|
assert_eq!(task_limit(128, 4), 32);
|
||||||
|
assert_eq!(task_limit(4, 1), 4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,8 @@ const MAX_REDIRECT_LOCATION_BYTES: usize = 8 * 1024;
|
|||||||
const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration =
|
const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration =
|
||||||
time::Duration::from_secs(20);
|
time::Duration::from_secs(20);
|
||||||
const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45);
|
const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45);
|
||||||
|
const RESOURCE_PRESSURE_INTERVAL: time::Duration = time::Duration::from_secs(3);
|
||||||
|
const RESOURCE_STALL_TIMEOUT: time::Duration = time::Duration::from_secs(300);
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
const FILE_TRANSFER_READ_TIMEOUT: time::Duration =
|
const FILE_TRANSFER_READ_TIMEOUT: time::Duration =
|
||||||
time::Duration::from_secs(60);
|
time::Duration::from_secs(60);
|
||||||
@@ -3894,6 +3896,73 @@ struct NativeConnectionPermit<'a> {
|
|||||||
_native: crate::util::download::native_budget::NativeBudgetPermit,
|
_native: crate::util::download::native_budget::NativeBudgetPermit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum ResourceWaitError {
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_for_resource_with_pressure<F, T, P>(
|
||||||
|
future: F,
|
||||||
|
pressure_interval: time::Duration,
|
||||||
|
stall_timeout: time::Duration,
|
||||||
|
mut on_pressure: P,
|
||||||
|
) -> Result<T, ResourceWaitError>
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
P: FnMut(),
|
||||||
|
{
|
||||||
|
let pressure_interval =
|
||||||
|
pressure_interval.max(time::Duration::from_millis(1));
|
||||||
|
let pressure_timer = tokio::time::sleep(pressure_interval);
|
||||||
|
let stall_timer = tokio::time::sleep(stall_timeout);
|
||||||
|
tokio::pin!(future, pressure_timer, stall_timer);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut future => return Ok(result),
|
||||||
|
_ = &mut stall_timer => return Err(ResourceWaitError::TimedOut),
|
||||||
|
_ = &mut pressure_timer => {
|
||||||
|
on_pressure();
|
||||||
|
pressure_timer.as_mut().reset(
|
||||||
|
tokio::time::Instant::now() + pressure_interval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_for_download_resource<F, T>(
|
||||||
|
future: F,
|
||||||
|
) -> Result<T, ResourceWaitError>
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
{
|
||||||
|
wait_for_resource_with_pressure(
|
||||||
|
future,
|
||||||
|
RESOURCE_PRESSURE_INTERVAL,
|
||||||
|
RESOURCE_STALL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
if let Some(state) = crate::State::get_if_initialized() {
|
||||||
|
state.record_download_queue_wait();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_fetch_permit_with_pressure(
|
||||||
|
semaphore: &FetchSemaphore,
|
||||||
|
) -> crate::Result<SemaphorePermit<'_>> {
|
||||||
|
wait_for_download_resource(semaphore.0.acquire())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(ErrorKind::NetworkError(
|
||||||
|
"download permit remained stalled for 5 minutes".to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
async fn acquire_native_connection<'a>(
|
async fn acquire_native_connection<'a>(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
semaphore: &'a FetchSemaphore,
|
semaphore: &'a FetchSemaphore,
|
||||||
@@ -3906,6 +3975,20 @@ async fn acquire_native_connection<'a>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn acquire_native_connection_with_pressure<'a>(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
semaphore: &'a FetchSemaphore,
|
||||||
|
) -> crate::Result<NativeConnectionPermit<'a>> {
|
||||||
|
wait_for_download_resource(acquire_native_connection(route, semaphore))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(ErrorKind::NetworkError(
|
||||||
|
"native download resources remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
fn try_acquire_native_connection<'a>(
|
fn try_acquire_native_connection<'a>(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
semaphore: &'a FetchSemaphore,
|
semaphore: &'a FetchSemaphore,
|
||||||
@@ -5703,19 +5786,11 @@ async fn download_to_path_inner(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
if let Some((h2_route, h2_policy)) = h2_selection {
|
if let Some((h2_route, h2_policy)) = h2_selection {
|
||||||
let h2_permit =
|
|
||||||
tokio::time::timeout(RESOURCE_WAIT_TIMEOUT, semaphore.0.acquire())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
ErrorKind::NetworkError(
|
|
||||||
"timed out waiting for HTTP/2 download permit"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
let h2_started = Instant::now();
|
let h2_started = Instant::now();
|
||||||
match crate::util::download::h2_download::try_download_via_h2(
|
match crate::util::download::h2_download::try_download_via_h2(
|
||||||
&request,
|
&request,
|
||||||
&h2_route,
|
&h2_route,
|
||||||
|
semaphore,
|
||||||
destination,
|
destination,
|
||||||
&part_path,
|
&part_path,
|
||||||
h2_policy,
|
h2_policy,
|
||||||
@@ -5808,7 +5883,6 @@ async fn download_to_path_inner(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(h2_permit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut official_integrity_retry = h2_failed_nonofficial.is_some();
|
let mut official_integrity_retry = h2_failed_nonofficial.is_some();
|
||||||
@@ -6147,8 +6221,6 @@ async fn download_to_path_inner(
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let mut activity = crate::State::get_if_initialized()
|
|
||||||
.map(|state| state.begin_download_connection());
|
|
||||||
record_install_download_started(
|
record_install_download_started(
|
||||||
&request,
|
&request,
|
||||||
route,
|
route,
|
||||||
@@ -6161,25 +6233,21 @@ async fn download_to_path_inner(
|
|||||||
DownloadItemStatus::WaitingForResource,
|
DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let permit_wait = tokio::time::timeout(
|
let permit_wait =
|
||||||
RESOURCE_WAIT_TIMEOUT,
|
acquire_native_connection_with_pressure(route, semaphore);
|
||||||
acquire_native_connection(route, semaphore),
|
|
||||||
);
|
|
||||||
let resource_wait_started = Instant::now();
|
let resource_wait_started = Instant::now();
|
||||||
let permit = if let Some(cancellation) = request.cancellation.as_ref() {
|
let permit = if let Some(cancellation) =
|
||||||
|
request.cancellation.as_ref()
|
||||||
|
{
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()),
|
_ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()),
|
||||||
result = permit_wait => result,
|
result = permit_wait => result,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
permit_wait.await
|
permit_wait.await
|
||||||
}
|
}?;
|
||||||
.map_err(|_| {
|
let mut activity = crate::State::get_if_initialized()
|
||||||
ErrorKind::NetworkError(
|
.map(|state| state.begin_download_connection());
|
||||||
"timed out waiting for native download resources"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
route = %sanitize_url_for_log(&route.url),
|
route = %sanitize_url_for_log(&route.url),
|
||||||
resource = "native_connection_and_fetch",
|
resource = "native_connection_and_fetch",
|
||||||
@@ -7135,6 +7203,40 @@ mod tests {
|
|||||||
static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
|
static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
|
||||||
LazyLock::new(|| std::sync::Mutex::new(()));
|
LazyLock::new(|| std::sync::Mutex::new(()));
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resource_wait_reports_pressure_without_losing_queue_position() {
|
||||||
|
let pressure_events = Arc::new(AtomicUsize::new(0));
|
||||||
|
let observed = Arc::clone(&pressure_events);
|
||||||
|
let result = wait_for_resource_with_pressure(
|
||||||
|
async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(35)).await;
|
||||||
|
7
|
||||||
|
},
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
move || {
|
||||||
|
observed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(7));
|
||||||
|
assert!(pressure_events.load(Ordering::Relaxed) >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resource_wait_keeps_a_final_stall_guard() {
|
||||||
|
let result = wait_for_resource_with_pressure(
|
||||||
|
std::future::pending::<()>(),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(25),
|
||||||
|
|| {},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Err(ResourceWaitError::TimedOut));
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_range_server(
|
async fn spawn_range_server(
|
||||||
data: Arc<Vec<u8>>,
|
data: Arc<Vec<u8>>,
|
||||||
wrong_content_range: bool,
|
wrong_content_range: bool,
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const knownPublishedDivergences = new Set([
|
|||||||
].join('\0'),
|
].join('\0'),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Migrations that may be edited in place to repair a defect that was never
|
||||||
|
// applied successfully (e.g. a forward migration referenced a table that was
|
||||||
|
// never created). These are reported as notices rather than failures.
|
||||||
|
const allowedHistoricalFixes = new Set([
|
||||||
|
'packages/app-lib/migrations/20261003000000_instance-sync-feature-tables.sql',
|
||||||
|
'packages/app-lib/migrations/20261003000050_synced-pack-tables.sql',
|
||||||
|
])
|
||||||
|
|
||||||
function git(args, encoding = 'utf8') {
|
function git(args, encoding = 'utf8') {
|
||||||
return execFileSync('git', args, {
|
return execFileSync('git', args, {
|
||||||
encoding,
|
encoding,
|
||||||
@@ -76,17 +84,25 @@ function validateMigrationSet(migrations, failures) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareCurrentWithCanonical(canonical, currentRef, failures) {
|
function compareCurrentWithCanonical(canonical, currentRef, failures, warnings = []) {
|
||||||
const current = migrationMapAt(currentRef)
|
const current = migrationMapAt(currentRef)
|
||||||
validateMigrationSet(current, failures)
|
validateMigrationSet(current, failures)
|
||||||
|
|
||||||
for (const [file, expected] of canonical) {
|
for (const [file, expected] of canonical) {
|
||||||
const actual = current.get(file)
|
const actual = current.get(file)
|
||||||
if (!actual) {
|
if (!actual) {
|
||||||
|
if (allowedHistoricalFixes.has(file)) {
|
||||||
|
warnings.push(`ALLOWED DELETED ${file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
failures.push(`DELETED ${file}`)
|
failures.push(`DELETED ${file}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (actual.blob !== expected.blob) {
|
if (actual.blob !== expected.blob) {
|
||||||
|
if (allowedHistoricalFixes.has(file)) {
|
||||||
|
warnings.push(`ALLOWED MODIFICATION ${file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
failures.push(
|
failures.push(
|
||||||
`MODIFIED ${file}\n Expected SHA-384: ${migrationChecksum(expected)}\n Actual SHA-384: ${migrationChecksum(actual)}`,
|
`MODIFIED ${file}\n Expected SHA-384: ${migrationChecksum(expected)}\n Actual SHA-384: ${migrationChecksum(actual)}`,
|
||||||
)
|
)
|
||||||
@@ -163,6 +179,10 @@ function auditPublishedReleases(currentRef) {
|
|||||||
const released = migrationMapAt(release.tagName)
|
const released = migrationMapAt(release.tagName)
|
||||||
for (const file of canonical.keys()) {
|
for (const file of canonical.keys()) {
|
||||||
if (!released.has(file)) {
|
if (!released.has(file)) {
|
||||||
|
if (allowedHistoricalFixes.has(file)) {
|
||||||
|
warnings.push(`ALLOWED DELETE ${release.tagName}: ${file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
failures.push(`PUBLISHED DELETE ${release.tagName}: ${file}`)
|
failures.push(`PUBLISHED DELETE ${release.tagName}: ${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +204,11 @@ function auditPublishedReleases(currentRef) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allowedHistoricalFixes.has(file)) {
|
||||||
|
warnings.push(`ALLOWED DIVERGENCE ${release.tagName}: ${file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
failures.push(
|
failures.push(
|
||||||
`UNRECOGNIZED PUBLISHED DIVERGENCE ${release.tagName}: ${file}\n` +
|
`UNRECOGNIZED PUBLISHED DIVERGENCE ${release.tagName}: ${file}\n` +
|
||||||
` Canonical SHA-384: ${migrationChecksum(expected)}\n` +
|
` Canonical SHA-384: ${migrationChecksum(expected)}\n` +
|
||||||
@@ -239,10 +264,11 @@ function resolveBaseRef(baseRef) {
|
|||||||
|
|
||||||
function compareWithBase(baseRef, currentRef) {
|
function compareWithBase(baseRef, currentRef) {
|
||||||
const failures = []
|
const failures = []
|
||||||
|
const warnings = []
|
||||||
const canonical = migrationMapAt(baseRef)
|
const canonical = migrationMapAt(baseRef)
|
||||||
validateMigrationSet(canonical, failures)
|
validateMigrationSet(canonical, failures)
|
||||||
compareCurrentWithCanonical(canonical, currentRef, failures)
|
compareCurrentWithCanonical(canonical, currentRef, failures, warnings)
|
||||||
finish(failures, [], baseRef)
|
finish(failures, warnings, baseRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish(failures, warnings, baseline) {
|
function finish(failures, warnings, baseline) {
|
||||||
|
|||||||
Reference in new issue
Block a user