fix(boot): preserve runtime fallback precedence
This commit is contained in:
1 parent
fcc6cc8c12
commit
518c7a604b
6 files changed
+182
-25
No files matched your search
@@ -47,7 +47,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
|
||||
const pnpm = join(root, 'bundled-pnpm.mjs')
|
||||
const nodeBin = join(root, 'bin')
|
||||
const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
const child = fork(entry, [root, root, root, pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
|
||||
const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Package directory canonicalization for Node and packaged executables. */
|
||||
/** Package directory canonicalization and stale profile-projection recognition. */
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { lstatSync, readlinkSync, realpathSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
|
||||
/** Profile-private package links projected into its pnpm-managed node_modules. */
|
||||
export const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback'
|
||||
|
||||
/**
|
||||
* Return whether the process reads application modules from pkg's virtual filesystem.
|
||||
@@ -18,3 +22,52 @@ export function isPackagedExecutable(): boolean {
|
||||
export function realModuleDirectory(path: string): string {
|
||||
return isPackagedExecutable() ? realpathSync(path) : realpathSync.native(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a link target without following the final path component.
|
||||
* @param path - candidate path whose parent is canonicalized.
|
||||
* @returns the canonical candidate, or undefined when its parent is absent.
|
||||
*/
|
||||
export function canonicalLinkPath(path: string): string | undefined {
|
||||
try {
|
||||
return join(realModuleDirectory(dirname(path)), basename(path))
|
||||
} catch (error) {
|
||||
// A missing parent means the candidate cannot identify an existing owned link.
|
||||
/* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||
/* v8 ignore next -- see the host-filesystem exception above */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a symlink or junction points at the same path as `target`.
|
||||
* @param link - symlink or junction to inspect.
|
||||
* @param target - expected target path.
|
||||
* @returns whether both paths identify the same entry.
|
||||
*/
|
||||
export function symlinkPointsTo(link: string, target: string): boolean {
|
||||
const actual = resolve(dirname(link), readlinkSync(link))
|
||||
const canonicalActual = canonicalLinkPath(actual)
|
||||
const canonicalTarget = canonicalLinkPath(resolve(target))
|
||||
return canonicalActual !== undefined && canonicalActual === canonicalTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether an observed profile package must not claim local precedence.
|
||||
* @param profileDir - profile directory containing the package projection.
|
||||
* @param packageName - bare package name to inspect.
|
||||
* @returns whether the entry is a managed fallback link or disappeared during inspection.
|
||||
*/
|
||||
export function isProfileModuleFallbackLink(profileDir: string, packageName: string): boolean {
|
||||
const link = join(profileDir, 'node_modules', packageName)
|
||||
const target = join(profileDir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules', packageName)
|
||||
try {
|
||||
return lstatSync(link).isSymbolicLink() && symlinkPointsTo(link, target)
|
||||
} catch (error) {
|
||||
/* v8 ignore next 2 -- a vanished candidate cannot claim local precedence */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
|
||||
/* v8 ignore next -- non-ENOENT lstat/readlink failures require a host filesystem fault */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { getEnvironmentData, setEnvironmentData } from 'node:worker_threads'
|
||||
import type { ModuleLoaderV1, ModuleLoaderV2, ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { imports as resolvePackageImports, type Package as ResolvePackageManifest } from 'resolve.exports'
|
||||
import { isProfileModuleFallbackLink } from './legacy-links.ts'
|
||||
import type { ProfileResolutionEntry, ProfileResolutionGeneration } from '../profile.ts'
|
||||
|
||||
const WORKER_RESOLUTION_KEY = '@deepseek-ai/dsh-app-boot/profile-resolution'
|
||||
@@ -167,14 +168,14 @@ function nativePackageDir(parent: string, name: string): string | undefined {
|
||||
|
||||
function localPackageCandidate(
|
||||
searchPath: string, name: string, flavor: 'esm' | 'cjs',
|
||||
): { packageDir: string } | undefined {
|
||||
): { packageDir: string; canBeManagedLink: boolean } | undefined {
|
||||
const candidate = join(searchPath, name)
|
||||
const stat = statSync(candidate, { throwIfNoEntry: false })
|
||||
const found = flavor === 'esm'
|
||||
? stat?.isDirectory() === true
|
||||
: stat !== undefined
|
||||
|| ['.js', '.json', '.node'].some(extension => existsSync(candidate + extension))
|
||||
return found ? { packageDir: candidate } : undefined
|
||||
return found ? { packageDir: candidate, canBeManagedLink: stat !== undefined } : undefined
|
||||
}
|
||||
|
||||
function selfReferenceName(parent: string): string | false | null {
|
||||
@@ -339,13 +340,19 @@ class ResolutionRouter {
|
||||
}
|
||||
|
||||
const target = generation.entries.get(name)
|
||||
const candidates: Array<{ packageDir: string }> = []
|
||||
const candidates: Array<{ packageDir: string; canBeManagedLink: boolean }> = []
|
||||
const localSearchPaths: string[] = []
|
||||
for (const searchPath of createRequire(parent).resolve.paths(name) as string[]) {
|
||||
if (generation.shared.has(resolve(searchPath))) break
|
||||
localSearchPaths.push(searchPath)
|
||||
const candidate = localPackageCandidate(searchPath, name, flavor)
|
||||
if (candidate !== undefined) candidates.push(candidate)
|
||||
if (candidate !== undefined) {
|
||||
const legacy = candidate.canBeManagedLink && generation.profile.some(prefix => (
|
||||
candidate.packageDir === join(prefix, 'node_modules', name)
|
||||
&& isProfileModuleFallbackLink(prefix.slice(0, -1), name)
|
||||
))
|
||||
if (!legacy) candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
if (flavor === 'cjs' && nativeResolve !== undefined) {
|
||||
@@ -379,7 +386,7 @@ class ResolutionRouter {
|
||||
|
||||
const eligible = target?.scope === 'installation'
|
||||
|| (target?.scope === 'profile' && parentRoutes.activeProfile)
|
||||
const after = join(profilesDir, 'package.json')
|
||||
const after = join(dirname(profilesDir), 'package.json')
|
||||
const route: ResolutionRoute = eligible
|
||||
? { kind: 'fallback', entry: target, after }
|
||||
: { kind: 'after-fallback', parent: after }
|
||||
|
||||
@@ -21,14 +21,16 @@
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import type { DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
|
||||
import {
|
||||
canonicalLinkPath, isProfileModuleFallbackLink, PROFILE_MODULE_FALLBACK_DIR, realModuleDirectory, symlinkPointsTo,
|
||||
} from './profile-resolution/legacy-links.ts'
|
||||
|
||||
/** Directory under the Harness home holding every profile. */
|
||||
export const PROFILES_DIR = 'profiles'
|
||||
@@ -213,7 +215,7 @@ function resolveModuleFallbackEntries(
|
||||
declarers: ReadonlyMap<string, string>
|
||||
versions: ReadonlyMap<string, string | undefined>
|
||||
} {
|
||||
// tsx skips workspace path mappings for importers under node_modules.
|
||||
// Real declaring paths keep workspace symlinks under node_modules from disabling tsx path mappings.
|
||||
const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor))
|
||||
const appManifest = readModuleFallbackManifest(canonicalAnchor)
|
||||
const links = new Map<string, string>()
|
||||
@@ -326,13 +328,15 @@ function installedProfilePackageNames(profile: Profile, manifest: ProfileManifes
|
||||
if (manifest === undefined) return []
|
||||
return profileDependencyNames(manifest).filter((name) => {
|
||||
const candidate = join(profile.dir, 'node_modules', name)
|
||||
return existsSync(join(candidate, 'package.json'))
|
||||
if (!existsSync(join(candidate, 'package.json'))) return false
|
||||
return !isProfileModuleFallbackLink(profile.dir, name)
|
||||
})
|
||||
}
|
||||
|
||||
/** Collect the first resolvable package directory for each dependency name. */
|
||||
function dependencyClosure(
|
||||
anchors: readonly string[], reserved: ReadonlySet<string>,
|
||||
exclude: (candidate: string, packageName: string) => boolean,
|
||||
declarers?: Map<string, string>,
|
||||
versions?: Map<string, string | undefined>,
|
||||
): Map<string, string> {
|
||||
@@ -355,7 +359,7 @@ function dependencyClosure(
|
||||
/* v8 ignore next -- an installable package manifest always declares dependencies or peers */
|
||||
for (const dep of profileDependencyNames(next.manifest)) {
|
||||
if (visited.has(dep)) continue
|
||||
const dir = packageDirFromAnchor(next.anchor, dep)
|
||||
const dir = packageDirFromAnchor(next.anchor, dep, exclude)
|
||||
// A declared-but-uninstalled dependency cannot be loader-visible.
|
||||
if (dir === undefined) continue
|
||||
visited.add(dep)
|
||||
@@ -377,10 +381,25 @@ function resolveProfileModuleFallback(
|
||||
declarers?: Map<string, string>,
|
||||
versions?: Map<string, string | undefined>,
|
||||
): Map<string, string> {
|
||||
const profileModulesDir = join(profile.dir, 'node_modules')
|
||||
const ownedModulesDir = join(profile.dir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules')
|
||||
const bundleAnchors = profile.layers
|
||||
.filter(layer => !installationPackageNames.has(layer.packageName))
|
||||
.map(layer => join(layer.packageDir, 'package.json'))
|
||||
const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, declarers, versions)
|
||||
const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, (candidate, packageName) => {
|
||||
const profileLink = join(profileModulesDir, packageName)
|
||||
if (canonicalLinkPath(candidate) !== canonicalLinkPath(profileLink)) return false
|
||||
try {
|
||||
return lstatSync(profileLink).isSymbolicLink()
|
||||
&& symlinkPointsTo(profileLink, join(ownedModulesDir, packageName))
|
||||
} catch (error) {
|
||||
// A concurrent cleanup may remove the projection after package discovery.
|
||||
/* v8 ignore next 2 -- a non-ENOENT lstat failure requires a host filesystem fault */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
|
||||
/* v8 ignore next -- see the host-filesystem exception above */
|
||||
throw error
|
||||
}
|
||||
}, declarers, versions)
|
||||
for (const layer of profile.layers) bundleLinks.delete(layer.packageName)
|
||||
return bundleLinks
|
||||
}
|
||||
@@ -456,12 +475,13 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan
|
||||
*/
|
||||
function packageDirFromAnchor(
|
||||
anchor: string, packageName: string,
|
||||
exclude: (candidate: string, packageName: string) => boolean = () => false,
|
||||
): string | undefined {
|
||||
// resolve.paths returns null only for builtins, which no bundle name is.
|
||||
/* v8 ignore next */
|
||||
for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
|
||||
const candidate = join(searchPath, packageName)
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate
|
||||
if (existsSync(join(candidate, 'package.json')) && !exclude(candidate, packageName)) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('profile resolution generation', { concurrent: false }, () => {
|
||||
imports: { '#local': 'local-import', '#ancestor': 'ancestor-import' },
|
||||
}))
|
||||
const local = join(f.profile.dir, 'node_modules', 'local-import')
|
||||
const ancestor = join(f.root, 'profiles', 'node_modules', 'ancestor-import')
|
||||
const ancestor = join(f.root, 'node_modules', 'ancestor-import')
|
||||
pkg(local, 'local-import', 4)
|
||||
pkg(ancestor, 'ancestor-import', 5)
|
||||
const require = createRequire(join(f.profile.dir, 'entry.cjs'))
|
||||
@@ -983,6 +983,84 @@ describe('profile resolution generation', { concurrent: false }, () => {
|
||||
})).toBe(alternativeSubpath)
|
||||
})
|
||||
|
||||
it('skips stale shared and profile-owned fallback entries when the generation misses', async () => {
|
||||
const f = fixture()
|
||||
pkg(join(f.root, 'profiles', 'node_modules', 'stale-shared'), 'stale-shared', 9)
|
||||
pkg(join(f.root, 'node_modules', 'stale-shared'), 'stale-shared', 3)
|
||||
const target = join(f.root, 'stale-private-target')
|
||||
const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'stale-private')
|
||||
const projected = join(f.profile.dir, 'node_modules', 'stale-private')
|
||||
pkg(target, 'stale-private', 9)
|
||||
mkdirSync(dirname(owned), { recursive: true })
|
||||
symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
mkdirSync(dirname(projected), { recursive: true })
|
||||
symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
pkg(join(f.root, 'node_modules', 'stale-private'), 'stale-private', 3)
|
||||
const registration = installProfileResolution(await generationOf(f))
|
||||
registrations.push(registration)
|
||||
const require = createRequire(join(f.profile.dir, 'entry.cjs'))
|
||||
for (const name of ['stale-shared', 'stale-private']) {
|
||||
expect(require(name)).toEqual({ marker: 3 })
|
||||
expect(require.resolve(name, { paths: [f.profile.dir] })).toBe(join(f.root, 'node_modules', name, 'index.cjs'))
|
||||
expect(await importFrom(name, pathToFileURL(join(f.profile.dir, `${name}.mjs`)).href))
|
||||
.toMatchObject({ marker: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips stale shared and declared profile projections when the generation supplies the package', async () => {
|
||||
const f = fixture()
|
||||
file(join(f.profile.dir, 'package.json'), JSON.stringify({
|
||||
name: 'test-profile', private: true, dependencies: { 'resolution-lib': '*' },
|
||||
imports: { '#library': 'resolution-lib' },
|
||||
}))
|
||||
const target = join(f.root, 'stale-target')
|
||||
pkg(target, 'resolution-lib', 9)
|
||||
const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'resolution-lib')
|
||||
const projected = join(f.profile.dir, 'node_modules', 'resolution-lib')
|
||||
const shared = join(f.root, 'profiles', 'node_modules', 'resolution-lib')
|
||||
mkdirSync(dirname(owned), { recursive: true })
|
||||
mkdirSync(dirname(projected), { recursive: true })
|
||||
mkdirSync(dirname(shared), { recursive: true })
|
||||
symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
symlinkSync(target, shared, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const generation = await generationOf(f)
|
||||
expect(generation.localPackageNames).toEqual([])
|
||||
const registration = installProfileResolution(generation)
|
||||
registrations.push(registration)
|
||||
const require = createRequire(join(f.profile.dir, 'entry.cjs'))
|
||||
for (const name of ['resolution-lib', '#library']) {
|
||||
expect(require(name)).toEqual({ marker: 1 })
|
||||
expect(await importFrom(name, pathToFileURL(join(f.profile.dir, 'entry.mjs')).href))
|
||||
.toMatchObject({ marker: 1 })
|
||||
}
|
||||
expect(realpathSync(projected)).toBe(target)
|
||||
expect(realpathSync(shared)).toBe(target)
|
||||
})
|
||||
|
||||
it('excludes profile-owned projections from selected bundle dependency traversal', async () => {
|
||||
const f = fixture()
|
||||
const bundle = join(f.profile.dir, 'node_modules', 'selected-bundle')
|
||||
pkg(bundle, 'selected-bundle', 1, { 'bundle-leaf': '*' })
|
||||
f.profile.layers.push({
|
||||
packageName: 'selected-bundle', packageDir: bundle,
|
||||
patchPath: join(bundle, 'cordis.patch.yml'), patches: [],
|
||||
})
|
||||
const target = join(f.root, 'stale-leaf')
|
||||
const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'bundle-leaf')
|
||||
const projected = join(f.profile.dir, 'node_modules', 'bundle-leaf')
|
||||
pkg(target, 'bundle-leaf', 9)
|
||||
mkdirSync(dirname(owned), { recursive: true })
|
||||
symlinkSync(target, owned, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
symlinkSync(owned, projected, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const ancestor = join(f.root, 'node_modules', 'bundle-leaf')
|
||||
pkg(ancestor, 'bundle-leaf', 3)
|
||||
const generation = await generationOf(f)
|
||||
expect(generation.entries.find(entry => entry.name === 'bundle-leaf'))
|
||||
.toMatchObject({ packageDir: ancestor, version: '3.0.0', scope: 'profile' })
|
||||
expect(realpathSync(projected)).toBe(target)
|
||||
})
|
||||
|
||||
it('continues after a canonicalized profiles directory from the matching parent tree', async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-profile-generation-symlink-')))
|
||||
roots.push(root)
|
||||
@@ -993,10 +1071,10 @@ describe('profile resolution generation', { concurrent: false }, () => {
|
||||
mkdirSync(realProfileDir, { recursive: true })
|
||||
mkdirSync(dirname(profilesDir), { recursive: true })
|
||||
symlinkSync(realProfilesDir, profilesDir, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const parentPackage = join(realProfilesDir, 'node_modules', 'parent-lib')
|
||||
pkg(parentPackage, 'parent-lib', 9)
|
||||
pkg(join(carrier, 'node_modules', 'ancestor-lib'), 'ancestor-lib', 3)
|
||||
pkg(join(root, 'home', 'node_modules', 'ancestor-lib'), 'ancestor-lib', 4)
|
||||
pkg(join(realProfilesDir, 'node_modules', 'stale-only'), 'stale-only', 9)
|
||||
const ancestor = join(carrier, 'node_modules', 'stale-only')
|
||||
pkg(ancestor, 'stale-only', 3)
|
||||
pkg(join(root, 'home', 'node_modules', 'stale-only'), 'stale-only', 4)
|
||||
const registration = installProfileResolution({
|
||||
profilesDir,
|
||||
profileDir: join(profilesDir, 'test'),
|
||||
@@ -1005,11 +1083,10 @@ describe('profile resolution generation', { concurrent: false }, () => {
|
||||
})
|
||||
registrations.push(registration)
|
||||
const require = createRequire(join(realProfileDir, 'entry.cjs'))
|
||||
expect(require('parent-lib')).toEqual({ marker: 9 })
|
||||
expect(require('ancestor-lib')).toEqual({ marker: 3 })
|
||||
expect(require('stale-only')).toEqual({ marker: 3 })
|
||||
const parent = pathToFileURL(join(realProfileDir, 'entry.mjs')).href
|
||||
expect(await importFrom('parent-lib', parent)).toMatchObject({ marker: 9 })
|
||||
expect(registration.packageDir('parent-lib', parent)).toBe(parentPackage)
|
||||
expect(await importFrom('stale-only', parent)).toMatchObject({ marker: 3 })
|
||||
expect(registration.packageDir('stale-only', parent)).toBe(ancestor)
|
||||
})
|
||||
|
||||
it('leaves conditional exports to Node', async () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
const tmp = (): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-'))
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-profile-')))
|
||||
tempRoots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user