Skip to content

PULSE/modules/work.ts: fetchIssues pipes gh stderr but never drains it — swallows the error cause and can deadlock until the 12s timeout #1482

Description

@ichoosetoaccept

Summary

fetchIssues() in LIFEOS/PULSE/modules/work.ts spawns gh issue list with stderr: "pipe" but never reads that stream. On a non-zero exit it logs only the exit code and discards gh's actual error message:

const proc = Bun.spawn(
  ["gh", "issue", "list", "--repo", repo, "--state", "all", "--limit", "500", "--json", /* … */],
  { stdout: "pipe", stderr: "pipe", timeout: 12000 },
);
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
  console.error(`[${MODULE}] gh issue list failed (exit ${exitCode})`); // gh's stderr is lost
  return null;
}

This one root cause (stderr piped but never drained) has two consequences:

1. No diagnosability. The work module polls every 60s, so a transient gh failure (auth blip, GitHub secondary rate limit, network) fills the log with cause-less [work] gh issue list failed (exit 1) lines. There's no way to tell an auth problem from a rate-limit from a repo-access problem. Observed live: hundreds of such lines accumulated in pulse-stderr.log, while the identical gh issue list command run by hand against the same repo returns exit 0.

2. Latent deadlock → spurious timeout failure. Because stderr is piped but never consumed, if gh ever writes more than the OS pipe buffer (~64KB) to stderr, gh blocks on the stderr write, await proc.exited never resolves, and the process is killed at the 12s timeout — surfacing as a failure even though gh would have succeeded. This is currently masked when the work repo has zero issues (gh writes nothing to stderr), but any gh deprecation/warning banner or a larger error payload can trigger it.

This is the same "undrained/dangling Bun stream" class already fixed for hook stdin in #1021 (hooks/lib/hook-io.ts); the fix here is symmetric.

Affected

  • LIFEOS/PULSE/modules/work.tsfetchIssues() — identical on main and in the v7.1.1 release.
  • The sibling gh helpers in TOOLS/WorkSweep.ts (ghIssueSearchSlug, ghLabelsExisting, ghListOpenIssues, ghCreateIssue, ghAddLabel) share the same pipe-stderr-but-never-read pattern.

Suggested fix

Drain both pipes and include stderr in the failure log:

const [stdout, stderr] = await Promise.all([
  new Response(proc.stdout).text(),
  new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
  console.error(`[${MODULE}] gh issue list failed (exit ${exitCode}): ${stderr.trim().slice(0, 500)}`);
  return null;
}

Draining both streams removes the deadlock and makes failures self-explaining.

Environment

  • LifeOS 7.1.1
  • Ubuntu, Pulse running as a systemd --user service
  • gh version 2.96.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions