fluidattacks-agent 0.2.0__tar.gz → 0.3.0__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 (22) hide show
  1. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/PKG-INFO +1 -1
  2. fluidattacks_agent-0.3.0/fluidattacks_agent/executions.py +162 -0
  3. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/loads.py +57 -27
  4. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/observer.py +63 -35
  5. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/report.py +58 -4
  6. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/startup.py +92 -88
  7. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/pyproject.toml +1 -1
  8. fluidattacks_agent-0.2.0/fluidattacks_agent/executions.py +0 -124
  9. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/.gitignore +0 -0
  10. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/README.md +0 -0
  11. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/__init__.py +0 -0
  12. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/batch.py +0 -0
  13. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/deliver.py +0 -0
  14. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/distributions.py +0 -0
  15. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/gate.py +0 -0
  16. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/outbox.py +0 -0
  17. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/patience.py +0 -0
  18. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/post.py +0 -0
  19. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/settings.py +0 -0
  20. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/sink.py +0 -0
  21. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent/switch.py +0 -0
  22. {fluidattacks_agent-0.2.0 → fluidattacks_agent-0.3.0}/fluidattacks_agent.pth +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks-agent
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -0,0 +1,162 @@
1
+ """Watch code inside a package start running, where the interpreter allows it."""
2
+
3
+ import sys
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass, field
6
+ from pathlib import PurePath
7
+ from typing import Final
8
+
9
+ from fluidattacks_agent.distributions import Dist, distribution_of
10
+ from fluidattacks_agent.report import (
11
+ Ecosystem,
12
+ Evidence,
13
+ Granularity,
14
+ Record,
15
+ Sighting,
16
+ dated,
17
+ merged,
18
+ reached,
19
+ sighted,
20
+ trimmed,
21
+ )
22
+
23
+ # 3.15 makes the import below lazy from this; every version we support
24
+ # ignores it, so nothing changes until the floor moves
25
+ __lazy_modules__ = ["pathlib"]
26
+
27
+ # what one window holds at most: a real application runs some 26,000 distinct
28
+ # functions while it starts, and the start is the busiest window there is. A
29
+ # name refused past it is counted, and named again the next window it runs in
30
+ MAX_FUNCTIONS: Final = 32768
31
+
32
+ OURS: Final = "fluidattacks_agent"
33
+
34
+ SOURCE: Final = ".py"
35
+
36
+ INITIALIZER: Final = "__init__"
37
+ PARENT: Final = ".."
38
+
39
+ TOOL: Final = 3
40
+ TOOL_NAME: Final = "fluidattacks-agent"
41
+
42
+
43
+ TOOLS: Final = 6
44
+
45
+
46
+ def available() -> bool:
47
+ """Say whether this interpreter can report code starting at all."""
48
+ return hasattr(sys, "monitoring")
49
+
50
+
51
+ def alone(tool: int = TOOL) -> bool:
52
+ """Say whether this is the only tool watching: restart_events takes none."""
53
+ held = (sys.monitoring.get_tool(other) for other in range(TOOLS) if other != tool)
54
+ return not any(name is not None for name in held)
55
+
56
+
57
+ def module_of(filename: str, sites: tuple[str, ...]) -> str | None:
58
+ """Name the module whose source a running code object came from."""
59
+ for site in sites:
60
+ prefix = site + "/"
61
+ if not filename.startswith(prefix) or not filename.endswith(SOURCE):
62
+ continue
63
+ held = PurePath(filename[len(prefix) :])
64
+ parts = list(held.parts)
65
+ if not parts or held.is_absolute() or PARENT in parts:
66
+ return None
67
+ stem = parts.pop()[: -len(SOURCE)]
68
+ if not stem:
69
+ return None
70
+ if stem != INITIALIZER:
71
+ parts.append(stem)
72
+ return ".".join(parts) or None
73
+ return None
74
+
75
+
76
+ @dataclass
77
+ class RunObserver:
78
+ """Functions that started in the window under way, and in no window before it."""
79
+
80
+ sites: tuple[str, ...] = ()
81
+ # the window's own: a fold takes the whole map, and the reader adds the
82
+ # windows up. So the bound is on a window and not on the process's life
83
+ seen: dict[str, Sighting] = field(default_factory=dict)
84
+ # what a fold took and did not report, for the next fold to offer again.
85
+ # The fold's own: no thread of the workload writes beside it. Bounded by
86
+ # the same ceiling, and what finds no room is counted with the refusals
87
+ owed: dict[str, Sighting] = field(default_factory=dict)
88
+ suppressed: int = 0
89
+ unkept: int = 0
90
+
91
+ def note(
92
+ self,
93
+ filename: str,
94
+ qualname: str,
95
+ clock: Callable[[], int] = reached,
96
+ ) -> str | None:
97
+ """Take note of code starting, naming the function whenever the window has room."""
98
+ module = module_of(filename, self.sites)
99
+ if module is None:
100
+ return None
101
+ if module.split(".", maxsplit=1)[0] == OURS:
102
+ return None
103
+ qualified = f"{module}.{qualname}"
104
+ # bound once: a fold may swap the window out meanwhile, and a sighting
105
+ # must land in the window it was counted against, never in the next
106
+ seen = self.seen
107
+ held = seen.get(qualified)
108
+ if held is None and len(seen) >= MAX_FUNCTIONS:
109
+ self.suppressed += 1
110
+ return None
111
+ seen[qualified] = sighted(held, clock())
112
+ return qualified
113
+
114
+ def drain(self) -> dict[str, Sighting]:
115
+ """Take the window whole, and what the last fold owed, leaving the next one empty."""
116
+ taken, self.seen = self.seen, {}
117
+ # into a map of the fold's own, so what a late sighting lands in the
118
+ # window taken is at most lost, and never written over by the fold
119
+ offered = self.owed
120
+ self.owed = {}
121
+ merged(offered, taken)
122
+ return offered
123
+
124
+ def restore(self, taken: Mapping[str, Sighting]) -> None:
125
+ """Keep for the next fold what this one took and did not report, as far as there is room."""
126
+ merged(self.owed, taken)
127
+ self.unkept += trimmed(self.owed, MAX_FUNCTIONS)
128
+
129
+ def records(
130
+ self,
131
+ taken: Mapping[str, Sighting],
132
+ installed: dict[str, Dist],
133
+ origin: int,
134
+ ) -> tuple[list[Record], dict[str, Sighting]]:
135
+ """Report every function a distribution owns, and say what the next window keeps."""
136
+ found: list[Record] = []
137
+ kept: dict[str, Sighting] = {}
138
+ scanned = bool(installed)
139
+ for qualified in sorted(taken):
140
+ held = taken[qualified]
141
+ dist = distribution_of(qualified, installed)
142
+ if dist is None:
143
+ # no distribution will own what a settled scan does not, and a
144
+ # scan that has found nothing yet may still own all of these
145
+ if not scanned:
146
+ kept[qualified] = held
147
+ continue
148
+ found.append(
149
+ Record(
150
+ ecosystem=Ecosystem.PYPI,
151
+ name=dist.name,
152
+ version=dist.version,
153
+ confidence=dist.confidence,
154
+ symbol=qualified,
155
+ granularity=Granularity.FUNCTION,
156
+ evidence=Evidence.EXECUTED,
157
+ frequency=held.times,
158
+ when=held.first,
159
+ used=dated(origin, held.last),
160
+ ),
161
+ )
162
+ return found, kept
@@ -1,13 +1,25 @@
1
1
  """Account for package code the interpreter read without importing it."""
2
2
 
3
3
  import os
4
- from collections.abc import Mapping
4
+ from collections.abc import Callable, Mapping
5
5
  from dataclasses import dataclass, field
6
6
  from types import ModuleType
7
7
 
8
8
  from fluidattacks_agent.distributions import Dist, distribution_of
9
- from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
9
+ from fluidattacks_agent.report import (
10
+ Ecosystem,
11
+ Evidence,
12
+ Granularity,
13
+ Record,
14
+ Sighting,
15
+ dated,
16
+ merged,
17
+ reached,
18
+ sighted,
19
+ trimmed,
20
+ )
10
21
 
22
+ # what one window holds at most, and not the process's life
11
23
  MAX_PACKAGES = 4096
12
24
 
13
25
  OURS = "fluidattacks_agent"
@@ -46,51 +58,67 @@ def package_of(path: str, sites: tuple[str, ...]) -> str | None:
46
58
 
47
59
  @dataclass
48
60
  class LoadObserver:
49
- """Packages whose code was read, bounded the way the reader's map is."""
61
+ """Packages whose code was read in the window under way."""
50
62
 
51
63
  sites: tuple[str, ...] = ()
52
64
  # how many files of each package were read, not merely whether any was
53
- seen: dict[str, int] = field(default_factory=dict)
54
- moved: set[str] = field(default_factory=set)
65
+ seen: dict[str, Sighting] = field(default_factory=dict)
66
+ # what a fold took and did not report, for the next fold to offer again.
67
+ # The fold's own: no thread of the workload writes beside it. Bounded by
68
+ # the same ceiling, and what finds no room is counted with the refusals
69
+ owed: dict[str, Sighting] = field(default_factory=dict)
55
70
  suppressed: int = 0
71
+ unkept: int = 0
56
72
 
57
- def note(self, path: str) -> str | None:
58
- """Take note of a file opened, naming the package whenever there is room."""
73
+ def note(self, path: str, clock: Callable[[], int] = reached) -> str | None:
74
+ """Take note of a file opened, naming the package whenever the window has room."""
59
75
  package = package_of(path, self.sites)
60
76
  if package is None or package == OURS:
61
77
  return None
62
- if package in self.seen:
63
- self.seen[package] += 1
64
- self.moved.add(package)
65
- return package
66
- if len(self.seen) >= MAX_PACKAGES:
78
+ seen = self.seen
79
+ held = seen.get(package)
80
+ if held is None and len(seen) >= MAX_PACKAGES:
67
81
  self.suppressed += 1
