From 027edff43a962d49f41921da787fcf0c92441d0c Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:11:08 +0200 Subject: [PATCH 1/9] chore: migrate root module to stdlib errors Replace pkg/errors and go-multierror with fmt.Errorf, errors.Is, and errors.Join, tidy the root module to Go 1.26.5, and add error-chain tests. Co-authored-by: Cursor --- README.md | 2 +- client.go | 36 ++++---- client_cluster.go | 15 ++-- client_commands.go | 84 +++++++++--------- client_test.go | 12 +-- conn.go | 6 +- errors.go | 2 +- errors_test.go | 27 ++++++ go.mod | 202 ++----------------------------------------- go.work | 2 +- protocol.go | 34 ++++---- protocol_bin.go | 15 ++-- protocol_bin_test.go | 2 +- protocol_builder.go | 14 +-- protocol_meta.go | 20 ++--- protocol_test.go | 2 +- 16 files changed, 155 insertions(+), 320 deletions(-) create mode 100644 errors_test.go diff --git a/README.md b/README.md index c24f0d5..ba30e9c 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Now, we have implemented some commands, and we will implement more commands in t #### Prerequisites -- Go 1.26 or higher +- Go 1.26.5 or higher - Python (for pre-commit hooks) or just `brew install pre-commit` on MacOS - Docker (for running memcached in tests) diff --git a/client.go b/client.go index 9790f24..438aa3e 100644 --- a/client.go +++ b/client.go @@ -3,12 +3,12 @@ package memcached import ( "bytes" "context" + "errors" + "fmt" "io" "sync" "time" - multierror "github.com/hashicorp/go-multierror" - "github.com/pkg/errors" "go.opentelemetry.io/otel/trace" "github.com/yeqown/memcached/telemetry" @@ -70,11 +70,11 @@ func newClientWithContext(_ context.Context, addr string, opts ...ClientOption) addrs, err := options.resolver.Resolve(addr) if err != nil { - return nil, errors.Wrap(err, "resolve failed") + return nil, fmt.Errorf("resolve failed: %w", err) } if len(addrs) == 0 { - return nil, errors.Wrap(ErrInvalidAddress, "empty address") + return nil, fmt.Errorf("empty address: %w", ErrInvalidAddress) } picker := options.pickBuilder.Build(addrs) @@ -100,7 +100,7 @@ func (c *client) Close() error { for _, pool := range c.connPools { if err := pool.close(); err != nil { - return errors.Wrap(err, "Close") + return fmt.Errorf("Close: %w", err) } } @@ -129,7 +129,7 @@ func (c *client) getConn(ctx context.Context, addr *Addr) (memcachedConn, error) cn, err = newConnContext(ctx2, addr, c.options.dialTimeout) if err != nil { - return nil, errors.Wrap(err, "newConnContext failed") + return nil, fmt.Errorf("newConnContext failed: %w", err) } // SASL auth if enabled @@ -196,12 +196,12 @@ func (c *client) broadcastRequest(ctx context.Context, call callFunc) error { wg.Wait() close(errCh) - var multiErr error + var errs []error for err := range errCh { - multiErr = multierror.Append(multiErr, err) + errs = append(errs, err) } - return multiErr + return errors.Join(errs...) } func (c *client) dispatchRequest(ctx context.Context, req *request, resp *response) error { @@ -213,7 +213,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon addr, err := c.picker.Pick(c.addrs, req.cmd, req.key) if err != nil { - return errors.Wrap(err, "pick node failed") + return fmt.Errorf("pick node failed: %w", err) } // START: Telemetry @@ -232,7 +232,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon if c.metrics != nil { c.metrics.RecordDuration(context.Background(), string(req.cmd), addr.Address, time.Since(start), err) } - return errors.Wrap(err, "alloc connection failed") + return fmt.Errorf("alloc connection failed: %w", err) } defer func() { _ = cn.release() }() @@ -245,7 +245,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon if c.metrics != nil { c.metrics.RecordDuration(context.Background(), string(req.cmd), addr.Address, time.Since(start), err) } - return errors.Wrap(err, "send failed") + return fmt.Errorf("send failed: %w", err) } recvErr := resp.recv(ctx, cn, c.options.readTimeout) @@ -275,13 +275,13 @@ func authSASL(conn memcachedConn, username, password string) error { // 1. first, list mechanisms the server supports req, resp := saslListMechanisms() if err := req.send(conn); err != nil { - return errors.Wrap(err, "authSASL send") + return fmt.Errorf("authSASL send: %w", err) } if err := resp.read(conn); err != nil { - return errors.Wrap(err, "authSASL recv") + return fmt.Errorf("authSASL recv: %w", err) } if err := resp.expect(_binaryStatusOK); err != nil { - return errors.Wrap(err, "authSASL") + return fmt.Errorf("authSASL: %w", err) } if !bytes.Contains(resp.value, []byte("PLAIN")) { @@ -291,13 +291,13 @@ func authSASL(conn memcachedConn, username, password string) error { // 2. choose one mechanism and send the authentication request req, resp = saslAuthRequestPlain(username, password) if err := req.send(conn); err != nil { - return errors.Wrap(err, "authSASL send") + return fmt.Errorf("authSASL send: %w", err) } if err := resp.read(conn); err != nil { - return errors.Wrap(err, "authSASL recv") + return fmt.Errorf("authSASL recv: %w", err) } if err := resp.expect(_binaryStatusOK); err != nil { - return errors.Wrap(err, "authSASL") + return fmt.Errorf("authSASL: %w", err) } return nil diff --git a/client_cluster.go b/client_cluster.go index c16f4a6..11845f8 100644 --- a/client_cluster.go +++ b/client_cluster.go @@ -1,12 +1,11 @@ package memcached import ( + "fmt" "hash/crc32" "net" "strings" - "github.com/pkg/errors" - "github.com/yeqown/memcached/hash" ) @@ -52,7 +51,7 @@ type defaultResolver struct{} func (r defaultResolver) Resolve(addr string) ([]*Addr, error) { if addr == "" { - return nil, errors.Wrap(ErrInvalidAddress, "empty address") + return nil, fmt.Errorf("empty address: %w", ErrInvalidAddress) } addrs := strings.Split(addr, ",") @@ -73,7 +72,7 @@ func (r defaultResolver) Resolve(addr string) ([]*Addr, error) { } if len(result) == 0 { - return nil, errors.Wrap(ErrInvalidAddress, "no available address") + return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress) } return result, nil @@ -83,7 +82,7 @@ func (r defaultResolver) Resolve(addr string) ([]*Addr, error) { func (r defaultResolver) resolveAddr(address string) (network, addr string, err error) { address = strings.TrimSpace(address) if address == "" { - return "", "", errors.Wrap(ErrInvalidAddress, "empty address") + return "", "", fmt.Errorf("empty address: %w", ErrInvalidAddress) } network = "tcp" @@ -108,7 +107,7 @@ func (r defaultResolver) resolveAddr(address string) (network, addr string, err } if err != nil { - return "", "", errors.Wrap(err, "invalid address: "+address) + return "", "", fmt.Errorf("invalid address: %s: %w", address, err) } return network, addr, nil @@ -121,7 +120,7 @@ type crc32HashPicker struct{} func (p *crc32HashPicker) Pick(addrs []*Addr, _, key []byte) (*Addr, error) { n := len(addrs) if n == 0 { - return nil, errors.Wrap(ErrInvalidAddress, "no available address") + return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress) } if n == 1 { return addrs[0], nil @@ -150,7 +149,7 @@ type murmur3HashPicker struct { func (p *murmur3HashPicker) Pick(addrs []*Addr, _, key []byte) (*Addr, error) { n := len(addrs) if n == 0 { - return nil, errors.Wrap(ErrInvalidAddress, "no available address") + return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress) } if n == 1 { return addrs[0], nil diff --git a/client_commands.go b/client_commands.go index 017ca29..1544a36 100644 --- a/client_commands.go +++ b/client_commands.go @@ -3,9 +3,9 @@ package memcached import ( "bytes" "context" + "errors" + "fmt" "time" - - "github.com/pkg/errors" ) type basicTextProtocolCommander interface { @@ -144,16 +144,16 @@ func (c *client) storageCommand(ctx context.Context, command, key string, value req, resp, err := buildStorageCommand(command, key, value, flag, expiry, c.options.noReply, c.options.codec) if err != nil { - return errors.Wrap(err, "build storage command failed") + return fmt.Errorf("build storage command failed: %w", err) } defer releaseReqAndResp(req, resp) if err = c.dispatchRequest(ctx, req, resp); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } if err = resp.expect(_StoredCRLFBytes); err != nil { - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil @@ -191,11 +191,11 @@ func (c *client) Cas(ctx context.Context, key string, value []byte, flag uint32, defer releaseReqAndResp(req, resp) if err = c.dispatchRequest(ctx, req, resp); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } if err = resp.expect(_StoredCRLFBytes); err != nil { - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil @@ -214,15 +214,15 @@ func (c *client) Get(ctx context.Context, key string) (*Item, error) { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } items, err := parseValueItems(resp.rawLines, false, false, c.options.codec) if err != nil { - return nil, errors.Wrap(err, "parse values failed") + return nil, fmt.Errorf("parse values failed: %w", err) } if len(items) == 0 { - return nil, errors.Wrap(ErrNotFound, "no items found") + return nil, fmt.Errorf("no items found: %w", ErrNotFound) } return items[0], nil @@ -237,15 +237,15 @@ func (c *client) Gets(ctx context.Context, keys ...string) ([]*Item, error) { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } items, err := parseValueItems(resp.rawLines, false, true, c.options.codec) if err != nil { - return nil, errors.Wrap(ErrMalformedResponse, "parse values failed") + return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse) } if len(items) == 0 { - return nil, errors.Wrap(ErrNotFound, "no items found") + return nil, fmt.Errorf("no items found: %w", ErrNotFound) } return items, nil @@ -260,16 +260,16 @@ func (c *client) GetAndTouch(ctx context.Context, expiry time.Duration, key stri defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } items, err := parseValueItems(resp.rawLines, false, false, c.options.codec) if err != nil { - return nil, errors.Wrap(ErrMalformedResponse, "parse values failed") + return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse) } if len(items) == 0 { - return nil, errors.Wrap(ErrNotFound, "no items found") + return nil, fmt.Errorf("no items found: %w", ErrNotFound) } return items[0], nil @@ -284,17 +284,17 @@ func (c *client) GetAndTouches(ctx context.Context, expiry time.Duration, keys . defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } // parse response items, err := parseValueItems(resp.rawLines, false, true, c.options.codec) if err != nil { - return nil, errors.Wrap(ErrMalformedResponse, "parse values failed") + return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse) } if len(items) == 0 { - return nil, errors.Wrap(ErrNotFound, "no items found") + return nil, fmt.Errorf("no items found: %w", ErrNotFound) } return items, nil @@ -313,12 +313,12 @@ func (c *client) Delete(ctx context.Context, key string) error { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } // expect DELETED\r\n if err := resp.expect(_DeletedCRLFBytes); err != nil { - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil @@ -331,13 +331,13 @@ func (c *client) Incr(ctx context.Context, key string, delta uint64) (uint64, er req, resp := buildArithmeticCommand("incr", key, delta, c.options.noReply) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return 0, errors.Wrap(err, "request failed") + return 0, fmt.Errorf("request failed: %w", err) } // parse response value, err := parseArithmetic(resp.rawLines[0]) if err != nil { - return 0, errors.Wrap(ErrMalformedResponse, err.Error()) + return 0, fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return value, nil @@ -350,13 +350,13 @@ func (c *client) Decr(ctx context.Context, key string, delta uint64) (uint64, er req, resp := buildArithmeticCommand("decr", key, delta, c.options.noReply) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return 0, errors.Wrap(err, "request failed") + return 0, fmt.Errorf("request failed: %w", err) } // parse response value, err := parseArithmetic(resp.rawLines[0]) if err != nil { - return 0, errors.Wrap(ErrMalformedResponse, err.Error()) + return 0, fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return value, nil @@ -371,12 +371,12 @@ func (c *client) Touch(ctx context.Context, key string, expiry time.Duration) er defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } // expect TOUCHED\r\n if err := resp.expect(_TouchedCRLFBytes); err != nil { - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil @@ -387,14 +387,14 @@ func (c *client) Version(ctx context.Context) (string, error) { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return "", errors.Wrap(err, "request") + return "", fmt.Errorf("request: %w", err) } // parse version number from response // VERSION 1.6.14 line := resp.rawLines[0] if !bytes.HasPrefix(line, _VersionBytes) { - return "", errors.Wrap(ErrMalformedResponse, string(line)) + return "", fmt.Errorf("%s: %w", string(line), ErrMalformedResponse) } return string(trimCRLF(line[8:])), nil @@ -408,22 +408,22 @@ func (c *client) FlushAll(ctx context.Context) error { c.autoSwitchToUDP(ctx, req, resp) if err := req.send(ctx, cn, c.options.writeTimeout); err != nil { - return errors.Wrap(err, "send failed") + return fmt.Errorf("send failed: %w", err) } if err := resp.recv(ctx, cn, c.options.readTimeout); err != nil { - return errors.Wrap(err, "recv failed") + return fmt.Errorf("recv failed: %w", err) } // expect OK\r\n if err := resp.expect(_OKCRLFBytes); err != nil { - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil } if err := c.broadcastRequest(ctx, call); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } return nil @@ -451,7 +451,7 @@ func (c *client) MetaSet(ctx context.Context, key, value []byte, msOptions ...Me } defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } item := &MetaItem{ @@ -486,7 +486,7 @@ func (c *client) MetaGet(ctx context.Context, key []byte, mgOptions ...MetaGetOp defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } item := &MetaItem{ @@ -513,7 +513,7 @@ func (c *client) MetaDelete(ctx context.Context, key []byte, options ...MetaDele defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } item := &MetaItem{ @@ -540,7 +540,7 @@ func (c *client) MetaArithmetic(ctx context.Context, key []byte, delta uint64, o defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } item := &MetaItem{ @@ -579,7 +579,7 @@ func (c *client) MetaDebug(ctx context.Context, key []byte, options ...MetaDebug defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } item := &MetaItemDebug{ @@ -598,14 +598,14 @@ func (c *client) MetaNoOp(ctx context.Context) error { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return errors.Wrap(err, "request failed") + return fmt.Errorf("request failed: %w", err) } if err := resp.expect(_MetaMNCRLFBytes); err != nil { if errors.Is(err, ErrMalformedResponse) { return err } - return errors.Wrap(ErrMalformedResponse, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse) } return nil @@ -621,7 +621,7 @@ func (c *client) Stats(ctx context.Context) (*Statistic, error) { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } return parseStats(resp.rawLines) @@ -632,7 +632,7 @@ func (c *client) Raw(ctx context.Context, cmd string) ([]string, error) { defer releaseReqAndResp(req, resp) if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, errors.Wrap(err, "request failed") + return nil, fmt.Errorf("request failed: %w", err) } lines := make([]string, 0, len(resp.rawLines)) diff --git a/client_test.go b/client_test.go index 3f767cb..17108c6 100644 --- a/client_test.go +++ b/client_test.go @@ -2,11 +2,11 @@ package memcached import ( "context" + "errors" "sync" "testing" "time" - pkgerrors "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -137,7 +137,7 @@ func (su *clientTestSuite) Test_concurrent() { for counter <= 200 { item, err := su.client.Get(ctx, key) - if pkgerrors.Is(err, ErrNotFound) { + if errors.Is(err, ErrNotFound) { goto next } su.NoError(err) @@ -160,7 +160,7 @@ func (su *clientTestSuite) Test_concurrent() { for counter <= 100 { err := su.client.Touch(ctx, key, 3) - if !pkgerrors.Is(err, ErrNotFound) { + if !errors.Is(err, ErrNotFound) { su.NoError(err) } @@ -247,11 +247,11 @@ func TestCompressionDisablesAppendPrepend(t *testing.T) { err := client.Append(context.Background(), "key", []byte("value"), 0, 0) require.Error(t, err) - assert.True(t, pkgerrors.Is(err, ErrNotSupported)) + assert.True(t, errors.Is(err, ErrNotSupported)) err = client.Prepend(context.Background(), "key", []byte("value"), 0, 0) require.Error(t, err) - assert.True(t, pkgerrors.Is(err, ErrNotSupported)) + assert.True(t, errors.Is(err, ErrNotSupported)) } func TestCompressionDisablesMetaAppendPrepend(t *testing.T) { @@ -269,7 +269,7 @@ func TestCompressionDisablesMetaAppendPrepend(t *testing.T) { ) assert.Nil(t, item) require.Error(t, err) - assert.True(t, pkgerrors.Is(err, ErrNotSupported)) + assert.True(t, errors.Is(err, ErrNotSupported)) }) } } diff --git a/conn.go b/conn.go index f5fc7d9..7b5c8e6 100644 --- a/conn.go +++ b/conn.go @@ -3,14 +3,14 @@ package memcached import ( "bufio" "context" + "errors" + "fmt" "io" "net" "strconv" "sync" "sync/atomic" "time" - - "github.com/pkg/errors" ) type nowFuncType func() time.Time @@ -121,7 +121,7 @@ type conn struct { func newConnContext(ctx context.Context, addr *Addr, dialTimeout time.Duration) (*conn, error) { rawConn, err := addr.dial(ctx, dialTimeout) if err != nil { - return nil, errors.Wrap(err, "dialContext") + return nil, fmt.Errorf("dialContext: %w", err) } cn := &conn{ diff --git a/errors.go b/errors.go index 8dce902..4e59271 100644 --- a/errors.go +++ b/errors.go @@ -1,7 +1,7 @@ package memcached import ( - "github.com/pkg/errors" + "errors" ) var ( diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..ea140c0 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,27 @@ +package memcached + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWrappedErrorChainPreservesSentinel(t *testing.T) { + err := fmt.Errorf("request failed: %w", fmt.Errorf("send failed: %w", ErrNotFound)) + assert.True(t, errors.Is(err, ErrNotFound)) + assert.Contains(t, err.Error(), "request failed") + assert.Contains(t, err.Error(), "send failed") +} + +func TestErrorsJoinPreservesJoinedSentinels(t *testing.T) { + joined := errors.Join( + fmt.Errorf("node-a: %w", ErrNotFound), + fmt.Errorf("node-b: %w", ErrServerError), + ) + require.Error(t, joined) + assert.True(t, errors.Is(joined, ErrNotFound)) + assert.True(t, errors.Is(joined, ErrServerError)) +} diff --git a/go.mod b/go.mod index d097580..e48cfb7 100644 --- a/go.mod +++ b/go.mod @@ -1,210 +1,24 @@ module github.com/yeqown/memcached -go 1.26.0 +go 1.26.5 require ( - github.com/hashicorp/go-multierror v1.1.1 + github.com/golang/snappy v1.0.0 + github.com/klauspost/compress v1.19.2 + github.com/pierrec/lz4/v4 v4.1.28 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/metric v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 ) require ( - 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect - 4d63.com/gochecknoglobals v0.2.2 // indirect - github.com/4meepo/tagalign v1.4.2 // indirect - github.com/Abirdcfly/dupword v0.1.3 // indirect - github.com/Antonboom/errname v1.0.0 // indirect - github.com/Antonboom/nilnil v1.0.1 // indirect - github.com/Antonboom/testifylint v1.5.2 // indirect - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect - github.com/Crocmagnon/fatcontext v0.7.1 // indirect - github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect - github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect - github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/alecthomas/go-check-sumtype v0.3.1 // indirect - github.com/alexkohler/nakedret/v2 v2.0.5 // indirect - github.com/alexkohler/prealloc v1.0.0 // indirect - github.com/alingse/asasalint v0.0.11 // indirect - github.com/alingse/nilnesserr v0.1.2 // indirect - github.com/ashanbrown/forbidigo v1.6.0 // indirect - github.com/ashanbrown/makezero v1.2.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blizzy78/varnamelen v0.8.0 // indirect - github.com/bombsimon/wsl/v4 v4.5.0 // indirect - github.com/breml/bidichk v0.3.2 // indirect - github.com/breml/errchkjson v0.4.0 // indirect - github.com/butuzov/ireturn v0.3.1 // indirect - github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.8.2 // indirect - github.com/ccojocar/zxcvbn-go v1.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.10 // indirect - github.com/chavacava/garif v0.1.0 // indirect - github.com/ckaznocha/intrange v0.3.0 // indirect - github.com/curioswitch/go-reassign v0.3.0 // indirect - github.com/daixiang0/gci v0.13.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/ettle/strcase v0.2.0 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/fatih/structtag v1.2.0 // indirect - github.com/firefart/nonamedreturns v1.0.5 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/ghostiam/protogetter v0.3.9 // indirect - github.com/go-critic/go-critic v0.12.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-toolsmith/astcast v1.1.0 // indirect - github.com/go-toolsmith/astcopy v1.1.0 // indirect - github.com/go-toolsmith/astequal v1.2.0 // indirect - github.com/go-toolsmith/astfmt v1.1.0 // indirect - github.com/go-toolsmith/astp v1.1.0 // indirect - github.com/go-toolsmith/strparse v1.1.0 // indirect - github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/golang/snappy v1.0.0 // indirect - github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect - github.com/golangci/go-printf-func-name v0.1.0 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint v1.64.8 // indirect - github.com/golangci/misspell v0.6.0 // indirect - github.com/golangci/plugin-module-register v0.1.1 // indirect - github.com/golangci/revgrep v0.8.0 // indirect - github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/gordonklaus/ineffassign v0.1.0 // indirect - github.com/gostaticanalysis/analysisutil v0.7.1 // indirect - github.com/gostaticanalysis/comment v1.5.0 // indirect - github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.1 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jgautheron/goconst v1.7.1 // indirect - github.com/jingyugao/rowserrcheck v1.1.1 // indirect - github.com/jjti/go-spancheck v0.6.4 // indirect - github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect - github.com/kisielk/errcheck v1.9.0 // indirect - github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/klauspost/compress v1.18.6 // indirect - github.com/kulti/thelper v0.6.3 // indirect - github.com/kunwardeep/paralleltest v1.0.10 // indirect - github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.2 // indirect - github.com/ldez/gomoddirectives v0.6.1 // indirect - github.com/ldez/grignotin v0.9.0 // indirect - github.com/ldez/tagliatelle v0.7.1 // indirect - github.com/ldez/usetesting v0.4.2 // indirect - github.com/leonklingele/grouper v1.1.2 // indirect - github.com/macabu/inamedparam v0.1.3 // indirect - github.com/magiconair/properties v1.8.6 // indirect - github.com/maratori/testableexamples v1.0.0 // indirect - github.com/maratori/testpackage v1.1.1 // indirect - github.com/matoous/godox v1.1.0 // indirect - github.com/matryer/is v1.4.1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/mgechev/revive v1.7.0 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moricho/tparallel v0.3.2 // indirect - github.com/nakabonne/nestif v0.3.1 // indirect - github.com/nishanths/exhaustive v0.12.0 // indirect - github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.19.1 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/polyfloyd/go-errorlint v1.7.1 // indirect - github.com/prometheus/client_golang v1.12.1 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.32.1 // indirect - github.com/prometheus/procfs v0.7.3 // indirect - github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect - github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect - github.com/quasilyte/gogrep v0.5.0 // indirect - github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect - github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect - github.com/raeperd/recvcheck v0.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/ryancurrah/gomodguard v1.3.5 // indirect - github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect - github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect - github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect - github.com/securego/gosec/v2 v2.22.2 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/sivchari/containedctx v1.0.3 // indirect - github.com/sivchari/tenv v1.12.1 // indirect - github.com/sonatard/noctx v0.1.0 // indirect - github.com/sourcegraph/go-diff v0.7.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.5.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/spf13/viper v1.12.0 // indirect - github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/subosito/gotenv v1.4.1 // indirect - github.com/tdakkota/asciicheck v0.4.1 // indirect - github.com/tetafro/godot v1.5.0 // indirect - github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect - github.com/timonwong/loggercheck v0.10.1 // indirect - github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect - github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect - github.com/ultraware/funlen v0.2.0 // indirect - github.com/ultraware/whitespace v0.2.0 // indirect - github.com/uudashr/gocognit v1.2.0 // indirect - github.com/uudashr/iface v1.3.1 // indirect - github.com/xen0n/gosmopolitan v1.2.2 // indirect - github.com/yagipy/maintidx v1.0.0 // indirect - github.com/yeya24/promlinter v0.3.0 // indirect - github.com/ykadowak/zerologlint v0.1.5 // indirect - gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.13.0 // indirect - go-simpler.org/sloglint v0.9.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.uber.org/atomic v1.7.0 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/goleak v1.3.0 // indirect - go.uber.org/multierr v1.6.0 // indirect - go.uber.org/zap v1.24.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.31.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - honnef.co/go/tools v0.6.1 // indirect - mvdan.cc/gofumpt v0.7.0 // indirect - mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect ) - -tool github.com/golangci/golangci-lint/cmd/golangci-lint diff --git a/go.work b/go.work index a4b482a..cc5a23f 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.26.5 use ( . diff --git a/protocol.go b/protocol.go index 7c80a1f..ba7954e 100644 --- a/protocol.go +++ b/protocol.go @@ -3,11 +3,10 @@ package memcached import ( "bytes" "encoding/json" + "fmt" "log" "strconv" "time" - - "github.com/pkg/errors" ) // Codec transforms value and flags at the protocol boundary. @@ -27,7 +26,7 @@ type Codec interface { func checkCodecSupportsOperation(codec Codec, operation string) error { if err := codec.SupportsOperation(operation); err != nil { - return errors.Wrap(ErrNotSupported, err.Error()) + return fmt.Errorf("%s: %w", err.Error(), ErrNotSupported) } return nil } @@ -147,9 +146,8 @@ func buildStorageCommand( noReply bool, codec Codec, ) (*request, *response, error) { - if err := checkCodecSupportsOperation(codec, command); err != nil { - return nil, nil, errors.Wrap(err, "codec does not support operation") + return nil, nil, fmt.Errorf("codec does not support operation: %w", err) } evalue, eflags, err := codec.Encode([]byte(key), value, flags) @@ -238,7 +236,7 @@ func buildCasCommand( key string, value []byte, flag uint32, expTime time.Duration, casUnique uint64, noReply bool, codec Codec, ) (*request, *response, error) { if err := checkCodecSupportsOperation(codec, "cas"); err != nil { - return nil, nil, errors.Wrap(err, "codec does not support operation") + return nil, nil, fmt.Errorf("codec does not support operation: %w", err) } evalue, eflag, err := codec.Encode([]byte(key), value, flag) @@ -328,11 +326,11 @@ func parseValueItems(lines [][]byte, withoutEndLine, withCAS bool, codec Codec) n := len(lines) if withoutEndLine && n%2 != 0 { // n must be even - return nil, errors.Wrap(ErrMalformedResponse, "want times of 2 lines, got "+strconv.Itoa(n)) + return nil, fmt.Errorf("want times of 2 lines, got %d: %w", n, ErrMalformedResponse) } if !withoutEndLine && n%2 == 0 { // n must be odd - return nil, errors.Wrap(ErrMalformedResponse, "want odd lines, got "+strconv.Itoa(n)) + return nil, fmt.Errorf("want odd lines, got %d: %w", n, ErrMalformedResponse) } var ( @@ -365,11 +363,11 @@ func parseValueItems(lines [][]byte, withoutEndLine, withCAS bool, codec Codec) // Read the data block if i+1 >= n { - return nil, errors.Wrap(ErrMalformedResponse, "missing data block") + return nil, fmt.Errorf("missing data block: %w", ErrMalformedResponse) } item.Value = trimCRLF(lines[i+1]) if len(item.Value) != int(dataLen) { - return nil, errors.Wrap(ErrMalformedResponse, "data block length mismatch") + return nil, fmt.Errorf("data block length mismatch: %w", ErrMalformedResponse) } decodedValue, decodedFlags, err := codec.Decode([]byte(item.Key), item.Value, uint32(item.Flags)) @@ -404,7 +402,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err for i := start; i < n; i++ { if nField > 5 || (!withCas && nField > 4) { - return 0, errors.Wrap(ErrMalformedResponse, "invalid VALUE line") + return 0, fmt.Errorf("invalid VALUE line: %w", ErrMalformedResponse) } if line[i] != ' ' && i != n-1 { @@ -419,7 +417,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err case flagsIndex: flags, err := parseUintFromBytes(line[fieldStart:i]) if err != nil { - return 0, errors.Wrap(ErrMalformedResponse, "invalid flags") + return 0, fmt.Errorf("invalid flags: %w", ErrMalformedResponse) } item.Flags = uint32(flags) case dataLenIndex: @@ -429,7 +427,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err } dataLen, err = parseUintFromBytes(line[fieldStart:si]) if err != nil { - return 0, errors.Wrap(ErrMalformedResponse, "invalid data length") + return 0, fmt.Errorf("invalid data length: %w", ErrMalformedResponse) } case casIndex: si := i @@ -438,7 +436,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err } item.CAS, err = parseUintFromBytes(line[fieldStart:si]) if err != nil { - return 0, errors.Wrap(ErrMalformedResponse, "invalid CAS") + return 0, fmt.Errorf("invalid CAS: %w", ErrMalformedResponse) } } @@ -465,7 +463,7 @@ func parseUintFromBytes(bs []byte) (uint64, error) { r := uint64(0) for _, b := range bs { if b < '0' || b > '9' { - return 0, errors.Wrap(ErrMalformedResponse, "invalid uint number") + return 0, fmt.Errorf("invalid uint number: %w", ErrMalformedResponse) } r = r*10 + uint64(b-'0') @@ -617,7 +615,7 @@ type Statistic struct { func parseStats(lines [][]byte) (*Statistic, error) { if len(lines) <= 0 { - return nil, errors.Wrap(ErrMalformedResponse, "empty response") + return nil, fmt.Errorf("empty response: %w", ErrMalformedResponse) } transitionMap := make(map[string]any, len(lines)) @@ -662,12 +660,12 @@ func parseStats(lines [][]byte) (*Statistic, error) { raw, err := json.Marshal(transitionMap) if err != nil { - return nil, errors.Wrap(err, "parseStats marshal transition map failed") + return nil, fmt.Errorf("parseStats marshal transition map failed: %w", err) } stat := &Statistic{} if err = json.Unmarshal(raw, stat); err != nil { - return nil, errors.Wrap(err, "parseStats unmarshal failed") + return nil, fmt.Errorf("parseStats unmarshal failed: %w", err) } return stat, nil diff --git a/protocol_bin.go b/protocol_bin.go index 6083633..824cd39 100644 --- a/protocol_bin.go +++ b/protocol_bin.go @@ -2,9 +2,8 @@ package memcached import ( "encoding/binary" + "fmt" "io" - - "github.com/pkg/errors" ) // https://docs.memcached.org/protocols/binary/ @@ -199,25 +198,25 @@ func (br *binaryResponse) expect(status uint16) error { case _binaryStatusNotSupported: return ErrNotSupported case _binaryStatusInternalError: - return errors.Wrap(ErrServerError, "internal error") + return fmt.Errorf("internal error: %w", ErrServerError) case _binaryStatusInvalidArgs: return ErrInvalidArgument case _binaryStatusOutOfMemory: - return errors.Wrap(ErrServerError, "out of memory") + return fmt.Errorf("out of memory: %w", ErrServerError) } // return: status: 0x1234 format - return errors.Wrapf(ErrServerError, "status: %x", br.status) + return fmt.Errorf("status: %x: %w", br.status, ErrServerError) } func (br *binaryResponse) read(rr io.Reader) error { buf := make([]byte, 24) if _, err := io.ReadFull(rr, buf); err != nil { - return errors.Wrap(err, "read header") + return fmt.Errorf("read header: %w", err) } if magic := buf[0]; magic != _binaryMagicRes { - return errors.Wrapf(ErrInvalidBinaryProtocol, "invalid magic: %d", magic) + return fmt.Errorf("invalid magic: %d: %w", magic, ErrInvalidBinaryProtocol) } br.opcode = buf[1] @@ -236,7 +235,7 @@ func (br *binaryResponse) read(rr io.Reader) error { // read the whole body and split them into extras, key, value body := make([]byte, br.totalBodyLength) if _, err := io.ReadFull(rr, body); err != nil { - return errors.Wrap(err, "read body") + return fmt.Errorf("read body: %w", err) } s := uint32(0) diff --git a/protocol_bin_test.go b/protocol_bin_test.go index 69ccccc..aabb253 100644 --- a/protocol_bin_test.go +++ b/protocol_bin_test.go @@ -2,10 +2,10 @@ package memcached import ( "bytes" + "errors" "io" "testing" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) diff --git a/protocol_builder.go b/protocol_builder.go index 4d7da57..949387e 100644 --- a/protocol_builder.go +++ b/protocol_builder.go @@ -4,13 +4,13 @@ import ( "bytes" "context" "encoding/base64" + "errors" + "fmt" "math" "strconv" "sync" "time" "unsafe" - - "github.com/pkg/errors" ) const ( @@ -53,10 +53,10 @@ func forecastCommonFaultLine(line []byte) error { return ErrNonexistentCommand case bytes.HasPrefix(line, []byte("CLIENT_ERROR")): message := string(line[12 : len(line)-2]) - return errors.Wrap(ErrClientError, message) + return fmt.Errorf("%s: %w", message, ErrClientError) case bytes.HasPrefix(line, []byte("SERVER_ERROR")): message := string(line[12 : len(line)-2]) - return errors.Wrap(ErrServerError, message) + return fmt.Errorf("%s: %w", message, ErrServerError) case bytes.Equal(line, []byte("NOT_FOUND\r\n")): return ErrNotFound case bytes.Equal(line, []byte("EXISTS\r\n")): @@ -468,7 +468,7 @@ func (resp *response) read1(rr memcachedConn) error { for read < int(resp.limitedLines) { line, err := rr.readLine('\n') if err != nil { - return errors.Wrap(err, "dispatchRequest read") + return fmt.Errorf("dispatchRequest read: %w", err) } if read == 0 { @@ -495,7 +495,7 @@ func (resp *response) read2(rr memcachedConn) error { // FIXME(@yeqown): read line would cost too much capacity. line, err := rr.readLine('\n') if err != nil { - return errors.Wrap(err, "dispatchRequest read") + return fmt.Errorf("dispatchRequest read: %w", err) } if read == 0 && resp.udpEnabled { @@ -528,7 +528,7 @@ func (resp *response) expect(line []byte) error { return nil } if n := len(resp.rawLines); n != 1 { - return errors.Wrapf(ErrMalformedResponse, "expect only 1 line, but got %d", n) + return fmt.Errorf("expect only 1 line, but got %d: %w", n, ErrMalformedResponse) } if bytes.Equal(resp.rawLines[0], line) { diff --git a/protocol_meta.go b/protocol_meta.go index 660814e..85b53a8 100644 --- a/protocol_meta.go +++ b/protocol_meta.go @@ -4,8 +4,6 @@ import ( "bytes" "fmt" "strconv" - - "github.com/pkg/errors" ) type metaSetFlags struct { @@ -118,13 +116,13 @@ func buildMetaSetCommand(key, value []byte, flags *metaSetFlags, codec Codec) (* operation = string(MetaSetModeSet) } if err := checkCodecSupportsOperation(codec, operation); err != nil { - return nil, nil, errors.Wrap(err, "codec does not support operation") + return nil, nil, fmt.Errorf("codec does not support operation: %w", err) } // codec hook evalue, eflags, err := codec.Encode(key, value, flags.F) if err != nil { - return nil, nil, errors.Wrap(err, "encode value and flags") + return nil, nil, fmt.Errorf("encode value and flags: %w", err) } flags.F = eflags @@ -358,13 +356,13 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er // figure out the if len(lines) == 0 { - return errors.Wrap(ErrMalformedResponse, "missing response") + return fmt.Errorf("missing response: %w", ErrMalformedResponse) } // Normal CD handling parts := bytes.Split(trimCRLF(lines[0]), _SpaceBytes) if len(parts) < 1 { - return errors.Wrap(ErrMalformedResponse, "invalid response") + return fmt.Errorf("invalid response: %w", ErrMalformedResponse) } const ( CDIndex = 0 @@ -393,12 +391,12 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er parseFlags(parts, 2, item) if len(lines) < 2 { - return errors.Wrap(ErrMalformedResponse, "missing value") + return fmt.Errorf("missing value: %w", ErrMalformedResponse) } var err error if item.Value, item.Flags, err = codec.Decode(item.Key, trimCRLF(lines[1]), item.Flags); err != nil { - return errors.Wrap(err, "codec decode") + return fmt.Errorf("codec decode: %w", err) } return nil @@ -711,12 +709,12 @@ func buildMetaDebugCommand(key []byte, flags *metaDebugFlags) (*request, *respon // failed: EN\r\n func parseMetaItemDebug(lines [][]byte, item *MetaItemDebug) error { if len(lines) != 1 { - return errors.Wrap(ErrMalformedResponse, "invalid response") + return fmt.Errorf("invalid response: %w", ErrMalformedResponse) } parts := bytes.Split(trimCRLF(lines[0]), _SpaceBytes) if len(parts) < 1 { - return errors.Wrap(ErrMalformedResponse, "invalid response") + return fmt.Errorf("invalid response: %w", ErrMalformedResponse) } const ( @@ -731,7 +729,7 @@ func parseMetaItemDebug(lines [][]byte, item *MetaItemDebug) error { case "ME": // success default: - return errors.Wrap(ErrMalformedResponse, "unexpected cd<="+string(cd)+">") + return fmt.Errorf("unexpected cd<=%s>: %w", string(cd), ErrMalformedResponse) } // parse key diff --git a/protocol_test.go b/protocol_test.go index 18781d4..e04d788 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -2,11 +2,11 @@ package memcached import ( "bytes" + "errors" "strconv" "testing" "time" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" memcodec "github.com/yeqown/memcached/codec" ) From e7807b490f14126e254f9cca34fde127c1a0d483 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:11:25 +0200 Subject: [PATCH 2/9] fix: harden connection pool shutdown and cleanup Keep the idle channel open across close, wake waiters via closedCh, and reject connections created during shutdown. Add race-focused pool tests. Co-authored-by: Cursor --- conn.go | 178 +++++++++++++++++++++++++++++++-------------------- conn_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++--- go.mod | 1 - 3 files changed, 259 insertions(+), 79 deletions(-) diff --git a/conn.go b/conn.go index 7b5c8e6..15e8cbc 100644 --- a/conn.go +++ b/conn.go @@ -244,6 +244,8 @@ func (c *conn) release() error { return c.pool.put(c) } +var errPoolClosed = errors.New("connection pool is closed") + // The connPool holds a pool of connections to one memcached server instance // and provides a way to get a connection from the pool. // @@ -268,7 +270,10 @@ type connPool struct { numOpen atomic.Int32 // Indicate if the pool is closed, if true, no new connections will be created // and all existing connections will be closed. - closed bool + closed bool + // closedCh is closed exactly once when the pool closes, so waiting get() + // callers can abort without receiving from a closed idle channel. + closedCh chan struct{} cleanerCh chan struct{} maxIdleClosed int64 // the number of connections closed due to maxIdle @@ -292,6 +297,7 @@ func newConnPool( createConn: createConn, numOpen: atomic.Int32{}, closed: false, + closedCh: make(chan struct{}), cleanerCh: nil, // created when needed in startCleaner maxIdleClosed: 0, @@ -310,11 +316,26 @@ func (p *connPool) close() error { } p.closed = true - // close all existing connections, so that the get() couldn't - // create a new connection. - close(p.conns) + close(p.closedCh) + + // Drain idle connections without closing the idle channel itself. + // Closing the channel would race with lock-free get() receives. + for { + select { + case cn := <-p.conns: + _ = cn.Close() + p.numOpen.Add(-1) + default: + goto drained + } + } +drained: + if p.cleanerCh != nil { - p.cleanerCh <- struct{}{} + select { + case p.cleanerCh <- struct{}{}: + default: + } } p.mu.Unlock() @@ -322,39 +343,48 @@ func (p *connPool) close() error { } func (p *connPool) get(ctx context.Context) (memcachedConn, error) { - if p.closed { - return nil, errors.New("connection pool is closed") - } - - // try to get a connection from the pool first if there is any + // Try to get an idle connection first if there is any, // otherwise create a new connection. select { case cn := <-p.conns: return cn, nil default: - p.mu.Lock() - // no available connection, check if we can create a new one. - if int(p.numOpen.Load()) >= p.maxConns { - p.mu.Unlock() - // the pool is full, wait for a connection to be returned - select { - case cn := <-p.conns: - return cn, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } - p.mu.Unlock() + } - cn, err := p.createConn(ctx) - if err != nil { - return nil, err + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, errPoolClosed + } + // No available connection, check if we can create a new one. + if int(p.numOpen.Load()) >= p.maxConns { + p.mu.Unlock() + // The pool is full, wait for a connection to be returned or for close. + select { + case cn := <-p.conns: + return cn, nil + case <-p.closedCh: + return nil, errPoolClosed + case <-ctx.Done(): + return nil, ctx.Err() } - cn.setConnPool(p) - p.numOpen.Add(1) + } + p.mu.Unlock() - return cn, nil + cn, err := p.createConn(ctx) + if err != nil { + return nil, err } + cn.setConnPool(p) + + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, errors.Join(errPoolClosed, cn.Close()) + } + p.numOpen.Add(1) + p.mu.Unlock() + return cn, nil } func (p *connPool) put(cn memcachedConn) error { @@ -408,7 +438,7 @@ func (p *connPool) connectionsCleaner(d time.Duration) { for { select { case <-t.C: - case <-p.cleanerCh: // db was closed + case <-p.cleanerCh: // pool was closed } p.mu.Lock() @@ -423,7 +453,7 @@ func (p *connPool) connectionsCleaner(d time.Duration) { return } - // make a copy of the connections those need to be closed. + // Snapshot idle connections that need to be closed. d, closing := p.connectionCleanerRunLocked(d) p.mu.Unlock() @@ -461,55 +491,65 @@ func (p *connPool) shortestIdleTimeLocked() time.Duration { // // 1. if the connection is expired (exceeds maxLifeTime since created). // 2. if the connection idle time exceeds the idle connection limit(maxIdleTime). +// +// The idle channel is kept intact: currently available connections are drained +// with non-blocking receives, filtered in one pass, and survivors are put back. func (p *connPool) connectionCleanerRunLocked(d time.Duration) (time.Duration, []memcachedConn) { var idleClosing int64 closing := make([]memcachedConn, 0, p.maxIdle/2) - newConns := make(chan memcachedConn, p.maxConns) - - if p.maxIdleTime > 0 { - idleSince := nowFunc().Add(-p.maxIdleTime) - close(p.conns) - // TODO(@yeqown): could we optimize the loop here? - // we re-allocate the newConns channel every time we clean up the connections. - for c := range p.conns { - if d2, ok := c.idle(idleSince); !ok { - if d2 < d { - // Ensure idle connections are cleaned up as soon - // as possible. - d = d2 - } - newConns <- c // put back - continue - } - closing = append(closing, c) - idleClosing++ + kept := make([]memcachedConn, 0, len(p.conns)) + + var idleSince, expiredSince time.Time + checkIdle := p.maxIdleTime > 0 + checkLife := p.maxLifeTime > 0 + if checkIdle { + idleSince = nowFunc().Add(-p.maxIdleTime) + } + if checkLife { + expiredSince = nowFunc().Add(-p.maxLifeTime) + } + + for { + var c memcachedConn + select { + case c = <-p.conns: + default: + goto drained } - p.conns = newConns - p.maxIdleTimeClosed += idleClosing - } - - newConns = make(chan memcachedConn, p.maxConns) - if p.maxLifeTime > 0 { - expiredSince := nowFunc().Add(-p.maxLifeTime) - close(p.conns) - for c := range p.conns { - if d2, ok := c.expired(expiredSince); !ok { - if d2 < d { - // Prevents connections staying in the pool when they - // have expired. - d = d2 - } - newConns <- c // put back - continue + shouldClose := false + if checkIdle { + if d2, ok := c.idle(idleSince); ok { + shouldClose = true + idleClosing++ + } else if d2 < d { + // Ensure idle connections are cleaned up as soon as possible. + d = d2 + } + } + if !shouldClose && checkLife { + if d2, ok := c.expired(expiredSince); ok { + shouldClose = true + } else if d2 < d { + // Prevents connections staying in the pool when they have expired. + d = d2 } + } + + if shouldClose { closing = append(closing, c) + } else { + kept = append(kept, c) } + } - p.conns = newConns - p.maxLifeTimeClosed += int64(len(closing)) - idleClosing +drained: + for _, c := range kept { + p.conns <- c } + p.maxIdleTimeClosed += idleClosing + p.maxLifeTimeClosed += int64(len(closing)) - idleClosing return d, closing } diff --git a/conn_test.go b/conn_test.go index 8db1732..823b83f 100644 --- a/conn_test.go +++ b/conn_test.go @@ -2,11 +2,12 @@ package memcached import ( "context" + "errors" "sync" + "sync/atomic" "testing" "time" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) @@ -21,6 +22,16 @@ type mockConn struct { pool *connPool } +type closeTrackingConn struct { + *mockConn + closed atomic.Bool +} + +func (m *closeTrackingConn) Close() error { + m.closed.Store(true) + return nil +} + func newMockConn() *mockConn { return &mockConn{ createdAt: time.Now(), @@ -131,14 +142,14 @@ func Test_connPool_new(t *testing.T) { t.Run(tt.name, func(t *testing.T) { pool := newConnPool(tt.maxIdle, tt.maxConn, tt.maxLifeTime, tt.maxIdleTimeout, createConn) - // 验证基本属性 assert.NotNil(t, pool) assert.Equal(t, tt.maxIdle, pool.maxIdle) assert.Equal(t, tt.maxConn, pool.maxConns) assert.Equal(t, tt.maxIdleTimeout, pool.maxIdleTime) - assert.Equal(t, tt.maxConn, cap(pool.conns)) + assert.Equal(t, 0, pool.stats().IdleConns) assert.Equal(t, 0, int(pool.numOpen.Load())) assert.False(t, pool.closed) + assert.NotNil(t, pool.closedCh) assert.Nil(t, pool.cleanerCh) assert.Equal(t, int64(0), pool.maxIdleClosed) assert.Equal(t, int64(0), pool.maxIdleTimeClosed) @@ -172,9 +183,9 @@ func Test_connPool_get_put(t *testing.T) { wg.Wait() - // idle connections reached maxConn + // idle connections reached maxIdle assert.Equal(t, 5, int(pool.numOpen.Load())) - assert.Equal(t, 5, len(pool.conns)) + assert.Equal(t, 5, pool.stats().IdleConns) } // Test_connPool_get_timeout_case1 mocking the case that the createConn function @@ -271,7 +282,7 @@ func Test_connPool_get_oversize(t *testing.T) { wg.Wait() assert.Equal(t, 5, int(pool.numOpen.Load())) - assert.Equal(t, 5, len(pool.conns)) + assert.Equal(t, 5, pool.stats().IdleConns) ctx, cancel = context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() @@ -317,7 +328,7 @@ func Test_connPool_cleanup_maxIdle(t *testing.T) { // connections to 5. time.Sleep(3 * time.Second) assert.Equal(t, 0, int(pool.numOpen.Load())) - assert.Equal(t, 0, len(pool.conns)) + assert.Equal(t, 0, pool.stats().IdleConns) assert.False(t, pool.closed) stat = pool.stats() @@ -368,16 +379,146 @@ func Test_connPool_cleanup_maxLife(t *testing.T) { // connections to 5. time.Sleep(3 * time.Second) assert.Equal(t, 0, int(pool.numOpen.Load())) - assert.Equal(t, 0, len(pool.conns)) + assert.Equal(t, 0, pool.stats().IdleConns) assert.False(t, pool.closed) stat = pool.stats() t.Logf("After cleanup: %+v", stat) assert.Equal(t, 0, stat.TotalConns) - assert.Equal(t, 0, stat.IdleConns) // since all connections are cleaned up(idle=1s) + assert.Equal(t, 0, stat.IdleConns) // since all connections are cleaned up(life=2s) assert.Equal(t, 10, stat.MaxConns) assert.Equal(t, 5, stat.MaxIdle) assert.Equal(t, int64(5), stat.maxIdleClosed) assert.Equal(t, int64(5), stat.maxLifeTimeClosed) assert.Equal(t, int64(0), stat.maxIdleTimeClosed) } + +func Test_connectionCleanerRunLocked_both_limits(t *testing.T) { + pool := newConnPool(10, 10, time.Hour, time.Hour, createConn) + fixedNow := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + origNow := nowFunc + nowFunc = func() time.Time { return fixedNow } + t.Cleanup(func() { nowFunc = origNow }) + + idleExpired := newMockConn() + idleExpired.createdAt = fixedNow.Add(-30 * time.Minute) + idleExpired.returnedAt = fixedNow.Add(-2 * time.Hour) + + lifeExpired := newMockConn() + lifeExpired.createdAt = fixedNow.Add(-2 * time.Hour) + lifeExpired.returnedAt = fixedNow.Add(-time.Minute) + + fresh := newMockConn() + fresh.createdAt = fixedNow.Add(-time.Minute) + fresh.returnedAt = fixedNow.Add(-time.Minute) + + pool.conns <- idleExpired + pool.conns <- lifeExpired + pool.conns <- fresh + pool.numOpen.Store(3) + connsBefore := pool.conns + + pool.maxIdleTime = time.Hour + pool.maxLifeTime = time.Hour + + d, closing := pool.connectionCleanerRunLocked(time.Hour) + assert.True(t, connsBefore == pool.conns, "cleaner must keep the idle channel") + assert.Len(t, closing, 2) + assert.Equal(t, int64(1), pool.maxIdleTimeClosed) + assert.Equal(t, int64(1), pool.maxLifeTimeClosed) + assert.Equal(t, 1, len(pool.conns)) + assert.Greater(t, d, time.Duration(0)) + + kept := <-pool.conns + assert.Same(t, fresh, kept) +} + +func Test_connPool_cleanup_concurrent_get(t *testing.T) { + pool := newConnPool(8, 8, time.Hour, 100*time.Millisecond, createConn) + ctx := context.Background() + + for i := 0; i < 8; i++ { + cn, err := pool.get(ctx) + assert.NoError(t, err) + assert.NoError(t, pool.put(cn)) + } + + done := make(chan struct{}) + go func() { + defer close(done) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + cn, err := pool.get(ctx) + if err != nil { + assert.Fail(t, "get failed during cleanup", err.Error()) + return + } + assert.NotNil(t, cn) + _ = pool.put(cn) + } + }() + + <-done + assert.NoError(t, pool.close()) + assert.Equal(t, 0, int(pool.numOpen.Load())) +} + +func Test_connPool_close_wakes_waiters(t *testing.T) { + pool := newConnPool(1, 1, time.Hour, time.Hour, createConn) + ctx := context.Background() + + cn, err := pool.get(ctx) + assert.NoError(t, err) + assert.NotNil(t, cn) + + errCh := make(chan error, 1) + go func() { + _, getErr := pool.get(ctx) + errCh <- getErr + }() + + time.Sleep(50 * time.Millisecond) + assert.NoError(t, pool.close()) + + select { + case getErr := <-errCh: + assert.ErrorIs(t, getErr, errPoolClosed) + case <-time.After(time.Second): + t.Fatal("waiting get was not woken by close") + } + + assert.NoError(t, pool.put(cn)) + assert.Equal(t, 0, int(pool.numOpen.Load())) + assert.Equal(t, 0, pool.stats().IdleConns) +} + +func Test_connPool_get_closes_connection_created_during_close(t *testing.T) { + creationStarted := make(chan struct{}) + releaseCreation := make(chan struct{}) + createdConn := &closeTrackingConn{mockConn: newMockConn()} + pool := newConnPool(1, 1, time.Hour, time.Hour, func(context.Context) (memcachedConn, error) { + close(creationStarted) + <-releaseCreation + return createdConn, nil + }) + + type getResult struct { + conn memcachedConn + err error + } + resultCh := make(chan getResult, 1) + go func() { + conn, err := pool.get(context.Background()) + resultCh <- getResult{conn: conn, err: err} + }() + + <-creationStarted + assert.NoError(t, pool.close()) + close(releaseCreation) + + result := <-resultCh + assert.Nil(t, result.conn) + assert.ErrorIs(t, result.err, errPoolClosed) + assert.True(t, createdConn.closed.Load()) + assert.Equal(t, 0, int(pool.numOpen.Load())) +} diff --git a/go.mod b/go.mod index e48cfb7..65fd5fb 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/golang/snappy v1.0.0 github.com/klauspost/compress v1.19.2 github.com/pierrec/lz4/v4 v4.1.28 - github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/metric v1.45.0 From 2398cc06c17f7ed29bd12812b9762975486d444a Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:12:10 +0200 Subject: [PATCH 3/9] fix: harden protocol parsing and buffer lifecycle Add overflow and bounds checks for text/meta/binary frames, replace unsafe string conversions, and cap protocol-builder buffer reuse. Co-authored-by: Cursor --- client_cluster.go | 8 ++- client_commands.go | 4 ++ protocol.go | 15 +++-- protocol_bin.go | 58 +++++++++++--------- protocol_builder.go | 46 +++++++--------- protocol_builder_test.go | 116 +++++++++++++++++++++++++++++++++++++++ protocol_meta.go | 82 ++++++++++++++++++++------- protocol_meta_test.go | 2 +- protocol_test.go | 24 ++++++++ 9 files changed, 277 insertions(+), 78 deletions(-) diff --git a/client_cluster.go b/client_cluster.go index 11845f8..5a351e2 100644 --- a/client_cluster.go +++ b/client_cluster.go @@ -3,6 +3,7 @@ package memcached import ( "fmt" "hash/crc32" + "math" "net" "strings" @@ -127,7 +128,12 @@ func (p *crc32HashPicker) Pick(addrs []*Addr, _, key []byte) (*Addr, error) { } sum := crc32.ChecksumIEEE(key) - return addrs[sum%uint32(n)], nil + index64 := uint64(sum) % uint64(n) + if index64 > math.MaxInt { + return nil, fmt.Errorf("hash index exceeds int: %w", ErrInvalidAddress) + } + index := int(index64) + return addrs[index], nil } type crc32HashPickBuilder struct{} diff --git a/client_commands.go b/client_commands.go index 1544a36..cbcb73f 100644 --- a/client_commands.go +++ b/client_commands.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "math" "time" ) @@ -443,6 +444,9 @@ func (c *client) MetaSet(ctx context.Context, key, value []byte, msOptions ...Me for _, applyFn := range msOptions { applyFn(msFlags) } + if msFlags.T > math.MaxInt64 { + return nil, fmt.Errorf("TTL exceeds int64: %w", ErrInvalidArgument) + } clientFlags := msFlags.F req, resp, err := buildMetaSetCommand(key, value, msFlags, c.options.codec) diff --git a/protocol.go b/protocol.go index ba7954e..161d21f 100644 --- a/protocol.go +++ b/protocol.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "math" "strconv" "time" ) @@ -366,7 +367,7 @@ func parseValueItems(lines [][]byte, withoutEndLine, withCAS bool, codec Codec) return nil, fmt.Errorf("missing data block: %w", ErrMalformedResponse) } item.Value = trimCRLF(lines[i+1]) - if len(item.Value) != int(dataLen) { + if uint64(len(item.Value)) != dataLen { return nil, fmt.Errorf("data block length mismatch: %w", ErrMalformedResponse) } @@ -413,12 +414,15 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err // the 'i' is the index of space or the last byte. switch nField { case keyIndex: - item.Key = unsafeByteSliceToString(line[fieldStart:i]) + item.Key = byteSliceToString(line[fieldStart:i]) case flagsIndex: flags, err := parseUintFromBytes(line[fieldStart:i]) if err != nil { return 0, fmt.Errorf("invalid flags: %w", ErrMalformedResponse) } + if flags > math.MaxUint32 { + return 0, fmt.Errorf("flags exceed uint32: %w", ErrMalformedResponse) + } item.Flags = uint32(flags) case dataLenIndex: si := i @@ -466,7 +470,11 @@ func parseUintFromBytes(bs []byte) (uint64, error) { return 0, fmt.Errorf("invalid uint number: %w", ErrMalformedResponse) } - r = r*10 + uint64(b-'0') + digit := uint64(b - '0') + if r > (math.MaxUint64-digit)/10 { + return 0, fmt.Errorf("uint number overflows uint64: %w", ErrMalformedResponse) + } + r = r*10 + digit } return r, nil @@ -690,7 +698,6 @@ func buildStatsCommand(subCommand string) (*request, *response) { return req, resp } -//nolint:unused func buildRawCommand(rawCommand string, indicator responseEndIndicator, lines int) (*request, *response) { _, _, _ = rawCommand, indicator, lines panic("IMPLEMENT ME!!!") diff --git a/protocol_bin.go b/protocol_bin.go index 824cd39..d434a24 100644 --- a/protocol_bin.go +++ b/protocol_bin.go @@ -125,36 +125,44 @@ type binaryRequest struct { } func (br *binaryRequest) send(w io.Writer) error { - nKey := uint16(len(br.key)) - nValue := uint32(len(br.value)) - nExtras := uint8(len(br.extras)) + keyLen := uint64(len(br.key)) + valueLen := uint64(len(br.value)) + extrasLen := uint64(len(br.extras)) + bodyLen := keyLen + valueLen + extrasLen - buf := make([]byte, 24+uint32(nExtras)+uint32(nKey)+nValue) - - buf[0] = _binaryMagicReq - buf[1] = br.opcode - binary.BigEndian.PutUint16(buf[2:4], nKey) // key length - buf[4] = nExtras // extras length - buf[5] = _binaryDataTypeRawBytes // data type - binary.BigEndian.PutUint16(buf[6:8], uint16(0)) // vbucket id - binary.BigEndian.PutUint32(buf[8:12], uint32(nKey)+nValue+uint32(nExtras)) // total body length - binary.BigEndian.PutUint32(buf[12:16], br.opaque) // opaque - binary.BigEndian.PutUint64(buf[16:24], br.cas) // cas - - s := uint32(24) - if nExtras > 0 { - copy(buf[s:s+uint32(nExtras)], br.extras) - s += uint32(nExtras) + if keyLen > maxKeySize { + return ErrInvalidKey } - - if nKey > 0 { - copy(buf[s:s+uint32(nKey)], br.key) - s += uint32(nKey) + if valueLen > maxValueSize { + return ErrInvalidValue } - if nValue > 0 { - copy(buf[s:], br.value) + if extrasLen > 255 { + return fmt.Errorf("binary extras exceed uint8: %w", ErrInvalidBinaryProtocol) + } + if bodyLen > maxValueSize { + return fmt.Errorf("binary body exceeds uint32: %w", ErrInvalidBinaryProtocol) } + nKey := uint16(keyLen) + nExtras := uint8(extrasLen) + nBody := uint32(bodyLen) + + buf := make([]byte, 24) + + buf[0] = _binaryMagicReq + buf[1] = br.opcode + binary.BigEndian.PutUint16(buf[2:4], nKey) // key length + buf[4] = nExtras // extras length + buf[5] = _binaryDataTypeRawBytes // data type + binary.BigEndian.PutUint16(buf[6:8], uint16(0)) // vbucket id + binary.BigEndian.PutUint32(buf[8:12], nBody) // total body length + binary.BigEndian.PutUint32(buf[12:16], br.opaque) // opaque + binary.BigEndian.PutUint64(buf[16:24], br.cas) // cas + + buf = append(buf, br.extras...) + buf = append(buf, br.key...) + buf = append(buf, br.value...) + _, err := w.Write(buf) return err } diff --git a/protocol_builder.go b/protocol_builder.go index 949387e..106cb3b 100644 --- a/protocol_builder.go +++ b/protocol_builder.go @@ -10,7 +10,6 @@ import ( "strconv" "sync" "time" - "unsafe" ) const ( @@ -79,9 +78,16 @@ func forecastCommonFaultLine(line []byte) error { } const ( - // defaultBufferSize is the default size of the buffer. - // TODO: It is used to avoid the buffer growth, but is 64B the most common case? + // defaultBufferSize covers common short text/meta command headers and + // small payloads without growth. Measured lengths with the builder's + // spacing rules: get/delete ≈14-17B, small set ≈32B, meta set with + // several flags ≈42B. Larger storage values grow once; oversized + // buffers are discarded on release via maxPooledBufferCap. defaultBufferSize = 64 + + // maxPooledBufferCap prevents large storage values from permanently + // retaining oversized buffers in the pool. + maxPooledBufferCap = 4 << 10 // 4 KiB ) var ( @@ -127,8 +133,10 @@ func newProtocolBuilder() *protocolBuilder { func (b *protocolBuilder) release() { if b.buf != nil { - b.buf.Reset() - bufferPool.Put(b.buf) + if b.buf.Cap() <= maxPooledBufferCap { + b.buf.Reset() + bufferPool.Put(b.buf) + } b.buf = nil } @@ -188,7 +196,8 @@ func (b *protocolBuilder) AddFlagUint(flag string, tok uint64) *protocolBuilder return b } - b.buf.WriteString(flag + strconv.FormatUint(tok, 10)) + b.buf.WriteString(flag) + b.buf.WriteString(strconv.FormatUint(tok, 10)) b.buf.WriteByte(_SpaceByte) return b } @@ -198,7 +207,8 @@ func (b *protocolBuilder) AddFlagString(flag, tok string) *protocolBuilder { return b } - b.buf.WriteString(flag + tok) + b.buf.WriteString(flag) + b.buf.WriteString(tok) b.buf.WriteByte(_SpaceByte) return b } @@ -225,10 +235,6 @@ func trimCRLF(line []byte) []byte { return bytes.TrimSuffix(line, _CRLFBytes) } -func withCRLF(bs []byte) []byte { - return append(bs, _CRLFBytes...) -} - var requestPool = sync.Pool{ New: func() any { return &request{ @@ -548,16 +554,6 @@ func base64Encode(src []byte) []byte { return dst } -func base64Decode(src []byte) ([]byte, error) { - dst := make([]byte, base64.StdEncoding.DecodedLen(len(src))) - n, err := base64.StdEncoding.Decode(dst, src) - if err != nil { - return nil, err - } - - return dst[:n], nil -} - func releaseReqAndResp(req *request, resp *response) { if req != nil { req.release() @@ -568,10 +564,6 @@ func releaseReqAndResp(req *request, resp *response) { } } -func unsafeStringToByteSlice(s string) []byte { - return unsafe.Slice(unsafe.StringData(s), len(s)) -} - -func unsafeByteSliceToString(bs []byte) string { - return unsafe.String(unsafe.SliceData(bs), len(bs)) +func byteSliceToString(bs []byte) string { + return string(bs) } diff --git a/protocol_builder_test.go b/protocol_builder_test.go index 5b158a5..6b43faf 100644 --- a/protocol_builder_test.go +++ b/protocol_builder_test.go @@ -1,6 +1,7 @@ package memcached import ( + "bytes" "context" "testing" "time" @@ -16,6 +17,121 @@ func Test_protocolBuilder(t *testing.T) { AddString("value") expected := "set key 0 0 5\r\nvalue\r\n" assert.Equal(t, expected, string(builder.build())) + builder.release() +} + +func Test_protocolBuilder_releaseDiscardsOversizedBuffer(t *testing.T) { + large := bytes.NewBuffer(make([]byte, 0, maxPooledBufferCap+1024)) + _, _ = large.Write(make([]byte, maxPooledBufferCap+1)) + b := &protocolBuilder{buf: large} + b.release() + assert.Nil(t, b.buf) + + // Oversized buffers must not be reused via the pool. + for i := 0; i < 32; i++ { + pb := newProtocolBuilder() + capSize := pb.buf.Cap() + pb.release() + assert.LessOrEqual(t, capSize, maxPooledBufferCap) + } +} + +func Test_protocolBuilder_shortCommandsFitDefaultCap(t *testing.T) { + cases := []struct { + name string + build func() *protocolBuilder + }{ + { + name: "get", + build: func() *protocolBuilder { + return newProtocolBuilder().AddString("get").AddString("test_key").AddCRLF() + }, + }, + { + name: "delete", + build: func() *protocolBuilder { + return newProtocolBuilder().AddString("delete").AddString("test_key").AddCRLF() + }, + }, + { + name: "set small", + build: func() *protocolBuilder { + value := []byte("test_value") + return newProtocolBuilder(). + AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)). + AddCRLF(). + AddBytes(value) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pb := tc.build() + defer pb.release() + assert.LessOrEqual(t, pb.buf.Len(), defaultBufferSize) + assert.LessOrEqual(t, pb.buf.Cap(), maxPooledBufferCap) + }) + } +} + +func BenchmarkProtocolBuilder_GetShortKey(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + pb := newProtocolBuilder().AddString("get").AddString("test_key").AddCRLF() + _ = pb.build() + pb.release() + } +} + +func BenchmarkProtocolBuilder_DeleteShortKey(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + pb := newProtocolBuilder().AddString("delete").AddString("test_key").AddCRLF() + _ = pb.build() + pb.release() + } +} + +func BenchmarkProtocolBuilder_SetSmallValue(b *testing.B) { + b.ReportAllocs() + value := []byte("test_value") + for i := 0; i < b.N; i++ { + pb := newProtocolBuilder(). + AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)). + AddCRLF(). + AddBytes(value) + _ = pb.build() + pb.release() + } +} + +func BenchmarkProtocolBuilder_MetaSetManyFlags(b *testing.B) { + b.ReportAllocs() + value := []byte("test_value") + for i := 0; i < b.N; i++ { + pb := newProtocolBuilder(). + AddString("ms").AddBytes([]byte("test_key")). + AddString("c").AddString("C").AddString("F").AddUint(1). + AddString("T").AddUint(60).AddString("S").AddInt(len(value)). + AddCRLF(). + AddBytes(value) + _ = pb.build() + pb.release() + } +} + +func BenchmarkProtocolBuilder_SetLargeValue(b *testing.B) { + b.ReportAllocs() + value := make([]byte, 4096) + for i := 0; i < b.N; i++ { + pb := newProtocolBuilder(). + AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)). + AddCRLF(). + AddBytes(value) + _ = pb.build() + pb.release() + } } func Test_selectProximateDeadline(t *testing.T) { diff --git a/protocol_meta.go b/protocol_meta.go index 85b53a8..aae49b9 100644 --- a/protocol_meta.go +++ b/protocol_meta.go @@ -3,6 +3,7 @@ package memcached import ( "bytes" "fmt" + "math" "strconv" ) @@ -382,19 +383,26 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er } if !bytes.Equal(cd, []byte("VA")) { - parseFlags(parts, 1, item) - return nil + return parseFlags(parts, 1, item) } // VA handling - item.Size, _ = strconv.ParseUint(string(parts[dataLenIndex]), 10, 32) - parseFlags(parts, 2, item) + if len(parts) <= dataLenIndex { + return fmt.Errorf("missing value size: %w", ErrMalformedResponse) + } + size, err := strconv.ParseUint(string(parts[dataLenIndex]), 10, 64) + if err != nil { + return fmt.Errorf("invalid value size: %w", ErrMalformedResponse) + } + item.Size = size + if err := parseFlags(parts, 2, item); err != nil { + return err + } if len(lines) < 2 { return fmt.Errorf("missing value: %w", ErrMalformedResponse) } - var err error if item.Value, item.Flags, err = codec.Decode(item.Key, trimCRLF(lines[1]), item.Flags); err != nil { return fmt.Errorf("codec decode: %w", err) } @@ -406,38 +414,72 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er // .e.g: // // HD c26 kZm9v b O456 s3\r\n -func parseFlags(parts [][]byte, startPos int, item *MetaItem) { - parseUint := func(b []byte) uint64 { - v, _ := strconv.ParseUint(string(b), 10, 64) - return v +func parseFlags(parts [][]byte, startPos int, item *MetaItem) error { + parseUint := func(flag byte, b []byte) (uint64, error) { + v, err := strconv.ParseUint(string(b), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %c flag: %w", flag, ErrMalformedResponse) + } + return v, nil } - parseInt := func(b []byte) int64 { - v, _ := strconv.ParseInt(string(b), 10, 64) - return v + parseInt := func(flag byte, b []byte) (int64, error) { + v, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %c flag: %w", flag, ErrMalformedResponse) + } + return v, nil } for i := startPos; i < len(parts); i++ { - switch parts[i][0] { + if len(parts[i]) == 0 { + return fmt.Errorf("empty meta flag: %w", ErrMalformedResponse) + } + + flag := parts[i][0] + value := parts[i][1:] + var err error + switch flag { case 'c': - item.CAS = parseUint(parts[i][1:]) + item.CAS, err = parseUint(flag, value) case 'f': - item.Flags = uint32(parseUint(parts[i][1:])) + var flags uint64 + flags, err = parseUint(flag, value) + if err == nil { + if flags > math.MaxUint32 { + return fmt.Errorf("f flag exceeds uint32: %w", ErrMalformedResponse) + } + item.Flags = uint32(flags) + } case 't': - item.TTL = parseInt(parts[i][1:]) + item.TTL, err = parseInt(flag, value) case 'l': - item.LastAccessedTime = int64(parseUint(parts[i][1:])) + var lastAccessed uint64 + lastAccessed, err = parseUint(flag, value) + if err == nil { + if lastAccessed > math.MaxInt64 { + return fmt.Errorf("l flag exceeds int64: %w", ErrMalformedResponse) + } + item.LastAccessedTime = int64(lastAccessed) + } case 's': - item.Size = parseUint(parts[i][1:]) + item.Size, err = parseUint(flag, value) case 'O': - item.Opaque = parseUint(parts[i][1:]) + item.Opaque, err = parseUint(flag, value) case 'h': - item.HitBefore = parseUint(parts[i][1:]) == 1 + var hitBefore uint64 + hitBefore, err = parseUint(flag, value) + item.HitBefore = hitBefore == 1 // NO need to parse key again in client. // case 'k': // item.Key = string(parts[i][1:]) } + if err != nil { + return err + } } + + return nil } // MetaDeleteOption is used to set options for MetaDelete command. diff --git a/protocol_meta_test.go b/protocol_meta_test.go index a8ad869..c3ed289 100644 --- a/protocol_meta_test.go +++ b/protocol_meta_test.go @@ -62,7 +62,7 @@ func Test_parseFlags(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - parseFlags(tt.args.parts, tt.args.startPos, tt.args.item) + require.NoError(t, parseFlags(tt.args.parts, tt.args.startPos, tt.args.item)) assert.Equal(t, tt.want, tt.args.item) }) } diff --git a/protocol_test.go b/protocol_test.go index e04d788..5282650 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -223,6 +223,30 @@ func Test_parseUintFromBytes(t *testing.T) { want: 1234567890, wantErr: false, }, + { + name: "max-uint64", + args: args{ + raw: []byte("18446744073709551615"), + }, + want: ^uint64(0), + wantErr: false, + }, + { + name: "overflow-uint64", + args: args{ + raw: []byte("18446744073709551616"), + }, + want: 0, + wantErr: true, + }, + { + name: "overflow-long-number", + args: args{ + raw: []byte("9999999999999999999999999999999999999999"), + }, + want: 0, + wantErr: true, + }, { name: "malformed-contains-letters", args: args{ From 2d48d4aab9f701b555bbb7778fa6e61f3155be9a Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:12:32 +0200 Subject: [PATCH 4/9] refactor: remove raw command passthrough Delete the undocumented Raw text-command API and document that callers must use typed client methods. Co-authored-by: Cursor --- README.md | 3 +++ client.go | 2 -- client_commands.go | 23 ----------------------- protocol.go | 5 ----- 4 files changed, 3 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ba30e9c..c0a5f84 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,9 @@ The codec receives `key` as context, but can only return transformed `value` and ### Support Commands +The public API exposes typed text- and meta-protocol commands only. There is no +arbitrary raw command passthrough; callers should use the methods below. + Now, we have implemented some commands, and we will implement more commands in the future. | Command | Status | API Usage | Description | diff --git a/client.go b/client.go index 438aa3e..3569357 100644 --- a/client.go +++ b/client.go @@ -21,8 +21,6 @@ type Client interface { basicTextProtocolCommander metaTextProtocolCommander statisticsTextProtocolCommander - // TODO: support rawTextProtocolCommander - // rawTextProtocolCommander } var _ Client = (*client)(nil) diff --git a/client_commands.go b/client_commands.go index cbcb73f..58eaf89 100644 --- a/client_commands.go +++ b/client_commands.go @@ -127,13 +127,6 @@ type statisticsTextProtocolCommander interface { Stats(ctx context.Context) (*Statistic, error) } -type rawTextProtocolCommander interface { - // Raw is used to send the raw command to the memcached server. - // Warning: this command is not recommended to use since it expects the server to return - // the `END` line, which is not guaranteed of all commands. For example, `version`. - Raw(ctx context.Context, cmd string) ([]string, error) -} - /** * Storage commands: set, add, replace, append, prepend, cas */ @@ -630,19 +623,3 @@ func (c *client) Stats(ctx context.Context) (*Statistic, error) { return parseStats(resp.rawLines) } - -func (c *client) Raw(ctx context.Context, cmd string) ([]string, error) { - req, resp := buildRawCommand(cmd, endIndicatorSpecificEndLine, 0) - defer releaseReqAndResp(req, resp) - - if err := c.dispatchRequest(ctx, req, resp); err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - - lines := make([]string, 0, len(resp.rawLines)) - for _, line := range resp.rawLines { - lines = append(lines, string(bytes.TrimSuffix(line, _CRLFBytes))) - } - - return lines, nil -} diff --git a/protocol.go b/protocol.go index 161d21f..0c0a858 100644 --- a/protocol.go +++ b/protocol.go @@ -697,8 +697,3 @@ func buildStatsCommand(subCommand string) (*request, *response) { return req, resp } - -func buildRawCommand(rawCommand string, indicator responseEndIndicator, lines int) (*request, *response) { - _, _, _ = rawCommand, indicator, lines - panic("IMPLEMENT ME!!!") -} From 5c3ed4575fa022da18b362eccfcc0b12d0436ea2 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:14:45 +0200 Subject: [PATCH 5/9] fix(cli): harden context and history persistence Tighten config/history file permissions with OpenRoot, improve error propagation, and add persistence regression tests while keeping go-prompt. Co-authored-by: Cursor --- cmd/memcached-cli/commands.go | 102 +++++++++++++--------- cmd/memcached-cli/context_manager.go | 70 +++++++++++---- cmd/memcached-cli/context_manager_test.go | 42 +++++++++ cmd/memcached-cli/errors.go | 14 +++ cmd/memcached-cli/go.mod | 44 ++++++---- cmd/memcached-cli/go.sum | 74 ++++++++++------ cmd/memcached-cli/history.go | 37 +++++--- cmd/memcached-cli/history_test.go | 59 +++++++++++++ cmd/memcached-cli/main.go | 14 +-- cmd/memcached-cli/repl.go | 74 ++++++++++------ 10 files changed, 386 insertions(+), 144 deletions(-) create mode 100644 cmd/memcached-cli/context_manager_test.go create mode 100644 cmd/memcached-cli/errors.go create mode 100644 cmd/memcached-cli/history_test.go diff --git a/cmd/memcached-cli/commands.go b/cmd/memcached-cli/commands.go index 3735791..826ec39 100644 --- a/cmd/memcached-cli/commands.go +++ b/cmd/memcached-cli/commands.go @@ -1,11 +1,13 @@ package main import ( + "errors" "fmt" + "io" + "os" "strings" "time" - "github.com/pkg/errors" "github.com/samber/lo" "github.com/spf13/cobra" "github.com/yeqown/memcached" @@ -162,9 +164,9 @@ func newContextCurrentCommand() *cobra.Command { const ( magicFlags uint32 = 0x0705 - magicSeed = 0x2014 + magicSeed uint64 = 0x2014 - historyTimeFormat = "2006-01-02 15:04:05" + historyTimeFormat string = "2006-01-02 15:04:05" ) func newKVGetCommand() *cobra.Command { @@ -193,14 +195,12 @@ func newKVGetCommand() *cobra.Command { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - history.addRecord("get", args) + recordHistory(cmd, history, "get", args) - printMetaItem(item) - - return nil + return printMetaItem(item) }, } } @@ -224,10 +224,10 @@ func newKVSetCommand() *cobra.Command { err = client.Set(cmd.Context(), args[0], []byte(args[1]), magicFlags, expiration) if err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - history.addRecord("set", args) + recordHistory(cmd, history, "set", args) fmt.Printf("OK\n") return nil @@ -254,10 +254,10 @@ func newKVDeleteCommand() *cobra.Command { err = client.Delete(cmd.Context(), args[0]) if err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - history.addRecord("delete", args) + recordHistory(cmd, history, "delete", args) fmt.Printf("OK\n") return nil @@ -295,18 +295,16 @@ func newKVGetsCommand() *cobra.Command { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - fmt.Printf("Encounter an error while getting key '%s': %v\n", key, errors.Cause(err)) + fmt.Printf("Encounter an error while getting key '%s': %v\n", key, rootErr(err)) continue } items = append(items, item) } - history.addRecord("gets", args) - - printMetaItems(items) + recordHistory(cmd, history, "gets", args) - return nil + return printMetaItems(items) }, } } @@ -330,10 +328,10 @@ func newKVTouchCommand() *cobra.Command { } if err := client.Touch(cmd.Context(), args[0], expiration); err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - history.addRecord("touch", args) + recordHistory(cmd, history, "touch", args) fmt.Println("OK") return nil @@ -392,10 +390,10 @@ func newKVFlushAllCommand() *cobra.Command { imme: if err := client.FlushAll(cmd.Context()); err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - history.addRecord("flushall", args) + recordHistory(cmd, history, "flushall", args) fmt.Println("OK") return nil @@ -406,25 +404,45 @@ func newKVFlushAllCommand() *cobra.Command { return cmd } -func printMetaItems(items []*memcached.MetaItem) { +func printMetaItems(items []*memcached.MetaItem) error { + return writeMetaItems(os.Stdout, items) +} + +func printMetaItem(item *memcached.MetaItem) error { + return writeMetaItem(os.Stdout, item) +} + +func writeMetaItems(w io.Writer, items []*memcached.MetaItem) error { for idx, item := range items { - fmt.Printf(" ================= The [%d] item =================\n", idx) - printMetaItem(item) + if _, err := io.WriteString(w, fmt.Sprintf(" ================= The [%d] item =================\n", idx)); err != nil { + return err + } + if err := writeMetaItem(w, item); err != nil { + return err + } } + + return nil } -func printMetaItem(item *memcached.MetaItem) { +func writeMetaItem(w io.Writer, item *memcached.MetaItem) error { lastAccessAt := time.Now().Add(-time.Duration(item.LastAccessedTime) * time.Second) + output := fmt.Sprintf("Key: %s\n", item.Key) + + fmt.Sprintf("Flags: %d (0x%x)\n", item.Flags, item.Flags) + + fmt.Sprintf("CAS: %d (0x%x)\n", item.CAS, item.CAS) + + fmt.Sprintf("ClientFlags: %d (0x%x)\n", item.Flags, item.Flags) + + fmt.Sprintf("LastAccessedTime: %s (%s)\n", lastAccessAt.Format(time.RFC3339), formatSeconds(int(item.LastAccessedTime), "before", "never")) + + fmt.Sprintf("HitBefore: %s\n", map[bool]string{true: "✅", false: "❌"}[item.HitBefore]) + + fmt.Sprintf("TTL: %d (%s)\n", item.TTL, formatSeconds(int(item.TTL), "later", "never expires")) + + fmt.Sprintf("Value: %s\n\n", item.Value) + _, err := io.WriteString(w, output) + return err +} - fmt.Printf("Key: %s\n", item.Key) - fmt.Printf("Flags: %d (0x%x)\n", item.Flags, item.Flags) - fmt.Printf("CAS: %d (0x%x)\n", item.CAS, item.CAS) - fmt.Printf("ClientFlags: %d (0x%x)\n", item.Flags, item.Flags) - fmt.Printf("LastAccessedTime: %s (%s)\n", lastAccessAt.Format(time.RFC3339), formatSeconds(int(item.LastAccessedTime), "before", "never")) - fmt.Printf("HitBefore: %s\n", map[bool]string{true: "✅", false: "❌"}[item.HitBefore]) - fmt.Printf("TTL: %d (%s)\n", item.TTL, formatSeconds(int(item.TTL), "later", "never expires")) - fmt.Printf("Value: %s\n", item.Value) - fmt.Println() +func recordHistory(cmd *cobra.Command, history *kvCommandHistoryManager, command string, args []string) { + if err := history.addRecord(command, args); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to record %s command in history: %v\n", command, err) + } } func formatSeconds(seconds int, suffix, zeroString string) (readable string) { @@ -458,14 +476,14 @@ func formatSeconds(seconds int, suffix, zeroString string) (readable string) { return readable + " " + suffix } -func ignoreMemcachedError(err error) error { +func ignoreMemcachedError(w io.Writer, err error) error { if err == nil { return nil } logger.Debugf("ignoreMemcachedError handling err: %v", err) - var memErrs = []error{ + memErrs := []error{ memcached.ErrNonexistentCommand, memcached.ErrClientError, memcached.ErrServerError, @@ -486,8 +504,8 @@ func ignoreMemcachedError(err error) error { for _, memErr := range memErrs { if errors.Is(err, memErr) { - fmt.Printf("Memcached Error: %v\n", errors.Cause(err)) - return nil + _, writeErr := fmt.Fprintf(w, "Memcached Error: %v\n", rootErr(err)) + return writeErr } } @@ -510,7 +528,9 @@ func newHistoryEnableCommand() *cobra.Command { manager := getContextManager(cmd, false) manager.historyEnabled = true manager.historyMaxLines = int(historyMaxLines) - manager.save() + if err := manager.save(); err != nil { + return fmt.Errorf("save history settings: %w", err) + } fmt.Println("History enabled!") return nil }, @@ -530,7 +550,9 @@ func newHistoryDisableCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manager := getContextManager(cmd, false) manager.historyEnabled = false - manager.save() + if err := manager.save(); err != nil { + return fmt.Errorf("save history settings: %w", err) + } fmt.Println("History disabled!") return nil }, diff --git a/cmd/memcached-cli/context_manager.go b/cmd/memcached-cli/context_manager.go index a46214f..d8ba276 100644 --- a/cmd/memcached-cli/context_manager.go +++ b/cmd/memcached-cli/context_manager.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -16,6 +17,7 @@ type contextManager struct { mu sync.RWMutex contexts map[string]*Context current string + configDir string path string historyMaxLines int historyEnabled bool @@ -32,8 +34,8 @@ func newContextManager() (*contextManager, error) { return nil, err } - path := filepath.Join(home, ".memcached-cli", "config.json") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + configDir := filepath.Join(home, ".memcached-cli") + if err := os.MkdirAll(configDir, 0o700); err != nil { return nil, err } @@ -41,7 +43,8 @@ func newContextManager() (*contextManager, error) { mu: sync.RWMutex{}, contexts: make(map[string]*Context, 4), current: "", - path: path, + configDir: configDir, + path: "config.json", historyMaxLines: 10000, historyEnabled: true, currentClient: nil, @@ -55,19 +58,26 @@ func newContextManager() (*contextManager, error) { } func (m *contextManager) close() error { - m.save() - + saveErr := m.save() if m.currentClient != nil { - return m.currentClient.Close() + return errors.Join(saveErr, m.currentClient.Close()) } - return nil + return saveErr } // initialize loads CLI config file and initialize current client while // the current is not empty. -func (m *contextManager) initialize() error { - data, err := os.ReadFile(m.path) +func (m *contextManager) initialize() (retErr error) { + root, err := os.OpenRoot(m.configDir) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, root.Close()) + }() + + data, err := root.ReadFile(m.path) if err != nil { return err } @@ -84,6 +94,7 @@ func (m *contextManager) initialize() error { } m.mu.Lock() + defer m.mu.Unlock() m.contexts = stored.Contexts m.current = stored.Current m.historyMaxLines = stored.HistoryMaxLines @@ -102,15 +113,13 @@ func (m *contextManager) initialize() error { } } - m.mu.Unlock() - return nil } // save writes contexts to disk -func (m *contextManager) save() error { - m.mu.RLock() - defer m.mu.RUnlock() +func (m *contextManager) save() (retErr error) { + m.mu.Lock() + defer m.mu.Unlock() if m.resetCurrent != nil { m.current = m.resetCurrent() @@ -134,7 +143,18 @@ func (m *contextManager) save() error { return err } - return os.WriteFile(m.path, data, 0644) + root, err := os.OpenRoot(m.configDir) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, root.Close()) + }() + + if err := root.WriteFile(m.path, data, 0o600); err != nil { + return err + } + return root.Chmod(m.path, 0o600) } // addTemporaryContext creates a new temporary context for interactive use @@ -145,9 +165,8 @@ func (m *contextManager) save() error { // // The temporary context is used to store the current context name, so it can be // used to switch back to the original context when the CLI exits. -func (m *contextManager) addTemporaryContext(servers, hashStrategy string) { +func (m *contextManager) addTemporaryContext(servers, hashStrategy string) error { m.mu.Lock() - defer m.mu.Unlock() ctx := &Context{ Name: "temporary", // temporary context name @@ -157,11 +176,26 @@ func (m *contextManager) addTemporaryContext(servers, hashStrategy string) { LastUsed: time.Now(), } + originalCurrent := m.current + originalTemporary, hadTemporary := m.contexts[ctx.Name] + previousClient := m.currentClient + m.currentClient = nil m.contexts[ctx.Name] = ctx m.resetCurrent = func() string { - return m.current + if hadTemporary { + m.contexts[ctx.Name] = originalTemporary + } else { + delete(m.contexts, ctx.Name) + } + return originalCurrent } m.current = ctx.Name + m.mu.Unlock() + + if previousClient != nil { + return previousClient.Close() + } + return nil } // CreateContext creates a new context with the given name and configuration diff --git a/cmd/memcached-cli/context_manager_test.go b/cmd/memcached-cli/context_manager_test.go new file mode 100644 index 0000000..97f54fc --- /dev/null +++ b/cmd/memcached-cli/context_manager_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemporaryContextIsNotPersisted(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + manager := &contextManager{ + contexts: map[string]*Context{ + "saved": {Name: "saved", Servers: "saved.example:11211"}, + }, + current: "saved", + configDir: configDir, + path: "config.json", + historyMaxLines: 100, + historyEnabled: true, + } + + require.NoError(t, manager.addTemporaryContext("temporary.example:11211", "rendezvous")) + assert.Equal(t, "temporary", manager.current) + require.NoError(t, manager.save()) + + data, err := os.ReadFile(filepath.Join(configDir, "config.json")) + require.NoError(t, err) + var stored struct { + Current string `json:"current"` + Contexts map[string]*Context `json:"contexts"` + } + require.NoError(t, json.Unmarshal(data, &stored)) + assert.Equal(t, "saved", stored.Current) + assert.Contains(t, stored.Contexts, "saved") + assert.NotContains(t, stored.Contexts, "temporary") +} diff --git a/cmd/memcached-cli/errors.go b/cmd/memcached-cli/errors.go new file mode 100644 index 0000000..5cdae31 --- /dev/null +++ b/cmd/memcached-cli/errors.go @@ -0,0 +1,14 @@ +package main + +import "errors" + +// rootErr returns the innermost error in an unwrap chain. +func rootErr(err error) error { + for { + next := errors.Unwrap(err) + if next == nil { + return err + } + err = next + } +} diff --git a/cmd/memcached-cli/go.mod b/cmd/memcached-cli/go.mod index 608f5ab..669f1d6 100644 --- a/cmd/memcached-cli/go.mod +++ b/cmd/memcached-cli/go.mod @@ -1,29 +1,41 @@ module github.com/yeqown/memcached/cmd/memcached-cli -go 1.26.0 +go 1.26.5 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/c-bata/go-prompt v0.2.6 - github.com/pkg/errors v0.9.1 - github.com/samber/lo v1.49.1 - github.com/spf13/cobra v1.9.1 - github.com/yeqown/log v1.1.1 - github.com/yeqown/memcached v1.3.4 + github.com/samber/lo v1.53.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/yeqown/log v1.3.1 + github.com/yeqown/memcached v1.4.0 ) require ( - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/mattn/go-colorable v0.1.7 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mattn/go-tty v0.0.3 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pkg/term v1.2.0-beta.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.11.1 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.23.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/yeqown/memcached => ../.. diff --git a/cmd/memcached-cli/go.sum b/cmd/memcached-cli/go.sum index d924dca..00ff95b 100644 --- a/cmd/memcached-cli/go.sum +++ b/cmd/memcached-cli/go.sum @@ -2,55 +2,73 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= -github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yeqown/log v1.1.1 h1:DKw/ZeyJUTWPh4MDxYKXbBQeKHZJ69lndancmcQoERM= -github.com/yeqown/log v1.1.1/go.mod h1:RTslXFTg+8Uj5AizIxdfichvBZi/OOKao6yP3tMtTns= -github.com/yeqown/memcached v1.3.4 h1:v0/ZuojwP3u+3DtXXKjB/NInM8Ob/I3okh/6yQzFAFk= -github.com/yeqown/memcached v1.3.4/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4= +github.com/yeqown/log v1.3.1 h1:Omu/TAw/oMMW4aHBOOHrtEzYDxYE/T1TZVP1H/LqYNI= +github.com/yeqown/log v1.3.1/go.mod h1:RTslXFTg+8Uj5AizIxdfichvBZi/OOKao6yP3tMtTns= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -58,13 +76,13 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff h1:1CPUrky56AcgSpxz/KfgzQWzfG09u5YOL8MvPYBlrL8= golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/memcached-cli/history.go b/cmd/memcached-cli/history.go index be58db1..26f997e 100644 --- a/cmd/memcached-cli/history.go +++ b/cmd/memcached-cli/history.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -18,31 +19,42 @@ type kvCommandHistory struct { } type kvCommandHistoryManager struct { - path string maxSize int disabled bool file *os.File // 保持文件句柄打开 } +const maxHistoryRecordSize = 2 * 1024 * 1024 + func newHistoryManager(enabled bool, maxLines int) (*kvCommandHistoryManager, error) { home, err := os.UserHomeDir() if err != nil { return nil, err } - path := filepath.Join(home, ".memcached-cli", ".history") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + historyDir := filepath.Join(home, ".memcached-cli") + if err := os.MkdirAll(historyDir, 0o700); err != nil { return nil, err } - // 以追加模式打开文件 - file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) + root, err := os.OpenRoot(historyDir) if err != nil { return nil, err } + // 以追加模式打开文件 + file, err := root.OpenFile(".history", os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, errors.Join(err, root.Close()) + } + if err := root.Close(); err != nil { + return nil, errors.Join(err, file.Close()) + } + if err := file.Chmod(0o600); err != nil { + return nil, errors.Join(err, file.Close()) + } + hm := &kvCommandHistoryManager{ - path: path, maxSize: maxLines, disabled: !enabled, file: file, @@ -80,10 +92,10 @@ func (hm *kvCommandHistoryManager) addRecord(cmd string, args []string) error { return hm.file.Sync() } -func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit int) []kvCommandHistory { +func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit int) ([]kvCommandHistory, error) { if hm == nil || hm.disabled || hm.file == nil { fmt.Println("History is not available.") - return []kvCommandHistory{} + return []kvCommandHistory{}, nil } now := time.Now() @@ -103,10 +115,11 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in var results []kvCommandHistory scanner := bufio.NewScanner(hm.file) + scanner.Buffer(make([]byte, 64*1024), maxHistoryRecordSize) // 重置文件指针到开始位置 if _, err := hm.file.Seek(0, 0); err != nil { - return nil + return nil, err } logger.Debugf("searching history, keyword=%s, since=%s(%d), until=%s(%d), limit=%d", keyword, since, startTime, until, endTime, limit) @@ -157,6 +170,10 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in results = append(results, history) } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan history: %w", err) + } + // 按时间戳倒序排序 sort.Slice(results, func(i, j int) bool { return results[i].Timestamp > results[j].Timestamp @@ -167,5 +184,5 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in results = results[:limit] } - return results + return results, nil } diff --git a/cmd/memcached-cli/history_test.go b/cmd/memcached-cli/history_test.go new file mode 100644 index 0000000..dda7533 --- /dev/null +++ b/cmd/memcached-cli/history_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHistorySearchSupportsRecordsLargerThanScannerDefault(t *testing.T) { + t.Parallel() + + file, err := os.CreateTemp(t.TempDir(), "history-*") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, file.Close()) }) + + value := strings.Repeat("x", 128*1024) + _, err = file.WriteString("1 set key " + value + "\n") + require.NoError(t, err) + + manager := &kvCommandHistoryManager{file: file} + results, err := manager.search("", "", "", 10) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "set", results[0].Command) + assert.Equal(t, "key "+value, results[0].Args) +} + +func TestHistorySearchReportsReadErrors(t *testing.T) { + t.Parallel() + + file, err := os.CreateTemp(t.TempDir(), "history-*") + require.NoError(t, err) + require.NoError(t, file.Close()) + + manager := &kvCommandHistoryManager{file: file} + _, err = manager.search("", "", "", 10) + assert.Error(t, err) +} + +func TestRecordHistoryWarnsWithoutFailingCommand(t *testing.T) { + t.Parallel() + + file, err := os.CreateTemp(t.TempDir(), "history-*") + require.NoError(t, err) + require.NoError(t, file.Close()) + + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + recordHistory(cmd, &kvCommandHistoryManager{file: file}, "set", []string{"key", "value"}) + + assert.Contains(t, stderr.String(), "Warning: failed to record set command in history") +} diff --git a/cmd/memcached-cli/main.go b/cmd/memcached-cli/main.go index ac570ca..22b80e6 100644 --- a/cmd/memcached-cli/main.go +++ b/cmd/memcached-cli/main.go @@ -9,7 +9,6 @@ import ( "github.com/MakeNowJust/heredoc" prompt "github.com/c-bata/go-prompt" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/yeqown/log" ) @@ -85,13 +84,15 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { manager, err := newContextManager() if err != nil { - return errors.Wrap(err, "failed to create context manager") + return fmt.Errorf("failed to create context manager: %w", err) } // if servers are not empty, create a temporary context if servers = strings.TrimSpace(servers); servers != "" { logger.Debugf("adding servers: %v to temporary context as 'temporary'", servers) - manager.addTemporaryContext(servers, hashStrategy) + if err := manager.addTemporaryContext(servers, hashStrategy); err != nil { + return fmt.Errorf("failed to switch to temporary context: %w", err) + } } contexts := manager.listContexts() @@ -105,7 +106,7 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { current, err := manager.getCurrentContext() if err != nil { - return errors.Wrap(err, "failed to get current context") + return fmt.Errorf("failed to get current context: %w", err) } if len(contexts) > 0 && current == nil { @@ -256,7 +257,10 @@ func newHistoryCommand() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { history := getContextManager(cmd, false).getHistoryManager() - results := history.search(keyword, since, until, limit) + results, err := history.search(keyword, since, until, limit) + if err != nil { + return fmt.Errorf("search history: %w", err) + } if len(results) == 0 { fmt.Println("No history found.") diff --git a/cmd/memcached-cli/repl.go b/cmd/memcached-cli/repl.go index f74891e..b675f75 100644 --- a/cmd/memcached-cli/repl.go +++ b/cmd/memcached-cli/repl.go @@ -9,7 +9,6 @@ import ( "time" "github.com/c-bata/go-prompt" - "github.com/pkg/errors" "github.com/yeqown/memcached" ) @@ -110,13 +109,8 @@ func (r *replCommander) commandExecutor(line string) { } } -func (r *replCommander) getMemcachedClient() memcached.Client { - client, err := r.cm.getCurrentClient() - if err != nil { - panic(err) - } - - return client +func (r *replCommander) getMemcachedClient() (memcached.Client, error) { + return r.cm.getCurrentClient() } /** @@ -161,7 +155,11 @@ func (r *replCommander) handleGet(ctx context.Context, args []string) error { return fmt.Errorf("usage: get ") } - item, err := r.getMemcachedClient().MetaGet( + client, err := r.getMemcachedClient() + if err != nil { + return err + } + item, err := client.MetaGet( ctx, []byte(args[1]), memcached.MetaGetFlagReturnTTL(), @@ -174,10 +172,9 @@ func (r *replCommander) handleGet(ctx context.Context, args []string) error { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } - printMetaItem(item) - return nil + return printMetaItem(item) } func (r *replCommander) handleSet(ctx context.Context, args []string) error { @@ -192,8 +189,12 @@ func (r *replCommander) handleSet(ctx context.Context, args []string) error { } } - if err := r.getMemcachedClient().Set(ctx, args[1], []byte(args[2]), magicFlags, expiration); err != nil { - return ignoreMemcachedError(err) + client, err := r.getMemcachedClient() + if err != nil { + return err + } + if err := client.Set(ctx, args[1], []byte(args[2]), magicFlags, expiration); err != nil { + return ignoreMemcachedError(os.Stdout, err) } fmt.Println("OK") return nil @@ -204,8 +205,12 @@ func (r *replCommander) handleDelete(ctx context.Context, args []string) error { return fmt.Errorf("usage: delete ") } - if err := r.getMemcachedClient().Delete(ctx, args[1]); err != nil { - return ignoreMemcachedError(err) + client, err := r.getMemcachedClient() + if err != nil { + return err + } + if err := client.Delete(ctx, args[1]); err != nil { + return ignoreMemcachedError(os.Stdout, err) } fmt.Println("OK") return nil @@ -222,9 +227,13 @@ func (r *replCommander) handleIncr(ctx context.Context, args []string) error { delta = d } } - newValue, err := r.getMemcachedClient().Incr(ctx, args[1], delta) + client, err := r.getMemcachedClient() + if err != nil { + return err + } + newValue, err := client.Incr(ctx, args[1], delta) if err != nil { - return ignoreMemcachedError(err) + return ignoreMemcachedError(os.Stdout, err) } fmt.Printf("%d\n", newValue) return nil @@ -241,9 +250,13 @@ func (r *replCommander) handleDecr(ctx context.Context, args []string) error { delta = d } } - newValue, err := r.getMemcachedClient().Decr(ctx, args[1], delta) + client, err := r.getMemcachedClient() if err != nil { - return ignoreMemcachedError(err) + return err + } + newValue, err := client.Decr(ctx, args[1], delta) + if err != nil { + return ignoreMemcachedError(os.Stdout, err) } fmt.Printf("%d\n", newValue) return nil @@ -258,8 +271,12 @@ func (r *replCommander) handleTouch(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("invalid expiration format: %v", err) } - if err := r.getMemcachedClient().Touch(ctx, args[1], time.Duration(expiration)*time.Second); err != nil { - return ignoreMemcachedError(err) + client, err := r.getMemcachedClient() + if err != nil { + return err + } + if err := client.Touch(ctx, args[1], time.Duration(expiration)*time.Second); err != nil { + return ignoreMemcachedError(os.Stdout, err) } fmt.Println("OK") return nil @@ -273,9 +290,14 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error { keys := make([]string, len(args)-1) copy(keys, args[1:]) + client, err := r.getMemcachedClient() + if err != nil { + return err + } + items := make([]*memcached.MetaItem, 0, len(keys)) for _, key := range keys { - item, err := r.getMemcachedClient().MetaGet( + item, err := client.MetaGet( ctx, []byte(key), memcached.MetaGetFlagReturnTTL(), @@ -288,16 +310,14 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - fmt.Printf("Encounter error while getting key '%s': %v\n", key, errors.Cause(err)) + fmt.Printf("Encounter error while getting key '%s': %v\n", key, rootErr(err)) continue } items = append(items, item) } - printMetaItems(items) - - return nil + return printMetaItems(items) } /** From c5b43900e67991af44c0c4ba973763174007af71 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:15:03 +0200 Subject: [PATCH 6/9] feat(cli): replace go-prompt REPL with Bubble Tea Migrate the interactive CLI to a Bubble Tea TUI with captured output, history navigation, and clean shutdown without os.Exit. Co-authored-by: Cursor --- cmd/memcached-cli/go.mod | 30 +++-- cmd/memcached-cli/go.sum | 76 +++++++----- cmd/memcached-cli/main.go | 33 +++-- cmd/memcached-cli/repl.go | 195 ++++++++++++++++------------- cmd/memcached-cli/repl_test.go | 220 +++++++++++++++++++++++++++++++++ cmd/memcached-cli/repl_tui.go | 175 ++++++++++++++++++++++++++ 6 files changed, 586 insertions(+), 143 deletions(-) create mode 100644 cmd/memcached-cli/repl_test.go create mode 100644 cmd/memcached-cli/repl_tui.go diff --git a/cmd/memcached-cli/go.mod b/cmd/memcached-cli/go.mod index 669f1d6..1f7fa9a 100644 --- a/cmd/memcached-cli/go.mod +++ b/cmd/memcached-cli/go.mod @@ -3,8 +3,9 @@ module github.com/yeqown/memcached/cmd/memcached-cli go 1.26.5 require ( + charm.land/bubbles/v2 v2.1.1 + charm.land/bubbletea/v2 v2.0.8 github.com/MakeNowJust/heredoc v1.0.0 - github.com/c-bata/go-prompt v0.2.6 github.com/samber/lo v1.53.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -13,28 +14,39 @@ require ( ) require ( + charm.land/lipgloss/v2 v2.0.4 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.19.2 // indirect - github.com/mattn/go-colorable v0.1.7 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect - github.com/mattn/go-tty v0.0.3 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkg/term v1.2.0-beta.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect - golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cmd/memcached-cli/go.sum b/cmd/memcached-cli/go.sum index 00ff95b..aec5b83 100644 --- a/cmd/memcached-cli/go.sum +++ b/cmd/memcached-cli/go.sum @@ -1,9 +1,35 @@ +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= -github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -25,26 +51,20 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= -github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -52,12 +72,15 @@ github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yeqown/log v1.3.1 h1:Omu/TAw/oMMW4aHBOOHrtEzYDxYE/T1TZVP1H/LqYNI= github.com/yeqown/log v1.3.1/go.mod h1:RTslXFTg+8Uj5AizIxdfichvBZi/OOKao6yP3tMtTns= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -69,17 +92,14 @@ go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff h1:1CPUrky56AcgSpxz/KfgzQWzfG09u5YOL8MvPYBlrL8= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/cmd/memcached-cli/main.go b/cmd/memcached-cli/main.go index 22b80e6..93c1ecd 100644 --- a/cmd/memcached-cli/main.go +++ b/cmd/memcached-cli/main.go @@ -2,13 +2,13 @@ package main import ( "context" + "errors" "fmt" "os" "strings" "time" "github.com/MakeNowJust/heredoc" - prompt "github.com/c-bata/go-prompt" "github.com/spf13/cobra" "github.com/yeqown/log" ) @@ -17,9 +17,7 @@ const ( version = "v1.3.1" ) -var ( - logger = newLogger() -) +var logger = newLogger() func main() { var ( @@ -72,7 +70,7 @@ func main() { } } -func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { +func runAsREPL(timeout time.Duration, servers, hashStrategy string) (retErr error) { fmt.Println(heredoc.Doc(` Welcome to memcached-cli Type 'help' to see available commands @@ -86,6 +84,13 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { if err != nil { return fmt.Errorf("failed to create context manager: %w", err) } + defer func() { + var historyErr error + if history := manager.getHistoryManager(); history != nil { + historyErr = history.close() + } + retErr = errors.Join(retErr, historyErr, manager.close()) + }() // if servers are not empty, create a temporary context if servers = strings.TrimSpace(servers); servers != "" { @@ -104,9 +109,9 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { `)) } - current, err := manager.getCurrentContext() - if err != nil { - return fmt.Errorf("failed to get current context: %w", err) + var current *Context + if len(contexts) > 0 { + current, _ = manager.getCurrentContext() } if len(contexts) > 0 && current == nil { @@ -128,16 +133,7 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error { return err } - p := prompt.New( - repl.commandExecutor, - repl.commandCompleter, - prompt.OptionTitle("memcached-cli"), - prompt.OptionPrefix(">>> "), - prompt.OptionInputTextColor(prompt.Yellow), - ) - - p.Run() - return nil + return runREPLTUI(repl) } func newVersionCommand() *cobra.Command { @@ -181,7 +177,6 @@ func newContextCommand() *cobra.Command { } func newKVCommand() *cobra.Command { - var contextName string cmd := &cobra.Command{ diff --git a/cmd/memcached-cli/repl.go b/cmd/memcached-cli/repl.go index b675f75..ac2c98d 100644 --- a/cmd/memcached-cli/repl.go +++ b/cmd/memcached-cli/repl.go @@ -1,20 +1,21 @@ package main import ( + "bytes" "context" "fmt" - "os" + "io" "strconv" "strings" "time" - "github.com/c-bata/go-prompt" "github.com/yeqown/memcached" ) type replCommander struct { cm *contextManager timeout time.Duration + out io.Writer } func newREPLCommander(manager *contextManager, timeout time.Duration) (*replCommander, error) { @@ -25,45 +26,53 @@ func newREPLCommander(manager *contextManager, timeout time.Duration) (*replComm return &replCommander{ cm: manager, timeout: timeout, + out: io.Discard, }, nil } -func (r *replCommander) commandCompleter(d prompt.Document) []prompt.Suggest { - suggestions := []prompt.Suggest{ - // context operations - {Text: "use", Description: "Switch to a different context"}, - {Text: "list", Description: "List all contexts"}, - {Text: "current", Description: "Show current context"}, - // key-value operations - {Text: "get", Description: "Get value by key"}, - {Text: "gets", Description: "Get multiple values by keys"}, - {Text: "set", Description: "Set key to value"}, - {Text: "delete", Description: "Delete key"}, - {Text: "incr", Description: "Increment value"}, - {Text: "decr", Description: "Decrement value"}, - {Text: "touch", Description: "Update expiration time"}, - // other - {Text: "version", Description: "Show version information"}, - {Text: "help", Description: "Show help message"}, - {Text: "exit", Description: "Exit the program"}, - {Text: "quit", Description: "Exit the program"}, - } - - sub := d.GetWordBeforeCursor() - - if sub == "" { - return []prompt.Suggest{} - } - - return prompt.FilterHasPrefix(suggestions, sub, true) +var replCommands = []string{ + "use", "list", "current", + "get", "gets", "set", "delete", "incr", "decr", "touch", + "version", "help", "exit", "quit", } -func (r *replCommander) commandExecutor(line string) { +// completeREPLCommands returns command suggestions matching the first-token prefix. +func completeREPLCommands(line string) []string { + fields := strings.Fields(line) + if len(fields) == 0 || strings.HasSuffix(line, " ") { + return nil + } + if len(fields) != 1 { + return nil + } + + prefix := strings.ToLower(fields[0]) + matches := make([]string, 0, len(replCommands)) + for _, cmd := range replCommands { + if strings.HasPrefix(cmd, prefix) { + matches = append(matches, cmd) + } + } + return matches +} + +type commandResult struct { + output string + quit bool +} + +// executeCommand runs a REPL command and captures its output. +func (r *replCommander) executeCommand(line string) commandResult { line = strings.TrimSpace(line) if line == "" { - return + return commandResult{} } + var buf bytes.Buffer + prev := r.out + r.out = &buf + defer func() { r.out = prev }() + args := strings.Fields(line) cmd := args[0] @@ -71,6 +80,7 @@ func (r *replCommander) commandExecutor(line string) { ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() + quit := false switch cmd { case "use": err = r.handleUse(ctx, args) @@ -78,7 +88,6 @@ func (r *replCommander) commandExecutor(line string) { err = r.handleList(ctx) case "current": err = r.handleCurrent(ctx) - case "get": err = r.handleGet(ctx, args) case "gets": @@ -93,19 +102,29 @@ func (r *replCommander) commandExecutor(line string) { err = r.handleDecr(ctx, args) case "touch": err = r.handleTouch(ctx, args) - case "version": err = r.handleVersion(ctx) case "help": - r.handleHelp() + err = r.handleHelp() case "exit", "quit": - r.handleExit() + err = r.handleExit() + quit = true default: - fmt.Printf("Unknown command: %s\n", cmd) + err = writeOutput(r.out, "Unknown command: %s\n", cmd) } if err != nil { - fmt.Printf("Execution `%s` failed: %v\n", cmd, err) + if writeErr := writeOutput(r.out, "Execution `%s` failed: %v\n", cmd, err); writeErr != nil { + return commandResult{ + output: fmt.Sprintf("Execution `%s` failed: %v (writing output failed: %v)\n", cmd, err, writeErr), + quit: quit, + } + } + } + + return commandResult{ + output: buf.String(), + quit: quit, } } @@ -117,7 +136,7 @@ func (r *replCommander) getMemcachedClient() (memcached.Client, error) { * context operations */ -func (r *replCommander) handleUse(ctx context.Context, args []string) error { +func (r *replCommander) handleUse(_ context.Context, args []string) error { if len(args) != 2 { return fmt.Errorf("usage: use ") } @@ -128,9 +147,13 @@ func (r *replCommander) handleList(_ context.Context) error { ctx, _ := r.cm.getCurrentContext() for _, name := range r.cm.listContexts() { if ctx != nil && ctx.Name == name { - fmt.Printf("* %s\n", name) + if err := writeOutput(r.out, "* %s\n", name); err != nil { + return err + } } else { - fmt.Printf(" %s\n", name) + if err := writeOutput(r.out, " %s\n", name); err != nil { + return err + } } } return nil @@ -141,9 +164,7 @@ func (r *replCommander) handleCurrent(_ context.Context) error { if err != nil { return err } - fmt.Printf("Current context: %s\n", ctx.Name) - fmt.Printf("Servers: %s\n", ctx.Servers) - return nil + return writeOutput(r.out, "Current context: %s\nServers: %s\n", ctx.Name, ctx.Servers) } /** @@ -172,9 +193,9 @@ func (r *replCommander) handleGet(ctx context.Context, args []string) error { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - return printMetaItem(item) + return writeMetaItem(r.out, item) } func (r *replCommander) handleSet(ctx context.Context, args []string) error { @@ -194,10 +215,10 @@ func (r *replCommander) handleSet(ctx context.Context, args []string) error { return err } if err := client.Set(ctx, args[1], []byte(args[2]), magicFlags, expiration); err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - fmt.Println("OK") - return nil + _, err = fmt.Fprintln(r.out, "OK") + return err } func (r *replCommander) handleDelete(ctx context.Context, args []string) error { @@ -210,10 +231,10 @@ func (r *replCommander) handleDelete(ctx context.Context, args []string) error { return err } if err := client.Delete(ctx, args[1]); err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - fmt.Println("OK") - return nil + _, err = fmt.Fprintln(r.out, "OK") + return err } func (r *replCommander) handleIncr(ctx context.Context, args []string) error { @@ -233,10 +254,9 @@ func (r *replCommander) handleIncr(ctx context.Context, args []string) error { } newValue, err := client.Incr(ctx, args[1], delta) if err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - fmt.Printf("%d\n", newValue) - return nil + return writeOutput(r.out, "%d\n", newValue) } func (r *replCommander) handleDecr(ctx context.Context, args []string) error { @@ -256,10 +276,9 @@ func (r *replCommander) handleDecr(ctx context.Context, args []string) error { } newValue, err := client.Decr(ctx, args[1], delta) if err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - fmt.Printf("%d\n", newValue) - return nil + return writeOutput(r.out, "%d\n", newValue) } func (r *replCommander) handleTouch(ctx context.Context, args []string) error { @@ -276,26 +295,24 @@ func (r *replCommander) handleTouch(ctx context.Context, args []string) error { return err } if err := client.Touch(ctx, args[1], time.Duration(expiration)*time.Second); err != nil { - return ignoreMemcachedError(os.Stdout, err) + return ignoreMemcachedError(r.out, err) } - fmt.Println("OK") - return nil + return writeOutput(r.out, "OK\n") } func (r *replCommander) handleMGet(ctx context.Context, args []string) error { if len(args) < 2 { - return fmt.Errorf("usage: mget [key2 ...]") + return fmt.Errorf("usage: gets [key2 ...]") } keys := make([]string, len(args)-1) copy(keys, args[1:]) + items := make([]*memcached.MetaItem, 0, len(keys)) client, err := r.getMemcachedClient() if err != nil { return err } - - items := make([]*memcached.MetaItem, 0, len(keys)) for _, key := range keys { item, err := client.MetaGet( ctx, @@ -310,14 +327,16 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error { memcached.MetaGetFlagReturnHitBefore(), ) if err != nil { - fmt.Printf("Encounter error while getting key '%s': %v\n", key, rootErr(err)) + if err := writeOutput(r.out, "Encounter error while getting key '%s': %v\n", key, rootErr(err)); err != nil { + return err + } continue } items = append(items, item) } - return printMetaItems(items) + return writeMetaItems(r.out, items) } /** @@ -325,29 +344,31 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error { */ func (r *replCommander) handleVersion(_ context.Context) error { - fmt.Printf("Version: %s\n", version) - return nil + return writeOutput(r.out, "Version: %s\n", version) +} + +func (r *replCommander) handleHelp() error { + return writeOutput(r.out, `Available commands: + use Switch to a different context + list List all contexts + current Show current context + get Get value by key + gets Get multiple values by keys + set Set key to value + delete Delete key + incr [delta] Increment value + decr [delta] Decrement value + touch Update expiration time + help Show this help message + exit, quit Exit the program +`) } -func (r *replCommander) handleHelp() { - fmt.Println("Available commands:") - fmt.Println(" use Switch to a different context") - fmt.Println(" list List all contexts") - fmt.Println(" current Show current context") - - fmt.Println(" get Get value by key") - fmt.Println(" gets Get multiple values by keys") - fmt.Println(" set Set key to value") - fmt.Println(" delete Delete key") - fmt.Println(" incr [delta] Increment value") - fmt.Println(" decr [delta] Decrement value") - fmt.Println(" touch Update expiration time") - - fmt.Println(" help Show this help message") - fmt.Println(" exit, quit Exit the program") +func (r *replCommander) handleExit() error { + return writeOutput(r.out, "Bye!\n") } -func (r *replCommander) handleExit() { - fmt.Println("Bye!") - os.Exit(0) +func writeOutput(w io.Writer, format string, args ...any) error { + _, err := fmt.Fprintf(w, format, args...) + return err } diff --git a/cmd/memcached-cli/repl_test.go b/cmd/memcached-cli/repl_test.go new file mode 100644 index 0000000..c836290 --- /dev/null +++ b/cmd/memcached-cli/repl_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "fmt" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yeqown/memcached" +) + +func TestCompleteREPLCommands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + want []string + }{ + {name: "empty", line: "", want: nil}, + {name: "whitespace", line: " ", want: nil}, + {name: "known prefix", line: "ge", want: []string{"get", "gets"}}, + {name: "exact command", line: "help", want: []string{"help"}}, + {name: "unknown prefix", line: "zzz", want: []string{}}, + {name: "after first token", line: "get foo", want: nil}, + {name: "trailing space", line: "get ", want: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := completeREPLCommands(tt.line) + if tt.want == nil { + assert.Nil(t, got) + return + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExecuteCommandHelpAndQuit(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + + help := repl.executeCommand("help") + assert.False(t, help.quit) + assert.Contains(t, help.output, "Available commands:") + assert.Contains(t, help.output, "exit, quit") + + quit := repl.executeCommand("exit") + assert.True(t, quit.quit) + assert.Contains(t, quit.output, "Bye!") +} + +func TestExecuteCommandEmptyAndUnknown(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + + empty := repl.executeCommand(" ") + assert.False(t, empty.quit) + assert.Empty(t, empty.output) + + unknown := repl.executeCommand("nope") + assert.False(t, unknown.quit) + assert.Contains(t, unknown.output, "Unknown command: nope") +} + +func TestExecuteCommandWithoutContextReturnsError(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(&contextManager{contexts: map[string]*Context{}}, 0) + require.NoError(t, err) + + result := repl.executeCommand("get missing") + assert.False(t, result.quit) + assert.Contains(t, result.output, "Execution `get` failed: no context selected") +} + +func TestIgnoreMemcachedErrorWritesToProvidedWriter(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + err := ignoreMemcachedError(&output, fmt.Errorf("request failed: %w", memcached.ErrNotFound)) + + require.NoError(t, err) + assert.Contains(t, output.String(), "Memcached Error:") + assert.Contains(t, output.String(), memcached.ErrNotFound.Error()) +} + +func TestREPLModelEnterRunsCommand(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + m.input.SetValue("help") + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd) + updated := next.(replModel) + assert.True(t, updated.busy) + + msg := cmd() + result, ok := msg.(commandResultMsg) + require.True(t, ok) + assert.Contains(t, result.output, "Available commands:") + assert.False(t, result.quit) + + next, cmd = updated.Update(result) + updated = next.(replModel) + assert.False(t, updated.busy) + assert.Empty(t, updated.input.Value()) + require.NotNil(t, cmd) +} + +func TestREPLModelHistoryNavigation(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + m.appendHistory("help") + m.appendHistory("version") + m.input.SetValue("draft") + + m.historyUp() + assert.Equal(t, "version", m.input.Value()) + m.historyUp() + assert.Equal(t, "help", m.input.Value()) + m.historyDown() + assert.Equal(t, "version", m.input.Value()) + m.historyDown() + assert.Equal(t, "draft", m.input.Value()) + assert.Equal(t, -1, m.histIdx) +} + +func TestREPLModelCtrlCQuits(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + + _, cmd := m.Update(tea.KeyPressMsg{ + Code: 'c', + Mod: tea.ModCtrl, + }) + require.NotNil(t, cmd) +} + +func TestREPLModelCtrlDQuitsWhenEmpty(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + + _, cmd := m.Update(tea.KeyPressMsg{ + Code: 'd', + Mod: tea.ModCtrl, + }) + require.NotNil(t, cmd) +} + +func TestREPLModelBusyIgnoresTyping(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + m.busy = true + m.input.SetValue("help") + + next, cmd := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + updated := next.(replModel) + assert.True(t, updated.busy) + assert.Equal(t, "help", updated.input.Value()) + assert.Nil(t, cmd) +} + +func TestREPLModelExitCommandQuits(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + m.input.SetValue("quit") + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + updated := next.(replModel) + require.NotNil(t, cmd) + assert.True(t, updated.busy) + + result := cmd().(commandResultMsg) + assert.True(t, result.quit) + assert.Contains(t, result.output, "Bye!") + + _, cmd = updated.Update(result) + require.NotNil(t, cmd) +} + +func TestREPLModelViewBusy(t *testing.T) { + t.Parallel() + + repl, err := newREPLCommander(nil, 0) + require.NoError(t, err) + m := newREPLModel(repl) + m.busy = true + + view := m.View() + assert.Contains(t, view.Content, "running") + assert.False(t, view.AltScreen) +} diff --git a/cmd/memcached-cli/repl_tui.go b/cmd/memcached-cli/repl_tui.go new file mode 100644 index 0000000..65596da --- /dev/null +++ b/cmd/memcached-cli/repl_tui.go @@ -0,0 +1,175 @@ +package main + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +type commandResultMsg commandResult + +type replModel struct { + repl *replCommander + input textinput.Model + busy bool + history []string + histIdx int // -1 means browsing is inactive + draft string + width int +} + +func newREPLModel(repl *replCommander) replModel { + ti := textinput.New() + ti.Prompt = ">>> " + ti.ShowSuggestions = true + ti.SetSuggestions(replCommands) + ti.SetVirtualCursor(false) + ti.Focus() + ti.CharLimit = 0 + ti.SetWidth(80) + + return replModel{ + repl: repl, + input: ti, + histIdx: -1, + width: 80, + } +} + +func runREPLTUI(repl *replCommander) error { + p := tea.NewProgram(newREPLModel(repl)) + _, err := p.Run() + return err +} + +func (m replModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + if m.width > 4 { + m.input.SetWidth(m.width - 4) + } + return m, nil + + case commandResultMsg: + m.busy = false + m.input.SetValue("") + m.input.Focus() + cmds := make([]tea.Cmd, 0, 2) + if msg.output != "" { + cmds = append(cmds, tea.Println(strings.TrimRight(msg.output, "\n"))) + } + if msg.quit { + cmds = append(cmds, tea.Quit) + return m, tea.Batch(cmds...) + } + return m, tea.Batch(cmds...) + + case tea.KeyPressMsg: + if m.busy { + if msg.String() == "ctrl+c" { + return m, tea.Batch(tea.Println("Bye!"), tea.Quit) + } + return m, nil + } + + switch msg.String() { + case "ctrl+c": + return m, tea.Batch(tea.Println("Bye!"), tea.Quit) + case "ctrl+d": + if m.input.Value() == "" { + return m, tea.Batch(tea.Println("Bye!"), tea.Quit) + } + case "enter": + line := strings.TrimSpace(m.input.Value()) + if line == "" { + return m, nil + } + m.appendHistory(line) + m.busy = true + m.input.Blur() + return m, m.runCommand(line) + case "up": + m.historyUp() + return m, nil + case "down": + m.historyDown() + return m, nil + } + } + + if m.busy { + return m, nil + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m replModel) View() tea.View { + content := m.input.View() + if m.busy { + content = "… running" + } + + v := tea.NewView(content) + if !m.busy { + if c := m.input.Cursor(); c != nil { + v.Cursor = c + } + } + return v +} + +func (m *replModel) appendHistory(line string) { + if n := len(m.history); n > 0 && m.history[n-1] == line { + m.histIdx = -1 + m.draft = "" + return + } + m.history = append(m.history, line) + m.histIdx = -1 + m.draft = "" +} + +func (m *replModel) historyUp() { + if len(m.history) == 0 { + return + } + if m.histIdx == -1 { + m.draft = m.input.Value() + m.histIdx = len(m.history) - 1 + } else if m.histIdx > 0 { + m.histIdx-- + } + m.input.SetValue(m.history[m.histIdx]) + m.input.CursorEnd() +} + +func (m *replModel) historyDown() { + if m.histIdx == -1 { + return + } + if m.histIdx < len(m.history)-1 { + m.histIdx++ + m.input.SetValue(m.history[m.histIdx]) + m.input.CursorEnd() + return + } + m.histIdx = -1 + m.input.SetValue(m.draft) + m.input.CursorEnd() +} + +func (m replModel) runCommand(line string) tea.Cmd { + repl := m.repl + return func() tea.Msg { + return commandResultMsg(repl.executeCommand(line)) + } +} From 8a17df6b15bcfe572d400e3abe2775cefeba3423 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:15:22 +0200 Subject: [PATCH 7/9] chore: refresh examples and benchmarks Move examples into dedicated main packages, fix the concurrent example, and update benchmark module paths, dependencies, and docs. Co-authored-by: Cursor --- benchmark/README.md | 9 ++-- benchmark/client_test.go | 31 ++++-------- benchmark/concurrent_benchmark_test.go | 6 ++- benchmark/go.mod | 25 ++++++---- benchmark/go.sum | 37 ++++++++++----- benchmark/mem_benchmark_test.go | 31 ++---------- example/{cas.go => cas/main.go} | 0 example/{cocurrent.go => concurrent/main.go} | 5 +- example/{flushall.go => flushall/main.go} | 0 example/go.mod | 28 +++++------ example/go.sum | 50 ++++++++++---------- example/{noreply.go => noreply/main.go} | 2 +- example/{options.go => options/main.go} | 7 +-- example/otel/main.go | 18 +++++-- example/{simple.go => simple/main.go} | 9 ++-- example/{udp.go => udp/main.go} | 11 +++-- 16 files changed, 136 insertions(+), 133 deletions(-) rename example/{cas.go => cas/main.go} (100%) rename example/{cocurrent.go => concurrent/main.go} (84%) rename example/{flushall.go => flushall/main.go} (100%) rename example/{noreply.go => noreply/main.go} (94%) rename example/{options.go => options/main.go} (83%) rename example/{simple.go => simple/main.go} (80%) rename example/{udp.go => udp/main.go} (81%) diff --git a/benchmark/README.md b/benchmark/README.md index ced8e80..56c6842 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,10 +1,9 @@ ## Benchmark differences -Comparing following three implementations: +Comparing following implementations: -- [yeqown/memcached](https://github.com/yeqown/memcahced) +- [yeqown/memcached](https://github.com/yeqown/memcached) - [bradfitz/gomemcache](https://github.com/bradfitz/gomemcache) -- [rainy/memcache](github.com/rainycape/memcache) ## How to run @@ -12,8 +11,8 @@ Comparing following three implementations: > And we can use `go build -gcflags="-m" ./...` to check the escape analysis. ```bash -# install benchcmp -go install golang.org/x/tools/cmd/benchcmp@latest +# install benchstat +go install golang.org/x/perf/cmd/benchstat@latest ``` To run benchmark and analysis: diff --git a/benchmark/client_test.go b/benchmark/client_test.go index 125719d..feba93f 100644 --- a/benchmark/client_test.go +++ b/benchmark/client_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/bradfitz/gomemcache/memcache" - rainycape "github.com/rainycape/memcache" "github.com/yeqown/memcached" ) @@ -15,29 +14,15 @@ func Test_Yeqown(t *testing.T) { if err != nil { t.Fatal(err) } - defer client.Close() - client.Set(context.Background(), testKey, testValue, 0, 0) - item, err := client.Get(context.Background(), testKey) - if err != nil { - t.Fatal(err) - } - if string(item.Value) != string(testValue) { - t.Fatalf("expect %s, got %s", string(testValue), string(item.Value)) - } -} - -func Test_Rainycape(t *testing.T) { - t.Skipf("It's a binary package, not support test") - - client, err := rainycape.New("127.0.0.1:11211") - if err != nil { - t.Fatal(err) - } - client.Set(&rainycape.Item{ - Key: testKey, - Value: testValue, + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("close client: %v", err) + } }) - item, err := client.Get(testKey) + if err := client.Set(context.Background(), testKey, testValue, 0, 0); err != nil { + t.Fatalf("set failed: %v", err) + } + item, err := client.Get(context.Background(), testKey) if err != nil { t.Fatal(err) } diff --git a/benchmark/concurrent_benchmark_test.go b/benchmark/concurrent_benchmark_test.go index 0448f5c..c964819 100644 --- a/benchmark/concurrent_benchmark_test.go +++ b/benchmark/concurrent_benchmark_test.go @@ -13,7 +13,11 @@ func BenchmarkYeqownMemcachedConcurrent(b *testing.B) { if err != nil { b.Fatal(err) } - defer client.Close() + b.Cleanup(func() { + if err := client.Close(); err != nil { + b.Errorf("close client: %v", err) + } + }) ctx := context.Background() diff --git a/benchmark/go.mod b/benchmark/go.mod index 7651c5b..1716bc3 100644 --- a/benchmark/go.mod +++ b/benchmark/go.mod @@ -1,16 +1,23 @@ -module benchmark +module github.com/yeqown/memcached/benchmark -go 1.26.0 +go 1.26.5 require ( - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 - github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 - github.com/yeqown/memcached v1.1.3 + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c + github.com/yeqown/memcached v1.4.0 ) require ( - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect ) + +replace github.com/yeqown/memcached => ../ diff --git a/benchmark/go.sum b/benchmark/go.sum index 0c42fcf..24421d5 100644 --- a/benchmark/go.sum +++ b/benchmark/go.sum @@ -1,20 +1,33 @@ -github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= -github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQRaHEqRAsQ1rE/pC1GUS4sc2rCbbFsAIY= -github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yeqown/memcached v1.1.3 h1:/b5Nwxq/2xDPgido5+2818uwkdDQYja3Pzl8X6LQyC0= -github.com/yeqown/memcached v1.1.3/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/benchmark/mem_benchmark_test.go b/benchmark/mem_benchmark_test.go index e8408c4..4970746 100644 --- a/benchmark/mem_benchmark_test.go +++ b/benchmark/mem_benchmark_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/bradfitz/gomemcache/memcache" - rainycape "github.com/rainycape/memcache" "github.com/yeqown/memcached" ) @@ -22,7 +21,11 @@ func BenchmarkYeqownMemcached(b *testing.B) { if err != nil { b.Fatal(err) } - defer client.Close() + b.Cleanup(func() { + if err := client.Close(); err != nil { + b.Errorf("close client: %v", err) + } + }) ctx := context.Background() @@ -56,27 +59,3 @@ func BenchmarkBradfitzGomemcache(b *testing.B) { } } } - -func BenchmarkRainycapeMemcache(b *testing.B) { - b.Skipf("It's a binary package, not support benchmark.") - - client, err := rainycape.New("localhost:11211") - if err != nil { - b.Fatal(err) - } - defer client.Close() - item := &rainycape.Item{ - Key: testKey, - Value: testValue, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - if err := client.Set(item); err != nil { - b.Fatal(err) - } - if _, err := client.Get(testKey); err != nil { - b.Fatal(err) - } - } -} diff --git a/example/cas.go b/example/cas/main.go similarity index 100% rename from example/cas.go rename to example/cas/main.go diff --git a/example/cocurrent.go b/example/concurrent/main.go similarity index 84% rename from example/cocurrent.go rename to example/concurrent/main.go index b8a01ed..35760cb 100644 --- a/example/cocurrent.go +++ b/example/concurrent/main.go @@ -28,13 +28,12 @@ func main() { wg.Add(1) go func(idx int) { defer wg.Done() - defer func() { fmt.Printf("routine(%d) done\n", i) }() + defer func() { fmt.Printf("routine(%d) done\n", idx) }() for counter := 0; counter < limits; counter++ { key := "example:cas" // set - err = client.Set(ctx, key, []byte("value2"), uint32(123), 10*time.Second) - if err != nil { + if err := client.Set(ctx, key, []byte("value2"), uint32(123), 10*time.Second); err != nil { panic("write: " + err.Error()) } // get diff --git a/example/flushall.go b/example/flushall/main.go similarity index 100% rename from example/flushall.go rename to example/flushall/main.go diff --git a/example/go.mod b/example/go.mod index c230878..264abcb 100644 --- a/example/go.mod +++ b/example/go.mod @@ -1,28 +1,28 @@ module github.com/yeqown/memcached/example -go 1.26.0 +go 1.26.5 require ( - github.com/yeqown/memcached v1.1.2 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/sdk/metric v1.40.0 + github.com/yeqown/memcached v1.4.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/sys v0.40.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) replace github.com/yeqown/memcached => ../ diff --git a/example/go.sum b/example/go.sum index bad0612..987b263 100644 --- a/example/go.sum +++ b/example/go.sum @@ -3,43 +3,45 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/example/noreply.go b/example/noreply/main.go similarity index 94% rename from example/noreply.go rename to example/noreply/main.go index 9e06cc7..c9bb946 100644 --- a/example/noreply.go +++ b/example/noreply/main.go @@ -14,7 +14,7 @@ func main() { panic(err) } - key := string("noReply") + key := "noReply" value := []byte("noReply") ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) diff --git a/example/options.go b/example/options/main.go similarity index 83% rename from example/options.go rename to example/options/main.go index 98b6608..2404208 100644 --- a/example/options.go +++ b/example/options/main.go @@ -10,9 +10,10 @@ import ( func main() { addrs := "localhost:11211" - // Create a new client with the default options. - client, err := memcached.New(addrs, - memcached.WithDialTimeout(time.Second*5), + // Create a new client with custom timeout options. + client, err := memcached.New( + addrs, + memcached.WithDialTimeout(5*time.Second), memcached.WithReadTimeout(100*time.Second), memcached.WithWriteTimeout(100*time.Second), ) diff --git a/example/otel/main.go b/example/otel/main.go index b3dc51c..525f155 100644 --- a/example/otel/main.go +++ b/example/otel/main.go @@ -35,13 +35,21 @@ func main() { tp := trace.NewTracerProvider( trace.WithBatcher(traceExporter), ) - defer tp.Shutdown(context.Background()) + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.Printf("Shutting down tracer provider: %v", err) + } + }() // Create meter provider mp := sdkmetric.NewMeterProvider( sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)), ) - defer mp.Shutdown(context.Background()) + defer func() { + if err := mp.Shutdown(context.Background()); err != nil { + log.Printf("Shutting down meter provider: %v", err) + } + }() // Set global providers otel.SetTracerProvider(tp) @@ -57,7 +65,11 @@ func main() { if err != nil { log.Fatalf("Failed to create client: %v", err) } - defer client.Close() + defer func() { + if err := client.Close(); err != nil { + log.Printf("Closing client: %v", err) + } + }() ctx := context.Background() diff --git a/example/simple.go b/example/simple/main.go similarity index 80% rename from example/simple.go rename to example/simple/main.go index f88018f..480f2d7 100644 --- a/example/simple.go +++ b/example/simple/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "time" "github.com/yeqown/memcached" @@ -22,7 +23,7 @@ func main() { if err != nil { panic(err) } - println("Version: ", version) + fmt.Println("Version:", version) // get first item, err := client.Get(ctx, "key") @@ -31,9 +32,9 @@ func main() { panic(err) } - println("'key' not found") + fmt.Println("'key' not found") } else { - println("key: ", item.Key, " value: ", string(item.Value)) + fmt.Println("key:", item.Key, "value:", string(item.Value)) } if err = client.Set(ctx, "key", []byte("value"), 0, 0); err != nil { @@ -49,6 +50,6 @@ func main() { } for _, item := range items { - println("key: ", item.Key, " value: ", string(item.Value)) + fmt.Println("key:", item.Key, "value:", string(item.Value)) } } diff --git a/example/udp.go b/example/udp/main.go similarity index 81% rename from example/udp.go rename to example/udp/main.go index cf0f91c..6328810 100644 --- a/example/udp.go +++ b/example/udp/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "time" "github.com/yeqown/memcached" @@ -22,19 +23,19 @@ func main() { if err != nil { panic(err) } - println("Version: ", version) + fmt.Println("Version:", version) key := "key:udp" value := "This is a value for key:udp" // get first - item, err := client.Get(ctx, key) + _, err = client.Get(ctx, key) if err != nil { if !errors.Is(err, memcached.ErrNotFound) { panic(err) } - println("'key' not found") + fmt.Println("'key' not found") } // set @@ -43,10 +44,10 @@ func main() { } // get again - item, err = client.Get(ctx, key) + item, err := client.Get(ctx, key) if err != nil { panic(err) } - println("key: ", item.Key, " value: ", string(item.Value)) + fmt.Println("key:", item.Key, "value:", string(item.Value)) } From d599ab499e8110a8167cb7ba1fe983e78bc20aef Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:15:44 +0200 Subject: [PATCH 8/9] fix(gui): harden connection config and lifecycle Use OpenRoot with restrictive permissions for context files and propagate connection close errors during connect and disconnect. Co-authored-by: Cursor --- gui/service/connection.go | 71 ++++++++++++++++++++-------------- gui/service/operations_test.go | 16 -------- 2 files changed, 42 insertions(+), 45 deletions(-) diff --git a/gui/service/connection.go b/gui/service/connection.go index 0f586bc..8e84e5e 100644 --- a/gui/service/connection.go +++ b/gui/service/connection.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -13,6 +14,8 @@ import ( memcached "github.com/yeqown/memcached" ) +const configFileName = "contexts.json" + // ConnectionService manages memcached contexts and connections. type ConnectionService struct { mu sync.Mutex @@ -30,7 +33,7 @@ func NewConnectionService() (*ConnectionService, error) { } configDir = filepath.Join(configDir, "memcached-gui") - if err := os.MkdirAll(configDir, 0755); err != nil { + if err := os.MkdirAll(configDir, 0o700); err != nil { return nil, fmt.Errorf("failed to create config dir: %w", err) } @@ -39,28 +42,15 @@ func NewConnectionService() (*ConnectionService, error) { }, nil } -func (s *ConnectionService) configPath() string { - return filepath.Join(s.configDir, "contexts.json") -} - // LoadContexts loads saved contexts from the config file. func (s *ConnectionService) LoadContexts() ([]Context, error) { s.mu.Lock() defer s.mu.Unlock() - path := s.configPath() - data, err := os.ReadFile(path) + contexts, err := s.loadContextsLocked() if err != nil { - if os.IsNotExist(err) { - return []Context{}, nil - } return nil, fmt.Errorf("failed to read config: %w", err) } - - var contexts []Context - if err := json.Unmarshal(data, &contexts); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) - } return contexts, nil } @@ -120,10 +110,8 @@ func (s *ConnectionService) Connect(ctxID string) error { defer s.mu.Unlock() // Disconnect existing connection - if s.client != nil { - s.client.Close() - s.client = nil - s.connected = false + if err := s.disconnectLocked(); err != nil { + return fmt.Errorf("failed to close existing connection: %w", err) } contexts, err := s.loadContextsLocked() @@ -162,8 +150,10 @@ func (s *ConnectionService) Connect(ctxID string) error { defer cancel() if _, err := client.Version(verifyCtx); err != nil { - client.Close() - return fmt.Errorf("failed to connect to %s: %w", addr, err) + return errors.Join( + fmt.Errorf("failed to connect to %s: %w", addr, err), + client.Close(), + ) } s.client = client @@ -177,13 +167,18 @@ func (s *ConnectionService) Disconnect() error { s.mu.Lock() defer s.mu.Unlock() + return s.disconnectLocked() +} + +func (s *ConnectionService) disconnectLocked() error { + var err error if s.client != nil { - s.client.Close() + err = s.client.Close() s.client = nil } s.connected = false s.activeCtx = nil - return nil + return err } // IsConnected returns whether there's an active connection. @@ -204,26 +199,44 @@ func (s *ConnectionService) GetClient() (memcached.Client, error) { return s.client, nil } -func (s *ConnectionService) loadContextsLocked() ([]Context, error) { - path := s.configPath() - data, err := os.ReadFile(path) +func (s *ConnectionService) loadContextsLocked() (contexts []Context, retErr error) { + root, err := os.OpenRoot(s.configDir) + if err != nil { + return nil, err + } + defer func() { + retErr = errors.Join(retErr, root.Close()) + }() + + data, err := root.ReadFile(configFileName) if err != nil { if os.IsNotExist(err) { return []Context{}, nil } return nil, err } - var contexts []Context if err := json.Unmarshal(data, &contexts); err != nil { return nil, err } return contexts, nil } -func (s *ConnectionService) saveContextsLocked(contexts []Context) error { +func (s *ConnectionService) saveContextsLocked(contexts []Context) (retErr error) { data, err := json.MarshalIndent(contexts, "", " ") if err != nil { return err } - return os.WriteFile(s.configPath(), data, 0644) + + root, err := os.OpenRoot(s.configDir) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, root.Close()) + }() + + if err := root.WriteFile(configFileName, data, 0o600); err != nil { + return err + } + return root.Chmod(configFileName, 0o600) } diff --git a/gui/service/operations_test.go b/gui/service/operations_test.go index 61ade5d..b082459 100644 --- a/gui/service/operations_test.go +++ b/gui/service/operations_test.go @@ -9,22 +9,6 @@ import ( memcached "github.com/yeqown/memcached" ) -type structuredOperationResult struct { - Success bool `json:"success"` - Data string `json:"data"` - Error string `json:"error"` - Key string `json:"key"` - Value string `json:"value"` - TTL int64 `json:"ttl"` - LastAccessedTime int64 `json:"lastAccessedTime"` - CAS uint64 `json:"cas"` - Flags uint32 `json:"flags"` - Size uint64 `json:"size"` - HitBefore bool `json:"hitBefore"` - Opaque uint64 `json:"opaque"` - ValueKind string `json:"valueKind"` -} - type fakeMemcachedClient struct { getCalled bool metaGetCalled bool From e819b51d30b967ddfa56dc28b73e7a45ea2889b3 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Tue, 11 Aug 2026 07:15:47 +0200 Subject: [PATCH 9/9] chore(gui): align module path and upgrade Wails Rename the GUI module to github.com/yeqown/memcached/gui and upgrade Wails to v2.14.0 with matching dependency updates. Co-authored-by: Cursor --- gui/go.mod | 35 +++++++++++++++++-------------- gui/go.sum | 59 +++++++++++++++++++++++++++++++---------------------- gui/main.go | 3 +-- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/gui/go.mod b/gui/go.mod index 8ad7081..2cd537e 100644 --- a/gui/go.mod +++ b/gui/go.mod @@ -1,25 +1,27 @@ -module memcached-gui +module github.com/yeqown/memcached/gui -go 1.26.0 +go 1.26.5 require ( github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 - github.com/wailsapp/wails/v2 v2.12.0 - github.com/yeqown/memcached v1.3.4 + github.com/wailsapp/wails/v2 v2.14.0 + github.com/yeqown/memcached v1.4.0 ) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -28,23 +30,26 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/samber/lo v1.49.1 // indirect + github.com/samber/lo v1.53.0 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.23.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) -// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/apple/.go/pkg/mod +replace github.com/yeqown/memcached => ../ diff --git a/gui/go.sum b/gui/go.sum index 1c5d177..7a1758f 100644 --- a/gui/go.sum +++ b/gui/go.sum @@ -2,28 +2,33 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -47,9 +52,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -57,11 +63,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= -github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= @@ -74,26 +79,32 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= -github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= -github.com/yeqown/memcached v1.3.4 h1:v0/ZuojwP3u+3DtXXKjB/NInM8Ob/I3okh/6yQzFAFk= -github.com/yeqown/memcached v1.3.4/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +github.com/wailsapp/wails/v2 v2.14.0 h1:+GlF6BM8Mg7Saa1lxUAzfZ6jsEMg2zQhQvUHtqB7xpc= +github.com/wailsapp/wails/v2 v2.14.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/gui/main.go b/gui/main.go index 28777ee..ad39cd8 100644 --- a/gui/main.go +++ b/gui/main.go @@ -7,7 +7,7 @@ import ( "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" - "memcached-gui/service" + "github.com/yeqown/memcached/gui/service" ) //go:embed all:frontend/dist @@ -39,7 +39,6 @@ func main() { opSvc, }, }) - if err != nil { println("Error:", err.Error()) }