Skip to content

Commit ee72c1a

Browse files
committed
fix(daemon): extend gateway launch timeout to 10s and improve CLI/script output
- Bump gateway auto-start deadline from 3s to 10s for slow machines - Reword timeout/dial error messages from "single fallback" to "automatic startup" for user clarity - daemon install CLI now writes structured logs to stderr (success/failure summary, hosts warning, daemon readiness) and prints explicit remedy commands on failure - Install scripts (install.sh / install.ps1) now emit multi-line remedy guidance on daemon install failure instead of a single vague warning, and catch top-level errors with retry instructions
1 parent 676fdf4 commit ee72c1a

6 files changed

Lines changed: 152 additions & 16 deletions

File tree

internal/cli/daemon_commands.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"net"
89
neturl "net/url"
910
"os"
@@ -25,6 +26,9 @@ var (
2526
installHTTPDaemon = urlscheme.InstallHTTPDaemon
2627
uninstallHTTPDaemon = urlscheme.UninstallHTTPDaemon
2728
getHTTPDaemonStatus = urlscheme.GetHTTPDaemonStatus
29+
30+
daemonInstallJSONWriter io.Writer = os.Stdout
31+
daemonInstallLogWriter io.Writer = os.Stderr
2832
)
2933

3034
type daemonServeCommandOptions struct {
@@ -287,13 +291,26 @@ func defaultDaemonInstallCommandRunner(ctx context.Context, options daemonInstal
287291
ListenAddress: options.ListenAddress,
288292
})
289293
if err != nil {
294+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install failed: %v\n", err)
295+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "remedy: run `%s daemon install --listen %s`\n", executablePath, options.ListenAddress)
290296
return err
291297
}
292-
return encodeJSONLine(os.Stdout, map[string]any{
293-
"status": "ok",
294-
"listen_address": result.ListenAddress,
295-
"autostart_mode": result.AutostartMode,
296-
"hosts_warning": strings.TrimSpace(result.HostsWarning),
298+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install succeeded: autostart=%s listen=%s\n", result.AutostartMode, result.ListenAddress)
299+
if strings.TrimSpace(result.HostsWarning) != "" {
300+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "hosts warning: %s\n", strings.TrimSpace(result.HostsWarning))
301+
}
302+
if result.DaemonStarted {
303+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon started in background and is ready on %s\n", result.ListenAddress)
304+
} else if strings.TrimSpace(result.DaemonStartWarning) != "" {
305+
_, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon startup warning: %s\n", strings.TrimSpace(result.DaemonStartWarning))
306+
}
307+
return encodeJSONLine(daemonInstallJSONWriter, map[string]any{
308+
"status": "ok",
309+
"listen_address": result.ListenAddress,
310+
"autostart_mode": result.AutostartMode,
311+
"hosts_warning": strings.TrimSpace(result.HostsWarning),
312+
"daemon_started": result.DaemonStarted,
313+
"daemon_start_warning": strings.TrimSpace(result.DaemonStartWarning),
297314
})
298315
}
299316

internal/cli/daemon_commands_test.go

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package cli
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"errors"
68
"net/url"
7-
"os"
89
"strings"
910
"testing"
1011

@@ -42,25 +43,34 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) {
4243
originalRunner := runDaemonInstallCommand
4344
originalResolveExecutablePath := resolveExecutablePath
4445
originalInstall := installHTTPDaemon
46+
originalJSONWriter := daemonInstallJSONWriter
47+
originalLogWriter := daemonInstallLogWriter
4548
t.Cleanup(func() { runDaemonInstallCommand = originalRunner })
4649
t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath })
4750
t.Cleanup(func() { installHTTPDaemon = originalInstall })
51+
t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter })
52+
t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter })
4853

4954
runDaemonInstallCommand = defaultDaemonInstallCommandRunner
5055
resolveExecutablePath = func() (string, error) {
5156
return "/tmp/neocode", nil
5257
}
58+
var stdout bytes.Buffer
59+
var stderr bytes.Buffer
60+
daemonInstallJSONWriter = &stdout
61+
daemonInstallLogWriter = &stderr
5362
var captured urlscheme.HTTPDaemonInstallOptions
5463
installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) {
5564
captured = options
5665
return urlscheme.HTTPDaemonInstallResult{
57-
ListenAddress: options.ListenAddress,
58-
AutostartMode: "test-mode",
66+
ListenAddress: options.ListenAddress,
67+
AutostartMode: "test-mode",
68+
DaemonStarted: true,
69+
DaemonStartWarning: "",
5970
}, nil
6071
}
6172

6273
command := NewRootCommand()
63-
command.SetOut(os.Stdout)
6474
command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"})
6575
if err := command.ExecuteContext(context.Background()); err != nil {
6676
t.Fatalf("ExecuteContext() error = %v", err)
@@ -71,6 +81,57 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) {
7181
if captured.ListenAddress != "127.0.0.1:19921" {
7282
t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "127.0.0.1:19921")
7383
}
84+
var payload map[string]any
85+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
86+
t.Fatalf("decode stdout json: %v", err)
87+
}
88+
if payload["status"] != "ok" {
89+
t.Fatalf("status = %v, want ok", payload["status"])
90+
}
91+
if payload["daemon_started"] != true {
92+
t.Fatalf("daemon_started = %v, want true", payload["daemon_started"])
93+
}
94+
if !strings.Contains(stderr.String(), "daemon install succeeded") {
95+
t.Fatalf("stderr = %q, want success summary", stderr.String())
96+
}
97+
}
98+
99+
func TestDaemonInstallDefaultRunnerFailureWritesRemedy(t *testing.T) {
100+
originalRunner := runDaemonInstallCommand
101+
originalResolveExecutablePath := resolveExecutablePath
102+
originalInstall := installHTTPDaemon
103+
originalJSONWriter := daemonInstallJSONWriter
104+
originalLogWriter := daemonInstallLogWriter
105+
t.Cleanup(func() { runDaemonInstallCommand = originalRunner })
106+
t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath })
107+
t.Cleanup(func() { installHTTPDaemon = originalInstall })
108+
t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter })
109+
t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter })
110+
111+
runDaemonInstallCommand = defaultDaemonInstallCommandRunner
112+
resolveExecutablePath = func() (string, error) {
113+
return "/tmp/neocode", nil
114+
}
115+
var stdout bytes.Buffer
116+
var stderr bytes.Buffer
117+
daemonInstallJSONWriter = &stdout
118+
daemonInstallLogWriter = &stderr
119+
installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) {
120+
return urlscheme.HTTPDaemonInstallResult{}, errors.New("boom")
121+
}
122+
123+
command := NewRootCommand()
124+
command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"})
125+
err := command.ExecuteContext(context.Background())
126+
if err == nil {
127+
t.Fatal("expected install failure")
128+
}
129+
if !strings.Contains(stderr.String(), "remedy: run `/tmp/neocode daemon install --listen 127.0.0.1:19921`") {
130+
t.Fatalf("stderr = %q, want remedy command", stderr.String())
131+
}
132+
if stdout.Len() != 0 {
133+
t.Fatalf("stdout should be empty on failure, got %q", stdout.String())
134+
}
74135
}
75136

76137
func TestDaemonServeDoesNotExposeTokenFileFlag(t *testing.T) {

internal/gateway/adapters/urlscheme/dispatcher.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const (
3232
// defaultDispatchIOTimeout 是 URL 派发读写超时时间。
3333
defaultDispatchIOTimeout = 10 * time.Second
3434
// defaultGatewayLaunchTimeout 是自动拉起网关后的就绪等待时间。
35-
defaultGatewayLaunchTimeout = 3 * time.Second
35+
defaultGatewayLaunchTimeout = 10 * time.Second
3636
// defaultGatewayLaunchRetryInterval 是拉起后拨号重试间隔。
3737
defaultGatewayLaunchRetryInterval = 100 * time.Millisecond
3838
wakeReviewStartupPromptTemplate = "请审查文件 %s"
@@ -416,14 +416,14 @@ func (d *Dispatcher) dialGatewayWithFallback(
416416
if launchErr := d.launchGateway(ctx, listenAddress, requestID, authToken); launchErr != nil {
417417
return nil, newDispatchError(
418418
ErrorCodeGatewayUnavailable,
419-
fmt.Sprintf("dial gateway failed: %v; launch gateway failed: %v", err, launchErr),
419+
fmt.Sprintf("dial gateway failed: %v; automatic gateway startup failed: %v", err, launchErr),
420420
)
421421
}
422422
retriedConnection, retryErr := dialFn(listenAddress)
423423
if retryErr != nil {
424424
return nil, newDispatchError(
425425
ErrorCodeGatewayUnavailable,
426-
fmt.Sprintf("dial gateway failed after single fallback: %v", retryErr),
426+
fmt.Sprintf("dial gateway failed after automatic startup: %v", retryErr),
427427
)
428428
}
429429
return retriedConnection, nil
@@ -566,7 +566,7 @@ func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string)
566566
return nil
567567
}
568568
if !nowFn().Before(deadline) {
569-
return fmt.Errorf("gateway did not become reachable within %s", effectiveTimeout)
569+
return fmt.Errorf("gateway auto-start timed out after %s", effectiveTimeout)
570570
}
571571
sleepFn(defaultGatewayLaunchRetryInterval)
572572
}

