Skip to content

Hook subprocess stdin write-end left open (no EOF) for tool-use hooks — documented $(cat) pattern hangs #4034

Description

@jens-f

Summary

When the Copilot CLI spawns a hook subprocess and writes the hook's JSON payload to that subprocess's stdin, it does not appear to close (send EOF on) the stdin write-end for tool-use hooks (preToolUse, postToolUse). For sessionStart the write-end is closed and the subprocess sees EOF promptly. For the tool-use hooks the write-end stays open, so a hook that reads stdin to completion never observes end-of-input.

This directly breaks the stdin-reading pattern shown in the official docs (INPUT=$(cat)), which blocks until EOF, and it holds the hook subprocess's stdin pipe handle open so the process cannot exit on its own until the CLI eventually tears the pipe down.

Environment

  • github/copilot-cli v1.0.64; still present as of the latest release at time of writing (v1.0.68).
  • Reproduced on macOS and Linux. The hook subprocess in our case is a Node script, but the mechanism (a read that waits for EOF) is language-agnostic.

Expected behavior

After the CLI writes a hook's JSON payload to the subprocess stdin, it closes the write-end (sends EOF) for all hook events alike: sessionStart, preToolUse, and postToolUse. So a hook that reads stdin to completion terminates deterministically.

Actual behavior

  • sessionStart: stdin write-end is closed → the hook sees EOF immediately. ✅
  • preToolUse / postToolUse: stdin write-end is left open → a hook reading to EOF never returns; its stdin pipe handle stays open and keeps the subprocess alive until the CLI's own timeout/teardown.

Reproduction

A minimal hook that reads stdin to EOF, per the documented $(cat) pattern:

#!/usr/bin/env bash
# hook.sh: reads the payload the way the docs show
INPUT=$(cat)                      # blocks here until EOF
echo "read ${#INPUT} bytes" >&2   # never prints for tool-use hooks

Wire it as a preToolUse/postToolUse hook, then trigger a tool call.

  • Observed: hook.sh never reaches the echo; the subprocess lingers.
  • With the same hook wired as sessionStart: it reads and prints immediately.

(A Node equivalent: for await (const chunk of process.stdin) { … } behaves identically after the last chunk: the async iterator never ends for tool-use hooks.)

Why this matters

  1. It breaks the CLI's own documented usage. The hooks how-to reads stdin via INPUT=$(cat), and cat returns only on EOF — so the documented pattern deadlocks for exactly the hooks most people write (tool-use).
  2. It leaks the hook subprocess. The open stdin pipe is a live, ref'd handle that keeps the process alive; hooks that would otherwise exit in milliseconds hang until the CLI's timeout, and overlapping hooks stack.
  3. The stdin/EOF contract is undocumented. Neither the hooks reference nor the use-hooks how-to states whether stdin is closed after the payload. The docs only imply "read stdin" via the $(cat) example.

Not a duplicate of

Suggested resolution

Either (preferably both):

  1. Close the hook subprocess's stdin write-end after writing the payload, for every hook event (match the sessionStart behavior).
  2. Document the stdin contract explicitly in the hooks reference, whether stdin is closed after the payload, and the recommended read pattern.

Notes

Hook-side workaround: don't read to EOF, instead read until the JSON payload parses as complete, or release the pipe (stdin.pause() / stdin.unref()) once the payload is in hand. But that only works around a contract the CLI should guarantee.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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