Commit 78219ab3 authored by Elias Naur's avatar Elias Naur

misc/ios: script lldb directly with Python

The iOS exec wrapper uses ios-deploy to set up a device, install
the wrapped app, and start a lldb session to run it. ios-deploy is
not built to be scripted, as can be seen from the brittle way it is
driven by the Go wrapper. There are many timeouts and comments such
as

"
// lldb tries to be clever with terminals.
// So we wrap it in script(1) and be clever
// right back at it.
"

This CL replaces the use of ios-deploy with a lldb driver script in
Python. lldb is designed to be scripted, so apart from getting rid
of the ios-deploy dependency, we gain:

- No timouts and scripting ios-deploy through stdin and parsing
stdout for responses.
- Accurate exit codes.
- Prompt exits when the wrapped binary fails for some reason. Before,
the go test timeout would kick in to fail the test.
- Support for environment variables.
- No noise in the test output. Only the test binary output is output
from the wrapper.

We have to do more work with the lldb driver: mounting the developer
image on the device, running idevicedebugserverproxy and installing
the app. Even so, the CL removes almost as many lines as it adds.
Furthermore, having the steps split up helps to tell setup errors
from runtime errors.

Change-Id: I48cccc32f475d17987283b2c93aacc3da18fe339
Reviewed-on: https://go-review.googlesource.com/107337
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: default avatarBrad Fitzpatrick <bradfitz@golang.org>
parent eef27a8f
...@@ -21,27 +21,25 @@ package main ...@@ -21,27 +21,25 @@ package main
import ( import (
"bytes" "bytes"
"encoding/xml"
"errors" "errors"
"flag"
"fmt" "fmt"
"go/build" "go/build"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
) )
const debug = false const debug = false
var errRetry = errors.New("failed to start test harness (retry attempted)")
var tmpdir string var tmpdir string
var ( var (
...@@ -88,11 +86,18 @@ func main() { ...@@ -88,11 +86,18 @@ func main() {
bundleID = parts[1] bundleID = parts[1]
} }
os.Exit(runMain())
}
func runMain() int {
var err error var err error
tmpdir, err = ioutil.TempDir("", "go_darwin_arm_exec_") tmpdir, err = ioutil.TempDir("", "go_darwin_arm_exec_")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if !debug {
defer os.RemoveAll(tmpdir)
}
appdir := filepath.Join(tmpdir, "gotest.app") appdir := filepath.Join(tmpdir, "gotest.app")
os.RemoveAll(appdir) os.RemoveAll(appdir)
...@@ -116,28 +121,36 @@ func main() { ...@@ -116,28 +121,36 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// Approximately 1 in a 100 binaries fail to start. If it happens, if err := install(appdir); err != nil {
// try again. These failures happen for several reasons beyond log.Fatal(err)
// our control, but all of them are safe to retry as they happen
// before lldb encounters the initial getwd breakpoint. As we
// know the tests haven't started, we are not hiding flaky tests
// with this retry.
for i := 0; i < 5; i++ {
if i > 0 {
fmt.Fprintln(os.Stderr, "start timeout, trying again")
}
err = run(appdir, os.Args[2:])
if err == nil || err != errRetry {
break
} }
deviceApp, err := findDeviceAppPath(bundleID)
if err != nil {
log.Fatal(err)
} }
if !debug {
os.RemoveAll(tmpdir) if err := mountDevImage(); err != nil {
log.Fatal(err)
} }
closer, err := startDebugBridge()
if err != nil { if err != nil {
log.Fatal(err)
}
defer closer()
if err := run(appdir, deviceApp, os.Args[2:]); err != nil {
// If the lldb driver completed with an exit code, use that.
if err, ok := err.(*exec.ExitError); ok {
if ws, ok := err.Sys().(interface{ ExitStatus() int }); ok {
return ws.ExitStatus()
}
}
fmt.Fprintf(os.Stderr, "go_darwin_arm_exec: %v\n", err) fmt.Fprintf(os.Stderr, "go_darwin_arm_exec: %v\n", err)
os.Exit(1) return 1
} }
return 0
} }
func getenv(envvar string) string { func getenv(envvar string) string {
...@@ -191,282 +204,209 @@ func assembleApp(appdir, bin string) error { ...@@ -191,282 +204,209 @@ func assembleApp(appdir, bin string) error {
return nil return nil
} }
func run(appdir string, args []string) (err error) { // mountDevImage ensures a developer image is mounted on the device.
oldwd, err := os.Getwd() // The image contains the device lldb server for idevicedebugserverproxy
// to connect to.
func mountDevImage() error {
// Check for existing mount.
cmd := idevCmd(exec.Command("ideviceimagemounter", "-l"))
out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return err os.Stderr.Write(out)
return fmt.Errorf("ideviceimagemounter: %v", err)
}
if len(out) > 0 {
// Assume there is an image mounted
return nil
} }
if err := os.Chdir(filepath.Join(appdir, "..")); err != nil { // No image is mounted. Find a suitable image.
imgPath, err := findDevImage()
if err != nil {
return err return err
} }
defer os.Chdir(oldwd) sigPath := imgPath + ".signature"
cmd = idevCmd(exec.Command("ideviceimagemounter", imgPath, sigPath))
if out, err := cmd.CombinedOutput(); err != nil {
os.Stderr.Write(out)
return fmt.Errorf("ideviceimagemounter: %v", err)
}
return nil
}
// Setting up lldb is flaky. The test binary itself runs when // findDevImage use the device iOS version and build to locate a suitable
// started is set to true. Everything before that is considered // developer image.
// part of the setup and is retried. func findDevImage() (string, error) {
started := false cmd := idevCmd(exec.Command("ideviceinfo"))
defer func() { out, err := cmd.Output()
if r := recover(); r != nil { if err != nil {
if w, ok := r.(waitPanic); ok { return "", fmt.Errorf("ideviceinfo: %v", err)
err = w.err
if !started {
fmt.Printf("lldb setup error: %v\n", err)
err = errRetry
} }
return var iosVer, buildVer string
lines := bytes.Split(out, []byte("\n"))
for _, line := range lines {
spl := bytes.SplitN(line, []byte(": "), 2)
if len(spl) != 2 {
continue
} }
panic(r) key, val := string(spl[0]), string(spl[1])
switch key {
case "ProductVersion":
iosVer = val
case "BuildVersion":
buildVer = val
} }
}() }
if iosVer == "" || buildVer == "" {
defer exec.Command("killall", "ios-deploy").Run() // cleanup return "", errors.New("failed to parse ideviceinfo output")
exec.Command("killall", "ios-deploy").Run() }
sdkBase := "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport"
var opts options patterns := []string{fmt.Sprintf("%s (%s)", iosVer, buildVer), fmt.Sprintf("%s (*)", iosVer), fmt.Sprintf("%s*", iosVer)}
opts, args = parseArgs(args) for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(sdkBase, pattern, "DeveloperDiskImage.dmg"))
// ios-deploy invokes lldb to give us a shell session with the app.
s, err := newSession(appdir, args, opts)
if err != nil { if err != nil {
return err return "", fmt.Errorf("findDevImage: %v", err)
} }
defer func() { if len(matches) > 0 {
b := s.out.Bytes() return matches[0], nil
if err == nil && !debug {
i := bytes.Index(b, []byte("(lldb) process continue"))
if i > 0 {
b = b[i:]
} }
} }
os.Stdout.Write(b) return "", fmt.Errorf("failed to find matching developer image for iOS version %s build %s", iosVer, buildVer)
}() }
cond := func(out *buf) bool { // startDebugBridge ensures that the idevicedebugserverproxy runs on
i0 := s.out.LastIndex([]byte("(lldb)")) // port 3222.
i1 := s.out.LastIndex([]byte("fruitstrap")) func startDebugBridge() (func(), error) {
i2 := s.out.LastIndex([]byte(" connect")) errChan := make(chan error, 1)
return i0 > 0 && i1 > 0 && i2 > 0 cmd := idevCmd(exec.Command("idevicedebugserverproxy", "3222"))
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("idevicedebugserverproxy: %v", err)
} }
if err := s.wait("lldb start", cond, 15*time.Second); err != nil { go func() {
panic(waitPanic{err}) if err := cmd.Wait(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
errChan <- fmt.Errorf("idevicedebugserverproxy: %s", stderr.Bytes())
} else {
errChan <- fmt.Errorf("idevicedebugserverproxy: %v", err)
} }
// Script LLDB. Oh dear.
s.do(`process handle SIGHUP --stop false --pass true --notify false`)
s.do(`process handle SIGPIPE --stop false --pass true --notify false`)
s.do(`process handle SIGUSR1 --stop false --pass true --notify false`)
s.do(`process handle SIGCONT --stop false --pass true --notify false`)
s.do(`process handle SIGSEGV --stop false --pass true --notify false`) // does not work
s.do(`process handle SIGBUS --stop false --pass true --notify false`) // does not work
if opts.lldb {
_, err := io.Copy(s.in, os.Stdin)
if err != io.EOF {
return err
} }
return nil errChan <- nil
}()
closer := func() {
cmd.Process.Kill()
<-errChan
}
// Dial localhost:3222 to ensure the proxy is ready.
delay := time.Second / 4
for attempt := 0; attempt < 5; attempt++ {
conn, err := net.DialTimeout("tcp", "localhost:3222", 5*time.Second)
if err == nil {
conn.Close()
return closer, nil
} }
select {
started = true case <-time.After(delay):
startTestsLen := s.out.Len() delay *= 2
case err := <-errChan:
fmt.Fprintln(s.in, "run") return nil, err
passed := func(out *buf) bool {
// Just to make things fun, lldb sometimes translates \n into \r\n.
return s.out.LastIndex([]byte("\nPASS\n")) > startTestsLen ||
s.out.LastIndex([]byte("\nPASS\r")) > startTestsLen ||
s.out.LastIndex([]byte("\n(lldb) PASS\n")) > startTestsLen ||
s.out.LastIndex([]byte("\n(lldb) PASS\r")) > startTestsLen ||
s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \n")) > startTestsLen ||
s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \r")) > startTestsLen
} }
err = s.wait("test completion", passed, opts.timeout)
if passed(s.out) {
// The returned lldb error code is usually non-zero.
// We check for test success by scanning for the final
// PASS returned by the test harness, assuming the worst
// in its absence.
return nil
} }
return err closer()
return nil, errors.New("failed to set up idevicedebugserverproxy")
} }
type lldbSession struct { // findDeviceAppPath returns the device path to the app with the
cmd *exec.Cmd // given bundle ID. It parses the output of ideviceinstaller -l -o xml,
in *os.File // looking for the bundle ID and the corresponding Path value.
out *buf func findDeviceAppPath(bundleID string) (string, error) {
timedout chan struct{} cmd := idevCmd(exec.Command("ideviceinstaller", "-l", "-o", "xml"))
exited chan error out, err := cmd.CombinedOutput()
}
func newSession(appdir string, args []string, opts options) (*lldbSession, error) {
lldbr, in, err := os.Pipe()
if err != nil { if err != nil {
return nil, err os.Stderr.Write(out)
return "", fmt.Errorf("ideviceinstaller: -l -o xml %v", err)
} }
s := &lldbSession{ var list struct {
in: in, Apps []struct {
out: new(buf), Data []byte `xml:",innerxml"`
exited: make(chan error), } `xml:"array>dict"`
} }
if err := xml.Unmarshal(out, &list); err != nil {
iosdPath, err := exec.LookPath("ios-deploy") return "", fmt.Errorf("failed to parse ideviceinstaller outout: %v", err)
if err != nil {
return nil, err
} }
cmdArgs := []string{ for _, app := range list.Apps {
// lldb tries to be clever with terminals. d := xml.NewDecoder(bytes.NewReader(app.Data))
// So we wrap it in script(1) and be clever values := make(map[string]string)
// right back at it. var key string
"script", var hasKey bool
"-q", "-t", "0", for {
"/dev/null", tok, err := d.Token()
if err == io.EOF {
iosdPath, break
"--debug",
"-u",
"-n",
`--args=` + strings.Join(args, " ") + ``,
"--bundle", appdir,
} }
if deviceID != "" { if err != nil {
cmdArgs = append(cmdArgs, "--id", deviceID) return "", fmt.Errorf("failed to device app data: %v", err)
} }
s.cmd = exec.Command(cmdArgs[0], cmdArgs[1:]...) if tok, ok := tok.(xml.StartElement); ok {
if debug { if tok.Name.Local == "key" {
log.Println(strings.Join(s.cmd.Args, " ")) if err := d.DecodeElement(&key, &tok); err != nil {
return "", fmt.Errorf("failed to device app data: %v", err)
} }
hasKey = true
var out io.Writer = s.out } else if hasKey {
if opts.lldb { var val string
out = io.MultiWriter(out, os.Stderr) if err := d.DecodeElement(&val, &tok); err != nil {
return "", fmt.Errorf("failed to device app data: %v", err)
} }
s.cmd.Stdout = out values[key] = val
s.cmd.Stderr = out // everything of interest is on stderr hasKey = false
s.cmd.Stdin = lldbr } else {
if err := d.Skip(); err != nil {
if err := s.cmd.Start(); err != nil { return "", fmt.Errorf("failed to device app data: %v", err)
return nil, fmt.Errorf("ios-deploy failed to start: %v", err)
} }
// Manage the -test.timeout here, outside of the test. There is a lot
// of moving parts in an iOS test harness (notably lldb) that can
// swallow useful stdio or cause its own ruckus.
if opts.timeout > 1*time.Second {
s.timedout = make(chan struct{})
time.AfterFunc(opts.timeout-1*time.Second, func() {
close(s.timedout)
})
} }
go func() {
s.exited <- s.cmd.Wait()
}()
return s, nil
}
func (s *lldbSession) do(cmd string) { s.doCmd(cmd, "(lldb)", 0) }
func (s *lldbSession) doCmd(cmd string, waitFor string, extraTimeout time.Duration) {
startLen := s.out.Len()
fmt.Fprintln(s.in, cmd)
cond := func(out *buf) bool {
i := s.out.LastIndex([]byte(waitFor))
return i > startLen
} }
if err := s.wait(fmt.Sprintf("running cmd %q", cmd), cond, extraTimeout); err != nil {
panic(waitPanic{err})
} }
} if values["CFBundleIdentifier"] == bundleID {
if path, ok := values["Path"]; ok {
func (s *lldbSession) wait(reason string, cond func(out *buf) bool, extraTimeout time.Duration) error { return path, nil
doTimeout := 2*time.Second + extraTimeout
doTimedout := time.After(doTimeout)
for {
select {
case <-s.timedout:
if p := s.cmd.Process; p != nil {
p.Kill()
}
return fmt.Errorf("test timeout (%s)", reason)
case <-doTimedout:
if p := s.cmd.Process; p != nil {
p.Kill()
}
return fmt.Errorf("command timeout (%s for %v)", reason, doTimeout)
case err := <-s.exited:
return fmt.Errorf("exited (%s: %v)", reason, err)
default:
if cond(s.out) {
return nil
} }
time.Sleep(20 * time.Millisecond)
} }
} }
return "", fmt.Errorf("failed to find device path for bundle: %s", bundleID)
} }
type buf struct { func install(appdir string) error {
mu sync.Mutex cmd := idevCmd(exec.Command(
buf []byte "ideviceinstaller",
} "-i", appdir,
))
func (w *buf) Write(in []byte) (n int, err error) { if out, err := cmd.CombinedOutput(); err != nil {
w.mu.Lock() os.Stderr.Write(out)
defer w.mu.Unlock() return fmt.Errorf("ideviceinstaller -i %q: %v", appdir, err)
w.buf = append(w.buf, in...) }
return len(in), nil return nil
}
func (w *buf) LastIndex(sep []byte) int {
w.mu.Lock()
defer w.mu.Unlock()
return bytes.LastIndex(w.buf, sep)
}
func (w *buf) Bytes() []byte {
w.mu.Lock()
defer w.mu.Unlock()
b := make([]byte, len(w.buf))
copy(b, w.buf)
return b
}
func (w *buf) Len() int {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.buf)
}
type waitPanic struct {
err error
}
type options struct {
timeout time.Duration
lldb bool
} }
func parseArgs(binArgs []string) (opts options, remainingArgs []string) { func idevCmd(cmd *exec.Cmd) *exec.Cmd {
var flagArgs []string if deviceID != "" {
for _, arg := range binArgs { cmd.Args = append(cmd.Args, "-u", deviceID)
if strings.Contains(arg, "-test.timeout") {
flagArgs = append(flagArgs, arg)
}
if strings.Contains(arg, "-lldb") {
flagArgs = append(flagArgs, arg)
continue
}
remainingArgs = append(remainingArgs, arg)
} }
f := flag.NewFlagSet("", flag.ContinueOnError) return cmd
f.DurationVar(&opts.timeout, "test.timeout", 10*time.Minute, "") }
f.BoolVar(&opts.lldb, "lldb", false, "")
f.Parse(flagArgs)
return opts, remainingArgs
func run(appdir, deviceapp string, args []string) error {
lldb := exec.Command(
"python",
"-", // Read script from stdin.
appdir,
deviceapp,
)
lldb.Args = append(lldb.Args, args...)
lldb.Stdin = strings.NewReader(lldbDriver)
lldb.Stdout = os.Stdout
lldb.Stderr = os.Stderr
return lldb.Run()
} }
func copyLocalDir(dst, src string) error { func copyLocalDir(dst, src string) error {
...@@ -662,3 +602,76 @@ const resourceRules = `<?xml version="1.0" encoding="UTF-8"?> ...@@ -662,3 +602,76 @@ const resourceRules = `<?xml version="1.0" encoding="UTF-8"?>
</dict> </dict>
</plist> </plist>
` `
const lldbDriver = `
import sys
import os
exe, device_exe, args = sys.argv[1], sys.argv[2], sys.argv[3:]
env = []
for k, v in os.environ.items():
env.append(k + "=" + v)
sys.path.append('/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Resources/Python')
import lldb
debugger = lldb.SBDebugger.Create()
debugger.SetAsync(True)
debugger.SkipLLDBInitFiles(True)
err = lldb.SBError()
target = debugger.CreateTarget(exe, None, 'remote-ios', True, err)
if not target.IsValid() or not err.Success():
sys.stderr.write("lldb: failed to setup up target: %s\n" % (err))
sys.exit(1)
target.modules[0].SetPlatformFileSpec(lldb.SBFileSpec(device_exe))
listener = debugger.GetListener()
process = target.ConnectRemote(listener, 'connect://localhost:3222', None, err)
if not err.Success():
sys.stderr.write("lldb: failed to connect to remote target: %s\n" % (err))
sys.exit(1)
# Don't stop on signals.
sigs = process.GetUnixSignals()
for i in range(0, sigs.GetNumSignals()):
sig = sigs.GetSignalAtIndex(i)
sigs.SetShouldStop(sig, False)
sigs.SetShouldNotify(sig, False)
event = lldb.SBEvent()
while True:
if not listener.WaitForEvent(1, event):
continue
if not lldb.SBProcess.EventIsProcessEvent(event):
continue
# Pass through stdout and stderr.
while True:
out = process.GetSTDOUT(8192)
if not out:
break
sys.stdout.write(out)
while True:
out = process.GetSTDERR(8192)
if not out:
break
sys.stderr.write(out)
state = process.GetStateFromEvent(event)
if state == lldb.eStateCrashed or state == lldb.eStateDetached or state == lldb.eStateUnloaded or state == lldb.eStateExited:
break
elif state == lldb.eStateConnected:
process.RemoteLaunch(args, env, None, None, None, None, 0, False, err)
if not err.Success():
sys.stderr.write("lldb: failed to launch remote process: %s\n" % (err))
sys.exit(1)
# Process stops once at the beginning. Continue.
process.Continue()
exitStatus = process.GetExitStatus()
process.Kill()
debugger.Terminate()
sys.exit(exitStatus)
`
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment