1#!@PYTHON@ 2# 3# This file and its contents are supplied under the terms of the 4# Common Development and Distribution License ("CDDL"), version 1.0. 5# You may only use this file in accordance with the terms of version 6# 1.0 of the CDDL. 7# 8# A full copy of the text of the CDDL should have accompanied this 9# source. A copy of the CDDL is also available via the Internet at 10# http://www.illumos.org/license/CDDL. 11# 12 13# 14# Copyright 2026 Gordon W. Ross 15# 16 17""" 18symbol_test.py - C and C++ symbol visibility test driver. 19 20Reads an environment config file and one or more symbols config files, 21generates probe programs for each (symbol, environment) pair, compiles 22them, and reports pass/fail. Supports parallel compilation jobs. 23 24This program is run separately for 64-bit compiles and (where supported) 2532-bit compiles by the test driver scripts. See the setup.ksh scripts in 26the ../c-symbols/ and ../cxx-symbols/ directories for examples. 27 28See also tests/common/README.md. 29""" 30 31import argparse 32import io 33import os 34import signal 35import subprocess 36import sys 37import tempfile 38import threading 39from concurrent.futures import ThreadPoolExecutor 40 41 42# --------------------------------------------------------------------------- 43# parse_cfg - shared cfg file parser 44# --------------------------------------------------------------------------- 45 46def parse_cfg(fileobj, filename, handlers): 47 """ 48 Parse a cfg file-like object, dispatching each directive line to handlers. 49 50 Handles continuation lines (trailing backslash), blank lines, and comments 51 (lines starting with '#'). Each non-blank, non-comment line is split on 52 '|' with each field stripped, then dispatched as: 53 54 handlers[keyword](fields, filename, lineno) 55 56 Unknown keywords cause an error via sys.exit(). 57 """ 58 lineno = 0 59 accum = '' 60 for raw in fileobj: 61 lineno += 1 62 text = raw.rstrip('\n').rstrip('\r') 63 if text.endswith('\\'): 64 accum += text[:-1] 65 continue 66 accum += text 67 line = accum.strip() 68 accum = '' 69 if not line or line.startswith('#'): 70 continue 71 parts = [p.strip() for p in line.split('|')] 72 keyword, fields = parts[0], parts[1:] 73 if keyword not in handlers: 74 sys.exit( 75 f'error: {filename}:{lineno}: unknown keyword {keyword!r}') 76 handlers[keyword](fields, filename, lineno) 77 78 79# --------------------------------------------------------------------------- 80# CompileEnv - one compilation environment (name, lang standard, defines) 81# --------------------------------------------------------------------------- 82 83class CompileEnv: 84 """One compilation environment as defined by an 'env' line in the cfg.""" 85 86 def __init__(self, name, lang, defs): 87 self.name = name 88 self.lang = lang # e.g. 'c++98', 'c99' 89 self.defs = defs # preprocessor defines string, may be empty 90 91 92# --------------------------------------------------------------------------- 93# EnvConfig - loads and represents the env cfg file 94# See test_parse_env.py for examples of method calls and results. 95# --------------------------------------------------------------------------- 96 97class EnvConfig: 98 """ 99 Loads a compilation environment config file (c-symbols-env.cfg or 100 cxx-symbols-env.cfg) and provides environment and group lookup. 101 102 Public attributes: 103 envs: dict name -> CompileEnv 104 groups: dict name -> frozenset of env names 105 """ 106 107 def __init__(self, lang): 108 self.lang = lang 109 self.envs = {} 110 self.groups = {} 111 112 def load(self, path): 113 """Load env cfg from a file path.""" 114 # Locations searched for the file: 115 # 1. path as given 116 # 2. $STF_SUITE/cfg/<path> 117 # 3. cfg/<path> 118 candidates = [path] 119 if not os.path.isabs(path): 120 stf = os.environ.get('STF_SUITE', '../..') 121 candidates.append(os.path.join(stf, 'cfg', path)) 122 candidates.append(os.path.join('cfg', path)) 123 124 for candidate in candidates: 125 if os.path.exists(candidate): 126 with open(candidate) as f: 127 self._parse(f, filename=candidate) 128 return 129 130 sys.exit(f'error: env cfg file not found: {path}') 131 132 def _parse(self, fileobj, filename='<input>'): 133 """ 134 Parse a file-like object as an environment config file. 135 136 self.lang must be 'c' or 'c++'. Each env line's lang field is 137 validated against this value; a mismatch is an error. 138 """ 139 handlers = { 140 'env': lambda f, fn, ln: self._do_env(f, fn, ln), 141 'env_group': self._do_env_group, 142 } 143 parse_cfg(fileobj, filename, handlers) 144 145 def _do_env(self, fields, filename, lineno): 146 if len(fields) != 3: 147 sys.exit( 148 f'error: {filename}:{lineno}: env: expected 3 fields, ' 149 f'got {len(fields)}') 150 151 name, env_lang, defs = fields 152 153 # Validate lang consistency 154 is_cxx = env_lang.startswith('c++') 155 if self.lang == 'c++' and not is_cxx: 156 sys.exit( 157 f'error: --lang=c++ but {filename} line {lineno} ' 158 f'has lang {env_lang!r}') 159 if self.lang == 'c' and is_cxx: 160 sys.exit( 161 f'error: --lang=c but {filename} line {lineno} ' 162 f'has lang {env_lang!r}') 163 164 self.envs[name] = CompileEnv(name=name, lang=env_lang, defs=defs) 165 166 def _do_env_group(self, fields, filename, lineno): 167 if len(fields) != 2: 168 sys.exit( 169 f'error: {filename}:{lineno}: env_group: expected 2 fields, ' 170 f'got {len(fields)}') 171 172 name, members_str = fields 173 members = set() 174 for member in members_str.split(): 175 if member in self.envs: 176 members.add(member) 177 elif member in self.groups: 178 members |= self.groups[member] 179 else: 180 sys.exit( 181 f'error: {filename}:{lineno}: ' 182 f'reference to undefined env {member!r}') 183 184 self.groups[name] = frozenset(members) 185 186 def resolve(self, spec): 187 """ 188 Parse an env spec string like 'CXX98+ -CXX11 +CXX98'. 189 190 Returns (test_set, need_set) where both are sets of env names: 191 test_set - all envs to compile 192 need_set - envs where compilation must succeed (pass) 193 194 Bare name or +name -> added to both test_set and need_set. 195 -name -> added to test_set only (must fail). 196 """ 197 test_set = set() 198 need_set = set() 199 200 for token in spec.split(): 201 if token.startswith('+'): 202 act = True 203 token = token[1:] 204 elif token.startswith('-'): 205 act = False 206 token = token[1:] 207 else: 208 act = True 209 210 # Expand token - may be a single env or a group 211 expanded = self._expand(token) 212 test_set |= expanded 213 if act: 214 need_set |= expanded 215 else: 216 need_set -= expanded 217 218 return (test_set, need_set) 219 220 def _expand(self, name): 221 """Return frozenset of env names for a single env name or group name.""" 222 if name in self.envs: 223 return frozenset({name}) 224 if name in self.groups: 225 return self.groups[name] 226 sys.exit(f'error: reference to undefined env or group {name!r}') 227 228 def expand(self, name): 229 """Return the set of environments named by one env or group.""" 230 return set(self._expand(name)) 231 232 233# --------------------------------------------------------------------------- 234# SymEntry - one symbol entry from a symbols config file 235# --------------------------------------------------------------------------- 236 237class SymEntry: 238 """One test entry as parsed from a symbols config file.""" 239 240 def __init__(self, directive, symbol, env_spec, headers, 241 rtype=None, atypes=None, defval=None): 242 self.directive = directive # 'func' | 'type' | 'value' | 'define' 243 self.symbol = symbol # name to test 244 self.env_spec = env_spec # raw env spec string e.g. 'CXX98+ -CXX11' 245 self.headers = headers # list of header filenames 246 self.rtype = rtype # return/declaration type (not for define) 247 self.atypes = atypes if atypes is not None else [] 248 self.defval = defval # expected value for 'define', else None 249 250 251# --------------------------------------------------------------------------- 252# SymConfig - loads symbols config files 253# See test_parse_sym.py for examples of method calls and results. 254# --------------------------------------------------------------------------- 255 256class SymConfig: 257 """ 258 Loads one or more symbols config files. 259 260 Public attributes: 261 entries: list of SymEntry, in file order 262 primary_header: first header named by the loaded configuration files 263 """ 264 265 def __init__(self): 266 self.entries = [] 267 self.primary_header = None 268 269 def load(self, path): 270 """Load symbols config from a file path, with STF_SUITE search.""" 271 candidates = [path] 272 if not os.path.isabs(path): 273 stf = os.environ.get('STF_SUITE', '../..') 274 candidates.append(os.path.join(stf, 'cfg', path)) 275 candidates.append(os.path.join('cfg', path)) 276 277 for candidate in candidates: 278 if os.path.exists(candidate): 279 with open(candidate) as f: 280 self._parse(f, filename=candidate) 281 return 282 283 sys.exit(f'error: symbols config file not found: {path}') 284 285 def _parse(self, fileobj, filename='<input>'): 286 """Parse a file-like object as a symbols config file.""" 287 self._file_primary_header = None 288 self._warned_primary_headers = set() 289 handlers = { 290 'type': self._do_type, 291 'value': self._do_value, 292 'define': self._do_define, 293 'func': self._do_func, 294 } 295 parse_cfg(fileobj, filename, handlers) 296 if self._file_primary_header is None: 297 sys.exit(f'error: {filename}: symbols configuration is empty') 298 299 @staticmethod 300 def _split_list(s): 301 """Split a semicolon-separated field, stripping each item.""" 302 return [item.strip() for item in s.split(';') if item.strip()] 303 304 def _add_entry(self, entry, filename, lineno): 305 if not entry.headers: 306 sys.exit(f'error: {filename}:{lineno}: no header specified') 307 308 # A configuration describes one primary header. Later headers in an 309 # entry are there to support its probe, but only the first identifies 310 # the header whose positive coverage we track. 311 header = entry.headers[0] 312 if self._file_primary_header is None: 313 self._file_primary_header = header 314 if self.primary_header is None: 315 self.primary_header = header 316 elif header != self.primary_header: 317 sys.exit( 318 f'error: {filename}:{lineno}: primary header {header!r} ' 319 f'differs from {self.primary_header!r}\n' 320 'Only one primary header per invocation is supported') 321 elif header != self._file_primary_header: 322 # Keep the entry for its ordinary symbol test, but it does not 323 # establish positive coverage for the primary header. 324 if header not in self._warned_primary_headers: 325 print( 326 f'warning: {filename}:{lineno}: primary header {header!r} ' 327 f'differs from {self._file_primary_header!r}\n' 328 'Only one primary header per configuration file is ' 329 'expected', 330 file=sys.stderr) 331 self._warned_primary_headers.add(header) 332 333 self.entries.append(entry) 334 335 def _do_type(self, fields, filename, lineno): 336 # type | decl | headers | envs 337 if len(fields) != 3: 338 sys.exit( 339 f'error: {filename}:{lineno}: type: expected 3 fields, ' 340 f'got {len(fields)}') 341 decl, hdrs, envs = fields 342 self._add_entry(SymEntry( 343 directive='type', 344 symbol=decl, 345 rtype=decl, 346 headers=self._split_list(hdrs), 347 env_spec=envs, 348 ), filename, lineno) 349 350 def _do_value(self, fields, filename, lineno): 351 # value | name | type | headers | envs 352 if len(fields) != 4: 353 sys.exit( 354 f'error: {filename}:{lineno}: value: expected 4 fields, ' 355 f'got {len(fields)}') 356 name, rtype, hdrs, envs = fields 357 self._add_entry(SymEntry( 358 directive='value', 359 symbol=name, 360 rtype=rtype, 361 headers=self._split_list(hdrs), 362 env_spec=envs, 363 ), filename, lineno) 364 365 def _do_define(self, fields, filename, lineno): 366 # define | name | value | headers | envs (value may be empty) 367 if len(fields) != 4: 368 sys.exit( 369 f'error: {filename}:{lineno}: define: expected 4 fields, ' 370 f'got {len(fields)}') 371 name, defval, hdrs, envs = fields 372 self._add_entry(SymEntry( 373 directive='define', 374 symbol=name, 375 defval=defval if defval else None, 376 headers=self._split_list(hdrs), 377 env_spec=envs, 378 ), filename, lineno) 379 380 def _do_func(self, fields, filename, lineno): 381 # func | name | rtype | atypes | headers | envs 382 if len(fields) != 5: 383 sys.exit( 384 f'error: {filename}:{lineno}: func: expected 5 fields, ' 385 f'got {len(fields)}') 386 name, rtype, atypes, hdrs, envs = fields 387 self._add_entry(SymEntry( 388 directive='func', 389 symbol=name, 390 rtype=rtype, 391 atypes=self._split_list(atypes), 392 headers=self._split_list(hdrs), 393 env_spec=envs, 394 ), filename, lineno) 395 396 397# --------------------------------------------------------------------------- 398# ProbeGen - generates probe program source for a (SymEntry, lang) pair 399# See test_gen_probe.py for examples of method calls and results. 400# --------------------------------------------------------------------------- 401 402class ProbeGen: 403 404 RESULT_MACRO = ( 405 '#if __cplusplus >= 201103L\n' 406 '#define RESULT(v) result{v}\n' 407 '#else\n' 408 '#define RESULT(v) result = (v)\n' 409 '#endif\n' 410 ) 411 412 @staticmethod 413 def gen_probe(entry, lang): 414 """ 415 Generate probe program source text for a SymEntry and language. 416 417 """ 418 out = [] 419 420 for h in entry.headers: 421 out.append(f'#include <{h}>\n') 422 423 rtype = entry.rtype or '' 424 prefix, suffix = ProbeGen._split_rtype(rtype) 425 is_fnptr = suffix != '' 426 427 # Emit the RESULT() macro for C++ func probes with a non-void, 428 # non-fnptr return value (brace-init catches narrowing errors in 429 # C++11+; plain assignment used for C++98). 430 has_rtype = (entry.directive == 'func' and 431 rtype != '' and rtype != 'void') 432 use_result = has_rtype and not is_fnptr and lang == 'c++' 433 434 if use_result: 435 out.append(ProbeGen.RESULT_MACRO) 436 437 # Emit return type prefix + trailing space (matches C programs' 438 # unconditional addprogch(' ') after the rtype loop). 439 if rtype: 440 out.append(prefix + ' ') 441 442 # Special "include-only" tests are used to ensure that a header 443 # (at least) compiles in a trivial C program. 444 if entry.directive == 'include-only': 445 out.append('int header_compile_test;\n') 446 447 elif entry.directive == 'type': 448 out.append('test_type;\n') 449 450 elif entry.directive == 'value': 451 out.append(f'test_value{suffix};\n') 452 out.append('void\ntest_func(void)\n{\n') 453 out.append(f'\ttest_value = {entry.symbol};\n}}\n') 454 455 elif entry.directive == 'define': 456 out.append(f'#if !defined({entry.symbol})') 457 if entry.defval: 458 out.append(f' || {entry.symbol} != {entry.defval}') 459 out.append(f'\n#error {entry.symbol} is not defined or has the wrong value') 460 out.append('\n#endif\n') 461 out.append('\n') 462 463 elif entry.directive == 'func': 464 arglist = ProbeGen._build_arglist(entry.atypes) 465 out.append(f'\ntest_func({arglist}){suffix}\n{{\n\t') 466 467 call = f'{entry.symbol}({ProbeGen._build_callargs(entry.atypes)})' 468 469 if use_result: 470 out.append(f'{rtype} RESULT({call});\n\treturn result;\n}}') 471 elif has_rtype or is_fnptr: 472 out.append(f'return {call};\n}}') 473 else: 474 out.append(f'{call};\n}}') 475 out.append('\n') 476 477 return ''.join(out) 478 479 @staticmethod 480 def _split_rtype(rtype): 481 """ 482 Split rtype for function pointer support. 483 484 For 'void (*)(int)': returns ('void (*', ')(int)') 485 For 'double': returns ('double', '') 486 """ 487 idx = rtype.find('(*') 488 if idx >= 0: 489 return rtype[:idx + 2], rtype[idx + 2:] 490 return rtype, '' 491 492 @staticmethod 493 def _build_arglist(atypes): 494 """Build the formal parameter list string, matching C arg-name insertion.""" 495 if not atypes: 496 return 'void' 497 parts = [] 498 for i, atype in enumerate(atypes): 499 if atype == '': 500 parts.append('void') 501 elif atype == 'void': 502 parts.append('void') 503 elif '(*' in atype: 504 idx = atype.index('(*') 505 parts.append(f'{atype[:idx + 2]}a{i}{atype[idx + 2:]}') 506 elif '[' in atype: 507 idx = atype.index('[') 508 parts.append(f'{atype[:idx]}a{i}{atype[idx:]}') 509 else: 510 parts.append(f'{atype} a{i}') 511 return ', '.join(parts) 512 513 @staticmethod 514 def _build_callargs(atypes): 515 """Build actual argument names for the function call, skipping void/empty.""" 516 return ', '.join( 517 f'a{i}' for i, atype in enumerate(atypes) 518 if atype not in ('', 'void') 519 ) 520 521 522# --------------------------------------------------------------------------- 523# Job - inputs to a single (symbol, env) compilation 524# --------------------------------------------------------------------------- 525 526class Job: 527 """All inputs needed to run one compilation job in a worker thread.""" 528 529 __slots__ = ('index', 'entry', 'env', 'expect_pass', 'lang', 530 'compiler', 'mflag', 'arch', 'std_flag', 'base_flags', 531 'tmpdir', 'debug', 'extra_debug', 'force', 532 'output', 'done') 533 534 def __init__(self, index, entry, env, expect_pass, lang, 535 compiler, mflag, arch, std_flag, base_flags, 536 tmpdir, debug, extra_debug, force): 537 self.index = index 538 self.entry = entry 539 self.env = env 540 self.expect_pass = expect_pass 541 self.lang = lang 542 self.compiler = compiler 543 self.mflag = mflag 544 self.arch = arch # '64-bit' or '32-bit' 545 self.std_flag = std_flag # e.g. '-std=c99' 546 self.base_flags = base_flags # list of flags common to all jobs 547 self.tmpdir = tmpdir 548 self.debug = debug # -d: show probe + compiler output on failure 549 self.extra_debug = extra_debug # -D: also show command + probe on pass 550 self.force = force # -f: continue after failures 551 self.output = None # buffered text, set once the job finishes 552 self.done = False # True once this job has finished 553 554 555# --------------------------------------------------------------------------- 556# TestDriver - builds job list, drives thread pool, reports results 557# --------------------------------------------------------------------------- 558 559class TestDriver: 560 """ 561 Expands symbol entries × environments into jobs, runs them in a thread 562 pool, and reports results. 563 """ 564 565 def run(self, sym_config, env_config, positive_coverage, 566 compiler, mflag, arch, base_flags, tmpdir, opts): 567 """ 568 Run all (symbol, env) compilations. 569 570 Returns True if all tests passed (and not interrupted), else False. 571 """ 572 lock = threading.Lock() 573 stop = threading.Event() 574 counters = {'pass': 0, 'fail': 0} 575 next_to_print = 0 576 577 orig_sigint = signal.getsignal(signal.SIGINT) 578 orig_sigterm = signal.getsignal(signal.SIGTERM) 579 580 def handle_signal(signum, frame): 581 stop.set() 582 583 signal.signal(signal.SIGINT, handle_signal) 584 signal.signal(signal.SIGTERM, handle_signal) 585 586 # drain() prints a run of now-finished jobs starting at 587 # next_to_print, stopping at the first unfinished job. 588 # This makes output appears in job (array) order even 589 # though jobs complete in a non-deterministic order. 590 # Must be called with lock held. 591 def drain(): 592 nonlocal next_to_print 593 while next_to_print < len(jobs) and jobs[next_to_print].done: 594 if jobs[next_to_print].output: 595 print(jobs[next_to_print].output, flush=True) 596 jobs[next_to_print].output = None 597 next_to_print += 1 598 599 # run_job() is the worker function called by each thread in the pool. 600 # It captures all output for one job in a local buffer, then acquires 601 # the lock to record it and drain any now-printable jobs in order. 602 def run_job(job): 603 if stop.is_set(): 604 return 605 606 # Generate probe source and write to temp file. 607 src = ProbeGen.gen_probe(job.entry, job.lang) 608 ext = 'cc' if job.lang == 'c++' else 'c' 609 base = os.path.join(job.tmpdir, f'job-{job.index}') 610 srcfile = f'{base}.{ext}' 611 objfile = f'{base}.o' 612 logfile = f'{base}.log' 613 614 with open(srcfile, 'w') as f: 615 f.write(src) 616 617 # Build compiler command. 618 env_defs = job.env.defs.split() if job.env.defs else [] 619 cmd = ([job.compiler, job.mflag, job.std_flag] + 620 job.base_flags + env_defs + 621 ['-c', srcfile, '-o', objfile]) 622 623 with open(logfile, 'w') as lf: 624 proc = subprocess.run(cmd, stdout=lf, stderr=lf) 625 626 compile_ok = (proc.returncode == 0) 627 passed = (compile_ok == job.expect_pass) 628 629 # Output buffer (flushed at end of job) 630 sign = '+' if job.expect_pass else '-' 631 label = f'{job.entry.symbol} : {sign}{job.env.name} ({job.arch})' 632 out = [f'TEST STARTING {label}:'] 633 634 if job.extra_debug: 635 out.append(f'TEST DEBUG {label}: command: {" ".join(cmd)}') 636 637 if job.extra_debug or (job.debug and not passed): 638 out.append(f'TEST DEBUG {label}: probe program:') 639 for line in src.splitlines(): 640 out.append(f'TEST DEBUG {label}: {line}') 641 642 if job.debug and not passed: 643 with open(logfile) as lf: 644 cc_out = lf.read().strip() 645 if cc_out: 646 out.append(f'TEST DEBUG {label}: compiler output:') 647 for line in cc_out.splitlines(): 648 out.append(f'TEST DEBUG {label}: {line}') 649 650 if passed: 651 out.append(f'TEST PASS: {label}') 652 else: 653 verb = 'FAILING' if job.force else 'FAILED' 654 reason = (f'error compiling in {job.env.name}' 655 if job.expect_pass 656 else f'symbol visible in {job.env.name}') 657 out.append(f'TEST {verb} {label}: {reason}') 658 659 with lock: 660 job.output = '\n'.join(out) 661 job.done = True 662 if passed: 663 counters['pass'] += 1 664 else: 665 counters['fail'] += 1 666 if not job.force: 667 stop.set() 668 drain() 669 670 # Build the list of jobs to run. 671 # Order jobs by configuration entry and then environment name 672 jobs = [] 673 674 # First add the synthetic include-only test configurations. 675 # These are added when there is no positive test case for some 676 # compilation environment. That ensures that negative cases 677 # don't pass by accident if a header does not compile at all. 678 # 679 # Note that with symbol filtering (opts.sym) we skip this, 680 # and with (opts.env) filter the envoronments the same as 681 # how normal jobs from the test config would do. 682 683 if not opts.sym: 684 covered = set() 685 for entry in sym_config.entries: 686 if entry.headers[0] == sym_config.primary_header: 687 _, need_set = env_config.resolve(entry.env_spec) 688 covered |= need_set 689 missing = positive_coverage - covered 690 if opts.env: 691 narrow, _ = env_config.resolve(opts.env) 692 missing &= narrow 693 entry = SymEntry( 694 directive='include-only', 695 symbol=f'include-only <{sym_config.primary_header}>', 696 env_spec='', 697 headers=[sym_config.primary_header], 698 ) 699 for env_name in sorted(missing): 700 env = env_config.envs[env_name] 701 jobs.append(Job( 702 index=len(jobs), 703 entry=entry, 704 env=env, 705 expect_pass=True, 706 lang=opts.lang, 707 compiler=compiler, 708 mflag=mflag, 709 arch=arch, 710 std_flag=f'-std={env.lang}', 711 base_flags=base_flags, 712 tmpdir=tmpdir, 713 debug=opts.debug, 714 extra_debug=opts.extra_debug, 715 force=opts.force, 716 )) 717 718 # 719 # Now add jobs from the normal config file rows. 720 # These may be filtered by symbol and/or environment 721 # using opts.sym or opts.env 722 # 723 724 for entry in sym_config.entries: 725 if opts.sym and entry.symbol != opts.sym: 726 continue 727 test_set, need_set = env_config.resolve(entry.env_spec) 728 if opts.env: 729 narrow, _ = env_config.resolve(opts.env) 730 test_set &= narrow 731 need_set &= narrow 732 for env_name in sorted(test_set): 733 env = env_config.envs[env_name] 734 jobs.append(Job( 735 index=len(jobs), 736 entry=entry, 737 env=env, 738 expect_pass=(env_name in need_set), 739 lang=opts.lang, 740 compiler=compiler, 741 mflag=mflag, 742 arch=arch, 743 std_flag=f'-std={env.lang}', 744 base_flags=base_flags, 745 tmpdir=tmpdir, 746 debug=opts.debug, 747 extra_debug=opts.extra_debug, 748 force=opts.force, 749 )) 750 751 # The ThreadPoolExecutor runs up to opts.j worker threads concurrently. 752 # executor.submit() queues each job; the pool calls run_job(job) in a 753 # worker thread. Exiting the "with" block waits for all thread pool 754 # executors to finish before proceeding. 755 with ThreadPoolExecutor(max_workers=opts.j) as executor: 756 for job in jobs: 757 executor.submit(run_job, job) 758 759 # All thread pool executors have finished. If we were interrupted, 760 # there may be unfinished jobs and there may also be finished jobs 761 # scattered among those with pending output. Scan the remainder of 762 # the jobs list and flush (drain) any jobs with pending output. 763 with lock: 764 while next_to_print < len(jobs): 765 if jobs[next_to_print].done and jobs[next_to_print].output: 766 print(jobs[next_to_print].output, flush=True) 767 # Could free jobs[].output here but we're 768 # about to exit so just skip that work. 769 next_to_print += 1 770 771 signal.signal(signal.SIGINT, orig_sigint) 772 signal.signal(signal.SIGTERM, orig_sigterm) 773 774 passes = counters['pass'] 775 total = passes + counters['fail'] 776 if passes == total: 777 print(f'TEST SUMMARY: {passes} / {total} (ok)') 778 else: 779 print(f'TEST SUMMARY: {passes} / {total} ({total - passes} failing)') 780 781 return counters['fail'] == 0 and not stop.is_set() 782 783 784# --------------------------------------------------------------------------- 785# Compiler detection 786# --------------------------------------------------------------------------- 787 788# Exit codes emitted by the compiler-detection probe. 789_COMP_STUDIO = 51 790_COMP_CLANG = 52 791_COMP_GCC = 53 792_COMP_UNKNOWN = 99 793 794# clang defines both __GNUC__ and __clang__, therefore test for 795# __clang__ ahead of __GNUC__. 796 797_C_PROBE_SRC = """\ 798#include <stdlib.h> 799int main(int argc, char **argv) { 800#if defined(__SUNPRO_C) 801exit(51); 802#elif defined(__clang__) 803exit(52); 804#elif defined(__GNUC__) 805exit(53); 806#else 807exit(99); 808#endif 809} 810""" 811 812_CXX_PROBE_SRC = """\ 813#include <cstdlib> 814int main(int argc, char **argv) { 815#if defined(__SUNPRO_CC) 816exit(51); 817#elif defined(__clang__) 818exit(52); 819#elif defined(__GNUC__) 820exit(53); 821#else 822exit(99); 823#endif 824} 825""" 826 827def sys_include_dir(root=None): 828 """ 829 Return the system include directory to use for -isystem/-nostdinc 830 compiles: '<root>/usr/include' if root is given, else '/usr/include'. 831 832 root is resolved by the caller from, in order of preference: the -R 833 command-line option, the HEADER_TEST_ROOT environment variable, or 834 None (meaning the true system root). 835 """ 836 if root: 837 return os.path.join(root, 'usr/include') 838 return '/usr/include' 839 840 841def c_base_flags(root=None): 842 """ 843 Base flags used for all C compilations. We turn off -Wformat-security 844 because the auto-generated tests don't pass string literals to printf 845 family functions, which will trigger warnings in some compilers (e.g. 846 clang-16). 847 """ 848 return [ 849 '-Wall', '-Werror', '-nostdinc', 850 '-isystem', sys_include_dir(root), 851 '-Wno-format-security', 852 ] 853 854 855def _run_compiler_probe(compiler, src, ext, mflag, tmpdir): 856 """ 857 Write src to detect.ext, compile with compiler+mflag, run the result. 858 Returns the probe exit code, or None on compile/exec failure. 859 """ 860 srcfile = os.path.join(tmpdir, f'detect.{ext}') 861 exec_name = os.path.join(tmpdir, 'detect') 862 with open(srcfile, 'w') as f: 863 f.write(src) 864 try: 865 r = subprocess.run( 866 [compiler, mflag, srcfile, '-o', exec_name], 867 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 868 if r.returncode != 0: 869 return None 870 r = subprocess.run( 871 [exec_name], 872 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 873 return r.returncode 874 except OSError: 875 return None 876 877 878def _validate_c_compiler(cc, mflag, tmpdir): 879 """Return True if cc is a usable C compiler (gcc or clang); else False.""" 880 code = _run_compiler_probe(cc, _C_PROBE_SRC, 'c', mflag, tmpdir) 881 return code in (_COMP_GCC, _COMP_CLANG) 882 883 884def _validate_cxx_compiler(cc, mflag, tmpdir): 885 """Return True if cc is a usable C++ compiler (g++ or clang++); else False.""" 886 code = _run_compiler_probe(cc, _CXX_PROBE_SRC, 'cc', mflag, tmpdir) 887 return code in (_COMP_GCC, _COMP_CLANG) 888 889 890def _compiler_kind(compiler): 891 """ 892 Return 'clang' or 'gcc' by inspecting the compiler's --version output. 893 Used when an explicit compiler path is given and the kind cannot be 894 inferred from the name. 895 """ 896 result = subprocess.run( 897 [compiler, '--version'], 898 capture_output=True, text=True) 899 if 'clang' in result.stdout.lower(): 900 return 'clang' 901 return 'gcc' 902 903 904def find_c_compiler(mflag, tmpdir, explicit=None): 905 """ 906 Find a usable C compiler. Returns the compiler path. 907 Raises SystemExit if none is found. 908 """ 909 candidates = [explicit] if explicit else ['gcc', 'clang'] 910 for cc in candidates: 911 if _validate_c_compiler(cc, mflag, tmpdir): 912 return cc 913 if explicit: 914 sys.exit(f'error: C compiler {explicit!r} is not usable') 915 sys.exit('error: no usable C compiler found (tried gcc, clang)') 916 917 918def find_cxx_compiler(mflag, tmpdir, explicit=None): 919 """ 920 Find a usable C++ compiler. Returns (compiler, kind) where kind is 921 'gcc' or 'clang'. Raises SystemExit if none is found. 922 """ 923 if explicit: 924 if not _validate_cxx_compiler(explicit, mflag, tmpdir): 925 sys.exit(f'error: C++ compiler {explicit!r} is not usable') 926 return explicit, _compiler_kind(explicit) 927 for cc, kind in [('g++', 'gcc'), ('clang++', 'clang')]: 928 if _validate_cxx_compiler(cc, mflag, tmpdir): 929 return cc, kind 930 sys.exit('error: no usable C++ compiler found (tried g++, clang++)') 931 932 933def find_gcc_cxx_includes(compiler, root=None): 934 """ 935 Query a GCC C++ compiler for its internal include directory and return a 936 base_flags list with all necessary -isystem paths. 937 938 The compiler reports its internal include directory as, e.g.: 939 /opt/gcc-14/lib/gcc/x86_64-pc-solaris2.11/14.2.0/include 940 941 We parse out prefix, target triple, and version, then build: 942 -isystem prefix/include/c++/version 943 -isystem prefix/include/c++/version/target 944 -isystem prefix/lib/gcc/target/version/include 945 -isystem <sys_include_dir> 946 947 The compiler's own internal C++ headers always come from the real 948 toolchain install; only the final system headers entry is redirected 949 under root (see sys_include_dir()). 950 """ 951 result = subprocess.run( 952 [compiler, '-print-file-name=include'], 953 capture_output=True, text=True) 954 buf = result.stdout.strip() 955 956 sep = '/lib/gcc/' 957 idx = buf.find(sep) 958 if idx < 0: 959 sys.exit(f'error: unexpected -print-file-name=include output: {buf!r}') 960 961 prefix = buf[:idx] 962 rest = buf[idx + len(sep):] # "target/version/include" 963 parts = rest.split('/') 964 if len(parts) < 3: 965 sys.exit(f'error: cannot parse target/version from: {buf!r}') 966 target = parts[0] 967 version = parts[1] 968 969 return [ 970 '-Wall', '-Werror', '-nostdinc', 971 '-isystem', f'{prefix}/include/c++/{version}', 972 '-isystem', f'{prefix}/include/c++/{version}/{target}', 973 '-isystem', f'{prefix}/lib/gcc/{target}/{version}/include', 974 '-isystem', sys_include_dir(root), 975 '-Wno-format-security', 976 ] 977 978 979def find_clang_cxx_includes(compiler, root=None): 980 """ 981 Query a clang++ compiler for its C++ include search paths by running 982 it in preprocessing mode with -v, then parse the include list from 983 stderr. Returns a base_flags list with -isystem for each path found. 984 985 clang always reports the real /usr/include in this list (it has no 986 notion of an alternate root); if root is given, that entry is 987 replaced with sys_include_dir(root) rather than appended alongside it, 988 so proto headers take precedence instead of conflicting with the 989 real ones. 990 """ 991 result = subprocess.run( 992 [compiler, '-xc++', '-E', '-v', '-'], 993 input='', capture_output=True, text=True) 994 995 paths = [] 996 in_list = False 997 for line in result.stderr.splitlines(): 998 if line == '#include <...> search starts here:': 999 in_list = True 1000 elif line == 'End of search list.': 1001 break 1002 elif in_list: 1003 paths.append(line.strip()) 1004 1005 if not paths: 1006 sys.exit(f'error: could not determine C++ include paths from {compiler}') 1007 1008 sys_dir = sys_include_dir(root) 1009 if '/usr/include' in paths: 1010 paths = [sys_dir if p == '/usr/include' else p for p in paths] 1011 else: 1012 paths.append(sys_dir) 1013 1014 flags = ['-Wall', '-Werror', '-nostdinc'] 1015 for p in paths: 1016 flags += ['-isystem', p] 1017 flags.append('-Wno-format-security') 1018 return flags 1019 1020 1021# --------------------------------------------------------------------------- 1022# Argument parsing and main 1023# --------------------------------------------------------------------------- 1024 1025def _parse_args(): 1026 p = argparse.ArgumentParser( 1027 description='Test C/C++ symbol visibility in system headers.') 1028 1029 p.add_argument('--lang', required=True, choices=('c', 'c++'), 1030 help='Language to test') 1031 1032 bits = p.add_mutually_exclusive_group(required=True) 1033 bits.add_argument('-m64', dest='mflag', action='store_const', const='-m64', 1034 help='Compile for 64-bit') 1035 bits.add_argument('-m32', dest='mflag', action='store_const', const='-m32', 1036 help='Compile for 32-bit') 1037 1038 p.add_argument('-c', dest='compiler', metavar='COMPILER', default=None, 1039 help='Explicit compiler path') 1040 p.add_argument('-d', dest='debug', action='store_true', 1041 help='Show probe and compiler output on failure') 1042 p.add_argument('-D', dest='extra_debug', action='store_true', 1043 help='Also show compiler command and probe program for ' 1044 'every test, not just failures (implies -d)') 1045 p.add_argument('-e', dest='env', metavar='ENV', default=None, 1046 help='Narrow to one environment name') 1047 p.add_argument('-f', dest='force', action='store_true', 1048 help='Continue after failures') 1049 p.add_argument('-j', dest='j', metavar='N', type=int, default=None, 1050 help='Number of parallel jobs (default: SYMBOL_TEST_JOBS or 4)') 1051 p.add_argument('--positive-coverage', metavar='NAME', default=None, 1052 help='Ensure an expected-success test compiles the primary ' 1053 'header in every environment named by NAME; by default ' 1054 'all declared environments require positive coverage') 1055 p.add_argument('-s', dest='sym', metavar='SYM', default=None, 1056 help='Narrow to one symbol name') 1057 1058 p.add_argument('-C', dest='compiler_check', action='store_true', 1059 help='Check compiler only, do not run tests') 1060 1061 p.add_argument('-R', dest='root', metavar='ROOT', default=None, 1062 help='Alternate root directory (e.g. a proto area) whose ' 1063 'ROOT/usr/include is tested instead of the default ' 1064 '(default: $HEADER_TEST_ROOT/usr/include if that ' 1065 'environment variable is set, else /usr/include)') 1066 1067 p.add_argument('env_cfg', nargs='?', help='Environment config file') 1068 p.add_argument('sym_cfgs', nargs='*', metavar='sym_cfg', 1069 help='One or more symbols config files') 1070 1071 args = p.parse_args() 1072 if args.extra_debug: 1073 args.debug = True 1074 jobs = 4 1075 env_jobs = os.environ.get('SYMBOL_TEST_JOBS') 1076 if env_jobs is not None: 1077 jobs = int(env_jobs) 1078 if args.j is not None: 1079 jobs = args.j 1080 args.j = jobs 1081 if args.root is None: 1082 args.root = os.environ.get('HEADER_TEST_ROOT') 1083 if not args.compiler_check and not args.env_cfg: 1084 p.error('env_cfg is required unless -C is specified') 1085 if not args.compiler_check and not args.sym_cfgs: 1086 p.error('at least one sym_cfg is required unless -C is specified') 1087 return args 1088 1089 1090def main(): 1091 args = _parse_args() 1092 mflag = args.mflag 1093 arch = '64-bit' if mflag == '-m64' else '32-bit' 1094 1095 with tempfile.TemporaryDirectory() as tmpdir: 1096 if args.lang == 'c': 1097 compiler = find_c_compiler(mflag, tmpdir, args.compiler) 1098 base_flags = c_base_flags(args.root) 1099 else: 1100 # kind is 'gcc' or 'clang', used to select the right 1101 # include path discovery method. 1102 compiler, kind = find_cxx_compiler(mflag, tmpdir, args.compiler) 1103 if kind == 'gcc': 1104 base_flags = find_gcc_cxx_includes(compiler, args.root) 1105 else: 1106 base_flags = find_clang_cxx_includes(compiler, args.root) 1107 1108 if args.compiler_check: 1109 sys.exit(0) 1110 1111 env_cfg = EnvConfig(args.lang) 1112 env_cfg.load(args.env_cfg) 1113 1114 sym_cfg = SymConfig() 1115 for path in args.sym_cfgs: 1116 sym_cfg.load(path) 1117 1118 # Named groups may intentionally omit declared environments, so the 1119 # default is the complete environment configuration. One could 1120 # also make "ALL" the default but that would encode expectations 1121 # on that being defined in the environment config file. 1122 if args.positive_coverage is None: 1123 positive_coverage = set(env_cfg.envs) 1124 else: 1125 positive_coverage = env_cfg.expand(args.positive_coverage) 1126 1127 ok = TestDriver().run( 1128 sym_cfg, env_cfg, positive_coverage, compiler, 1129 mflag, arch, base_flags, tmpdir, args) 1130 1131 sys.exit(0 if ok else 1) 1132 1133 1134if __name__ == '__main__': 1135 main() 1136