internal/gateway/adapters/urlscheme/dispatcher_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"neo-code/internal/gateway"
1213
"neo-code/internal/gateway/protocol"
@@ -510,3 +511,34 @@ func TestDispatchWakeIntentReturnsGatewayFrameError(t *testing.T) {
510511
t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String())
511512
}
512513
}
514+
515+
func TestGatewayLaunchTimeoutIsTenSeconds(t *testing.T) {
516+
if defaultGatewayLaunchTimeout != 10*time.Second {
517+
t.Fatalf("defaultGatewayLaunchTimeout = %s, want %s", defaultGatewayLaunchTimeout, 10*time.Second)
518+
}
519+
}
520+
521+
func TestWaitGatewayReadyTimeoutMessage(t *testing.T) {
522+
dispatcher := NewDispatcher()
523+
start := time.Unix(1700000000, 0)
524+
nowCalls := 0
525+
dispatcher.nowFn = func() time.Time {
526+
nowCalls++
527+
if nowCalls <= 2 {
528+
return start
529+
}
530+
return start.Add(11 * time.Second)
531+
}
532+
dispatcher.sleepFn = func(time.Duration) {}
533+
dispatcher.dialFn = func(string) (net.Conn, error) {
534+
return nil, errors.New("dial failed")
535+
}
536+
537+
err := dispatcher.waitGatewayReady(context.Background(), "inmemory")
538+
if err == nil {
539+
t.Fatal("expected wait timeout")
540+
}
541+
if !strings.Contains(err.Error(), "gateway auto-start timed out after") {
542+
t.Fatalf("error = %v, want timeout message", err)
543+
}
544+
}

scripts/install.ps1

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ switch ($Flavor) {
2222
}
2323
}
2424

25+
function Write-FullInstallRemedy {
26+
Write-Warning "Remedy commands:"
27+
Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon install"
28+
Write-Warning " echo 127.0.0.1 neocode >> C:\\Windows\\System32\\drivers\\etc\\hosts"
29+
Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon status"
30+
}
31+
2532
# 1. 识别物理架构(优先考虑 64 位重定向环境)
2633
$RawArch = $env:PROCESSOR_ARCHITEW6432
2734
if ([string]::IsNullOrWhiteSpace($RawArch)) {
@@ -122,11 +129,19 @@ try {
122129
}
123130
catch {
124131
Write-Warning "Failed to install HTTP daemon autostart automatically."
125-
Write-Warning "Run '$NeoCodeExecutablePath daemon install' manually after installation."
132+
Write-FullInstallRemedy
126133
}
127134
}
128135
Write-Host "Installed $BinaryName ($Flavor) from $LatestTag." -ForegroundColor Green
129136
}
137+
catch {
138+
Write-Error "Installation failed: $($_.Exception.Message)"
139+
Write-Warning "Retry command: powershell -ExecutionPolicy Bypass -File scripts\\install.ps1 -Flavor $Flavor"
140+
if ($Flavor -eq "full") {
141+
Write-FullInstallRemedy
142+
}
143+
throw
144+
}
130145
finally {
131146
if (Test-Path $TempDir) {
132147
Remove-Item -Path $TempDir -Recurse -Force

scripts/install.sh

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ DEFAULT_FLAVOR="full"
77
flavor="$DEFAULT_FLAVOR"
88
dry_run=0
99

10+
print_full_install_remedy() {
11+
cat >&2 <<'REMEDY'
12+
Remedy commands:
13+
/usr/local/bin/neocode daemon install
14+
sudo echo '127.0.0.1 neocode' >> /etc/hosts
15+
/usr/local/bin/neocode daemon status
16+
REMEDY
17+
}
18+
1019
usage() {
1120
cat <<'USAGE'
1221
Usage: install.sh [--flavor full|gateway] [--dry-run]
@@ -59,6 +68,8 @@ case "$flavor" in
5968
;;
6069
esac
6170

71+
trap 'status=$?; if [[ $status -ne 0 ]]; then echo "Installation failed (exit ${status})." >&2; echo "Retry: bash scripts/install.sh --flavor ${flavor}" >&2; if [[ "${flavor}" == "full" ]]; then print_full_install_remedy; fi; fi' ERR
72+
6273
os="$(uname -s)"
6374
arch="$(uname -m)"
6475

@@ -152,6 +163,6 @@ if [[ "${flavor}" == "full" ]]; then
152163
echo "Installing HTTP daemon autostart..."
153164
if ! /usr/local/bin/neocode daemon install >/dev/null 2>&1; then
154165
echo "Warning: failed to install HTTP daemon autostart automatically." >&2
155-
echo "Run '/usr/local/bin/neocode daemon install' manually after installation." >&2
166+
print_full_install_remedy
156167
fi
157-
fi
168+
fi

0 commit comments

Comments
 (0)