@@ -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+
205360func TestFrontend (t * testing.T ) {
206361 t .Parallel ()
207362 iotest .Frontend (t , iotest.FrontendConfig {
0 commit comments