ข้ามไปยังเนื้อหา

Worked review 3: a CLI with context, signals, and timeout

CLI นี้ fetch resource สามตัวทีละตัว พร้อม timeout รวมและการจัดการ Ctrl+C มัน compile ผ่าน, go vet ไม่บ่นอะไรเลย, golangci-lint run ก็ไม่บ่นอะไรเลย, และมันรันได้ มันยังมี bug จริง 5 ตัว — สองตัวไม่มีเครื่องมือไหนในคอร์สนี้จับได้ และอีกหนึ่งตัวเครื่องมือจับได้แค่ครึ่งเดียว

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
  1. context.WithTimeout(context.Background(), 2) ขาด * time.Second 2 เป็น untyped constant ที่แปลงตรงๆ เป็น time.Duration(2) — สอง nanosecond context จึงเลย deadline ไปแล้วก่อนที่ fetch call แรกจะเริ่มด้วยซ้ำ ตรงกับ output จริงเป๊ะ: error: timed out fetching users: context deadline exceeded บน resource ตัวแรกสุด
  2. 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 ง่ายๆ ของ analyzer lostcancel มองเห็นได้
  3. 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:
    sending SIGINT to myself, 500ms after NotifyContext with no stop() call
    still alive: default SIGINT-exit behavior was never restored (stop() was never called)
    โปรแกรมเดียวกันที่ไม่มี signal handling เลย exit ทันทีกับ signal เดียวกันเป๊ะ:
    sending SIGINT to myself with no signal handling installed at all
    signal: interrupt
    การลืม stop() ไม่ได้แค่รั่ว memory — มันปิดการใช้งาน Ctrl+C แบบเงียบๆ ไปตลอดชีวิตที่เหลือของ process
  4. 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 ทิ้งไปเลย
  5. 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)
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 สะอาด

fetch แรกสุดของโปรแกรมที่มี bug timeout ทันที ก่อนที่งานจำลอง 30ms ใดๆ จะทำเสร็จเลยด้วยซ้ำ สาเหตุคืออะไร?
go vet ขึ้น warning lostcancel ให้กับ ctx, _ := context.WithTimeout(...) แต่ไม่พูดอะไรเลยกับ ctx, cancel := context.WithTimeout(...); _ = cancel ทั้งที่ทั้งคู่ leak เหมือนกันเป๊ะ ช่องว่างนี้บอกอะไรเกี่ยวกับ analyzer lostcancel?
การทิ้ง function stop ที่ signal.NotifyContext คืนมา มีต้นทุนอะไรจริงๆ ตามที่ documentation ของมันบอกและ demo ที่ verify แล้วในบทนี้แสดงให้เห็น?
ในเวอร์ชันที่มี bug บรรทัด (background: fetch for users finished anyway, nobody was waiting) พิมพ์ออกมา *หลัง* error: timed out fetching users: ... ลำดับนี้พิสูจน์อะไร?