Skip to content

Commit a56452c

Browse files
committed
gs: Stop the timer if context is cancelled
1 parent 97c4ca5 commit a56452c

3 files changed

Lines changed: 167 additions & 3 deletions

File tree

pkg/gatewayserver/io/io.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ func (c *Connection) ScheduleDown(path *ttnpb.DownlinkPath, msg *ttnpb.DownlinkM
601601
logger := logger.WithFields(log.Fields(
602602
"rx_window", i+1,
603603
"frequency", rx.frequency,
604-
"data_rate", rx.dataRate,
604+
"data_rate", rx.dataRate.String(),
605605
))
606606
logger.Debug("Attempt to schedule downlink in receive window")
607607
// The maximum payload size is MACPayload only; for PHYPayload take MHDR (1 byte) and MIC (4 bytes) into account.
@@ -610,7 +610,7 @@ func (c *Connection) ScheduleDown(path *ttnpb.DownlinkPath, msg *ttnpb.DownlinkM
610610
return false, false, 0, errTooLong.WithAttributes(
611611
"payload_length", len(msg.RawPayload),
612612
"maximum_length", maxPHYLength,
613-
"data_rate", rx.dataRate,
613+
"data_rate", rx.dataRate.String(),
614614
)
615615
}
616616
eirp := phy.DefaultMaxEIRP

pkg/gatewayserver/io/udp/udp.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,16 @@ func (s *srv) handleDown(ctx context.Context, st *state) error {
530530
st.clockMu.RUnlock()
531531
d := time.Until(serverTime.Add(-s.config.ScheduleLateTime))
532532
logger.WithField("duration", d).Debug("Wait to schedule downlink message late")
533-
time.AfterFunc(d, write)
533+
go func() {
534+
t := time.NewTimer(d)
535+
defer t.Stop()
536+
select {
537+
case <-t.C:
538+
write()
539+
case <-ctx.Done():
540+
case <-st.io.Context().Done():
541+
}
542+
}()
534543
case <-healthCheck.C:
535544
if st.isPullPathActive(s.config.DownlinkPathExpires) {
536545
break

pkg/gatewayserver/io/udp/udp_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,161 @@ func TestConnection(t *testing.T) {
202202
cancelCtx()
203203
}
204204

205+
// TestScheduleLateCancel verifies that cancelling a connection context stops
206+
// a pending late-scheduled write goroutine before it fires, rather than
207+
// letting it write to the gateway after the connection has been torn down.
208+
// This is a regression test for the time.AfterFunc timer leak in handleDown.
209+
func TestScheduleLateCancel(t *testing.T) {
210+
t.Parallel()
211+
212+
var (
213+
registeredGatewayID = ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"}
214+
timeout = (1 << 4) * test.Delay
215+
testConfig = Config{
216+
PacketHandlers: 2,
217+
PacketBuffer: 10,
218+
DownlinkPathExpires: 8 * timeout,
219+
ConnectionExpires: 20 * timeout,
220+
ScheduleLateTime: 0,
221+
}
222+
)
223+
224+
a, ctx := test.New(t)
225+
ctx, cancelCtx := context.WithCancel(ctx)
226+
defer cancelCtx()
227+
228+
is, _, closeIS := mockis.New(ctx)
229+
defer closeIS()
230+
231+
c := componenttest.NewComponent(t, &component.Config{
232+
ServiceBase: config.ServiceBase{
233+
FrequencyPlans: config.FrequencyPlansConfig{
234+
ConfigSource: "static",
235+
Static: test.StaticFrequencyPlans,
236+
},
237+
},
238+
})
239+
componenttest.StartComponent(t, c)
240+
defer c.Close()
241+
242+
gs := mock.NewServer(c, is)
243+
addr, _ := net.ResolveUDPAddr("udp", ":0")
244+
lis, err := net.ListenUDP("udp", addr)
245+
if !a.So(err, should.BeNil) {
246+
t.FailNow()
247+
}
248+
249+
go Serve(ctx, gs, lis, testConfig) // nolint:errcheck
250+
251+
connections := &sync.Map{}
252+
eui := types.EUI64{0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05}
253+
254+
udpConn, err := net.Dial("udp", lis.LocalAddr().String())
255+
if !a.So(err, should.BeNil) {
256+
t.FailNow()
257+
}
258+
defer udpConn.Close()
259+
260+
// Establish a downlink path by sending PULL_DATA.
261+
pullPacket := generatePullData(eui)
262+
pullPacket.Token = [2]byte{0x00, 0x01}
263+
pullBuf, err := pullPacket.MarshalBinary()
264+
if !a.So(err, should.BeNil) {
265+
t.FailNow()
266+
}
267+
_, err = udpConn.Write(pullBuf)
268+
if !a.So(err, should.BeNil) {
269+
t.FailNow()
270+
}
271+
expectAck(t, udpConn, true, encoding.PullAck, pullPacket.Token)
272+
273+
conn := expectConnection(t, gs, connections, eui, true)
274+
275+
// Sync the gateway clock by sending PUSH_DATA with a known concentrator timestamp.
276+
syncConcentratorTime := 300 * test.Delay
277+
pushPacket := generatePushData(eui, false, syncConcentratorTime)
278+
pushPacket.Token = [2]byte{0x00, 0x02}
279+
pushBuf, err := pushPacket.MarshalBinary()
280+
if !a.So(err, should.BeNil) {
281+
t.FailNow()
282+
}
283+
_, err = udpConn.Write(pushBuf)
284+
if !a.So(err, should.BeNil) {
285+
t.FailNow()
286+
}
287+
clockSynced := time.Now()
288+
expectAck(t, udpConn, true, encoding.PushAck, pushPacket.Token)
289+
time.Sleep(timeout) // ensure the clock sync is processed before scheduling
290+
291+
// Schedule a Class A downlink. No TxAck has been received yet, so
292+
// canImmediate=false; with a synced clock, handleDown takes the late-schedule
293+
// path and starts a goroutine with a timer for d = time.Until(serverTime).
294+
path := &ttnpb.DownlinkPath{
295+
Path: &ttnpb.DownlinkPath_UplinkToken{
296+
UplinkToken: io.MustUplinkToken(
297+
&ttnpb.GatewayAntennaIdentifiers{GatewayIds: &registeredGatewayID},
298+
uint32(syncConcentratorTime/time.Microsecond), // nolint:gosec
299+
scheduling.ConcentratorTime(syncConcentratorTime),
300+
time.Unix(0, int64(syncConcentratorTime)),
301+
nil,
302+
),
303+
},
304+
}
305+
msg := &ttnpb.DownlinkMessage{
306+
RawPayload: []byte{0x01},
307+
Settings: &ttnpb.DownlinkMessage_Request{
308+
Request: &ttnpb.TxRequest{
309+
Class: ttnpb.Class_CLASS_A,
310+
Priority: ttnpb.TxSchedulePriority_NORMAL,
311+
Rx1Delay: ttnpb.RxDelay_RX_DELAY_1,
312+
Rx1DataRate: &ttnpb.DataRate{
313+
Modulation: &ttnpb.DataRate_Lora{
314+
Lora: &ttnpb.LoRaDataRate{
315+
SpreadingFactor: 7,
316+
Bandwidth: 125000,
317+
CodingRate: band.Cr4_5,
318+
},
319+
},
320+
},
321+
Rx1Frequency: 868100000,
322+
FrequencyPlanId: test.EUFrequencyPlanID,
323+
},
324+
},
325+
}
326+
_, _, _, err = conn.ScheduleDown(path, msg)
327+
if !a.So(err, should.BeNil) {
328+
t.FailNow()
329+
}
330+
331+
// Compute the wall-clock time at which the timer goroutine would call write().
332+
// serverTime(T) = clockSynced + (T - syncConcentratorTime); with ScheduleLateTime=0,
333+
// d = time.Until(serverTime(scheduledTimestamp)).
334+
scheduledTimestamp := time.Duration(msg.GetScheduled().Timestamp) * time.Microsecond
335+
expectedFireTime := clockSynced.Add(-syncConcentratorTime).Add(scheduledTimestamp)
336+
337+
// Give handleDown time to dequeue the message and start the timer goroutine.
338+
time.Sleep(timeout)
339+
340+
// Cancel the connection. The goroutine must observe ctx.Done() and exit
341+
// without calling write(), so no PULL_RESP should be sent to the gateway.
342+
conn.Disconnect(context.Canceled)
343+
344+
// Read from the UDP connection until expectedFireTime + margin. A broken
345+
// implementation (time.AfterFunc) would deliver a PULL_RESP near
346+
// expectedFireTime. With the fix the goroutine exits on cancel and nothing
347+
// is written.
348+
var buf [65507]byte
349+
udpConn.SetReadDeadline(expectedFireTime.Add(2 * timeout)) // nolint:errcheck,gosec
350+
n, readErr := udpConn.Read(buf[:])
351+
if readErr == nil {
352+
var pkt encoding.Packet
353+
if unmarshalErr := pkt.UnmarshalBinary(buf[:n]); unmarshalErr == nil {
354+
a.So(pkt.PacketType, should.NotEqual, encoding.PullResp)
355+
}
356+
}
357+
// A deadline-exceeded error means nothing was written — the expected outcome.
358+
}
359+
205360
func TestFrontend(t *testing.T) {
206361
t.Parallel()
207362
iotest.Frontend(t, iotest.FrontendConfig{

0 commit comments

Comments
 (0)