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
6 changes: 6 additions & 0 deletions cli/common/clicore/tool_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ func waitForToolReadinessWithDeps(ctx context.Context, projectRoot string, timeo
if IsReadinessCLIUpdateRequiredError(err) {
return err
}
// Why abort: polling cannot outlast a connect the kernel refused permanently, and
// waiting out the timeout replaces that syscall error with server-not-responding
// guidance the caller cannot act on.
if clierrors.IsPermanentConnectError(err) {
return err
}
lastErr = err
}

Expand Down
40 changes: 40 additions & 0 deletions cli/common/clicore/tool_readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"net"
"os"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -53,6 +56,43 @@ func TestWaitForToolReadinessReturnsCliUpdateRequiredImmediately(t *testing.T) {
}
}

// Verifies a connect() the operating system refused permanently ends the readiness wait at the
// first probe and reports that error, instead of polling out the whole timeout and replacing it
// with server-not-responding guidance the caller cannot act on.
func TestWaitForToolReadinessReturnsPermanentlyRefusedConnectImmediately(t *testing.T) {
expectedErr := &unityipc.ConnectionAttemptError{
Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock",
Cause: &net.OpError{
Op: "dial",
Net: "unix",
Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"},
Err: os.NewSyscallError("connect", syscall.EPERM),
},
}
probeCount := 0
deps := toolReadinessDeps{
probeToolReadinessSequence: func(context.Context, string) error {
probeCount++
return expectedErr
},
findRunningUnityProcess: func(context.Context, string) (*unityprocess.UnityProcess, error) {
return nil, nil
},
}

// Why a timeout shorter than the poll interval: without the abort the wait falls through to
// its own timeout, and this test then fails on the assertions below rather than hanging until
// the package test deadline.
err := waitForToolReadinessWithDeps(context.Background(), t.TempDir(), ToolReadinessPoll/10, deps)

if err != error(expectedErr) {
t.Fatalf("expected the refused connect error itself, got %v", err)
}
if probeCount != 1 {
t.Fatalf("expected the wait to stop after the first probe, got %d probes", probeCount)
}
}

// Verifies that parent cancellation is preserved instead of being reported as a timeout.
func TestToolReadinessDoneErrorPropagatesParentCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
Expand Down
27 changes: 27 additions & 0 deletions cli/common/errors/error_envelope_classification.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ func unityServerNotRespondingCLIError(err UnityServerNotRespondingError, context
}

func connectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context ErrorContext) CLIError {
if IsPermanentConnectError(err) {
return refusedConnectionAttemptCLIError(err, context)
}
return CLIError{
ErrorCode: ErrorCodeUnityNotReachable,
Phase: ErrorPhaseConnection,
Expand All @@ -109,6 +112,30 @@ func connectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context Err
}
}

// Reports a connect the operating system refused permanently. Waiting changes nothing here, so
// the envelope states the syscall error as it came back and points at what actually blocks the
// socket instead of repeating the reachability guidance.
func refusedConnectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context ErrorContext) CLIError {
return CLIError{
ErrorCode: ErrorCodeUnityNotReachable,
Phase: ErrorPhaseConnection,
Message: "The operating system refused the connection to the Unity CLI Loop server for this project.",
Retryable: false,
SafeToRetry: false,
ProjectRoot: firstNonEmpty(context.ProjectRoot, err.ProjectRoot),
Command: context.Command,
NextActions: []string{
"Retrying will not help: the connection was refused before it reached Unity, and the Editor never saw it.",
"If this command ran inside a sandbox (for example an AI agent's sandboxed shell), the sandbox denied the connection; run it with sandboxing disabled for this command.",
"Otherwise check the ownership and permissions of the endpoint path in Details.",
},
Details: map[string]any{
"Endpoint": err.Endpoint,
"Cause": connectionAttemptCause(err),
},
}
}

func classifyRPCError(rpcErr *unityipc.RPCError, context ErrorContext) CLIError {
details, decodedData := rpcErrorDetails(rpcErr)
switch RPCDataType(decodedData) {
Expand Down
32 changes: 32 additions & 0 deletions cli/common/errors/error_envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"bytes"
"encoding/json"
"errors"
"net"
"os"
"strings"
"syscall"
"testing"

"github.com/hatayama/unity-cli-loop/common/unityipc"
Expand Down Expand Up @@ -65,6 +68,35 @@ func TestClassifyConnectionAttemptError(t *testing.T) {
}
}

// Verifies a connect() the operating system refused outright is reported as a permanent
// failure carrying the syscall text verbatim: the retry guidance sent the agent into a
// 60-second wait for a condition (sandbox policy, socket permissions) that never clears.
func TestClassifyConnectionAttemptErrorForRefusedConnect(t *testing.T) {
err := &unityipc.ConnectionAttemptError{
ProjectRoot: "/tmp/MyProject",
Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock",
Cause: &net.OpError{
Op: "dial",
Net: "unix",
Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"},
Err: os.NewSyscallError("connect", syscall.EPERM),
},
}

cliErr := ClassifyError(err, ErrorContext{Command: "compile"})
if cliErr.Retryable || cliErr.SafeToRetry {
t.Fatalf("a permanently refused connect must not be advertised as retryable: %#v", cliErr)
}
if cliErr.Details["Cause"] != err.Cause.Error() {
t.Fatalf("the syscall error must be reported verbatim: %#v", cliErr.Details)
}
for _, action := range cliErr.NextActions {
if strings.Contains(strings.ToLower(action), "wait and retry") {
t.Fatalf("next actions must not advise waiting: %#v", cliErr.NextActions)
}
}
}

func TestClassifyConnectionAttemptAllowsNilCause(t *testing.T) {
// Verifies connection classification handles a missing low-level cause.
err := &unityipc.ConnectionAttemptError{
Expand Down
18 changes: 18 additions & 0 deletions cli/common/errors/transport_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ func IsTransportDisconnectError(err error) bool {
strings.Contains(message, "use of closed network connection")
}

// Reports whether a dial failed for a reason that cannot clear while the caller waits: the
// kernel refused the socket outright (a sandbox policy that denies Unix socket connects,
// permissions on the socket path) instead of reporting that nobody is listening yet. Retrying
// such an error wastes the whole dial-retry window and then reports the window's own deadline
// expiry, so the syscall error the first attempt already had never reaches the caller.
// Why os.ErrPermission rather than the POSIX errnos: a Windows named pipe reports access denial
// as ERROR_ACCESS_DENIED, which maps to os.ErrPermission but matches neither EPERM nor EACCES, so
// an errno test would leave Windows retrying a refusal that never clears. Why only inside a
// connection attempt: os.ErrPermission also covers file permission failures that reach the same
// callers (project resolution, endpoint inspection), and those are not dial outcomes.
func IsPermanentConnectError(err error) bool {
var connectionErr *unityipc.ConnectionAttemptError
if !errors.As(err, &connectionErr) {
return false
}
return errors.Is(connectionErr, os.ErrPermission)
}

// Reports whether the error is a connection deadline expiry. The Timeout() probe
// runs through the unwrap chain because go-winio's named pipe deadline error is not
// os.ErrDeadlineExceeded and os.IsTimeout does not unwrap fmt.Errorf("%w") wrapping.
Expand Down
68 changes: 68 additions & 0 deletions cli/common/errors/transport_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,74 @@ func TestIsTransportDisconnectErrorMatchesWrappedNoResponseError(t *testing.T) {
}
}

// Verifies a connect() refused by the operating system is classified as permanent, so the
// dial-retry loops abort instead of spending their window on an error that cannot clear.
func TestIsPermanentConnectErrorMatchesRefusedSyscalls(t *testing.T) {
refusedSyscalls := []syscall.Errno{syscall.EPERM, syscall.EACCES}

for _, errno := range refusedSyscalls {
dialError := &net.OpError{
Op: "dial",
Net: "unix",
Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"},
Err: os.NewSyscallError("connect", errno),
}
wrapped := &unityipc.ConnectionAttemptError{Cause: dialError}
if !IsPermanentConnectError(wrapped) {
t.Fatalf("refused connect was not classified as permanent: %v", wrapped)
}
}
}

// Verifies a named pipe access denial is classified as permanent too. go-winio reports it as a
// path error whose cause maps to os.ErrPermission and to neither POSIX errno, so matching errnos
// alone would leave Windows retrying a refusal that never clears.
func TestIsPermanentConnectErrorMatchesNamedPipeAccessDenial(t *testing.T) {
deniedPipe := &unityipc.ConnectionAttemptError{
Cause: &os.PathError{
Op: "open",
Path: `\\.\pipe\UnityCliLoop-sample`,
Err: os.ErrPermission,
},
}

if !IsPermanentConnectError(deniedPipe) {
t.Fatalf("denied named pipe was not classified as permanent: %v", deniedPipe)
}
}

// Verifies a permission failure that is not a dial outcome stays out of this classification: the
// same callers also surface project and endpoint file errors, and those must not abort a wait.
func TestIsPermanentConnectErrorIgnoresPermissionErrorsOutsideDialing(t *testing.T) {
fileError := &os.PathError{
Op: "open",
Path: "/tmp/MyProject/ProjectSettings/ProjectVersion.txt",
Err: syscall.EACCES,
}

if IsPermanentConnectError(fileError) {
t.Fatalf("a file permission error was classified as a refused connect: %v", fileError)
}
}

// Verifies the errors a retry is meant to absorb — the socket not existing yet, nobody
// listening yet, a deadline expiry — stay retryable.
func TestIsPermanentConnectErrorRejectsTransientFailures(t *testing.T) {
transientErrors := []error{
nil,
&unityipc.ConnectionAttemptError{Cause: os.NewSyscallError("connect", syscall.ENOENT)},
&unityipc.ConnectionAttemptError{Cause: os.NewSyscallError("connect", syscall.ECONNREFUSED)},
&unityipc.ConnectionAttemptError{Cause: timeoutOnlyError{}},
fmt.Errorf("dial unix /tmp/uloop-501/UnityCliLoop-sample.sock: i/o timeout"),
}

for _, err := range transientErrors {
if IsPermanentConnectError(err) {
t.Fatalf("transient error was classified as permanent: %v", err)
}
}
}

// Verifies that final-response timeout classification matches typed deadline errors
// and Timeout()-reporting errors instead of relying on the "i/o timeout" message.
func TestIsFinalResponseTimeoutErrorMatchesTypedCauses(t *testing.T) {
Expand Down
33 changes: 33 additions & 0 deletions cli/common/errors/transport_errors_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//go:build windows

package clierrors

import (
"os"
"syscall"
"testing"

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

// The status Windows returns when opening the project's named pipe is denied, and the one
// go-winio puts in the path error it hands back.
const windowsErrorAccessDenied = syscall.Errno(5)

// Verifies the classification holds against the real Windows status code. The cross-platform test
// substitutes os.ErrPermission for it, which assumes the mapping Go's syscall package performs;
// this test fails if that assumption ever stops holding and Windows silently starts retrying a
// refusal that never clears.
func TestIsPermanentConnectErrorMatchesWindowsAccessDenied(t *testing.T) {
deniedPipe := &unityipc.ConnectionAttemptError{
Cause: &os.PathError{
Op: "open",
Path: `\\.\pipe\UnityCliLoop-sample`,
Err: windowsErrorAccessDenied,
},
}

if !IsPermanentConnectError(deniedPipe) {
t.Fatalf("denied named pipe was not classified as permanent: %v", deniedPipe)
}
}
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": "1f44f61e2cada92cf5f4ecba945c1ec2400b0dc3"
"sharedInputsHash": "b9bc288d3a76099a1fe4cee8843e510bf5bbe632"
}
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,13 @@ func shouldRetryUndispatchedConnection(err error, outcome unityipc.UnitySendOutc
}

var connectionErr *unityipc.ConnectionAttemptError
return errors.As(err, &connectionErr)
if !errors.As(err, &connectionErr) {
return false
}
// The retry window exists for a server that is not listening yet. A connect the kernel
// refused permanently never becomes reachable inside it, and retrying it replaces the
// syscall error with the window's own deadline expiry.
return !clierrors.IsPermanentConnectError(connectionErr)
}

func logConnectionRetryFocusAttempt(
Expand Down
Loading