Skip to content

fix(cli): write tool output to stdout via fd 1 instead of stderr (#324)#346

Merged
magyargergo merged 4 commits intoabhigyanpatwari:mainfrom
ShunsukeHayashi:fix/cypher-stdout
Mar 18, 2026
Merged

fix(cli): write tool output to stdout via fd 1 instead of stderr (#324)#346
magyargergo merged 4 commits intoabhigyanpatwari:mainfrom
ShunsukeHayashi:fix/cypher-stdout

Conversation

@ShunsukeHayashi
Copy link
Contributor

Problem

The gitnexus cypher (and all other CLI tool commands) output JSON to stderr instead of stdout (#324). This breaks standard Unix pipeline conventions:

# stdout is empty:
gitnexus cypher 'MATCH (n) RETURN n LIMIT 3' > /tmp/out.txt 2>/dev/null
cat /tmp/out.txt  # (empty)

# Result only appears on stderr:
gitnexus cypher 'MATCH (n) RETURN n LIMIT 3' 2>&1 | jq .  # works, but non-standard

This affects everyone building scripts or automation around GitNexus CLI.

Root Cause

The output() function in tool.ts uses process.stderr.write() because LadybugDB's native module captures Node.js process.stdout at the OS level during initialization. The comment in the code explicitly notes this:

// stderr because LadybugDB captures stdout at OS level

Fix

Use fs.writeSync(1, ...) to write directly to file descriptor 1 (stdout) at the OS level, bypassing Node.js's process.stdout stream which LadybugDB has captured.

Key insight: LadybugDB captures the Node.js stream layer (process.stdout), but the underlying OS file descriptor 1 remains intact. By writing to fd 1 directly, we restore standard pipeline behavior.

import { writeSync } from 'node:fs';

function output(data: any): void {
  const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  try {
    writeSync(1, text + '\n');
  } catch {
    // Fallback: stderr (previous behavior)
    process.stderr.write(text + '\n');
  }
}

After this fix:

gitnexus cypher 'MATCH (n) RETURN n LIMIT 3' | jq .          # ✅ works
gitnexus impact MyFunction upstream | jq '.risk'              # ✅ works
gitnexus context --name MyClass | jq '.symbol.filePath'       # ✅ works

Backward compatibility

  • If writeSync(1, ...) fails (e.g., broken pipe, Windows edge cases), falls back to stderr (existing behavior)
  • Diagnostic/error messages remain on stderr via console.error()
  • No changes to MCP server output (only CLI commands are affected)

Testing

Verified locally on macOS (Apple Silicon) with GitNexus 1.4.5 across 25 indexed repositories.

Context

We've been running GitNexus in production and built gitnexus-stable-ops as an operational toolkit. Our gni wrapper script has been using 2>&1 as a workaround for this issue — this PR fixes it at the source.

Related: #344

…shing (#321)

- Wrap impact() in try-catch to return structured error JSON instead of
  process crash (SIGSEGV/exit 139)
- Extract core logic to _impactImpl() for clean error boundary
- Break out of depth traversal loop on query failure, return partial
  results collected so far (previously silently swallowed errors)
- Add 'partial' flag to response when traversal was interrupted
- Add try-catch in CLI impactCommand with structured error output
- Improve formatImpactResult to show suggestion text and partial warning
- Add 3 new unit tests for error/suggestion/partial scenarios

Fixes #321
LadybugDB's native module captures Node.js process.stdout during init,
but the underlying OS file descriptor 1 remains intact. By using
fs.writeSync(1, ...) we bypass the Node.js stream layer and write
directly to the real stdout fd.

This restores standard Unix pipeline behavior:
  gitnexus cypher 'MATCH (n) RETURN n LIMIT 3' | jq .

Falls back to stderr if fd write fails (e.g., broken pipe on Windows).

Fixes #324
@vercel
Copy link

vercel bot commented Mar 18, 2026

@ShunsukeHayashi is attempting to deploy a commit to the NexusCore Team on Vercel.

A member of the Team first needs to authorize it.

@magyargergo
Copy link
Collaborator

@claude ultra-think while looking at this PR and find potential bugs and performance issues.

@claude
Copy link

claude bot commented Mar 18, 2026

Claude finished @magyargergo's task in 2m 38s —— View job


PR Review: fix(cli): write tool output to stdout via fd 1

Tasks

  • Gather context and understand request
  • Read changed files
  • Analyze for bugs and performance issues
  • Post findings

This PR is a 2-commit bundle:

  • Commit 1 (f21ec0d): fix(impact) — structured error + partial results (4 files)
  • Commit 2 (f57f62e): fix(cli) — fd 1 stdout write (tool.ts only)

Here's my full analysis:


🔴 Bug: Inconsistent stdout fix in eval-server.ts

File: gitnexus/src/cli/eval-server.ts:411

try {
  process.stdout.write(`GITNEXUS_EVAL_SERVER_READY:${port}\n`);
} catch {
  // stdout may not be available
}

This is the exact same LadybugDB capture problem that was fixed in tool.ts. The GITNEXUS_EVAL_SERVER_READY signal is critical — it's how Docker-based SWE-bench evaluators know the server is up and ready to accept connections. If LadybugDB has captured process.stdout, this signal is silently lost, and the evaluation harness will hang or time out waiting for it.

The fix in tool.ts uses writeSync(1, ...) to bypass the stream layer — but here, the same approach was not applied. The try-catch makes the failure invisible.

Fix: Use writeSync(1, ...) (same as tool.ts) instead of process.stdout.write(). Fix this →


🔴 Bug: Stale file header comment contradicts the fix

File: gitnexus/src/cli/tool.ts:13-14

 * Note: Output goes to stderr because LadybugDB's native module captures stdout
 * at the OS level during init. This is consistent with augment.ts.

This comment is now factually wrong and will mislead future maintainers. The fix writes to stdout (fd 1), not stderr. Fix this →


🟡 Bug: EPIPE / broken pipe doesn't exit cleanly

File: gitnexus/src/cli/tool.ts:47-49

} catch {
  // Fallback: stderr (previous behavior, works on all platforms)
  process.stderr.write(text + '\n');
}

When writeSync(1, ...) throws EPIPE (consumer of the pipe closed — e.g., gitnexus cypher ... | head -1), the fallback writes the full output to stderr. Unix convention is that a process getting EPIPE should exit quietly (exit code 0 or 141). Writing large JSON to stderr on broken pipe is noisy and unexpected for scripts that do 2>/dev/null.

Recommended fix: check if err.code === 'EPIPE' and exit cleanly instead of falling back to stderr. Fix this →


🟡 Bug: parseInt without NaN validation

Files: gitnexus/src/cli/tool.ts:70,115 and gitnexus/src/cli/eval-server.ts:307-308

limit: options?.limit ? parseInt(options.limit) : undefined,  // tool.ts:70
maxDepth: options?.depth ? parseInt(options.depth) : undefined,  // tool.ts:115
const port = parseInt(options?.port || '4848');  // eval-server.ts:307
const idleTimeoutSec = parseInt(options?.idleTimeout || '0');  // eval-server.ts:308

All four parseInt calls:

  1. Missing the radix argument (parseInt(str, 10)) — not a practical bug here since inputs are decimal, but it's a JS best-practice violation
  2. No NaN guard — if the user passes --limit abc or --port xyz, NaN flows into the backend/server.listen(). Node's server.listen(NaN) throws ERR_SOCKET_BAD_PORT.

🟡 Bug: Request timeout missing in eval-server

File: gitnexus/src/cli/eval-server.ts:333

The HTTP server has no per-request timeout. If backend.callTool() hangs (e.g., LadybugDB native crash inside a query), the socket remains open indefinitely. In a Docker eval environment, this can block the agent with no recourse other than killing the server.

// No server.setTimeout() or AbortSignal on callTool
const result = await backend.callTool(toolName, args);

Fix this →


🟡 Bug: Race condition in idle timer shutdown

File: gitnexus/src/cli/eval-server.ts:326-330

idleTimer = setTimeout(async () => {
  console.error('GitNexus eval-server: Idle timeout reached, shutting down');
  await backend.disconnect();
  process.exit(0);
}, idleTimeoutSec * 1000);

If a request arrives during backend.disconnect(), resetIdleTimer() clears the timer but the disconnect is already in flight. The subsequent backend.callTool() from the new request will fail with a connection error. The server won't respond cleanly to that request.


🟢 Performance: Unnecessary string concatenation for large outputs

File: gitnexus/src/cli/tool.ts:44,46

const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
writeSync(1, text + '\n');

For large graph query results (potentially MB-scale JSON), text + '\n' allocates a second large string before writing. Use Buffer or write the newline as a second writeSync call:

writeSync(1, text);
writeSync(1, '\n');

This is minor for most use cases but can matter when results are large.


🟢 Performance: Pretty-printing JSON to a pipe

File: gitnexus/src/cli/tool.ts:44

const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);

The output always uses pretty-printed JSON (null, 2). When piping to jq (the primary use case described in the PR), jq re-formats the JSON anyway — the extra whitespace wastes bytes and writeSync time. Consider a --compact / -c flag, or use compact JSON for non-TTY output.


🟢 Minor: formatCypherResult silently drops columns from heterogeneous rows

File: gitnexus/src/cli/eval-server.ts:197

const keys = Object.keys(result[0]);

If Cypher returns rows with different keys (e.g., a UNION query), only columns present in the first row are displayed. Subsequent rows with extra keys are silently truncated. A defensive approach would be to collect the union of all keys across the first N rows.


Summary

Severity Issue File
🔴 High eval-server uses process.stdout.write() for ready signal — same LadybugDB bug as #324 eval-server.ts:411
🔴 High Stale comment says output goes to stderr tool.ts:13
🟡 Med EPIPE writes full JSON to stderr instead of exiting cleanly tool.ts:47
🟡 Med parseInt without NaN guard on user inputs tool.ts:70,115 / eval-server.ts:307
🟡 Med No per-request timeout — hung queries block sockets forever eval-server.ts:333
🟡 Med Race condition: new request during idle shutdown hits disconnected backend eval-server.ts:326
🟢 Low text + '\n' double-allocates large string tool.ts:46
🟢 Low Always pretty-prints JSON even when piped tool.ts:44
🟢 Low formatCypherResult drops extra columns for heterogeneous rows eval-server.ts:197

The core fix (writeSync(1, ...)) is sound — the approach of bypassing the Node.js stream layer to write directly to fd 1 is correct. The most impactful oversight is not applying the same fix to the GITNEXUS_EVAL_SERVER_READY signal in eval-server.ts, which would cause silent failures in the Docker eval environment this server is designed for.

@magyargergo
Copy link
Collaborator

Could you please tackle with this? 🙏

…PE handling

Per @claude's review:

- [RED] Fix stale file header comment in tool.ts (now references fd 1, not stderr)
- [RED] Apply same writeSync(1, ...) fix to eval-server.ts READY signal
  (LadybugDB captures process.stdout, breaking Docker eval harness)
- [YELLOW] Handle EPIPE in output() — exit cleanly per Unix convention
  instead of dumping full JSON to stderr on broken pipe
@ShunsukeHayashi
Copy link
Contributor Author

Great review @claude. Addressed the high-severity items:

Fixed:

  • 🔴 eval-server READY signal — now uses \writeSync(1, ...)\ (same fix as tool.ts)
  • 🔴 Stale header comment — updated to reflect fd 1 behavior
  • 🟡 EPIPE handling — exits cleanly (\process.exit(0)) instead of dumping to stderr

Acknowledged but not in this PR (scope):

  • 🟡 parseInt NaN guard — good catch, but affects pre-existing code not touched by this PR
  • 🟡 Request timeout + idle race condition — same, pre-existing eval-server issues
  • 🟢 Performance items — minor, can be follow-up PRs

Happy to submit separate PRs for the pre-existing issues if you'd like.

@magyargergo
Copy link
Collaborator

Awesome work! Thank you for your contribution!

@github-actions
Copy link
Contributor

github-actions bot commented Mar 18, 2026

CI Report

All checks passed

Pipeline Status

Stage Status Details
✅ Typecheck success tsc --noEmit
✅ Unit Tests success 3 platforms
✅ Integration success 3 OS x 4 groups = 12 jobs

Test Results

Suite Tests Passed Failed Skipped Duration
Unit 1565 1564 0 1 8s
Integration 1167 1147 0 19 50s
Total 2732 2711 0 20 58s

✅ All 2711 tests passed

20 test(s) skipped — expand for details

Integration:

  • Python match/case as-pattern type binding > resolves u.save() to User#save via match/case as-pattern binding
  • Python match/case as-pattern type binding > does NOT resolve u.save() to Repo#save (negative disambiguation)
  • Swift constructor-inferred type resolution > detects User and Repo classes, both with save methods
  • Swift constructor-inferred type resolution > resolves user.save() to Models/User.swift via constructor-inferred type
  • Swift constructor-inferred type resolution > resolves repo.save() to Models/Repo.swift via constructor-inferred type
  • Swift constructor-inferred type resolution > emits exactly 2 save() CALLS edges (one per receiver type)
  • Swift self resolution > detects User and Repo classes, each with a save function
  • Swift self resolution > resolves self.save() inside User.process to User.save, not Repo.save
  • Swift parent resolution > detects BaseModel and User classes plus Serializable protocol
  • Swift parent resolution > emits EXTENDS edge: User → BaseModel
  • Swift parent resolution > emits IMPLEMENTS edge: User → Serializable (protocol conformance)
  • Swift cross-file User.init() inference > resolves user.save() via User.init(name:) inference
  • Swift cross-file User.init() inference > resolves user.greet() via User.init(name:) inference
  • Swift return type inference > detects User class and getUser function
  • Swift return type inference > detects save function on User (Swift class methods are Function nodes)
  • Swift return type inference > resolves user.save() to User#save via return type of getUser() -> User
  • Swift return-type inference via function return type > resolves user.save() to User#save via return type of getUser()
  • Swift return-type inference via function return type > user.save() does NOT resolve to Repo#save
  • Swift return-type inference via function return type > resolves repo.save() to Repo#save via return type of getRepo()

Unit:

  • buildTypeEnv > known limitations (documented skip tests) > Ruby block parameter: users.each { |user| } — closure param inference, different feature

Code Coverage

Combined (Unit + Integration)

Metric Coverage Covered Base Delta Status
Statements 61.47% 6846/11137 46.42% 📈 +15.0 🟢 ████████████░░░░░░░░
Branches 55.68% 4676/8397 41.41% 📈 +14.3 🟢 ███████████░░░░░░░░░
Functions 60.71% 592/975 45.99% 📈 +14.7 🟢 ████████████░░░░░░░░
Lines 63.41% 6125/9658 47.96% 📈 +15.4 🟢 ████████████░░░░░░░░
Coverage breakdown by test suite

Unit Tests

Metric Coverage Covered Base Delta Status
Statements 46.41% 5169/11137 46.42% 📉 -0.0 🔴 █████████░░░░░░░░░░░
Branches 41.4% 3477/8397 41.41% 📉 -0.0 🔴 ████████░░░░░░░░░░░░
Functions 46.05% 449/975 45.99% 📈 +0.1 🟢 █████████░░░░░░░░░░░
Lines 47.96% 4632/9658 47.96% = 0.0 🟢 █████████░░░░░░░░░░░

Integration Tests

Metric Coverage Covered Base Delta Status
Statements 46.5% 5179/11137 46.42% 📈 +0.1 🟢 █████████░░░░░░░░░░░
Branches 43.89% 3686/8397 41.41% 📈 +2.5 🟢 ████████░░░░░░░░░░░░
Functions 44.1% 430/975 45.99% 📉 -1.9 🔴 ████████░░░░░░░░░░░░
Lines 47.58% 4596/9658 47.96% 📉 -0.4 🔴 █████████░░░░░░░░░░░

📋 View full run · Generated by CI

@magyargergo
Copy link
Collaborator

@claude can you recommend a testing strategy for this PR? I want to see an integration test basically.

@claude
Copy link

claude bot commented Mar 18, 2026

Claude finished @magyargergo's task in 1m 33s —— View job


Tasks

  • Gather context and understand request
  • Read changed files and existing test patterns
  • Design integration test strategy
  • Post recommendations

Integration Test Strategy for PR #346

The core behaviors introduced by this PR that need integration coverage are:

  1. CLI tool commands write JSON to stdout (fd 1), not stderr
  2. EPIPE causes a clean exit (code 0), not a stderr dump
  3. eval-server READY signal appears on stdout

The right home for these is gitnexus/test/integration/cli-e2e.test.ts, which already spawns the CLI as a real child process and captures stdout/stderr separately via stdio: ['pipe', 'pipe', 'pipe']. The mini-repo fixture + analyze step already exist, so you can chain off them.


Test 1 — Tool output on stdout, not stderr

This is the core regression test. After analyze runs on the mini-repo, add a describe block that runs each tool command and asserts the JSON lands on result.stdout with result.stderr clean of JSON:

// Add AFTER the analyze test, so .gitnexus/ already exists
describe('tool output goes to stdout (fd 1)', () => {
  // Wrapper so each command runs against the indexed mini-repo
  function runToolCmd(args: string[], timeoutMs = 15000) {
    return spawnSync(
      process.execPath,
      ['--import', 'tsx', cliEntry, ...args],
      {
        cwd: MINI_REPO,
        encoding: 'utf8',
        timeout: timeoutMs,
        stdio: ['pipe', 'pipe', 'pipe'],
        env: {
          ...process.env,
          NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
        },
      },
    );
  }

  it('cypher: JSON appears on stdout, not stderr', () => {
    const result = runToolCmd(['cypher', 'MATCH (n) RETURN n.name LIMIT 3']);
    if (result.status === null) return; // CI timeout tolerance

    expect(result.status).toBe(0);

    // stdout must contain valid JSON (array or object)
    expect(() => JSON.parse(result.stdout.trim())).not.toThrow();

    // stderr must NOT contain JSON — only human-readable diagnostics are allowed there
    expect(() => JSON.parse(result.stderr.trim())).toThrow();
  });

  it('query: JSON appears on stdout, not stderr', () => {
    const result = runToolCmd(['query', 'handler']);
    if (result.status === null) return;

    expect(result.status).toBe(0);
    expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
    expect(() => JSON.parse(result.stderr.trim())).toThrow();
  });

  it('impact: JSON appears on stdout, not stderr', () => {
    const result = runToolCmd(['impact', 'handleRequest', '--direction', 'upstream']);
    if (result.status === null) return;

    expect(result.status).toBe(0);
    expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
    expect(() => JSON.parse(result.stderr.trim())).toThrow();
  });

  it('stdout is pipeable: cypher output parses as valid JSON', () => {
    const result = runToolCmd(['cypher', 'MATCH (n:Function) RETURN n.name LIMIT 5']);
    if (result.status === null) return;

    expect(result.status).toBe(0);

    // Simulate what jq does: parse stdout as JSON
    const parsed = JSON.parse(result.stdout.trim());
    expect(Array.isArray(parsed) || typeof parsed === 'object').toBe(true);
  });
});

Why this matters: Before this PR, result.stdout would be empty and the JSON would only appear in result.stderr. This test would have caught the regression.


Test 2 — EPIPE causes clean exit (code 0)

This requires spawn (async) instead of spawnSync, so you can close the read end of the pipe while the child is still writing:

import { spawn } from 'child_process';

it('cypher: EPIPE exits with code 0, not stderr dump', (done) => {
  // Produce enough output to fill the pipe buffer so EPIPE triggers
  const child = spawn(
    process.execPath,
    ['--import', 'tsx', cliEntry, 'cypher', 'MATCH (n) RETURN n LIMIT 500'],
    {
      cwd: MINI_REPO,
      stdio: ['ignore', 'pipe', 'pipe'],
      env: {
        ...process.env,
        NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
      },
    },
  );

  let stderrOutput = '';
  child.stderr.on('data', (chunk) => { stderrOutput += chunk.toString(); });

  // Destroy stdout immediately — this simulates `| head -0` (consumer closes early)
  child.stdout.once('data', () => {
    child.stdout.destroy(); // triggers EPIPE on the next write
  });

  child.on('close', (code) => {
    // Clean EPIPE exit: code 0, not a stderr dump of the full JSON
    expect(code).toBe(0);
    // No JSON payload should appear on stderr
    expect(() => JSON.parse(stderrOutput.trim())).toThrow();
    done();
  });
}, 20000);

Why this matters: Before the EPIPE fix, the full JSON payload would be dumped to stderr when the pipe consumer closed early (e.g., gitnexus cypher ... | head -1).


Test 3 — eval-server READY signal on stdout

import { spawn } from 'child_process';

it('eval-server: READY signal appears on stdout', (done) => {
  const child = spawn(
    process.execPath,
    ['--import', 'tsx', cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '2'],
    {
      cwd: MINI_REPO,
      stdio: ['ignore', 'pipe', 'pipe'],
      env: {
        ...process.env,
        NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
      },
    },
  );

  let stdoutBuffer = '';
  child.stdout.on('data', (chunk) => {
    stdoutBuffer += chunk.toString();
    if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) {
      // Signal arrived on stdout ✅
      child.kill('SIGTERM');
      done();
    }
  });

  child.stderr.on('data', (chunk) => {
    const text = chunk.toString();
    // READY signal must NOT appear on stderr
    if (text.includes('GITNEXUS_EVAL_SERVER_READY:')) {
      child.kill('SIGTERM');
      done(new Error('READY signal appeared on stderr instead of stdout'));
    }
  });

  child.on('close', () => {
    // If child exited without emitting the signal, fail
    if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) {
      done(new Error('eval-server exited without emitting READY signal on stdout'));
    }
  });
}, 30000);

