Skip to content

Commit 6a2055c

Browse files
jkyberneeesclaude
andauthored
perf(tools): centralize batch parallelism with panic-safe helper (#33)
* perf(tools): centralize batch parallelism with panic-safe helper The eight batch tools each hand-rolled the same semaphore + drain pattern, and three of them (batch_read, http_batch, parallel_shell) ran their worker bodies with NO per-goroutine panic recovery. A panic in a worker goroutine is not caught by the deferred recover in the spawning Call — it crashes the entire agent process. So a single malformed input to one of those tools could take odek down. - Add parallelMap[I,O]: bounded-concurrency indexed map that recovers a panic in any worker and routes it through a caller-supplied handler, guaranteeing one bad task can never crash the process. Results stay in input order. Replaces ~8 copies of the semaphore/drain boilerplate. - Add toolConcurrency(): CPU-aware worker count clamped to [4,8] (was a hard-coded 4), overlapping more I/O on multi-core machines without thrashing. - Migrate batch_read, count_lines, checksum, word_count, multi_grep, head_tail, parallel_shell, http_batch to the helper. parallel_shell and http_batch keep their serial up-front approval pre-check (which may prompt) and only parallelize the side-effect-free execution; their inline worker bodies are extracted into runOne/fetchOne. Recoverability is the headline fix; the concurrency bump and de-duplication come along for free. Behavior (outputs, ordering, approval semantics) is unchanged — verified by the existing per-tool tests under -race plus new parallelMap tests covering ordering, panic recovery, the concurrency bound, and limit clamping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(parallel): make crash-prevention guarantee unconditional vprotocol axis 2.5/2.8: parallelMap recovers a worker panic and calls the caller's recovered() handler — but if that handler itself panicked, the second panic escaped the goroutine and crashed the process, defeating the helper's whole purpose. Recover around the handler too, so no worker can crash the process under any input. Added a test with a panicking handler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 03bd9b2 commit 6a2055c

4 files changed

Lines changed: 412 additions & 199 deletions

File tree

cmd/odek/file_tool.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -916,21 +916,10 @@ func (t *batchReadTool) Call(argsJSON string) (result string, err error) {
916916
return jsonError(fmt.Sprintf("max %d files per batch_read call", maxBatchFiles))
917917
}
918918

919-
results := make([]batchReadFileResult, len(args.Files))
920-
sem := make(chan struct{}, 4) // bounded concurrency for file I/O
921-
922-
for i, f := range args.Files {
923-
sem <- struct{}{}
924-
go func(idx int, fileArg batchReadFileArg) {
925-
defer func() { <-sem }()
926-
results[idx] = t.readSingle(fileArg)
927-
}(i, f)
928-
}
929-
930-
// Drain — wait for all goroutines
931-
for i := 0; i < cap(sem); i++ {
932-
sem <- struct{}{}
933-
}
919+
results := parallelMap(args.Files, toolConcurrency(), t.readSingle,
920+
func(f batchReadFileArg, p any) batchReadFileResult {
921+
return batchReadFileResult{Path: f.Path, Error: fmt.Sprintf("internal error: %v", p)}
922+
})
934923

935924
return jsonResult(batchReadResult{Results: results})
936925
}

cmd/odek/parallel.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import (
4+
"runtime"
5+
"sync"
6+
)
7+
8+
// toolConcurrency is the worker count the batch tools use for bounded
9+
// parallelism. It scales with the machine (GOMAXPROCS) but stays in a modest
10+
// [4, 8] band: enough to overlap file/network/subprocess I/O on a multi-core
11+
// dev machine, low enough to avoid thrashing disks, sockets, or the process
12+
// table. The batch tools previously hard-coded 4; this never drops below that.
13+
func toolConcurrency() int {
14+
n := runtime.GOMAXPROCS(0)
15+
switch {
16+
case n < 4:
17+
return 4
18+
case n > 8:
19+
return 8
20+
default:
21+
return n
22+
}
23+
}
24+
25+
// parallelMap applies work to every item with at most `limit` workers running
26+
// at once, returning results in input order: out[i] == work(items[i]).
27+
//
28+
// It recovers a panic in any single worker and routes it through
29+
// recovered(item, panicValue) instead of letting it unwind. This is the whole
30+
// point of centralising the pattern: a panic in a goroutine is NOT caught by a
31+
// deferred recover in the function that spawned it — it crashes the entire
32+
// process. Every batch tool fans work out to goroutines, so without this guard
33+
// one malformed input (a nil deref, an out-of-range slice) in a single worker
34+
// takes down the whole agent. Three of the hand-rolled copies this replaces
35+
// were missing per-worker recovery entirely.
36+
//
37+
// A nil `recovered` is tolerated: a panicked slot is left as the zero value.
38+
func parallelMap[I, O any](items []I, limit int, work func(I) O, recovered func(I, any) O) []O {
39+
out := make([]O, len(items))
40+
if len(items) == 0 {
41+
return out
42+
}
43+
if limit < 1 {
44+
limit = 1
45+
}
46+
if limit > len(items) {
47+
limit = len(items)
48+
}
49+
50+
var wg sync.WaitGroup
51+
sem := make(chan struct{}, limit)
52+
for i := range items {
53+
sem <- struct{}{} // acquire before spawning — bounds live goroutines too
54+
wg.Add(1)
55+
go func(idx int) {
56+
defer wg.Done()
57+
defer func() { <-sem }()
58+
defer func() {
59+
if r := recover(); r != nil && recovered != nil {
60+
// The handler runs while unwinding a worker panic; if it
61+
// panics too, that second panic must not escape either — the
62+
// guarantee is that NO worker can crash the process. A
63+
// panicked handler leaves the slot at its zero value.
64+
defer func() { _ = recover() }()
65+
out[idx] = recovered(items[idx], r)
66+
}
67+
}()
68+
out[idx] = work(items[idx])
69+
}(i)
70+
}
71+
wg.Wait()
72+
return out
73+
}

cmd/odek/parallel_test.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
"sync/atomic"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestParallelMap_OrderAndCorrectness(t *testing.T) {
12+
items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
13+
out := parallelMap(items, 3,
14+
func(i int) int { return i * i },
15+
func(i int, _ any) int { return -1 })
16+
for i, v := range out {
17+
if v != i*i {
18+
t.Errorf("out[%d] = %d, want %d", i, v, i*i)
19+
}
20+
}
21+
}
22+
23+
func TestParallelMap_Empty(t *testing.T) {
24+
out := parallelMap(nil, 4,
25+
func(i int) int { return i },
26+
func(i int, _ any) int { return -1 })
27+
if len(out) != 0 {
28+
t.Errorf("expected empty output, got %d", len(out))
29+
}
30+
}
31+
32+
// TestParallelMap_RecoversWorkerPanic is the core recoverability contract: a
33+
// panic in one worker must NOT unwind (which would crash the process) and must
34+
// not affect the other slots — the panicked slot gets the recovered() value.
35+
func TestParallelMap_RecoversWorkerPanic(t *testing.T) {
36+
items := []int{0, 1, 2, 3, 4}
37+
out := parallelMap(items, 3,
38+
func(i int) string {
39+
if i == 2 {
40+
panic("boom")
41+
}
42+
return fmt.Sprintf("ok-%d", i)
43+
},
44+
func(i int, r any) string {
45+
return fmt.Sprintf("recovered-%d-%v", i, r)
46+
})
47+
48+
want := []string{"ok-0", "ok-1", "recovered-2-boom", "ok-3", "ok-4"}
49+
for i := range want {
50+
if out[i] != want[i] {
51+
t.Errorf("out[%d] = %q, want %q", i, out[i], want[i])
52+
}
53+
}
54+
}
55+
56+
func TestParallelMap_MultiplePanicsAllRecovered(t *testing.T) {
57+
items := []int{0, 1, 2, 3, 4, 5}
58+
out := parallelMap(items, 4,
59+
func(i int) int {
60+
if i%2 == 0 {
61+
panic(i)
62+
}
63+
return i
64+
},
65+
func(i int, _ any) int { return -i })
66+
for i := range items {
67+
want := i
68+
if i%2 == 0 {
69+
want = -i
70+
}
71+
if out[i] != want {
72+
t.Errorf("out[%d] = %d, want %d", i, out[i], want)
73+
}
74+
}
75+
}
76+
77+
// TestParallelMap_NilRecoveredLeavesZero verifies a nil recovered handler is
78+
// tolerated: the panicked slot is left as the zero value, and crucially the
79+
// panic still does not crash the process.
80+
func TestParallelMap_NilRecoveredLeavesZero(t *testing.T) {
81+
out := parallelMap([]int{0, 1, 2}, 2,
82+
func(i int) string {
83+
if i == 1 {
84+
panic("x")
85+
}
86+
return "v"
87+
}, nil)
88+
if out[0] != "v" || out[2] != "v" {
89+
t.Errorf("non-panicking slots wrong: %v", out)
90+
}
91+
if out[1] != "" {
92+
t.Errorf("panicked slot should be zero value, got %q", out[1])
93+
}
94+
}
95+
96+
// TestParallelMap_PanickingHandlerDoesNotCrash verifies the guarantee is
97+
// unconditional: even a recovered() handler that itself panics cannot crash the
98+
// process — the worker's slot is just left at its zero value.
99+
func TestParallelMap_PanickingHandlerDoesNotCrash(t *testing.T) {
100+
out := parallelMap([]int{0, 1, 2}, 2,
101+
func(i int) string {
102+
if i == 1 {
103+
panic("work boom")
104+
}
105+
return "ok"
106+
},
107+
func(i int, _ any) string {
108+
panic("handler boom") // must not escape the goroutine
109+
})
110+
if out[0] != "ok" || out[2] != "ok" {
111+
t.Errorf("non-panicking slots wrong: %v", out)
112+
}
113+
if out[1] != "" {
114+
t.Errorf("slot with panicking handler should be zero value, got %q", out[1])
115+
}
116+
}
117+
118+
// TestParallelMap_ConcurrencyBounded verifies no more than `limit` workers run
119+
// at once, even with far more items than the limit.
120+
func TestParallelMap_ConcurrencyBounded(t *testing.T) {
121+
const n, limit = 30, 4
122+
var cur int64
123+
var mu sync.Mutex
124+
peak := int64(0)
125+
release := make(chan struct{})
126+
127+
work := func(i int) int {
128+
c := atomic.AddInt64(&cur, 1)
129+
mu.Lock()
130+
if c > peak {
131+
peak = c
132+
}
133+
mu.Unlock()
134+
<-release // hold the worker so concurrency can be observed
135+
atomic.AddInt64(&cur, -1)
136+
return i
137+
}
138+
139+
items := make([]int, n)
140+
for i := range items {
141+
items[i] = i
142+
}
143+
144+
doneCh := make(chan []int, 1)
145+
go func() {
146+
doneCh <- parallelMap(items, limit, work, func(int, any) int { return -1 })
147+
}()
148+
149+
// Wait until the limit is saturated.
150+
deadline := time.After(2 * time.Second)
151+
for atomic.LoadInt64(&cur) < int64(limit) {
152+
select {
153+
case <-deadline:
154+
close(release)
155+
t.Fatal("workers never reached the concurrency limit")
156+
default:
157+
time.Sleep(time.Millisecond)
158+
}
159+
}
160+
// Give any over-scheduled workers a moment to (incorrectly) start.
161+
time.Sleep(50 * time.Millisecond)
162+
163+
mu.Lock()
164+
gotPeak := peak
165+
mu.Unlock()
166+
if gotPeak > limit {
167+
close(release)
168+
t.Fatalf("peak concurrency %d exceeded limit %d", gotPeak, limit)
169+
}
170+
171+
close(release)
172+
out := <-doneCh
173+
for i, v := range out {
174+
if v != i {
175+
t.Errorf("out[%d] = %d, want %d", i, v, i)
176+
}
177+
}
178+
}
179+
180+
func TestParallelMap_LimitClamping(t *testing.T) {
181+
items := []int{0, 1, 2}
182+
// limit larger than len and limit < 1 must both still produce correct output.
183+
for _, limit := range []int{0, -5, 100} {
184+
out := parallelMap(items, limit,
185+
func(i int) int { return i + 1 },
186+
func(i int, _ any) int { return -1 })
187+
for i := range items {
188+
if out[i] != i+1 {
189+
t.Errorf("limit=%d: out[%d] = %d, want %d", limit, i, out[i], i+1)
190+
}
191+
}
192+
}
193+
}
194+
195+
func TestToolConcurrency_InBand(t *testing.T) {
196+
c := toolConcurrency()
197+
if c < 4 || c > 8 {
198+
t.Errorf("toolConcurrency() = %d, want in [4,8]", c)
199+
}
200+
}

0 commit comments

Comments
 (0)