Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The end of_execution in Go code (DevoxxFR 2023)

The end of_execution in Go code (DevoxxFR 2023)

There are many ways a function, including a unit test, can end its execution in Go code. Some of these ways include return, os.Exit, runtime.Goexit.

They all interact in different ways with the defer instruction, the testing.Cleanup function, and the actual goroutine execution.

Let's see how to get our code to end its life properly, ensuring defer and Cleanup work as expected even in the face of panics, fatals, and exits.

This session was presented at the DevoxxFR 2023 conference.

Frédéric G. MARAND

May 26, 2023
Tweet

More Decks by Frédéric G. MARAND

Other Decks in Programming

Transcript

  1. Frédéric G. MARAND - 23 avril 2023 The end of

    execution (In a Go program) Devoxx FR 2023 1
  2. © 2023 OSInet 3 Why we can’t have nice things

    What we want: • defer / recover is a real nice Go pair of features • Let’s use them both ! How Go breaks them: • func main() doesn’t return a process exit code to the OS • os.Exit() emits a process exit code, but skips defer. • os.Exit(0) in SUT panics • os.Exit(nonZero) in SUT kills the test
  3. © 2023 OSInet 4 How execution ends Functions / goroutines:

    • return ◦ Runs defer-red code ◦ Terminates goroutine if called using go fn() ◦ Does not set exit code in main • panic ◦ Prepare stack trace ◦ Return (see above) ◦ If not recovered, dump trace • os.Exit, syscall.Exit, log.Fatal* ◦ Exit without returning Goroutines: • All userland goroutines ◦ End of return • os.Exit, syscall.Exit, log.Fatal* ◦ No defer-red code 
 Exit without return-ing • runtime.Goexit ◦ Runs defer-red code ◦ Ends goroutine ◦ Does not handle #1 finishing program: deadlock Two separate notions: the call flow, and the scheduling (goroutine)
  4. © 2023 OSInet 6 runtime.Goexit vs main() package main import

    ( "log" "runtime" ) func main() { defer func() { log.Println("Deferred code") }() log.Println("In main") runtime.Goexit() // DEADLOCK PANIC ! log.Println("Nothing after os.Exit") }
  5. © 2023 OSInet 7 os.Exit() in tests package problem3 import

    ( "log" "os" ) func Exit0() { log.Println("In Exit0") os.Exit(0) } func Exit1() { log.Println("In Exit1") os.Exit(1) } func TestExit(t *testing.T) { for _, test := range [...]struct { name string fn func() }{ {"Exit0", Exit0}, {"Exit1", Exit1}, } { t.Run(fmt.Sprintf("%#v", test.name), 
 func(t *testing.T) { defer func() { rec := recover() switch x := rec.(type) { case nil: t.Fatal("nothing recovered") case string: if !strings.Contains(x, 
 "unexpected call to os.Exit(0)") { t.Fatalf("unexpected string: %q", x) } t.Log("successful recovery") default: t.Fatalf("unexpected recovery: %#v", x) } }() test.fn() }) } }
  6. © 2023 OSInet 8 os.Exit() in tests: 0 vs 1

    % go test -v -run TestExit/Exit0 === RUN TestExit === RUN TestExit/"Exit0" 2023/01/09 14:40:02 In Exit0 problem3_test.go:27: successful recovery --- PASS: TestExit (0.00s) --- PASS: TestExit/"Exit0" (0.00s) PASS ok code.osinet.fr/fgm/go__deferplus/step01-problem/problem3 0.107s 
 % go test -v -run TestExit/Exit1 === RUN TestExit === RUN TestExit/"Exit1" 2023/01/09 14:40:05 In Exit1 exit status 1 FAIL code.osinet.fr/fgm/go__deferplus/step01-problem/problem3 0.100s
  7. © 2023 OSInet 9 os.Exit() vs os.Exit(1) vs syscall.Exit(n) //

    Exit causes the current program to exit with the given status code. // Conventionally, code zero indicates success, non-zero an error. // The program terminates immediately; deferred functions are not run. // // For portability, the status code should be in the range [0, 125]. func Exit(code int) { if code == 0 { if testlog.PanicOnExit0() { // We were told to panic on calls to os.Exit(0). // This is used to fail tests that make an early // unexpected call to os.Exit(0). panic("unexpected call to os.Exit(0) during test") } // Give race detector a chance to fail the program. // Racy programs do not have the right to finish successfully. runtime_beforeExit() } syscall.Exit(code) }
  8. © 2023 OSInet 11 Combining os.Exit() and defer package main

    import ( "log" "os" ) var someCondition = true func main() { var exitCode = 0 defer func() { log.Println("Deferred code") os.Exit(exitCode) }() log.Println("Dans main") if someCondition { // Instead of os.Exit(1) exitCode = 1 return } }
  9. © 2023 OSInet 12 A better way to exit from

    func main() package main import "os" func main() { os.Exit(int(realMain())) } // realMain is the actual main. Exit codes must be in [0, 125]. func realMain() byte { // Do whatever you want. Use defer freely. var someExitCode byte // Return will set the exit code, just like in C and many others. return someExitCode }
  10. © 2023 OSInet • When running tests, log.Fatal* allows finishing

    the test early with a message • The downside is that this uses os.Exit(), hence skipping defer execution • A better equivalent is t.FailNow which uses the testing package’s own test termination mechanism, based on runtime.Goexit(), which allows defer to work after a failure, unlike log.Fatal*. 13 Keep defers: avoiding exit in tests
  11. © 2023 OSInet 14 Setup and cleanup in tests Using

    defer: • setup functions ◦ return their actual result ◦ return a defer-rable func • tests ◦ call setup functions ◦ need to defer each such defer-rable in order ◦ defer-red run : ▪ in the test scope ▪ before the end of the test Using testing.Cleanup(): • setup functions ◦ return their actual result ◦ call t.Cleanup with their teardown callback • tests ◦ call setup functions ◦ no cleanup work needed ◦ cleanups run : ▪ in the setup scope ▪ after the end of the test
  12. © 2023 OSInet 15 Testing an os.Exit-ing(0) function func TestExit(t

    *testing.T) { for _, test := range [...]struct { name string fn func() }{{"Exit0", Exit0},{"Exit1", Exit1}} { t.Run(fmt.Sprintf("%#v", test.name), 
 func(t *testing.T) { defer func() { rec := recover() switch x := rec.(type) { case nil: t.Fatal("nothing recovered") case string: if !strings.Contains(x, 
 "unexpected call to os.Exit(0)") { t.Fatalf("unexpected: %q", x) } t.Log("successful recovery") default: t.Fatalf("unexpected: %#v", x) } }() test.fn() }) } } As seen previously, this only works if the exit code is zero: otherwise in os.Exit, the value of PanicOnExit0 will not be considered. It might even be false with alternative test runners using internal.testlog.SetPanicOnExit 0
  13. © 2023 OSInet 16 Testing an os.Exit-ing(nonZero) func Method: •

    Test creates a new command using the same test binary running itself • Runs itself recursively, passing a flag to identify the recursive call • Outer code receives the exit code of the recursive call Examples: • https://blog.antoine-augusti.fr/ 2015/12/testing-an-os-exit- scenario-in-golang/ • https://github.com/kohirens/ tmpltoapp/blob/ 46e536e50eaf55ea60d66bf815c 9a4d6f51d27b9/ main_test.go#L31