Note: This test depends on the mini-repo being indexed (requires the analyze test to run first, or a pre-indexed .gitnexus/ fixture). The --idle-timeout 2 ensures the server self-terminates if the test harness misses the kill.


Suggested placement

All three tests belong in gitnexus/test/integration/cli-e2e.test.ts. Tests 1 should live inside the existing analyze describe block (so the ordering dependency is explicit). Tests 2 and 3 can be sibling describe blocks.

If you'd like me to open a follow-up commit implementing these tests directly, just say the word.

@magyargergo
Copy link
Collaborator

Could you please add these extra tests? 🙏

Per @magyargergo's request via @claude's testing strategy:

1. Tool output on stdout (4 tests):
   - cypher: JSON on stdout, not stderr
   - query: JSON on stdout
   - impact: JSON on stdout
   - pipeable: parsed as valid JSON

2. EPIPE clean exit (1 test):
   - Destroys stdout pipe mid-write, verifies exit code 0
   - No JSON dump on stderr

3. eval-server READY signal (1 test):
   - GITNEXUS_EVAL_SERVER_READY appears on stdout
   - Does NOT appear on stderr

All tests gracefully handle CI timeouts (accept null exit code).
@ShunsukeHayashi
Copy link
Contributor Author

Added all 3 integration test categories per @claude's testing strategy:

1. Tool output on stdout (4 tests)

  • cypher: JSON on stdout, not stderr
  • query: JSON on stdout
  • impact: JSON on stdout (handles both success and structured error)
  • Pipeable: JSON.parse() succeeds on stdout (simulates | jq)

2. EPIPE clean exit (1 test)

  • Destroys stdout pipe mid-write (simulates | head -0)
  • Asserts exit code 0, no JSON dump on stderr

3. eval-server READY signal (1 test)

  • GITNEXUS_EVAL_SERVER_READY: appears on stdout
  • Does NOT appear on stderr (would break Docker eval harness)

All tests gracefully handle CI timeouts (status === null → skip, not fail). Uses the existing runCliRaw helper + spawn for async tests.

@magyargergo magyargergo merged commit c1703fc into abhigyanpatwari:main Mar 18, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants