Worked review 3: a CLI with context, signals, and timeout
CLI นี้ fetch resource สามตัวทีละตัว พร้อม timeout รวมและการจัดการ Ctrl+C มัน compile ผ่าน, go vet ไม่บ่นอะไรเลย, golangci-lint run ก็ไม่บ่นอะไรเลย, และมันรันได้ มันยังมี bug จริง 5 ตัว — สองตัวไม่มีเครื่องมือไหนในคอร์สนี้จับได้ และอีกหนึ่งตัวเครื่องมือจับได้แค่ครึ่งเดียว
The program
หัวข้อที่มีชื่อว่า “The program”package main
import ( "context" "fmt" "os" "os/signal" "time")
func fetch(ctx context.Context, name string, take time.Duration) (string, error) { done := make(chan string, 1) go func() { time.Sleep(take) // pretend network work — ignores ctx entirely done <- name + " ready" fmt.Println(" (background: fetch for", name, "finished anyway, nobody was waiting)") }()
select { case msg := <-done: return msg, nil case <-ctx.Done(): return "", ctx.Err() }}
func run() error { ctx, cancel := context.WithTimeout(context.Background(), 2) _ = cancel // referenced but never called — still leaks, but go vet cannot tell sigCtx, _ := signal.NotifyContext(ctx, os.Interrupt)
for _, name := range []string{"users", "orders", "invoices"} { msg, err := fetch(sigCtx, name, 30*time.Millisecond) if err != nil { if err.Error() == "context deadline exceeded" { return fmt.Errorf("timed out fetching %s: %v", name, err) } return fmt.Errorf("fetch %s: %v", name, err) } fmt.Println(msg) } return nil}
func main() { if err := run(); err != nil { fmt.Println("error:", err) } time.Sleep(60 * time.Millisecond) // long enough to see the abandoned background print} fetch ตัวแรกสุด timeout ทันที — ก่อนที่งานจำลอง 30ms จะมีโอกาสเสร็จด้วยซ้ำ — และมีบรรทัดหนึ่งพิมพ์ออกมาหลังจาก error ทั้งที่ไม่ควรพิมพ์เลยถ้า timeout หยุดงานได้จริง ลองระบุ bug อย่างน้อย 3 ใน 5 ตัวก่อนเปิดเฉลย
เฉลย 5 bug
context.WithTimeout(context.Background(), 2)ขาด* time.Second2เป็น untyped constant ที่แปลงตรงๆ เป็นtime.Duration(2)— สอง nanosecond context จึงเลย deadline ไปแล้วก่อนที่fetchcall แรกจะเริ่มด้วยซ้ำ ตรงกับ output จริงเป๊ะ:error: timed out fetching users: context deadline exceededบน resource ตัวแรกสุดcancelถูกอ้างอิงแต่ไม่เคยถูกเรียก_ = cancelทำให้ “ตัวแปรถูกใช้” แต่ไม่เคยเรียก function จริง timer เบื้องหลังWithTimeoutรั่วไปตลอด 2 nanosecond ของ… ความว่างเปล่า เพราะมันหมดเวลาไปแล้ว แต่โค้ดรูปแบบเดียวกันที่มี timeout ยาวขึ้นจะรั่วตลอดระยะเวลานั้นเต็มๆ การสะกดมีผลกับ tooling: เขียนใหม่เป็นรูปที่ผิดชัดกว่าคือctx, _ := context.WithTimeout(...)ทำให้go vetค้านทันที:การสะกดแบบmain.go:11:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak_ = cancelในโปรแกรมจริงด้านบนรั่วแบบเดียวกันเป๊ะ และgo vetไม่พูดอะไรเลย — เป็นช่องว่างจริงในสิ่งที่ heuristic ง่ายๆ ของ analyzerlostcancelมองเห็นได้stopจากsignal.NotifyContextถูกทิ้งและไม่เคยถูกเรียก นี่ไม่ใช่แค่ “leak เล็กๆ” — documentation ของsignal.NotifyContextบอกไว้ชัดเจน: “Future interrupts received will not trigger the default (exit) behavior until the returned stop function is called.” verify โดยตรงแล้ว: process ที่เรียกsignal.NotifyContext(ctx, os.Interrupt)แล้วทิ้งstopจากนั้นได้รับSIGINTจริง จะไม่ exit:โปรแกรมเดียวกันที่ไม่มี signal handling เลย exit ทันทีกับ signal เดียวกันเป๊ะ:sending SIGINT to myself, 500ms after NotifyContext with no stop() callstill alive: default SIGINT-exit behavior was never restored (stop() was never called)การลืมsending SIGINT to myself with no signal handling installed at allsignal: interruptstop()ไม่ได้แค่รั่ว memory — มันปิดการใช้งาน Ctrl+C แบบเงียบๆ ไปตลอดชีวิตที่เหลือของ process- error check เทียบ
err.Error() == "context deadline exceeded"และ wrap ด้วย%vมันบังเอิญทำงานได้ตรงนี้เพราะยังไม่มีอะไร wrap error นี้ แต่มันห่างจากการพังแบบเงียบๆ แค่ refactor เดียว: ทันทีที่ caller ตัวไหน wrap error นี้อีกที (fmt.Errorf("worker failed: %w", err)) string comparison จะไม่ match อีกต่อไป และ%v(แทนที่จะเป็น%w) ก็จะทำให้errors.Isพังเร็วกว่านั้นอีก เพราะตัด chain ทิ้งไปเลย - background goroutine ของ
fetchไม่เคยดูctxเลย มันแค่ sleep ตามtakeแล้วก็ส่ง จบ output จริงพิสูจน์ด้วยลำดับของมันเอง:error: timed out fetching users: ...พิมพ์ทันที และต่อเมื่อหลังจากนั้นเท่านั้น — พอ sleep 30ms เต็มผ่านไป —(background: fetch for users finished anyway, nobody was waiting)ถึงพิมพ์ caller ยอมแพ้ไปแล้ว แต่ “งาน” ยังรันจนจบอยู่ดี เปลืองอะไรก็ตามที่มันถืออยู่ (connection, file handle, goroutine)
The corrected program
หัวข้อที่มีชื่อว่า “The corrected program”package main
import ( "context" "errors" "fmt" "os" "os/signal" "time")
func fetch(ctx context.Context, name string, take time.Duration) (string, error) { done := make(chan string, 1) go func() { select { // fixed: the background goroutine now watches ctx too case <-time.After(take): done <- name + " ready" case <-ctx.Done(): return // stop pretending to work once the caller stopped waiting } }()
select { case msg := <-done: return msg, nil case <-ctx.Done(): return "", ctx.Err() }}
func run() error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // fixed: real unit defer cancel() // fixed: always release the timer sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() // fixed: always unregister — restores default Ctrl+C behavior when we're done
for _, name := range []string{"users", "orders", "invoices"} { msg, err := fetch(sigCtx, name, 30*time.Millisecond) if err != nil { if errors.Is(err, context.DeadlineExceeded) { // fixed: errors.Is, not string comparison return fmt.Errorf("timed out fetching %s: %w", name, err) // fixed: %w keeps the chain } return fmt.Errorf("fetch %s: %w", name, err) } fmt.Println(msg) } return nil}
func main() { if err := run(); err != nil { fmt.Println("error:", err) } time.Sleep(60 * time.Millisecond)} รันเวอร์ชันที่แก้แล้ว: ทั้งสาม resource พิมพ์ ready (timeout ตอนนี้เป็น 2 วินาทีจริงๆ นานพอสำหรับ fetch 30ms) ไม่มี background print กำพร้าเพราะ fetch goroutine ตอนนี้ exit ทันทีที่ ctx.Done() fire และทั้ง cancel() กับ stop() ถูก defer ทันทีข้างจุดที่สร้างมัน go vet และ golangci-lint run ทั้งคู่รายงาน 0 issue บนไฟล์ทั้งสองเวอร์ชัน — นี่แหละคือบทเรียนในตัวมันเอง: 3 ใน 5 bug ที่นี่ compile สะอาด, vet สะอาด, และ lint สะอาด