Skip to content

fix(serve): lazy-import StdioServerTransport so serve exits cleanly on startup errors #157

@stackbilt-admin

Description

@stackbilt-admin

Problem

charter serve hangs indefinitely when the .ai/ directory is missing or invalid. The process must be killed — it never exits on its own.

$ charter serve --ai-dir /tmp/nonexistent
# (hangs forever, no output, no exit)

The error-guard logic in serveCommand() is correct — it detects the missing path, writes a JSON-RPC error to stdout, and throws CLIError. But the process cannot exit.

Root Cause

serve.ts imports StdioServerTransport at the top of the file:

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

The MCP SDK's StdioServerTransport binds process.stdin to a raw/flowing read loop at module load time. This registers an active handle in Node's event loop before any command logic runs. Even after CLIError is thrown and caught, the stdin handle keeps the process alive indefinitely.

Confirmed root cause:

require('@modelcontextprotocol/sdk/server/stdio.js');
process.exit(0); // ← never exits

Because serve.ts is unconditionally imported by index.ts, every charter invocation loads the MCP SDK and hooks stdin — not just charter serve.

Steps to Reproduce

  1. charter serve --ai-dir /tmp/nonexistent
  2. Observe: process hangs, no output
  3. Confirm: timeout 3 charter serve --ai-dir /tmp/nonexistent; echo $? → exit 124 (killed by timeout)

Expected Behavior

Process exits within ~100ms with a non-zero code and a clear error message:

charter: No .ai/ directory found. Run: charter init

Actual Behavior

Process hangs indefinitely. Agents and shells must kill it via timeout or SIGKILL.

Fix

Replace the top-level import with a dynamic import inside serveCommand(), after all path/manifest guards have passed:

// After fs.existsSync guards pass — acquire stdin only on the happy path
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
const stdioTransport = new StdioServerTransport();
await server.connect(stdioTransport);

Also add a child-process integration test that asserts charter serve --ai-dir /tmp/nonexistent exits within 1s with non-zero status.

Environment

  • Charter CLI: v0.15.1
  • Discovered during: ADX-008 dogfood session (v0.15.1 release validation)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p0Must ship this cycle

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions