|
|
@@ -407,6 +407,69 @@ function ensureModuleProxy(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+type ModuleFallbackEntry =
|
|
|
+ | { kind: 'symlink'; packageName: string; packageDir: string }
|
|
|
+ | { kind: 'proxy'; packageName: string; version: string; targets: Record<string, string> }
|
|
|
+
|
|
|
+/** Resolve the installation generation that every profile must find through the fallback directory. */
|
|
|
+function resolveModuleFallbackEntries(installAnchor: string): ModuleFallbackEntry[] {
|
|
|
+ const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
|
|
+ const links = new Map<string, string>()
|
|
|
+ /* v8 ignore next -- a real app manifest always declares its name */
|
|
|
+ if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
|
|
|
+ // BFS over the resolvable dependency graph; the visited set is the link
|
|
|
+ // map itself (first resolution wins, matching Node's own nearest-wins).
|
|
|
+ const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
|
|
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
|
+ // Peer dependencies participate: Service Definition packages (dsh-subprocess,
|
|
|
+ // dsh-compaction, ...) are peers of their implementations, never plain
|
|
|
+ // dependencies, yet out-of-tree plugins import them directly.
|
|
|
+ /* v8 ignore next -- a real app manifest always declares dependencies */
|
|
|
+ for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
|
|
|
+ if (links.has(dep)) continue
|
|
|
+ const dir = packageDirFromAnchor(next.anchor, dep)
|
|
|
+ // A declared-but-uninstalled dependency cannot be a loader-visible
|
|
|
+ // plugin; skip it rather than fail the whole boot.
|
|
|
+ if (dir === undefined) continue
|
|
|
+ links.set(dep, dir)
|
|
|
+ const manifestPath = join(dir, 'package.json')
|
|
|
+ queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!isPackagedExecutable()) {
|
|
|
+ return [...links].map(([packageName, packageDir]) => ({ kind: 'symlink', packageName, packageDir }))
|
|
|
+ }
|
|
|
+ return [...links].flatMap(([packageName, packageDir]) => {
|
|
|
+ const source = packageProxySource(packageName, packageDir)
|
|
|
+ return Object.keys(source.targets).length === 0
|
|
|
+ ? []
|
|
|
+ : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** Return whether one existing fallback entry already matches its resolved installation generation. */
|
|
|
+function moduleFallbackEntryCurrent(modulesDir: string, entry: ModuleFallbackEntry): boolean {
|
|
|
+ const link = join(modulesDir, entry.packageName)
|
|
|
+ try {
|
|
|
+ const stat = lstatSync(link)
|
|
|
+ if (entry.kind === 'symlink') {
|
|
|
+ return stat.isSymbolicLink() && readlinkSync(link) === entry.packageDir
|
|
|
+ }
|
|
|
+ if (!stat.isDirectory()) return false
|
|
|
+ const existing = readModuleProxyRecord(link)
|
|
|
+ return existing?.version === entry.version
|
|
|
+ && JSON.stringify(existing.dsh?.moduleFallback?.targets) === JSON.stringify(entry.targets)
|
|
|
+ && Object.keys(entry.targets).every((_, index) => existsSync(join(link, `entry-${index}.js`)))
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Return whether every required fallback entry is already ready for this installation. */
|
|
|
+function moduleFallbackCurrent(modulesDir: string, entries: readonly ModuleFallbackEntry[]): boolean {
|
|
|
+ return entries.every(entry => moduleFallbackEntryCurrent(modulesDir, entry))
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
|
|
|
* entry per package in the dsh app's resolvable dependency CLOSURE (BFS
|
|
|
@@ -415,8 +478,9 @@ function ensureModuleProxy(
|
|
|
* exports under ESM import conditions and writes small proxy packages because
|
|
|
* the host filesystem cannot follow a symlink into pkg's virtual `/snapshot`
|
|
|
* tree; the proxy re-exports the virtual URL, preserving the executable's
|
|
|
- * single module instance. One cross-process writer lock prevents partial
|
|
|
- * proxies and serializes carrier transitions. Node's
|
|
|
+ * single module instance. A complete matching generation returns without a
|
|
|
+ * writer lock; actual repairs acquire and recheck one cross-process lock so
|
|
|
+ * partial proxies and carrier transitions remain serialized. Node's
|
|
|
* parent-directory walk from any profile finds this
|
|
|
* directory after the profile's own `node_modules`, so every in-box plugin
|
|
|
* resolves without pnpm ever managing it — the exact "bundles come from the
|
|
|
@@ -431,53 +495,29 @@ function ensureModuleProxy(
|
|
|
* reused because resolution cannot discover it.
|
|
|
* @param installAnchor - absolute path of the dsh app's package.json.
|
|
|
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
|
|
- * @returns settlement after the locked fallback generation is complete.
|
|
|
+ * @returns settlement after current-state validation or a locked repair.
|
|
|
*/
|
|
|
export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise<void> {
|
|
|
const profilesDir = join(home, PROFILES_DIR)
|
|
|
const modulesDir = join(profilesDir, 'node_modules')
|
|
|
mkdirSync(modulesDir, { recursive: true })
|
|
|
+ const entries = resolveModuleFallbackEntries(installAnchor)
|
|
|
+ if (moduleFallbackCurrent(modulesDir, entries)) return
|
|
|
await withFileLock(modulesDir, () => {
|
|
|
- healProfilesModuleFallbackLocked(installAnchor, modulesDir)
|
|
|
+ if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir)
|
|
|
return Promise.resolve()
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/** Heal one module-fallback generation while the cross-process writer lock is held. */
|
|
|
-function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void {
|
|
|
- const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
|
|
- const links = new Map<string, string>()
|
|
|
- /* v8 ignore next -- a real app manifest always declares its name */
|
|
|
- if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
|
|
|
- // BFS over the resolvable dependency graph; the visited set is the link
|
|
|
- // map itself (first resolution wins, matching Node's own nearest-wins).
|
|
|
- const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
|
|
- for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
|
- // Peer dependencies participate: Service Definition packages (dsh-subprocess,
|
|
|
- // dsh-compaction, ...) are peers of their implementations, never plain
|
|
|
- // dependencies, yet out-of-tree plugins import them directly.
|
|
|
- /* v8 ignore next -- a real app manifest always declares dependencies */
|
|
|
- for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
|
|
|
- if (links.has(dep)) continue
|
|
|
- const dir = packageDirFromAnchor(next.anchor, dep)
|
|
|
- // A declared-but-uninstalled dependency cannot be a loader-visible
|
|
|
- // plugin; skip it rather than fail the whole boot.
|
|
|
- if (dir === undefined) continue
|
|
|
- links.set(dep, dir)
|
|
|
- const manifestPath = join(dir, 'package.json')
|
|
|
- queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
|
|
|
- }
|
|
|
- }
|
|
|
- for (const [packageName, target] of links) {
|
|
|
- const link = join(modulesDir, packageName)
|
|
|
+function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[], modulesDir: string): void {
|
|
|
+ for (const entry of entries) {
|
|
|
+ const link = join(modulesDir, entry.packageName)
|
|
|
mkdirSync(dirname(link), { recursive: true })
|
|
|
- if (isPackagedExecutable()) {
|
|
|
- const source = packageProxySource(packageName, target)
|
|
|
- if (Object.keys(source.targets).length > 0) {
|
|
|
- ensureModuleProxy(link, packageName, source.version, source.targets)
|
|
|
- }
|
|
|
+ if (entry.kind === 'proxy') {
|
|
|
+ ensureModuleProxy(link, entry.packageName, entry.version, entry.targets)
|
|
|
} else {
|
|
|
- ensureSymlink(link, target)
|
|
|
+ ensureSymlink(link, entry.packageDir)
|
|
|
}
|
|
|
}
|
|
|
}
|