| // Copyright 2020 The Chromium OS Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package utils |
| |
| import ( |
| "bufio" |
| "bytes" |
| "fmt" |
| "io/ioutil" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "regexp" |
| "strconv" |
| "strings" |
| "trace_profiling/cmd/harvest/config" |
| "trace_profiling/cmd/profile/remote" |
| ) |
| |
| const ( |
| // Useful VT100 escape sequences. |
| beginBold = "\033[1m" |
| resetCharAttr = "\033[0m" |
| redOnBlack = "\033[31;40m" |
| defaultColors = "\033[39;49m" |
| ) |
| |
| var ( |
| // Where to find the GpuVis bash scripts within the GpuVis folder. There are |
| // two possibilities, depending on whether the locally built or pre-built version |
| // of GpuVis is used. |
| gpuVisScriptDirs = []string{"sample", "scripts"} |
| |
| // The trace scripts that are used to collect traces on the host device. |
| gpuVisTraceScripts = []string{ |
| "trace-cmd-capture.sh", "trace-cmd-command.sh", "trace-cmd-setup.sh", |
| "trace-cmd-start-tracing.sh", "trace-cmd-status.sh", "trace-cmd-stop-tracing.sh"} |
| ) |
| |
| // GpuVisTool provides support for collecting GpuVis trace data on a target device, |
| // transfer the data back to the workstation and optionally run GpuVis on this data. |
| type GpuVisTool struct { |
| // Configuration parameters read from json config file. |
| gpuVisConfig *config.GpuVisConfig |
| targetDevice *config.TargetDevice |
| |
| // The profiler is used to run the trace. These are the relevant profiler |
| // config parameters from harvest config. |
| profilerBinPath string |
| traceCacheDir string |
| keepTracesInCache bool |
| traceToRun string |
| |
| // Errors are fed to this channel. |
| errorOut chan error |
| |
| // GpuVisTool state and parameters. |
| targetDeviceSSH *remote.SSHTarget |
| targetWorkingDir string |
| firstFrame int |
| lastFrame int |
| frameCount int |
| |
| verbose bool |
| } |
| |
| // NewGpuVisTool create and returns a new GpuVisTool instance. |
| func NewGpuVisTool(verbose bool) *GpuVisTool { |
| return &GpuVisTool{verbose: verbose} |
| } |
| |
| // Setup is called to setup the GpuVisTool instance prior to running the tool. |
| func (gv *GpuVisTool) Setup( |
| targetDevice *config.TargetDevice, |
| gpuVisConfig *config.GpuVisConfig, |
| profilerBinPath string, |
| traceCacheDir string, |
| traces []string, |
| keepTracesInCache bool) error { |
| |
| if len(traces) == 0 { |
| return fmt.Errorf("GpuVisTool setup failed: no trace file provided") |
| } else if len(traces) > 1 { |
| fmt.Printf("Warning: more than one trace file specified. Will use %s\n", |
| traces[0]) |
| } |
| |
| gv.targetDevice = targetDevice |
| gv.gpuVisConfig = gpuVisConfig |
| gv.profilerBinPath = profilerBinPath |
| gv.keepTracesInCache = keepTracesInCache |
| gv.traceCacheDir = traceCacheDir |
| gv.traceToRun = traces[0] |
| |
| if err := gv.setupSSHToHostDevice(); err != nil { |
| return err |
| } |
| |
| return nil |
| } |
| |
| // Run runs the GpuVisTool. Errors are fed to the channel errorOut. |
| func (gv *GpuVisTool) Run(errorOut chan error) { |
| gv.errorOut = errorOut |
| |
| err := gv.verifyTargetDeviceCompatibility() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| |
| // We're about to run several scripts on the host device and need to maintain |
| // the shell's state between these scrips. To this end we start a shell session. |
| err = gv.targetDeviceSSH.OpenShellSession() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| defer gv.targetDeviceSSH.CloseShellSession() |
| |
| err = gv.createTargetWorkingDir() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| defer gv.targetDeviceSSH.DelFile(gv.targetWorkingDir) |
| |
| err = gv.installTraceTools() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| |
| // Always run terminateTracing to restore to device to its base state, even |
| // if initiateTracing partially fails. |
| defer gv.terminateTracing() |
| err = gv.initiateTracing() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| |
| // We're going to run the profiler asynchronously. Channel profilerExited is |
| // used to get notified in case the profiler fails or exists early. |
| profilerExited := make(chan bool) |
| err = gv.startProfiling(profilerExited) |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| |
| // Tell the user what's happening and to hit enter at the right time. When |
| // that happens, capture the traces. |
| if gv.tellUserToHitEnterKeyAndWait(profilerExited) { |
| err = gv.captureTrace() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| } else { |
| gv.errorOut <- fmt.Errorf("exited without capturing trace data") |
| return |
| } |
| |
| // The terminal mode in the shell session interferes with downloading files |
| // through stdout. Since we no longer need to preserve shell state, we can close |
| // the session. (Yes, defer will close the shell session again and that's ok.) |
| gv.targetDeviceSSH.CloseShellSession() |
| |
| files, err := gv.downloadTraceFiles() |
| if err != nil { |
| gv.errorOut <- err |
| return |
| } |
| |
| // Optionally run GpuVis on the trace data. |
| if gv.gpuVisConfig.RunGpuVis != "" { |
| err = gv.runGpuVis(files) |
| if err != nil { |
| gv.errorOut <- err |
| } |
| } |
| } |
| |
| // Setup a SSH session to the host OS on the target device. This session is |
| // used the run the trace-cmd scripts to gather CPU and GPU performance data. Thus |
| // it is always to the host device and never to any VM or container device. |
| func (gv *GpuVisTool) setupSSHToHostDevice() error { |
| ssh := gv.targetDevice.ProfilerConfig.SSHConfig |
| if gv.targetDevice.ProfilerConfig.TunnelConfig != nil { |
| ssh = &gv.targetDevice.ProfilerConfig.TunnelConfig.Server |
| } |
| |
| if ssh == nil { |
| return fmt.Errorf("missing SSH info to reach host device") |
| } |
| |
| var err error |
| gv.targetDeviceSSH, err = remote.CreateSSHTargetWithParams(ssh) |
| if err != nil { |
| return err |
| } |
| |
| return gv.targetDeviceSSH.Connect() |
| } |
| |
| // Verify that a device is compatible with GpuVis. The kernel must be configured |
| // to generate i915 trace points, ideally low-level i915 trace points. Returns |
| // an error if the device is not compatible. Prints a warning to stdout if |
| // low-level trace points are not generated. |
| func (gv *GpuVisTool) verifyTargetDeviceCompatibility() error { |
| gv.printIfVerbose("Checking target device.\n") |
| |
| // List the i915 trace points. |
| traceDir := "/sys/kernel/debug/tracing/events/i915/" |
| files, err := gv.targetDeviceSSH.ListFiles(traceDir, "i915_request*") |
| if err != nil { |
| return fmt.Errorf("could not check target device: err = %s", err.Error()) |
| } |
| |
| // Verify that at least some i915 trace points are available. Also check that |
| // low-level i915 trace points are available and print a warning if not. |
| lowLevelEventCount := 0 |
| allEventCount := 0 |
| for _, f := range files { |
| if strings.HasPrefix(f, "/sys/") { |
| allEventCount++ |
| |
| // Look for low-level i915_request_[in, out, submit] trace point. They are |
| // directories and will have a ':' at the end in the listing. |
| fname := strings.TrimPrefix(filepath.Base(f), "i915_request_") |
| if fname == "in:" || fname == "out:" || fname == "submit:" { |
| lowLevelEventCount++ |
| } |
| } |
| } |
| |
| if lowLevelEventCount == 0 && allEventCount == 0 { |
| fmt.Printf("Error: target device doesn't support i915 tracing events.\n") |
| return fmt.Errorf("wrong target device type") |
| } |
| |
| if lowLevelEventCount < 3 { |
| fmt.Printf("Warning: target-device kernel isn't configure to generate low-level i915\n" + |
| "tracing events. (See https://github.com/mikesart/gpuvis/wiki/TechDocs-Intel)\n") |
| } |
| |
| return nil |
| } |
| |
| // Create the working dir on the target device. Note that the working dir is a |
| // temporary dir inside the ChromeOSWorkingDir specified in the config. That makes |
| // easier to list the generated trace files and cleanup when done. |
| func (gv *GpuVisTool) createTargetWorkingDir() error { |
| gv.printIfVerbose("Creating temp dir in %s\n", gv.gpuVisConfig.ChromeOSWorkingDir) |
| |
| if gv.gpuVisConfig.ChromeOSWorkingDir == "" { |
| return fmt.Errorf("no device working Dir specified in config") |
| } |
| |
| err := gv.targetDeviceSSH.Mkdir(gv.gpuVisConfig.ChromeOSWorkingDir) |
| if err != nil { |
| return err |
| } |
| |
| // Make sure there's enough space in working dir to receive trace-collection |
| // tools and collect traces. A single GPU trace is typically less than 40MB. |
| // CPU traces are even smaller and the scripts are just a few KB. 50MB should |
| // be more than enough space |
| kBytes, err := gv.targetDeviceSSH.GetRemoteFreeSpaceKb(gv.gpuVisConfig.ChromeOSWorkingDir) |
| if err != nil { |
| return err |
| } |
| if kBytes < 50*1024 { |
| return fmt.Errorf("not enough free space on %s to run GpuVis trace collection tools", |
| gv.gpuVisConfig.ChromeOSWorkingDir) |
| } |
| |
| gv.targetWorkingDir, err = gv.targetDeviceSSH.MkTempDir(gv.gpuVisConfig.ChromeOSWorkingDir) |
| gv.printIfVerbose("Using temp dir %s\n", gv.targetWorkingDir) |
| |
| return err |
| } |
| |
| // Install the trace GpuVis script by copying them from the local GpuVis folder |
| // to the working dir on the target device. |
| func (gv *GpuVisTool) installTraceTools() error { |
| var err error |
| if _, err = os.Stat(gv.gpuVisConfig.LocalGpuVisDir); err != nil { |
| return err |
| } |
| |
| // Find which subdir contains the trace scripts. |
| var localScriptDir string |
| for _, scriptDir := range gpuVisScriptDirs { |
| localScriptDir = filepath.Join(gv.gpuVisConfig.LocalGpuVisDir, scriptDir) |
| _, err := os.Stat(localScriptDir) |
| if err == nil || !os.IsNotExist(err) { |
| break |
| } |
| } |
| |
| if err != nil { |
| return fmt.Errorf("could not find GpuVis trace scripts within %s", |
| gv.gpuVisConfig.LocalGpuVisDir) |
| } |
| |
| // Copy each script to the target device. |
| for _, script := range gpuVisTraceScripts { |
| src := filepath.Join(localScriptDir, script) |
| dst := filepath.Join(gv.targetWorkingDir, script) |
| if err := gv.targetDeviceSSH.SendFile(src, dst, "0755"); err != nil { |
| return err |
| } |
| } |
| |
| return nil |
| } |
| |
| // Initiating tracing on the target device. Note that this step only configures |
| // the target device so that it is ready to capture trace data. The actual data |
| // capture happens in function captureTrace. |
| func (gv *GpuVisTool) initiateTracing() error { |
| for _, env := range gv.gpuVisConfig.ChromeOSEnvVar { |
| cmd := "export " + env |
| _, err := gv.targetDeviceSSH.RunCmd(cmd) |
| if err != nil { |
| return fmt.Errorf("failed: %s, err = %s", cmd, err.Error()) |
| } |
| } |
| |
| cmd := "cd " + gv.targetWorkingDir |
| _, err := gv.targetDeviceSSH.RunCmd(cmd) |
| if err != nil { |
| return fmt.Errorf("failed: %s, err = %s", cmd, err.Error()) |
| } |
| |
| cmd = "./trace-cmd-setup.sh" |
| output, err := gv.targetDeviceSSH.RunCmd(cmd) |
| if err != nil { |
| return err |
| } |
| if strings.Index(output, "ERROR") != -1 { |
| return fmt.Errorf("GpuVis trace-cmd-setup failed: %s", output) |
| } |
| |
| cmd = "./trace-cmd-start-tracing.sh" |
| output, err = gv.targetDeviceSSH.RunCmd(cmd) |
| if err != nil { |
| return err |
| } |
| if strings.Index(output, "ERROR") != -1 { |
| return fmt.Errorf("GpuVis trace-cmd-start-tracing failed: %s", output) |
| } |
| |
| return nil |
| } |
| |
| // Restore the host device to its original non-tracing state. |
| func (gv *GpuVisTool) terminateTracing() { |
| cmd := "cd " + gv.targetWorkingDir |
| gv.targetDeviceSSH.RunCmd(cmd) |
| |
| cmd = filepath.Join(gv.targetWorkingDir, "trace-cmd-stop-tracing.sh") |
| gv.targetDeviceSSH.RunCmd(cmd) |
| } |
| |
| // A call to this function triggers capturing of traces on the target device. |
| // A set of cpu and gpu traces is captured and stored in the target working |
| // dir with extensions .dat and .i915-dat. |
| func (gv *GpuVisTool) captureTrace() error { |
| cmd := filepath.Join(gv.targetWorkingDir, "trace-cmd-capture.sh") |
| _, err := gv.targetDeviceSSH.RunCmd(cmd) |
| return err |
| } |
| |
| // Download the captured trace files from the target device to the local machine. |
| // The trace files are stored in LocalGpuVisDir. |
| func (gv *GpuVisTool) downloadTraceFiles() ([]string, error) { |
| files := make([]string, 0, 2) |
| |
| // Trace files have extensions .dat and .i915-dat. |
| srcFiles, err := gv.targetDeviceSSH.ListFiles(gv.targetWorkingDir, "*.*dat") |
| if err != nil { |
| return nil, err |
| } |
| |
| for _, file := range srcFiles { |
| filename := filepath.Base(file) |
| dstFile := filepath.Join(gv.gpuVisConfig.LocalGpuVisDir, filename) |
| |
| gv.printIfVerbose("Download target-device trace %s to %s\n", file, dstFile) |
| |
| err = gv.getRemoteFile(file, dstFile) |
| if err != nil { |
| return nil, err |
| } |
| |
| files = append(files, dstFile) |
| } |
| |
| return files, nil |
| } |
| |
| // Run GpuVis with the trace data file captured from the target device. |
| func (gv *GpuVisTool) runGpuVis(files []string) error { |
| if len(files) == 0 { |
| return fmt.Errorf("no performance data file to run GpuVis with") |
| } |
| |
| // Path to GpuVis app. |
| gpuVis := filepath.Join(gv.gpuVisConfig.LocalGpuVisDir, gv.gpuVisConfig.RunGpuVis) |
| // CPU and GPU trace data files. |
| cpuData := "" |
| gpuData := "" |
| |
| // If there's only one file, it'd better be CPU trace data. |
| if len(files) == 1 { |
| if !strings.HasSuffix(files[0], ".dat") { |
| return fmt.Errorf("only one trace file was captured and it is not CPU data: %s", files[0]) |
| } |
| |
| cpuData = files[0] |
| } else { |
| // We have at least two trace data files. Let's look for the CPU and GPU trace data. |
| for _, f := range files { |
| if strings.HasSuffix(f, ".dat") { |
| cpuData = f |
| } else if strings.HasSuffix(f, ".i915-dat") { |
| gpuData = f |
| } |
| } |
| |
| if cpuData == "" { |
| return fmt.Errorf("missing CPU trace data: %s, %s", files[0], files[1]) |
| } |
| if gpuData == "" { |
| return fmt.Errorf("missing GPU trace data: %s, %s", files[0], files[1]) |
| } |
| } |
| |
| // GpuVis is rather picky, the CPU data file must be given before the GPU data file. |
| cmd := exec.Command(gpuVis, cpuData, gpuData) |
| result, err := cmd.CombinedOutput() |
| if err != nil { |
| return fmt.Errorf("exec failed: %s, err = %s", result, err) |
| } |
| |
| return nil |
| } |
| |
| // Download a file from the remote device to local storage. |
| func (gv *GpuVisTool) getRemoteFile(remoteFilePath, localFilePath string) error { |
| outFile, err := os.OpenFile(localFilePath, os.O_RDWR|os.O_CREATE, 0755) |
| if err != nil { |
| return err |
| } |
| defer outFile.Close() |
| outFile.Truncate(0) |
| |
| cmd := fmt.Sprintf("dd if=%s status=none bs=512", remoteFilePath) |
| return gv.targetDeviceSSH.RunCmdWithWriter(cmd, outFile) |
| } |
| |
| // Run the profiler on the trace asynchronously. The profiler command is modified |
| // to loop over the frame range given in the GpuVis configuration. The profiler is |
| // launched in a separate coroutine and this function returns immediately. |
| func (gv *GpuVisTool) startProfiling(profilerExited chan bool) error { |
| var err error |
| gv.firstFrame, gv.lastFrame, gv.frameCount, err = parseFrameLoop(gv.gpuVisConfig.FrameLoop) |
| if err != nil { |
| return err |
| } |
| |
| cmdTokens := parseProfCmd(gv.targetDevice.ProfilerConfig.ProfileParams.ProfCommand) |
| if len(cmdTokens) < 2 { |
| return fmt.Errorf("invalid profile command: %s", |
| gv.targetDevice.ProfilerConfig.ProfileParams.ProfCommand) |
| } |
| |
| tmpDir, err := ioutil.TempDir(os.TempDir(), "gpuvis-profile-") |
| if err != nil { |
| return err |
| } |
| |
| // We use the profiler configuration from the config file albeit with two |
| // modifications. (1) The profile command is altered to include the frame loop. |
| // (2) The profile generated by the profiler is downloaded to a temporary folder. |
| newCommand := genCustomProfileCmd(cmdTokens, gv.firstFrame, gv.lastFrame, gv.frameCount) |
| gv.targetDevice.ProfilerConfig.ProfileParams.ProfCommand = newCommand |
| gv.targetDevice.ProfilerConfig.ProfileParams.LocalProfileDir = tmpDir |
| |
| profiler := NewTraceProfile(false /* not verbose */) |
| profiler.Setup(gv.targetDevice, nil, gv.errorOut, gv.profilerBinPath, gv.keepTracesInCache) |
| |
| go func() { |
| traces := []string{gv.traceToRun} |
| profiler.RunTraces(traces, gv.traceCacheDir) |
| os.RemoveAll(tmpDir) |
| profilerExited <- true |
| }() |
| |
| return nil |
| } |
| |
| // Print a helpful message asking the user to hit Enter at the right time, then wait |
| // until that happens. Channel profilerExited is used to get notified when the profiler |
| // exits, in case the user forgets to hit Enter or the profiler fails. |
| func (gv *GpuVisTool) tellUserToHitEnterKeyAndWait(profilerExited chan bool) bool { |
| fmt.Printf(beginBold+ |
| "\nThe profiler has been launched to run the trace and will loop %d times on frames %d to %d.\n", |
| gv.frameCount, gv.firstFrame, gv.lastFrame) |
| fmt.Printf("When the profiler enters the frame loop, hit the " + redOnBlack + |
| "Enter" + defaultColors + " key to capture CPU/GPU trace data.\n" + resetCharAttr) |
| |
| // Wait for user to hit Enter, but bail out if the profiler exits for any reason. |
| stdin := make(chan byte) |
| go getStdinInput(stdin) |
| select { |
| case <-profilerExited: |
| fmt.Printf("Profiler exited before capturing trace data: did you forget to hit Enter?\n") |
| return false |
| case <-stdin: |
| return true |
| } |
| } |
| |
| // Get char input from stdin. This function will not return until the user hits |
| // Enter. The Enter key is forwarded to channel input. |
| func getStdinInput(input chan byte) { |
| r := bufio.NewReader(os.Stdin) |
| b, _ := r.ReadByte() |
| input <- b |
| } |
| |
| // If verbose mode is enabled, print the formatted string. |
| func (gv *GpuVisTool) printIfVerbose(format string, a ...interface{}) { |
| if gv.verbose { |
| fmt.Printf(format, a...) |
| } |
| } |
| |
| // Parse and return the frame-loop data from the given string. Frame loop data |
| // has the form <first-frame>-<last-frame>,loop-count and the three values are |
| // returned as f0, fN, count. |
| func parseFrameLoop(str string) (f0, fN, count int, err error) { |
| f0 = 0 |
| fN = 0 |
| count = 0 |
| |
| // Frame-loop definition should have the form f0-fN,count. |
| reFrameLoop := regexp.MustCompile(`^\s*([\d]+)\s*-\s*([\d]+),([\d]+)\s*$`) |
| match := reFrameLoop.FindSubmatch([]byte(str)) |
| if len(match) < 4 { |
| err = fmt.Errorf("invalid FrameLoop specification: %s", str) |
| return |
| } |
| |
| f0, err = strconv.Atoi(string(match[1])) |
| if err != nil { |
| return |
| } |
| fN, err = strconv.Atoi(string(match[2])) |
| if err != nil { |
| return |
| } |
| count, err = strconv.Atoi(string(match[3])) |
| if err != nil { |
| return |
| } |
| |
| if f0 >= fN || count <= 0 { |
| err = fmt.Errorf("invalid FrameLoop specification: %s", str) |
| } |
| return |
| } |
| |
| // Parse the profile-command definition, from the profiler config, of the form |
| // [env] bin-path [options] "[[trace-file]] > [[prof-file]]". Options have |
| // the form -c, --name or --name=val. The bin path and each option are returned |
| // as a separate token. The last component between quotes is returned as a |
| // single token. |
| func parseProfCmd(str string) (tokens []string) { |
| var withinString = false |
| var prevChar, c rune = 0, 0 |
| var command bytes.Buffer |
| var i int |
| |
| // Scan the cmd line up to the first - (option) or [ (file parameters). |
| // Beware of - and [ embedded in strings. |
| for i, c = range str { |
| if !withinString && (c == '-' || c == '[') { |
| break |
| } |
| |
| if !withinString && c == '"' { |
| withinString = true |
| } else if withinString && c == '"' && prevChar != '\\' { |
| withinString = false |
| } |
| |
| command.WriteRune(c) |
| prevChar = c |
| } |
| |
| // All the char accumulated so far form the first token. |
| tokens = append(tokens, command.String()) |
| |
| // Scan the options, each to one token. Each option starts with a '-' and options |
| // are separated by spaces. Again, beware of strings. |
| str = str[i:] |
| if c == '-' { |
| var option bytes.Buffer |
| for i, c = range str { |
| if option.Len() == 0 && c != '-' && c != ' ' { |
| break |
| } |
| |
| if !withinString && c == ' ' { |
| if option.Len() > 0 { |
| tokens = append(tokens, option.String()) |
| option.Reset() |
| } |
| |
| prevChar = c |
| continue |
| } |
| |
| if !withinString && c == '"' { |
| withinString = true |
| } else if withinString && c == '"' && prevChar != '\\' { |
| withinString = false |
| } |
| |
| option.WriteRune(c) |
| prevChar = c |
| } |
| } |
| |
| // What's left of the cmd string forms the last token. |
| str = str[i:] |
| if len(str) > 0 { |
| tokens = append(tokens, str) |
| } |
| return |
| } |
| |
| // Generate a new profiler command that includes the frame-loop options. If the |
| // existing profiler-cmd token already include frame-loop options, they are |
| // discarded. The tokens are those generated by function parseProfCmd above. |
| func genCustomProfileCmd(cmdTokens []string, startFrame, endFrame, loopCount int) string { |
| var newCmd bytes.Buffer |
| |
| newCmd.WriteString(cmdTokens[0]) |
| |
| // Accumulate existing options, skipping frame-loop options. |
| for _, opt := range cmdTokens[1 : len(cmdTokens)-1] { |
| if !strings.HasPrefix("--loop", opt) { |
| newCmd.WriteString(" " + opt) |
| } |
| } |
| |
| // Add our own frame-loop options. |
| newCmd.WriteString(fmt.Sprintf(" --loop-begin=%d", startFrame)) |
| newCmd.WriteString(fmt.Sprintf(" --loop-end=%d", endFrame)) |
| newCmd.WriteString(fmt.Sprintf(" --loop-repeat-cnt=%d", loopCount)) |
| |
| newCmd.WriteString(" " + cmdTokens[len(cmdTokens)-1]) |
| |
| return newCmd.String() |
| } |