Skip to content

Give each started Automaton instance its own timers - #5199

Open
simpleqt wants to merge 1 commit into
secdev:masterfrom
simpleqt:sq/automaton-per-instance-timers
Open

simpleqt wants to merge 1 commit into
secdev:masterfrom
simpleqt:sq/automaton-per-instance-timers

Conversation

@simpleqt

Copy link
Copy Markdown

Problem

The @ATMT.timeout and @ATMT.timer decorators create one Timer object per decorated condition, registered on the class (scapy/automaton.py:596 def deco(f, state=state, timeout=Timer(timeout)), collected into the class-level cls.timeout dict). Instances never shadow that dict, so every running instance of the same automaton shares the same Timer objects.

A Timer's runtime state (_time, _expired, _just_expired) is per-run bookkeeping that is consumed exactly once:

  • _decrement() flips _expired the first time the remaining time hits zero,
  • expired() hands out the timers whose _just_expired is set and resets that flag for whoever asked first.

With two instances of the same automaton running concurrently — exactly what Automaton.spawn() ("start the automaton for each new client") and @ATMT.ioevent(as_supersocket=...) do — whichever control thread decrements a shared timer first consumes the expiration. The other instance's until_next() keeps returning None (blocking) for that timer, so its select() blocks forever: the timeout silently never fires for it. spawn()'s housekeeping only reclaims instances with isrunning() == False, so the wedged instance also leaks its sockets.

Measured on current master (two instances, one 0.2s timeout each):

unpatched: a.isrunning()=False  b.isrunning()=True   <- b never observed its timeout
patched:   both instances reach END within the timeout

Changes

_do_control copies the class-level timers into a per-instance self.timeout when the instance starts (after parse_args(), so pre-start reconfiguration through timer_by_name() — which mutates the class-level timers — is still honored; _func and the configuration stay shared, only the runtime state becomes per-instance).

Existing semantics verified unchanged: the timer-count assertions from automaton.uts (10/6 counts, timer_by_name timeouts), the pre-start "reconfigure timers" flow, and class defaults staying pristine across repeated runs.

Testing

New case in test/scapy/automaton.uts races two instances of a one-timeout automaton and asserts both observe their own timeout (fails on unpatched master by hanging one instance; passes with the patch). Locally:

$ python verify_race.py        # unpatched
a done: False a.fired: False   <- instance a wedged
$ python verify_race.py        # patched
OK: both instances observed their own timeout

flake8 (max-line 88, repo ignores) clean on the changed file aside from pre-existing F401s that only appear with pyflakes ≥3 (type-comment usage; invisible to the pinned flake8<6 CI uses).

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)
Copilot AI lite review requested due to automatic review settings September 24, 2026 18:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.87%. Comparing base (e4742a5) to head (336482a).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5199      +/-   ##
==========================================
- Coverage   80.92%   80.87%   -0.05%     
==========================================
  Files         393      393              
  Lines       98107    98114       +7     
==========================================
- Hits        79392    79354      -38     
- Misses      18715    18760      +45     
Files with missing lines Coverage Δ
scapy/automaton.py 80.75% <100.00%> (-1.02%) ⬇️

... and 27 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants