fluidattacks-agent 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/.gitignore +1 -1
  2. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/PKG-INFO +1 -1
  3. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/deliver.py +23 -3
  4. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/executions.py +3 -1
  5. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/startup.py +50 -24
  6. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/pyproject.toml +1 -1
  7. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/README.md +0 -0
  8. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/__init__.py +0 -0
  9. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/batch.py +0 -0
  10. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/distributions.py +0 -0
  11. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/gate.py +0 -0
  12. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/loads.py +0 -0
  13. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/observer.py +0 -0
  14. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/outbox.py +0 -0
  15. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/patience.py +0 -0
  16. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/post.py +0 -0
  17. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/report.py +0 -0
  18. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/settings.py +0 -0
  19. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/sink.py +0 -0
  20. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent/switch.py +0 -0
  21. {fluidattacks_agent-0.1.1 → fluidattacks_agent-0.1.2}/fluidattacks_agent.pth +0 -0
@@ -1,4 +1,4 @@
1
- # Rust build artifacts (top-level crate and the ebpf/ sub-crates)
1
+ # Rust build artifacts
2
2
  target/
3
3
 
4
4
  # Python tooling caches from the probe's lint and test targets
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks-agent
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: In-process probe reporting what a Python workload imports and runs
5
5
  Project-URL: Homepage, https://fluidattacks.com
6
6
  Project-URL: Source, https://gitlab.com/fluidattacks/universe/-/tree/trunk/watches/agents/python
@@ -46,6 +46,12 @@ class Deliverer:
46
46
  guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
47
47
  # one pass at a time: what is taken but not delivered lives in one place
48
48
  passing: threading.Lock = field(default_factory=threading.Lock, repr=False)
49
+ # told once a period went by with the far end in reach and nothing left to
50
+ # carry, so what the probe still holds goes out before it is any older
51
+ remind: Callable[[], None] = field(default=lambda: None, repr=False)
52
+ # when the probe was last reminded: a far end that refuses and then takes
53
+ # ends a wait every standoff, and a wait ending is not a period going by
54
+ reminded: float = 0.0
49
55
  leaving: bool = False
50
56
  delivered: int = 0
51
57
 
@@ -75,10 +81,23 @@ class Deliverer:
75
81
  while not self.leaving:
76
82
  # a thread that dies takes delivery with it and tells nobody
77
83
  with contextlib.suppress(Exception):
78
- self.woken.wait(self._waiting())
84
+ roused = self.woken.wait(self._waiting())
79
85
  self.woken.clear()
80
- if time.monotonic() >= self.due:
81
- self.offering()
86
+ if time.monotonic() < self.due:
87
+ continue
88
+ self.offering()
89
+ # a period, never a wake: a wake says there is something to
90
+ # carry, and asking for more on one made each flush bring the
91
+ # next. Nothing in hand, or the far end is still refusing
92
+ now = time.monotonic()
93
+ if (
94
+ not roused
95
+ and self.holding is None
96
+ and not self.leaving
97
+ and now - self.reminded >= self.interval
98
+ ):
99
+ self.reminded = now
100
+ self.remind()
82
101
 
83
102
  def parting(self) -> None:
84
103
  """Give whatever is held one bounded chance to travel, then give up."""
@@ -109,6 +128,7 @@ class Deliverer:
109
128
  # every worker of a pre-forking server was born in the same second
110
129
  self.due = 0.0
111
130
  self.tries = 0
131
+ self.reminded = 0.0
112
132
  # a child is not on its way out merely because its parent was
113
133
  self.leaving = False
114
134
  self.batcher.forked()
@@ -13,7 +13,9 @@ from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record,
13
13
  # ignores it, so nothing changes until the floor moves
14
14
  __lazy_modules__ = ["pathlib"]
15
15
 
16
- MAX_FUNCTIONS: Final = 4096
16
+ # a real application runs some 26,000 distinct functions while it starts, and
17
+ # what filled the ceiling is what never gets named again for the process's life
18
+ MAX_FUNCTIONS: Final = 32768
17
19
 
18
20
  OURS: Final = "fluidattacks_agent"
19
21
 
