TheDiveO
TheDiveO

Reputation: 2681

Golang code coverage for re-executing process?

In order to discover Linux namespaces under certain conditions my open source Golang package lxkns needs to re-execute the application it is used in as a new child process in order to be able to switch mount namespaces before the Golang runtime spins up. The way Linux mount namespaces work makes it impossible to switch them from Golang applications after the runtime has spun up OS threads.

This means that the original process "P" re-runs a copy of itself as a child "C" (reexec package), passing a special indication via the child's environment which signals to the child to only run a specific "action" function belonging to the included "lxkns" package (see below for details), instead of running the whole application normally (avoiding endless recursively spawning children).

forkchild := exec.Command("/proc/self/exe")
forkchild.Start()
...
forkchild.Wait()

At the moment, I invoke the coverage tests from VisualStudio Code, which runs:

go test -timeout 30s -coverprofile=/tmp/vscode-goXXXXX/go-code-cover github.com/thediveo/lxkns

So, "P" re-executes a copy "C" of itself, and tells it to run some action "A", print some result to stdout, and then to immediately terminate. "P" waits for "C"'s output, parses it, and then continues in its program flow.

The module test uses Ginkgo/Gomega and a dedicated TestMain in order to catch when the test gets re-executed as a child in order to run only the requested "action" function.

package lxkns

import (
    "os"
    "testing"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
    "github.com/thediveo/gons/reexec"
)

func TestMain(m *testing.M) {
    // Ensure that the registered handler is run in the re-executed child. This
    // won't trigger the handler while we're in the parent, because the
    // parent's Arg[0] won't match the name of our handler.
    reexec.CheckAction()
    os.Exit(m.Run())
}

func TestLinuxKernelNamespaces(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "lxkns package")
}

I would like to also create code coverage data from the re-executed child process.

Note: switching mount namespaces won't conflict with creating coverage files in the new mount namespaces in my test cases. The reason is that these test mount namespaces are copies of the initial mount namespaces, so creating a new file will show up also in the filesystem normally.

Upvotes: 2

Views: 1155

Answers (1)

TheDiveO
TheDiveO

Reputation: 2681

After @Volker's comment on my Q I knew I had to take the challenge and went straight for the source code of Go's testing package. While @marco.m's suggestion is helpful in many cases, it cannot handle my admittedly slightly bizare usecase. testing's mechanics relevant to my original question are as follows, heavily simplified:

  • cover.go: implements coverReport() which writes a coverage data file (in ASCII text format); if the file already exists (stale version from a previous run), then it will be truncated first. Please note that coverReport() has the annoying habit of printing some “statistics” information to os.Stdout.
  • testing.go:
    • gets the CLI arguments -test.coverprofile= and -test.outputdir= from os.Args (via the flags package). If also implements toOutputDir(path) which places cover profile files inside -test.outputdir if specified.
    • But when does coverReport() get called? Simply spoken, at the end of testing.M.Run().

Now with this knowledge under the belt, a crazy solutions starts to emerge, kind of "Go-ing Bad" ;)

  • Wrap testing.M in a special re-execution enabled version reexec.testing.M: it detects whether it is running with coverage enabled:
    • if it is the "parent" process P, then it runs the tests as normal, and then it collects coverage profile data files from re-executed child processes C and merges them into P's coverage profile data file.
    • while in P and when just about to re-execute a new child C, a new dedicated coverage profile data filename is allocated for the child C. C then gets the filename via its "personal" -test.coverprofile= CLI arg.
    • when in C, we run the desired action function. Next, we need to run an empty test set in order to trigger writing the coverage profile data for C. For this, the re-execution function in P adds a test.run= with a very special "Bielefeld test pattern" that will most likely result in an empty result. Remember, P will -- after it has run all its tests -- pick up the individual C coverage profile data files and merge them into P's.
  • when coverage profiling isn't enabled, then no special actions need to be taken.

The downside of this solution is that it depends on some un-guaranteed behavior of Go's testing with respect to how and when it writes code coverage reports. But since a Linux-kernel namespace discovery package already pushes Go probably even harder than Docker's libnetwork, that's just a quantum further over the edge.

To a test developer, the whole enchilada is hidden inside an "enhanced" rxtst.M wrapper.

import (
    "testing"
    rxtst "github.com/thediveo/gons/reexec/testing"
)

func TestMain(m *testing.M) {
    // Ensure that the registered handler is run in the re-executed child.
    // This won't trigger the handler while we're in the parent. We're using
    // gons' very special coverage profiling support for re-execution.
    mm := &rxtst.M{M: m}
    os.Exit(mm.Run())
}

Running the whole lxkns test suite with coverage, preferably using go-acc (go accurate code coverage calculation), then shows in the screenshot below that the function discoverNsfsBindMounts() was run once (1). This function isn't directly called from anywhere in P. Instead, this function is registered and then run in a re-executed child C. Previously, no code coverage was reported for discoverNsfsBindMounts(), but now with the help of package github.com/thediveo/gons/reexec/testing code coverage for C is transparently merged in P's code coverage.

enter image description here

Upvotes: 1

Related Questions