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
4 changes: 0 additions & 4 deletions cli/common/clicore/focus.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ func RunFocusWindow(ctx context.Context, projectRoot string, stdout io.Writer, s
return runFocusWindow(ctx, projectRoot, stdout, stderr, defaultFocusWindowDeps())
}

func FindRunningUnityProcess(ctx context.Context, projectRoot string) (*UnityProcess, error) {
return unityprocess.FindRunningUnityProcess(ctx, projectRoot)
}

func FocusUnityProcess(ctx context.Context, pid int) error {
return unityprocess.FocusUnityProcess(ctx, pid)
}
Expand Down
10 changes: 9 additions & 1 deletion cli/common/clicore/tool_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const executeDynamicCodeReadinessProbe = `return "Unity CLI Loop dynamic code pr
type toolReadinessDeps struct {
findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error)
probeToolReadinessSequence func(context.Context, string) error
// now is the clock the Unity exit watch spaces its lookups with; nil uses time.Now.
now func() time.Time
}

func WaitForToolReadiness(ctx context.Context, projectRoot string) error {
Expand All @@ -40,7 +42,7 @@ func WaitForToolReadinessWithTimeout(ctx context.Context, projectRoot string, ti

func defaultToolReadinessDeps() toolReadinessDeps {
return toolReadinessDeps{
findRunningUnityProcess: unityprocess.FindRunningUnityProcess,
findRunningUnityProcess: unityprocess.FindRunningUnityProcessDefinitively,
probeToolReadinessSequence: ProbeToolReadinessSequence,
}
}
Expand All @@ -52,6 +54,7 @@ func waitForToolReadinessWithDeps(ctx context.Context, projectRoot string, timeo
defer cancel()

var lastErr error
exitWatch := NewUnityExitWatch(projectRoot, resolveProjectEndpointAddress(projectRoot), deps.findRunningUnityProcess, deps.now)
ticker := time.NewTicker(ToolReadinessPoll)
defer ticker.Stop()
for {
Expand All @@ -67,6 +70,11 @@ func waitForToolReadinessWithDeps(ctx context.Context, projectRoot string, timeo
if clierrors.IsPermanentConnectError(err) {
return err
}
// Why abort: an Editor that exited cannot become ready, and launch would otherwise wait
// its whole ten minutes for it.
if exitErr := exitWatch.Observe(timeoutContext, err); exitErr != nil {
return exitErr
}
lastErr = err
}

Expand Down
37 changes: 37 additions & 0 deletions cli/common/clicore/tool_readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
Expand Down Expand Up @@ -264,3 +265,39 @@ func assertConnectionAttemptToProject(t *testing.T, err error, projectRoot strin
t.Fatalf("connection endpoint = %q, want %q", attemptErr.Endpoint, expected)
}
}

// Verifies a readiness wait whose Editor exited ends as soon as the exit is confirmed, instead of
// polling a server that cannot come back until the wait times out.
func TestWaitForToolReadinessStopsOnceUnityHasExited(t *testing.T) {
clock := time.Unix(1_700_000_000, 0)
deps := toolReadinessDeps{
probeToolReadinessSequence: func(context.Context, string) error {
return &unityipc.ConnectionAttemptError{Cause: syscall.ECONNREFUSED}
},
findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) {
return nil, nil
},
// Each reading moves a full confirm interval, so the second poll can already confirm.
now: func() time.Time {
clock = clock.Add(unityExitConfirmInterval)
return clock
},
}

err := waitForToolReadinessWithDeps(context.Background(), t.TempDir(), time.Hour, deps)

var notRunning clierrors.UnityNotRunningError
if !errors.As(err, &notRunning) {
t.Fatalf("expected UnityNotRunningError, got %v", err)
}
}

// Verifies the readiness wait's default lookup is the one that reports an Editor of unknown project
// instead of "none", since its exit watch concludes from "none" that Unity is not running.
func TestDefaultToolReadinessLookupDoesNotTakeAnUnidentifiedEditorAsNone(t *testing.T) {
lookup := defaultToolReadinessDeps().findRunningUnityProcess

if reflect.ValueOf(lookup).Pointer() != reflect.ValueOf(unityprocess.FindRunningUnityProcessDefinitively).Pointer() {
t.Fatal("the default lookup must be unityprocess.FindRunningUnityProcessDefinitively")
}
}
109 changes: 109 additions & 0 deletions cli/common/clicore/unity_exit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package clicore

import (
"context"
"os"
"time"

clierrors "github.com/hatayama/unity-cli-loop/common/errors"
"github.com/hatayama/unity-cli-loop/common/unityprocess"
)

const (
// Why spaced lookups: an Editor that is tearing down can still be listed for a moment after its
// server closed, and one empty listing is thin evidence for ending a ten-minute wait. Two lookups this
// far apart that both find nothing are not a transient.
unityExitConfirmInterval = 2 * time.Second
unityExitConfirmations = 2
)

// NewUnityNotRunningError describes a project whose Unity Editor a successful process lookup did not
// find, noting whether the Editor left its lockfile behind and, if so, where its log is. exitedDuringCommand
// says the command had reached Unity before it went away.
func NewUnityNotRunningError(
projectRoot string,
endpoint string,
cause error,
exitedDuringCommand bool,
) clierrors.UnityNotRunningError {
leftBehind := lockfileLeftBehind(projectRoot)
editorLogPath := ""
// Why only after an abnormal exit: the log is pointed at only then, and finding it reads two logs.
if leftBehind {
editorLogPath = unityprocess.EditorLogPathFor(projectRoot)
}
return clierrors.UnityNotRunningError{
ProjectRoot: projectRoot,
Endpoint: endpoint,
Cause: cause,
LockfileLeftBehind: leftBehind,
EditorLogPath: editorLogPath,
ExitedDuringCommand: exitedDuringCommand,
}
}

// Why only a lockfile that is certainly there counts: a stat that fails for another reason does not show
// how Unity shut down, and the abnormal-exit guidance must not rest on it.
func lockfileLeftBehind(projectRoot string) bool {
_, err := os.Stat(unityprocess.LockfilePath(projectRoot))
return err == nil
}

// UnityExitWatch tells a wait loop that keeps losing its connection to Unity when the Editor process
// for the project has exited, so the loop can stop instead of polling until its own timeout.
type UnityExitWatch struct {
projectRoot string
endpoint string
findProcess func(context.Context, string) (*unityprocess.UnityProcess, error)
now func() time.Time
lastLookupAt time.Time
goneLookups int
}

// NewUnityExitWatch creates a watch for the project at projectRoot. findProcess is the process lookup and
// now the clock; a nil now uses time.Now.
func NewUnityExitWatch(
projectRoot string,
endpoint string,
findProcess func(context.Context, string) (*unityprocess.UnityProcess, error),
now func() time.Time,
) *UnityExitWatch {
if now == nil {
now = time.Now
}
return &UnityExitWatch{
projectRoot: projectRoot,
endpoint: endpoint,
findProcess: findProcess,
now: now,
}
}

// Observe takes the error of one poll, nil for a poll that succeeded. It returns a
// clierrors.UnityNotRunningError once polls that found the server gone were followed by two lookups, at
// least unityExitConfirmInterval apart, that both found no Editor process; otherwise it returns nil.
func (watch *UnityExitWatch) Observe(ctx context.Context, pollErr error) error {
// Any other outcome, a success included, shows a server that may be alive, so the lookups that
// found nothing so far no longer belong to one exit.
if !clierrors.IsUnityServerGoneError(pollErr) {
watch.goneLookups = 0
return nil
}
now := watch.now()
if !watch.lastLookupAt.IsZero() && now.Sub(watch.lastLookupAt) < unityExitConfirmInterval {
return nil
}
watch.lastLookupAt = now

process, err := watch.findProcess(ctx, watch.projectRoot)
// A lookup that failed observed nothing, so it cannot count toward an exit.
if err != nil || process != nil {
watch.goneLookups = 0
return nil
}
watch.goneLookups++
if watch.goneLookups < unityExitConfirmations {
return nil
}
return NewUnityNotRunningError(watch.projectRoot, watch.endpoint, pollErr, true)
}
Loading
Loading