Skip to content

Commit 7f00fdb

Browse files
authored
fix(isolator): isolate cyclic dependencies of seeders to avoid registry fetch (#10449)
## Summary Fixes the circular-dependency build failure seen consistently on Ripple, where the build tries to install a cycle member from the registry that isn't published yet: ``` GET https://node-registry.bit.cloud/@<scope>%2Fcomp1: Not Found - 404 @<scope>/comp1@0.0.0-<hash> is not in the npm registry (ERR_PNPM_FETCH_404) ``` ### Root cause `bit sign` isolates components in **`seedersOnly`** mode — only the components being signed get capsules; every *dependency* is installed as an npm package from the registry. That breaks for a dependency in a **cycle** with a seeder: the cyclic component references back into a seeder whose snap-version package was never published, so the capsule install 404s. It only reproduces on a real hub/lane (locally, remotes are registered and workspace components link locally), which is why it's Ripple-specific. ### Fix (`scopes/component/isolator/isolator.main.runtime.ts`) - **seedersOnly path** — new `getCyclicDependenciesOfSeeders`: build the seeder graph, use `graph.findCycles()` (the graph is seeded, so this returns seeder-involving cycles), and pull those cyclic dependency components into `componentsToIsolate` so they become real capsules linked locally instead of fetched. - **non-seedersOnly path** (`bit build`) — `filterUnmodifiedExportedDependencies` now takes `idsInCycle` and never excludes a component that's part of a seeder cycle from capsule creation, closing the same gap for built+exported cyclic deps. ## Test plan - [x] `npm run lint` (tsc + oxlint) clean. - [x] Reproduced via the sign-from-scope flow (lane, `comp1 ↔ comp2` cycle, snap both pending + export, sign one alone). **Without** the fix: `ERR_PNPM_FETCH_404` on the unpublished cyclic snap. **With** the fix: signs `succeed`, cyclic dep isolated as a capsule and linked locally. - [x] Regression test added to the `sign` command spec ("signing one member of a circular dependency while the other is still pending"). The `sign` command lives in its own repo, so that test is committed there, not in this PR. ## Notes `getCyclicDependenciesOfSeeders` builds the seeder graph on every seedersOnly isolation. It's cheap relative to the full sign (capsule writes + install + tasks), but can be gated behind a lighter pre-check if preferred.
1 parent 1a75bd9 commit 7f00fdb

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect } from 'chai';
2+
import { Helper } from '@teambit/legacy.e2e-helper';
3+
4+
describe('isolating cyclic dependencies', function () {
5+
this.timeout(0);
6+
let helper: Helper;
7+
before(() => {
8+
helper = new Helper();
9+
});
10+
after(() => {
11+
helper.scopeHelper.destroy();
12+
});
13+
14+
// Regression for the Ripple circular-dependency build failure. Seeders-only isolation (used by
15+
// `bit sign` and tag/snap-from-scope) installs non-seeder dependencies as packages from the
16+
// registry. A dependency that is in a cycle with a seeder can't be installed that way — its
17+
// snap-version isn't published — so the isolator must pull it into the isolation as a capsule.
18+
describe('two components requiring each other, isolated seeders-only', () => {
19+
let capsuleIds: string[];
20+
before(() => {
21+
helper.scopeHelper.setWorkspaceWithRemoteScope();
22+
helper.fs.outputFile('comp1/index.js', `require('@${helper.scopes.remote}/comp2');`);
23+
helper.fs.outputFile('comp2/index.js', `require('@${helper.scopes.remote}/comp1');`);
24+
helper.command.addComponent('comp1');
25+
helper.command.addComponent('comp2');
26+
helper.command.install();
27+
helper.command.tagAllWithoutBuild('--ignore-issues="CircularDependencies"');
28+
const output = helper.command.runCmd('bit capsule create comp2 --seeders-only -j');
29+
capsuleIds = JSON.parse(output).map((capsule: { id: string }) => capsule.id.split('@')[0]);
30+
});
31+
it('should isolate the cyclic dependency as a capsule, not only the seeder', () => {
32+
expect(capsuleIds).to.include(`${helper.scopes.remote}/comp1`);
33+
expect(capsuleIds).to.include(`${helper.scopes.remote}/comp2`);
34+
});
35+
});
36+
});

