hooks: don't block on stdin EOF when reading hook input (#1398) · Entire
hooks: don't block on stdin EOF when reading hook input (
#1398)
5f734ba → main · Karthik Rameshkumar · 4d ago · 2 files · +108 added / -8 removed
entire hooks gemini session-start (and every other agent hook) hung indefinitely on Windows/Git Bash. The shared ReadAndParseHookInput used io.ReadAll, which blocks until stdin reaches EOF. Agents deliver the hook payload by piping JSON to the hook process, but some keep the write end of that pipe open for the hook's lifetime instead of closing it after the write—on Windows/Git Bash the full payload arrives yet EOF never does, so io.ReadAll waited forever and the session never started (WSL closes stdin, which is why it worked there).
Switch to a streaming json.Decoder, which returns as soon as one complete JSON value has been read, independent of when—or whether—stdin is closed. Also short-circuit when stdin is an interactive terminal (manual runs, or an agent that leaves the console attached instead of a pipe): there is no payload coming, so return promptly as "empty hook input" instead of blocking.
Empty-input and malformed-JSON error semantics are preserved. Fixes the hang for all agents that route through this helper (cursor, claude-code, codex, factory-droid, gemini, opencode, copilot).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
01KXDSZGRJRHEE2N68N467Y9G6 View transcript
Changes
2
cmd/entire/cli/agent
Mevent.go +24/-8
- Aevent_test.go +84
4 unmodified lines
5
6
7
8
9
10
11
12
13
14
145 unmodified lines
160
161
162
160
161
163
164
165
166
167
168
169
170
171
172
173
174
163
164
165
166
167
175
176
177
178
179
180
181
182
183
171
184
185
186
187
188
189
190
4 unmodified lines
"errors"
"fmt"
"io"
"os"
"time"
"golang.org/x/term"
// EventType represents a normalized lifecycle event from any agent.
145 unmodified lines
Metadata map[string]string
}
// ReadAndParseHookInput reads all bytes from stdin and unmarshals JSON into the given type.
// This is a shared helper for agent ParseHookEvent implementations.
// ReadAndParseHookInput decodes a single JSON hook payload from stdin into the
// given type. This is a shared helper for agent ParseHookEvent implementations.
//
// It deliberately does NOT use io.ReadAll, which waits for stdin to reach EOF.
// Agents drive hooks by piping a JSON payload to the hook process, but some
// keep the write end of that pipe open for the hook's lifetime rather than
// closing it after writing—notably on Windows/Git Bash, where a full payload
// arrives but EOF never does. io.ReadAll then blocked indefinitely and the hook
// (e.g. gemini session-start) hung forever (issue #1398). A streaming
// json.Decoder returns as soon as one complete JSON value has been read,
// independent of when—or whether—stdin is closed.
func ReadAndParseHookInput[T any](stdin io.Reader) (*T, error) {
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("failed to read hook input: %w", err)
}
if len(data) == 0 {
// If stdin is an interactive terminal there is no payload coming at all: the
// command was run by hand, or the agent left the console attached instead of
// wiring up a pipe. Decoding would block waiting for input that never comes,
// so treat it as empty and return promptly (also issue #1398).
if f, ok := stdin.(*os.File); ok && term.IsTerminal(int(f.Fd())) { //nolint:gosec // G115: uintptr->int is safe for fd
return nil, errors.New("empty hook input")
}
var result T
if err := json.Unmarshal(data, &result); err != nil {
if err := json.NewDecoder(stdin).Decode(&result); err != nil {
if errors.Is(err, io.EOF) {
return nil, errors.New("empty hook input")
}
return nil, fmt.Errorf("failed to parse hook input: %w", err)
}
return &result, nil
}
``` go
Mcmd/entire/cli/agent/event.go +24/-8
``` go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package agent
import (
"io"
"strings"
"testing"
"time"
)
type hookInput struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
}
// TestReadAndParseHookInput_ReturnsBeforeEOF proves the hook reader returns as
// soon as a complete JSON value has arrived, WITHOUT waiting for stdin to be
// closed. On Windows/Git Bash the agent keeps the pipe's write end open for the
// hook's lifetime; io.ReadAll blocked forever there (issue #1398). We simulate
// that by writing the payload to an io.Pipe and never closing the writer.
func TestReadAndParseHookInput_ReturnsBeforeEOF(t *testing.T) {
t.Parallel()
pr, pw := io.Pipe()
// Write a complete payload, then hold the pipe open (never Close) — mimics an
// agent that keeps stdin open after delivering the JSON.
go func() {
if _, err := pw.Write([]byte(`{"session_id":"s1","transcript_path":"/t.jsonl"}`)); err != nil {
_ = pw.CloseWithError(err)
}
// Intentionally no pw.Close() on success: stdin stays open, so EOF never arrives.
}()
type result struct {
val *hookInput
err error
}
done := make(chan result, 1)
go func() {
v, err := ReadAndParseHookInput[hookInput](pr)
done <- result{v, err}
}()
select {
case r := <-done:
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.val == nil || r.val.SessionID != "s1" || r.val.TranscriptPath != "/t.jsonl" {
t.Fatalf("unexpected value: %+v", r.val)
}
case <-time.After(3 * time.Second):
t.Fatal("ReadAndParseHookInput blocked waiting for EOF — regression of #1398")
}
}
func TestReadAndParseHookInput_EmptyInputEOF(t *testing.T) {
t.Parallel()
_, err := ReadAndParseHookInput[hookInput](strings.NewReader(""))
if err == nil || !strings.Contains(err.Error(), "empty hook input") {
t.Fatalf("want 'empty hook input' error, got: %v", err)
}
}
func TestReadAndParseHookInput_MalformedJSON(t *testing.T) {
t.Parallel()
_, err := ReadAndParseHookInput[hookInput](strings.NewReader(`{"session_id": INVALID}`))
if err == nil || !strings.Contains(err.Error(), "failed to parse hook input") {
t.Fatalf("want 'failed to parse hook input' error, got: %v", err)
}
}
func TestReadAndParseHookInput_ValidPayload(t *testing.T) {
t.Parallel()
got, err := ReadAndParseHookInput[hookInput](strings.NewReader(`{"session_id":"abc","transcript_path":"/x"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.SessionID != "abc" || got.TranscriptPath != "/x" {
t.Fatalf("unexpected value: %+v", got)
}
}