Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions cli/src/commands/router/commands/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
import Table from 'cli-table3';
import { FederationSuccess, ROUTER_COMPATIBILITY_VERSION_ONE } from '@wundergraph/composition';
import { BaseCommandOptions } from '../../../core/types/types.js';
import { composeSubgraphs, introspectSubgraph } from '../../../utils.js';
import { composeSubgraphs, introspectSubgraph, wrapText } from '../../../utils.js';
import { TABLE_CONTENT_WIDTH } from '../../../wrap-text.js';

const STATIC_SCHEMA_VERSION_ID = '00000000-0000-0000-0000-000000000000';

Expand Down Expand Up @@ -218,14 +219,13 @@ export default (opts: BaseCommandOptions) => {
const compositionErrorsTable = new Table({
head: [pc.bold(pc.white('ERROR_MESSAGE'))],
colWidths: [120],
wordWrap: true,
});

console.log(
pc.red(`We found composition errors, while composing.\n${pc.bold('Please check the errors below:')}`),
);
for (const compositionError of result.errors) {
compositionErrorsTable.push([compositionError.message]);
compositionErrorsTable.push([wrapText(compositionError.message, TABLE_CONTENT_WIDTH)]);
}
console.log(compositionErrorsTable.toString());
process.exitCode = 1;
Expand All @@ -236,12 +236,11 @@ export default (opts: BaseCommandOptions) => {
const compositionWarningsTable = new Table({
head: [pc.bold(pc.white('WARNING_MESSAGE'))],
colWidths: [120],
wordWrap: true,
});

console.log(pc.yellow(`The following warnings were produced while composing:`));
for (const warning of result.warnings) {
compositionWarningsTable.push([warning.message]);
compositionWarningsTable.push([wrapText(warning.message, TABLE_CONTENT_WIDTH)]);
}
console.log(compositionWarningsTable.toString());
}
Expand Down Expand Up @@ -601,7 +600,6 @@ async function buildFeatureFlagsConfig(
const compositionErrorsTable = new Table({
head: [pc.bold(pc.white('ERROR_MESSAGE'))],
colWidths: [120],
wordWrap: true,
});

console.log(
Expand All @@ -612,7 +610,7 @@ async function buildFeatureFlagsConfig(
),
);
for (const compositionError of featureResult.errors) {
compositionErrorsTable.push([compositionError.message]);
compositionErrorsTable.push([wrapText(compositionError.message, TABLE_CONTENT_WIDTH)]);
}
console.log(compositionErrorsTable.toString());
continue;
Expand All @@ -622,14 +620,13 @@ async function buildFeatureFlagsConfig(
const compositionWarningsTable = new Table({
head: [pc.bold(pc.white('WARNING_MESSAGE'))],
colWidths: [120],
wordWrap: true,
});

console.log(
pc.yellow(`The following warnings were produced while composing the feature flag ${pc.italic(ff.name)}:`),
);
for (const warning of featureResult.warnings) {
compositionWarningsTable.push([warning.message]);
compositionWarningsTable.push([wrapText(warning.message, TABLE_CONTENT_WIDTH)]);
}
console.log(compositionWarningsTable.toString());
}
Expand Down
11 changes: 5 additions & 6 deletions cli/src/handle-composition-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
DeploymentError,
} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { SubgraphCommandJsonOutput } from './core/types/types.js';
import { wrapText } from './utils.js';
import { TABLE_CONTENT_WIDTH } from './wrap-text.js';

export const handleCompositionResult = ({
responseCode,
Expand Down Expand Up @@ -88,7 +90,6 @@ export const handleCompositionResult = ({
pc.bold(pc.white('ERROR_MESSAGE')),
],
colWidths: [30, 30, 30, 120],
wordWrap: true,
});

console.log(pc.yellow(subgraphCompositionDetailedErrorMessage));
Expand All @@ -97,7 +98,7 @@ export const handleCompositionResult = ({
compositionError.federatedGraphName,
compositionError.namespace,
compositionError.featureFlag || '-',
compositionError.message,
wrapText(compositionError.message, TABLE_CONTENT_WIDTH),
]);
}
// Don't exit here with 1 because the change was still applied
Expand Down Expand Up @@ -131,14 +132,13 @@ export const handleCompositionResult = ({
pc.bold(pc.white('ERROR_MESSAGE')),
],
colWidths: [30, 30, 120],
wordWrap: true,
});

