Skip to content

Commit 083f3aa

Browse files
committed
PR feedback: reject pipeline pushes once stopped
A push racing the connection-close drain could strand its JSA reply registration in the account-scoped map. push() now rejects entries on a stopped pipeline and re-drains after a raced enqueue. Signed-off-by: Lev Brouk <levbrouk@gmail.com>
1 parent e99060a commit 083f3aa

2 files changed

Lines changed: 78 additions & 31 deletions

File tree

server/mqtt.go

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ var (
246246
errMQTTInvalidRetainedMessage = errors.New("invalid retained message")
247247
errMQTTSessionCollision = errors.New("stored session does not match client ID")
248248
errMQTTInvalidPublishLength = errors.New("invalid publish message, variable header exceeds remaining length")
249+
errMQTTAckPipelineStopped = errors.New("QoS1 PUBACK pipeline has shut down while admitting a message, " +
250+
"abandoning the wait for its JetStream ack; failing the connection, " +
251+
"the client will re-send unacknowledged PUBLISH packets on reconnect")
249252
)
250253

251254
type srvMQTT struct {
@@ -4693,33 +4696,58 @@ func (s *Server) mqttPipelinePush(c *client, jsa *mqttJSA, ack *mqttPipelinedAck
46934696
c.mqtt.acks = pipe
46944697
}
46954698

4696-
// A just-stopped pipeline may admit an unconsumed entry; the
4697-
// connection-close handler (mqttHandleClosedClient) drains the queue
4698-
// via pipe.shutdown().
4699+
return pipe.push(ack)
4700+
}
4701+
4702+
// Admits an entry into the pipeline. Rejects it once the pipeline is
4703+
// stopped: an entry admitted after the connection-close drain
4704+
// (mqttHandleClosedClient -> pipe.shutdown) would strand its reply
4705+
// registration in the account-scoped jsa.replies. readLoop only.
4706+
func (pipe *mqttAckPipeline) push(ack *mqttPipelinedAck) error {
4707+
jsa := pipe.jsa
46994708
select {
4700-
case pipe.q <- ack:
4701-
return nil
4709+
case <-pipe.quitCh:
4710+
jsa.replies.Delete(ack.reply)
4711+
return errMQTTAckPipelineStopped
47024712
default:
47034713
}
47044714

4705-
// Window full: wait for an available slot in the pipeline.
4706-
t := time.NewTimer(jsa.timeout)
4707-
defer t.Stop()
4715+
enqueued := false
47084716
select {
47094717
case pipe.q <- ack:
4710-
return nil
4711-
case <-pipe.quitCh:
4712-
jsa.replies.Delete(ack.reply)
4713-
return errors.New("QoS1 PUBACK pipeline has shut down while admitting a message, " +
4714-
"abandoning the wait for its JetStream ack; failing the connection, " +
4715-
"the client will re-send unacknowledged PUBLISH packets on reconnect")
4716-
case <-t.C:
4718+
enqueued = true
4719+
default:
4720+
// Window full: wait for an available slot in the pipeline.
4721+
t := time.NewTimer(jsa.timeout)
4722+
defer t.Stop()
4723+
select {
4724+
case pipe.q <- ack:
4725+
enqueued = true
4726+
case <-pipe.quitCh:
4727+
case <-t.C:
4728+
jsa.replies.Delete(ack.reply)
4729+
return fmt.Errorf("QoS1 in-flight window is full (%d messages) and JetStream has not acknowledged "+
4730+
"the oldest message within %v; failing the connection, "+
4731+
"the client will re-send unacknowledged PUBLISH packets on reconnect",
4732+
mqttMaxAcksInFlight, jsa.timeout)
4733+
}
4734+
}
4735+
if !enqueued {
4736+
// Stopped while waiting for a slot, never admitted.
47174737
jsa.replies.Delete(ack.reply)
4718-
return fmt.Errorf("QoS1 in-flight window is full (%d messages) and JetStream has not acknowledged "+
4719-
"the oldest message within %v; failing the connection, "+
4720-
"the client will re-send unacknowledged PUBLISH packets on reconnect",
4721-
mqttMaxAcksInFlight, jsa.timeout)
4738+
return errMQTTAckPipelineStopped
4739+
}
4740+
4741+
// A stop may have raced the enqueue: the close-time drain could run
4742+
// before the entry landed and miss it. Drain again; both drains only
4743+
// dequeue and delete, so overlapping is harmless.
4744+
select {
4745+
case <-pipe.quitCh:
4746+
pipe.shutdown()
4747+
return errMQTTAckPipelineStopped
4748+
default:
47224749
}
4750+
return nil
47234751
}
47244752

47254753
func (s *Server) mqttInitiateMsgDelivery(c *client, pp *mqttPublish) error {

server/mqtt_test.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10402,11 +10402,12 @@ func TestMQTTQoS1PubAckPipelineConnClose(t *testing.T) {
1040210402
testMQTTCheckPubMsgNoAck(t, mcs, msr, "foo", mqttPubQos1, []byte("msg2"))
1040310403
}
1040410404

10405-
// A pipeline stopped while the readLoop is still admitting entries must
10406-
// not leak JSA reply registrations: shutdown() runs after the last push.
10405+
// A pipeline shut down while the readLoop is still admitting entries must
10406+
// not leak JSA reply registrations, even when the close-time drain runs
10407+
// concurrently with (or before) a racing push.
1040710408
func TestMQTTQoS1PubAckPipelineShutdownRace(t *testing.T) {
1040810409
for i := 0; i < 100; i++ {
10409-
jsa := &mqttJSA{}
10410+
jsa := &mqttJSA{timeout: time.Second}
1041010411
pipe := &mqttAckPipeline{
1041110412
jsa: jsa,
1041210413
q: make(chan *mqttPipelinedAck, 4),
@@ -10415,28 +10416,25 @@ func TestMQTTQoS1PubAckPipelineShutdownRace(t *testing.T) {
1041510416

1041610417
done := make(chan struct{})
1041710418
go func() {
10418-
// The readLoop side: register, admit, clean up on stop.
10419+
// The readLoop side: register, admit until rejected.
1041910420
defer close(done)
10420-
defer pipe.shutdown()
1042110421
for n := 0; ; n++ {
1042210422
ack := &mqttPipelinedAck{pi: uint16(n%0xFFFF + 1), reply: fmt.Sprintf("reply.%d", n), done: make(chan error, 1)}
1042310423
jsa.replies.Store(ack.reply, func(any) {})
10424-
select {
10425-
case pipe.q <- ack:
10426-
case <-pipe.quitCh:
10427-
// As mqttPipelinePush does when stopped.
10428-
jsa.replies.Delete(ack.reply)
10424+
if err := pipe.push(ack); err != nil {
1042910425
return
1043010426
}
1043110427
}
1043210428
}()
1043310429

10434-
// The consumer side: take a few entries, then stop mid-stream.
10430+
// The consumer side: take a few entries, then stop and drain
10431+
// concurrently with the pushes, as the connection-close handler
10432+
// does.
1043510433
for j := 0; j < i%4; j++ {
1043610434
ack := <-pipe.q
1043710435
jsa.replies.Delete(ack.reply)
1043810436
}
10439-
pipe.stop()
10437+
pipe.shutdown()
1044010438
<-done
1044110439

1044210440
leaked := 0
@@ -10445,4 +10443,25 @@ func TestMQTTQoS1PubAckPipelineShutdownRace(t *testing.T) {
1044510443
t.Fatalf("iteration %d: %v reply registration(s) leaked after shutdown", i, leaked)
1044610444
}
1044710445
}
10446+
10447+
// A push after the pipeline has stopped must be rejected outright,
10448+
// with its registration cleaned up and nothing left in the queue.
10449+
jsa := &mqttJSA{timeout: time.Second}
10450+
pipe := &mqttAckPipeline{
10451+
jsa: jsa,
10452+
q: make(chan *mqttPipelinedAck, 4),
10453+
quitCh: make(chan struct{}),
10454+
}
10455+
pipe.shutdown()
10456+
ack := &mqttPipelinedAck{pi: 1, reply: "reply.stopped", done: make(chan error, 1)}
10457+
jsa.replies.Store(ack.reply, func(any) {})
10458+
if err := pipe.push(ack); err != errMQTTAckPipelineStopped {
10459+
t.Fatalf("Expected errMQTTAckPipelineStopped, got %v", err)
10460+
}
10461+
if _, ok := jsa.replies.Load(ack.reply); ok {
10462+
t.Fatal("reply registration not cleaned up on rejected push")
10463+
}
10464+
if n := len(pipe.q); n != 0 {
10465+
t.Fatalf("%v entry(ies) admitted into a stopped pipeline", n)
10466+
}
1044810467
}

0 commit comments

Comments
 (0)