68
82
  return None
69
- self.seen[package] = 1
70
- self.moved.add(package)
83
+ seen[package] = sighted(held, clock())
71
84
  return package
72
85
 
86
+ def drain(self) -> dict[str, Sighting]:
87
+ """Take the window whole, and what the last fold owed, leaving the next one empty."""
88
+ taken, self.seen = self.seen, {}
89
+ # into a map of the fold's own, so what a late sighting lands in the
90
+ # window taken is at most lost, and never written over by the fold
91
+ offered = self.owed
92
+ self.owed = {}
93
+ merged(offered, taken)
94
+ return offered
95
+
96
+ def restore(self, taken: Mapping[str, Sighting]) -> None:
97
+ """Keep for the next fold what this one took and did not report, as far as there is room."""
98
+ merged(self.owed, taken)
99
+ self.unkept += trimmed(self.owed, MAX_PACKAGES)
100
+
73
101
  def records(
74
102
  self,
103
+ taken: Mapping[str, Sighting],
75
104
  modules: dict[str, ModuleType],
76
105
  installed: dict[str, Dist],
77
- carried: Mapping[tuple[str, str], int],
78
- ) -> list[Record]:
79
- """Report the packages that were read and never became modules."""
106
+ origin: int,
107
+ ) -> tuple[list[Record], dict[str, Sighting]]:
108
+ """Report the packages read that became no module, and say what the next window keeps."""
80
109
  found: list[Record] = []
110
+ kept: dict[str, Sighting] = {}
81
111
  scanned = bool(installed)
82
- for package in sorted(self.moved):
83
- times = self.seen[package]
84
- if times <= carried.get(keyed(Evidence.READ, package), 0):
85
- self.moved.discard(package)
86
- continue
112
+ for package in sorted(taken):
113
+ held = taken[package]
87
114
  if package in modules:
88
- # kept, because a name can leave sys.modules and be read again
115
+ # the stronger evidence has it. A name that leaves sys.modules
116
+ # and is read again is a sighting of the window that reads it
89
117
  continue
90
118
  dist = distribution_of(package, installed)
91
119
  if dist is None:
92
- if scanned:
93
- self.moved.discard(package)
120
+ if not scanned:
121
+ kept[package] = held
94
122
  continue
95
123
  found.append(
96
124
  Record(
@@ -103,7 +131,9 @@ class LoadObserver:
103
131
  # module it is would claim the interpreter reached it
104
132
  granularity=Granularity.PACKAGE,
105
133
  evidence=Evidence.READ,
106
- frequency=times,
134
+ frequency=held.times,
135
+ when=held.first,
136
+ used=dated(origin, held.last),
107
137
  ),
108
138
  )
109
- return found
139
+ return found, kept
@@ -1,16 +1,27 @@
1
1
  """Watch what a workload imports, without taking part in importing it."""
2
2
 
3
3
  import sys
4
- from collections.abc import Mapping
4
+ from collections.abc import Callable, Mapping
5
5
  from dataclasses import dataclass, field
6
6
  from types import ModuleType
7
7
  from typing import Protocol
8
8
 
9
9
  from fluidattacks_agent.distributions import Dist, distribution_of
10
- from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
11
-
12
- # the probe rides inside a workload we do not own, so its bookkeeping is
13
- # bounded the same way the reader's is
10
+ from fluidattacks_agent.report import (
11
+ Ecosystem,
12
+ Evidence,
13
+ Granularity,
14
+ Record,
15
+ Sighting,
16
+ dated,
17
+ merged,
18
+ reached,
19
+ sighted,
20
+ trimmed,
21
+ )
22
+
23
+ # the probe rides inside a workload we do not own, so what one window holds is
24
+ # bounded, and the bound is on the window rather than on the process's life
14
25
  MAX_MODULES = 4096
15
26
 
16
27
  # our own package would otherwise land in the workload's inventory
@@ -27,29 +38,29 @@ class ImportObserver:
27
38
  finders behind it to decide the outcome.
