fix(boot): keep linked-root removal independent of relinking

This commit is contained in:
imccyu committed 2026-09-21 14:00:52 +08:00
1 parent 73e9efbbdf
commit 7252e5823b
4 files changed
+392 -20

No files matched your search

@@ -7,7 +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 type { LinkedRoot, RuntimeResolutionEntry, RuntimeResolution } from '../profile.ts'
import type { RuntimeResolutionEntry, RuntimeResolution } from '../profile.ts'
const WORKER_RESOLUTION_KEY = '@deepseek-ai/dsh-app-boot/profile-resolution'
const EMPTY_ATTRIBUTES: ImportAttributes = Object.freeze({})
@@ -91,11 +91,6 @@ type InterceptionLayer =
| LayerPositions & { readonly kind: 'profile'; readonly active: boolean }
| { readonly kind: 'linked' }
interface CompiledLinkedRoot {
readonly root: LinkedRoot
readonly paths: readonly string[]
}
interface ParentRoutes {
readonly parent: string
readonly layer: InterceptionLayer
@@ -112,7 +107,7 @@ interface CompiledResolution {
readonly profilePaths: readonly string[]
readonly profile: readonly string[]
readonly installationPaths: readonly string[]
readonly linkedRoots: readonly CompiledLinkedRoot[]
readonly linkedPaths: readonly string[]
readonly localPackageNames: ReadonlySet<string>
readonly esmRoutes: ResolutionRoutes
readonly cjsRoutes: ResolutionRoutes
@@ -129,9 +124,11 @@ export interface RuntimeInterception {
*/
packageDir(specifier: string, parentURL: string): string | undefined
/**
* Atomically publish an additive package table and fresh caches.
* @param successor - fully constructed additive successor generation.
* @throws when the profile scope, an existing package mapping, or a linked root's real directory changes.
* Atomically publish a complete successor and fresh caches. Linked roots may be added or removed.
* Removed roots stop intercepting uncovered directories; existing modules and Node caches remain intact.
* @param successor - fully constructed generation retaining existing package mappings and local names.
* @throws when the profile scope or existing mappings change, local names are removed or override
* existing mappings, or a previously published link name selects a different real directory.
*/
replace(successor: RuntimeResolution): void
/** Restore the native resolver methods. Interceptions dispose in reverse order. */
@@ -178,7 +175,7 @@ function compileResolution(resolution: RuntimeResolution): CompiledResolution {
installationPaths: [...new Set(resolution.entries
.filter(entry => entry.scope === 'installation')
.flatMap(entry => prefixes(entry.packageDir)))],
linkedRoots: resolution.linkedRoots.map(root => ({ root, paths: prefixes(root.realPath) })),
linkedPaths: resolution.linkedRoots.flatMap(root => prefixes(root.realPath)),
localPackageNames: new Set(resolution.localPackageNames),
esmRoutes: new Map(),
cjsRoutes: new Map(),
@@ -205,7 +202,7 @@ function findInterceptionLayer(path: string, resolution: CompiledResolution): In
const activeProfile = resolution.profile.find(prefix => path.startsWith(prefix))
const dir = treeRoot !== undefined ? profileChild(path, treeRoot) : activeProfile?.slice(0, -1)
if (dir !== undefined) return computeProfileLayer(dir, activeProfile !== undefined)
if (!resolution.linkedRoots.some(candidate => startsWithin(path, candidate.paths))) return undefined
if (!startsWithin(path, resolution.linkedPaths)) return undefined
if (startsWithin(path, resolution.installationPaths)) return undefined
return { kind: 'linked' }
}
@@ -366,9 +363,11 @@ function sameResolution(left: string, right: string): boolean {
/** One mutable pointer to the immutable runtime resolution. */
class ResolutionRouter {
private current: CompiledResolution
private readonly linkedTargets: Map<string, string>
constructor(resolution: RuntimeResolution, private readonly nodeModulePaths: (directory: string) => string[]) {
this.current = compileResolution(resolution)
this.linkedTargets = new Map(resolution.linkedRoots.map(root => [root.name, root.realPath]))
}
replace(successor: RuntimeResolution): void {
@@ -398,15 +397,18 @@ class ResolutionRouter {
throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`)
}
}
// Node memoizes real paths for the process lifetime, so a link that now points elsewhere would keep
// answering from its former directory.
for (const { root } of this.current.linkedRoots) {
const next = successor.linkedRoots.find(candidate => candidate.name === root.name)
if (next !== undefined && !sameResolution(root.realPath, next.realPath)) {
// Node retains realpath caches after a root leaves the interception scope.
for (const root of successor.linkedRoots) {
const previous = this.linkedTargets.get(root.name)
if (previous !== undefined && !sameResolution(previous, root.realPath)) {
throw new Error(`profile resolution: relinking ${JSON.stringify(root.name)} requires a process restart`)
}
}
this.current = compileResolution(successor)
const next = compileResolution(successor)
for (const { name, realPath } of successor.linkedRoots) {
if (!this.linkedTargets.has(name)) this.linkedTargets.set(name, realPath)
}
this.current = next
}
/** Whether a module path has an interception layer: it lies in the profiles tree, the active profile, or a linked root. */
@@ -75,8 +75,9 @@ export class PluginPackages extends Service {
}
/**
* Publish an additive successor generation for this process and subsequently created Workers.
* @param successor - fully constructed successor generation.
* Publish a complete successor generation for this process and subsequently created Workers.
* Linked roots may be removed without unloading modules or clearing Node caches.
* @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}.
*/
replace(successor: RuntimeResolution): void {
if (this.interception === undefined) throw new Error('plugin-packages: runtime resolution is not installed')
@@ -161,6 +161,71 @@ describe('profile package metadata service', () => {
)).toBeUndefined()
})
it('removes and restores linked roots while rejecting changes to their historical targets', async () => {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-linked-package-service-')))
roots.push(root)
const profilesDir = join(root, 'profiles')
const profileDir = join(profilesDir, 'test')
const runtime = join(root, 'runtime')
const runtimeAnchor = pkg(runtime, '1.0.0')
const linkedA = join(root, 'work', 'a')
const linkedB = join(root, 'work', 'b')
for (const dir of [linkedA, linkedB]) {
file(join(dir, 'package.json'), JSON.stringify({
name: 'linked-plugin', peerDependencies: { 'metadata-lib': '*' },
}))
}
const local = join(linkedA, 'node_modules', 'metadata-lib')
pkg(local, '2.0.0')
const parentPath = join(linkedA, 'entry.mjs')
file(parentPath, '')
const parentURL = pathToFileURL(parentPath).href
const require = createRequire(parentURL)
const linked = { name: 'linked-plugin', realPath: linkedA }
const initial: RuntimeResolution = {
...resolution(profilesDir, profileDir, runtime, runtimeAnchor, '1.0.0'),
linkedRoots: [linked],
}
const removed: RuntimeResolution = { ...initial, linkedRoots: [] }
const relinked: RuntimeResolution = { ...initial, linkedRoots: [{ ...linked, realPath: linkedB }] }
const key = '@deepseek-ai/dsh-app-boot/profile-resolution'
const previous = getEnvironmentData(key)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(PluginPackages, { resolution: initial })
const expectSelectedPackage = (dir: string, version: string): void => {
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toMatchObject({
name: 'metadata-lib', version, dir,
})
expect(require.resolve('metadata-lib')).toBe(realpathSync.native(join(dir, 'index.cjs')))
}
expectSelectedPackage(runtime, '1.0.0')
expect(getEnvironmentData(key)).toEqual({ resolution: initial })
ctx.pluginPackages.replace(removed)
expectSelectedPackage(local, '2.0.0')
expect(getEnvironmentData(key)).toEqual({ resolution: removed })
ctx.pluginPackages.replace(initial)
expectSelectedPackage(runtime, '1.0.0')
expect(getEnvironmentData(key)).toEqual({ resolution: initial })
ctx.pluginPackages.replace(removed)
expectSelectedPackage(local, '2.0.0')
const removedWorkerData = getEnvironmentData(key)
expect(removedWorkerData).toEqual({ resolution: removed })
expect(() => { ctx.pluginPackages.replace(relinked) }).toThrow(
'profile resolution: relinking "linked-plugin" requires a process restart',
)
expectSelectedPackage(local, '2.0.0')
expect(getEnvironmentData(key)).toBe(removedWorkerData)
await ctx.fiber.dispose()
contexts.pop()
expect(getEnvironmentData(key)).toBe(previous)
})
it('publishes additive generations to the process and future Workers', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-resolution-'))
roots.push(root)
@@ -1876,6 +1876,310 @@ describe('runtime resolution', { concurrent: false }, () => {
return { f, linkedRoot, helper, writeManifest }
}
describe('linked root generations', () => {
const peer = '@deepseek-ai/dsh-core'
function unsavedFixture(): ReturnType<typeof linkedPluginFixture> {
const result = linkedPluginFixture()
file(join(result.f.profile.dir, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true, dependencies: {},
}))
return result
}
function expectPeerLookup(
registration: RuntimeInterception, require: NodeJS.Require, parent: string, selected: string,
): void {
expect(require.resolve(peer)).toBe(join(selected, 'index.cjs'))
expect(resolveFrom(peer, parent)).toBe(pathToFileURL(join(selected, 'index.js')).href)
expect(registration.packageDir(peer, parent)).toBe(selected)
}
it('returns an unlinked importer to native lookup without changing its first generation', async () => {
const { f, linkedRoot } = unsavedFixture()
const link = join(f.profile.dir, 'node_modules', 'my-plugin')
const local = join(linkedRoot, 'node_modules', peer)
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
expect(first.linkedRoots).toEqual([{ name: 'my-plugin', realPath: linkedRoot }])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(link)
const second = await resolutionOf(f)
expect(second.localPackageNames).toEqual([])
expect(second.linkedRoots).toEqual([])
registration.replace(second)
expectPeerLookup(registration, require, parent, local)
expect(first.linkedRoots).toEqual([{ name: 'my-plugin', realPath: linkedRoot }])
} finally {
registration.dispose()
registrations.pop()
}
})
it('reports native misses after unlinking when no physical peer remains', async () => {
const { f, linkedRoot } = unsavedFixture()
rmSync(join(linkedRoot, 'node_modules', peer), { recursive: true })
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(join(f.profile.dir, 'node_modules', 'my-plugin'))
const second = await resolutionOf(f)
expect(second.linkedRoots).toEqual([])
registration.replace(second)
expect(thrownError(() => require.resolve(peer))).toMatchObject({ code: 'MODULE_NOT_FOUND' })
expect(thrownError(() => require(peer))).toMatchObject({ code: 'MODULE_NOT_FOUND' })
expect(thrownError(() => resolveFrom(peer, parent))).toMatchObject({ code: 'ERR_MODULE_NOT_FOUND' })
await expect(importFrom(peer, parent)).rejects.toMatchObject({ code: 'ERR_MODULE_NOT_FOUND' })
expect(registration.packageDir(peer, parent)).toBeUndefined()
} finally {
registration.dispose()
registrations.pop()
}
})
it('restores the same link target and admits an additional root name', async () => {
const { f, linkedRoot } = unsavedFixture()
const link = join(f.profile.dir, 'node_modules', 'my-plugin')
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(link)
registration.replace(await resolutionOf(f))
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(linkedRoot, link, linkType)
registration.replace(await resolutionOf(f))
expectPeerLookup(registration, require, parent, f.installed)
const additional = join(f.root, 'work', 'another-plugin')
pkg(additional, 'another-plugin', 0, {}, { [peer]: '*' })
pkg(join(additional, 'node_modules', peer), peer, 31)
symlinkSync(additional, join(f.profile.dir, 'node_modules', 'another-plugin'), linkType)
const successor = await resolutionOf(f)
expect(successor.localPackageNames).toEqual([])
expect(successor.linkedRoots).toEqual([
{ name: 'another-plugin', realPath: additional },
{ name: 'my-plugin', realPath: linkedRoot },
])
registration.replace(successor)
expectPeerLookup(registration, require, parent, f.installed)
expectPeerLookup(registration, createRequire(join(additional, 'entry.cjs')),
pathToFileURL(join(additional, 'entry.mjs')).href, f.installed)
} finally {
registration.dispose()
registrations.pop()
}
})
it.each(['inner', 'outer'] as const)(
'keeps an overlapping importer proxied after removing the %s root until the last root is removed', async (removed) => {
const { f, linkedRoot } = unsavedFixture()
const innerLink = join(f.profile.dir, 'node_modules', 'my-plugin')
const outer = dirname(linkedRoot)
const outerLink = join(f.profile.dir, 'node_modules', 'workspace')
symlinkSync(outer, outerLink, process.platform === 'win32' ? 'junction' : 'dir')
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
expect(first.linkedRoots).toEqual([
{ name: 'my-plugin', realPath: linkedRoot },
{ name: 'workspace', realPath: outer },
])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(removed === 'inner' ? innerLink : outerLink)
const second = await resolutionOf(f)
expect(second.linkedRoots).toEqual(removed === 'inner'
? [{ name: 'workspace', realPath: outer }]
: [{ name: 'my-plugin', realPath: linkedRoot }])
registration.replace(second)
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(removed === 'inner' ? outerLink : innerLink)
const third = await resolutionOf(f)
expect(third.linkedRoots).toEqual([])
registration.replace(third)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
} finally {
registration.dispose()
registrations.pop()
}
},
)
it('uses native fresh subpaths from a loaded caller while held modules keep their values', async () => {
const { f, linkedRoot } = unsavedFixture()
const local = join(linkedRoot, 'node_modules', peer)
for (const [dir, marker] of [[f.installed, 1], [local, 21]] as const) {
file(join(dir, 'package.json'), JSON.stringify({
name: peer, version: `${String(marker)}.0.0`, type: 'module',
exports: {
'.': { import: './index.js', require: './index.cjs' },
'./fresh': { import: './fresh.js', require: './fresh.cjs' },
},
}))
file(join(dir, 'fresh.js'), `export const marker = ${String(marker)}\n`)
file(join(dir, 'fresh.cjs'), `module.exports = { marker: ${String(marker)} }\n`)
}
// The fresh subpath has no completed Node resolution; root removal does not replace held module references.
file(join(linkedRoot, 'caller.js'), `import * as held from '${peer}'\nexport { held }\n`
+ `export async function fresh() { return await import('${peer}/fresh') }\n`)
file(join(linkedRoot, 'caller.cjs'), `module.exports = { held: require('${peer}'), `
+ `fresh: () => require('${peer}/fresh') }\n`)
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
const cjsCaller = require('./caller.cjs') as { held: { marker: number }; fresh(): { marker: number } }
const esmCaller = await importFrom('./caller.js', parent) as {
held: { marker: number }
fresh(): Promise<{ marker: number }>
}
const heldCjs = cjsCaller.held
const heldEsm = esmCaller.held
expect(heldCjs).toEqual({ marker: 1 })
expect(heldEsm).toMatchObject({ marker: 1 })
unlinkSync(join(f.profile.dir, 'node_modules', 'my-plugin'))
const second = await resolutionOf(f)
expect(second.linkedRoots).toEqual([])
registration.replace(second)
expect(cjsCaller.fresh()).toEqual({ marker: 21 })
await expect(esmCaller.fresh()).resolves.toMatchObject({ marker: 21 })
expect(cjsCaller.held).toBe(heldCjs)
expect(esmCaller.held).toBe(heldEsm)
expect(heldCjs).toEqual({ marker: 1 })
expect(heldEsm).toMatchObject({ marker: 1 })
} finally {
registration.dispose()
registrations.pop()
}
})
it.each(['initial', 'added'] as const)(
'rejects a removed %s root retarget without reactivating either directory', async (generation) => {
const { f, linkedRoot } = unsavedFixture()
const link = join(f.profile.dir, 'node_modules', 'my-plugin')
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
if (generation === 'added') unlinkSync(link)
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
expect(first.linkedRoots).toEqual(generation === 'added'
? [] : [{ name: 'my-plugin', realPath: linkedRoot }])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
if (generation === 'added') {
symlinkSync(linkedRoot, link, linkType)
registration.replace(await resolutionOf(f))
}
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(link)
const removed = await resolutionOf(f)
expect(removed.linkedRoots).toEqual([])
registration.replace(removed)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
const target = join(f.root, 'work', 'retargeted-plugin')
pkg(target, 'my-plugin', 0, {}, { [peer]: '*' })
pkg(join(target, 'node_modules', peer), peer, 22)
symlinkSync(target, link, linkType)
const successor = await resolutionOf(f)
expect(successor.localPackageNames).toEqual([])
expect(successor.linkedRoots).toEqual([{ name: 'my-plugin', realPath: target }])
expect(() => { registration.replace(successor) })
.toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
expectPeerLookup(registration, createRequire(join(target, 'entry.cjs')),
pathToFileURL(join(target, 'entry.mjs')).href, join(target, 'node_modules', peer))
} finally {
registration.dispose()
registrations.pop()
}
},
)
it('does not retain targets from a rejected successor', async () => {
const { f, linkedRoot } = unsavedFixture()
const modules = join(f.profile.dir, 'node_modules')
const originalLink = join(modules, 'my-plugin')
const newLink = join(modules, 'a-new-plugin')
const replacement = join(f.root, 'work', 'replacement')
const rejectedTarget = join(f.root, 'work', 'rejected-target')
const acceptedTarget = join(f.root, 'work', 'accepted-target')
for (const [dir, name, marker] of [
[replacement, 'my-plugin', 22],
[rejectedTarget, 'a-new-plugin', 31],
[acceptedTarget, 'a-new-plugin', 32],
] as const) {
pkg(dir, name, 0, {}, { [peer]: '*' })
pkg(join(dir, 'node_modules', peer), peer, marker)
}
const require = createRequire(join(linkedRoot, 'entry.cjs'))
const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href
const first = await resolutionOf(f)
expect(first.localPackageNames).toEqual([])
const registration = installRuntimeInterception(first)
registrations.push(registration)
try {
expectPeerLookup(registration, require, parent, f.installed)
unlinkSync(originalLink)
const removed = await resolutionOf(f)
expect(removed.linkedRoots).toEqual([])
registration.replace(removed)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(replacement, originalLink, linkType)
symlinkSync(rejectedTarget, newLink, linkType)
const rejected = await resolutionOf(f)
expect(rejected.linkedRoots).toEqual([
{ name: 'a-new-plugin', realPath: rejectedTarget },
{ name: 'my-plugin', realPath: replacement },
])
expect(() => { registration.replace(rejected) })
.toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
expectPeerLookup(registration, createRequire(join(rejectedTarget, 'entry.cjs')),
pathToFileURL(join(rejectedTarget, 'entry.mjs')).href, join(rejectedTarget, 'node_modules', peer))
unlinkSync(originalLink)
unlinkSync(newLink)
symlinkSync(acceptedTarget, newLink, linkType)
const accepted = await resolutionOf(f)
expect(accepted.linkedRoots).toEqual([{ name: 'a-new-plugin', realPath: acceptedTarget }])
registration.replace(accepted)
expectPeerLookup(registration, createRequire(join(acceptedTarget, 'entry.cjs')),
pathToFileURL(join(acceptedTarget, 'entry.mjs')).href, f.installed)
expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer))
} finally {
registration.dispose()
registrations.pop()
}
})
})
it.each(['src', 'dist'] as const)(
'keeps intermediate %s/node_modules ahead of linked-root peers and ancestor packages', async (directory) => {
const { f, linkedRoot } = linkedPluginFixture()