mirror of
https://github.com/lightninglabs/pool.git
synced 2026-08-13 12:33:04 +02:00
649 lines
19 KiB
Go
649 lines
19 KiB
Go
package auctioneer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/lightninglabs/pool/account"
|
|
"github.com/lightninglabs/pool/auctioneerrpc"
|
|
"github.com/lightninglabs/pool/clientdb"
|
|
"github.com/lightninglabs/pool/order"
|
|
"github.com/lightningnetwork/lnd/keychain"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// TestJitterBackoffBounds samples the jitter helper for a typical configured
|
|
// backoff and asserts results fall in the expected [backoff, backoff +
|
|
// backoff/4] range and aren't pinned to a single value.
|
|
func TestJitterBackoffBounds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
base = 5 * time.Second
|
|
samples = 200
|
|
)
|
|
seen := make(map[time.Duration]struct{}, samples)
|
|
for i := 0; i < samples; i++ {
|
|
got := jitterBackoff(base)
|
|
if got < base || got > base+base/4 {
|
|
t.Fatalf("jitterBackoff(%v) = %v, out of [%v, %v]",
|
|
base, got, base, base+base/4)
|
|
}
|
|
seen[got] = struct{}{}
|
|
}
|
|
|
|
// In 200 samples over a 1.25s window of nanosecond resolution we
|
|
// expect many distinct values. If we get only a handful, jitter is
|
|
// broken.
|
|
if len(seen) < 10 {
|
|
t.Fatalf("expected diverse jitter samples, "+
|
|
"only got %d unique values", len(seen))
|
|
}
|
|
}
|
|
|
|
// fakeServerStream is a minimal implementation of
|
|
// ChannelAuctioneer_SubscribeBatchAuctionClient that returns predetermined
|
|
// results from Recv and captures client-sent messages on `sent` (when
|
|
// non-nil). It is only sufficient for driving the client's read loop and
|
|
// the auth handshake.
|
|
type fakeServerStream struct {
|
|
grpc.ClientStream
|
|
|
|
recv chan recvResult
|
|
sent chan *auctioneerrpc.ClientAuctionMessage
|
|
}
|
|
|
|
type recvResult struct {
|
|
msg *auctioneerrpc.ServerAuctionMessage
|
|
err error
|
|
}
|
|
|
|
func (s *fakeServerStream) Send(msg *auctioneerrpc.ClientAuctionMessage) error {
|
|
if s.sent != nil {
|
|
s.sent <- msg
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeServerStream) Recv() (*auctioneerrpc.ServerAuctionMessage, error) {
|
|
r := <-s.recv
|
|
return r.msg, r.err
|
|
}
|
|
|
|
func (s *fakeServerStream) CloseSend() error {
|
|
return nil
|
|
}
|
|
|
|
// fakeAuctioneerClient embeds the real ChannelAuctioneerClient interface so it
|
|
// satisfies all 14 methods by nil-deref (none are called in this test other
|
|
// than the two we override below).
|
|
type fakeAuctioneerClient struct {
|
|
auctioneerrpc.ChannelAuctioneerClient
|
|
|
|
stream auctioneerrpc.ChannelAuctioneer_SubscribeBatchAuctionClient
|
|
}
|
|
|
|
func (f *fakeAuctioneerClient) Terms(ctx context.Context,
|
|
in *auctioneerrpc.TermsRequest,
|
|
opts ...grpc.CallOption) (*auctioneerrpc.TermsResponse, error) {
|
|
|
|
return &auctioneerrpc.TermsResponse{}, nil
|
|
}
|
|
|
|
func (f *fakeAuctioneerClient) SubscribeBatchAuction(ctx context.Context,
|
|
opts ...grpc.CallOption) (
|
|
auctioneerrpc.ChannelAuctioneer_SubscribeBatchAuctionClient, error) {
|
|
|
|
return f.stream, nil
|
|
}
|
|
|
|
// noPendingBatchSource is a BatchSource stub that always reports "no pending
|
|
// batch", letting checkPendingBatch return cleanly.
|
|
type noPendingBatchSource struct{}
|
|
|
|
func (noPendingBatchSource) PendingBatchSnapshot() (
|
|
*clientdb.LocalBatchSnapshot, error) {
|
|
|
|
return nil, account.ErrNoPendingBatch
|
|
}
|
|
|
|
// newTestClient returns a Client wired up just enough to drive
|
|
// readIncomingStream against a fake server stream.
|
|
func newTestClient(stream auctioneerrpc.ChannelAuctioneer_SubscribeBatchAuctionClient,
|
|
) (*Client, chan error) {
|
|
|
|
mainErrChan := make(chan error, 1)
|
|
c := &Client{
|
|
serverStream: stream,
|
|
FromServerChan: make(chan *auctioneerrpc.ServerAuctionMessage),
|
|
StreamErrChan: mainErrChan,
|
|
errChanSwitch: NewErrChanSwitch(mainErrChan),
|
|
quit: make(chan struct{}),
|
|
subscribedAccts: make(map[[33]byte]*acctSubscription),
|
|
}
|
|
c.errChanSwitch.Start()
|
|
return c, mainErrChan
|
|
}
|
|
|
|
// runReadLoop runs readIncomingStream in a goroutine and returns a channel
|
|
// that closes when the loop exits.
|
|
func runReadLoop(c *Client) <-chan struct{} {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
c.readIncomingStream()
|
|
close(done)
|
|
}()
|
|
return done
|
|
}
|
|
|
|
// TestReadIncomingStreamEOFTriggersReconnect ensures that an io.EOF received
|
|
// on the server stream is surfaced as ErrServerErrored on the error channel,
|
|
// which is the signal the rpcserver consumer uses to trigger reconnect logic.
|
|
//
|
|
// This is a regression test: EOF was previously reported as a separate
|
|
// "ErrServerShutdown" sentinel that the consumer silently ignored under the
|
|
// (incorrect) assumption that the client had already scheduled its own
|
|
// reconnect. The result was a permanently dead subscription stream after any
|
|
// clean close (proxy/LB timeout, planned server shutdown, etc.), with the
|
|
// trader being filtered as offline until the process restarted.
|
|
func TestReadIncomingStreamEOFTriggersReconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stream := &fakeServerStream{recv: make(chan recvResult, 1)}
|
|
c, mainErrChan := newTestClient(stream)
|
|
defer c.errChanSwitch.Stop()
|
|
defer close(c.quit)
|
|
|
|
// Tell the fake stream to return io.EOF, simulating the server (or an
|
|
// intermediate proxy) cleanly closing its side of the bidi stream.
|
|
stream.recv <- recvResult{err: io.EOF}
|
|
|
|
done := runReadLoop(c)
|
|
|
|
select {
|
|
case err := <-mainErrChan:
|
|
if !errors.Is(err, ErrServerErrored) {
|
|
t.Fatalf("expected ErrServerErrored on EOF, got: %v",
|
|
err)
|
|
}
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("timed out waiting for error after EOF")
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("readIncomingStream did not return after EOF")
|
|
}
|
|
}
|
|
|
|
// TestReadIncomingStreamTransportErrorTriggersReconnect ensures non-EOF
|
|
// transport errors continue to be surfaced as ErrServerErrored. This is the
|
|
// pre-existing behaviour we want to preserve after unifying it with the EOF
|
|
// path.
|
|
func TestReadIncomingStreamTransportErrorTriggersReconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stream := &fakeServerStream{recv: make(chan recvResult, 1)}
|
|
c, mainErrChan := newTestClient(stream)
|
|
defer c.errChanSwitch.Stop()
|
|
defer close(c.quit)
|
|
|
|
// A "transport is closing" style error, which is what gRPC surfaces
|
|
// when the underlying TCP connection breaks abruptly.
|
|
stream.recv <- recvResult{
|
|
err: status.Error(codes.Unavailable, "transport is closing"),
|
|
}
|
|
|
|
done := runReadLoop(c)
|
|
|
|
select {
|
|
case err := <-mainErrChan:
|
|
if !errors.Is(err, ErrServerErrored) {
|
|
t.Fatalf("expected ErrServerErrored on transport "+
|
|
"error, got: %v", err)
|
|
}
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("timed out waiting for error after transport failure")
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("readIncomingStream did not return after transport " +
|
|
"failure")
|
|
}
|
|
}
|
|
|
|
// TestReadIncomingStreamContextCanceledDoesNotReconnect ensures that a
|
|
// codes.Canceled error (which happens when *we* cancel the stream context
|
|
// during shutdown or a planned reconnect) does NOT surface an error to the
|
|
// consumer, so we don't accidentally schedule a second reconnect.
|
|
func TestReadIncomingStreamContextCanceledDoesNotReconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stream := &fakeServerStream{recv: make(chan recvResult, 1)}
|
|
c, mainErrChan := newTestClient(stream)
|
|
defer c.errChanSwitch.Stop()
|
|
defer close(c.quit)
|
|
|
|
stream.recv <- recvResult{
|
|
err: status.Error(codes.Canceled, "context canceled"),
|
|
}
|
|
|
|
done := runReadLoop(c)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("readIncomingStream did not return after cancel")
|
|
}
|
|
|
|
select {
|
|
case err := <-mainErrChan:
|
|
t.Fatalf("unexpected error surfaced on cancel: %v", err)
|
|
case <-time.After(defaultTimeout):
|
|
// Expected: no error surfaced.
|
|
}
|
|
}
|
|
|
|
// TestConnectAndAuthenticateCleansUpOnError drives a full
|
|
// connectAndAuthenticate call in recovery mode against a scripted fake stream
|
|
// for each per-account error path that the auctioneer can return after the
|
|
// Subscribe message, and asserts the subscription entry is always removed
|
|
// from c.subscribedAccts on return.
|
|
//
|
|
// Regression: previously the entry was added to the map before authenticate
|
|
// ran (so readIncomingStream could route the server's Challenge/Error back
|
|
// to it) and was never removed on error paths. A later StartAccountSubscription
|
|
// for the same account — typically when handleStateOpen runs after on-chain
|
|
// confirmation — would hit the "already subscribed" early-return guard at the
|
|
// top of connectAndAuthenticate and silently no-op without sending a fresh
|
|
// Commit. The per-account 3-way handshake never ran and the trader stayed
|
|
// filtered as offline at matching time until the process restarted.
|
|
func TestConnectAndAuthenticateCleansUpOnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var pubKey [33]byte
|
|
copy(pubKey[:], testAccountDesc.PubKey.SerializeCompressed())
|
|
|
|
cases := []struct {
|
|
name string
|
|
errResp *auctioneerrpc.SubscribeError
|
|
checkRes func(t *testing.T, sub *acctSubscription,
|
|
canRecover bool, err error)
|
|
}{
|
|
{
|
|
// Realistic case: RecoverAccounts probes a key the
|
|
// auctioneer hasn't yet seen on chain.
|
|
name: "account does not exist",
|
|
errResp: &auctioneerrpc.SubscribeError{
|
|
ErrorCode: auctioneerrpc.SubscribeError_ACCOUNT_DOES_NOT_EXIST,
|
|
TraderKey: pubKey[:],
|
|
},
|
|
checkRes: func(t *testing.T, sub *acctSubscription,
|
|
canRecover bool, err error) {
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if canRecover {
|
|
t.Fatal("expected canRecover=false")
|
|
}
|
|
if sub == nil {
|
|
t.Fatal("expected non-nil subscription")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// The auctioneer knows about a reservation for this
|
|
// key but the funding tx hasn't confirmed yet. The
|
|
// function returns a non-nil sub *and* a typed error,
|
|
// which makes the cleanup invariant especially easy
|
|
// to get wrong.
|
|
name: "incomplete account reservation",
|
|
errResp: &auctioneerrpc.SubscribeError{
|
|
ErrorCode: auctioneerrpc.SubscribeError_INCOMPLETE_ACCOUNT_RESERVATION,
|
|
TraderKey: pubKey[:],
|
|
AccountReservation: &auctioneerrpc.AuctionAccount{
|
|
Value: 100_000,
|
|
Expiry: 144,
|
|
TraderKey: pubKey[:],
|
|
AuctioneerKey: bytes.Repeat([]byte{0x02}, 33),
|
|
BatchKey: bytes.Repeat([]byte{0x03}, 33),
|
|
HeightHint: 1,
|
|
},
|
|
},
|
|
checkRes: func(t *testing.T, sub *acctSubscription,
|
|
canRecover bool, err error) {
|
|
|
|
var resErr *AcctResNotCompletedError
|
|
if !errors.As(err, &resErr) {
|
|
t.Fatalf("expected "+
|
|
"AcctResNotCompletedError, "+
|
|
"got %v", err)
|
|
}
|
|
if !canRecover {
|
|
t.Fatal("expected canRecover=true")
|
|
}
|
|
if sub == nil {
|
|
t.Fatal("expected non-nil subscription")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runCleanupCase(t, pubKey, tc.errResp, tc.checkRes)
|
|
})
|
|
}
|
|
}
|
|
|
|
// runCleanupCase wires up a fresh Client + fake stream, drives a full
|
|
// connectAndAuthenticate handshake in recovery mode, feeds the supplied
|
|
// error response back at the Subscribe step, runs the caller's assertions on
|
|
// the return values, and finally asserts that subscribedAccts is empty.
|
|
func runCleanupCase(t *testing.T, pubKey [33]byte,
|
|
errResp *auctioneerrpc.SubscribeError,
|
|
checkRes func(t *testing.T, sub *acctSubscription, canRecover bool,
|
|
err error)) {
|
|
|
|
stream := &fakeServerStream{
|
|
recv: make(chan recvResult, 1),
|
|
sent: make(chan *auctioneerrpc.ClientAuctionMessage, 2),
|
|
}
|
|
|
|
mainErrChan := make(chan error, 1)
|
|
c := &Client{
|
|
cfg: &Config{
|
|
Signer: testSigner,
|
|
BatchVersion: order.LatestBatchVersion,
|
|
},
|
|
serverStream: stream,
|
|
FromServerChan: make(chan *auctioneerrpc.ServerAuctionMessage),
|
|
StreamErrChan: mainErrChan,
|
|
errChanSwitch: NewErrChanSwitch(mainErrChan),
|
|
quit: make(chan struct{}),
|
|
subscribedAccts: make(map[[33]byte]*acctSubscription),
|
|
}
|
|
c.errChanSwitch.Start()
|
|
defer c.errChanSwitch.Stop()
|
|
defer close(c.quit)
|
|
|
|
// Run the read loop in the background so server responses are routed
|
|
// to the subscription's msgChan via subscribedAccts lookups.
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
c.readIncomingStream()
|
|
close(readDone)
|
|
}()
|
|
|
|
type result struct {
|
|
sub *acctSubscription
|
|
canRecover bool
|
|
err error
|
|
}
|
|
resCh := make(chan result, 1)
|
|
go func() {
|
|
sub, canRecover, err := c.connectAndAuthenticate(
|
|
context.Background(), testAccountDesc, true,
|
|
)
|
|
resCh <- result{sub, canRecover, err}
|
|
}()
|
|
|
|
// Step 1: capture the Commit and echo its commitHash back in the
|
|
// Challenge so readIncomingStream can route it to the right sub.
|
|
var commitHash []byte
|
|
select {
|
|
case msg := <-stream.sent:
|
|
commit, ok := msg.Msg.(*auctioneerrpc.ClientAuctionMessage_Commit)
|
|
if !ok {
|
|
t.Fatalf("expected Commit, got %T", msg.Msg)
|
|
}
|
|
commitHash = commit.Commit.CommitHash
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive Commit from client")
|
|
}
|
|
|
|
// Step 2: feed back the Challenge.
|
|
stream.recv <- recvResult{
|
|
msg: &auctioneerrpc.ServerAuctionMessage{
|
|
Msg: &auctioneerrpc.ServerAuctionMessage_Challenge{
|
|
Challenge: &auctioneerrpc.ServerChallenge{
|
|
Challenge: []byte{1, 2, 3, 4},
|
|
CommitHash: commitHash,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Step 3: drain the Subscribe message so authenticate() returns.
|
|
select {
|
|
case msg := <-stream.sent:
|
|
if _, ok := msg.Msg.(*auctioneerrpc.ClientAuctionMessage_Subscribe); !ok {
|
|
t.Fatalf("expected Subscribe, got %T", msg.Msg)
|
|
}
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive Subscribe from client")
|
|
}
|
|
|
|
// Step 4: server responds with the supplied error.
|
|
stream.recv <- recvResult{
|
|
msg: &auctioneerrpc.ServerAuctionMessage{
|
|
Msg: &auctioneerrpc.ServerAuctionMessage_Error{
|
|
Error: errResp,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Step 5: connectAndAuthenticate should return; let the caller assert
|
|
// the return values.
|
|
var res result
|
|
select {
|
|
case res = <-resCh:
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("connectAndAuthenticate did not return")
|
|
}
|
|
checkRes(t, res.sub, res.canRecover, res.err)
|
|
|
|
// In every error case, the subscription must NOT be left in the map.
|
|
// A later StartAccountSubscription for this account would otherwise
|
|
// hit the "already subscribed" guard and silently no-op without ever
|
|
// sending a fresh Commit.
|
|
c.subscribedAcctsMtx.Lock()
|
|
_, present := c.subscribedAccts[pubKey]
|
|
c.subscribedAcctsMtx.Unlock()
|
|
if present {
|
|
t.Fatal("subscribedAccts entry was not cleaned up; later " +
|
|
"subscribes for the same account would silently no-op")
|
|
}
|
|
|
|
// Clean up the background read loop. Sending io.EOF unblocks the
|
|
// Recv call and lets readIncomingStream exit cleanly.
|
|
stream.recv <- recvResult{err: io.EOF}
|
|
select {
|
|
case <-readDone:
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("read loop did not exit after EOF")
|
|
}
|
|
}
|
|
|
|
// TestHandleServerShutdownPartialResubscribeFailure asserts that
|
|
// HandleServerShutdown attempts to re-subscribe every account even when one
|
|
// of the handshakes fails server-side. The current loop bails on the first
|
|
// error, silently leaving the remaining accounts un-subscribed.
|
|
func TestHandleServerShutdownPartialResubscribeFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Three distinct account keys.
|
|
keys := make([]*keychain.KeyDescriptor, 3)
|
|
for i := range keys {
|
|
priv, err := btcec.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("could not generate key: %v", err)
|
|
}
|
|
keys[i] = &keychain.KeyDescriptor{PubKey: priv.PubKey()}
|
|
}
|
|
keyBytes := func(k *keychain.KeyDescriptor) [33]byte {
|
|
var b [33]byte
|
|
copy(b[:], k.PubKey.SerializeCompressed())
|
|
return b
|
|
}
|
|
|
|
// Stream that connectServerStream will hand back after closeStream.
|
|
stream := &fakeServerStream{
|
|
recv: make(chan recvResult, 8),
|
|
sent: make(chan *auctioneerrpc.ClientAuctionMessage, 8),
|
|
}
|
|
|
|
mainErrChan := make(chan error, 4)
|
|
c := &Client{
|
|
cfg: &Config{
|
|
Signer: testSigner,
|
|
BatchVersion: order.LatestBatchVersion,
|
|
MinBackoff: time.Millisecond,
|
|
MaxBackoff: time.Millisecond,
|
|
BatchSource: noPendingBatchSource{},
|
|
},
|
|
client: &fakeAuctioneerClient{stream: stream},
|
|
FromServerChan: make(chan *auctioneerrpc.ServerAuctionMessage),
|
|
StreamErrChan: mainErrChan,
|
|
errChanSwitch: NewErrChanSwitch(mainErrChan),
|
|
quit: make(chan struct{}),
|
|
subscribedAccts: make(map[[33]byte]*acctSubscription),
|
|
}
|
|
c.errChanSwitch.Start()
|
|
defer c.errChanSwitch.Stop()
|
|
defer close(c.quit)
|
|
|
|
// Pre-populate the subscribed accounts. HandleServerShutdown only reads
|
|
// acctKey out of each subscription to seed its re-subscribe loop; the
|
|
// channels here are placeholders.
|
|
for _, k := range keys {
|
|
c.subscribedAccts[keyBytes(k)] = &acctSubscription{
|
|
acctKey: k,
|
|
msgChan: make(chan *auctioneerrpc.ServerAuctionMessage),
|
|
quit: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Orchestrator: walk each handshake through Challenge + final
|
|
// response. The first attempt always gets ACCOUNT_DOES_NOT_EXIST;
|
|
// the rest get Success. Failing on the first attempt (rather than a
|
|
// fixed key) keeps the assertion deterministic under Go's randomized
|
|
// map iteration order.
|
|
var (
|
|
attemptedMtx sync.Mutex
|
|
attempted = make(map[[33]byte]struct{})
|
|
)
|
|
go func() {
|
|
first := true
|
|
for {
|
|
var msg *auctioneerrpc.ClientAuctionMessage
|
|
select {
|
|
case msg = <-stream.sent:
|
|
case <-c.quit:
|
|
return
|
|
}
|
|
commit, ok := msg.Msg.(*auctioneerrpc.ClientAuctionMessage_Commit)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Send Challenge back with the matching commitHash so
|
|
// readIncomingStream can route it.
|
|
stream.recv <- recvResult{
|
|
msg: &auctioneerrpc.ServerAuctionMessage{
|
|
Msg: &auctioneerrpc.ServerAuctionMessage_Challenge{
|
|
Challenge: &auctioneerrpc.ServerChallenge{
|
|
Challenge: []byte{1, 2, 3, 4},
|
|
CommitHash: commit.Commit.CommitHash,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Wait for the Subscribe with the trader key.
|
|
var subMsg *auctioneerrpc.ClientAuctionMessage
|
|
select {
|
|
case subMsg = <-stream.sent:
|
|
case <-c.quit:
|
|
return
|
|
}
|
|
sub, ok := subMsg.Msg.(*auctioneerrpc.ClientAuctionMessage_Subscribe)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var traderKey [33]byte
|
|
copy(traderKey[:], sub.Subscribe.TraderKey)
|
|
attemptedMtx.Lock()
|
|
attempted[traderKey] = struct{}{}
|
|
attemptedMtx.Unlock()
|
|
|
|
final := &auctioneerrpc.ServerAuctionMessage{
|
|
Msg: &auctioneerrpc.ServerAuctionMessage_Success{
|
|
Success: &auctioneerrpc.SubscribeSuccess{
|
|
TraderKey: sub.Subscribe.TraderKey,
|
|
},
|
|
},
|
|
}
|
|
if first {
|
|
final = &auctioneerrpc.ServerAuctionMessage{
|
|
Msg: &auctioneerrpc.ServerAuctionMessage_Error{
|
|
Error: &auctioneerrpc.SubscribeError{
|
|
ErrorCode: auctioneerrpc.SubscribeError_ACCOUNT_DOES_NOT_EXIST,
|
|
TraderKey: sub.Subscribe.TraderKey,
|
|
},
|
|
},
|
|
}
|
|
first = false
|
|
}
|
|
stream.recv <- recvResult{msg: final}
|
|
}
|
|
}()
|
|
|
|
// Drive HandleServerShutdown in a goroutine.
|
|
shutdownErr := make(chan error, 1)
|
|
go func() {
|
|
shutdownErr <- c.HandleServerShutdown(nil)
|
|
}()
|
|
|
|
// Wait for HandleServerShutdown to return. With the bug, it returns
|
|
// after the first handshake's error. With a fix, it returns after
|
|
// all three handshakes complete.
|
|
select {
|
|
case <-shutdownErr:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("HandleServerShutdown did not return")
|
|
}
|
|
|
|
// Every account must have been attempted, even though one handshake
|
|
// failed. Otherwise that single failure silently took the rest of the
|
|
// trader's accounts offline.
|
|
attemptedMtx.Lock()
|
|
got := len(attempted)
|
|
attemptedMtx.Unlock()
|
|
if got < len(keys) {
|
|
t.Fatalf("expected re-subscribe to attempt all %d accounts, "+
|
|
"only attempted %d — the loop bails on first error "+
|
|
"and leaves later accounts silently un-subscribed",
|
|
len(keys), got)
|
|
}
|
|
|
|
// Terminate the readIncomingStream goroutine that connectServerStream
|
|
// spawned, so it doesn't leak past the test.
|
|
stream.recv <- recvResult{err: io.EOF}
|
|
}
|