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: 3 additions & 1 deletion cli/common/clicore/tool_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ func toolReadinessDoneErrorWithDeps(ctx context.Context, projectRoot string, cau
if err := ctx.Err(); err != nil {
return err
}
runningProcess, err := deps.findRunningUnityProcess(context.Background(), projectRoot)
processLookupContext, cancel := context.WithTimeout(context.Background(), unityprocess.ProcessListCommandTimeout)
defer cancel()
runningProcess, err := deps.findRunningUnityProcess(processLookupContext, projectRoot)
if err == nil && runningProcess != nil {
return clierrors.UnityServerNotRespondingError{
ProjectRoot: projectRoot,
Expand Down
11 changes: 10 additions & 1 deletion cli/common/clicore/tool_readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

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

// Verifies shared readiness waits keep the shorter non-launch timeout.
Expand Down Expand Up @@ -82,7 +83,15 @@ func TestToolReadinessDoneErrorReportsTimeoutWhenParentIsActive(t *testing.T) {
// Verifies that readiness timeout reports a live Unity process whose IPC server does not respond.
func TestToolReadinessDoneErrorReportsServerNotRespondingWhenUnityRuns(t *testing.T) {
deps := toolReadinessDeps{
findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) {
findRunningUnityProcess: func(ctx context.Context, projectRoot string) (*UnityProcess, error) {
deadline, hasDeadline := ctx.Deadline()
if !hasDeadline {
t.Fatal("process lookup context should have a deadline")
}
remaining := time.Until(deadline)
if remaining < unityprocess.ProcessListCommandTimeout-time.Second || remaining > unityprocess.ProcessListCommandTimeout {
t.Fatalf("process lookup timeout mismatch: %s", remaining)
}
return &UnityProcess{Pid: 123}, nil
},
}
Expand Down
8 changes: 6 additions & 2 deletions cli/common/unityprocess/focus_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFun
}

func readFrontmostProcessIDMac(ctx context.Context) int {
output, err := exec.CommandContext(ctx, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output()
commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout)
defer cancel()
output, err := exec.CommandContext(commandContext, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output()
if err != nil {
return 0
}
Expand All @@ -40,6 +42,8 @@ func readFrontmostProcessIDMac(ctx context.Context) int {
}

func setFrontmostProcessMac(ctx context.Context, pid int) error {
commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout)
defer cancel()
script := fmt.Sprintf(`tell application "System Events" to set frontmost of (first process whose unix id is %d) to true`, pid)
return exec.CommandContext(ctx, "osascript", "-e", script).Run()
return exec.CommandContext(commandContext, "osascript", "-e", script).Run()
}
12 changes: 9 additions & 3 deletions cli/common/unityprocess/focus_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import (
)

func FocusUnityProcess(ctx context.Context, pid int) error {
commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout)
defer cancel()
script := buildFocusUnityProcessWindowsScript(pid)
stderr := bytes.Buffer{}
command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command.Stderr = &stderr
if err := command.Run(); err != nil {
return commandErrorWithStderr(err, stderr.String())
Expand All @@ -20,9 +22,11 @@ func FocusUnityProcess(ctx context.Context, pid int) error {
}

func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) {
commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout)
defer cancel()
script := buildFocusUnityProcessWindowsWithRestoreScript(pid)
stderr := bytes.Buffer{}
command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
Expand All @@ -38,9 +42,11 @@ func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFun
}

func restoreWindowsForegroundWindow(ctx context.Context, handle int64) error {
commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout)
defer cancel()
script := buildRestoreWindowsForegroundWindowScript(handle)
stderr := bytes.Buffer{}
command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command.Stderr = &stderr
if err := command.Run(); err != nil {
return commandErrorWithStderr(err, stderr.String())
Expand Down
8 changes: 6 additions & 2 deletions cli/common/unityprocess/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ func listUnityProcesses(ctx context.Context) ([]UnityProcess, error) {
}

func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) {
output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,command=", "-ww").Output()
commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout)
defer cancel()
output, err := exec.CommandContext(commandContext, "ps", "-axo", "pid=,command=", "-ww").Output()
if err != nil {
return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err)
}
Expand All @@ -80,7 +82,9 @@ func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) {
" Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)",
"}",
}
output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output()
commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout)
defer cancel()
output, err := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output()
if err != nil {
return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err)
}
Expand Down
17 changes: 17 additions & 0 deletions cli/common/unityprocess/timeouts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package unityprocess

import (
"context"
"time"
)

const (
// ProcessListCommandTimeout bounds ps and PowerShell process-enumeration calls that can hang on WMI stalls.
ProcessListCommandTimeout = 10 * time.Second
// FocusCommandTimeout bounds osascript and PowerShell focus calls that can hang on permission dialogs.
FocusCommandTimeout = 10 * time.Second
)

func withCommandTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, timeout)
}
40 changes: 40 additions & 0 deletions cli/common/unityprocess/timeouts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package unityprocess

import (
"context"
"testing"
"time"
)