28
39
  """
29
40
 
30
- # how many times each module was looked up, by its whole dotted name, so a
31
- # record can name the module that was seen and not only the package holding
32
- # it. The name is what bounds the ceiling below
33
- seen: dict[str, int] = field(default_factory=dict)
34
- moved: set[str] = field(default_factory=set)
35
- # modules seen past the ceiling, so the loss is never silent
41
+ # how many times each module was looked up in the window under way, by its
42
+ # whole dotted name, so a record can name the module that was seen and not
43
+ # only the package holding it. The name is what bounds the ceiling below
44
+ seen: dict[str, Sighting] = field(default_factory=dict)
45
+ # what a fold took and did not report, for the next fold to offer again.
46
+ # The fold's own: no thread of the workload writes beside it. Bounded by
47
+ # the same ceiling, and what finds no room is counted with the refusals
48
+ owed: dict[str, Sighting] = field(default_factory=dict)
36
49
  suppressed: int = 0
50
+ unkept: int = 0
37
51
 
38
- def note(self, fullname: str) -> str | None:
39
- """Take note of a module looked up, naming it whenever there is room."""
52
+ def note(self, fullname: str, clock: Callable[[], int] = reached) -> str | None:
53
+ """Take note of a module looked up, naming it whenever the window has room."""
40
54
  if fullname.split(".", maxsplit=1)[0] == OURS:
41
55
  return None
42
- if fullname in self.seen:
43
- self.seen[fullname] += 1
44
- self.moved.add(fullname)
45
- return fullname
46
- # the ceiling bounds how many distinct names are held, not how often a
47
- # name already held is counted
48
- if len(self.seen) >= MAX_MODULES:
56
+ seen = self.seen
57
+ held = seen.get(fullname)
58
+ # the ceiling bounds how many distinct names a window holds, not how
59
+ # often a name already held is counted
60
+ if held is None and len(seen) >= MAX_MODULES:
49
61
  self.suppressed += 1
50
62
  return None
51
- self.seen[fullname] = 1
52
- self.moved.add(fullname)
63
+ seen[fullname] = sighted(held, clock())
53
64
  return fullname
54
65
 
55
66
  def find_spec(
@@ -61,38 +72,53 @@ class ImportObserver:
61
72
  """Note the module being looked up, then defer to the finders behind."""
62
73
  self.note(fullname)
63
74
 
75
+ def drain(self) -> dict[str, Sighting]:
76
+ """Take the window whole, and what the last fold owed, leaving the next one empty."""
77
+ taken, self.seen = self.seen, {}
78
+ # into a map of the fold's own, so what a late sighting lands in the
79
+ # window taken is at most lost, and never written over by the fold
80
+ offered = self.owed
81
+ self.owed = {}
82
+ merged(offered, taken)
83
+ return offered
84
+
85
+ def restore(self, taken: Mapping[str, Sighting]) -> None:
86
+ """Keep for the next fold what this one took and did not report, as far as there is room."""
87
+ merged(self.owed, taken)
88
+ self.unkept += trimmed(self.owed, MAX_MODULES)
89
+
64
90
  def records(
65
91
  self,
92
+ taken: Mapping[str, Sighting],
66
93
  modules: dict[str, ModuleType],
67
94
  installed: dict[str, Dist],
68
- carried: Mapping[tuple[str, str], int],
69
- ) -> list[Record]:
95
+ origin: int,
96
+ ) -> tuple[list[Record], dict[str, Sighting]]:
70
97
  """
71
98
  Name the distribution behind every module that finished importing.
72
99
 
73
100
  A lookup is only an attempt: an import that raised never reaches
74
101
  ``modules``, and reporting it would claim a package loaded when it did
75
102
  not. Intersecting the two is what makes this evidence rather than
76
- intent.
103
+ intent. What has not landed yet is what the next window keeps.
77
104
 
78
105
  Both maps are taken by the caller so this stays a pure fold, and so the
79
106
  distribution scan happens once per report instead of once per module.
80
107
  """
81
108
  found: list[Record] = []
109
+ kept: dict[str, Sighting] = {}
82
110
  scanned = bool(installed)
83
- for name in sorted(self.moved):
84
- times = self.seen[name]
85
- if times <= carried.get(keyed(Evidence.IMPORTED, name), 0):
86
- self.moved.discard(name)
87
- continue
111
+ for name in sorted(taken):
112
+ held = taken[name]
88
113
  module = modules.get(name)
89
114
  if module is None:
90
- # kept, because a lookup mid-import finishes after this flush
115
+ # kept, because a lookup mid-import finishes after this fold
116
+ kept[name] = held
91
117
  continue
92
118
  dist = distribution_of(name, installed)
93
119
  if dist is None:
94
- if scanned:
95
- self.moved.discard(name)
120
+ if not scanned:
121
+ kept[name] = held
96
122
  continue
97
123
  found.append(
98
124
  Record(
@@ -103,10 +129,12 @@ class ImportObserver:
103
129
  symbol=name,
104
130
  granularity=granularity_of(module),
105
131
  evidence=Evidence.IMPORTED,
106
- frequency=times,
132
+ frequency=held.times,
133
+ when=held.first,
134
+ used=dated(origin, held.last),
107
135
  ),
108
136
  )
109
- return found
137
+ return found, kept
110
138
 
111
139
 
112
140
  def granularity_of(module: ModuleType) -> Granularity:
@@ -7,9 +7,11 @@ a signal about the wire rather than about us.
7
7
  """
8
8
 
9
9
  import re
10
+ import time
11
+ from collections.abc import Mapping
10
12
  from dataclasses import dataclass
11
13
  from enum import Enum
12
- from typing import Final
14
+ from typing import Final, NamedTuple
13
15
 
14
16
  REPORT_TAG: Final = "watches-exec/1"
15
17
  ABSENT: Final = "-"