for (const deploymentError of deploymentErrors) {
deploymentErrorsTable.push([
deploymentError.federatedGraphName,
deploymentError.namespace,
deploymentError.message,
wrapText(deploymentError.message, TABLE_CONTENT_WIDTH),
]);
}
// Don't exit here with 1 because the change was still applied
Expand Down Expand Up @@ -184,7 +184,6 @@ export const handleCompositionResult = ({
pc.bold(pc.white('WARNING_MESSAGE')),
],
colWidths: [30, 30, 30, 120],
wordWrap: true,
});

console.log(pc.yellow(`The following warnings were produced while composing the federated graph:`));
Expand All @@ -193,7 +192,7 @@ export const handleCompositionResult = ({
compositionWarning.federatedGraphName,
compositionWarning.namespace,
compositionWarning.featureFlag || '-',
compositionWarning.message,
wrapText(compositionWarning.message, TABLE_CONTENT_WIDTH),
]);
}
console.log(compositionWarningsTable.toString());
Expand Down
2 changes: 2 additions & 0 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export const introspectSubgraph = async ({
};
};

export { wrapText } from './wrap-text.js';

/**
* Composes a list of subgraphs into a single schema.
*/
Expand Down
33 changes: 33 additions & 0 deletions cli/src/wrap-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Content width for a 120-char cli-table3 column: 120 total - 2 padding (1 left + 1 right) - 2 borders
export const TABLE_CONTENT_WIDTH = 116;

/**
* Wraps text to fit within a given width, breaking on word boundaries.
* This avoids cli-table3's built-in wordWrap which can deadlock due to
* expensive string-width/emoji-regex evaluations on large inputs (#2619).
*/
export function wrapText(text: string, maxWidth: number): string {
const lines: string[] = [];
for (const paragraph of text.split('\n')) {
if (paragraph.length === 0) {
lines.push('');
continue;
}
const words = paragraph.split(/\s+/).filter((w) => w.length > 0);
let currentLine = '';
for (const word of words) {
if (currentLine.length === 0) {
currentLine = word;
} else if (currentLine.length + 1 + word.length <= maxWidth) {
currentLine += ' ' + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
return lines.join('\n');
}
78 changes: 78 additions & 0 deletions cli/test/compose-error-table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, test, expect } from 'vitest';
import Table from 'cli-table3';
import { wrapText, TABLE_CONTENT_WIDTH } from '../src/wrap-text.js';

// Regression test for https://github.com/wundergraph/cosmo/issues/2619
//
// The bug: cli-table3's wordWrap option uses string-width → emoji-regex for every
// word, which grows super-linearly with text volume. With large composition errors
// (~9 MB in real-world cases), table.toString() hangs indefinitely.
//
// The fix: pre-wrap text with our lightweight wrapText() utility instead of relying
// on cli-table3's built-in wordWrap.

function generateLargeErrorMessage(sizeBytes: number): string {
const lines: string[] = ['The field "id" is unresolvable at the following path:'];
let depth = 1;
while (lines.join('\n').length < sizeBytes) {
const indent = ' '.repeat(depth);
lines.push(`${indent}type${depth} {`);
lines.push(`${indent} edges {`);
lines.push(`${indent} node {`);
depth += 3;
if (depth > 300) depth = 1;
}
return lines.join('\n').substring(0, sizeBytes);
}

describe('Error table rendering with large text (#2619)', () => {
test('wrapText handles 1 MB of error text in under 1 second', () => {
// In real-world cases, composition produces ~9 MB of errors (29 errors,
// each 100-430 KB). cli-table3's wordWrap takes hours on this volume.
// Our wrapText must handle it in milliseconds.
const errors = Array.from({ length: 10 }, () => generateLargeErrorMessage(100_000));
const totalMB = errors.reduce((a, e) => a + e.length, 0) / (1024 * 1024);
expect(totalMB).toBeGreaterThan(0.9);

const t0 = Date.now();
for (const error of errors) {
const wrapped = wrapText(error, TABLE_CONTENT_WIDTH);
for (const line of wrapped.split('\n')) {
if (line.trim().length > 0) {
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1);
}
}
}
const elapsed = Date.now() - t0;

// wrapText on 1 MB must complete in well under 1 second.
// cli-table3's wordWrap takes ~13 seconds on 1 MB and hours on 9 MB.
expect(elapsed).toBeLessThan(1000);
}, 5_000);

test('cli-table3 wordWrap is too slow for large error text (demonstrates the bug)', () => {
// This test proves the bug exists: cli-table3's wordWrap is unusably slow
// even on a small 500-byte input. It takes >100ms where wrapText takes <1ms.
// At real-world scale (9 MB), wordWrap takes hours.
const text = generateLargeErrorMessage(500);

// Measure the buggy path: cli-table3 wordWrap: true
const buggyTable = new Table({ head: ['MSG'], colWidths: [120], wordWrap: true });
buggyTable.push([text]);
const t0 = Date.now();
buggyTable.toString();
const buggyMs = Date.now() - t0;

// Measure the fixed path: wrapText + no wordWrap
const fixedTable = new Table({ head: ['MSG'], colWidths: [120] });
fixedTable.push([wrapText(text, TABLE_CONTENT_WIDTH)]);
const t1 = Date.now();
fixedTable.toString();
const fixedMs = Date.now() - t1;

// The fixed path must be significantly faster than the buggy path.
// wordWrap on 500 bytes takes ~100-300ms, wrapText takes <5ms.
// This ratio only gets worse with more text (super-linear).
expect(fixedMs).toBeLessThan(buggyMs);
}, 5_000);
});
97 changes: 97 additions & 0 deletions cli/test/wrap-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, test, expect } from 'vitest';
import { wrapText } from '../src/wrap-text.js';

