Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

### Fixed

- **Editing "Target process" during a session changes nothing until you click "Apply
changes".** The field used to reach the running session on its own. Clearing it to type
a new name, or stopping halfway through an expression such as `re:^fire(`, switched
process targeting off, so every connection on the computer was impaired, while the
note under the field said traffic was not being impaired. The field now works like
every other field: "Apply changes" lights up, and the session keeps its target until
you click it.

- **The note under "Target process" tells the truth when targeting cannot be used.**
When an applied target cannot narrow the session, for example because psutil is not
installed, every connection in the traffic filter is impaired, and the note now says
so. It used to say that no traffic was being impaired. The note also goes away when
the session stops.

- **The program no longer freezes while it records an internal error.** Writing a crash
report used to read the Control page form. If a field held a value the program cannot
accept, such as a letter typed into Loss, or if a background task hit an error at the
Expand Down
102 changes: 41 additions & 61 deletions beantester/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
from ..processes import port_process_map
from ..repro import save_repro_report, settings_to_cli_string
from ..scenario import load_scenario_file
from ..settings import (DEFAULT_SETTINGS, apply_settings, apply_targeting,
load_config_file, non_profile_active, save_config_file,
settings_from_raw, warn_if_unbounded)
from ..settings import (DEFAULT_SETTINGS, apply_settings, load_config_file,
non_profile_active, save_config_file, settings_from_raw,
warn_if_unbounded)
from ..summary import settings_summary
from ..utils import number_string
from . import crash as gui_crash
Expand Down Expand Up @@ -131,7 +131,9 @@ def __init__(self, root):
self._ui_errors_shown = set()
self.engine = BeanEngine(self.log)
self.running = False
self._applied_target = None # last expression pushed to the engine
# The target the user last APPLIED (START / "Apply changes"), not what the
# field holds now. Only the banner reads it - see _refresh_target_verdict.
self._applied_target = ""
# Start/stop run their blocking parts (WinDivert driver load ~0.5-1 s, and
# the worker-thread joins on stop) OFF the UI thread, so the window never
# freezes. The worker leaves its result in _ui_queue and the main thread
Expand All @@ -142,11 +144,6 @@ def __init__(self, root):
self._transition_thread = None
self._pending_start_settings = None
self._closing = False
# Main-thread snapshot of the targeting fields. The refresher thread used
# to call tk Variable.get() directly - a Tcl call from a worker thread,
# which is exactly the kind of thing that makes Tk hang or crash at
# random (and then the process lingers, holding the WinDivert driver).
self._target_expr = ""
# The refresher thread's verdict on the target ("matches nothing", or ""),
# handed to the main thread the same way log lines are: the thread writes a
# plain string, _tick() puts it on the widget. It used to call
Expand Down Expand Up @@ -1194,6 +1191,7 @@ def apply_if_running(self, *_, announce=False):
self.log(f"{T('log.error')}: {e}")
return
apply_settings(self.engine, s, self.log)
self._applied_target = str(s.get("target", "")).strip()
# A session can BECOME unbounded: clear the target, press "Apply changes",
# and from that moment everything on the machine is in scope. Warning only
# at START would mean the one path that reaches this state in silence is
Expand All @@ -1212,18 +1210,6 @@ def reset_now_click(self):
self.engine.reset_now(3.0)
self.log(T("log.resetting"))

def _snapshot_target(self):
"""Read the target field ON THE MAIN THREAD (tkinter is not thread-safe).

The refresher thread only ever sees this plain string.
"""
try:
expression = str(self.vars["target"].get()).strip()
except Exception:
expression = ""
self._target_expr = expression
return self._target_expr

def set_target_warning(self, text):
"""Show (or clear) the "targeting is doing nothing" banner.

Expand Down Expand Up @@ -1282,41 +1268,35 @@ def _drain_target_warning(self):
self._shown_target_warning = text
self.set_target_warning(text)

def _refresh_target(self, force=False):
"""Keep the engine's target in step with the field, and report what it caught.

This used to run on a 2 s background loop (``_target_refresher``, removed).
Every pass called ``apply_targeting``, which resolved the port set
SYNCHRONOUSLY - four syscalls and a psutil walk - on a thread nobody
watched. Worse, the loop was never joined while ``_finish_start`` spawned a
new one on every start, so a STOP followed by a START inside its sleep left
the old one running as well: one extra permanent scanner per fast restart.

Keeping the port set fresh is ``target_resolver``'s job now. What is left
here is cheap and runs on the main thread from ``_tick``: apply the
expression when the USER changed it, then read the verdict.

