@@ -1,6 +1,7 @@
1package shell
1package shell
4 "encoding/json"
5 "encoding/json"
@@ -36,10 +37,16 @@ Options:
36// flags: -r (raw output), -c (compact output), -s (slurp), -n (null input),
37// flags: -r (raw output), -c (compact output), -s (slurp), -n (null input),
37// -e (exit status), -R (raw input), and --arg name value.
38// -e (exit status), -R (raw input), and --arg name value.
40+// ctx is polled at each iteration of the output loop and at each reader in
41+// [readInputs] so that hook timeouts or other cancellations can interrupt
42+// long-running queries. A cancelled context surfaces as ctx.Err(), not an
43+// [interp.ExitStatus], so callers (e.g. the hook runner) can distinguish
44+// "filter exited non-zero" from "we ran out of time".
39// Note that this is somewhat of a reimplmentation of the CLI of the glorious
46// Note that this is somewhat of a reimplmentation of the CLI of the glorious
40// github.com/itchyny/gojq, and we'd ideally get the CLI exposed upstream to
47// github.com/itchyny/gojq, and we'd ideally get the CLI exposed upstream to
41// avoid this falling out of sync.
48// avoid this falling out of sync.
42−func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
49+func handleJQ(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
44 rawOutput bool
51 rawOutput bool
45 compact bool
52 compact bool
@@ -143,8 +150,13 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
145 // Build input values.
152 // Build input values.
146− inputs, err := readInputs(stdin, fileArgs, nullInput, rawInput, slurp)
153+ inputs, err := readInputs(ctx, stdin, fileArgs, nullInput, rawInput, slurp)
147 if err != nil {
154 if err != nil {
155+ // Prefer surfacing ctx cancellation verbatim so timeouts are
156+ // distinguishable from user input errors.
157+ if ctxErr := ctx.Err(); ctxErr != nil {
148 fmt.Fprintf(stderr, "jq: %s\n", err)
160 fmt.Fprintf(stderr, "jq: %s\n", err)
149 return interp.ExitStatus(2)
161 return interp.ExitStatus(2)
@@ -153,6 +165,12 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
153 for _, input := range inputs {
165 for _, input := range inputs {
154 iter := code.Run(input, argValues...)
166 iter := code.Run(input, argValues...)
168+ // Poll ctx on every value so a long-running filter (e.g. a
169+ // generator over a slurped array) can be interrupted by hook
170+ // timeouts without waiting for iter.Next to yield.
171+ if err := ctx.Err(); err != nil {
156 v, ok := iter.Next()
174 v, ok := iter.Next()
@@ -177,7 +195,22 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
179// readInputs reads JSON (or raw) input values from stdin or files.
197// readInputs reads JSON (or raw) input values from stdin or files.
199+// ctx is polled in three places so that a cancellation observed mid-read
200+// short-circuits promptly:
201+// - between readers (before opening the next file / consuming stdin);
202+// - on every io.Read call via ctxReader, so io.ReadAll on a large but
203+// non-blocking source (e.g. the bytes.NewReader payload the hook
204+// runner supplies) returns ctx.Err() on the next chunk boundary;
205+// - inside the post-read value accumulation loops (raw-input line
206+// split and JSON stream decode), which are otherwise unbounded in
207+// the size of the input.
209+// A reader that blocks forever in Read (e.g. an unterminated pipe) can
210+// still outlast ctx; the outer abandon-goroutine path in the hook
211+// runner (internal/hooks/runner.go) is the authoritative enforcer for
180−func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
213+func readInputs(ctx context.Context, stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
181 if nullInput {
214 if nullInput {
182 return []any{nil}, nil
215 return []any{nil}, nil
@@ -198,8 +231,16 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
199 var vals []any
232 var vals []any
200 for _, r := range readers {
233 for _, r := range readers {
234+ if err := ctx.Err(); err != nil {
201− data, err := io.ReadAll(r)
237+ data, err := io.ReadAll(ctxReader{ctx: ctx, r: r})
202 if err != nil {
238 if err != nil {
239+ // ctxReader surfaces ctx.Err() verbatim; preserve it so the
240+ // caller can distinguish cancellation from a parse error.
241+ if ctxErr := ctx.Err(); ctxErr != nil {
203 return nil, err
244 return nil, err
@@ -209,6 +250,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
209 vals = append(vals, strings.Join(lines, "\n"))
250 vals = append(vals, strings.Join(lines, "\n"))
211 for _, line := range lines {
252 for _, line := range lines {
253+ if err := ctx.Err(); err != nil {
212 if line != "" || !slurp {
256 if line != "" || !slurp {
213 vals = append(vals, line)
257 vals = append(vals, line)
@@ -221,6 +265,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
221 dec := json.NewDecoder(strings.NewReader(string(data)))
265 dec := json.NewDecoder(strings.NewReader(string(data)))
222 var streamVals []any
266 var streamVals []any
268+ if err := ctx.Err(); err != nil {
224 var v any
271 var v any
225 if err := dec.Decode(&v); err != nil {
272 if err := dec.Decode(&v); err != nil {
226 if err == io.EOF {
273 if err == io.EOF {
@@ -244,6 +291,24 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
244 return vals, nil
291 return vals, nil
294+// ctxReader wraps an io.Reader so that each Read call checks ctx first.
295+// This makes io.ReadAll over a large but non-blocking source (e.g. a
296+// bytes.Reader of the hook stdin payload) cancellable on the next chunk
297+// boundary. A reader that itself blocks in Read will still outlast ctx —
298+// the hook runner's abandon-goroutine path is the enforcer of last resort
300+type ctxReader struct {
305+func (cr ctxReader) Read(p []byte) (int, error) {
306+ if err := cr.ctx.Err(); err != nil {
247// writeValue writes a single jq output value.
312// writeValue writes a single jq output value.
248func writeValue(w io.Writer, v any, raw, compact, join bool) error {
313func writeValue(w io.Writer, v any, raw, compact, join bool) error {