Skip to content

Commit 86187d6

Browse files
bodia-uzclaude
andcommitted
Add getUnreadyEntryPointsReport debug util reporting all root unready APIs
The previous getRootUnreadyAPI walk was structurally lossy: it started from an arbitrary first unready entry point, abandoned sibling dependency branches when descending into a declarer, could loop forever on dependency cycles, and returned at most one API when real failures often have several independent roots. getUnreadyEntryPointsReport computes the full picture with set math instead: - all root unready APIs (a dependency of an unready entry point that is neither ready nor declared by another unready entry point), each categorized as declared-by-nobody (missing package/pact) or declared by an installed shell whose contribution never completed - which entry points are directly blocked on each root - a probable-dependency-cycle flag for hosts running with circular-dependency validation disabled getRootUnreadyAPI is kept as a deprecated shim over the report, which also makes it cycle-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 27738f7 commit 86187d6

4 files changed

Lines changed: 232 additions & 45 deletions

File tree

packages/repluggable-core/src/repluggableAppDebug/debug.d.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,25 @@ export interface DependencyTree {
2323
deps: { api: string; subtree: DependencyTree | null }[]
2424
}
2525

26+
export interface RootUnreadyAPIInfo {
27+
key: AnySlotKey
28+
declaredBy: 'nobody' | { entryPointName: string }
29+
requiredBy: string[]
30+
}
31+
32+
export interface UnreadyEntryPointsReport {
33+
unreadyEntryPoints: EntryPoint[]
34+
rootUnreadyAPIs: RootUnreadyAPIInfo[]
35+
rootUnreadyEntryPoints: EntryPoint[]
36+
probableDependencyCycle: boolean
37+
}
38+
2639
export interface RepluggableDebugUtils {
2740
apis(): APIDebugInfo[]
2841
unReadyEntryPoints(): EntryPoint[]
29-
getRootUnreadyAPI(): SlotKey<any>
42+
getUnreadyEntryPointsReport(): UnreadyEntryPointsReport
43+
/** @deprecated use getUnreadyEntryPointsReport() - this returns only the first of possibly many root unready APIs */
44+
getRootUnreadyAPI(): SlotKey<any> | undefined
3045
whyEntryPointUnready(name: string): void
3146
findAPI(name: string): APIDebugInfo[]
3247
getAPIOrEntryPointsDependencies(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { setupDebugInfo } from './repluggableAppDebug'
1+
export { setupDebugInfo, RootUnreadyAPIInfo, UnreadyEntryPointsReport } from './repluggableAppDebug'

packages/repluggable-core/src/repluggableAppDebug/repluggableAppDebug.ts

Lines changed: 65 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -36,54 +36,74 @@ function mapApiToEntryPoint(allPackages: EntryPoint[]) {
3636
return apiToEntryPoint
3737
}
3838

39-
/**
40-
* a function that returns all the entry points in the system with their declared APIs and dependencies
41-
*/
42-
const getAllEntryPoints = () => {
43-
return [
44-
...globalThis.repluggableAppDebug.utils.unReadyEntryPoints(),
45-
...[...globalThis.repluggableAppDebug.addedShells].map(([_, shell]) => shell.entryPoint)
46-
]
39+
export interface RootUnreadyAPIInfo {
40+
key: AnySlotKey
41+
/**
42+
* 'nobody' -> not declared by any entry point in the host (missing package / pact)
43+
* otherwise -> declared by an INSTALLED shell whose contributeAPI never completed (hung async contribution)
44+
*/
45+
declaredBy: 'nobody' | { entryPointName: string }
46+
/** names of unready entry points directly blocked on this API */
47+
requiredBy: string[]
48+
}
49+
50+
export interface UnreadyEntryPointsReport {
51+
unreadyEntryPoints: EntryPoint[]
52+
rootUnreadyAPIs: RootUnreadyAPIInfo[]
53+
/** unready entry points directly blocked on a root unready API */
54+
rootUnreadyEntryPoints: EntryPoint[]
55+
/** true when entry points are unready but no root unready API exists - their dependencies form a cycle */
56+
probableDependencyCycle: boolean
4757
}
4858

59+
type UnreadyReportParams = Pick<SetupDebugInfoParams, 'readyAPIs' | 'getOwnSlotKey' | 'getUnreadyEntryPoints' | 'addedShells'>
60+
4961
/**
50-
* this function is used to get the root unready API in case there are too many to understand.
51-
* for example if you have 200 unready entry points, running this function will give you the first unready API that
52-
* will unblock the rest of the entry point (note that there might be more than one)
53-
*
54-
* this function basically takes the first unready entry point, get its dependencies and iterates over them to find an API that is not ready
55-
* at this point it follows the same process recursively until it reaced the target API.
62+
* Computes all root unready APIs at once: dependencies of unready entry points that are neither ready
63+
* nor declared by another unready entry point (i.e. the APIs actually blocking the host from loading).
5664
*/
57-
const getRootUnreadyAPI = (host: AppHost) => {
58-
return () => {
59-
// get all unready entry points
60-
const allEntryPoints = getAllEntryPoints()
61-
const unReadyAPIsArray = []
62-
// get the depdenencies of the first unready entry point
63-
let dependenciesOfUnreadyEntryPoint = allEntryPoints?.[0]?.getDependencyAPIs?.()
64-
65-
while (dependenciesOfUnreadyEntryPoint?.length) {
66-
const currentAPI = dependenciesOfUnreadyEntryPoint.pop()
67-
68-
if (!currentAPI) {
69-
continue
70-
}
71-
// try to get the API from this host, we are looking for an API that is not ready
72-
try {
73-
const api = host.getAPI(currentAPI as SlotKey<any>)
74-
if (api) {
75-
continue
65+
const createGetUnreadyEntryPointsReport = ({ readyAPIs, getOwnSlotKey, getUnreadyEntryPoints, addedShells }: UnreadyReportParams) => {
66+
return (): UnreadyEntryPointsReport => {
67+
const unreadyEntryPoints = getUnreadyEntryPoints()
68+
const isReady = (key: AnySlotKey) => readyAPIs.has(getOwnSlotKey(key as SlotKey<any>))
69+
70+
const declaredByUnready = new Set<string>()
71+
unreadyEntryPoints.forEach(entryPoint => entryPoint.declareAPIs?.().forEach(key => declaredByUnready.add(key.name)))
72+
73+
const declaredByInstalled = new Map<string, string>()
74+
for (const shell of addedShells.values()) {
75+
shell.entryPoint.declareAPIs?.().forEach(key => declaredByInstalled.set(key.name, shell.entryPoint.name))
76+
}
77+
78+
const rootByName = new Map<string, RootUnreadyAPIInfo>()
79+
unreadyEntryPoints.forEach(entryPoint => {
80+
entryPoint.getDependencyAPIs?.().forEach(key => {
81+
if (isReady(key) || declaredByUnready.has(key.name)) {
82+
return
7683
}
77-
} catch (e) {
78-
unReadyAPIsArray.push(currentAPI)
79-
// we found an API that is unready, lets find which entry point declares it
80-
const declarer = allEntryPoints.find(entryPointData =>
81-
entryPointData.declareAPIs?.().some(api => currentAPI?.name === api.name)
82-
)
83-
dependenciesOfUnreadyEntryPoint = declarer?.getDependencyAPIs?.()
84-
}
84+
const declarerName = declaredByInstalled.get(key.name)
85+
const info = rootByName.get(key.name) ?? {
86+
key,
87+
declaredBy: declarerName ? { entryPointName: declarerName } : ('nobody' as const),
88+
requiredBy: []
89+
}
90+
info.requiredBy.push(entryPoint.name)
91+
rootByName.set(key.name, info)
92+
})
93+
})
94+
95+
const rootUnreadyAPIs = [...rootByName.values()]
96+
const rootAPINames = new Set(rootByName.keys())
97+
const rootUnreadyEntryPoints = unreadyEntryPoints.filter(entryPoint =>
98+
entryPoint.getDependencyAPIs?.().some(key => rootAPINames.has(key.name))
99+
)
100+
101+
return {
102+
unreadyEntryPoints,
103+
rootUnreadyAPIs,
104+
rootUnreadyEntryPoints,
105+
probableDependencyCycle: unreadyEntryPoints.length > 0 && rootUnreadyAPIs.length === 0
85106
}
86-
return unReadyAPIsArray.reverse()[0]
87107
}
88108
}
89109

@@ -207,7 +227,9 @@ export function setupDebugInfo({
207227
}
208228
})
209229
},
210-
getRootUnreadyAPI: getRootUnreadyAPI(host),
230+
getUnreadyEntryPointsReport: createGetUnreadyEntryPointsReport({ readyAPIs, getOwnSlotKey, getUnreadyEntryPoints, addedShells }),
231+
/** @deprecated use getUnreadyEntryPointsReport() - this returns only the first of possibly many root unready APIs */
232+
getRootUnreadyAPI: (): AnySlotKey | undefined => utils.getUnreadyEntryPointsReport().rootUnreadyAPIs[0]?.key,
211233
unReadyEntryPoints: (): EntryPoint[] => getUnreadyEntryPoints(),
212234
whyEntryPointUnready: (name: string) => {
213235
const unreadyEntryPoint = _.find(

packages/repluggable-core/test/repluggableAppDebug.spec.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,154 @@ describe('RepluggableAppDebug', () => {
245245
expect(unreadyAPIs).toEqual({ name: 'unreadyAPI' })
246246
})
247247
})
248+
249+
describe('getUnreadyEntryPointsReport', () => {
250+
const getReport = async () => {
251+
await new Promise(resolve => setTimeout(resolve, 0))
252+
return globalThis.repluggableAppDebug.utils.getUnreadyEntryPointsReport()
253+
}
254+
255+
it('should report no unready entry points when all are loaded', async () => {
256+
createAppHost([
257+
{
258+
name: 'entryPoint',
259+
declareAPIs: () => []
260+
}
261+
])
262+
263+
const report = await getReport()
264+
265+
expect(report).toEqual({
266+
unreadyEntryPoints: [],
267+
rootUnreadyAPIs: [],
268+
rootUnreadyEntryPoints: [],
269+
probableDependencyCycle: false
270+
})
271+
})
272+
273+
it('should report all root unready APIs of independent unready clusters', async () => {
274+
createAppHost([
275+
{
276+
name: 'entryPoint A',
277+
getDependencyAPIs: () => [{ name: 'missing A' }]
278+
},
279+
{
280+
name: 'entryPoint B',
281+
getDependencyAPIs: () => [{ name: 'missing B' }]
282+
}
283+
])
284+
285+
const report = await getReport()
286+
287+
expect(report.rootUnreadyAPIs).toEqual([
288+
{ key: { name: 'missing A' }, declaredBy: 'nobody', requiredBy: ['entryPoint A'] },
289+
{ key: { name: 'missing B' }, declaredBy: 'nobody', requiredBy: ['entryPoint B'] }
290+
])
291+
expect(report.rootUnreadyEntryPoints.map(entryPoint => entryPoint.name)).toEqual(['entryPoint A', 'entryPoint B'])
292+
expect(report.probableDependencyCycle).toBe(false)
293+
})
294+
295+
it('should report only the root API of a transitively blocked chain', async () => {
296+
createAppHost([
297+
{
298+
name: 'entryPoint A',
299+
getDependencyAPIs: () => [{ name: 'API B' }]
300+
},
301+
{
302+
name: 'entryPoint B',
303+
declareAPIs: () => [{ name: 'API B' }],
304+
getDependencyAPIs: () => [unreadAPI]
305+
}
306+
])
307+
308+
const report = await getReport()
309+
310+
expect(report.rootUnreadyAPIs).toEqual([{ key: unreadAPI, declaredBy: 'nobody', requiredBy: ['entryPoint B'] }])
311+
expect(report.rootUnreadyEntryPoints.map(entryPoint => entryPoint.name)).toEqual(['entryPoint B'])
312+
expect(report.unreadyEntryPoints.map(entryPoint => entryPoint.name)).toEqual(['entryPoint A', 'entryPoint B'])
313+
})
314+
315+
it('should report roots of sibling branches, including APIs declared by an installed shell that never contributed them', async () => {
316+
createAppHost([
317+
{
318+
name: 'entryPoint 0',
319+
getDependencyAPIs: () => [{ name: 'missing API' }, { name: 'API B' }]
320+
},
321+
{
322+
name: 'entryPoint 1',
323+
declareAPIs: () => [{ name: 'API B' }]
324+
}
325+
])
326+
327+
const report = await getReport()
328+
329+
expect(report.rootUnreadyAPIs).toEqual([
330+
{ key: { name: 'missing API' }, declaredBy: 'nobody', requiredBy: ['entryPoint 0'] },
331+
{ key: { name: 'API B' }, declaredBy: { entryPointName: 'entryPoint 1' }, requiredBy: ['entryPoint 0'] }
332+
])
333+
})
334+
335+
it('should aggregate all entry points blocked on the same root API', async () => {
336+
createAppHost([
337+
{
338+
name: 'entryPoint A',
339+
getDependencyAPIs: () => [unreadAPI]
340+
},
341+
{
342+
name: 'entryPoint B',
343+
getDependencyAPIs: () => [unreadAPI]
344+
}
345+
])
346+
347+
const report = await getReport()
348+
349+
expect(report.rootUnreadyAPIs).toEqual([{ key: unreadAPI, declaredBy: 'nobody', requiredBy: ['entryPoint A', 'entryPoint B'] }])
350+
})
351+
352+
it('should flag a probable dependency cycle when unready entry points have no root unready API', async () => {
353+
createAppHost(
354+
[
355+
{
356+
name: 'entryPoint A',
357+
declareAPIs: () => [{ name: 'API X' }],
358+
getDependencyAPIs: () => [{ name: 'API Y' }]
359+
},
360+
{
361+
name: 'entryPoint B',
362+
declareAPIs: () => [{ name: 'API Y' }],
363+
getDependencyAPIs: () => [{ name: 'API X' }]
364+
}
365+
],
366+
{ monitoring: {}, disableCheckCircularDependencies: true }
367+
)
368+
369+
const report = await getReport()
370+
371+
expect(report.rootUnreadyAPIs).toEqual([])
372+
expect(report.unreadyEntryPoints.map(entryPoint => entryPoint.name)).toEqual(['entryPoint A', 'entryPoint B'])
373+
expect(report.probableDependencyCycle).toBe(true)
374+
})
375+
376+
it('should not hang getRootUnreadyAPI on a dependency cycle', async () => {
377+
createAppHost(
378+
[
379+
{
380+
name: 'entryPoint A',
381+
declareAPIs: () => [{ name: 'API X' }],
382+
getDependencyAPIs: () => [{ name: 'API Y' }]
383+
},
384+
{
385+
name: 'entryPoint B',
386+
declareAPIs: () => [{ name: 'API Y' }],
387+
getDependencyAPIs: () => [{ name: 'API X' }]
388+
}
389+
],
390+
{ monitoring: {}, disableCheckCircularDependencies: true }
391+
)
392+
393+
const unreadyAPIs = await getUnreadyAPIs()
394+
395+
expect(unreadyAPIs).toBeUndefined()
396+
}, 3000)
397+
})
248398
})

0 commit comments

Comments
 (0)