// Verifies external-command timeouts use the parent deadline when it is sooner than the command cap.
func TestWithCommandTimeoutRespectsEarlierParentDeadline(t *testing.T) {
parentContext, parentCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer parentCancel()

commandContext, cancel := withCommandTimeout(parentContext, ProcessListCommandTimeout)
defer cancel()

parentDeadline, parentHasDeadline := parentContext.Deadline()
commandDeadline, commandHasDeadline := commandContext.Deadline()
if !parentHasDeadline || !commandHasDeadline {
t.Fatal("expected both parent and command contexts to have deadlines")
}
if !commandDeadline.Equal(parentDeadline) {
t.Fatalf("command deadline %s should match parent deadline %s", commandDeadline, parentDeadline)
}
}

// Verifies external-command timeouts still bound background callers that pass no deadline.
func TestWithCommandTimeoutBoundsBackgroundContext(t *testing.T) {
commandContext, cancel := withCommandTimeout(context.Background(), ProcessListCommandTimeout)
defer cancel()

deadline, hasDeadline := commandContext.Deadline()
if !hasDeadline {
t.Fatal("expected command context to have a deadline")
}
remaining := time.Until(deadline)
if remaining < ProcessListCommandTimeout-time.Second || remaining > ProcessListCommandTimeout {
t.Fatalf("command timeout mismatch: %s", remaining)
}
}
7 changes: 6 additions & 1 deletion cli/dispatcher/cmd/dispatcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ package main
import (
"context"
"os"
"os/signal"
"syscall"

"github.com/hatayama/unity-cli-loop/dispatcher/internal/dispatcher"
)

func main() {
os.Exit(dispatcher.RunDispatcher(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
context.AfterFunc(ctx, stop)
os.Exit(dispatcher.RunDispatcher(ctx, os.Args[1:], os.Stdout, os.Stderr))
}
2 changes: 1 addition & 1 deletion cli/dispatcher/shared-inputs-stamp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"sharedInputsHash": "dff7284c186f1c36decc25ab36242de2860feeac"
"sharedInputsHash": "8b248a96bc8a1cbc8312c7f77da08d9ef8903680"
}
7 changes: 6 additions & 1 deletion cli/project-runner/cmd/project-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ package main
import (
"context"
"os"
"os/signal"
"syscall"

"github.com/hatayama/unity-cli-loop/project-runner/internal/projectrunner"
)

func main() {
os.Exit(projectrunner.RunProjectLocal(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
context.AfterFunc(ctx, stop)
os.Exit(projectrunner.RunProjectLocal(ctx, os.Args[1:], os.Stdout, os.Stderr))
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ func (controller *connectionRetryFocusController) tryFocusProcess(

correlationID := vibelog.NewCLIVibeCorrelationID()
logConnectionRetryFocusAttempt(controller.connection, controller.method, pid, reason, cause, correlationID)
restorer, focusErr := controller.deps.focusUnityProcess(ctx, pid)
focusContext, cancel := context.WithTimeout(ctx, unityprocess.FocusCommandTimeout)
defer cancel()
restorer, focusErr := controller.deps.focusUnityProcess(focusContext, pid)
if focusErr == nil {
controller.restoreFocus = restorer
logConnectionRetryFocusSuccess(controller.connection, controller.method, pid, reason, cause, correlationID)
Expand Down
29 changes: 29 additions & 0 deletions cli/project-runner/internal/projectrunner/connection_retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/hatayama/unity-cli-loop/common/clicore"
"github.com/hatayama/unity-cli-loop/common/unityipc"
"github.com/hatayama/unity-cli-loop/common/unityprocess"
)

// Verifies the default busy-stall focus threshold fires before the bounded busy retry window ends.
Expand All @@ -33,6 +34,34 @@ func TestDefaultBusyFocusStallThresholdFitsWithinBusyRetryWindow(t *testing.T) {
}
}

// Verifies connection-retry focus rescue bounds the focus external command with a deadline.
func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) {
var receivedContext context.Context
deps := defaultConnectionRetryDeps()
deps.focusUnityProcess = func(ctx context.Context, pid int) (unityprocess.RestoreFocusFunc, error) {
receivedContext = ctx
return nil, nil
}
controller := newConnectionRetryFocusController(
unityipc.Connection{ProjectRoot: t.TempDir()},
"get-logs",
deps,
)
controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy"))

if receivedContext == nil {
t.Fatal("expected focus attempt context")
}
deadline, hasDeadline := receivedContext.Deadline()
if !hasDeadline {
t.Fatal("focus attempt context should have a deadline")
}
remaining := time.Until(deadline)
if remaining < unityprocess.FocusCommandTimeout-time.Second || remaining > unityprocess.FocusCommandTimeout {
t.Fatalf("focus timeout mismatch: %s", remaining)
}
}

// Verifies transient IPC connection failures focus Unity once and restore focus before reporting server-not-responding.
func TestSendWithTransientConnectionRetryReportsUnityServerNotResponding(t *testing.T) {
deps := defaultConnectionRetryDeps()
Expand Down
2 changes: 1 addition & 1 deletion cli/project-runner/shared-inputs-stamp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"sharedInputsHash": "be6d79a7389bbf54e3c5020913f91955c15ec3f4"
"sharedInputsHash": "8c336555a69a3cf485147e5669ce8bb4d3fe2982"
}
Loading