NO WIDGET IS TOUCHED HERE - the verdict goes into a plain field and
``_drain_target_warning`` renders it. Deliberately kept that way: it is the
shape that stops a Tcl call ever leaving the main thread, whoever calls this
next (convention 26).
def _refresh_target_verdict(self):
"""Say what the APPLIED target catches. It reads; it never applies anything.

The target reaches the engine the way every other field does: START and
"Apply changes", through ``apply_settings`` - validated, announced, and
followed by the unbounded-impairment warning (convention 15, "nothing
applies itself"). Until 2026-09-28 this ran from every tick and pushed the
RAW field to the engine whenever it changed. Clearing the field to type a
new name, or a half-typed ``re:^fire(``, switched targeting off - every
connection in the filter impaired - while the banner said the opposite.

The verdict follows the ENGINE, not the field: a scenario step may change
the target too, and what is typed but not applied changes nothing yet (the
Apply button says so). Keeping the port set fresh is ``target_resolver``'s
job.

NO WIDGET IS TOUCHED HERE and no tk variable is read - the verdict goes into
a plain field and ``_drain_target_warning`` renders it. It is the shape that
stops a Tcl call ever leaving the main thread, whoever calls this next
(convention 26).
"""
expression = self._target_expr
if force or expression != self._applied_target:
self._applied_target = expression
if not expression:
self.engine.set_target(False)
else:
# One shared implementation (settings.apply_targeting) compiles the
# expression and points the engine at it, so the GUI and
# apply_settings can never drift apart.
apply_targeting(self.engine, expression, self.log, announce=force)
if not expression:
self._pending_target_warning = ""
return
targeting = self.engine.targeting()
if targeting is None:
self._pending_target_warning = T("fields.target_no_match")
# Nothing narrows the session. That is only news when a target WAS
# applied - an expression that narrows nothing, no psutil, or a regex
# refused at apply time - and then EVERY connection is impaired. The
# banner used to say "traffic is NOT being impaired" here.
self._pending_target_warning = (
T("fields.target_all_traffic") if self._applied_target else "")
return
if targeting.refreshes == 0:
if self.engine.is_running():
Expand Down Expand Up @@ -1404,13 +1384,12 @@ def _finish_start(self, err):
if self._scenario is not None:
self._scenario.loop = self.loop_var.get()
self.engine.start_scenario(self._scenario, s, log=self.log)
self._snapshot_target()
note = scope.capture_scope_note(s, self.engine.capture_narrowed())
if note:
self.log(T(note))
# No refresher thread any more: _tick applies a changed expression and the
# engine's resolver keeps the port set fresh (see _refresh_target).
self._applied_target = None # re-apply once, now that the engine is up
# What START applied (the worker ran apply_settings with `s`), not what
# the field holds now - an edit made while the driver loaded is unapplied.
self._applied_target = str(s.get("target", "")).strip()
self._sync_running_ui()

def _stop(self):
Expand Down Expand Up @@ -1793,11 +1772,12 @@ def _tick(self):
self._drain_target_warning() # render the target verdict (main thread)
self._drain_engine_warning() # "the tool itself is dropping packets"
self._sample()
self._snapshot_target() # main-thread read of the target field
if self.running:
# Cheap now: applies only when the expression changed, and the
# resolving happens on the engine's resolver thread.
self._refresh_target()
# Reads the verdict on what START / "Apply changes" applied and
# never applies anything itself (convention 15, see the method).
self._refresh_target_verdict()
else:
self._pending_target_warning = "" # nothing is impaired when stopped
if self._transition is None and self.running and not self.engine.is_running():
self._on_engine_stopped() # deadline reached / worker fault
if self._visible():
Expand Down
1 change: 1 addition & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"fields.spike_prob": "Spike chance:",
"fields.spike_prob_up": "Spike chance up:",
"fields.syn_drop": "Dropped TCP SYN:",
"fields.target_all_traffic": "Process targeting is not active - every connection in the traffic filter is being impaired.",
"fields.target_dest": "Target dest",
"fields.target_example": "e.g. chrome.exe, 12345, re:^fire",
"fields.target_no_match": "No running process matches this target - traffic is NOT being impaired.",
Expand Down
1 change: 1 addition & 0 deletions lang/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"fields.spike_prob": "Szansa skoku:",
"fields.spike_prob_up": "Szansa skoku w górę:",
"fields.syn_drop": "Gubione TCP SYN:",
"fields.target_all_traffic": "Celowanie w proces nie działa - modyfikowane jest każde połączenie objęte filtrem ruchu.",
"fields.target_dest": "Celuj w cel",
"fields.target_example": "np. chrome.exe, 12345, re:^fire",
"fields.target_no_match": "Żaden działający proces nie pasuje do tego celu - ruch NIE jest modyfikowany.",
Expand Down
1 change: 1 addition & 0 deletions lang/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"fields.spike_prob": "尖峰概率:",
"fields.spike_prob_up": "上传尖峰概率:",
"fields.syn_drop": "丢弃 TCP SYN:",
"fields.target_all_traffic": "进程定位未生效。流量过滤器中的所有连接都在受到弱网影响。",
"fields.target_dest": "目标地址",
"fields.target_example": "例如 chrome.exe、12345、re:^fire",
"fields.target_no_match": "没有正在运行的进程符合此目标。流量当前不会受到弱网影响。",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_code_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ def test_the_known_unused_list_only_ever_shrinks():
# ratchet rather than a list of the usual suspects: a NEW file full of silent
# handlers cannot slip in by simply not being mentioned.
SILENT_BROAD_HANDLERS = {
"gui/app.py": 13,
"gui/app.py": 12,
# 12 on 2026-09-06, then seven were dealt with in the same change - the file
# this inventory was built to look at first, because it is on the targeting
# path. The five left are per-PID lookups (`_make_native`, the two halves of
Expand Down
15 changes: 9 additions & 6 deletions tests/test_code_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@
# ceiling exactly, so the two CSV exports moved to `gui/csv_export.py` instead of the
# number moving up. The crowd band below was re-measured after the drop (`engine.py`
# is 779, still clear of it) - lowering a ceiling tightens that band too.
FILE_CEILING = 1166 # beantester/gui/app.py
# Lowered 2026-09-28 from 1166: the target field stopped being applied from the
# tick (convention 15), which took `_snapshot_target` and half of the old
# `_refresh_target` out of `app.py`.
FILE_CEILING = 1150 # beantester/gui/app.py

# 🔴 THE SECOND KNOB. A ceiling on the worst single item sees one thing growing
# to a record and is blind to everything creeping upward together: five files at
Expand Down Expand Up @@ -790,11 +793,11 @@ def test_the_strictly_typed_modules_only_ever_grow():
# in this file: down is routine, up is the owner's decision, and the numbers must
# BE the measurement rather than sit above it (two tests below enforce that, the
# same pair that guards the ceilings).
CLASS_METHOD_CEILING = 96 # gui/app.py::App
CLASS_ATTR_CEILING = 80 # gui/app.py::App
# The crowd counts, on the same 70% band as the sizes. Methods: App (96) and
# BeanEngine (69) against a band of 67.2. Attributes: App (80) and BeanCore (57)
# against a band of 56.0 - and BeanCore is the interesting one, because it is a
CLASS_METHOD_CEILING = 95 # gui/app.py::App (96 until 2026-09-28: _snapshot_target)
CLASS_ATTR_CEILING = 79 # gui/app.py::App (80 until 2026-09-28: _target_expr)
# The crowd counts, on the same 70% band as the sizes. Methods: App (95) and
# BeanEngine (69) against a band of 66.5. Attributes: App (79) and BeanCore (57)
# against a band of 55.3 - and BeanCore is the interesting one, because it is a
# 485-line file that no size ratchet has ever had a reason to look at. Fifty-seven
# attributes is what a decision core with twelve pipeline steps accumulates.
CLASSES_NEAR_METHOD_CEILING = 2 # App, BeanEngine
Expand Down
28 changes: 12 additions & 16 deletions tests/test_failsafe.py
Original file line number Diff line number Diff line change
Expand Up @@ -951,32 +951,28 @@ def test_the_ui_notices_when_the_engine_stops_itself():
""")


def test_target_syncing_reads_only_the_main_thread_snapshot():
"""``_refresh_target`` works off ``_target_expr``, never off the tk variable.
def test_the_target_verdict_never_reads_the_tk_variable():
"""``_refresh_target_verdict`` works off the engine and the applied target.

The background refresher that used to call this is gone (resolving moved to
``target_resolver``), but the separation it forced is worth keeping: the
snapshot is taken on the main thread, and everything downstream consumes the
plain string. That is what makes it safe to call this from anywhere later.
It never touches the tk variable: that is what makes it safe to call from
anywhere, and since 2026-09-28 it is also the rule - the field reaches the
engine only through "Apply changes", so the verdict has no business reading it.
"""
run_gui("""
app.vars["target"].set("chrome.exe")
assert app._snapshot_target() == "chrome.exe"
from beantester.settings import apply_targeting

# an empty field means "no targeting" - there is no checkbox to tick
app.vars["target"].set(" ")
assert app._snapshot_target() == ""
apply_targeting(app.engine, "chrome.exe", announce=False)
app._applied_target = "chrome.exe"

# from now on the tk variable explodes if anything downstream reads it
# from now on the tk variable explodes if anything reads or writes it
class Exploding:
def get(self):
raise AssertionError("_refresh_target read the tk variable")
raise AssertionError("the verdict read the tk variable")
def set(self, *a):
raise AssertionError("_refresh_target wrote the tk variable")
raise AssertionError("the verdict wrote the tk variable")

app.vars["target"] = Exploding()
app._target_expr = "chrome.exe"
app._refresh_target() # consumes the snapshot only
app._refresh_target_verdict()
""")


Expand Down
Loading
Loading