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
25 changes: 17 additions & 8 deletions cli/common/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ const (
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

type TerminalSpinner struct {
writer io.Writer
Enabled bool
message string
frame int
done chan struct{}
stopped chan struct{}
stopOnce sync.Once
mutex sync.Mutex
writer io.Writer
Enabled bool
message string
frame int
done chan struct{}
stopped chan struct{}
stopOnce sync.Once
mutex sync.Mutex
stoppedFlag bool
}

func NewToolSpinner(stderr io.Writer, showFeedback bool) *TerminalSpinner {
Expand Down Expand Up @@ -77,6 +78,10 @@ func (spinner *TerminalSpinner) Update(message string) {
}

spinner.mutex.Lock()
if spinner.stoppedFlag {
spinner.mutex.Unlock()
return
}
spinner.message = message
spinner.mutex.Unlock()
spinner.render()
Expand All @@ -92,6 +97,7 @@ func (spinner *TerminalSpinner) Stop() {
<-spinner.stopped
spinner.mutex.Lock()
defer spinner.mutex.Unlock()
spinner.stoppedFlag = true
_, _ = fmt.Fprint(spinner.writer, "\r\x1b[K\n")
})
}
Expand All @@ -114,6 +120,9 @@ func (spinner *TerminalSpinner) run() {
func (spinner *TerminalSpinner) render() {
spinner.mutex.Lock()
defer spinner.mutex.Unlock()
if spinner.stoppedFlag {
return
}

frame := spinnerFrames[spinner.frame]
spinner.frame = (spinner.frame + 1) % len(spinnerFrames)
Expand Down
18 changes: 18 additions & 0 deletions cli/common/ui/spinner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ func TestSpinnerProgressFuncMapsConnectionEventsToExecutingMessage(t *testing.T)
}
}

func TestSpinnerUpdateAfterStopWritesNothing(t *testing.T) {
// Verifies Update after Stop does not render leftover TTY frames.
var stderr bytes.Buffer

spinner := newSpinner(&stderr, true, "Executing compile...")
spinner.Stop()
afterStop := stderr.Len()

spinner.Update("Warming execute-dynamic-code after compile...")

if stderr.Len() != afterStop {
t.Fatalf("Update after Stop wrote output: %q", stderr.String()[afterStop:])
}
if strings.Contains(stderr.String(), "Warming execute-dynamic-code after compile...") {
t.Fatalf("stopped spinner rendered an Update message: %q", stderr.String())
}
}

func TestNewToolSpinnerRespectsFeedbackFlag(t *testing.T) {
// Verifies callers can disable tool spinner feedback for hot paths.
var stderr bytes.Buffer
Expand Down
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": "0e716971c0854f6f73596d38f09343e4c97345f2"
"sharedInputsHash": "37b3868ecf195cac6405ed46a758ffa87cdc84db"
}
3 changes: 3 additions & 0 deletions cli/project-runner/internal/projectrunner/compile_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func attachWaitForPendingCompile(
pollInterval = compileWaitPollInterval
}
waitStartedAt := time.Now()
bindCompileWaitInterimReporter(stderr, spinner, &deps)
result, outcome, lastStatus, waitErr := waitForAttachedCompileCompletion(ctx, compileCompletionOptions{
connection: connection,
requestID: record.RequestID,
Expand Down Expand Up @@ -204,6 +205,7 @@ func waitForAttachedCompileCompletion(
lastObservationKey := ""

logCompileStatusPollStart(options, startedAt, deadline)
interim := newCompileWaitInterimState(compileWaitNow(deps))

ticker := time.NewTicker(options.pollInterval)
defer ticker.Stop()
Expand Down Expand Up @@ -235,6 +237,7 @@ func waitForAttachedCompileCompletion(
}
}
logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey)
observeCompileWaitInterim(&interim, deps, status, err)

select {
case <-ctx.Done():
Expand Down
42 changes: 42 additions & 0 deletions cli/project-runner/internal/projectrunner/compile_attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,48 @@ func TestRunCompileAttachWaitsForInFlightCompileAndClearsRecord(t *testing.T) {
}
}

// Verifies attachWaitForPendingCompile binds the interim reporter so stderr
// gets the progress line without injecting reportInterim on the caller.
func TestRunCompileAttachWaitReportsInterimProgressLine(t *testing.T) {
projectRoot := t.TempDir()
requestID := "compile_attach_interim_route"
if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{
RequestID: requestID,
TimedOutAtUtc: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("write pending record failed: %v", err)
}

start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
now := start
queryCalls := 0
deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) {
queryCalls++
if queryCalls == 1 {
return compileStatusResponse{Ready: false, IsCompiling: true}, nil
}
now = now.Add(60 * time.Second)
return compileStatusResponse{Ready: false, IsCompiling: true}, nil
})
deps.now = func() time.Time { return now }
deps.interimReportInterval = 60 * time.Second
Comment thread
coderabbitai[bot] marked this conversation as resolved.
connection := unityipc.Connection{
Endpoint: unityipc.Endpoint{Network: "tcp", Address: "127.0.0.1:1"},
ProjectRoot: projectRoot,
}
params := map[string]any{compileWaitTimeoutParam: 1}
var stdout, stderr bytes.Buffer

