package process import ( "encoding/json" "errors" "fmt" "io" "io/ioutil" "os" "os/exec" "runtime" "strings" "sync" "github.com/Masterminds/semver/v3" "github.com/aws/jsii-runtime-go/internal/embedded" ) const JSII_NODE string = "JSII_NODE" const JSII_RUNTIME string = "JSII_RUNTIME" type ErrorResponse struct { Error string `json:"error"` Stack *string `json:"stack"` Name *string `json:"name"` } // Process is a simple interface over the child process hosting the // @jsii/kernel process. It only exposes a very straight-forward // request/response interface. type Process struct { compatibleVersions *semver.Constraints cmd *exec.Cmd tmpdir string stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser requests *json.Encoder responses *json.Decoder stderrDone chan bool started bool closed bool mutex sync.Mutex } // NewProcess prepares a new child process, but does not start it yet. It will // be automatically started whenever the client attempts to send a request // to it. // // If the JSII_RUNTIME environment variable is set, this command will be used // to start the child process, in a sub-shell (using %COMSPEC% or cmd.exe on // Windows; $SHELL or /bin/sh on other OS'es). Otherwise, the embedded runtime // application will be extracted into a temporary directory, and used. // // The current process' environment is inherited by the child process. Additional // environment may be injected into the child process' environment - all of which // with lower precedence than the launching process' environment, with the notable // exception of JSII_AGENT, which is reserved. func NewProcess(compatibleVersions string) (*Process, error) { p := Process{} if constraints, err := semver.NewConstraint(compatibleVersions); err != nil { return nil, err } else { p.compatibleVersions = constraints } if custom := os.Getenv(JSII_RUNTIME); custom != "" { var ( command string args []string ) // Sub-shelling in order to avoid having to parse arguments if runtime.GOOS == "windows" { // On windows, we use %ComSpec% if set, or cmd.exe if cmd := os.Getenv("ComSpec"); cmd != "" { command = cmd } else { command = "cmd.exe" } // The /d option disables Registry-defined AutoRun, it's safer to enable // The /s option tells cmd.exe the command is quoted as if it were typed into a prompt // The /c option tells cmd.exe to run the specified command and exit immediately args = []string{"/d", "/s", "/c", custom} } else { // On other OS'es, we use $SHELL and fall back to "/bin/sh" if shell := os.Getenv("SHELL"); shell != "" { command = shell } else { command = "/bin/sh" } args = []string{"-c", custom} } p.cmd = exec.Command(command, args...) } else if tmpdir, err := ioutil.TempDir("", "jsii-runtime.*"); err != nil { return nil, err } else { p.tmpdir = tmpdir if entrypoint, err := embedded.ExtractRuntime(tmpdir); err != nil { p.Close() return nil, err } else { if node := os.Getenv(JSII_NODE); node != "" { p.cmd = exec.Command(node, entrypoint) } else { p.cmd = exec.Command("node", entrypoint) } } } // Setting up environment - if duplicate keys are found, the last value is used, so we are careful with ordering. In // particular, we are setting NODE_OPTIONS only if `os.Environ()` does not have another value... So the user can // control the environment... However, JSII_AGENT must always be controlled by this process. p.cmd.Env = append([]string{"NODE_OPTIONS=--max-old-space-size=4069"}, os.Environ()...) p.cmd.Env = append(p.cmd.Env, fmt.Sprintf("JSII_AGENT=%v/%v/%v", runtime.Version(), runtime.GOOS, runtime.GOARCH)) if stdin, err := p.cmd.StdinPipe(); err != nil { p.Close() return nil, err } else { p.stdin = stdin p.requests = json.NewEncoder(stdin) } if stdout, err := p.cmd.StdoutPipe(); err != nil { p.Close() return nil, err } else { p.stdout = stdout p.responses = json.NewDecoder(stdout) } if stderr, err := p.cmd.StderrPipe(); err != nil { p.Close() return nil, err } else { p.stderr = stderr } return &p, nil } func (p *Process) ensureStarted() error { if p.closed { return fmt.Errorf("this process has been closed") } if p.started { return nil } if err := p.cmd.Start(); err != nil { p.Close() return err } p.started = true done := make(chan bool, 1) go p.consumeStderr(done) p.stderrDone = done var handshake handshakeResponse if err := p.readResponse(&handshake); err != nil { p.Close() return err } if runtimeVersion, err := handshake.runtimeVersion(); err != nil { p.Close() return err } else if ok, errs := p.compatibleVersions.Validate(runtimeVersion); !ok { causes := make([]string, len(errs)) for i, err := range errs { causes[i] = fmt.Sprintf("- %v", err) } p.Close() return fmt.Errorf("incompatible runtime version:\n%v", strings.Join(causes, "\n")) } go func() { err := p.cmd.Wait() if err != nil { fmt.Fprintf(os.Stderr, "Runtime process exited abnormally: %v", err.Error()) } p.Close() }() return nil } // Request starts the child process if that has not happened yet, then // encodes the supplied request and sends it to the child process // via the requests channel, then decodes the response into the provided // response pointer. If the process is not in a usable state, or if the // encoding fails, an error is returned. func (p *Process) Request(request interface{}, response interface{}) error { if err := p.ensureStarted(); err != nil { return err } if err := p.requests.Encode(request); err != nil { p.Close() return err } return p.readResponse(response) } func (p *Process) readResponse(into interface{}) error { if !p.responses.More() { return fmt.Errorf("no response received from child process") } var raw json.RawMessage var respmap map[string]interface{} err := p.responses.Decode(&raw) if err != nil { return err } err = json.Unmarshal(raw, &respmap) if err != nil { return err } var errResp ErrorResponse if _, ok := respmap["error"]; ok { json.Unmarshal(raw, &errResp) if errResp.Name != nil && *errResp.Name == "@jsii/kernel.Fault" { return fmt.Errorf("JsiiError: %s", *errResp.Name) } return errors.New(errResp.Error) } return json.Unmarshal(raw, &into) } func (p *Process) Close() { if p.closed { return } // Acquire the lock, so we don't try to concurrently close multiple times p.mutex.Lock() defer p.mutex.Unlock() // Check again now that we own the lock, it may be a fast exit! if p.closed { return } if p.stdin != nil { // Try to send the exit message, this might fail, but we can ignore that. p.stdin.Write([]byte("{\"exit\":0}\n")) // Close STDIN for the child process now. Ignoring errors, as it may // have been closed already (e.g: if the process exited). p.stdin.Close() p.stdin = nil } if p.stdout != nil { // Close STDOUT for the child process now, as we don't expect to receive // responses anymore. Ignoring errors, as it may have been closed // already (e.g: if the process exited). p.stdout.Close() p.stdout = nil } if p.stderrDone != nil { // Wait for the stderr sink goroutine to have finished <-p.stderrDone p.stderrDone = nil } if p.stderr != nil { // Close STDERR for the child process now, as we're no longer consuming // it anyway. Ignoring errors, as it may havebeen closed already (e.g: // if the process exited). p.stderr.Close() p.stderr = nil } if p.cmd != nil { // Wait for the child process to be dead and gone (should already be) p.cmd.Wait() p.cmd = nil } if p.tmpdir != "" { // Clean up any temporary directory we provisioned. if err := os.RemoveAll(p.tmpdir); err != nil { fmt.Fprintf(os.Stderr, "could not clean up temporary directory: %v\n", err) } p.tmpdir = "" } p.closed = true }