diff --git a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml index a7cc0ab6f3d20b..a355f1a026ebd8 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -61,10 +61,9 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare - onlyOnNodeModulesCacheMiss: true + - task: NuGetAuthenticate@1 + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Setup NuGet Authentication - task: Docker@1 inputs: @@ -127,17 +126,14 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - onlyOnNodeModulesCacheMiss: true - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 6c72b85a219947..a4a21ef8852022 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -106,9 +106,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - task: Docker@1 inputs: @@ -171,16 +170,14 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/common/dictation-runtime-produce.yml b/build/azure-pipelines/common/dictation-runtime-produce.yml index ba30ab03719f46..acd3d1d4d45b73 100644 --- a/build/azure-pipelines/common/dictation-runtime-produce.yml +++ b/build/azure-pipelines/common/dictation-runtime-produce.yml @@ -16,10 +16,10 @@ parameters: # real publish runs (VSCODE_PUBLISH=true). darwin-x64 stamps but uploads # nothing (the darwin-arm64 job publishes the shared Apple Silicon payload). # -# The core libraries are fetched from NuGet for the target's explicit RID (see +# The ONNX libraries are fetched from NuGet for the target's explicit RID (see # build/dictation-runtime/nuget.ts), so ARM64 targets build fine on x64 pools; -# the prebuilt addon is copied from the already-restored node_modules. No npm -# registry auth is needed. +# both addons and the Foundry Local library are copied from the already-restored +# node_modules. No npm registry auth is needed. # # Steps: # 1. AzureCLI@2 to fetch the SPN credentials for the CDN storage account — diff --git a/build/azure-pipelines/common/disableFoundryLocalInstall.ts b/build/azure-pipelines/common/disableFoundryLocalInstall.ts deleted file mode 100644 index f63cdaeea0aaad..00000000000000 --- a/build/azure-pipelines/common/disableFoundryLocalInstall.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { disableFoundryLocalInstall } from './foundryLocalInstall.ts'; - -disableFoundryLocalInstall(); diff --git a/build/azure-pipelines/common/foundry-local.yml b/build/azure-pipelines/common/foundry-local.yml deleted file mode 100644 index fd9d88e8907546..00000000000000 --- a/build/azure-pipelines/common/foundry-local.yml +++ /dev/null @@ -1,27 +0,0 @@ -parameters: - - name: phase - type: string - values: - - prepare - - install - - name: onlyOnNodeModulesCacheMiss - type: boolean - default: false - -steps: - - ${{ if eq(parameters.phase, 'prepare') }}: - - task: NuGetAuthenticate@1 - ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: - condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NuGet Authentication - - - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts - ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: - condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Disable Foundry Local Native Install - - - ${{ if eq(parameters.phase, 'install') }}: - - script: node build/azure-pipelines/common/foundryLocalInstall.ts - ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: - condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Install Foundry Local Native Dependencies diff --git a/build/azure-pipelines/common/foundryLocalInstall.ts b/build/azure-pipelines/common/foundryLocalInstall.ts deleted file mode 100644 index ff80bc58bf5020..00000000000000 --- a/build/azure-pipelines/common/foundryLocalInstall.ts +++ /dev/null @@ -1,121 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { execFileSync } from 'child_process'; -import { createHash } from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; -import { fetchCoreLibraries, getStandardArtifacts, type IFoundryDependencyVersions, requiredCoreLibraryNames, supportsCoreLibraryTarget, VSCODE_NUGET_FEED } from '../../dictation-runtime/nuget.ts'; - -const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); -const packageName = 'foundry-local-sdk'; -const credentialTokenEnvironmentVariable = 'VSS_NUGET_ACCESSTOKEN'; -const expectedInstallerUtilsHash = '0831c932b10389283e805f88a204b0f6a5a8053f2ee520e56a0f0adf1352aa8b'; - -type RootPackageJson = { - dependencies?: Record; - allowScripts?: Record; -}; - -type FoundryPackageJson = { - version?: string; - scripts?: Record; -}; - -function readJson(filePath: string): T { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; -} - -function getPinnedPackage(root: string): { packageJsonPath: string; packageJson: RootPackageJson; allowScripts: Record; allowScriptsKey: string } { - const packageJsonPath = path.join(root, 'package.json'); - const packageJson = readJson(packageJsonPath); - const allowScripts = packageJson.allowScripts; - const version = packageJson.dependencies?.[packageName]; - const allowScriptsKey = version ? `${packageName}@${version}` : undefined; - - if (!allowScripts || !version || !allowScriptsKey || allowScripts[allowScriptsKey] !== true) { - throw new Error(`Expected an approved, pinned ${packageName} install script in package.json`); - } - - return { packageJsonPath, packageJson, allowScripts, allowScriptsKey }; -} - -export function disableFoundryLocalInstall(root = repositoryRoot): void { - const { packageJsonPath, packageJson, allowScripts, allowScriptsKey } = getPinnedPackage(root); - allowScripts[allowScriptsKey] = false; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, undefined, 2)}\n`); - console.log(`Disabled ${allowScriptsKey} install script for this CI job`); -} - -function validateInstallerUtils(installerUtilsPath: string): void { - const contents = fs.readFileSync(installerUtilsPath); - const actualHash = createHash('sha256').update(contents).digest('hex'); - - if (actualHash !== expectedInstallerUtilsHash) { - throw new Error(`Unexpected ${packageName} installer utility hash ${actualHash}`); - } -} - -function runLifecycleScript(packageRoot: string, relativeScriptPath: string): void { - execFileSync(process.execPath, [path.join(packageRoot, relativeScriptPath)], { - cwd: packageRoot, - stdio: 'inherit' - }); -} - -export async function installFoundryLocal(root = repositoryRoot): Promise { - if (!process.env[credentialTokenEnvironmentVariable]) { - throw new Error(`${credentialTokenEnvironmentVariable} was not set by NuGetAuthenticate`); - } - - const rootPackageJson = readJson(path.join(root, 'package.json')); - const version = rootPackageJson.dependencies?.[packageName]; - const allowScriptsKey = version ? `${packageName}@${version}` : undefined; - if (!version || !allowScriptsKey || rootPackageJson.allowScripts?.[allowScriptsKey] !== false) { - throw new Error(`Expected the pinned ${packageName} install script to be disabled before installation`); - } - - const packageRoot = path.join(root, 'node_modules', packageName); - const packageJson = readJson(path.join(packageRoot, 'package.json')); - if (packageJson.version !== version) { - throw new Error(`Expected ${packageName}@${version}, found ${packageJson.version ?? 'an unknown version'}`); - } - if (packageJson.scripts?.preinstall !== 'node script/preinstall.cjs' || packageJson.scripts.install !== 'node script/install-standard.cjs') { - throw new Error(`Unexpected ${packageName}@${version} lifecycle scripts`); - } - - validateInstallerUtils(path.join(packageRoot, 'script', 'install-utils.cjs')); - runLifecycleScript(packageRoot, 'script/preinstall.cjs'); - - const target = `${process.platform}-${process.arch}`; - if (!supportsCoreLibraryTarget(target)) { - console.warn(`[foundry-local] Unsupported platform: ${target}. Skipping.`); - return; - } - - const dependencies = readJson(path.join(packageRoot, 'deps_versions.json')); - const artifacts = getStandardArtifacts(target, dependencies); - const binDir = path.join(packageRoot, 'foundry-local-core', target); - await fetchCoreLibraries(target, artifacts, binDir, { feeds: [VSCODE_NUGET_FEED], skipIfPresent: true }); - - const missingFiles = requiredCoreLibraryNames(target).filter(file => !fs.existsSync(path.join(binDir, file))); - if (missingFiles.length > 0) { - throw new Error(`[foundry-local] Missing required native libraries for ${target}: ${missingFiles.join(', ')}`); - } - - const coreVersion = dependencies['foundry-local-core'].nuget; - const platformPackageJson = { - name: `@foundry-local-core/${target}`, - version: coreVersion, - description: `Native binaries for Foundry Local SDK (${target})`, - private: true, - }; - fs.writeFileSync(path.join(binDir, 'package.json'), JSON.stringify(platformPackageJson, undefined, 2)); - console.log('[foundry-local] Installation complete.'); -} - -if (import.meta.filename === process.argv[1]) { - await installFoundryLocal(); -} diff --git a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml index 3317e43816e990..eaeb4a83fae3c6 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml @@ -75,10 +75,9 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Setup PyPI Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare - onlyOnNodeModulesCacheMiss: true + - task: NuGetAuthenticate@1 + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Setup NuGet Authentication - script: | set -e @@ -99,6 +98,8 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen @@ -107,11 +108,6 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - onlyOnNodeModulesCacheMiss: true - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 4a9d596103944c..92debf2c28431b 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -27,10 +27,6 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts - displayName: Disable Foundry Local Native Install - - template: ../../distro/download-distro.yml@self - task: AzureKeyVault@2 @@ -84,9 +80,8 @@ steps: displayName: Setup NPM Authentication - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - task: PipAuthenticate@1 inputs: @@ -112,6 +107,11 @@ steps: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: + FOUNDRY_LOCAL_SKIP_INSTALL: "1" + ${{ else }}: + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen @@ -120,11 +120,6 @@ steps: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index ea09134e1a863d..176562a639441c 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -82,10 +82,9 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare - onlyOnNodeModulesCacheMiss: true + - task: NuGetAuthenticate@1 + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Setup NuGet Authentication - script: | set -e @@ -144,14 +143,11 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 ONNXRUNTIME_NODE_INSTALL: skip GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - onlyOnNodeModulesCacheMiss: true - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index fcc5663ad7c71d..2880e77597c9b9 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -35,10 +35,6 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts - displayName: Disable Foundry Local Native Install - - template: ../../distro/download-distro.yml@self - task: AzureKeyVault@2 @@ -104,9 +100,8 @@ steps: displayName: Setup NPM Authentication - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - script: | set -e @@ -164,14 +159,14 @@ steps: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 ONNXRUNTIME_NODE_INSTALL: skip GITHUB_TOKEN: "$(github-token-code-oss)" + ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: + FOUNDRY_LOCAL_SKIP_INSTALL: "1" + ${{ else }}: + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 59df292c8fb031..ec11d009794db4 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -56,9 +56,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ./common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - script: | set -e @@ -106,13 +105,11 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ./common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/web/product-build-web-node-modules.yml b/build/azure-pipelines/web/product-build-web-node-modules.yml index 4f935de7336480..1aef6b5f8ac2bb 100644 --- a/build/azure-pipelines/web/product-build-web-node-modules.yml +++ b/build/azure-pipelines/web/product-build-web-node-modules.yml @@ -54,10 +54,9 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare - onlyOnNodeModulesCacheMiss: true + - task: NuGetAuthenticate@1 + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Setup NuGet Authentication - script: | set -e @@ -81,14 +80,11 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - onlyOnNodeModulesCacheMiss: true - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 6a6c132da177f0..ddb61356e86144 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -68,9 +68,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - script: | set -e @@ -94,13 +93,11 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml index 528a580afe3e4b..2cc86618cee49b 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -71,10 +71,9 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare - onlyOnNodeModulesCacheMiss: true + - task: NuGetAuthenticate@1 + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Setup NuGet Authentication - powershell: | . build/azure-pipelines/win32/exec.ps1 @@ -86,15 +85,12 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - template: ../common/foundry-local.yml@self - parameters: - phase: install - onlyOnNodeModulesCacheMiss: true - - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 01d29af19c0399..1b83b7cf1f2974 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -48,9 +48,8 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication - - template: ../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - pwsh: | $includes = @' @@ -93,13 +92,11 @@ steps: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" retryCountOnTaskFailure: 5 displayName: Install dependencies - - template: ../common/foundry-local.yml@self - parameters: - phase: install - - script: node build/azure-pipelines/distro/mixin-npm.ts displayName: Mixin distro node modules diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 4f0ee18846f5d6..089b150311a0d4 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -29,10 +29,6 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts - displayName: Disable Foundry Local Native Install - - task: UsePythonVersion@0 inputs: versionSpec: "3.x" @@ -90,9 +86,8 @@ steps: displayName: Setup NPM Authentication - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: prepare + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication - powershell: | . build/azure-pipelines/win32/exec.ps1 @@ -104,15 +99,15 @@ steps: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-token-code-oss)" + ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: + FOUNDRY_LOCAL_SKIP_INSTALL: "1" + ${{ else }}: + FOUNDRY_LOCAL_NUGET_MODE: dotnet + FOUNDRY_LOCAL_NUGET_FEEDS: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: - - template: ../../common/foundry-local.yml@self - parameters: - phase: install - - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/dictation-runtime/common.ts b/build/dictation-runtime/common.ts index 857618f3cf7801..36ed2955064e4e 100644 --- a/build/dictation-runtime/common.ts +++ b/build/dictation-runtime/common.ts @@ -11,9 +11,9 @@ * at packaging time. * * This mirrors `build/agent-sdk/` but for the Foundry Local native runtime used - * by on-device dictation: a prebuilt N-API addon (`foundry_local_napi.node`) - * plus the Foundry Local Core / onnxruntime / onnxruntime-genai shared - * libraries. Rather than downloading those from npm + NuGet at runtime, each + * by on-device dictation: two prebuilt N-API addons plus the Foundry Local / + * onnxruntime / onnxruntime-genai shared libraries. Rather than downloading + * those from npm + NuGet at runtime, each * platform build job produces its own tarball and uploads it to * `main.vscode-cdn.net`; the runtime downloads the single content-addressed * tarball for its target (see `foundryLocalRuntime.ts`). @@ -38,6 +38,11 @@ const ROOT_PACKAGE_JSON = path.join(THIS_DIR, '..', '..', 'package.json'); /** The npm package whose native payload we republish to the CDN. */ export const SDK_PACKAGE_NAME = 'foundry-local-sdk'; +/** Resolve the SDK root through its ESM-only exported entry point. */ +export function resolveSdkPackageRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.resolve(SDK_PACKAGE_NAME))), '..'); +} + /** * Path segment under the CDN URL and the conceptual id of the runtime. Kept as a * named constant (rather than inlined) so the blob layout, the url builders, and @@ -103,7 +108,7 @@ export const SUPPORTED_TARGETS: ReadonlySet = new Set([ * reports on-device dictation unsupported there and never downloads. * * The legacy Alpine x64 encoding (`{platform: 'linux', arch: 'alpine'}`) and any - * real `alpine` platform return `undefined`: the core libraries are glibc-linked. + * real `alpine` platform return `undefined`: the native libraries are glibc-linked. */ export function getRuntimeTargetForBuild(vscodePlatform: string, arch: string): string | undefined { if (vscodePlatform === 'alpine' || arch === 'alpine' || arch === 'musl') { @@ -127,13 +132,13 @@ export function getRuntimeTargetForBuild(vscodePlatform: string, arch: string): * config so dictation works when the Universal app runs natively on Apple * Silicon — even though only the `darwin-arm64` job builds/uploads the * payload. So `darwin-x64` stamps but does not produce. - * - non-publish product builds: packaging always strips the SDK's native - * payload (`getFoundryLocalExcludeFilter` in `gulpfile.vscode.ts`), so a - * packaged build with no stamp would have NEITHER a CDN location NOR a - * `node_modules` fallback. Stamping regardless of `VSCODE_PUBLISH` gives the + * - non-publish product builds: packaging keeps the SDK's addons but strips + * its shared libraries (`getFoundryLocalExcludeFilter` in + * `gulpfile.vscode.ts`), so a packaged build with no stamp would have no + * loadable runtime. Stamping regardless of `VSCODE_PUBLISH` gives the * packaged app a usable CDN source; the payload for that version is uploaded - * (idempotently) by publish runs. Only local dev-from-source (which never - * runs `produce.ts`) keeps the `node_modules` payload. + * (idempotently) by publish runs. Local dev-from-source (which never runs + * `produce.ts`) uses the complete `node_modules` payload. * * Returns `false` for platforms/arches that can never host dictation (armhf, * Alpine/musl, web) so their `product.json` stays clean. diff --git a/build/dictation-runtime/nuget.ts b/build/dictation-runtime/nuget.ts index 06693879567d30..01af174a434b49 100644 --- a/build/dictation-runtime/nuget.ts +++ b/build/dictation-runtime/nuget.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ /** - * Fetches the Foundry Local native core libraries (Foundry Local Core + - * onnxruntime + onnxruntime-genai) from NuGet for an EXPLICIT RID, so a single + * Fetches the Foundry Local native dependencies (onnxruntime + + * onnxruntime-genai) from NuGet for an EXPLICIT RID, so a single * build agent can assemble a tarball for any target regardless of its own * `process.platform`/`process.arch`. * @@ -17,7 +17,7 @@ * tarballs; extracting `runtimes//native/*` from the same `.nupkg` files * for an explicit RID is host-independent and fixes that. * - * Only the "standard" artifact set is supported (the three packages selected by + * Only the "standard" artifact set is supported (the two packages selected by * `package.ts`); the SDK installer's WinML override / `includeFiles` / * `removeFiles` paths are intentionally not ported. */ @@ -28,7 +28,7 @@ import * as fs from 'fs'; import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; -import { SDK_PACKAGE_NAME } from './common.ts'; +import { resolveSdkPackageRoot } from './common.ts'; const SCRIPT = 'nuget.ts'; const VSCODE_FEED_PREFIX = 'https://pkgs.dev.azure.com/monacotools/'; @@ -44,8 +44,7 @@ export const VSCODE_NUGET_FEED = 'https://pkgs.dev.azure.com/monacotools/Monaco/ * `.nupkg` archives. */ function loadAdmZip(): any { - const sdkRequire = createRequire(import.meta.url); - const fromSdk = createRequire(sdkRequire.resolve(`${SDK_PACKAGE_NAME}/package.json`)); + const fromSdk = createRequire(path.join(resolveSdkPackageRoot(), 'package.json')); return fromSdk('adm-zip'); } @@ -89,46 +88,68 @@ export interface INugetArtifact { } export interface IFoundryDependencyVersions { - readonly 'foundry-local-core': { readonly nuget: string }; readonly onnxruntime: { readonly version: string }; readonly 'onnxruntime-genai': { readonly version: string }; } -export interface IFetchCoreLibrariesOptions { +export interface IFetchDependencyLibrariesOptions { readonly feeds?: readonly string[]; readonly skipIfPresent?: boolean; } -export function supportsCoreLibraryTarget(target: string): boolean { +export function supportsDependencyLibraryTarget(target: string): boolean { return Object.hasOwn(RID_BY_TARGET, target); } -export function getStandardArtifacts(target: string, dependencies: IFoundryDependencyVersions): readonly INugetArtifact[] { - const ortPackageName = target === 'linux-x64' ? 'Microsoft.ML.OnnxRuntime.Gpu.Linux' : 'Microsoft.ML.OnnxRuntime.Foundry'; +export function getStandardArtifacts(dependencies: IFoundryDependencyVersions): readonly INugetArtifact[] { return [ - { name: 'Microsoft.AI.Foundry.Local.Core', version: dependencies['foundry-local-core'].nuget }, - { name: ortPackageName, version: dependencies.onnxruntime.version }, + { name: 'Microsoft.ML.OnnxRuntime', version: dependencies.onnxruntime.version }, { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: dependencies['onnxruntime-genai'].version }, ]; } -export function requiredCoreLibraryNames(target: string): readonly string[] { +export function requiredDependencyLibraryNames(target: string, dependencies: IFoundryDependencyVersions): readonly string[] { const isWin = target.startsWith('win32-'); - const ext = isWin ? '.dll' : target.startsWith('darwin-') ? '.dylib' : '.so'; + const isDarwin = target.startsWith('darwin-'); + const ext = isWin ? '.dll' : isDarwin ? '.dylib' : '.so'; const prefix = isWin ? '' : 'lib'; + const onnxRuntime = isWin + ? 'onnxruntime.dll' + : isDarwin + ? `libonnxruntime.${dependencies.onnxruntime.version.split('.')[0]}.dylib` + : 'libonnxruntime.so.1'; return [ - `Microsoft.AI.Foundry.Local.Core${ext}`, - `${prefix}onnxruntime${ext}`, + onnxRuntime, `${prefix}onnxruntime-genai${ext}`, ]; } +export function normalizeOrtLibraryName(binDir: string, target: string, version: string): void { + let unversioned: string; + let versioned: string; + if (target.startsWith('linux-')) { + unversioned = path.join(binDir, 'libonnxruntime.so'); + versioned = path.join(binDir, 'libonnxruntime.so.1'); + } else if (target.startsWith('darwin-')) { + unversioned = path.join(binDir, 'libonnxruntime.dylib'); + versioned = path.join(binDir, `libonnxruntime.${version.split('.')[0]}.dylib`); + } else { + return; + } + if (!fs.existsSync(versioned) && fs.existsSync(unversioned)) { + fs.renameSync(unversioned, versioned); + } + if (target.startsWith('darwin-') && fs.existsSync(versioned) && !fs.existsSync(unversioned)) { + fs.symlinkSync(path.basename(versioned), unversioned); + } +} + /** * Download each `artifact` `.nupkg` for `target`'s RID and extract its native * shared libraries into `binDir`. Throws if a package can't be fetched from any * feed; callers verify the resulting library set separately. */ -export async function fetchCoreLibraries(target: string, artifacts: readonly INugetArtifact[], binDir: string, options?: IFetchCoreLibrariesOptions): Promise { +export async function fetchDependencyLibraries(target: string, artifacts: readonly INugetArtifact[], binDir: string, options?: IFetchDependencyLibrariesOptions): Promise { const rid = RID_BY_TARGET[target]; if (!rid) { throw new Error(`[${SCRIPT}] No NuGet RID mapping for target '${target}'.`); @@ -163,7 +184,7 @@ async function installPackage( skipIfPresent: boolean, ): Promise { if (skipIfPresent) { - const expectedFile = expectedCoreLibraryName(target, artifact.name); + const expectedFile = expectedDependencyLibraryName(target, artifact.name); if (expectedFile && fs.existsSync(path.join(binDir, expectedFile))) { console.log(`[${SCRIPT}] ${artifact.name}: already present, skipping download.`); return; @@ -206,16 +227,14 @@ async function installPackage( throw new Error(`[${SCRIPT}] Failed to download ${artifact.name} ${artifact.version} from any feed (${feedHosts}): ${lastError instanceof Error ? lastError.message : lastError}`); } -function expectedCoreLibraryName(target: string, packageName: string): string | undefined { - const [foundryCore, onnxRuntime, onnxRuntimeGenAI] = requiredCoreLibraryNames(target); - if (packageName.includes('Foundry.Local.Core')) { - return foundryCore; - } +function expectedDependencyLibraryName(target: string, packageName: string): string | undefined { + const isWin = target.startsWith('win32-'); + const isDarwin = target.startsWith('darwin-'); if (packageName.includes('OnnxRuntimeGenAI')) { - return onnxRuntimeGenAI; + return `${isWin ? '' : 'lib'}onnxruntime-genai${isWin ? '.dll' : isDarwin ? '.dylib' : '.so'}`; } if (packageName.includes('OnnxRuntime')) { - return onnxRuntime; + return isWin ? 'onnxruntime.dll' : isDarwin ? 'libonnxruntime.dylib' : 'libonnxruntime.so'; } return undefined; } diff --git a/build/dictation-runtime/package.ts b/build/dictation-runtime/package.ts index 22ecfcab261608..a85112a385b947 100644 --- a/build/dictation-runtime/package.ts +++ b/build/dictation-runtime/package.ts @@ -5,40 +5,35 @@ /** * Builds one per-target tarball of the Foundry Local native runtime (the - * prebuilt N-API addon + the Foundry Local Core / onnxruntime / onnxruntime-genai - * shared libraries). Callable as both a Node library (`buildOne(...)`) and a thin + * Foundry Local / onnxruntime / onnxruntime-genai shared libraries). Callable + * as both a Node library (`buildOne(...)`) and a thin * CLI (bottom of this file). * * The library form is what `produce.ts` calls during the per-platform * "Dictation runtime: build + upload" pipeline step; the CLI form is for local * one-off builds and requires `VSS_NUGET_ACCESSTOKEN` for the VS Code NuGet feed. * - * The addon is copied from the pinned `foundry-local-sdk` package's `prebuilds/` - * (which ships every target), and the core libraries are fetched from NuGet for - * the requested target's RID via `fetchCoreLibraries` (NOT the SDK's host-locked - * installer), so ANY build host can produce ANY target's tarball. This is what - * lets VS Code's ARM64 desktop builds — which run on x64 pools — publish their - * `linux-arm64`/`win32-arm64` runtimes. + * The Foundry Local library is copied from the pinned `foundry-local-sdk` + * package's `prebuilds/` (which ships every target), and the ONNX libraries are + * fetched from NuGet for the requested target's RID via + * `fetchDependencyLibraries`, so ANY build host can produce ANY target's tarball. * * The produced tarball's internal layout mirrors the runtime cache layout so the * runtime extraction is a plain untar: * - * prebuilds//foundry_local_napi.node - * foundry-local-core// + * prebuilds// */ -import { createRequire } from 'module'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as tar from 'tar'; -import { getRuntimeVersion, parseFlags, SDK_PACKAGE_NAME, sha256OfFile, SUPPORTED_TARGETS } from './common.ts'; -import { fetchCoreLibraries, getStandardArtifacts, type IFoundryDependencyVersions, requiredCoreLibraryNames } from './nuget.ts'; +import { getRuntimeVersion, parseFlags, resolveSdkPackageRoot, SDK_PACKAGE_NAME, sha256OfFile, SUPPORTED_TARGETS } from './common.ts'; +import { fetchDependencyLibraries, getStandardArtifacts, type IFoundryDependencyVersions, normalizeOrtLibraryName, requiredDependencyLibraryNames } from './nuget.ts'; const SCRIPT = 'package.ts'; -/** Resolve `foundry-local-sdk` subpaths from the repo-root `node_modules`. */ -const sdkRequire = createRequire(import.meta.url); +const SDK_ROOT = resolveSdkPackageRoot(); export interface IBuildResult { readonly tgzPath: string; @@ -53,10 +48,9 @@ export interface IBuildArgs { } /** - * Build one runtime tarball for `args.target`. Copies the prebuilt addon from - * the installed SDK, fetches the matching core libraries via the SDK's NuGet - * installer, and tars both into a single gzipped tarball. Returns the produced - * `.tgz` path and its sha256. + * Build one runtime tarball for `args.target`. Copies the SDK's prebuilt native + * files, fetches the matching ONNX libraries from NuGet, and tars them into a + * single gzipped tarball. Returns the produced `.tgz` path and its sha256. */ export async function buildOne(args: IBuildArgs): Promise { if (!SUPPORTED_TARGETS.has(args.target)) { @@ -68,8 +62,8 @@ export async function buildOne(args: IBuildArgs): Promise { try { console.log(`[${SCRIPT}] Building ${SDK_PACKAGE_NAME}@${version} native runtime for ${args.target} in ${stagingDir}`); - await stageAddon(stagingDir, args.target); - await stageCoreLibraries(stagingDir, args.target); + await stageSdkSharedLibraries(stagingDir, args.target); + await stageDependencyLibraries(stagingDir, args.target); fs.mkdirSync(args.outDir, { recursive: true }); const tgzPath = path.join(args.outDir, `${args.target}.tgz`); @@ -86,41 +80,61 @@ export async function buildOne(args: IBuildArgs): Promise { } /** - * Copy the prebuilt N-API addon for `target` out of the installed - * `foundry-local-sdk` package (`prebuilds//foundry_local_napi.node`) - * into the staging tree. + * Copy the Foundry Local shared library shipped in the SDK's prebuild directory + * for `target`. The Node-API addons are bundled with the product. */ -async function stageAddon(stagingDir: string, target: string): Promise { - const sdkRoot = path.dirname(sdkRequire.resolve(`${SDK_PACKAGE_NAME}/package.json`)); - const addonSrc = path.join(sdkRoot, 'prebuilds', target, 'foundry_local_napi.node'); - if (!fs.existsSync(addonSrc)) { - throw new Error(`[${SCRIPT}] Prebuilt addon not found for ${target} at ${addonSrc}. Is ${SDK_PACKAGE_NAME} installed?`); +async function stageSdkSharedLibraries(stagingDir: string, target: string): Promise { + const sourceDir = path.join(SDK_ROOT, 'prebuilds', target); + if (!fs.existsSync(sourceDir)) { + throw new Error(`[${SCRIPT}] Prebuild directory not found for ${target} at ${sourceDir}. Is ${SDK_PACKAGE_NAME} installed?`); + } + const targetDir = path.join(stagingDir, 'prebuilds', target); + fs.mkdirSync(targetDir, { recursive: true }); + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (entry.isFile() && isSharedLibrary(entry.name)) { + fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name)); + } + } + for (const name of requiredSdkSharedLibraryNames(target)) { + if (!fs.existsSync(path.join(targetDir, name))) { + throw new Error(`[${SCRIPT}] SDK shared library '${name}' not found for ${target} in ${sourceDir}.`); + } } - const addonDest = path.join(stagingDir, 'prebuilds', target, 'foundry_local_napi.node'); - fs.mkdirSync(path.dirname(addonDest), { recursive: true }); - fs.copyFileSync(addonSrc, addonDest); } /** - * Fetch the Foundry Local core libraries for `target` into the staging tree from - * NuGet, for that target's explicit RID (see `fetchCoreLibraries`). Replicates - * the standard variant's artifact selection, including the linux-x64 GPU ONNX - * Runtime package. Host-independent — `target` need not match the build host. + * Fetch the ONNX libraries for `target` into the same flat prebuild directory, + * using that target's explicit RID. Host-independent — `target` need not match + * the build host. */ -async function stageCoreLibraries(stagingDir: string, target: string): Promise { - const dependencies = sdkRequire(`${SDK_PACKAGE_NAME}/deps_versions.json`) as IFoundryDependencyVersions; - const artifacts = getStandardArtifacts(target, dependencies); +async function stageDependencyLibraries(stagingDir: string, target: string): Promise { + const dependencies = JSON.parse(fs.readFileSync(path.join(SDK_ROOT, 'deps_versions.json'), 'utf8')) as IFoundryDependencyVersions; + const artifacts = getStandardArtifacts(dependencies); - const coreDir = path.join(stagingDir, 'foundry-local-core', target); - await fetchCoreLibraries(target, artifacts, coreDir); + const targetDir = path.join(stagingDir, 'prebuilds', target); + await fetchDependencyLibraries(target, artifacts, targetDir, { skipIfPresent: true }); + normalizeOrtLibraryName(targetDir, target, dependencies.onnxruntime.version); - for (const name of requiredCoreLibraryNames(target)) { - if (!fs.existsSync(path.join(coreDir, name))) { - throw new Error(`[${SCRIPT}] Core library '${name}' missing after install for ${target} — refusing to build an incomplete tarball.`); + for (const name of requiredDependencyLibraryNames(target, dependencies)) { + if (!fs.existsSync(path.join(targetDir, name))) { + throw new Error(`[${SCRIPT}] Dependency library '${name}' missing after install for ${target} — refusing to build an incomplete tarball.`); } } } +function requiredSdkSharedLibraryNames(target: string): readonly string[] { + const foundryLocalLibrary = target.startsWith('win32-') + ? 'foundry_local.dll' + : target.startsWith('darwin-') + ? 'libfoundry_local.dylib' + : 'libfoundry_local.so'; + return [foundryLocalLibrary]; +} + +function isSharedLibrary(name: string): boolean { + return name.endsWith('.dll') || name.includes('.dylib') || name.includes('.so'); +} + /** * Build the gzipped tar via node-tar so the output is consistent regardless of * which host's system tar would otherwise be used. `portable`/`mtime` strip @@ -135,7 +149,7 @@ async function buildTarball(stagingDir: string, outTgz: string): Promise { portable: true, mtime: new Date(0), }, - ['prebuilds', 'foundry-local-core'], + ['prebuilds'], ); } diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index ac122c3794028a..10c1684e936120 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,7 +31,7 @@ import { copyCodiconsTask } from './lib/compilation.ts'; import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { ensureOSProxyResolverPlatformPackage, getOSProxyResolverExcludeFilter, getOSProxyResolverPlatformFiles } from './lib/osProxyResolver.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; -import { readDictationRuntimeResults } from './dictation-runtime/common.ts'; +import { getRuntimeTargetForBuild, readDictationRuntimeResults } from './dictation-runtime/common.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; @@ -252,19 +252,20 @@ function computeChecksum(filename: string): string { return hash; } -// foundry-local-sdk (on-device chat dictation) ships a prebuilt N-API addon -// (`foundry_local_napi.node`) inside its tarball, and its native core libraries -// are fetched per-RID into `foundry-local-core/-/` at install -// time. The addon requires a newer glibc than VS Code's minimum supported Linux -// distros, so we deliberately do NOT ship any of this native payload: it is -// downloaded on demand at runtime, only on supported platforms, into a per-user -// cache (see `src/vs/platform/localTranscription/node/foundryLocalRuntime.ts`). -// Exclude every prebuilt addon and core library from the package here. -function getFoundryLocalExcludeFilter(): string[] { +// foundry-local-sdk (on-device chat dictation) loads two N-API addons from its +// package, while configureNativeLoader redirects its shared libraries to the +// per-user runtime cache. Keep the target platform's addons but exclude the +// shared libraries, which require a newer glibc than VS Code's minimum supported +// Linux distros. +function getFoundryLocalExcludeFilter(platform: string, arch: string): string[] { + const target = getRuntimeTargetForBuild(platform, arch); return [ '**', '!**/foundry-local-sdk/prebuilds/**', - '!**/foundry-local-sdk/foundry-local-core/**', + ...(target ? [ + `**/foundry-local-sdk/prebuilds/${target}/foundry_local_node.node`, + `**/foundry-local-sdk/prebuilds/${target}/foundry_local_preload.node`, + ] : []), ]; } @@ -394,7 +395,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) .pipe(filter(getRipgrepExcludeFilter(platform, arch))) .pipe(filter(getMxcExcludeFilter(arch))) - .pipe(filter(getFoundryLocalExcludeFilter())) + .pipe(filter(getFoundryLocalExcludeFilter(platform, arch))) .pipe(filter(getOSProxyResolverExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) diff --git a/build/lib/test/dictationRuntime.test.ts b/build/lib/test/dictationRuntime.test.ts new file mode 100644 index 00000000000000..65e168c1386930 --- /dev/null +++ b/build/lib/test/dictationRuntime.test.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { suite, test } from 'node:test'; +import { + getStandardArtifacts, + type IFoundryDependencyVersions, + normalizeOrtLibraryName, + requiredDependencyLibraryNames, +} from '../../dictation-runtime/nuget.ts'; + +const dependencies: IFoundryDependencyVersions = { + onnxruntime: { version: '1.28.0' }, + 'onnxruntime-genai': { version: '0.15.2' }, +}; + +suite('dictation runtime', () => { + test('uses the Foundry Local 2.x dependency artifact set', () => { + assert.deepStrictEqual(getStandardArtifacts(dependencies), [ + { name: 'Microsoft.ML.OnnxRuntime', version: '1.28.0' }, + { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.15.2' }, + ]); + }); + + test('requires the platform-specific 2.x dependency names', () => { + assert.deepStrictEqual({ + linux: requiredDependencyLibraryNames('linux-x64', dependencies), + darwin: requiredDependencyLibraryNames('darwin-arm64', dependencies), + win32: requiredDependencyLibraryNames('win32-arm64', dependencies), + }, { + linux: ['libonnxruntime.so.1', 'libonnxruntime-genai.so'], + darwin: ['libonnxruntime.1.dylib', 'libonnxruntime-genai.dylib'], + win32: ['onnxruntime.dll', 'onnxruntime-genai.dll'], + }); + }); + + test('normalizes the Linux ONNX Runtime soname', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'dictation-runtime-test-')); + try { + fs.writeFileSync(path.join(directory, 'libonnxruntime.so'), 'runtime'); + normalizeOrtLibraryName(directory, 'linux-x64', dependencies.onnxruntime.version); + assert.deepStrictEqual(fs.readdirSync(directory), ['libonnxruntime.so.1']); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 774df8476dc5ee..bd22fc3faf9c28 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -352,53 +352,6 @@ async function main() { } } } - - // foundry-local-sdk (on-device chat dictation) resolves its prebuilt N-API - // addon and native core libraries from fixed, package-relative paths. We do - // not ship that native payload (its addon requires a newer glibc than our - // minimum supported Linux distros); it is downloaded on demand at runtime - // into a per-user cache. Patch the SDK loader so it honors the - // `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR` env var (pointing at that cache) for both - // the addon and the core libraries, falling back to the original - // package-relative logic so dev-from-source still works. Idempotent. - for (const dir of ['', 'remote']) { - const coreInteropFile = path.join(root, dir, 'node_modules', 'foundry-local-sdk', 'dist', 'detail', 'coreInterop.js'); - if (!fs.existsSync(coreInteropFile)) { - continue; - } - const content = fs.readFileSync(coreInteropFile, 'utf8'); - // Apply the addon and core patches independently. They previously shared - // a single `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR` presence check, so if only - // one SDK needle changed the file was left half-patched and every later - // run skipped it entirely — and since packaging removes both native - // fallbacks, a missing half makes shipped dictation unusable. Use a - // distinct marker per half and apply whichever is absent. - const addonMarker = '// VSCODE_PATCH:foundry-addon-native-dir'; - const coreMarker = '// VSCODE_PATCH:foundry-core-native-dir'; - const addonNeedle = ` const platformKey = \`\${platform}-\${arch}\`;\n // The prebuilt addon ships inside the SDK package under prebuilds//\n const sdkRoot = path.resolve(__dirname, '..', '..');`; - const addonReplacement = ` const platformKey = \`\${platform}-\${arch}\`;\n ${addonMarker}: prefer the on-demand native runtime cache when present.\n const overrideDir = process.env.VSCODE_FOUNDRY_LOCAL_NATIVE_DIR;\n if (overrideDir) {\n const overridePath = path.join(overrideDir, 'prebuilds', platformKey, 'foundry_local_napi.node');\n if (fs.existsSync(overridePath)) {\n return require(overridePath);\n }\n }\n // The prebuilt addon ships inside the SDK package under prebuilds//\n const sdkRoot = path.resolve(__dirname, '..', '..');`; - const coreNeedle = ` const platformKey = \`\${platform}-\${arch}\`;\n // Resolve the native binary directory at foundry-local-core/,`; - const coreReplacement = ` const platformKey = \`\${platform}-\${arch}\`;\n ${coreMarker}: prefer the on-demand native runtime cache when present.\n const overrideDir = process.env.VSCODE_FOUNDRY_LOCAL_NATIVE_DIR;\n if (overrideDir) {\n const overrideExt = CoreInterop._getLibraryExtension();\n const overrideCorePath = path.join(overrideDir, 'foundry-local-core', platformKey, \`Microsoft.AI.Foundry.Local.Core\${overrideExt}\`);\n if (fs.existsSync(overrideCorePath)) {\n config.params['FoundryLocalCorePath'] = overrideCorePath;\n return overrideCorePath;\n }\n }\n // Resolve the native binary directory at foundry-local-core/,`; - let patched = content; - if (!patched.includes(addonMarker)) { - if (patched.includes(addonNeedle)) { - patched = patched.replace(addonNeedle, addonReplacement); - } else { - log(dir || '.', 'WARNING: foundry-local-sdk coreInterop.js loadAddon shape changed; skipped addon override patch'); - } - } - if (!patched.includes(coreMarker)) { - if (patched.includes(coreNeedle)) { - patched = patched.replace(coreNeedle, coreReplacement); - } else { - log(dir || '.', 'WARNING: foundry-local-sdk coreInterop.js _resolveDefaultCorePath shape changed; skipped core override patch'); - } - } - if (content !== patched) { - fs.writeFileSync(coreInteropFile, patched); - log(dir || '.', 'Patched foundry-local-sdk coreInterop.js (on-demand native runtime override)'); - } - } } main().catch(err => { diff --git a/package-lock.json b/package-lock.json index aa6fb296daef4c..3ea4698a5bd339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "1.2.3", + "foundry-local-sdk": "2.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -5659,12 +5659,12 @@ } }, "node_modules/adm-zip": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", - "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { @@ -10291,13 +10291,26 @@ } }, "node_modules/foundry-local-sdk": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-1.2.3.tgz", - "integrity": "sha512-1iy5Aixi+p3GiGbIZN3rIuKsLYmcXWjqEDPMNFie2g9ufoTq58OZf+tdYdI4AKA7kNsteO9HWHnzDoPqdoxw5A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.0.1.tgz", + "integrity": "sha512-5TwBhKypAGEeezfvluiNlWPVIBX82MW9riqerlROKEzODRYxmzo13fKQ2qEDkVBbioV2PWP4LFFzUqk/NrKjcw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "adm-zip": "^0.5.16" + "adm-zip": "^0.6.0", + "node-addon-api": "^8.2.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foundry-local-sdk/node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" } }, "node_modules/fragment-cache": { diff --git a/package.json b/package.json index 6cb4debf31707f..0546d25d38862d 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "1.2.3", + "foundry-local-sdk": "2.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -299,7 +299,7 @@ "allowScripts": { "bufferutil@4.1.0": true, "cpu-features": false, - "foundry-local-sdk@1.2.3": true, + "foundry-local-sdk@2.0.1": true, "kerberos@2.1.1": true, "koffi@3.1.2": false, "native-keymap@3.3.9": true, diff --git a/src/vs/platform/localTranscription/common/localTranscription.ts b/src/vs/platform/localTranscription/common/localTranscription.ts index 2678c9159558b3..133c68e13f3a79 100644 --- a/src/vs/platform/localTranscription/common/localTranscription.ts +++ b/src/vs/platform/localTranscription/common/localTranscription.ts @@ -115,10 +115,10 @@ export interface ILocalTranscriptionService { * * `proxyUrl`/`noProxy` bridge VS Code's `http.proxy`/`http.noProxy` settings * into this utility process: when set, they are applied as the standard proxy - * environment variables before any download, so all provisioning legs — the - * addon tarball and NuGet core libraries (our own fetches) and the native - * Foundry Local *model* download — route through the proxy. When they are - * omitted, the process's inherited OS environment proxy vars still apply. + * environment variables before any download, so both the native runtime CDN + * download and the Foundry Local *model* download route through the proxy. + * When they are omitted, the process's inherited OS environment proxy vars + * still apply. * * `proxyStrictSSL === false` (VS Code's `http.proxyStrictSSL`) disables TLS * certificate verification for the JavaScript download legs. `proxyAuthorization` @@ -130,9 +130,10 @@ export interface ILocalTranscriptionService { * * `runtimeUrlTemplate`/`runtimeVersion` come from `product.dictationRuntime` * (stamped by `build/dictation-runtime/produce.ts`). When set, the native - * runtime (Foundry Local addon + core libraries) is downloaded from VS Code's - * CDN for this host's target. When omitted (local dev builds), the runtime - * falls back to the SDK's own `node_modules` payload and nothing is downloaded. + * runtime (Foundry Local addons + shared libraries) is downloaded from VS + * Code's CDN for this host's target. When omitted (local dev builds), the + * runtime falls back to the SDK's own `node_modules` payload and nothing is + * downloaded. */ start(options: { readonly cacheDir: string; readonly model?: string; readonly language?: string; readonly proxyUrl?: string; readonly noProxy?: string; readonly proxyStrictSSL?: boolean; readonly proxyAuthorization?: string; readonly runtimeUrlTemplate?: string; readonly runtimeVersion?: string }): Promise; diff --git a/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts b/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts index 5a268ab7e508c3..c9acd11f8a8308 100644 --- a/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts +++ b/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as os from 'os'; +import { timeout } from '../../../base/common/async.js'; import { dirname, join } from '../../../base/common/path.js'; import { format2 } from '../../../base/common/strings.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; @@ -14,24 +15,23 @@ import { CancellationError } from '../../../base/common/errors.js'; * On-demand provisioning of the Foundry Local native runtime used by on-device * dictation. * - * `foundry-local-sdk` ships a prebuilt N-API addon (`foundry_local_napi.node`) - * and native core libraries (Foundry Local Core + ONNX Runtime + ONNX Runtime - * GenAI). The addon requires a newer glibc than VS Code's minimum supported - * Linux distros, so we deliberately do NOT bundle any of this native payload - * with the product (see `build/gulpfile.vscode.ts`). Instead we republish a - * per-target tarball of the addon + core libraries to VS Code's CDN at build - * time (see `build/dictation-runtime/`) and download it here, at runtime, only - * on supported platforms, into a per-user writable cache — keeping the shipped - * package's glibc floor intact and avoiding any runtime dependency on the npm - * registry or NuGet. + * `foundry-local-sdk` ships two prebuilt N-API addons (`foundry_local_node.node` + * and `foundry_local_preload.node`) and native libraries (Foundry Local + ONNX + * Runtime + ONNX Runtime GenAI). The shared libraries require a newer glibc than + * VS Code's minimum supported Linux distros, so we bundle only the addons with + * the product (see `build/gulpfile.vscode.ts`). We republish the per-target + * shared libraries to VS Code's CDN at build time (see + * `build/dictation-runtime/`) and download them here, at runtime, into a + * per-user writable cache. This keeps the shipped package's glibc floor intact + * and avoids any runtime dependency on the npm registry or NuGet. * - * The SDK loader (`dist/detail/coreInterop.js`) is patched during - * `postinstall` to honor `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR`, pointing it at the - * cache directory this module populates. The tarball's internal layout mirrors - * the SDK's own package layout so the patched resolution is a trivial path join: + * The tarball's internal layout mirrors the SDK's own package layout: * - * //prebuilds//foundry_local_napi.node - * //foundry-local-core// + * //prebuilds// + * + * The SDK keeps its addons in the packaged npm module and is configured through + * `configureNativeLoader`/`FoundryLocalConfig.libraryPath` to preload the shared + * libraries from this cache directory. * * NOTE: the single CDN download leg honors the standard proxy environment * variables (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`, with `NO_PROXY`). VS Code's @@ -45,7 +45,7 @@ import { CancellationError } from '../../../base/common/errors.js'; /** * Platforms (`-`) for which Foundry Local ships - * a native addon + core libraries. Mirrors the SDK installer's RID map. + * native addons and libraries. Mirrors the SDK installer's RID map. */ export const FOUNDRY_LOCAL_SUPPORTED_PLATFORMS: ReadonlySet = new Set([ 'darwin-arm64', @@ -82,12 +82,14 @@ const inFlight = new Map>(); /** Abort a download after this long without any connection/response progress. */ const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 60_000; +const PUBLISH_LOCK_RETRY_MS = 100; +const INVALID_PUBLISH_LOCK_STALE_MS = 30_000; /** - * Ensure the Foundry Local native runtime (addon + core libraries) is present - * in ``, downloading the per-target CDN tarball if necessary. Returns - * the versioned override directory to set as `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR` - * before loading the SDK. + * Ensure the Foundry Local shared libraries are present in ``, + * downloading the per-target CDN tarball if necessary. Returns the concrete + * cached `prebuilds/` directory to use as the SDK's `libraryPath` + * before constructing a manager. * * Idempotent: once a version is fully provisioned a per-platform `.complete` * marker is written and subsequent calls return immediately (after verifying the @@ -100,15 +102,16 @@ export async function ensureFoundryLocalRuntime(cacheRoot: string, download: IFo } const overrideDir = join(cacheRoot, download.version); + const libraryPath = foundryPrebuildDir(overrideDir, platformKey); // A single in-flight provisioning per override dir; late joiners share it. - const existing = inFlight.get(overrideDir); + const existing = inFlight.get(libraryPath); if (existing) { return existing; } const promise = doEnsure(overrideDir, platformKey, download, token, onProgress) - .finally(() => inFlight.delete(overrideDir)); - inFlight.set(overrideDir, promise); + .finally(() => inFlight.delete(libraryPath)); + inFlight.set(libraryPath, promise); return promise; } @@ -120,7 +123,7 @@ async function doEnsure(overrideDir: string, platformKey: string, download: IFou // marker never short-circuits this arch's provisioning and a stale/partially // deleted cache is repaired rather than trusted. if (isRuntimeProvisioned(overrideDir, platformKey)) { - return overrideDir; + return foundryPrebuildDir(overrideDir, platformKey); } // Fail fast (before any download) when the host can't actually load the @@ -131,7 +134,7 @@ async function doEnsure(overrideDir: string, platformKey: string, download: IFou onProgress?.('Downloading dictation runtime…'); await provisionRuntime(overrideDir, platformKey, download.urlTemplate, download.version, token); - return overrideDir; + return foundryPrebuildDir(overrideDir, platformKey); } /** @@ -142,8 +145,7 @@ async function doEnsure(overrideDir: string, platformKey: string, download: IFou * host should use `ensureFoundryLocalRuntime`, which gates and de-dupes. */ export async function provisionRuntime(overrideDir: string, platformKey: string, urlTemplate: string, version: string, token: CancellationToken): Promise { - const addonPath = foundryAddonPath(overrideDir, platformKey); - const coreDir = foundryCoreDir(overrideDir, platformKey); + const targetDir = foundryPrebuildDir(overrideDir, platformKey); // The cache is shared by the utility processes of every open VS Code window, // so provision into a process-unique staging dir and atomically promote each @@ -152,55 +154,137 @@ export async function provisionRuntime(overrideDir: string, platformKey: string, // the published copy and the loser accepts it as success. const url = format2(urlTemplate, { target: platformKey }); const staging = join(overrideDir, `.staging-${process.pid}-${randomSuffix()}`); - const stagingAddon = join(staging, 'prebuilds', platformKey, 'foundry_local_napi.node'); - const stagingCore = join(staging, 'foundry-local-core', platformKey); + const stagingTarget = foundryPrebuildDir(staging, platformKey); try { await downloadAndExtractTarball(url, staging, token); throwIfCancelled(token); - if (!fs.existsSync(stagingAddon) || !hasAllCoreLibraries(stagingCore)) { + if (!hasAllRuntimeFiles(stagingTarget, platformKey)) { throw new Error(`Foundry Local native runtime download from ${url} completed but expected files are missing.`); } - await promoteDir(dirname(stagingAddon), dirname(addonPath)); - await promoteDir(stagingCore, coreDir); + await publishRuntime(stagingTarget, overrideDir, platformKey, token); } finally { await fs.promises.rm(staging, { recursive: true, force: true }).catch(() => { /* best effort */ }); } // Verify the published payload — ours or a concurrent winner's — is complete. - if (!fs.existsSync(addonPath) || !hasAllCoreLibraries(coreDir)) { + if (!hasAllRuntimeFiles(targetDir, platformKey)) { throw new Error('Foundry Local native runtime is incomplete after provisioning.'); } await fs.promises.writeFile(foundryMarkerPath(overrideDir, platformKey), `${version}\n`).catch(() => { /* best effort marker */ }); } +/** Publish a staged runtime without replacing a complete concurrent winner. */ +export async function publishRuntime(stagingTarget: string, overrideDir: string, platformKey: string, token: CancellationToken): Promise { + const lock = await acquireRuntimePublishLock(overrideDir, platformKey, token); + try { + throwIfCancelled(token); + const targetDir = foundryPrebuildDir(overrideDir, platformKey); + if (hasAllRuntimeFiles(targetDir, platformKey)) { + return; + } + await fs.promises.rm(targetDir, { recursive: true, force: true }); + await promoteDir(stagingTarget, targetDir); + } finally { + await releaseRuntimePublishLock(lock); + } +} + +interface IRuntimePublishLock { + readonly path: string; + readonly handle: fs.promises.FileHandle; +} + +async function acquireRuntimePublishLock(overrideDir: string, platformKey: string, token: CancellationToken): Promise { + await fs.promises.mkdir(overrideDir, { recursive: true }); + const lockPath = join(overrideDir, `.publish-${platformKey}.lock`); + while (true) { + throwIfCancelled(token); + try { + const handle = await fs.promises.open(lockPath, 'wx'); + try { + await handle.writeFile(`${process.pid}\n`); + return { path: lockPath, handle }; + } catch (err) { + await handle.close(); + await removeFileIfExists(lockPath); + throw err; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + + if (await isStaleRuntimePublishLock(lockPath)) { + await removeFileIfExists(lockPath); + continue; + } + await timeout(PUBLISH_LOCK_RETRY_MS); + } +} + +async function releaseRuntimePublishLock(lock: IRuntimePublishLock): Promise { + await lock.handle.close(); + await removeFileIfExists(lock.path); +} + +async function removeFileIfExists(path: string): Promise { + try { + await fs.promises.unlink(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } +} + +async function isStaleRuntimePublishLock(lockPath: string): Promise { + try { + const pid = Number.parseInt(await fs.promises.readFile(lockPath, 'utf8'), 10); + if (Number.isSafeInteger(pid) && pid > 0) { + return !isPidAlive(pid); + } + const stat = await fs.promises.stat(lockPath); + return Date.now() - stat.mtimeMs >= INVALID_PUBLISH_LOCK_STALE_MS; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw err; + } +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + /** Path of the per-platform completion marker inside a versioned override dir. */ function foundryMarkerPath(overrideDir: string, platformKey: string): string { return join(overrideDir, `.complete-${platformKey}`); } -/** Path of the prebuilt N-API addon inside a versioned override dir. */ -function foundryAddonPath(overrideDir: string, platformKey: string): string { - return join(overrideDir, 'prebuilds', platformKey, 'foundry_local_napi.node'); -} - -/** Directory of the native core libraries inside a versioned override dir. */ -function foundryCoreDir(overrideDir: string, platformKey: string): string { - return join(overrideDir, 'foundry-local-core', platformKey); +/** Directory containing the target's shared libraries. */ +function foundryPrebuildDir(overrideDir: string, platformKey: string): string { + return join(overrideDir, 'prebuilds', platformKey); } /** * Whether `` holds a complete, verified runtime for `platformKey`: - * the per-platform marker AND the actual addon + all core libraries. A marker - * alone is insufficient (it can belong to a different architecture, or the - * payload can be partially deleted). Exported for tests. + * the per-platform marker AND all expected shared libraries. A marker alone is + * insufficient (it can belong to a different architecture, or the payload can + * be partially deleted). Exported for tests. */ export function isRuntimeProvisioned(overrideDir: string, platformKey: string): boolean { return fs.existsSync(foundryMarkerPath(overrideDir, platformKey)) - && fs.existsSync(foundryAddonPath(overrideDir, platformKey)) - && hasAllCoreLibraries(foundryCoreDir(overrideDir, platformKey)); + && hasAllRuntimeFiles(foundryPrebuildDir(overrideDir, platformKey), platformKey); } /** @@ -265,10 +349,9 @@ function detectGlibcVersion(): [number, number] | undefined { /** * Download the per-target runtime tarball from `url` and extract it into - * `stagingDir`, which then contains `prebuilds//foundry_local_napi.node` - * and `foundry-local-core//` (the tarball's layout - * mirrors the cache layout). The tarball is published to VS Code's CDN by - * `build/dictation-runtime/`. + * `stagingDir`, which then contains + * `prebuilds//`. The tarball is published + * to VS Code's CDN by `build/dictation-runtime/`. */ async function downloadAndExtractTarball(url: string, stagingDir: string, token: CancellationToken): Promise { await fs.promises.mkdir(stagingDir, { recursive: true }); @@ -286,20 +369,22 @@ async function downloadAndExtractTarball(url: string, stagingDir: string, token: } } -/** The core library filenames required for the current platform. Exported for tests. */ -export function requiredCoreLibraryNames(): string[] { - const ext = process.platform === 'win32' ? '.dll' : process.platform === 'darwin' ? '.dylib' : '.so'; - const prefix = process.platform === 'win32' ? '' : 'lib'; +/** The native files required for a target's complete runtime. Exported for tests. */ +export function requiredRuntimeFileNames(platformKey: string): string[] { + const isWin = platformKey.startsWith('win32-'); + const isDarwin = platformKey.startsWith('darwin-'); + const ext = isWin ? '.dll' : isDarwin ? '.dylib' : '.so'; + const prefix = isWin ? '' : 'lib'; return [ - `Microsoft.AI.Foundry.Local.Core${ext}`, - `${prefix}onnxruntime${ext}`, + `${prefix}foundry_local${ext}`, + isWin ? 'onnxruntime.dll' : isDarwin ? 'libonnxruntime.1.dylib' : 'libonnxruntime.so.1', `${prefix}onnxruntime-genai${ext}`, ]; } -/** Whether all required core libraries already exist in `coreDir`. */ -function hasAllCoreLibraries(coreDir: string): boolean { - return requiredCoreLibraryNames().every(name => fs.existsSync(join(coreDir, name))); +/** Whether all required runtime files already exist in `targetDir`. */ +function hasAllRuntimeFiles(targetDir: string, platformKey: string): boolean { + return requiredRuntimeFileNames(platformKey).every(name => fs.existsSync(join(targetDir, name))); } /** diff --git a/src/vs/platform/localTranscription/node/localTranscriptionService.ts b/src/vs/platform/localTranscription/node/localTranscriptionService.ts index 4d0573695d659b..5c70b618d76b26 100644 --- a/src/vs/platform/localTranscription/node/localTranscriptionService.ts +++ b/src/vs/platform/localTranscription/node/localTranscriptionService.ts @@ -35,9 +35,8 @@ const FOUNDRY_APP_NAME = 'vscode-dictation'; * unresolved forever — and since `start`/`pushAudio`/`stop`/`cancel` all await * these calls (directly or via the serialized append chain / stream consumer), * the renderer would wait indefinitely with no error ever surfacing to the - * user. Model *download* already reports progress and is not included here; - * a stalled download is instead caught by its own inactivity timeout inside - * `ensureFoundryLocalRuntime`. + * user. Model download reports progress but exposes no `AbortSignal`; + * cancellation is enforced by terminating this utility process. */ const MODEL_CATALOG_TIMEOUT_MS = 30_000; const MODEL_LOAD_TIMEOUT_MS = 120_000; @@ -72,11 +71,10 @@ function runtimeCacheDir(modelCacheDir: string): string { } /** - * Foundry Local JS SDK. It is an ESM package that loads a native addon - * (`foundry_local_napi.node`) plus the Foundry Local Core / onnxruntime / - * onnxruntime-genai shared libraries. Import it lazily so forking the utility - * process stays cheap; the model itself is only downloaded/loaded when dictation - * first runs. + * Foundry Local JS SDK. It is an ESM package that loads two native addons plus + * the Foundry Local / onnxruntime / onnxruntime-genai shared libraries. Import + * it lazily so forking the utility process stays cheap; the model itself is only + * downloaded/loaded when dictation first runs. */ type FoundryLocal = typeof import('foundry-local-sdk'); type FoundryLocalManager = import('foundry-local-sdk').FoundryLocalManager; @@ -234,7 +232,7 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans private _loadedModelId: string | undefined; /** In-flight (or resolved) model download+load for the selected model. */ private _modelPromise: Promise | undefined; - /** Cancellation source for the in-flight model download/load; aborts it when cancelled. */ + /** Cancellation source for in-flight model preparation. */ private _modelPrepareCts: CancellationTokenSource | undefined; /** @@ -516,33 +514,35 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans // The model cache state is unknown until the catalog is queried. this._setStatus({ state: LocalTranscriptionModelState.Loading }); - // Ensure the Foundry Local native runtime (N-API addon + core - // libraries) is available before loading the SDK. We do not ship - // it — the addon requires a newer glibc than our minimum supported - // Linux distros — so in packaged builds it is downloaded on demand - // from VS Code's CDN (per `product.dictationRuntime`) into a - // per-user cache and the SDK loader is pointed at it via env var. + // Ensure the Foundry Local shared libraries are available before + // loading the SDK. Packaged builds keep the two addons but download + // the shared libraries on demand from VS Code's CDN into a per-user + // cache and point the SDK loader at it via libraryPath. // This is a no-op once cached. In dev builds (no product config) - // the SDK resolves its addon + core libs from node_modules, so we + // the SDK resolves its addons + shared libraries from node_modules, so we // skip provisioning and leave the loader on its default path. + let nativeLibraryPath: string | undefined; if (this._runtimeDownload) { - const nativeDir = await ensureFoundryLocalRuntime(runtimeCacheDir(cacheDir), this._runtimeDownload, cts.token); - process.env.VSCODE_FOUNDRY_LOCAL_NATIVE_DIR = nativeDir; + nativeLibraryPath = await ensureFoundryLocalRuntime(runtimeCacheDir(cacheDir), this._runtimeDownload, cts.token); } if (!this._sdk) { this._sdk = await import('foundry-local-sdk'); } if (!this._manager) { + if (nativeLibraryPath) { + this._sdk.configureNativeLoader({ libraryPath: nativeLibraryPath }); + } // Store downloaded model files under VS Code's cache dir so // subsequent sessions load without re-downloading ("model // management"). `createAsync` avoids blocking the event loop // during native init. - this._manager = await this._sdk.FoundryLocalManager.createAsync({ + this._manager = this._register(await this._sdk.FoundryLocalManager.createAsync({ appName: FOUNDRY_APP_NAME, modelCacheDir: cacheDir, logLevel: 'warn', - }); + ...(nativeLibraryPath ? { libraryPath: nativeLibraryPath } : {}), + })); } const model = await withTimeout( @@ -559,16 +559,9 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans // download UI appears immediately rather than waiting for the // SDK's first progress callback. this._setStatus({ state: LocalTranscriptionModelState.Downloading, progress: 0 }); - // Bridge VS Code cancellation to the AbortSignal the SDK expects. - const ac = new AbortController(); - const sub = cts.token.onCancellationRequested(() => ac.abort()); - try { - await model.download((percent: number) => { - this._setStatus({ state: LocalTranscriptionModelState.Downloading, progress: Math.min(1, Math.max(0, percent / 100)) }); - }, ac.signal); - } finally { - sub.dispose(); - } + await model.download((percent: number) => { + this._setStatus({ state: LocalTranscriptionModelState.Downloading, progress: Math.min(1, Math.max(0, percent / 100)) }); + }); } // model.load() has no AbortSignal; check cancellation before starting it. diff --git a/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts b/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts index 10253beb4b878d..c5295d745609b4 100644 --- a/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts +++ b/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts @@ -18,9 +18,10 @@ import { foundryLocalPlatformKey, isFoundryLocalRuntimeSupported, isRuntimeProvisioned, + publishRuntime, promoteDir, provisionRuntime, - requiredCoreLibraryNames, + requiredRuntimeFileNames, resolveProxyUrl, } from '../../node/foundryLocalRuntime.js'; @@ -39,12 +40,10 @@ flakySuite('FoundryLocalRuntime', () => { teardown(() => Promises.rm(testDir)); function writePayload(overrideDir: string, key: string): void { - fs.mkdirSync(join(overrideDir, 'prebuilds', key), { recursive: true }); - fs.writeFileSync(join(overrideDir, 'prebuilds', key, 'foundry_local_napi.node'), 'addon'); - const coreDir = join(overrideDir, 'foundry-local-core', key); - fs.mkdirSync(coreDir, { recursive: true }); - for (const name of requiredCoreLibraryNames()) { - fs.writeFileSync(join(coreDir, name), 'lib'); + const targetDir = join(overrideDir, 'prebuilds', key); + fs.mkdirSync(targetDir, { recursive: true }); + for (const name of requiredRuntimeFileNames(key)) { + fs.writeFileSync(join(targetDir, name), 'native'); } } @@ -73,13 +72,25 @@ flakySuite('FoundryLocalRuntime', () => { assert.strictEqual(isRuntimeProvisioned(testDir, platformKey), false); }); - test('isRuntimeProvisioned: false when one core library is missing (partial cache)', () => { + test('isRuntimeProvisioned: false when one shared library is missing (partial cache)', () => { writePayload(testDir, platformKey); writeMarker(testDir, platformKey); - fs.rmSync(join(testDir, 'foundry-local-core', platformKey, requiredCoreLibraryNames()[0])); + fs.rmSync(join(testDir, 'prebuilds', platformKey, 'libonnxruntime-genai.so')); assert.strictEqual(isRuntimeProvisioned(testDir, platformKey), false); }); + test('requiredRuntimeFileNames: includes only shared libraries', () => { + assert.deepStrictEqual({ + linux: requiredRuntimeFileNames('linux-x64'), + darwin: requiredRuntimeFileNames('darwin-arm64'), + win32: requiredRuntimeFileNames('win32-x64'), + }, { + linux: ['libfoundry_local.so', 'libonnxruntime.so.1', 'libonnxruntime-genai.so'], + darwin: ['libfoundry_local.dylib', 'libonnxruntime.1.dylib', 'libonnxruntime-genai.dylib'], + win32: ['foundry_local.dll', 'onnxruntime.dll', 'onnxruntime-genai.dll'], + }); + }); + test('isRuntimeProvisioned: a different arch marker does not satisfy this arch', () => { // A first run for another architecture wrote its marker + payload; this // arch has neither and must not be considered provisioned. @@ -115,6 +126,28 @@ flakySuite('FoundryLocalRuntime', () => { assert.strictEqual(fs.readFileSync(join(to, 'file'), 'utf8'), 'winner'); }); + test('publishRuntime: keeps a complete runtime published by another process', async () => { + const targetDir = join(testDir, 'prebuilds', platformKey); + writePayload(testDir, platformKey); + fs.writeFileSync(join(targetDir, 'winner'), 'winner'); + + const stagingTarget = join(testDir, 'staging', 'prebuilds', platformKey); + fs.mkdirSync(stagingTarget, { recursive: true }); + for (const name of requiredRuntimeFileNames(platformKey)) { + fs.writeFileSync(join(stagingTarget, name), 'loser'); + } + + await publishRuntime(stagingTarget, testDir, platformKey, CancellationToken.None); + + assert.deepStrictEqual({ + winner: fs.readFileSync(join(targetDir, 'winner'), 'utf8'), + stagingStillExists: fs.existsSync(stagingTarget), + }, { + winner: 'winner', + stagingStillExists: true, + }); + }); + test('resolveProxyUrl: honors scheme-specific vars, ALL_PROXY fallback, and NO_PROXY', () => { const actual = { none: resolveProxyUrl('https://api.nuget.org/', {}), @@ -149,21 +182,20 @@ flakySuite('FoundryLocalRuntime', () => { /** * Build a runtime tarball fixture (`.tgz`) whose internal layout - * matches what `provisionRuntime` extracts and verifies. Set `omitCoreLib` - * to leave one required core library out (an incomplete/corrupt payload). + * matches what `provisionRuntime` extracts and verifies. Set + * `omitRuntimeFile` to leave one required file out. */ - async function makeTarball(key: string, opts?: { omitCoreLib?: boolean }): Promise { + async function makeTarball(key: string, opts?: { omitRuntimeFile?: string }): Promise { const src = join(testDir, `src-${key}`); - fs.mkdirSync(join(src, 'prebuilds', key), { recursive: true }); - fs.writeFileSync(join(src, 'prebuilds', key, 'foundry_local_napi.node'), 'addon'); - const coreDir = join(src, 'foundry-local-core', key); - fs.mkdirSync(coreDir, { recursive: true }); - const libs = requiredCoreLibraryNames(); - for (const name of opts?.omitCoreLib ? libs.slice(1) : libs) { - fs.writeFileSync(join(coreDir, name), 'lib'); + const targetDir = join(src, 'prebuilds', key); + fs.mkdirSync(targetDir, { recursive: true }); + for (const name of requiredRuntimeFileNames(key)) { + if (name !== opts?.omitRuntimeFile) { + fs.writeFileSync(join(targetDir, name), 'native'); + } } const tgz = join(testDir, `${key}.tgz`); - await tar.c({ file: tgz, cwd: src, gzip: true }, ['prebuilds', 'foundry-local-core']); + await tar.c({ file: tgz, cwd: src, gzip: true }, ['prebuilds']); return tgz; } @@ -205,9 +237,8 @@ flakySuite('FoundryLocalRuntime', () => { // {target} was substituted with the platform key in the request URL. assert.deepStrictEqual(server.requested, [`${platformKey}.tgz`]); // Payload extracted into the cache layout + completion marker written. - assert.strictEqual(fs.existsSync(join(overrideDir, 'prebuilds', platformKey, 'foundry_local_napi.node')), true); - for (const name of requiredCoreLibraryNames()) { - assert.strictEqual(fs.existsSync(join(overrideDir, 'foundry-local-core', platformKey, name)), true); + for (const name of requiredRuntimeFileNames(platformKey)) { + assert.strictEqual(fs.existsSync(join(overrideDir, 'prebuilds', platformKey, name)), true); } assert.strictEqual(isRuntimeProvisioned(overrideDir, platformKey), true); } finally { @@ -229,8 +260,8 @@ flakySuite('FoundryLocalRuntime', () => { } }); - test('provisionRuntime: rejects an incomplete payload (missing core library) and writes no marker', async () => { - const tgz = await makeTarball(platformKey, { omitCoreLib: true }); + test('provisionRuntime: rejects an incomplete payload and writes no marker', async () => { + const tgz = await makeTarball(platformKey, { omitRuntimeFile: 'libfoundry_local.so' }); const server = await startServer({ [`${platformKey}.tgz`]: tgz }); try { const overrideDir = join(testDir, '1.2.3'); diff --git a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts index c15ef4b2e181f6..3c758ab0174662 100644 --- a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts +++ b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts @@ -5,11 +5,14 @@ import { getDelayedChannel, IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { arch, platform } from '../../../../base/common/process.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { ILocalTranscriptionService, localTranscriptionChannelName } from '../../../../platform/localTranscription/common/localTranscription.js'; -import { IUtilityProcessWorkerWorkbenchService } from '../../utilityProcess/electron-browser/utilityProcessWorkerWorkbenchService.js'; +import { ILocalTranscriptionModelStatus, ILocalTranscriptionResult, ILocalTranscriptionService, localTranscriptionChannelName } from '../../../../platform/localTranscription/common/localTranscription.js'; +import { IUtilityProcessWorker, IUtilityProcessWorkerWorkbenchService } from '../../utilityProcess/electron-browser/utilityProcessWorkerWorkbenchService.js'; /** * Platform/architecture combinations for which the Foundry Local native runtime @@ -37,40 +40,71 @@ function isOnDeviceTranscriptionSupported(): boolean { * onnxruntime-genai). The worker is spun up lazily on first use and torn down * with the window. */ -export class LocalTranscriptionService { +export class LocalTranscriptionService extends Disposable { declare readonly _serviceBrand: undefined; readonly isSupported = isOnDeviceTranscriptionSupported(); + private readonly _onDidChangeModelStatus = this._register(new Emitter()); + readonly onDidChangeModelStatus = this._onDidChangeModelStatus.event; + + private readonly _onDidTranscribe = this._register(new Emitter()); + readonly onDidTranscribe = this._onDidTranscribe.event; + + private readonly _worker = this._register(new MutableDisposable()); + private readonly _workerEvents = this._register(new DisposableStore()); private _channel: IChannel | undefined; private _proxy: ILocalTranscriptionService | undefined; + private _workerGeneration = 0; constructor( @IUtilityProcessWorkerWorkbenchService private readonly utilityProcessWorkerWorkbenchService: IUtilityProcessWorkerWorkbenchService, @IConfigurationService private readonly configurationService: IConfigurationService, @IProductService private readonly productService: IProductService, - ) { } + ) { + super(); + } private _getChannel(): IChannel { if (!this._channel) { - this._channel = getDelayedChannel((async () => { - const { client } = await this.utilityProcessWorkerWorkbenchService.createWorker({ - moduleId: 'vs/platform/localTranscription/node/localTranscriptionMain', - type: 'localTranscription', - name: 'local-transcription', - // The on-device dictation runtime is downloaded from our CDN and its - // native addon (foundry_local_napi.node) is signed by a third party, - // so on macOS it must load in the plugin helper (library validation - // disabled) to avoid a Team ID mismatch dlopen failure. - allowLoadingUnsignedLibraries: true - }); - return client.getChannel(localTranscriptionChannelName); - })()); + this._channel = getDelayedChannel(this._createWorkerChannel(this._workerGeneration)); } return this._channel; } + private async _createWorkerChannel(generation: number): Promise { + const worker = await this.utilityProcessWorkerWorkbenchService.createWorker({ + moduleId: 'vs/platform/localTranscription/node/localTranscriptionMain', + type: 'localTranscription', + name: 'local-transcription', + // The on-device dictation runtime is downloaded from our CDN and its + // native addons are signed by a third party, + // so on macOS it must load in the plugin helper (library validation + // disabled) to avoid a Team ID mismatch dlopen failure. + allowLoadingUnsignedLibraries: true + }); + if (generation !== this._workerGeneration || this._store.isDisposed) { + worker.dispose(); + throw new CancellationError(); + } + this._worker.value = worker; + + const channel = worker.client.getChannel(localTranscriptionChannelName); + const proxy = ProxyChannel.toService(channel, { disableMarshalling: true }); + this._workerEvents.clear(); + this._workerEvents.add(proxy.onDidChangeModelStatus(status => this._onDidChangeModelStatus.fire(status))); + this._workerEvents.add(proxy.onDidTranscribe(result => this._onDidTranscribe.fire(result))); + + void worker.onDidTerminate.then(() => { + if (this._worker.value === worker) { + this._resetWorker(); + } + }); + + return channel; + } + private _getProxy(): ILocalTranscriptionService { if (!this._proxy) { this._proxy = ProxyChannel.toService(this._getChannel(), { disableMarshalling: true }); @@ -78,9 +112,6 @@ export class LocalTranscriptionService { return this._proxy; } - get onDidChangeModelStatus() { return this._getProxy().onDidChangeModelStatus; } - get onDidTranscribe() { return this._getProxy().onDidTranscribe; } - getModelStatus() { return this._getProxy().getModelStatus(); } importModel(options: Parameters[0]) { return this._getProxy().importModel(options); } start(options: { cacheDir: string; model?: string; language?: string }) { @@ -100,7 +131,38 @@ export class LocalTranscriptionService { } pushAudio(chunk: Parameters[0]) { return this._getProxy().pushAudio(chunk); } stop() { return this._getProxy().stop(); } - cancel() { return this._getProxy().cancel(); } + async cancel(): Promise { + const proxy = this._proxy; + if (!proxy) { + return; + } + const worker = this._detachWorker(); + if (!worker) { + return; + } + try { + await proxy.cancel(); + } finally { + worker.dispose(); + } + } + + private _detachWorker(): IUtilityProcessWorker | undefined { + this._workerGeneration++; + this._channel = undefined; + this._proxy = undefined; + this._workerEvents.clear(); + return this._worker.clearAndLeak(); + } + + private _resetWorker(): void { + this._detachWorker()?.dispose(); + } + + override dispose(): void { + this._workerGeneration++; + super.dispose(); + } /** * Read VS Code's `http.proxy`/`http.noProxy`/`http.proxyStrictSSL`/