Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cli/internal/cli/error_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const (
errorCodeUnknownCommand = "UNKNOWN_COMMAND"
errorCodeProjectNotFound = "PROJECT_NOT_FOUND"
errorCodeUnityNotReachable = "UNITY_NOT_REACHABLE"
errorCodeUnityStartupTimeout = "UNITY_STARTUP_TIMEOUT"
errorCodeUnityProcessExitTimeout = "UNITY_PROCESS_EXIT_TIMEOUT"
errorCodeUnityDisconnectedAfterDispatch = "UNITY_DISCONNECTED_AFTER_DISPATCH"
errorCodeUnityDisconnectedAfterAccept = "UNITY_DISCONNECTED_AFTER_ACCEPT"
errorCodeUnityResponseTimeoutAfterAccept = "UNITY_RESPONSE_TIMEOUT_AFTER_ACCEPT"
Expand Down Expand Up @@ -145,6 +147,16 @@ func classifyError(err error, context errorContext) cliError {
return argumentErr.toCLIError(context)
}

var startupTimeoutErr launchStartupTimeoutError
if errors.As(err, &startupTimeoutErr) {
return unityStartupTimeoutCLIError(startupTimeoutErr, context)
}

var processExitTimeoutErr launchProcessExitTimeoutError
if errors.As(err, &processExitTimeoutErr) {
return unityProcessExitTimeoutCLIError(processExitTimeoutErr, context)
}

var notRespondingErr unityServerNotRespondingError
if errors.As(err, &notRespondingErr) {
return cliError{
Expand Down
49 changes: 49 additions & 0 deletions cli/internal/cli/error_envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,55 @@ func TestClassifyUnityServerNotRespondingError(t *testing.T) {
}
}

func TestClassifyLaunchStartupTimeoutError(t *testing.T) {
// Verifies launch startup timeouts do not look like generic reachability or package failures.
cliErr := classifyError(
launchStartupTimeoutError{
projectRoot: "/tmp/MyProject",
cause: errors.New("timed out waiting for Unity tool readiness"),
},
errorContext{projectRoot: "/tmp/MyProject", command: launchCommandName},
)

if cliErr.ErrorCode != errorCodeUnityStartupTimeout {
t.Fatalf("error code mismatch: %#v", cliErr)
}
if cliErr.Message != "Unity is running, but the Editor did not finish startup before the launch timeout." {
t.Fatalf("message mismatch: %#v", cliErr)
}
for _, action := range cliErr.NextActions {
lowerAction := strings.ToLower(action)
if strings.Contains(lowerAction, "package") || strings.Contains(lowerAction, "uloop launch") {
t.Fatalf("next action should avoid package guesses and launch retry guidance: %#v", cliErr.NextActions)
}
}
if cliErr.Details["timeoutSeconds"] != 600 {
t.Fatalf("timeout details mismatch: %#v", cliErr.Details)
}
}

func TestClassifyLaunchProcessExitTimeoutError(t *testing.T) {
// Verifies restart and quit process-exit timeouts are structured as retryable launch failures.
cliErr := classifyError(
launchProcessExitTimeoutError{
projectRoot: "/tmp/MyProject",
pid: 123,
timeout: launchProcessExitTimeout,
},
errorContext{projectRoot: "/tmp/MyProject", command: launchCommandName},
)

if cliErr.ErrorCode != errorCodeUnityProcessExitTimeout {
t.Fatalf("error code mismatch: %#v", cliErr)
}
if !cliErr.Retryable || !cliErr.SafeToRetry {
t.Fatalf("process exit timeout should be retryable: %#v", cliErr)
}
if cliErr.Details["pid"] != 123 {
t.Fatalf("pid details mismatch: %#v", cliErr.Details)
}
}

func TestWriteToolFailureWhenServerStopsBeforeAcceptingDispatchedRequestIsNotSafeToRetry(t *testing.T) {
// Verifies pre-accept server silence does not advertise a dispatched state-changing command as safe to retry.
var stderr bytes.Buffer
Expand Down
71 changes: 58 additions & 13 deletions cli/internal/cli/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,26 @@ import (
)

const (
launchCommandName = "launch"
launchLockfilePoll = 100 * time.Millisecond
launchLockfileTimeout = 5 * time.Second
projectVersionFilePath = "ProjectSettings/ProjectVersion.txt"
recoveryDirectoryPath = "Assets/_Recovery"
launchTempDirectoryName = "Temp"
unityLockfileName = "UnityLockfile"
launchCommandName = "launch"
launchLockfilePoll = 100 * time.Millisecond
launchLockfileTimeout = 5 * time.Second
launchProcessExitPoll = 100 * time.Millisecond
launchProcessExitTimeout = 20 * time.Second
launchReadinessTimeout = 10 * time.Minute
projectVersionFilePath = "ProjectSettings/ProjectVersion.txt"
recoveryDirectoryPath = "Assets/_Recovery"
launchTempDirectoryName = "Temp"
unityLockfileName = "UnityLockfile"
)

var (
findRunningUnityProcessForLaunch = findRunningUnityProcess
focusUnityProcessForLaunch = focusUnityProcess
killUnityProcessForLaunch = killUnityProcess
resolveUnityExecutablePathForLaunch = resolveUnityExecutablePath
waitForUnityLockfileForLaunch = waitForUnityLockfile
waitForToolReadinessForLaunch = waitForToolReadiness
waitForUnityProcessExitForLaunch = waitForUnityProcessExit
waitForUnityStartupMarkerForLaunch = waitForUnityStartupMarkerOrTimeout
waitForToolReadinessForLaunch = waitForToolReadinessWithTimeout
probeProjectIpcForLaunchFallback = probeToolReadinessSequence
)

Expand Down Expand Up @@ -214,7 +218,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std
spinner := newLaunchSpinner(stdout, stderr)
defer spinner.Stop()
writeLaunchReadinessWait(stdout, spinner)
if err := waitForToolReadinessForLaunch(ctx, projectRoot); err != nil {
if err := waitForLaunchReadiness(ctx, projectRoot); err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
Expand All @@ -225,6 +229,10 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
if err := waitForUnityProcessExitForLaunch(ctx, projectRoot, runningProcess.pid, launchProcessExitPoll, launchProcessExitTimeout); err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
if options.quit {
return writeLaunchQuitResponse(stdout, stderr, projectRoot, &runningProcess.pid, launchStoppedMessage)
}
Expand Down Expand Up @@ -276,12 +284,12 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
if err := waitForUnityLockfileForLaunch(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil {
if err := waitForUnityStartupMarkerForLaunch(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
writeLaunchReadinessWait(stdout, spinner)
if err := waitForToolReadinessForLaunch(ctx, projectRoot); err != nil {
if err := waitForLaunchReadiness(ctx, projectRoot); err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName})
return 1
}
Expand Down Expand Up @@ -312,7 +320,44 @@ func cleanStaleUnityTemp(projectRoot string) (bool, error) {
return true, os.RemoveAll(filepath.Join(projectRoot, launchTempDirectoryName))
}

func waitForUnityLockfile(ctx context.Context, lockfilePath string, pollInterval time.Duration, timeout time.Duration) error {
func waitForUnityProcessExit(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error {
timeoutContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
timeoutError := func() error {
if ctx.Err() != nil {
return ctx.Err()
}
return launchProcessExitTimeoutError{
projectRoot: projectRoot,
pid: pid,
timeout: timeout,
}
}

ticker := time.NewTicker(pollInterval)
defer ticker.Stop()

for {
runningProcess, err := findRunningUnityProcessForLaunch(timeoutContext, projectRoot)
if err != nil {
if timeoutContext.Err() != nil {
return timeoutError()
}
return err
}
if runningProcess == nil || runningProcess.pid != pid {
return nil
}

select {
case <-timeoutContext.Done():
return timeoutError()
case <-ticker.C:
}
}
}

func waitForUnityStartupMarkerOrTimeout(ctx context.Context, lockfilePath string, pollInterval time.Duration, timeout time.Duration) error {
timeoutContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

Expand Down
37 changes: 37 additions & 0 deletions cli/internal/cli/launch_process_exit_timeout_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"fmt"
"time"
)

type launchProcessExitTimeoutError struct {
projectRoot string
pid int
timeout time.Duration
}

func (err launchProcessExitTimeoutError) Error() string {
return fmt.Sprintf("Unity process %d did not exit within %s", err.pid, err.timeout)
}

func unityProcessExitTimeoutCLIError(err launchProcessExitTimeoutError, context errorContext) cliError {
projectRoot := firstNonEmpty(context.projectRoot, err.projectRoot)
return cliError{
ErrorCode: errorCodeUnityProcessExitTimeout,
Phase: errorPhaseExecution,
Message: fmt.Sprintf("Unity process %d did not exit before the launch timeout.", err.pid),
Retryable: true,
SafeToRetry: true,
ProjectRoot: projectRoot,
Command: context.command,
NextActions: []string{
"Wait for Unity to finish exiting, then retry the launch command.",
"If Unity remains visible or keeps project files locked, close the Unity process from the OS and retry.",
},
Details: map[string]any{
"pid": err.pid,
"timeoutSeconds": int(err.timeout.Seconds()),
},
}
}
66 changes: 66 additions & 0 deletions cli/internal/cli/launch_startup_timeout_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cli

import (
"context"
"errors"
"fmt"
)

type launchStartupTimeoutError struct {
projectRoot string
cause error
}

func (err launchStartupTimeoutError) Error() string {
if err.cause == nil {
return "Unity startup did not finish before the launch timeout"
}
return fmt.Sprintf("Unity startup did not finish before the launch timeout: %s", err.cause.Error())
}

func (err launchStartupTimeoutError) Unwrap() error {
return err.cause
}

func waitForLaunchReadiness(ctx context.Context, projectRoot string) error {
err := waitForToolReadinessForLaunch(ctx, projectRoot, launchReadinessTimeout)
if err == nil {
return nil
}
if ctx.Err() != nil || isReadinessCLIUpdateRequiredError(err) {
return err
}
var notRespondingErr unityServerNotRespondingError
if !errors.As(err, &notRespondingErr) {
return err
}
return launchStartupTimeoutError{
projectRoot: projectRoot,
cause: err,
}
}

func unityStartupTimeoutCLIError(err launchStartupTimeoutError, context errorContext) cliError {
projectRoot := firstNonEmpty(context.projectRoot, err.projectRoot)
details := map[string]any{
"timeoutSeconds": int(launchReadinessTimeout.Seconds()),
}
if err.cause != nil {
details["cause"] = err.cause.Error()
}
return cliError{
ErrorCode: errorCodeUnityStartupTimeout,
Phase: errorPhaseConnection,
Message: "Unity is running, but the Editor did not finish startup before the launch timeout.",
Retryable: true,
SafeToRetry: true,
ProjectRoot: projectRoot,
Command: context.command,
NextActions: []string{
"Wait for Unity to finish importing assets, compiling scripts, or reloading the domain.",
"After the Editor becomes responsive, continue with the uloop command you wanted to run.",
"If Unity appears stuck, focus the Editor and check the Console or Editor log.",
},
Details: details,
}
}
Loading
Loading