xref: /illumos-gate/usr/src/test/header-tests/tests/common/symbol_test.py (revision e1e6b944360d951edf36ef4198261818ed2d9b2f)
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