@@ -35,6 +37,15 @@ USED: Final = "u"
35
37
  MAX_WHEN: Final = 3_153_600_000_000
36
38
  MAX_USED: Final = 4_102_444_800_000
37
39
 
40
+ MS: Final = 1_000_000
41
+ SECOND: Final = 1000
42
+
43
+ START: Final = time.monotonic_ns()
44
+
45
+ # the reader reads u= as milliseconds since the epoch. Taken once and advanced
46
+ # by the monotonic clock, so a stepped clock cannot walk a stamp backwards
47
+ ORIGIN: Final = time.time_ns() // MS
48
+
38
49
  # a kind outside the three rungs: the six fixed columns describe a symbol, and
39
50
  # this describes the window the symbols were seen in
40
51
  WINDOW: Final = "s"
@@ -162,9 +173,52 @@ class Report:
162
173
  held: tuple[Record, ...] = ()
163
174
 
164
175
 
165
- def keyed(evidence: Evidence, symbol: str) -> tuple[str, str]:
166
- """Name a record the way a probe holds what it has already carried."""
167
- return (evidence.value, symbol)
176
+ def reached() -> int:
177
+ """Say how far into the probe's own life this moment is, in milliseconds."""
178
+ return (time.monotonic_ns() - START) // MS
179
+
180
+
181
+ def dated(origin: int, at: int) -> int:
182
+ """Date a moment of the probe's life by the epoch, to the second."""
183
+ # what drains a report holds its own clock to whole seconds and drops any
184
+ # date past it, so a finer one here is one it can only throw away
185
+ return (origin + at) // SECOND * SECOND
186
+
187
+
188
+ class Sighting(NamedTuple):
189
+ """What a window holds of one symbol: how often, and when it was first and last seen."""
190
+
191
+ times: int
192
+ first: int
193
+ last: int
194
+
195
+
196
+ def sighted(held: Sighting | None, now: int) -> Sighting:
197
+ """Fold one more sighting into what the window holds of its symbol."""
198
+ return Sighting(1, now, now) if held is None else Sighting(held.times + 1, held.first, now)
199
+
200
+
201
+ def merged(into: dict[str, Sighting], taken: Mapping[str, Sighting]) -> None:
202
+ """Fold what one window holds into another."""
203
+ # a copy of the items, taken in one step: the window taken may still be
204
+ # written by a thread that bound it before the fold swapped it out
205
+ for symbol, held in list(taken.items()):
206
+ kept = into.get(symbol)
207
+ into[symbol] = (
208
+ held
209
+ if kept is None
210
+ else Sighting(
211
+ kept.times + held.times,
212
+ min(kept.first, held.first),
213
+ max(kept.last, held.last),
214
+ )
215
+ )
216
+
217
+
218
+ def trimmed(held: dict[str, Sighting], ceiling: int) -> int:
219
+ """Drop what a window holds past its ceiling, and say how many sightings that was."""
220
+ beyond = list(held)[ceiling:]
221
+ return sum(held.pop(symbol).times for symbol in beyond)
168
222
 
169
223
 
170
224
  def _writable(value: str, limit: int) -> bool:
@@ -5,9 +5,8 @@ import atexit
5
5
  import contextlib
6
6
  import os
7
7
  import sys
8
- import time
9
8
  from collections.abc import Callable
10
- from dataclasses import dataclass, field, replace
9
+ from dataclasses import dataclass, field
11
10
  from types import CodeType, ModuleType
12
11
  from typing import TYPE_CHECKING, Final
13
12
 
@@ -15,7 +14,15 @@ from fluidattacks_agent.distributions import Dist, distribution_map
15
14
  from fluidattacks_agent.executions import TOOL, TOOL_NAME, RunObserver, alone, available
16
15
  from fluidattacks_agent.loads import LoadObserver
17
16
  from fluidattacks_agent.observer import ImportObserver, install
18
- from fluidattacks_agent.report import Evidence, Record, Window, keyed, render
17
+ from fluidattacks_agent.report import (
18
+ ORIGIN,
19
+ Evidence,
20
+ Record,
21
+ Sighting,
22
+ Window,
23
+ reached,
24
+ render,
25
+ )
19
26
  from fluidattacks_agent.settings import Delivery, delivery
20
27
  from fluidattacks_agent.sink import PARTING, Sink, Stalled
21
28
  from fluidattacks_agent.switch import switched_on
@@ -30,24 +37,10 @@ PTH_NAME: Final = "fluidattacks_agent.pth"
30
37
  # reading the switch and for nothing above it
31
38
  PTH_LINE: Final = "import fluidattacks_agent.gate\n"
32
39
 
33
- MS: Final = 1_000_000
34
- SECOND: Final = 1000
35
-
36
40
  # what a workload's fork may be delayed by while a fold under way ends: a fold
37
41
  # imports, and a lock held across a fork is held in the child forever
38
42
  FORKING: Final = 0.5
39
43
 