code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps)
if code != 1 {
t.Fatalf("expected attach timeout exit 1: code=%d stderr=%s", code, stderr.String())
}
expected := "compile: still waiting for Unity (elapsed 60s; last status: is_compiling=true, is_domain_reload_in_progress=false)."
if !strings.Contains(stderr.String(), expected) {
t.Fatalf("attach wait must print the interim progress line via bindCompileWaitInterimReporter:\n%s", stderr.String())
}
}

// Verifies a completed pending compile returns the stored result without a new request.
func TestRunCompileAttachReturnsStoredResultAndClearsRecord(t *testing.T) {
enableCliVibeLog(t)
Expand Down
2 changes: 2 additions & 0 deletions cli/project-runner/internal/projectrunner/compile_wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func waitForCompileCompletionWithDeps(
lastObservationKey := ""

logCompileStatusPollStart(options, startedAt, deadline)
interim := newCompileWaitInterimState(compileWaitNow(deps))

ticker := time.NewTicker(options.pollInterval)
defer ticker.Stop()
Expand All @@ -205,6 +206,7 @@ func waitForCompileCompletionWithDeps(
observedStatus = true
}
logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey)
observeCompileWaitInterim(&interim, deps, status, err)

select {
case <-ctx.Done():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type compileWaitDeps struct {
attachProbeTimeout time.Duration
attachProbeInterval time.Duration
attachWaitPollInterval time.Duration
now func() time.Time
interimReportInterval time.Duration
reportInterim compileWaitInterimReporter
}

func compileSendOrDefault(deps compileWaitDeps) compileSendFunc {
Expand Down
131 changes: 131 additions & 0 deletions cli/project-runner/internal/projectrunner/compile_wait_interim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package projectrunner

import (
"fmt"
"io"
"time"

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

const (
compileWaitInterimIntervalDefault = 60 * time.Second
compileWaitInterimSilentThreshold = 30 * time.Second
)

type compileWaitInterimReporter func(string)

type compileWaitInterimState struct {
waitStartedAt time.Time
lastSuccessAt time.Time
lastSuccessStatus compileStatusResponse
hasSuccess bool
lastReportAt time.Time
hasReported bool
}

func newCompileWaitInterimState(waitStartedAt time.Time) compileWaitInterimState {
return compileWaitInterimState{waitStartedAt: waitStartedAt}
}

func (state *compileWaitInterimState) noteSuccessfulPoll(completedAt time.Time, status compileStatusResponse) {
state.lastSuccessAt = completedAt
state.lastSuccessStatus = status
state.hasSuccess = true
}

func (state *compileWaitInterimState) lineIfDue(now time.Time, interval time.Duration) (string, bool) {
if interval <= 0 {
interval = compileWaitInterimIntervalDefault
}
silentFor := now.Sub(state.silentAnchor())
useSilent := silentFor >= compileWaitInterimSilentThreshold
if !state.isDue(now, interval, useSilent, silentFor) {
return "", false
}
state.hasReported = true
state.lastReportAt = now
if useSilent {
return formatCompileWaitSilentLine(silentFor), true
}
return formatCompileWaitProgressLine(now.Sub(state.waitStartedAt), state.lastSuccessStatus), true
}

func (state *compileWaitInterimState) silentAnchor() time.Time {
if state.hasSuccess {
return state.lastSuccessAt
}
return state.waitStartedAt
}

func (state *compileWaitInterimState) isDue(
now time.Time,
interval time.Duration,
useSilent bool,
silentFor time.Duration,
) bool {
if state.hasReported {
return now.Sub(state.lastReportAt) >= interval
}
if useSilent {
return silentFor >= compileWaitInterimSilentThreshold
}
if !state.hasSuccess {
return false
}
return now.Sub(state.waitStartedAt) >= interval
}

func formatCompileWaitProgressLine(elapsed time.Duration, status compileStatusResponse) string {
return fmt.Sprintf(
"compile: still waiting for Unity (elapsed %ds; last status: is_compiling=%t, is_domain_reload_in_progress=%t).",
int(elapsed/time.Second),
status.IsCompiling,
status.IsDomainReloadInProgress,
)
}

func formatCompileWaitSilentLine(silentFor time.Duration) string {
return fmt.Sprintf(
"compile: Unity has not answered status polls for %ds. The Editor may be blocked by a modal dialog or stuck; if this persists, restart Unity with 'uloop launch -r'.",
int(silentFor/time.Second),
)
}

func compileWaitNow(deps compileWaitDeps) time.Time {
if deps.now != nil {
return deps.now()
}
return time.Now()
}

func observeCompileWaitInterim(
state *compileWaitInterimState,
deps compileWaitDeps,
status compileStatusResponse,
err error,
) {
now := compileWaitNow(deps)
if err == nil {
state.noteSuccessfulPoll(now, status)
}
if deps.reportInterim == nil {
return
}
line, due := state.lineIfDue(now, deps.interimReportInterval)
if !due {
return
}
deps.reportInterim(line)
}

func bindCompileWaitInterimReporter(stderr io.Writer, spinner *ui.TerminalSpinner, deps *compileWaitDeps) {
stopped := false
deps.reportInterim = func(line string) {
if !stopped {
spinner.Stop()
stopped = true
}
_, _ = fmt.Fprintln(stderr, line)
}
}
Loading