package helperclient import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "math" "os" "os/exec" "path/filepath" "time" ) const ( ErrorCodeMissingHelper = "MISSING_HELPER" ErrorCodeTimeout = "TIMEOUT" ErrorCodeBadJSON = "BAD_JSON" ErrorCodeProcessFailed = "PROCESS_FAILED" ) type Client struct { HelperPath string Timeout time.Duration } type ClientError struct { Code string Message string HelperPath string Stderr string Stdout string ExitCode int Cause error } func (e *ClientError) Error() string { if e == nil { return "" } if e.HelperPath == "" { return fmt.Sprintf("%s: %s", e.Code, e.Message) } return fmt.Sprintf("%s: %s: %s", e.Code, e.HelperPath, e.Message) } func (e *ClientError) Unwrap() error { if e == nil { return nil } return e.Cause } func (c Client) Call(ctx context.Context, op string, args map[string]any) (*HelperResponse, error) { if ctx == nil { ctx = context.Background() } helperPath := c.HelperPath if helperPath == "" { helperPath = defaultHelperPath() } if err := ensureHelperFile(helperPath); err != nil { return nil, &ClientError{ Code: ErrorCodeMissingHelper, Message: "helper executable is not available", HelperPath: helperPath, Cause: err, } } callCtx := ctx var cancel context.CancelFunc if c.Timeout > 0 { callCtx, cancel = context.WithTimeout(ctx, c.Timeout) defer cancel() } if args == nil { args = map[string]any{} } request := HelperRequest{ Protocol: HelperProtocolV1, RequestID: newRequestID(), Op: op, TimeoutMS: timeoutMillis(callCtx), Args: args, } requestBytes, err := json.Marshal(request) if err != nil { return nil, &ClientError{ Code: ErrorCodeProcessFailed, Message: "marshal helper request", HelperPath: helperPath, Cause: err, } } cmd := exec.CommandContext(callCtx, helperPath, "--json") cmd.Stdin = bytes.NewReader(requestBytes) var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr runErr := cmd.Run() stderrText := stderr.String() stdoutText := stdout.String() if callCtx.Err() != nil { return nil, &ClientError{ Code: ErrorCodeTimeout, Message: "helper process timed out", HelperPath: helperPath, Stderr: stderrText, Stdout: stdoutText, ExitCode: exitCode(runErr), Cause: callCtx.Err(), } } if runErr != nil { return nil, &ClientError{ Code: ErrorCodeProcessFailed, Message: "helper process failed", HelperPath: helperPath, Stderr: stderrText, Stdout: stdoutText, ExitCode: exitCode(runErr), Cause: runErr, } } var response HelperResponse if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { return nil, &ClientError{ Code: ErrorCodeBadJSON, Message: "helper stdout was not valid response JSON", HelperPath: helperPath, Stderr: stderrText, Stdout: stdoutText, Cause: err, } } return &response, nil } func ensureHelperFile(path string) error { info, err := os.Stat(path) if err != nil { return err } if info.IsDir() { return fmt.Errorf("helper path is a directory") } return nil } func defaultHelperPath() string { return filepath.Join(repoRoot(), "runs", "win-helper", "ISphereWinHelper.exe") } func repoRoot() string { wd, err := os.Getwd() if err != nil { return "." } dir := wd for { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { return dir } parent := filepath.Dir(dir) if parent == dir { return wd } dir = parent } } func timeoutMillis(ctx context.Context) int { deadline, ok := ctx.Deadline() if !ok { return 0 } remaining := time.Until(deadline) if remaining <= 0 { return 1 } millis := remaining.Milliseconds() if millis < 1 { return 1 } if millis > int64(math.MaxInt) { return math.MaxInt } return int(millis) } func exitCode(err error) int { if err == nil { return 0 } var exitErr *exec.ExitError if errors.As(err, &exitErr) { return exitErr.ExitCode() } return -1 } func newRequestID() string { var bytes [16]byte if _, err := rand.Read(bytes[:]); err == nil { return hex.EncodeToString(bytes[:]) } return fmt.Sprintf("req-%d", time.Now().UnixNano()) }