Skip to content

fix(cli): avoid cli-table3 wordWrap deadlock with 4+ subgraphs#2620

Closed
kamil-gwozdz wants to merge 2 commits intowundergraph:mainfrom
kamil-gwozdz:fix/compose-table-deadlock-2619
Closed

fix(cli): avoid cli-table3 wordWrap deadlock with 4+ subgraphs#2620
kamil-gwozdz wants to merge 2 commits intowundergraph:mainfrom
kamil-gwozdz:fix/compose-table-deadlock-2619

Conversation

@kamil-gwozdz
Copy link
Copy Markdown

@kamil-gwozdz kamil-gwozdz commented Mar 10, 2026

Summary

Fixes #2619

wgc router compose deadlocks when federation composition produces errors and the config references 4+ subgraphs. The process prints the error header but hangs indefinitely before rendering the error table.

Root Cause

cli-table3's built-in wordWrap: true option triggers an expensive code path through string-widthemoji-regex for every word in every table cell. With enough error content (4+ subgraphs), this blocks the Node.js main thread indefinitely, preventing signal handling and event loop progress.

Fix

  • Introduced a lightweight wrapText() utility (cli/src/wrap-text.ts) that wraps text on word boundaries using simple character counting — no heavy regex dependencies.
  • Exported a TABLE_CONTENT_WIDTH constant (116 = 120 column width − 2 padding − 2 borders) to avoid magic numbers.
  • Pre-wrap error/warning messages before inserting them into the table.
  • Removed wordWrap: true from all affected table configurations.
  • Table output looks identical to before; only the wrapping mechanism changes.

Files Changed

File Change
cli/src/wrap-text.ts New standalone utility with wrapText() and TABLE_CONTENT_WIDTH — zero dependencies
cli/src/utils.ts Re-exports wrapText for backward compatibility
cli/src/commands/router/commands/compose.ts Fixed 4 table instances (errors + warnings for main and feature flag compositions)
cli/src/handle-composition-result.ts Fixed 3 table instances (composition errors, deployment errors, warnings)
cli/test/wrap-text.test.ts 10 unit tests for wrapText, including a regression test verifying 500-word inputs complete in <1s
cli/test/compose-error-table.test.ts Integration test reproducing the exact issue — 4 subgraphs with circular @override, verifying the error table renders without hanging (5s timeout)

Testing

  • wrap-text.test.ts — 10 tests covering word wrapping edge cases, empty inputs, long words, paragraph preservation, and a performance regression test.
  • compose-error-table.test.ts — Integration test using the exact reproduction schemas from the issue (4 subgraphs with circular @override between subgraph-b and subgraph-c). Runs federateSubgraphs, asserts composition fails, renders the error table through the fixed code path, and verifies output correctness within a 5-second timeout.
  • All 11 new tests pass. Existing CLI tests unaffected.

Summary by CodeRabbit

  • Bug Fixes

    • Improved CLI composition output: long error and warning messages are now reliably wrapped to prevent hangs and ensure readable table rendering, including very large messages.
  • Tests

    • Added tests covering wrapping behavior, edge cases, performance, and a regression to ensure fast, correct error reporting.

@github-actions github-actions bot added the cli label Mar 10, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f741118-ae9a-4f68-bdd7-81cdfd8d1b3f

📥 Commits

Reviewing files that changed from the base of the PR and between b6a5edd and 79fa0da.

📒 Files selected for processing (1)
  • cli/test/compose-error-table.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • cli/test/compose-error-table.test.ts

Walkthrough

Adds a paragraph-aware text-wrapping utility (TABLE_CONTENT_WIDTH, wrapText) and replaces cli-table3 wordWrap usage by pre-wrapping error/warning messages before inserting into CLI tables. Adds re-export and unit/regression tests verifying correctness and large-message performance.

Changes