@@ -1,5 +1,6 @@
1
1
  """Start the probe inside a workload and leave reports where the agent drains."""
2
2
 
3
+ import _thread
3
4
  import atexit
4
5
  import contextlib
5
6
  import os
@@ -19,7 +20,8 @@ from fluidattacks_agent.settings import Delivery, asked, delivery
19
20
  from fluidattacks_agent.sink import FileSink, Holding, Sink, Stalled
20
21
  from fluidattacks_agent.switch import switched_on
21
22
 
22
- FLUSH_EVERY: Final = 32
23
+ # first sightings between flushes. The carrier's clock brings the rest
24
+ FLUSH_EVERY: Final = 128
23
25
 
24
26
  # what a site directory is given so that installing the package starts the
25
27
  # probe. The agent writes the same line when it injects one it did not install
@@ -74,6 +76,13 @@ class Probe:
74
76
  # sampled when the events were armed, not when the report is written: the
75
77
  # later reading governs the next window rather than the one reported
76
78
  solitary: bool = False
79
+ # set off the workload's threads, acted on by the next sighting on one of
80
+ # them, since what is held is walked nowhere else
81
+ overdue: bool = False
82
+ # every thread sees overdue at once, so the flush is gated by a lock nothing
83
+ # waits on: a second caller finds it taken and returns. From _thread, which
84
+ # every interpreter start has already paid for, and threading is not
85
+ gate: _thread.LockType = field(default_factory=_thread.allocate_lock, repr=False)
77
86
 
