Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
statelessness_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Classify every translation unit as stateless or stateful, and say why.
3
4"Stateless" here means: calling it twice with the same arguments does the same thing, because
5it reads and writes nothing that outlives the call. That is the property that lets a module be
6reused, tested, threaded, or ported without dragging the application with it -- and the whole
7point of sorting the tree by it is that checking becomes mechanical. Once a directory is known
8stateless, anything built only from it is stateless too, and nobody has to re-derive that.
9
10Measured from the linker's own view, not from reading source: `nm` on the compiled objects.
11
12 DIRECT the object defines mutable storage at file scope -- `d`/`b` for a static, `D`/`B`
13 for a global. Read-only data (`r`/`R`) is not state.
14 INDIRECT the object calls a symbol defined by an object that has state. Transitive, so a
15 module three hops from a global is still reported, with the chain that gets there.
16
17Needs a build directory whose objects still carry symbol tables: an LTO build (`-flto
18-fno-fat-lto-objects`, which this tree uses in Release) emits bytecode that `nm` cannot read.
19Use a Debug build.
20
21Usage:
22 tools/statelessness_audit.py [--build DIR] [--dir src/system] [--json]
23 tools/statelessness_audit.py --chains dt_screen_dpi # why is this one stateful?
24"""
25
26import collections
27import json
28import os
29import re
30import subprocess
31import sys
32
33REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
34
35# Read the ELF section, not nm's collapsed type letter. `static const char *const KEY = "..."`
36# is fully const, but because it holds a pointer the linker must relocate, it is emitted to
37# .data.rel.ro -- which nm reports as `d`, indistinguishable from real mutable state. Judging
38# by letter therefore accuses every const table of pointers in the tree.
39#
40# .data.rel.ro* is read-only once the dynamic linker has finished, so it is not state.
41#
42# COFF (Windows) names the same things differently: `.rdata` is read-only, `.tls` is
43# thread-local storage, and a grouped section takes a `$` suffix (`.bss$foo`) where ELF takes
44# a `.` one.
45MUTABLE_SECTIONS = re.compile(r"^\.(data|bss|sdata|sbss|tdata|tbss|tls)([.$]|$)")
46READONLY_SECTIONS = re.compile(r"^\.(rodata|rdata|data\.rel\.ro)([.$]|$)")
47
48# What a compiled object is called: CMake names it `<source>.obj` on Windows, `<source>.o`
49# elsewhere. Looking for the wrong one finds nothing -- which scan() refuses to call a pass.
50OBJ_SUFFIX = ".obj" if sys.platform in ("win32", "cygwin", "msys") else ".o"
51
52# Compiler-emitted symbols that are not program state.
53NOISE = re.compile(r"^(__func__|__PRETTY_FUNCTION__|CSWTCH|__gcov|__profc|__profd|__llvm|"
54 r"\.L|_ZZ.*E19__PRETTY_FUNCTION__|__odr_asan|__const_|"
55 # C++ exception-handling bookkeeping the compiler emits per object.
56 r"DW\.ref\.|__gxx_personality|__dso_handle|_ZSt|__cxa_)")
57
58
59def objects(build_dir):
60 out = []
61 for dirpath, _, filenames in os.walk(build_dir):
62 for fn in filenames:
63 if fn.endswith(OBJ_SUFFIX):
64 out.append(os.path.join(dirpath, fn))
65 return out
66
67
68def source_of(obj, build_dir):
69 """Map an object path back to its source.
70
71 Two shapes, and getting only the first is how src/widgets -- an entire CMake target --
72 silently reported zero translation units:
73
74 build/src/CMakeFiles/lib_ansel.dir/common/bar.c.o -> src/common/bar.c
75 build/src/widgets/CMakeFiles/ansel_widgets.dir/bar.c.o -> src/widgets/bar.c
76
77 The general rule covers both: drop the build directory, then drop the
78 `CMakeFiles/<target>.dir/` component wherever it sits, and what is left is the path
79 relative to the project root.
80 """
81 rel = os.path.relpath(obj, build_dir).replace("\\", "/")
82 rel = re.sub(r"CMakeFiles/[^/]*\.dir/", "", rel)
83 if rel.endswith(OBJ_SUFFIX):
84 rel = rel[:-len(OBJ_SUFFIX)]
85 if os.path.isfile(os.path.join(REPO, rel)):
86 return rel
87 # Generated sources (introspection_*.c) have no counterpart in the tree; ignore them.
88 return None
89
90
91def scan(build_dir):
92 defines = {} # symbol -> source file
93 state_syms = collections.defaultdict(list) # source file -> [state symbol]
94 calls = collections.defaultdict(set) # source file -> {symbol}
95
96 objs = objects(build_dir)
97 if not objs:
98 sys.exit(f"error: no {OBJ_SUFFIX} files under {build_dir}")
99
100 # ELF: objdump -t prints value flags section \t size name
101 # flags contain spaces, so anchor on the tab that precedes the size.
102 # objdump prints a visibility marker before the name for non-default visibility:
103 # ... .data.rel.local.DW.ref... 0000000000000008 .hidden DW.ref.__gxx_personality_v0
104 # Capturing the first token there yields ".hidden" as the symbol name.
105 row = re.compile(r"^[0-9a-fA-F]+\s+(?P<flags>.{7})\s+(?P<section>\S+)\t[0-9a-fA-F]+\s+"
106 r"(?:\.(?:hidden|protected|internal)\s+)?(?P<name>\S+)")
107 # COFF: objdump -t prints the section as a NUMBER, not a name:
108 # [ 28](sec 3)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000000 _accels_global
109 # 0 is undefined, -1 absolute, -2 debug. The names come from the one symbol objdump emits
110 # per section (`[ 4](sec 3)... (nx 1) 0x0... .bss'), which is listed before anything
111 # that lands in it. The ELF pattern above matches none of these lines: an object read
112 # with it alone yields no symbols at all, and a tree of them reads as entirely stateless.
113 coff_row = re.compile(r"^\[\s*\d+\]\‍(sec\s+(?P<sec>-?\d+)\‍)\‍(fl [^)]*\‍)\‍(ty\s+[0-9a-fA-F]+\‍)"
114 r"\‍(scl\s+\d+\‍)\s+\‍(nx (?P<nx>\d+)\‍)\s+0x[0-9a-fA-F]+\s+(?P<name>\S+)")
115
116 for obj in objs:
117 src = source_of(obj, build_dir)
118 if not src:
119 continue
120 try:
121 out = subprocess.run(["objdump", "-t", obj], capture_output=True, text=True).stdout
122 except FileNotFoundError:
123 sys.exit("error: objdump not found")
124
125 # Both formats reduce to (name, section) with ELF's spellings for the two special
126 # sections, so the classification below is written once.
127 symbols = []
128 section_names = {}
129 for line in out.splitlines():
130 m = row.match(line)
131 if m:
132 if m.group("name") != m.group("section"): # objdump's one symbol per section
133 symbols.append((m.group("name"), m.group("section")))
134 continue
135 m = coff_row.match(line)
136 if not m:
137 continue
138 sec, name = int(m.group("sec")), m.group("name")
139 if sec > 0 and m.group("nx") == "1" and name.startswith("."):
140 section_names[sec] = name # the section's own symbol
141 elif sec == 0:
142 # A call into another DLL goes through its import thunk; the callee is the
143 # name behind the prefix.
144 symbols.append((name[len("__imp_"):] if name.startswith("__imp_") else name,
145 "*UND*"))
146 elif sec > 0:
147 symbols.append((name, section_names.get(sec, "")))
148 # sec < 0: absolute (`@feat.00') or debug (`.file') -- neither is a symbol here
149
150 for name, section in symbols:
151 if NOISE.match(name):
152 continue
153 if section == "*ABS*":
154 continue # the file symbol
155 if section == "*UND*":
156 calls[src].add(name)
157 elif READONLY_SECTIONS.match(section):
158 # Checked BEFORE the mutable test: `.data.rel.ro.local` starts with `.data`
159 # and would otherwise be accused of being state.
160 defines.setdefault(name, src)
161 elif MUTABLE_SECTIONS.match(section):
162 defines[name] = src
163 state_syms[src].append(name)
164 elif section.startswith(".text"):
165 defines.setdefault(name, src)
166 return defines, state_syms, calls
167
168
169def split_header_defined(state_syms):
170 """A mutable symbol defined in more than one object came from a header.
171
172 `static const char *dt_supported_extensions[]` in config.h and `loaders_info[]` in
173 common/image.h are emitted into every translation unit that includes them, so they show up
174 as "own state" for 170 files that never heard of them. They are still mutable storage --
175 `const char *` makes the pointee const, not the array -- and each copy is independently
176 writable, which is worth fixing at the header. But they are the header's problem, not each
177 includer's, and counting them per-includer buries every real finding.
178 """
179 seen = collections.Counter()
180 for syms in state_syms.values():
181 seen.update(set(syms))
182
183 # Appearing in several objects is necessary but not sufficient: `_handler` and
184 # `_module_usage` are just names two unrelated files both chose for a static. Confirm
185 # against the headers, so only a symbol actually defined in one is excused.
186 header_text = []
187 for dirpath, dirnames, filenames in os.walk(os.path.join(REPO, "src")):
188 dirnames[:] = [d for d in dirnames if d not in ("external", "build")]
189 for fn in filenames:
190 if fn.endswith((".h", ".hpp", ".cmake.h")):
191 try:
192 with open(os.path.join(dirpath, fn), errors="ignore") as fh:
193 header_text.append(fh.read())
194 except OSError:
195 pass
196 headers = "\n".join(header_text)
197
198 header_defined = set()
199 for sym, n in seen.items():
200 if n < 2:
201 continue
202 bare = re.sub(r"^_ZL\d+", "", sym) # C++ mangling for a file-local
203 bare = re.sub(r"\.\d+$", "", bare) # gcc's suffix for a function-local static
204 patterns = [
205 # static const char *name[] = ... / static int name = ...
206 r"^\s*(?:static\s+)?[A-Za-z_][\w \t*]*\b" + re.escape(bare) + r"\s*(?:\[|=)",
207 # } name[N] = ... -- a struct-array definition closing a typedef
208 r"^\s*}\s*" + re.escape(bare) + r"\s*\[",
209 ]
210 if any(re.search(pat, headers, re.M) for pat in patterns):
211 header_defined.add(sym)
212 own = {src: [s for s in syms if s not in header_defined]
213 for src, syms in state_syms.items()}
214 return {k: v for k, v in own.items() if v}, sorted(header_defined)
215
216
217def propagate(defines, state_syms, calls):
218 """Stateful = has state, or reaches something that has. Returns file -> chain."""
219 stateful = {src: [src] for src in state_syms}
220 changed = True
221 while changed:
222 changed = False
223 for src, syms in calls.items():
224 if src in stateful:
225 continue
226 for sym in syms:
227 owner = defines.get(sym)
228 if owner and owner in stateful and owner != src:
229 stateful[src] = [src] + stateful[owner]
230 changed = True
231 break
232 return stateful
233
234
235def main():
236 build = "build-debug"
237 if "--build" in sys.argv:
238 build = sys.argv[sys.argv.index("--build") + 1]
239 only = None
240 if "--dir" in sys.argv:
241 only = sys.argv[sys.argv.index("--dir") + 1]
242
243 defines, raw_state, calls = scan(os.path.join(REPO, build))
244 state_syms, header_defined = split_header_defined(raw_state)
245 stateful = propagate(defines, state_syms, calls)
246
247 files = sorted(set(list(calls) + list(state_syms)))
248 if only:
249 files = [f for f in files if f.startswith(only)]
250
251 if "--json" in sys.argv:
252 print(json.dumps({f: {"stateful": f in stateful,
253 "own_state": sorted(state_syms.get(f, [])),
254 "chain": stateful.get(f, [])} for f in files}, indent=2))
255 return 0
256
257 direct = [f for f in files if state_syms.get(f)]
258 indirect = [f for f in files if f in stateful and not state_syms.get(f)]
259 clean = [f for f in files if f not in stateful]
260
261 scope = only or "src/"
262 print(f"{len(files)} translation unit(s) under {scope}: "
263 f"{len(clean)} stateless, {len(direct)} with own state, {len(indirect)} reaching state\n")
264
265 if header_defined and not only:
266 print(f"--- MUTABLE DATA DEFINED IN HEADERS ({len(header_defined)}) ---")
267 print(" One writable copy per including translation unit. Not any single file's")
268 print(" state; fix at the header (usually a missing second const).")
269 for sym in header_defined:
270 print(f" {sym}")
271 print()
272
273 if direct:
274 print("--- OWN STATE (mutable storage at file scope) ---")
275 for f in direct:
276 syms = state_syms[f]
277 print(f" {f} ({len(syms)}): {', '.join(sorted(syms)[:6])}"
278 + (" ..." if len(syms) > 6 else ""))
279 print()
280 if indirect:
281 print("--- REACHES STATE (through what it calls) ---")
282 for f in indirect:
283 chain = stateful[f]
284 print(f" {f}")
285 print(f" via {' -> '.join(chain[1:4])}" + (" ..." if len(chain) > 4 else ""))
286 print()
287 if clean:
288 print(f"--- STATELESS ({len(clean)}) ---")
289 for f in clean:
290 print(f" {f}")
291 return 0
292
293
294if __name__ == "__main__":
295 sys.exit(main())
propagate(defines, state_syms, calls)
source_of(obj, build_dir)