mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Fix Remote Workbench release blockers / 修复远程工作台发版阻断 (#6790)
* fix(remote): harden workbench restore and detach Problem: - Physical Windows-to-Linux acceptance exposed null Git pages, a crash restoring sessions after provider catalog changes, and leaked attach/runtime processes after disconnect. Root cause: - Empty pages cloned into nil slices, typed-nil controllers crossed an interface boundary, and runtime children shared the SSH session while proxy teardown waited indefinitely. Fix: - Preserve allocated empty result pages, normalize typed-nil controllers, detach and reap runtime children, and make proxy teardown bounded. - Add regression coverage for clean Git pages, provider removal restore, child reaping, and blocked stdin shutdown. Verification: - go test ./... and desktop go test ./... - go test -race ./... and desktop go test -race ./... - go vet ./... and desktop go vet ./... - pnpm frontend test:all and build - cache guard, protocol generation check, six-platform CLI cross-build, and npm release dry run * fix(deps): update x/text for GO-2026-5970 Problem: - The release CI govulncheck gate detected reachable GO-2026-5970 in golang.org/x/text v0.38.0. Root cause: - Both the root and Desktop modules pinned the vulnerable x/text release used through proxy normalization. Fix: - Upgrade golang.org/x/text to v0.39.0 in both modules and refresh their checksums. Verification: - govulncheck ./... reports no reachable vulnerabilities in both modules. - Root and Desktop full tests, vet, and race suites pass.
This commit is contained in:
1 parent
5480019c4f
commit
03cc774973
9 files changed
+190
-23
No files matched your search
+1
-1
@@ -22,7 +22,7 @@ require (
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/text v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
+2
-2
@@ -196,8 +196,8 @@ golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
@@ -38,7 +38,7 @@ require (
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/text v0.39.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
@@ -177,8 +177,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/remote/protocol"
|
||||
"reasonix/internal/remote/workbench/runtime"
|
||||
"reasonix/internal/rpcwire"
|
||||
@@ -200,10 +200,9 @@ func ensureRuntime(ctx context.Context, opts Options, home, workspace, sock stri
|
||||
"--workspace", workspace, "--socket", sock, "--version", opts.Version)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
if _, err := startRuntimeProcess(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
deadline = time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if c, err := dialSocket(ctx, sock, 200*time.Millisecond); err == nil {
|
||||
@@ -215,6 +214,23 @@ func ensureRuntime(ctx context.Context, opts Options, home, workspace, sock stri
|
||||
return errors.New("runtime child did not become ready")
|
||||
}
|
||||
|
||||
func startRuntimeProcess(cmd *exec.Cmd) (<-chan error, error) {
|
||||
// The runtime must outlive the SSH command process during the detach grace
|
||||
// period. A new session prevents sshd from forwarding the transport's
|
||||
// SIGHUP to the runtime, while Wait keeps short-lived children out of the
|
||||
// zombie state until the attach process exits.
|
||||
proc.SetProcessGroupKill(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func dialSocket(ctx context.Context, sock string, timeout time.Duration) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
return d.DialContext(ctx, "unix", sock)
|
||||
@@ -251,11 +267,8 @@ func dialSocketUntil(ctx context.Context, sock string, timeout time.Duration) (n
|
||||
}
|
||||
|
||||
func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdout io.Writer, conn net.Conn) error {
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Drain buffered remainder then stdin → conn.
|
||||
if reader.Buffered() > 0 {
|
||||
buf := make([]byte, reader.Buffered())
|
||||
@@ -271,7 +284,6 @@ func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdou
|
||||
_ = conn.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := io.Copy(stdout, conn)
|
||||
errCh <- err
|
||||
_ = stdin.Close()
|
||||
@@ -280,12 +292,10 @@ func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdou
|
||||
case <-ctx.Done():
|
||||
_ = conn.Close()
|
||||
_ = stdin.Close()
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
_ = conn.Close()
|
||||
_ = stdin.Close()
|
||||
wg.Wait()
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package attach
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
@@ -16,6 +19,17 @@ import (
|
||||
"reasonix/internal/remote/protocol"
|
||||
)
|
||||
|
||||
type nonInterruptibleReadCloser struct {
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r *nonInterruptibleReadCloser) Read([]byte) (int, error) {
|
||||
<-r.release
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (*nonInterruptibleReadCloser) Close() error { return nil }
|
||||
|
||||
type responseBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
@@ -218,3 +232,46 @@ func TestAttachInProcessInitializeOK(t *testing.T) {
|
||||
t.Logf("run err (may be ok): %v out=%q", err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyReturnsWhenPeerClosesAndInputCloseDoesNotUnblockRead(t *testing.T) {
|
||||
stdin := &nonInterruptibleReadCloser{release: make(chan struct{})}
|
||||
local, peer := net.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- proxy(context.Background(), stdin, bufio.NewReader(stdin), io.Discard, local)
|
||||
}()
|
||||
_ = peer.Close()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("proxy returned transport error after peer close: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
close(stdin.release)
|
||||
t.Fatal("proxy waited indefinitely for the blocked stdin copy")
|
||||
}
|
||||
close(stdin.release)
|
||||
}
|
||||
|
||||
func TestStartRuntimeProcessReapsExitedChild(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_ATTACH_RUNTIME_HELPER") == "1" {
|
||||
os.Exit(0)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestStartRuntimeProcessReapsExitedChild$")
|
||||
cmd.Env = append(os.Environ(), "GO_WANT_ATTACH_RUNTIME_HELPER=1")
|
||||
done, err := startRuntimeProcess(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("runtime helper exit: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("runtime child was not reaped")
|
||||
}
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
t.Fatal("runtime child was not reaped")
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func (s *Server) gitCommitDetail(p protocol.GitCommitDetailParams) (protocol.Git
|
||||
offset = len(filesOut)
|
||||
}
|
||||
end := min(offset+limit, len(filesOut))
|
||||
page := append([]protocol.GitCommitFile(nil), filesOut[offset:end]...)
|
||||
page := append([]protocol.GitCommitFile{}, filesOut[offset:end]...)
|
||||
more := end < len(filesOut)
|
||||
next := protocol.Cursor("")
|
||||
if more {
|
||||
@@ -468,7 +468,7 @@ func pageChangedFiles(all []protocol.ChangedFile, offset, limit int, kind string
|
||||
offset = len(all)
|
||||
}
|
||||
end := min(offset+limit, len(all))
|
||||
page := append([]protocol.ChangedFile(nil), all[offset:end]...)
|
||||
page := append([]protocol.ChangedFile{}, all[offset:end]...)
|
||||
if end == len(all) {
|
||||
return page, false, ""
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
goruntime "runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -744,14 +745,34 @@ func (s *Server) create(ctx context.Context, p protocol.SessionCreateParams) (pr
|
||||
}
|
||||
|
||||
func (s *Server) buildController(ctx context.Context, model string, effort *string, sink event.Sink, tokenMode protocol.TokenMode) (SessionController, error) {
|
||||
var ctrl SessionController
|
||||
var err error
|
||||
if s.opts.BuildController != nil {
|
||||
return s.opts.BuildController(ctx, model, effort, sink)
|
||||
ctrl, err = s.opts.BuildController(ctx, model, effort, sink)
|
||||
} else {
|
||||
ctrl, err = boot.Build(ctx, boot.Options{
|
||||
Model: model, EffortOverride: effort, RequireKey: false,
|
||||
WorkspaceRoot: s.opts.Workspace, ProviderResolver: s.broker, Sink: sink, TokenMode: string(tokenMode),
|
||||
SessionDir: s.sessionDir(),
|
||||
})
|
||||
}
|
||||
if nilSessionController(ctrl) {
|
||||
ctrl = nil
|
||||
}
|
||||
return ctrl, err
|
||||
}
|
||||
|
||||
func nilSessionController(ctrl SessionController) bool {
|
||||
if ctrl == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(ctrl)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return boot.Build(ctx, boot.Options{
|
||||
Model: model, EffortOverride: effort, RequireKey: false,
|
||||
WorkspaceRoot: s.opts.Workspace, ProviderResolver: s.broker, Sink: sink, TokenMode: string(tokenMode),
|
||||
SessionDir: s.sessionDir(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) closeSession(p protocol.SessionCloseParams) (protocol.SessionCloseResult, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -651,6 +652,39 @@ func TestRuntimeRestoresSessionRegistryAfterProcessRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeDefersRestoreWhenControllerBuilderReturnsTypedNil(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
registryPath := filepath.Join(t.TempDir(), "remote-sessions.json")
|
||||
log := &strings.Builder{}
|
||||
var typedNil *persistentFakeController
|
||||
srv := New(Options{
|
||||
Workspace: workspace, SessionDir: sessionDir, RegistryPath: registryPath, Logger: log,
|
||||
BuildController: func(context.Context, string, *string, event.Sink) (SessionController, error) {
|
||||
return typedNil, errors.New("provider removed")
|
||||
},
|
||||
})
|
||||
srv.registryRead = true
|
||||
record := runtimeSessionRecord{
|
||||
ID: "session_removed_provider", Path: filepath.Join(sessionDir, "removed.jsonl"),
|
||||
Model: "removed/model", TopicID: "topic_removed_provider",
|
||||
}
|
||||
srv.dormant[record.ID] = record
|
||||
|
||||
if err := srv.ensureSessionsRestored(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(srv.sessions) != 0 {
|
||||
t.Fatalf("restored sessions = %d, want zero", len(srv.sessions))
|
||||
}
|
||||
if _, ok := srv.dormant[record.ID]; !ok {
|
||||
t.Fatal("failed restore was not kept dormant for a later Provider catalog")
|
||||
}
|
||||
if !strings.Contains(log.String(), "defer session restore") {
|
||||
t.Fatalf("restore failure was not logged: %q", log.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeEarlyShutdownDoesNotOverwriteUnreadRegistry(t *testing.T) {
|
||||
registryPath := filepath.Join(t.TempDir(), "remote-sessions.json")
|
||||
original := []byte(`{"version":99,"workspace":"future","sessions":[{"future":true}]}` + "\n")
|
||||
@@ -981,6 +1015,51 @@ func TestRuntimeWorkspaceGitQueriesAndSnapshotProjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeGitEmptyCollectionsEncodeAsArrays(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
runGit := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", workspace}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
runGit("init")
|
||||
runGit("config", "user.email", "[email protected]")
|
||||
runGit("config", "user.name", "Remote Test")
|
||||
runGit("commit", "--allow-empty", "-m", "empty")
|
||||
hash := runGit("rev-parse", "HEAD")
|
||||
srv := New(Options{Workspace: workspace, Version: "test"})
|
||||
target := srv.installTestSession(&fakeController{model: "local/test"})
|
||||
query := protocol.RuntimeQuery{ExpectedHostEpoch: srv.hostEpoch, Target: target, ExpectedRuntimeEpoch: "runtime_test"}
|
||||
|
||||
changes, err := srv.workspaceChanges(protocol.WorkspaceChangesParams{RuntimeQuery: query})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changes.Files == nil || len(changes.Files) != 0 {
|
||||
t.Fatalf("clean workspace files = %#v, want allocated empty slice", changes.Files)
|
||||
}
|
||||
encodedChanges, err := json.Marshal(changes)
|
||||
if err != nil || !bytes.Contains(encodedChanges, []byte(`"files":[]`)) {
|
||||
t.Fatalf("encoded clean workspace changes = %s err=%v", encodedChanges, err)
|
||||
}
|
||||
|
||||
detail, err := srv.gitCommitDetail(protocol.GitCommitDetailParams{RuntimeQuery: query, Hash: hash})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Files == nil || *detail.Files == nil || len(*detail.Files) != 0 {
|
||||
t.Fatalf("empty commit files = %#v, want pointer to allocated empty slice", detail.Files)
|
||||
}
|
||||
encodedDetail, err := json.Marshal(detail)
|
||||
if err != nil || !bytes.Contains(encodedDetail, []byte(`"files":[]`)) {
|
||||
t.Fatalf("encoded empty commit detail = %s err=%v", encodedDetail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeFilePreviewDoesNotDecodeBinary(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(workspace, "blob.bin"), []byte{'a', 0, 'b'}, 0o600); err != nil {
|
||||
|
||||
Reference in new issue
Block a user