Cohort / File(s) Summary
Compose command & result handling
cli/src/commands/router/commands/compose.ts, cli/src/handle-composition-result.ts
Import and use wrapText(..., TABLE_CONTENT_WIDTH) for error and warning message cells; removed per-table wordWrap: true. Applies to composition, deployment, and feature-flag error/warning tables.
New text-wrap utility
cli/src/wrap-text.ts
Adds TABLE_CONTENT_WIDTH constant and wrapText(text: string, maxWidth: number): string that wraps text at word boundaries, preserves paragraphs and empty lines, and ensures lines fit within maxWidth.
Utility re-export
cli/src/utils.ts
Re-exports wrapText from ./wrap-text.js.
Unit tests for wrapper
cli/test/wrap-text.test.ts
New comprehensive tests: short inputs, word-boundary wrapping, newline/paragraph preservation, spacing edge cases, single-long-word handling, realistic long-message wrapping, and a performance regression test.
Compose error table test (regression)
cli/test/compose-error-table.test.ts
Adds regression test generating large synthetic composition errors (~1 MB) validating wrapText keeps lines within width and completes quickly; compares against cli-table3 wordWrap slowdown.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main fix: avoiding cli-table3 wordWrap deadlock with 4+ subgraphs, which directly matches the core problem addressed in PR #2619.
Linked Issues check ✅ Passed All coding requirements from issue #2619 are met: the PR implements a lightweight wrapText utility, removes wordWrap from table configurations, pre-wraps error/warning messages, and includes comprehensive tests demonstrating the fix works without deadlock.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the composition error table deadlock: new wrap-text utility, updated table configurations in compose.ts and handle-composition-result.ts, and related tests. No unrelated modifications present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
cli/test/wrap-text.test.ts (1)

25-33: Consider tightening the assertion tolerance.

The test asserts line.length <= 35 for a maxWidth of 30, which is a 16% overage. While wrapText can legitimately exceed maxWidth for single words longer than the limit, this test's assertion is loose enough that it wouldn't catch certain wrapping bugs.

For word-boundary wrapping where words are short, lines should be at or below maxWidth. Consider splitting the assertion:

for (const line of lines) {
  // Lines should not exceed maxWidth unless they contain a single word longer than maxWidth
  const words = line.split(' ');
  if (words.length > 1) {
    expect(line.length).toBeLessThanOrEqual(30);
  }
}

This is a minor suggestion—the current test still validates the core behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/test/wrap-text.test.ts` around lines 25 - 33, The test's length tolerance
is too loose; update the assertion in the wrap-text.test to enforce maxWidth for
normal wrapped lines: iterate over lines (the lines array from result) and for
each line split into words, and if words.length > 1 assert line.length <=
maxWidth (use the same numeric maxWidth variable used when calling wrapText,
e.g., 30); allow longer lengths only when a single word exceeds maxWidth. This
change targets the test block using input, result, lines and wrapText.
cli/src/handle-composition-result.ts (1)

12-12: Apply the same wrapText fix to handle-proposal-result.ts for consistency.

cli/src/handle-proposal-result.ts currently uses wordWrap: true in multiple Table configurations (lines 26, 32, 38, 49, 61). Update these tables to use wrapText from ./utils.js instead, consistent with the fix applied to handle-composition-result.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/src/handle-composition-result.ts` at line 12, handle-proposal-result.ts
still uses deprecated/incorrect Table options with wordWrap: true in several
Table configurations; replace those usages by importing and using the wrapText
helper from ./utils.js (same as in handle-composition-result.ts). Specifically,
add import { wrapText } from './utils.js' at the top of
handle-proposal-result.ts if missing, then for each Table definition that sets
wordWrap: true (the tables around the blocks with wordWrap at the spots
corresponding to the earlier lines 26, 32, 38, 49, 61) remove wordWrap and pass
wrapText for the cell wrapping (e.g., use wrapText(cellValue, width) or whatever
wrapText signature is used elsewhere) so the table rendering matches
handle-composition-result.ts behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cli/src/handle-composition-result.ts`:
- Line 12: handle-proposal-result.ts still uses deprecated/incorrect Table
options with wordWrap: true in several Table configurations; replace those
usages by importing and using the wrapText helper from ./utils.js (same as in
handle-composition-result.ts). Specifically, add import { wrapText } from
'./utils.js' at the top of handle-proposal-result.ts if missing, then for each
Table definition that sets wordWrap: true (the tables around the blocks with
wordWrap at the spots corresponding to the earlier lines 26, 32, 38, 49, 61)
remove wordWrap and pass wrapText for the cell wrapping (e.g., use
wrapText(cellValue, width) or whatever wrapText signature is used elsewhere) so
the table rendering matches handle-composition-result.ts behavior.

In `@cli/test/wrap-text.test.ts`:
- Around line 25-33: The test's length tolerance is too loose; update the
assertion in the wrap-text.test to enforce maxWidth for normal wrapped lines:
iterate over lines (the lines array from result) and for each line split into
words, and if words.length > 1 assert line.length <= maxWidth (use the same
numeric maxWidth variable used when calling wrapText, e.g., 30); allow longer
lengths only when a single word exceeds maxWidth. This change targets the test
block using input, result, lines and wrapText.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e2743d5-d797-4e83-8bf3-7412675425e2

📥 Commits

Reviewing files that changed from the base of the PR and between 610f4c0 and 1c30315.

📒 Files selected for processing (5)
  • cli/src/commands/router/commands/compose.ts
  • cli/src/handle-composition-result.ts
  • cli/src/utils.ts
  • cli/src/wrap-text.ts
  • cli/test/wrap-text.test.ts

@kamil-gwozdz kamil-gwozdz force-pushed the fix/compose-table-deadlock-2619 branch from 1c30315 to ea579d1 Compare March 10, 2026 12:21
…rgraph#2619)

Replace cli-table3's built-in wordWrap with a lightweight wrapText() utility
that wraps text on word boundaries using simple character counting. This avoids
the expensive string-width/emoji-regex code path in cli-table3 that causes the
Node.js main thread to block indefinitely when rendering large error tables.

Fixes: wundergraph#2619

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kamil-gwozdz kamil-gwozdz force-pushed the fix/compose-table-deadlock-2619 branch from ea579d1 to 5d92b16 Compare March 10, 2026 12:23
@kamil-gwozdz kamil-gwozdz marked this pull request as ready for review March 10, 2026 12:26
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 15, 2026

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

@kamil-gwozdz kamil-gwozdz force-pushed the fix/compose-table-deadlock-2619 branch from 23ac6c9 to b6a5edd Compare March 15, 2026 12:08
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
cli/test/compose-error-table.test.ts (2)

17-25: Avoid repeated full joins in the fixture generator.

Recomputing lines.join('\n').length each loop is unnecessarily expensive for large payloads. Tracking accumulated length keeps this helper linear.

Suggested patch
 function generateLargeErrorMessage(sizeBytes: number): string {
   const lines: string[] = ['The field "id" is unresolvable at the following path:'];
+  let currentLength = lines[0].length;
   let depth = 1;
-  while (lines.join('\n').length < sizeBytes) {
+  while (currentLength < sizeBytes) {
     const indent = ' '.repeat(depth);
-    lines.push(`${indent}type${depth} {`);
-    lines.push(`${indent} edges {`);
-    lines.push(`${indent}  node {`);
+    const nextLines = [`${indent}type${depth} {`, `${indent} edges {`, `${indent}  node {`];
+    lines.push(...nextLines);
+    currentLength += nextLines.reduce((sum, line) => sum + line.length, 0) + nextLines.length; // '\n' separators
     depth += 3;
     if (depth > 300) depth = 1;
   }
   return lines.join('\n').substring(0, sizeBytes);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/test/compose-error-table.test.ts` around lines 17 - 25, The loop
repeatedly calls lines.join('\n').length which is O(n) each iteration; replace
this with an accumulated length counter (e.g., let accumulated =
lines.join('\n').length or start from 0 if lines empty) and update it whenever
you push new strings: compute each newLine (using indent/type/edges/node
strings), push to lines, and increment accumulated by newLine.length + 1 (for
the newline separator) so the while condition uses accumulated < sizeBytes;
maintain the existing depth wrap logic and substring at the end as before,
referencing the variables lines, sizeBytes, depth, and indent in the diff.

53-56: The hard 1-second wall-clock gate is likely CI-flaky.

Line [53] can fail intermittently on slower or contended runners even when the deadlock fix is valid. Consider a less brittle upper bound while preserving the non-hang guarantee.

Suggested patch
-      expect(elapsed).toBeLessThan(1000);
+      expect(elapsed).toBeLessThan(4_000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/test/compose-error-table.test.ts` around lines 53 - 56, The test
currently asserts expect(elapsed).toBeLessThan(1000) which is a brittle 1s
wall-clock gate and can flake on CI; update the assertion on elapsed (the
variable used in compose-error-table.test.ts) to a looser bound (for example
toBeLessThan(3000)) or assert relative to the test timeout (e.g.,
expect(elapsed).toBeLessThan(5_000 - 500)) so the check still prevents hangs but
is CI-tolerant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/test/compose-error-table.test.ts`:
- Line 45: The test currently allows one extra character by asserting
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1); which can hide
off-by-one wrap bugs; update the assertion to
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH) so the test matches
production wrapping behavior (reference the TABLE_CONTENT_WIDTH constant and the
expect(...).toBeLessThanOrEqual call in compose-error-table.test.ts).

---

Nitpick comments:
In `@cli/test/compose-error-table.test.ts`:
- Around line 17-25: The loop repeatedly calls lines.join('\n').length which is
O(n) each iteration; replace this with an accumulated length counter (e.g., let
accumulated = lines.join('\n').length or start from 0 if lines empty) and update
it whenever you push new strings: compute each newLine (using
indent/type/edges/node strings), push to lines, and increment accumulated by
newLine.length + 1 (for the newline separator) so the while condition uses
accumulated < sizeBytes; maintain the existing depth wrap logic and substring at
the end as before, referencing the variables lines, sizeBytes, depth, and indent
in the diff.
- Around line 53-56: The test currently asserts
expect(elapsed).toBeLessThan(1000) which is a brittle 1s wall-clock gate and can
flake on CI; update the assertion on elapsed (the variable used in
compose-error-table.test.ts) to a looser bound (for example toBeLessThan(3000))
or assert relative to the test timeout (e.g., expect(elapsed).toBeLessThan(5_000
- 500)) so the check still prevents hangs but is CI-tolerant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9e9d6ea6-1096-446c-96b1-68d42f349771

📥 Commits

Reviewing files that changed from the base of the PR and between 23ac6c9 and b6a5edd.

📒 Files selected for processing (1)
  • cli/test/compose-error-table.test.ts

// Every line should respect the max width
for (const line of wrapped.split('\n')) {
if (line.trim().length > 0) {
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tighten the width assertion to avoid masking wrap regressions.

Line [45] allows TABLE_CONTENT_WIDTH + 1, but production call sites wrap with exactly TABLE_CONTENT_WIDTH. This can hide an off-by-one bug in wrapping output.

Suggested patch
-            expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1);
+            expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1);
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/test/compose-error-table.test.ts` at line 45, The test currently allows
one extra character by asserting
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH + 1); which can hide
off-by-one wrap bugs; update the assertion to
expect(line.length).toBeLessThanOrEqual(TABLE_CONTENT_WIDTH) so the test matches
production wrapping behavior (reference the TABLE_CONTENT_WIDTH constant and the
expect(...).toBeLessThanOrEqual call in compose-error-table.test.ts).

The previous test used minimal schemas (4 subgraphs with circular @OverRide)
that only produced ~1 KB of error text. This was too small to trigger the
cli-table3 wordWrap deadlock, so the test passed both with and without the fix.

The new test:
- Feeds 500 KB of synthetic error text (simulating deeply nested composition
  path resolution failures) through the fixed table rendering path
- Verifies it completes in <2s (with wordWrap: true, 500 KB takes 3+ seconds
  and 9 MB takes hours)
- Directly compares wrapText vs wordWrap performance
- No longer depends on @wundergraph/composition (removes a build dependency)

Reproduction repo: https://github.com/kamil-gwozdz/cosmo-compose-deadlock-repro

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kamil-gwozdz kamil-gwozdz force-pushed the fix/compose-table-deadlock-2619 branch from b6a5edd to 79fa0da Compare March 15, 2026 12:21
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 15, 2026

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

@github-actions
Copy link
Copy Markdown

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@endigma
Copy link
Copy Markdown
Member

endigma commented Mar 31, 2026

Hi @kamil-gwozdz, thanks for your contribution! Closing this in favour of #2715

@endigma endigma closed this Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wgc router compose hangs indefinitely when composition errors produce large error text

2 participants