Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
876 lines
26 KiB
Go
876 lines
26 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
|
|
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/httpx"
|
|
)
|
|
|
|
const defaultAppHealthcheckTimeout = 30 * time.Second
|
|
|
|
// A workspace app chooses its own listener after reading TUTTI_APP_PORT. The
|
|
// port allocator cannot hold that listener across exec, so a short-lived
|
|
// bind race is still possible. Retry only when the child proves that its
|
|
// failure was the selected listener, rather than hiding an application
|
|
// startup or healthcheck bug behind a blind restart loop.
|
|
const maxAppPortStartupRetries = 2
|
|
|
|
type AppRunner struct {
|
|
ShellAdapter AppShellAdapter
|
|
HealthcheckTimeout time.Duration
|
|
HTTPClient *http.Client
|
|
OnStateChanged AppRunnerStateChanged
|
|
RuntimeResolver AppRuntimeResolver
|
|
|
|
initOnce sync.Once
|
|
mu sync.Mutex
|
|
processes map[string]*appProcess
|
|
states map[string]workspacebiz.AppRuntimeState
|
|
starts map[string]*appStart
|
|
queue chan struct{}
|
|
}
|
|
|
|
type AppRunnerStateChanged func(workspaceID string, appID string, state workspacebiz.AppRuntimeState)
|
|
|
|
type AppStartInput struct {
|
|
WorkspaceID string
|
|
WorkspaceName string
|
|
AppID string
|
|
PackageDir string
|
|
Bootstrap string
|
|
HealthcheckPath string
|
|
RuntimeProfile string
|
|
RuntimeDir string
|
|
DataDir string
|
|
DatabaseDir string
|
|
LogDir string
|
|
Restart bool
|
|
}
|
|
|
|
type appProcess struct {
|
|
command *exec.Cmd
|
|
containment appProcessContainment
|
|
done chan error
|
|
stopRequested bool
|
|
logFile *os.File
|
|
}
|
|
|
|
type appProcessContainment interface {
|
|
close() error
|
|
kill() error
|
|
}
|
|
|
|
type appStart struct {
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (r *AppRunner) State(workspaceID string, appID string) workspacebiz.AppRuntimeState {
|
|
r.ensure()
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
state, ok := r.states[appRuntimeKey(workspaceID, appID)]
|
|
if !ok {
|
|
return workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusIdle,
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (r *AppRunner) Start(ctx context.Context, input AppStartInput) (workspacebiz.AppRuntimeState, error) {
|
|
r.ensure()
|
|
|
|
key := appRuntimeKey(input.WorkspaceID, input.AppID)
|
|
r.mu.Lock()
|
|
existing, running := r.processes[key]
|
|
existingState := r.states[key]
|
|
if running && existing != nil && existingState.Status == workspacebiz.AppRuntimeStatusRunning && !input.Restart {
|
|
r.mu.Unlock()
|
|
return existingState, nil
|
|
}
|
|
if r.starts[key] != nil &&
|
|
!input.Restart &&
|
|
(existingState.Status == workspacebiz.AppRuntimeStatusPreparing || existingState.Status == workspacebiz.AppRuntimeStatusStarting) {
|
|
r.mu.Unlock()
|
|
return existingState, nil
|
|
}
|
|
if start := r.starts[key]; start != nil {
|
|
start.cancel()
|
|
delete(r.starts, key)
|
|
}
|
|
startCtx, cancel := context.WithCancel(context.Background())
|
|
start := &appStart{cancel: cancel}
|
|
r.starts[key] = start
|
|
r.mu.Unlock()
|
|
if running {
|
|
_, _ = r.stopProcess(ctx, key, existing)
|
|
}
|
|
state, committed := r.setStateForStart(key, start, workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusPreparing,
|
|
PackageDir: input.PackageDir,
|
|
})
|
|
if !committed {
|
|
cancel()
|
|
return state, nil
|
|
}
|
|
go r.startQueued(startCtx, key, input, start)
|
|
return state, nil
|
|
}
|
|
|
|
func (r *AppRunner) PreloadRuntime(ctx context.Context) error {
|
|
r.ensure()
|
|
_, err := r.runtimeResolver().Resolve(ctx)
|
|
return err
|
|
}
|
|
|
|
func (r *AppRunner) PreloadRuntimeForProfile(ctx context.Context, profile string) error {
|
|
r.ensure()
|
|
if appRuntimeProfileIsStandalone(profile) {
|
|
return nil
|
|
}
|
|
resolver := r.runtimeResolver()
|
|
if preloader, ok := resolver.(AppRuntimeProfilePreloader); ok {
|
|
return preloader.PreloadProfile(ctx, profile)
|
|
}
|
|
_, err := resolver.Resolve(ctx)
|
|
return err
|
|
}
|
|
|
|
func (r *AppRunner) startQueued(ctx context.Context, key string, input AppStartInput, start *appStart) {
|
|
select {
|
|
case r.queue <- struct{}{}:
|
|
defer func() { <-r.queue }()
|
|
case <-ctx.Done():
|
|
r.finishStart(key, ctx, start)
|
|
return
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
r.finishStart(key, ctx, start)
|
|
return
|
|
}
|
|
r.startProcess(ctx, key, input, start)
|
|
r.finishStart(key, ctx, start)
|
|
}
|
|
|
|
func (r *AppRunner) startProcess(ctx context.Context, key string, input AppStartInput, start *appStart) {
|
|
r.startProcessAttempt(ctx, key, input, start, 0)
|
|
}
|
|
|
|
func (r *AppRunner) startProcessAttempt(ctx context.Context, key string, input AppStartInput, start *appStart, attempt int) {
|
|
port, err := allocateLoopbackPort()
|
|
if err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, 0, "startup", fmt.Errorf("allocate app port: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("allocate app port: %w", err))
|
|
return
|
|
}
|
|
|
|
if err := os.MkdirAll(input.RuntimeDir, 0o755); err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("create app runtime dir: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("create app runtime dir: %w", err))
|
|
return
|
|
}
|
|
if err := os.MkdirAll(input.DataDir, 0o755); err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("create app data dir: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("create app data dir: %w", err))
|
|
return
|
|
}
|
|
if err := os.MkdirAll(input.DatabaseDir, 0o755); err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("create app database dir: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("create app database dir: %w", err))
|
|
return
|
|
}
|
|
if err := os.MkdirAll(input.LogDir, 0o755); err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("create app log dir: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("create app log dir: %w", err))
|
|
return
|
|
}
|
|
appRuntime, err := r.resolveRuntime(ctx, input.RuntimeProfile)
|
|
if err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "runtime_unavailable", err)
|
|
r.setFailedForStart(key, start, "runtime_unavailable", err)
|
|
return
|
|
}
|
|
|
|
logFile, err := os.OpenFile(filepath.Join(input.LogDir, "runtime.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("open app runtime log: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("open app runtime log: %w", err))
|
|
return
|
|
}
|
|
logStartOffset := int64(0)
|
|
if info, statErr := logFile.Stat(); statErr == nil {
|
|
logStartOffset = info.Size()
|
|
}
|
|
|
|
bootstrap := strings.TrimSpace(input.Bootstrap)
|
|
if bootstrap == "" {
|
|
bootstrap = "bootstrap.sh"
|
|
}
|
|
bootstrapPath := filepath.Join(input.PackageDir, filepath.FromSlash(bootstrap))
|
|
shellAdapter := resolveAppShellAdapter(r.ShellAdapter)
|
|
command, shellBinDirs, err := shellAdapter.Command(ctx, bootstrapPath)
|
|
if err != nil {
|
|
_ = logFile.Close()
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "bootstrap_unavailable", err)
|
|
r.setFailedForStart(key, start, "bootstrap_unavailable", err)
|
|
return
|
|
}
|
|
prepareAppProcessCommand(command)
|
|
command.Dir = input.RuntimeDir
|
|
command.Stdout = logFile
|
|
command.Stderr = logFile
|
|
tuttiCLIPath, err := workspaceAppCLIPath()
|
|
if err != nil {
|
|
_ = logFile.Close()
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "cli_unavailable", err)
|
|
r.setFailedForStart(key, start, "cli_unavailable", err)
|
|
return
|
|
}
|
|
tuttiAPIBaseURL := tuttiAPIBaseURLFromEnv()
|
|
appToolchainRoot := tuttiAppToolchainRoot()
|
|
envOverrides := []string{
|
|
"TUTTI_APP_ID=" + input.AppID,
|
|
"TUTTI_WORKSPACE_ID=" + input.WorkspaceID,
|
|
"TUTTI_WORKSPACE_NAME=" + input.WorkspaceName,
|
|
"TUTTI_APP_HOST=127.0.0.1",
|
|
"TUTTI_PLATFORM=" + runtime.GOOS + "-" + runtime.GOARCH,
|
|
"TUTTI_APP_PACKAGE_DIR=" + input.PackageDir,
|
|
"TUTTI_APP_RUNTIME_DIR=" + input.RuntimeDir,
|
|
"TUTTI_APP_DATA_DIR=" + input.DataDir,
|
|
"TUTTI_APP_DATABASE_DIR=" + input.DatabaseDir,
|
|
"TUTTI_APP_LOG_DIR=" + input.LogDir,
|
|
"TUTTI_APP_TOOLCHAIN_ROOT=" + appToolchainRoot,
|
|
"TUTTI_APP_PORT=" + strconv.Itoa(port),
|
|
"TUTTI_APP_BASE_URL=http://127.0.0.1:" + strconv.Itoa(port),
|
|
"TUTTI_API_BASE_URL=" + tuttiAPIBaseURL,
|
|
"TUTTI_APP_INSTALLATION_ID=" + input.WorkspaceID + ":" + input.AppID,
|
|
"TUTTI_APP_SERVER_TOKEN=" + appServerToken(input.WorkspaceID, input.AppID),
|
|
}
|
|
envOverrides = append(envOverrides, workspaceAppCLIEnvOverrides(runtime.GOOS, tuttiCLIPath)...)
|
|
envOverrides = append(envOverrides, appRuntime.EnvOverrides...)
|
|
if runtime.GOOS == "windows" {
|
|
envOverrides = append(envOverrides, appRuntimePathWithBinDirs(appRuntime, shellBinDirs...))
|
|
} else {
|
|
envOverrides = append(envOverrides, appRuntimePathWithCLIShim(appRuntime, tuttiCLIPath, shellBinDirs...))
|
|
}
|
|
envOverrides = append(envOverrides, shellAdapter.EnvironmentOverrides()...)
|
|
command.Env = workspaceAppProcessEnv(envOverrides...)
|
|
writeAppStartupDiagnostic(logFile, input, bootstrapPath, port, appRuntime, command, shellBinDirs)
|
|
|
|
launchURL := "http://127.0.0.1:" + strconv.Itoa(port)
|
|
startedAt := unixMsNow()
|
|
process := &appProcess{
|
|
command: command,
|
|
done: make(chan error, 1),
|
|
logFile: logFile,
|
|
}
|
|
if _, committed := r.setStateForStart(key, start, workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusStarting,
|
|
LaunchURL: &launchURL,
|
|
Port: &port,
|
|
StartedAtUnixMs: &startedAt,
|
|
PackageDir: input.PackageDir,
|
|
}); !committed {
|
|
_ = logFile.Close()
|
|
return
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
_ = logFile.Close()
|
|
return
|
|
}
|
|
if err := command.Start(); err != nil {
|
|
_ = logFile.Close()
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", fmt.Errorf("start app process: %w", err))
|
|
r.setFailedForStart(key, start, "startup", fmt.Errorf("start app process: %w", err))
|
|
return
|
|
}
|
|
containment, err := containAppProcess(command)
|
|
if err != nil {
|
|
_ = signalAppProcessTree(command, true)
|
|
_ = command.Wait()
|
|
_ = logFile.Close()
|
|
wrappedErr := fmt.Errorf("contain app process tree: %w", err)
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", wrappedErr)
|
|
r.setFailedForStart(key, start, "startup", wrappedErr)
|
|
return
|
|
}
|
|
process.containment = containment
|
|
if err := releaseAppProcessCommand(command); err != nil {
|
|
_ = killAppProcess(process)
|
|
_ = command.Wait()
|
|
_ = containment.close()
|
|
_ = logFile.Close()
|
|
wrappedErr := fmt.Errorf("release contained app process: %w", err)
|
|
logAppRuntimeControl("workspace_app_runtime_start_failed", input, port, "startup", wrappedErr)
|
|
r.setFailedForStart(key, start, "startup", wrappedErr)
|
|
return
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if r.starts[key] != start || ctx.Err() != nil {
|
|
process.stopRequested = true
|
|
r.mu.Unlock()
|
|
go waitForDetachedAppProcess(process)
|
|
if err := interruptAppProcess(process.command); err != nil {
|
|
_ = killAppProcess(process)
|
|
}
|
|
return
|
|
}
|
|
r.processes[key] = process
|
|
r.mu.Unlock()
|
|
|
|
go r.waitForProcess(key, process)
|
|
|
|
healthErr := r.waitForHealth(ctx, key, process, launchURL, input.HealthcheckPath)
|
|
if healthErr != nil {
|
|
if errors.Is(healthErr, context.Canceled) {
|
|
_, _ = r.stopProcess(context.Background(), key, process)
|
|
return
|
|
}
|
|
_, _ = r.stopProcess(context.Background(), key, process)
|
|
if attempt < maxAppPortStartupRetries && appRuntimeLogHasPortBindFailure(filepath.Join(input.LogDir, "runtime.log"), logStartOffset) {
|
|
logAppRuntimeControl("workspace_app_runtime_retrying_port", input, port, "port_bind", healthErr)
|
|
r.startProcessAttempt(ctx, key, input, start, attempt+1)
|
|
return
|
|
}
|
|
logAppRuntimeControl("workspace_app_runtime_healthcheck_failed", input, port, "healthcheck", healthErr)
|
|
r.setFailedForStart(key, start, "healthcheck", healthErr)
|
|
return
|
|
}
|
|
|
|
if r.setRunningIfProcessCurrent(key, start, process, workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusRunning,
|
|
LaunchURL: &launchURL,
|
|
Port: &port,
|
|
StartedAtUnixMs: &startedAt,
|
|
PackageDir: input.PackageDir,
|
|
}) {
|
|
logAppRuntimeControl("workspace_app_runtime_running", input, port, "", nil)
|
|
}
|
|
}
|
|
|
|
func tuttiAPIBaseURLFromEnv() string {
|
|
if addr := tuttiBoundAddrFromListenerInfo(); addr != "" {
|
|
return "http://" + addr
|
|
}
|
|
addr := strings.TrimSpace(os.Getenv("TUTTID_ADDR"))
|
|
if addr == "" {
|
|
return "http://127.0.0.1:28100"
|
|
}
|
|
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
|
return addr
|
|
}
|
|
return "http://" + addr
|
|
}
|
|
|
|
func tuttiBoundAddrFromListenerInfo() string {
|
|
path := strings.TrimSpace(os.Getenv("TUTTID_LISTENER_INFO_PATH"))
|
|
if path == "" {
|
|
path = tuttitypes.TuttidListenerInfoPath()
|
|
}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var info struct {
|
|
Addr string `json:"addr"`
|
|
}
|
|
if err := json.Unmarshal(body, &info); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(info.Addr)
|
|
}
|
|
|
|
func appServerToken(workspaceID string, appID string) string {
|
|
token := strings.TrimSpace(os.Getenv("TUTTID_ACCESS_TOKEN"))
|
|
return workspacebiz.AppServerToken(token, workspaceID, appID)
|
|
}
|
|
|
|
func (r *AppRunner) Stop(ctx context.Context, workspaceID string, appID string) (workspacebiz.AppRuntimeState, error) {
|
|
r.ensure()
|
|
|
|
key := appRuntimeKey(workspaceID, appID)
|
|
r.mu.Lock()
|
|
if start := r.starts[key]; start != nil {
|
|
start.cancel()
|
|
delete(r.starts, key)
|
|
}
|
|
process := r.processes[key]
|
|
existingState, hasExistingState := r.states[key]
|
|
if process == nil {
|
|
if !hasExistingState || existingState.Status == workspacebiz.AppRuntimeStatusIdle {
|
|
r.mu.Unlock()
|
|
return workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusIdle,
|
|
}, nil
|
|
}
|
|
state := withRuntimeUpdated(workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusIdle,
|
|
})
|
|
r.states[key] = state
|
|
r.mu.Unlock()
|
|
r.notifyStateChanged(key, state)
|
|
return state, nil
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
return r.stopProcess(ctx, key, process)
|
|
}
|
|
|
|
func (r *AppRunner) StopWorkspace(ctx context.Context, workspaceID string) {
|
|
r.ensure()
|
|
|
|
r.mu.Lock()
|
|
var keys []string
|
|
for key := range r.processes {
|
|
if appRuntimeWorkspaceIDFromKey(key) == workspaceID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
for key := range r.starts {
|
|
if appRuntimeWorkspaceIDFromKey(key) == workspaceID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
for key := range r.states {
|
|
if appRuntimeWorkspaceIDFromKey(key) == workspaceID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
for _, key := range uniqueRuntimeKeys(keys) {
|
|
appID := appRuntimeAppIDFromKey(key)
|
|
_, _ = r.Stop(ctx, workspaceID, appID)
|
|
}
|
|
}
|
|
|
|
func (r *AppRunner) StopApp(ctx context.Context, appID string) {
|
|
r.ensure()
|
|
|
|
appID = strings.TrimSpace(appID)
|
|
if appID == "" {
|
|
return
|
|
}
|
|
|
|
r.mu.Lock()
|
|
var keys []string
|
|
for key := range r.processes {
|
|
if appRuntimeAppIDFromKey(key) == appID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
for key := range r.starts {
|
|
if appRuntimeAppIDFromKey(key) == appID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
for key := range r.states {
|
|
if appRuntimeAppIDFromKey(key) == appID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
for _, key := range uniqueRuntimeKeys(keys) {
|
|
_, _ = r.Stop(ctx, appRuntimeWorkspaceIDFromKey(key), appID)
|
|
}
|
|
}
|
|
|
|
func (r *AppRunner) StopAll(ctx context.Context) {
|
|
r.ensure()
|
|
|
|
r.mu.Lock()
|
|
keys := make([]string, 0, len(r.processes))
|
|
for key := range r.processes {
|
|
keys = append(keys, key)
|
|
}
|
|
for key := range r.starts {
|
|
keys = append(keys, key)
|
|
}
|
|
for key := range r.states {
|
|
keys = append(keys, key)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
for _, key := range uniqueRuntimeKeys(keys) {
|
|
_, _ = r.Stop(ctx, appRuntimeWorkspaceIDFromKey(key), appRuntimeAppIDFromKey(key))
|
|
}
|
|
}
|
|
|
|
func (r *AppRunner) stopProcess(ctx context.Context, key string, process *appProcess) (workspacebiz.AppRuntimeState, error) {
|
|
r.mu.Lock()
|
|
currentProcess := r.processes[key]
|
|
current := r.states[key]
|
|
if currentProcess != nil && currentProcess != process {
|
|
r.mu.Unlock()
|
|
return currentRuntimeStateOrIdle(current), nil
|
|
}
|
|
if currentProcess == nil {
|
|
r.mu.Unlock()
|
|
return currentRuntimeStateOrIdle(current), nil
|
|
}
|
|
if process.stopRequested {
|
|
r.mu.Unlock()
|
|
return currentRuntimeStateOrIdle(current), nil
|
|
}
|
|
process.stopRequested = true
|
|
stoppingState := withRuntimeUpdated(workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusStopping,
|
|
LaunchURL: current.LaunchURL,
|
|
Port: current.Port,
|
|
FailureReason: current.FailureReason,
|
|
LastError: current.LastError,
|
|
StartedAtUnixMs: current.StartedAtUnixMs,
|
|
PackageDir: current.PackageDir,
|
|
})
|
|
r.states[key] = stoppingState
|
|
r.mu.Unlock()
|
|
r.notifyStateChanged(key, stoppingState)
|
|
|
|
if err := interruptAppProcess(process.command); err != nil {
|
|
_ = killAppProcess(process)
|
|
}
|
|
|
|
select {
|
|
case <-process.done:
|
|
case <-ctx.Done():
|
|
_ = killAppProcess(process)
|
|
select {
|
|
case <-process.done:
|
|
case <-time.After(500 * time.Millisecond):
|
|
}
|
|
return r.setStoppedProcessFailed(key, process, "stop", ctx.Err()), ctx.Err()
|
|
case <-time.After(2 * time.Second):
|
|
_ = killAppProcess(process)
|
|
select {
|
|
case <-process.done:
|
|
return r.setStoppedProcessIdle(key, process), nil
|
|
case <-time.After(500 * time.Millisecond):
|
|
}
|
|
return r.setStoppedProcessFailed(key, process, "stop", errors.New("timed out stopping app process")), nil
|
|
}
|
|
|
|
return r.setStoppedProcessIdle(key, process), nil
|
|
}
|
|
|
|
func (r *AppRunner) setStoppedProcessIdle(key string, process *appProcess) workspacebiz.AppRuntimeState {
|
|
return r.setStoppedProcessTerminalState(key, process, workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusIdle,
|
|
})
|
|
}
|
|
|
|
func (r *AppRunner) setStoppedProcessFailed(key string, process *appProcess, failureReason string, err error) workspacebiz.AppRuntimeState {
|
|
message := err.Error()
|
|
failurePhase := workspacebiz.AppFailurePhaseRuntime
|
|
return r.setStoppedProcessTerminalState(key, process, workspacebiz.AppRuntimeState{
|
|
Status: workspacebiz.AppRuntimeStatusFailed,
|
|
FailurePhase: &failurePhase,
|
|
FailureReason: &failureReason,
|
|
LastError: &message,
|
|
})
|
|
}
|
|
|
|
func (r *AppRunner) setStoppedProcessTerminalState(key string, process *appProcess, next workspacebiz.AppRuntimeState) workspacebiz.AppRuntimeState {
|
|
r.mu.Lock()
|
|
currentProcess := r.processes[key]
|
|
current := r.states[key]
|
|
if currentProcess != nil && currentProcess != process {
|
|
r.mu.Unlock()
|
|
return currentRuntimeStateOrIdle(current)
|
|
}
|
|
if currentProcess == nil && current.Status != workspacebiz.AppRuntimeStatusStopping {
|
|
r.mu.Unlock()
|
|
return currentRuntimeStateOrIdle(current)
|
|
}
|
|
state := withRuntimeUpdated(next)
|
|
r.states[key] = state
|
|
r.mu.Unlock()
|
|
r.notifyStateChanged(key, state)
|
|
return state
|
|
}
|
|
|
|
func currentRuntimeStateOrIdle(state workspacebiz.AppRuntimeState) workspacebiz.AppRuntimeState {
|
|
if state.Status == "" {
|
|
return workspacebiz.AppRuntimeState{Status: workspacebiz.AppRuntimeStatusIdle}
|
|
}
|
|
return state
|
|
}
|
|
|
|
func writeAppStartupDiagnostic(logFile *os.File, input AppStartInput, bootstrapPath string, port int, appRuntime ResolvedAppRuntime, command *exec.Cmd, shellBinDirs []string) {
|
|
if logFile == nil {
|
|
return
|
|
}
|
|
env := command.Env
|
|
_, _ = fmt.Fprintf(logFile, "tutti workspace app startup\n")
|
|
_, _ = fmt.Fprintf(logFile, " appId=%s\n", input.AppID)
|
|
_, _ = fmt.Fprintf(logFile, " workspaceId=%s\n", input.WorkspaceID)
|
|
_, _ = fmt.Fprintf(logFile, " workspaceName=%s\n", input.WorkspaceName)
|
|
_, _ = fmt.Fprintf(logFile, " bootstrap=%s\n", bootstrapPath)
|
|
_, _ = fmt.Fprintf(logFile, " runtimeRoot=%s\n", appRuntime.Root)
|
|
_, _ = fmt.Fprintf(logFile, " python=%s\n", appRuntime.Python)
|
|
_, _ = fmt.Fprintf(logFile, " node=%s\n", appRuntime.Node)
|
|
_, _ = fmt.Fprintf(logFile, " npm=%s\n", appRuntime.NPM)
|
|
_, _ = fmt.Fprintf(logFile, " cwd=%s\n", input.RuntimeDir)
|
|
_, _ = fmt.Fprintf(logFile, " packageDir=%s\n", input.PackageDir)
|
|
_, _ = fmt.Fprintf(logFile, " dataDir=%s\n", input.DataDir)
|
|
_, _ = fmt.Fprintf(logFile, " databaseDir=%s\n", input.DatabaseDir)
|
|
_, _ = fmt.Fprintf(logFile, " logDir=%s\n", input.LogDir)
|
|
_, _ = fmt.Fprintf(logFile, " toolchainRoot=%s\n", appRuntimeEnvValue(env, "TUTTI_APP_TOOLCHAIN_ROOT"))
|
|
_, _ = fmt.Fprintf(logFile, " managedPosixShell=%s\n", appRuntimeEnvValue(env, "TUTTI_MANAGED_POSIX_SHELL"))
|
|
_, _ = fmt.Fprintf(logFile, " shellBinDirs=%s\n", strings.Join(shellBinDirs, string(os.PathListSeparator)))
|
|
_, _ = fmt.Fprintf(logFile, " msys2PathType=%s\n", appRuntimeEnvValue(env, "MSYS2_PATH_TYPE"))
|
|
_, _ = fmt.Fprintf(logFile, " command=%s\n", strings.Join(command.Args, " "))
|
|
_, _ = fmt.Fprintf(logFile, " host=127.0.0.1\n")
|
|
_, _ = fmt.Fprintf(logFile, " port=%d\n", port)
|
|
_, _ = fmt.Fprintf(logFile, " path=%s\n", appRuntimeEnvValue(env, "PATH"))
|
|
}
|
|
|
|
func logAppRuntimeControl(event string, input AppStartInput, port int, failureReason string, err error) {
|
|
fields := []any{
|
|
"workspaceId", input.WorkspaceID,
|
|
"appId", input.AppID,
|
|
"packageDir", input.PackageDir,
|
|
"bootstrap", input.Bootstrap,
|
|
"healthcheckPath", input.HealthcheckPath,
|
|
}
|
|
if strings.TrimSpace(input.RuntimeProfile) != "" {
|
|
fields = append(fields, "runtimeProfile", strings.TrimSpace(input.RuntimeProfile))
|
|
}
|
|
if port != 0 {
|
|
fields = append(fields, "port", port)
|
|
}
|
|
if failureReason != "" {
|
|
fields = append(fields, "failureReason", failureReason)
|
|
}
|
|
if err != nil {
|
|
fields = append(fields, "lastError", err.Error(), "error", err)
|
|
slog.Warn(event, fields...)
|
|
return
|
|
}
|
|
slog.Info(event, fields...)
|
|
}
|
|
|
|
func (r *AppRunner) waitForHealth(ctx context.Context, key string, process *appProcess, launchURL string, healthcheckPath string) error {
|
|
timeout := r.HealthcheckTimeout
|
|
if timeout <= 0 {
|
|
timeout = defaultAppHealthcheckTimeout
|
|
}
|
|
deadline := time.Now().Add(timeout)
|
|
healthcheckPath = path.Clean("/" + strings.TrimPrefix(healthcheckPath, "/"))
|
|
|
|
for {
|
|
if time.Now().After(deadline) {
|
|
return errors.New("app healthcheck timed out")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if !r.isCurrentProcess(key, process) {
|
|
return errors.New("app process exited before healthcheck")
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, launchURL+healthcheckPath, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create app healthcheck request: %w", err)
|
|
}
|
|
response, err := r.httpClient().Do(request)
|
|
if err == nil {
|
|
_ = response.Body.Close()
|
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *AppRunner) isCurrentProcess(key string, process *appProcess) bool {
|
|
if r == nil || process == nil {
|
|
return false
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.processes[key] == process
|
|
}
|
|
|
|
func (r *AppRunner) httpClient() *http.Client {
|
|
if r.HTTPClient != nil {
|
|
return r.HTTPClient
|
|
}
|
|
return httpx.NewClient(1 * time.Second)
|
|
}
|
|
|
|
func (r *AppRunner) runtimeResolver() AppRuntimeResolver {
|
|
if r.RuntimeResolver != nil {
|
|
return r.RuntimeResolver
|
|
}
|
|
return DefaultManagedAppRuntimeResolver{}
|
|
}
|
|
|
|
func (r *AppRunner) resolveRuntime(ctx context.Context, profile string) (ResolvedAppRuntime, error) {
|
|
profile = strings.TrimSpace(profile)
|
|
if appRuntimeProfileIsStandalone(profile) {
|
|
return ResolvedAppRuntime{}, nil
|
|
}
|
|
resolver := r.runtimeResolver()
|
|
if profile != "" {
|
|
if profileResolver, ok := resolver.(AppRuntimeProfileResolver); ok {
|
|
return profileResolver.ResolveProfile(ctx, profile)
|
|
}
|
|
}
|
|
return resolver.Resolve(ctx)
|
|
}
|
|
|
|
func (r *AppRunner) ensure() {
|
|
r.initOnce.Do(func() {
|
|
if r.processes == nil {
|
|
r.processes = make(map[string]*appProcess)
|
|
}
|
|
if r.states == nil {
|
|
r.states = make(map[string]workspacebiz.AppRuntimeState)
|
|
}
|
|
if r.starts == nil {
|
|
r.starts = make(map[string]*appStart)
|
|
}
|
|
if r.queue == nil {
|
|
r.queue = make(chan struct{}, 2)
|
|
}
|
|
})
|
|
}
|
|
|
|
func uniqueRuntimeKeys(keys []string) []string {
|
|
if len(keys) < 2 {
|
|
return keys
|
|
}
|
|
seen := make(map[string]struct{}, len(keys))
|
|
result := keys[:0]
|
|
for _, key := range keys {
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
result = append(result, key)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func allocateLoopbackPort() (int, error) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer listener.Close()
|
|
|
|
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
return 0, fmt.Errorf("unexpected listener address %q", listener.Addr().String())
|
|
}
|
|
return tcpAddr.Port, nil
|
|
}
|
|
|
|
func appRuntimeLogHasPortBindFailure(logPath string, startOffset int64) bool {
|
|
body, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if startOffset < 0 {
|
|
startOffset = 0
|
|
}
|
|
if startOffset > 0 {
|
|
if startOffset >= int64(len(body)) {
|
|
return false
|
|
}
|
|
body = body[startOffset:]
|
|
}
|
|
message := strings.ToLower(string(body))
|
|
if !strings.Contains(message, "listen") && !strings.Contains(message, "bind") {
|
|
return false
|
|
}
|
|
for _, marker := range []string{"eacces", "eaddrinuse", "address already in use", "permission denied"} {
|
|
if strings.Contains(message, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tuttiAppToolchainRoot() string {
|
|
return filepath.Join(tuttitypes.DefaultStateDir(), "app-toolchains")
|
|
}
|
|
|
|
func appRuntimePathWithCLIShim(appRuntime ResolvedAppRuntime, cliShimPath string, extraBinDirs ...string) string {
|
|
prefixDirs := append([]string{filepath.Dir(cliShimPath)}, extraBinDirs...)
|
|
return appRuntimePathWithBinDirs(appRuntime, prefixDirs...)
|
|
}
|
|
|
|
func appRuntimePathWithBinDirs(appRuntime ResolvedAppRuntime, prefixDirs ...string) string {
|
|
pathKey := pathEnvKey(appRuntime.EnvOverrides)
|
|
pathValue := envValue(appRuntime.EnvOverrides, pathKey)
|
|
if strings.TrimSpace(pathValue) == "" {
|
|
pathValue = os.Getenv(pathKey)
|
|
}
|
|
pathDirs := filepath.SplitList(pathValue)
|
|
pathDirs = mergeAppPathDirs(append(prefixDirs, pathDirs...))
|
|
return pathKey + "=" + strings.Join(pathDirs, string(os.PathListSeparator))
|
|
}
|
|
|
|
func tuttiCLIShimPathForPlatform(platform string) string {
|
|
commandName := "tutti"
|
|
if tuttitypes.IsDevelopmentEnv() {
|
|
commandName = "tutti-dev"
|
|
}
|
|
if platform == "windows" {
|
|
commandName += ".cmd"
|
|
}
|
|
return filepath.Join(tuttitypes.DefaultStateDir(), "bin", commandName)
|
|
}
|
|
|
|
func appRuntimeKey(workspaceID string, appID string) string {
|
|
return workspaceID + "\x00" + appID
|
|
}
|
|
|
|
func appRuntimeWorkspaceIDFromKey(key string) string {
|
|
for index, value := range key {
|
|
if value == 0 {
|
|
return key[:index]
|
|
}
|
|
}
|
|
return key
|
|
}
|
|
|
|
func appRuntimeAppIDFromKey(key string) string {
|
|
for index, value := range key {
|
|
if value == 0 {
|
|
return key[index+1:]
|
|
}
|
|
}
|
|
return ""
|
|
}
|