Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion badges/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 17 additions & 17 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

30 changes: 20 additions & 10 deletions scripts/update-links/archive.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type { ArchiveEntry } from './types.js'
import { CUDA_ARCHIVE_URL } from './constants.js'
import { ARCHIVE_LEADING_VERSION_REGEX, ARCHIVE_LINK_REGEX } from './regex.js'
import { fetchText } from './utils/http.js'

const CUDA_ARCHIVE_URL = 'https://developer.nvidia.com/cuda-toolkit-archive'
const ARCHIVE_LINK_REGEX = /<a[^>]+href="([^"]+)"[^>]*>(CUDA Toolkit[^<]*)<\/a>/gi
const ARCHIVE_LEADING_VERSION_REGEX = /^CUDA Toolkit\s+(\d+\.\d+(?:\.\d+)?)/i
const CUDA_TOOLKIT_PREFIX_REGEX = /^CUDA Toolkit\s*/i

export interface ArchiveEntry {
url: string
label: string
baseVersion: string | null
version: string
}

export async function fetchArchiveVersions(): Promise<ArchiveEntry[]> {
const response = await fetch(CUDA_ARCHIVE_URL)
if (!response.ok) {
throw new Error(`Failed to fetch CUDA toolkit archive: ${response.status} ${response.statusText}`)
}

const html = await response.text()
const html = await fetchText(CUDA_ARCHIVE_URL, 'Failed to fetch CUDA toolkit archive')
const entries: ArchiveEntry[] = []
const seen = new Set<string>()
for (const match of html.matchAll(ARCHIVE_LINK_REGEX)) {
Expand All @@ -31,7 +36,12 @@ export async function fetchArchiveVersions(): Promise<ArchiveEntry[]> {
if (seen.has(dedupeKey))
continue

entries.push({ url: new URL(href, CUDA_ARCHIVE_URL).toString(), label, baseVersion })
entries.push({
url: new URL(href, CUDA_ARCHIVE_URL).toString(),
label,
baseVersion,
version: label.replace(CUDA_TOOLKIT_PREFIX_REGEX, ''),
})
seen.add(dedupeKey)

if (baseVersion === '7.5')
Expand Down
1 change: 0 additions & 1 deletion scripts/update-links/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ import { fileURLToPath } from 'node:url'

const __dirname = fileURLToPath(new URL('.', import.meta.url))

export const CUDA_ARCHIVE_URL = 'https://developer.nvidia.com/cuda-toolkit-archive'
export const LINUX_LINKS_PATH = resolve(__dirname, 'linux-links.json')
export const WINDOWS_LINKS_PATH = resolve(__dirname, 'windows-links.json')
31 changes: 3 additions & 28 deletions scripts/update-links/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,9 @@
import type { ArchiveEntry } from './types.js'
import { writeFile } from 'node:fs/promises'
import process from 'node:process'
import { fetchArchiveVersions } from './archive.js'
import { fetchArchiveVersions, type ArchiveEntry } from './archive.js'
import { LINUX_LINKS_PATH, WINDOWS_LINKS_PATH } from './constants.js'
import { fetchDownloadLinks } from './releases.js'

const CUDA_TOOLKIT_PREFIX_REGEX = /^CUDA Toolkit\s*/i

async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = Array.from({ length: items.length })
let nextIndex = 0

const runWorker = async () => {
while (true) {
const current = nextIndex
nextIndex += 1
if (current >= items.length)
break
results[current] = await worker(items[current], current)
}
}

const workers = Array.from({ length: Math.min(limit, items.length) }).fill(runWorker())
await Promise.all(workers)
return results
}
import { mapWithConcurrency } from './utils/concurrency.js'

function parseConcurrencyArg(argv: string[]): number | null {
for (let i = 0; i < argv.length; i += 1) {
Expand All @@ -51,7 +26,7 @@ function parseConcurrencyArg(argv: string[]): number | null {

async function main() {
const archiveEntries = await fetchArchiveVersions()
const versions = archiveEntries.map((entry: ArchiveEntry) => entry.label.replace(CUDA_TOOLKIT_PREFIX_REGEX, ''))
const versions = archiveEntries.map((entry: ArchiveEntry) => entry.version)
console.log(`Resolved versions: ${versions.join(', ')}`)

const linuxLinks: { local: { x86_64: Record<string, string>, arm64: Record<string, string> } } = {
Expand Down
52 changes: 42 additions & 10 deletions scripts/update-links/legacy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
import type { LegacyDownloadLinks } from './types.js'
import { normalizeLegacyUrl, pickAllMatches, pickFirstMatch, pickWin10Preferred } from './matching.js'
import {
LEGACY_LINUX_ARM64_RUNFILE_REGEX,
LEGACY_LINUX_RUNFILE_FALLBACK_REGEX,
LEGACY_LINUX_RUNFILE_REGEX,
LEGACY_WINDOWS_LOCAL_REGEX,
LEGACY_WINDOWS_NETWORK_REGEX,
} from './regex.js'
import { pickAllMatches, pickFirstMatch, PATCHES_REGEX } from './utils/regex-match.js'

const LEGACY_LINUX_RUNFILE_REGEX = /(?:https?:\/\/developer\.download\.nvidia\.com)?\/compute\/cuda\/[0-9.]+(?:\/Prod[^/]*)?\/local_installers2?\/[^"'\s><]*_linux[^"'\s><]*/gi
const LEGACY_LINUX_ARM64_RUNFILE_REGEX = /(?:https?:\/\/developer\.download\.nvidia\.com)?\/compute\/cuda\/[0-9.]+(?:\/Prod[^/]*)?\/local_installers2?\/[^"'\s><]*_(?:linux_sbsa|linux_aarch64|linux_arm64)[^"'\s><]*/gi
const LEGACY_WINDOWS_LOCAL_REGEX = /(?:https?:\/\/developer\.download\.nvidia\.com)?\/compute\/cuda\/[0-9.]+(?:\/Prod[^/]*)?\/local_installers2?\/[^"'\s><]*(?:win|windows)[^"'\s><]*/gi
const LEGACY_WINDOWS_NETWORK_REGEX = /(?:https?:\/\/developer\.download\.nvidia\.com)?\/compute\/cuda\/[0-9.]+(?:\/Prod[^/]*)?\/network_installers2?\/[^"'\s><]*(?:win|windows)[^"'\s><]*_network(?:\.exe)?/gi
const WIN10_REGEX = /win10/i

export interface LegacyDownloadLinks {
linuxUrl: string
linuxArm64Url: string | null
windowsLocalUrl: string
windowsNetworkUrl: string
}

export function normalizeLegacyUrl(url: string): string {
const trimmed = url.split('<')[0]?.trim() ?? url.trim()
const safe = trimmed.split('"')[0]?.split('\'')[0]?.trim() ?? trimmed

if (safe.startsWith('/compute/')) {
return `https://developer.download.nvidia.com${safe}`
}
if (safe.startsWith('http://')) {
return safe.replace('http://', 'https://')
}
return safe
}

export function pickWin10Preferred(urls: string[]): string | null {
if (urls.length === 0)
return null
return urls.find(url => WIN10_REGEX.test(url)) ?? urls[0]
}

export function extractLegacyDownloadLinks(html: string): LegacyDownloadLinks | null {
const linuxMatch = pickFirstMatch(LEGACY_LINUX_RUNFILE_REGEX, html)
?? pickFirstMatch(LEGACY_LINUX_RUNFILE_FALLBACK_REGEX, html)
const linuxArm64Match = pickFirstMatch(LEGACY_LINUX_ARM64_RUNFILE_REGEX, html)
const windowsLocalCandidates = pickAllMatches(LEGACY_WINDOWS_LOCAL_REGEX, html).map(normalizeLegacyUrl)
const windowsNetworkCandidates = pickAllMatches(LEGACY_WINDOWS_NETWORK_REGEX, html).map(normalizeLegacyUrl)
Expand All @@ -25,3 +48,12 @@ export function extractLegacyDownloadLinks(html: string): LegacyDownloadLinks |

return { linuxUrl, linuxArm64Url, windowsLocalUrl, windowsNetworkUrl }
}

export function describeLegacyFailure(html: string): string {
return [
`hasLegacyLinux=${pickFirstMatch(LEGACY_LINUX_RUNFILE_REGEX, html) !== null}`,
`hasLegacyWindowsLocal=${pickFirstMatch(LEGACY_WINDOWS_LOCAL_REGEX, html) !== null}`,
`hasLegacyWindowsNetwork=${pickFirstMatch(LEGACY_WINDOWS_NETWORK_REGEX, html) !== null}`,
`hasPatchesLinks=${PATCHES_REGEX.test(html)}`,
].join(' | ')
}
35 changes: 0 additions & 35 deletions scripts/update-links/matching.ts

This file was deleted.

15 changes: 0 additions & 15 deletions scripts/update-links/regex.ts

This file was deleted.

62 changes: 47 additions & 15 deletions scripts/update-links/releases.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import type { DownloadLinks, ReleaseEntry } from './types.js'
import { extractReactProps, extractVersion, extractVersionFromUrl } from './html.js'
import { extractLegacyDownloadLinks } from './legacy.js'
import { pickFirstMatch } from './matching.js'
import { FALLBACK_DOWNLOAD_REGEX, LEGACY_LINUX_RUNFILE_REGEX, LEGACY_WINDOWS_LOCAL_REGEX, LEGACY_WINDOWS_NETWORK_REGEX, PATCHES_REGEX, PRIMARY_DOWNLOAD_REGEX } from './regex.js'
import { extractVersion, extractVersionFromUrl } from './utils/version.js'
import { describeLegacyFailure, extractLegacyDownloadLinks } from './legacy.js'
import { PATCHES_REGEX } from './utils/regex-match.js'
import { fetchText } from './utils/http.js'

const PRIMARY_DOWNLOAD_REGEX = /targetDownloadButtonHref[^>]+href="([^"]+)"/
const FALLBACK_DOWNLOAD_REGEX = /href="(https:\/\/developer\.download\.nvidia\.com\/compute\/cuda\/[^"]+)"/
const REACT_PROPS_REGEX = /data-react-props="([^"]+)"/

interface ReleaseEntry {
details?: string
}

interface PageData {
pageData?: {
header?: { title?: string }
releases?: Record<string, ReleaseEntry>
}
}

export interface DownloadLinks {
version: string
linuxUrl: string
linuxArm64Url: string | null
windowsLocalUrl: string
windowsNetworkUrl: string
}

function decodeHtmlEntities(input: string): string {
return input
.replaceAll('&quot;', '"')
.replaceAll('&#39;', '\'')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
}

function extractReactProps(html: string): PageData | null {
const match = REACT_PROPS_REGEX.exec(html)
if (match === null || match[1] === undefined || match[1] === '') {
return null
}
const decoded = decodeHtmlEntities(match[1])
return JSON.parse(decoded) as PageData
}

function extractDownloadUrl(details?: string): string {
if (details === undefined || details === '') {
Expand Down Expand Up @@ -57,12 +97,7 @@ function pickReleaseOptional(
}

export async function fetchDownloadLinks(pageUrl: string): Promise<DownloadLinks> {
const response = await fetch(pageUrl)
if (!response.ok) {
throw new Error(`Failed to fetch CUDA downloads page: ${response.status} ${response.statusText} (${pageUrl})`)
}

const html = await response.text()
const html = await fetchText(pageUrl, `Failed to fetch CUDA downloads page (${pageUrl})`)
const props = extractReactProps(html)
if (props !== null) {
const pageData = props.pageData
Expand Down Expand Up @@ -159,10 +194,7 @@ export async function fetchDownloadLinks(pageUrl: string): Promise<DownloadLinks
[
`Failed to locate download data on page: ${pageUrl}`,
`Debug flags: hasReactProps=${props !== null}`,
`hasLegacyLinux=${pickFirstMatch(LEGACY_LINUX_RUNFILE_REGEX, html) !== null}`,
`hasLegacyWindowsLocal=${pickFirstMatch(LEGACY_WINDOWS_LOCAL_REGEX, html) !== null}`,
`hasLegacyWindowsNetwork=${pickFirstMatch(LEGACY_WINDOWS_NETWORK_REGEX, html) !== null}`,
`hasPatchesLinks=${PATCHES_REGEX.test(html)}`,
describeLegacyFailure(html),
`htmlLength=${html.length}`,
].join(' | '),
)
Expand Down
31 changes: 0 additions & 31 deletions scripts/update-links/types.ts

This file was deleted.

27 changes: 27 additions & 0 deletions scripts/update-links/utils/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1)
throw new RangeError('mapWithConcurrency limit must be a positive integer')

const results: R[] = Array.from({ length: items.length })
let nextIndex = 0

const runWorker = async () => {
while (true) {
const current = nextIndex
nextIndex += 1
if (current >= items.length)
break
results[current] = await worker(items[current], current)
}
}

// 用 Array.from 的工厂回调(不要 .fill(runWorker()))让每个槽各自启动一个 worker 循环。
// .fill 会复用同一个 promise,导致退化成串行——这正是本次重构要修的并发 bug。
const workers = Array.from({ length: Math.min(limit, items.length) }, () => runWorker())
await Promise.all(workers)
return results
}
7 changes: 7 additions & 0 deletions scripts/update-links/utils/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function fetchText(url: string, errorLabel: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`)
}
return response.text()
}
16 changes: 16 additions & 0 deletions scripts/update-links/utils/regex-match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const PATCHES_REGEX = /patches/i

export function pickFirstMatch(regex: RegExp, input: string): string | null {
regex.lastIndex = 0
const match = regex.exec(input)
if (match === null)
return null
return match[0] ?? null
}

export function pickAllMatches(regex: RegExp, input: string): string[] {
regex.lastIndex = 0
return Array.from(input.matchAll(regex), match => match[0])
.filter((value): value is string => value !== undefined)
.filter(value => !PATCHES_REGEX.test(value))
}
Loading
Loading