describe('wrapText', () => {
test('returns short text unchanged', () => {
expect(wrapText('hello world', 80)).toBe('hello world');
});

test('wraps text at word boundary when exceeding maxWidth', () => {
const input = 'the quick brown fox jumps over the lazy dog';
const result = wrapText(input, 20);
const lines = result.split('\n');
for (const line of lines) {
expect(line.length).toBeLessThanOrEqual(20);
}
// All words should be present
expect(result.replace(/\n/g, ' ')).toBe(input);
});

test('preserves existing newlines', () => {
const input = 'line one\nline two\nline three';
expect(wrapText(input, 80)).toBe(input);
});

test('wraps each paragraph independently', () => {
const input = 'short\nthis is a longer line that should be wrapped at the boundary';
const result = wrapText(input, 30);
const lines = result.split('\n');
expect(lines[0]).toBe('short');
for (const line of lines) {
expect(line.length).toBeLessThanOrEqual(35); // some tolerance for word boundaries
}
});

test('handles empty string', () => {
expect(wrapText('', 80)).toBe('');
});

test('handles single word longer than maxWidth', () => {
const longWord = 'abcdefghijklmnopqrstuvwxyz';
const result = wrapText(longWord, 10);
// A single word longer than maxWidth should still appear (not be lost)
expect(result).toContain(longWord);
});

test('handles multiple spaces between words', () => {
const input = 'word1 word2 word3';
const result = wrapText(input, 80);
expect(result).toContain('word1');
expect(result).toContain('word2');
expect(result).toContain('word3');
});

test('handles realistic composition error message', () => {
const errorMsg =
'The field "Foo.description" is defined in subgraph "subgraph-b" with @override(from: "subgraph-c"), ' +
'but subgraph "subgraph-c" also defines "Foo.description" with @override(from: "subgraph-b"). ' +
'This creates a circular override that cannot be resolved. Remove one of the @override directives to fix this error.';

const result = wrapText(errorMsg, 116);
const lines = result.split('\n');

// No line should exceed the maxWidth
for (const line of lines) {
expect(line.length).toBeLessThanOrEqual(116);
}
// All content should be preserved (no truncation)
expect(result.replace(/\n/g, ' ')).toBe(errorMsg);
});

test('handles very long text with many words (regression test for deadlock)', () => {
// Simulate the kind of error content that caused the deadlock with 4+ subgraphs
const words = [];
for (let i = 0; i < 500; i++) {
words.push(`word${i}`);
}
const input = words.join(' ');

const startTime = Date.now();
const result = wrapText(input, 116);
const elapsed = Date.now() - startTime;

// Must complete quickly — the original bug caused an indefinite hang
expect(elapsed).toBeLessThan(1000);

const lines = result.split('\n');
for (const line of lines) {
expect(line.length).toBeLessThanOrEqual(116);
}
});

test('preserves empty lines between paragraphs', () => {
const input = 'paragraph one\n\nparagraph two';
const result = wrapText(input, 80);
expect(result).toBe('paragraph one\n\nparagraph two');
});
});
Loading