scopes/component/isolator/isolator.main.runtime.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,21 @@ export class IsolatorMain {
365365
)}`
366366
);
367367
const createGraphOpts = pick(opts, ['includeFromNestedHosts', 'host']);
368-
const componentsToIsolate = opts.seedersOnly
368+
let componentsToIsolate = opts.seedersOnly
369369
? await host.getMany(seeders)
370370
: await this.createGraph(seeders, createGraphOpts);
371+
// seedersOnly skips the dependency graph and installs deps as packages from the registry.
372+
// A dependency in a cycle with a seeder can't be installed that way (its snap-version isn't
373+
// published yet), so pull it into the isolation as a capsule. Skip this for aspect isolation:
374+
// aspects are loaded from already-published packages and pulling cyclic aspect deps in only
375+
// forces loading envs that aren't installed in that context.
376+
if (opts.seedersOnly && !opts.context?.aspects) {
377+
const cyclicDeps = await this.getCyclicDependenciesOfSeeders(seeders, componentsToIsolate, createGraphOpts);
378+
if (cyclicDeps.length) {
379+
this.logger.debug(`isolateComponents, adding ${cyclicDeps.length} cyclic dependencies of the seeders`);
380+
componentsToIsolate = [...componentsToIsolate, ...cyclicDeps];
381+
}
382+
}
371383
this.logger.debug(`isolateComponents, total componentsToIsolate: ${componentsToIsolate.length}`);
372384
const seedersWithVersions = seeders.map((seeder) => {
373385
if (seeder._legacy.hasVersion()) return seeder;
@@ -433,6 +445,40 @@ export class IsolatorMain {
433445
return filteredComps;
434446
}
435447

448+
/**
449+
* When isolating seeders-only (e.g. `bit sign`), dependencies are normally installed as packages
450+
* from the registry rather than getting a capsule. That breaks for a dependency that is part of a
451+
* dependency cycle with a seeder: the cyclic component references back into a seeder whose
452+
* snap-version package was never published, so the capsule install tries to fetch an unpublished
453+
* snap from the registry and fails (the Ripple circular-dependency build failure). Return the
454+
* dependency components that participate in a cycle with any seeder so they can be added to the
455+
* isolation as real capsules and linked locally instead of installed from the registry.
456+
*/
457+
private async getCyclicDependenciesOfSeeders(
458+
seeders: ComponentID[],
459+
alreadyIsolated: Component[],
460+
opts: CreateGraphOptions = {}
461+
): Promise<Component[]> {
462+
const host = opts.host || this.componentAspect.getHost();
463+
const getGraphOpts = pick(opts, ['host']);
464+
const graph = await this.graph.getGraphIds(seeders, getGraphOpts);
465+
// the graph only spans the seeders and their dependencies, so any cycle in it is a cycle a
466+
// seeder participates in. Pass includeDeps=true so detection doesn't rely on matching the
467+
// (possibly versionless) seeder ids against the graph's versioned node ids — otherwise a
468+
// seeder-involving cycle can be missed and its members get installed from the registry again.
469+
const cyclicIds = new Set<string>(graph.findCycles(undefined, true).flat());
470+
if (cyclicIds.size === 0) return [];
471+
const alreadyIsolatedIds = new Set(alreadyIsolated.map((comp) => comp.id.toString()));
472+
const idsToAdd = [...cyclicIds]
473+
.filter((idStr) => !alreadyIsolatedIds.has(idStr))
474+
.map((idStr) => graph.node(idStr)?.attr)
475+
.filter((id): id is ComponentID => id != null);
476+
if (idsToAdd.length === 0) return [];
477+
// The ids come straight from the graph we just built, so their objects are already present in
478+
// the host — getMany loads them without hitting the network.
479+
return host.getMany(idsToAdd);
480+
}
481+
436482
private registerMoveCapsuleOnProcessExit(
437483
datedCapsuleDir: string,
438484
targetCapsuleDir: string,

0 commit comments

Comments
 (0)