From 36fc565d9bf349a76186bdfab3da4b61cfeda7e4 Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:05:19 +0800 Subject: [PATCH] Give each started Automaton instance its own timers The @ATMT.timeout and @ATMT.timer decorators register their Timer objects on the class (one Timer per decorated condition), and instances never shadowed that dict, so every running instance shared the same Timer objects. A Timer's runtime state (_time, _expired, _just_expired) is per-run bookkeeping consumed by exactly one control thread: _decrement() flips _expired once, and expired() resets _just_expired for whoever asks first. With two instances of the same automaton running concurrently - exactly what Automaton.spawn() and @ATMT.ioevent(as_supersocket=...) are for - the instance whose control thread decrements a shared timer first consumes the expiration; the loser's until_next() keeps returning None and its select() blocks forever. The timeout silently never fires for it, and since spawn()'s housekeeping only reclaims instances with isrunning() == False, the wedged instance also leaks its sockets. Measured (two instances, one 0.2s timeout each): unpatched: a.isrunning()=False b.isrunning()=True <- b hung patched: both instances end within the timeout Copy the class-level timers per started instance in _do_control, after parse_args(), so pre-start reconfiguration through timer_by_name() (which mutates the class-level timers) is still honored, and class defaults stay pristine across runs (verified against the existing timer counts and the reconfigure tests). AI-Assisted: yes (GLM-5.3 via ZCode) --- scapy/automaton.py | 18 ++++++++++++++++++ test/scapy/automaton.uts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/scapy/automaton.py b/scapy/automaton.py index f26370b102f..f29e4d7857d 100644 --- a/scapy/automaton.py +++ b/scapy/automaton.py @@ -11,6 +11,7 @@ - add documentation for ioevent, as_supersocket... """ +import copy import ctypes import itertools import logging @@ -1308,6 +1309,23 @@ def _do_control(self, ready, *args, **kargs): k.update(kargs) self.parse_args(*a, **k) + # The timers are registered on the class by the @ATMT.timeout and + # @ATMT.timer decorators, but their runtime state (_time, _expired, + # _just_expired) is per-run bookkeeping. Left shared on the class, + # concurrently running instances of the same automaton (Automaton.spawn, + # ioevents used as supersockets) race for the one-shot expiry flags: + # whichever control thread decrements a timer first consumes the + # expiration, and the other instances never observe the timeout and + # block forever in select(). Copy the timers per started instance, + # after parse_args() so pre-start reconfiguration through + # timer_by_name() (which mutates the class-level timers) is honored. + self.timeout = {} # type: Dict[str, _TimerList] + for state_name, timers in type(self).timeout.items(): + timer_list = _TimerList() + for t in timers: + timer_list.add_timer(copy.copy(t)) + self.timeout[state_name] = timer_list + # Start the automaton self.state = self.initial_states[0](self) self.send_sock = self.sock or self.send_sock_class(**self.socket_kargs) diff --git a/test/scapy/automaton.uts b/test/scapy/automaton.uts index 6aec9b0bbac..d71c2820de3 100644 --- a/test/scapy/automaton.uts +++ b/test/scapy/automaton.uts @@ -386,6 +386,42 @@ assert sm.timer_by_name("goto_end")._timeout == 1 assert sm.count1 == 10 assert sm.count2 == 6 +~ timers are per-instance when several instances run concurrently + +import time + + +class TimerRaceTest(Automaton): + @ATMT.state(initial=1) + def BEGIN(self): + self.fired = False + + @ATMT.timeout(BEGIN, 0.2) + def goto_end(self): + self.fired = True + raise self.END() + + @ATMT.state(final=1) + def END(self): + pass + + +a = TimerRaceTest(ll=lambda: None, recvsock=lambda: None) +b = TimerRaceTest(ll=lambda: None, recvsock=lambda: None) +assert a.timer_by_name("goto_end") is not None +assert b.timer_by_name("goto_end") is not None + +a.runbg() +b.runbg() +deadline = time.time() + 10 +while time.time() < deadline and (a.isrunning() or b.isrunning()): + time.sleep(0.05) +assert not a.isrunning(), "instance a never observed its timeout" +assert not b.isrunning(), "instance b never observed its timeout" +assert a.fired and b.fired +a.stop() +b.stop() + ~ reconfigure timers sm = TimerTest(ll=lambda: None, recvsock=lambda: None)