78
87
  def find_spec(
79
88
  self,
@@ -93,10 +102,8 @@ class Probe:
93
102
  def _looked(self, fullname: str) -> None:
94
103
  if self.flushing:
95
104
  return
96
- held = self.observer.suppressed
97
105
  module = self.observer.note(fullname)
98
- fresh = 0 if module is None else self._mark(Evidence.IMPORTED, module)
99
- self._pile(fresh or int(self.observer.suppressed != held))
106
+ self._pile(0 if module is None else self._mark(Evidence.IMPORTED, module))
100
107
 
101
108
  def read(self, path: str) -> None:
102
109
  """Note a file the workload opened, and report as those pile up too."""
@@ -104,21 +111,17 @@ class Probe:
104
111
  # is the workload using anything
105
112
  if self.flushing:
106
113
  return
107
- held = self.loads.suppressed
108
114
  package = self.loads.note(path)
109
115
  # tested here and not inside: the hook runs on every open the host
110
116
  # makes, and all but a few are not ours to stamp
111
- fresh = 0 if package is None else self._mark(Evidence.READ, package)
112
- self._pile(fresh or int(self.loads.suppressed != held))
117
+ self._pile(0 if package is None else self._mark(Evidence.READ, package))
113
118
 
114
119
  def ran(self, filename: str, qualname: str) -> None:
115
120
  """Note code starting, and report as first sightings pile up too."""
116
121
  if self.flushing:
117
122
  return
118
- held = self.runs.suppressed
119
123
  symbol = self.runs.note(filename, qualname)
120
- fresh = 0 if symbol is None else self._mark(Evidence.EXECUTED, symbol)
121
- self._pile(fresh or int(self.runs.suppressed != held))
124
+ self._pile(0 if symbol is None else self._mark(Evidence.EXECUTED, symbol))
122
125
 
123
126
  def _mark(self, evidence: Evidence, symbol: str) -> int:
124
127
  """Stamp a sighting, saying whether it was the first of its symbol."""
@@ -130,11 +133,17 @@ class Probe:
130
133
  self.first[key] = seen
131
134
  return 1
132
135
 
136
+ def remind(self) -> None:
137
+ """Have the next sighting bring a flush, however few have piled up."""
138
+ # a flag and never a flush: the carrier's clock calls this from a
139
+ # thread of its own, and a flush runs on the workload's alone
140
+ self.overdue = True
141
+
133
142
  def _pile(self, added: int) -> None:
134
- # a sighting with no room counts too, or a probe at its ceiling stops
135
- # flushing exactly when it has the most to say
143
+ # first sightings only: a flush re-arms every code object, so counting
144
+ # one seen again, or one with no room, made each flush bring the next
136
145
  self.pending += added
137
- if self.pending >= FLUSH_EVERY:
146
+ if self.overdue or self.pending >= FLUSH_EVERY:
138
147
  # cleared before the call, because monitoring reports the entry of
139
148
  # flush itself and would otherwise pile up and call it again
140
149
  self.pending = 0
@@ -142,9 +151,11 @@ class Probe:
142
151
 
143
152
  def flush(self) -> None:
144
153
  """Write what has not been reported, without ever raising at a caller."""
145
- if self.flushing:
154
+ if not self.gate.acquire(blocking=False):
146
155
  return
147
156
  self.flushing = True
157
+ # spent here, so a reminder given while this runs brings one more flush
158
+ self.overdue = False
148
159
  try:
149
160
  with contextlib.suppress(Exception):
150
161
  if self._sighted():
@@ -152,8 +163,18 @@ class Probe:
152
163
  finally:
153
164
  self.pending = 0
154
165
  self.flushing = False
166
+ self.gate.release()
155
167
  self._rearm()
156
168
 
169
+ def forked(self) -> None:
170
+ """Leave a child able to flush, whatever its parent was doing at the fork."""
171
+ # a lock another thread held at the fork is held in the child forever,
172
+ # and a flag it set stays set: the child has no thread left to clear it
173
+ self.gate = _thread.allocate_lock()
174
+ self.flushing = False
175
+ self.overdue = False
176
+ self.pending = 0
177
+
157
178
  def _owed(self) -> bool:
158
179
  """Say whether a count is waiting for a report that can state it."""
159
180
  return bool(self.refused or self.undated)
@@ -371,23 +392,28 @@ def start(sink: Sink | None = None) -> Probe | None:
371
392
  return None
372
393
 
373
394
 
374
- def chosen() -> Sink:
375
- """Give a workload what it asked for, and something loud if it asked badly."""
395
+ def chosen(remind: Callable[[], None]) -> Sink:
396
+ """
397
+ Give a workload what it asked for, and something loud if it asked badly.
398
+
399
+ ``remind`` is what a carrier calls, off the workload's threads, once a
400
+ period has gone by with the far end in reach and nothing left to carry.
401
+ """
376
402
  if not asked():
377
403
  return FileSink()
378
404
  told = delivery()
379
405
  if told is None:
380
406
  # half configured must be loud, not indistinguishable from quiet
381
407
  return Holding()
382
- return carrier(told)
408
+ return carrier(told, remind)
383
409
 
384
410
 
385
- def carrier(told: Delivery) -> Sink:
411
+ def carrier(told: Delivery, remind: Callable[[], None]) -> Sink:
386
412
  """Build the one thing that carries reports out, and wire it to this process."""
387
413
  # imported here, so a workload that never asked pays for none of it
388
414
  from fluidattacks_agent.deliver import Deliverer # noqa: PLC0415
389
415
 
390
- held = Deliverer(told=told)
416
+ held = Deliverer(told=told, remind=remind)
391
417
  # before the flush handler, since exit handlers run in reverse order
392
418
  atexit.register(held.parting)
393
419
  held.registered()
@@ -401,11 +427,11 @@ def _start(sink: Sink | None) -> Probe | None:
401
427
  if not switched_on():
402
428
  return None
403
429
  sites = site_dirs()
404
- probe = Probe(
405
- sink=chosen() if sink is None else sink,
406
- loads=LoadObserver(sites=sites),
407
- runs=RunObserver(sites=sites),
408
- )
430
+ probe = Probe(loads=LoadObserver(sites=sites), runs=RunObserver(sites=sites))
431
+ # after the probe, because what carries its reports is told what to remind
432
+ probe.sink = chosen(probe.remind) if sink is None else sink
433
+ if hasattr(os, "register_at_fork"):
434
+ os.register_at_fork(after_in_child=probe.forked)
409
435
  install(probe)
410
436
  sys.addaudithook(watch(probe))
411
437
  probe.monitoring = observe_runs(probe)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks-agent"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "In-process probe reporting what a Python workload imports and runs"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]