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.
41MUTABLE_SECTIONS = re.compile(r"^\.(data|bss|sdata|sbss|tdata|tbss)(\.|$)")
42READONLY_SECTIONS = re.compile(r"^\.(rodata|data\.rel\.ro)(\.|$)")
43
44# Compiler-emitted symbols that are not program state.
45NOISE = re.compile(r"^(__func__|__PRETTY_FUNCTION__|CSWTCH|__gcov|__profc|__profd|__llvm|"
46 r"\.L|_ZZ.*E19__PRETTY_FUNCTION__|__odr_asan|__const_|"
47 # C++ exception-handling bookkeeping the compiler emits per object.
48 r"DW\.ref\.|__gxx_personality|__dso_handle|_ZSt|__cxa_)")
49
50
51def objects(build_dir):
52 out = []
53 for dirpath, _, filenames in os.walk(build_dir):
54 for fn in filenames:
55 if fn.endswith(".o"):
56 out.append(os.path.join(dirpath, fn))
57 return out
58
59
60def source_of(obj, build_dir):
61 """Map an object path back to its source.
62
63 Two shapes, and getting only the first is how src/widgets -- an entire CMake target --
64 silently reported zero translation units:
65
66 build/src/CMakeFiles/lib_ansel.dir/common/bar.c.o -> src/common/bar.c
67 build/src/widgets/CMakeFiles/ansel_widgets.dir/bar.c.o -> src/widgets/bar.c
68
69 The general rule covers both: drop the build directory, then drop the
70 `CMakeFiles/<target>.dir/` component wherever it sits, and what is left is the path
71 relative to the project root.
72 """
73 rel = os.path.relpath(obj, build_dir)
74 rel = re.sub(r"CMakeFiles/[^/]*\.dir/", "", rel)
75 if rel.endswith(".o"):
76 rel = rel[:-2]
77 if os.path.isfile(os.path.join(REPO, rel)):
78 return rel
79 # Generated sources (introspection_*.c) have no counterpart in the tree; ignore them.
80 return None
81
82
83def scan(build_dir):
84 defines = {} # symbol -> source file
85 state_syms = collections.defaultdict(list) # source file -> [state symbol]
86 calls = collections.defaultdict(set) # source file -> {symbol}
87
88 objs = objects(build_dir)
89 if not objs:
90 sys.exit(f"error: no .o files under {build_dir}")
91
92 # objdump -t prints: value flags section \t size name
93 # flags contain spaces, so anchor on the tab that precedes the size.
94 # objdump prints a visibility marker before the name for non-default visibility:
95 # ... .data.rel.local.DW.ref... 0000000000000008 .hidden DW.ref.__gxx_personality_v0
96 # Capturing the first token there yields ".hidden" as the symbol name.
97 row = re.compile(r"^[0-9a-fA-F]+\s+(?P<flags>.{7})\s+(?P<section>\S+)\t[0-9a-fA-F]+\s+"
98 r"(?:\.(?:hidden|protected|internal)\s+)?(?P<name>\S+)")
99
100 for obj in objs:
101 src = source_of(obj, build_dir)
102 if not src:
103 continue
104 try:
105 out = subprocess.run(["objdump", "-t", obj], capture_output=True, text=True).stdout
106 except FileNotFoundError:
107 sys.exit("error: objdump not found")
108 for line in out.splitlines():
109 m = row.match(line)
110 if not m:
111 continue
112 name, section, flags = m.group("name"), m.group("section"), m.group("flags")
113 if NOISE.match(name):
114 continue
115 if section == "*ABS*" or name == section:
116 continue # the file symbol, and objdump's one symbol per section
117 if section == "*UND*":
118 calls[src].add(name)
119 elif READONLY_SECTIONS.match(section):
120 # Checked BEFORE the mutable test: `.data.rel.ro.local` starts with `.data`
121 # and would otherwise be accused of being state.
122 defines.setdefault(name, src)
123 elif MUTABLE_SECTIONS.match(section):
124 defines[name] = src
125 state_syms[src].append(name)
126 elif section.startswith(".text"):
127 defines.setdefault(name, src)
128 return defines, state_syms, calls
129
130
131def split_header_defined(state_syms):
132 """A mutable symbol defined in more than one object came from a header.
133
134 `static const char *dt_supported_extensions[]` in config.h and `loaders_info[]` in
135 common/image.h are emitted into every translation unit that includes them, so they show up
136 as "own state" for 170 files that never heard of them. They are still mutable storage --
137 `const char *` makes the pointee const, not the array -- and each copy is independently
138 writable, which is worth fixing at the header. But they are the header's problem, not each
139 includer's, and counting them per-includer buries every real finding.
140 """
141 seen = collections.Counter()
142 for syms in state_syms.values():
143 seen.update(set(syms))
144
145 # Appearing in several objects is necessary but not sufficient: `_handler` and
146 # `_module_usage` are just names two unrelated files both chose for a static. Confirm
147 # against the headers, so only a symbol actually defined in one is excused.
148 header_text = []
149 for dirpath, dirnames, filenames in os.walk(os.path.join(REPO, "src")):
150 dirnames[:] = [d for d in dirnames if d not in ("external", "build")]
151 for fn in filenames:
152 if fn.endswith((".h", ".hpp", ".cmake.h")):
153 try:
154 with open(os.path.join(dirpath, fn), errors="ignore") as fh:
155 header_text.append(fh.read())
156 except OSError:
157 pass
158 headers = "\n".join(header_text)
159
160 header_defined = set()
161 for sym, n in seen.items():
162 if n < 2:
163 continue
164 bare = re.sub(r"^_ZL\d+", "", sym) # C++ mangling for a file-local
165 bare = re.sub(r"\.\d+$", "", bare) # gcc's suffix for a function-local static
166 patterns = [
167 # static const char *name[] = ... / static int name = ...
168 r"^\s*(?:static\s+)?[A-Za-z_][\w \t*]*\b" + re.escape(bare) + r"\s*(?:\[|=)",
169 # } name[N] = ... -- a struct-array definition closing a typedef
170 r"^\s*}\s*" + re.escape(bare) + r"\s*\[",
171 ]
172 if any(re.search(pat, headers, re.M) for pat in patterns):
173 header_defined.add(sym)
174 own = {src: [s for s in syms if s not in header_defined]
175 for src, syms in state_syms.items()}
176 return {k: v for k, v in own.items() if v}, sorted(header_defined)
177
178
179def propagate(defines, state_syms, calls):
180 """Stateful = has state, or reaches something that has. Returns file -> chain."""
181 stateful = {src: [src] for src in state_syms}
182 changed = True
183 while changed:
184 changed = False
185 for src, syms in calls.items():
186 if src in stateful:
187 continue
188 for sym in syms:
189 owner = defines.get(sym)
190 if owner and owner in stateful and owner != src:
191 stateful[src] = [src] + stateful[owner]
192 changed = True
193 break
194 return stateful
195
196
197def main():
198 build = "build-debug"
199 if "--build" in sys.argv:
200 build = sys.argv[sys.argv.index("--build") + 1]
201 only = None
202 if "--dir" in sys.argv:
203 only = sys.argv[sys.argv.index("--dir") + 1]
204
205 defines, raw_state, calls = scan(os.path.join(REPO, build))
206 state_syms, header_defined = split_header_defined(raw_state)
207 stateful = propagate(defines, state_syms, calls)
208
209 files = sorted(set(list(calls) + list(state_syms)))
210 if only:
211 files = [f for f in files if f.startswith(only)]
212
213 if "--json" in sys.argv:
214 print(json.dumps({f: {"stateful": f in stateful,
215 "own_state": sorted(state_syms.get(f, [])),
216 "chain": stateful.get(f, [])} for f in files}, indent=2))
217 return 0
218
219 direct = [f for f in files if state_syms.get(f)]
220 indirect = [f for f in files if f in stateful and not state_syms.get(f)]
221 clean = [f for f in files if f not in stateful]
222
223 scope = only or "src/"
224 print(f"{len(files)} translation unit(s) under {scope}: "
225 f"{len(clean)} stateless, {len(direct)} with own state, {len(indirect)} reaching state\n")
226
227 if header_defined and not only:
228 print(f"--- MUTABLE DATA DEFINED IN HEADERS ({len(header_defined)}) ---")
229 print(" One writable copy per including translation unit. Not any single file's")
230 print(" state; fix at the header (usually a missing second const).")
231 for sym in header_defined:
232 print(f" {sym}")
233 print()
234
235 if direct:
236 print("--- OWN STATE (mutable storage at file scope) ---")
237 for f in direct:
238 syms = state_syms[f]
239 print(f" {f} ({len(syms)}): {', '.join(sorted(syms)[:6])}"
240 + (" ..." if len(syms) > 6 else ""))
241 print()
242 if indirect:
243 print("--- REACHES STATE (through what it calls) ---")
244 for f in indirect:
245 chain = stateful[f]
246 print(f" {f}")
247 print(f" via {' -> '.join(chain[1:4])}" + (" ..." if len(chain) > 4 else ""))
248 print()
249 if clean:
250 print(f"--- STATELESS ({len(clean)}) ---")
251 for f in clean:
252 print(f" {f}")
253 return 0
254
255
256if __name__ == "__main__":
257 sys.exit(main())
propagate(defines, state_syms, calls)
source_of(obj, build_dir)