Fleet convention: reading pm items from a package

Every pm package that shells out to the pm CLI to read items must cap the child process's stdout buffer explicitly. This page records why, and the shape the fleet settled on, so the next package does not rediscover it the hard way.

The failure

Node's spawnSync / execFileSync default maxBuffer is 1 MiB. A pm workspace crosses that at a few hundred items — this repo's own tracker (443 items) dumps ~1,054,000 bytes for pm list-all --json --include-body.

Past the cap Node kills the child and hands back:

status: null   signal: SIGTERM   error.code: ENOBUFS   stderr: ""

Two things make this worse than an ordinary error:

  1. stderr is empty. The near-universal pattern if (result.status !== 0) throw result.stderr || "pm ... failed" therefore reports an unexplained failure with nothing to act on.
  2. Never-throw paths turn it into a wrong answer. A read that returns [] (demo/starter helpers) or { results: [] } (search providers) makes an unreadable workspace indistinguishable from an empty one — silence that looks like success.

At larger sizes stdout is genuinely truncated mid-document; forcing a 100 KiB cap on the same workspace yields Unterminated string in JSON at position 130829.

The convention

/** Read-buffer cap for `pm` output, in bytes. 64 MiB by default; override with the
 * `PM_JSON_MAX_BUFFER` env var. Resolved per call so the override takes effect
 * without an import-order dependency. Invalid or non-positive values fall back to
 * the default rather than silently disabling the guard. */
function pmJsonMaxBuffer(): number {
  // Number(), not parseInt(): parseInt("64MiB") silently yields 64, which would
  // impose a 64-BYTE cap and break every ordinary read while appearing to honor
  // the documented invalid-value fallback. Number() rejects the whole string.
  const raw = Number(process.env.PM_JSON_MAX_BUFFER);
  return Number.isSafeInteger(raw) && raw > 0 ? raw : 64 * 1024 * 1024;
}

Rules that came out of the review rounds:

  • Pass maxBuffer on every pm read, including single-item pm show --json (a large body is enough) and central pmRun-style spawners.
  • Check result.error separately from result.status. An overrun sets error.code === "ENOBUFS" with status: null; a status check alone reports it as an unexplained failure.
  • Report a nonzero exit too. Hardening only the error branch leaves the original silent-failure hole open for unreadable or invalid workspaces. Log the exit code and stderr; never-throw paths can keep their contract and still say what went wrong.
  • Resolve the cap per call, not at module load, or the env override depends on import order (and the branch becomes untestable).
  • Parse the override strictly. Number() + Number.isSafeInteger, never parseIntparseInt("64MiB") is the plausible typo that turns the guard into a guaranteed failure.
  • Only advertise remedies the reader can apply. "Narrow the operation" belongs in importer/exporter messages that really have --labels / --since / --status / --type filters; whole-workspace readers should name the env var only.
  • Test the failure contract, not just the happy path. With the cap env configurable, force an overrun against a real pm init workspace and assert both the documented return value and that the cause was reported.

Status

Fixed 2026-07-24 in: pm-github, pm-csv, pm-beads, pm-gantt-chart, pm-starter, pm-jira, pm-todos, pm-ts-starter.

Already compliant: pm-changelog, pm-context, pm-brief, pm-graph, pm-linear.

Audit a package with:

grep -n "spawnSync\|execFileSync" index.ts src/*.ts

and confirm every call that captures pm JSON passes a maxBuffer. Tracked in this repo as pm-cli-website-j766.


Fleet Pm Read Limits local
Ein Problem melden