40
- START: Final = time.monotonic_ns()
41
-
42
- # the reader reads u= as milliseconds since the epoch. Taken once and advanced
43
- # by the monotonic clock, so a stepped clock cannot walk a stamp backwards
44
- ORIGIN: Final = time.time_ns() // MS
45
-
46
-
47
- def reached() -> int:
48
- """Say how far into the probe's own life this moment is, in milliseconds."""
49
- return (time.monotonic_ns() - START) // MS
50
-
51
44
 
52
45
  @dataclass
53
46
  class Probe:
@@ -60,12 +53,10 @@ class Probe:
60
53
  distributions: Callable[[], dict[str, Dist]] = distribution_map
61
54
  clock: Callable[[], int] = reached
62
55
  origin: int = ORIGIN
63
- # what each record was last reported as, so a flush carries the increment
64
- reported: dict[tuple[str, str], int] = field(default_factory=dict)
65
- # the same, for the sightings each observer found no room for
56
+ # what the sightings each observer found no room for were last stated as, so
57
+ # a report carries the increment. The sightings themselves are never held
58
+ # past the window that saw them: the reader adds the windows up
66
59
  stated: dict[str, int] = field(default_factory=dict)
67
- first: dict[tuple[str, str], int] = field(default_factory=dict)
68
- last: dict[tuple[str, str], int] = field(default_factory=dict)
69
60
  scanned: dict[str, Dist] | None = None
70
61
  # set while a fold runs, with the thread running it: that thread's own
71
62
  # reads and imports are the probe's, and every other thread's are the
@@ -108,34 +99,21 @@ class Probe:
108
99
  def _looked(self, fullname: str) -> None:
109
100
  if self._folding():
110
101
  return
111
- module = self.observer.note(fullname)
112
- if module is not None:
113
- self._mark(Evidence.IMPORTED, module)
102
+ self.observer.note(fullname, self.clock)
114
103
 
115
104
  def read(self, path: str) -> None:
116
105
  """Note a file the workload opened."""
117
106
  if self._folding():
118
107
  return
119
- package = self.loads.note(path)
120
- # tested here and not inside: the hook runs on every open the host
121
- # makes, and all but a few are not ours to stamp
122
- if package is not None:
123
- self._mark(Evidence.READ, package)
108
+ # the hook runs on every open the host makes, and all but a few are ours
109
+ # to stamp, so the clock is read inside and only for those
110
+ self.loads.note(path, self.clock)
124
111
 
125
112
  def ran(self, filename: str, qualname: str) -> None:
126
113
  """Note code starting."""
127
114
  if self._folding():
128
115
  return
129
- symbol = self.runs.note(filename, qualname)
130
- if symbol is not None:
131
- self._mark(Evidence.EXECUTED, symbol)
132
-
133
- def _mark(self, evidence: Evidence, symbol: str) -> None:
134
- """Stamp a sighting: when its symbol was first reached, and this once."""
135
- key = keyed(evidence, symbol)
136
- seen = self.clock()
137
- self.last[key] = seen
138
- self.first.setdefault(key, seen)
116
+ self.runs.note(filename, qualname, self.clock)
139
117
 
140
118
  def flush(self) -> None:
141
119
  """Write what has not been reported, waiting out a fold under way."""
@@ -188,15 +166,16 @@ class Probe:
188
166
 
189
167
  def _sighted(self) -> bool:
190
168
  """Say whether this window holds anything a report could carry."""
191
- # what moved and not what is held, since only that can be a record; and
192
- # a copy of it, since the workload's threads add to it meanwhile
169
+ # a copy of the lookups, since the workload's threads add to them meanwhile
193
170
  return bool(
194
171
  self._owed()
195
- or self.loads.moved
196
- or self.runs.moved
197
- # a lookup that never became a module is dropped when the records are
172
+ or self.loads.seen
173
+ or self.loads.owed
174
+ or self.runs.seen
175
+ or self.runs.owed
176
+ # a lookup that never became a module is kept when the records are
198
177
  # folded, so on its own it is not cause to walk any metadata
199
- or any(name in sys.modules for name in list(self.observer.moved))
178
+ or any(name in sys.modules for name in [*list(self.observer.seen), *self.observer.owed])
200
179
  or any(self._beyond().values())
201
180
  or self.stalled != self.charged,
202
181
  )
@@ -227,16 +206,66 @@ class Probe:
227
206
  return scanned
228
207
 
229
208
  def _write_fresh(self, installed: dict[str, Dist]) -> None:
230
- seen = [
231
- *self.loads.records(sys.modules, installed, self.reported),
232
- *self.observer.records(sys.modules, installed, self.reported),
233
- *self.runs.records(installed, self.reported),
234
- ]
235
- fresh = [record for record in map(self._since, seen) if record is not None]
209
+ # every window is taken whole before any is read, so a sighting the
210
+ # workload makes meanwhile lands in the next one and in nothing older
211
+ taken = {
212
+ Evidence.READ: self.loads.drain(),
213
+ Evidence.IMPORTED: self.observer.drain(),
214
+ Evidence.EXECUTED: self.runs.drain(),
215
+ }
216
+ try:
217
+ read, keep_read = self.loads.records(
218
+ taken[Evidence.READ],
219
+ sys.modules,
220
+ installed,
221
+ self.origin,
222
+ )
223
+ imported, keep_imported = self.observer.records(
224
+ taken[Evidence.IMPORTED],
225
+ sys.modules,
226
+ installed,
227
+ self.origin,
228
+ )
229
+ ran, keep_ran = self.runs.records(taken[Evidence.EXECUTED], installed, self.origin)
230
+ except Exception:
231
+ # nothing taken is lost to a fold that could not even read it
232
+ self._restore(taken)
233
+ raise
234
+ # given back at once: the next window keeps these whatever this fold's fate
235
+ self._restore(
236
+ {
237
+ Evidence.READ: keep_read,
238
+ Evidence.IMPORTED: keep_imported,
239
+ Evidence.EXECUTED: keep_ran,
240
+ },
241
+ )
242
+ fresh = [*read, *imported, *ran]
236
243
  counted = self._beyond()
237
244
  beyond = {tag: n - self.stated.get(tag, 0) for tag, n in counted.items()}
238
245
  if not fresh and not any(beyond.values()) and not self._owed():
239
246
  return
247
+ # what this fold offers and has not landed yet, by rung: a record a
248
+ # report never carried goes back to its window rather than being lost
249
+ offered = {
250
+ evidence: {record.symbol: taken[evidence][record.symbol] for record in records}
251
+ for evidence, records in (
252
+ (Evidence.READ, read),
253
+ (Evidence.IMPORTED, imported),
254
+ (Evidence.EXECUTED, ran),
255
+ )
256
+ }
257
+ try:
258
+ self._land(fresh, beyond, counted, offered)
259
+ finally:
260
+ self._restore(offered)
261
+
262
+ def _land(
263
+ self,
264
+ fresh: list[Record],
265
+ beyond: dict[str, int],
266
+ counted: dict[str, int],
267
+ offered: dict[Evidence, dict[str, Sighting]],
268
+ ) -> None:
240
269
  # the reader sums what every report states, so this is the increment
241
270
  stalled = self.stalled
242
271
  window: Window | None = self._window(beyond, stalled - self.charged)
@@ -244,7 +273,9 @@ class Probe:
244
273
  while True:
245
274
  report = render(pending, window, self.refused, self.undated)
246
275
  self._write(report.text)
247
- self._charge(pending[: len(pending) - len(report.held)])
276
+ # landed, or dropped and counted: neither is the window's any more
277
+ for record in pending[: len(pending) - len(report.held)]:
278
+ offered[record.evidence].pop(record.symbol, None)
248
279
  held = list(report.held)
249
280
  self.refused = report.dropped if held else 0
250
281
  self.undated = report.unstamped if held else 0
@@ -258,16 +289,17 @@ class Probe:
258
289
  return
259
290
  pending = held
260
291
 
261
- def _charge(self, written: list[Record]) -> None:
262
- for record in written:
263
- key = _key(record)
264
- self.reported[key] = self.reported.get(key, 0) + record.frequency
292
+ def _restore(self, kept: dict[Evidence, dict[str, Sighting]]) -> None:
293
+ self.loads.restore(kept[Evidence.READ])
294
+ self.observer.restore(kept[Evidence.IMPORTED])
295
+ self.runs.restore(kept[Evidence.EXECUTED])
265
296
 
266
297
  def _beyond(self) -> dict[str, int]:
298
+ # what a window had no room for, and what a fold had no room to keep
267
299
  return {
268
- Evidence.READ.value: self.loads.suppressed,
269
- Evidence.IMPORTED.value: self.observer.suppressed,
270
- Evidence.EXECUTED.value: self.runs.suppressed,
300
+ Evidence.READ.value: self.loads.suppressed + self.loads.unkept,
301
+ Evidence.IMPORTED.value: self.observer.suppressed + self.observer.unkept,
302
+ Evidence.EXECUTED.value: self.runs.suppressed + self.runs.unkept,
271
303
  }
272
304
 
273
305
  def _window(self, beyond: dict[str, int], backlog: int) -> Window:
@@ -280,30 +312,6 @@ class Probe:
280
312
  alone=self.monitoring and self.solitary,
281
313
  )
282
314
 
283
- def _since(self, record: Record) -> Record | None:
284
- # the reader adds up what every report of a record says, so a report
285
- # carries what has happened since the last one and never the running
286
- # total, which would count each sighting once per flush that follows it
287
- key = _key(record)
288
- since = record.frequency - self.reported.get(key, 0)
289
- if since < 1:
290
- return None
291
- return replace(
292
- record,
293
- frequency=since,
294
- when=self.first.get(key),
295
- used=self._used(key),
296
- )
297
-
298
- def _used(self, key: tuple[str, str]) -> int | None:
299
- """Date the last sighting of a symbol as of this report, to the second."""
300
- seen = self.last.get(key)
301
- if seen is None:
302
- return None
303
- # what drains a report holds its own clock to whole seconds and drops
304
- # any date past it, so a finer one here is one it can only throw away
305
- return (self.origin + seen) // SECOND * SECOND
306
-
307
315
  def _write(self, text: str) -> None:
308
316
  try:
309
317
  self.sink.write(text)
@@ -314,10 +322,6 @@ class Probe:
314
322
  raise
315
323
 
316
324
 
317
- def _key(record: Record) -> tuple[str, str]:
318
- return keyed(record.evidence, record.symbol)
319
-
320
-
321
325
  def site_dirs() -> tuple[str, ...]:
322
326
  """Name the directories the workload installs its distributions into."""
323
327
  found: list[str] = []
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks-agent"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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" }]
@@ -1,124 +0,0 @@
1
- """Watch code inside a package start running, where the interpreter allows it."""
2
-
3
- import sys
4
- from collections.abc import Mapping
5
- from dataclasses import dataclass, field
6
- from pathlib import PurePath
7
- from typing import Final
8
-
9
- from fluidattacks_agent.distributions import Dist, distribution_of
10
- from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
11
-
12
- # 3.15 makes the import below lazy from this; every version we support
13
- # ignores it, so nothing changes until the floor moves
14
- __lazy_modules__ = ["pathlib"]
15
-
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
19
-
20
- OURS: Final = "fluidattacks_agent"
21
-
22
- SOURCE: Final = ".py"
23
-
24
- INITIALIZER: Final = "__init__"
25
- PARENT: Final = ".."
26
-
27
- TOOL: Final = 3
28
- TOOL_NAME: Final = "fluidattacks-agent"
29
-
30
-
31
- TOOLS: Final = 6
32
-
33
-
34
- def available() -> bool:
35
- """Say whether this interpreter can report code starting at all."""
36
- return hasattr(sys, "monitoring")
37
-
38
-
39
- def alone(tool: int = TOOL) -> bool:
40
- """Say whether this is the only tool watching: restart_events takes none."""
41
- held = (sys.monitoring.get_tool(other) for other in range(TOOLS) if other != tool)
42
- return not any(name is not None for name in held)
43
-
44
-
45
- def module_of(filename: str, sites: tuple[str, ...]) -> str | None:
46
- """Name the module whose source a running code object came from."""
47
- for site in sites:
48
- prefix = site + "/"
49
- if not filename.startswith(prefix) or not filename.endswith(SOURCE):
50
- continue
51
- held = PurePath(filename[len(prefix) :])
52
- parts = list(held.parts)
53
- if not parts or held.is_absolute() or PARENT in parts:
54
- return None
55
- stem = parts.pop()[: -len(SOURCE)]
56
- if not stem:
57
- return None
58
- if stem != INITIALIZER:
59
- parts.append(stem)
60
- return ".".join(parts) or None
61
- return None
62
-
63
-
64
- @dataclass
65
- class RunObserver:
66
- """Functions that started, bounded the way the reader's own map is."""
67
-
68
- sites: tuple[str, ...] = ()
69
- seen: dict[str, int] = field(default_factory=dict)
70
- moved: set[str] = field(default_factory=set)
71
- suppressed: int = 0
72
-
73
- def note(self, filename: str, qualname: str) -> str | None:
74
- """Take note of code starting, naming the function whenever there is room."""
75
- module = module_of(filename, self.sites)
76
- if module is None:
77
- return None
78
- if module.split(".", maxsplit=1)[0] == OURS:
79
- return None
80
- qualified = f"{module}.{qualname}"
81
- if qualified in self.seen:
82
- self.seen[qualified] += 1
83
- self.moved.add(qualified)
84
- return qualified
85
- if len(self.seen) >= MAX_FUNCTIONS:
86
- self.suppressed += 1
87
- return None
88
- self.seen[qualified] = 1
89
- self.moved.add(qualified)
90
- return qualified
91
-
92
- def records(
93
- self,
94
- installed: dict[str, Dist],
95
- carried: Mapping[tuple[str, str], int],
96
- ) -> list[Record]:
97
- """Report every function whose package a distribution installed."""
98
- found: list[Record] = []
99
- scanned = bool(installed)
100
- for qualified in sorted(self.moved):
101
- times = self.seen[qualified]
102
- # what a report carried comes back only if note sees it again,
103
- # and no distribution will own what a settled scan does not
104
- if times <= carried.get(keyed(Evidence.EXECUTED, qualified), 0):
105
- self.moved.discard(qualified)
106
- continue
107
- dist = distribution_of(qualified, installed)
108
- if dist is None:
109
- if scanned:
110
- self.moved.discard(qualified)
111
- continue
112
- found.append(
113
- Record(
114
- ecosystem=Ecosystem.PYPI,
115
- name=dist.name,
116
- version=dist.version,
117
- confidence=dist.confidence,
118
- symbol=qualified,
119
- granularity=Granularity.FUNCTION,
120
- evidence=Evidence.EXECUTED,
121
- frequency=times,
122
- ),
123
- )
124
- return found