diff --git a/README.md b/README.md index 5476cb83..caf8c047 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ Ready-to-adapt workflow examples: --escape-model= loads external noescape rules for stack pointer escape analysis (`noescape_arg`) --buffer-model= loads external buffer write rules for copy/string overflow checks (`bounded_write`/`unbounded_write`) --resource-model= loads external acquire/release rules for generic resource lifetime checks +A model path given to one of these three options that is not a readable file stops the run with an error (exit code 1). A model that can be read but holds malformed rules is reported on stderr, and the analysis runs without it. --resource-cross-tu enables cross-TU resource summaries for resource lifetime analysis (default: on) --no-resource-cross-tu disables cross-TU resource summaries --resource-summary-cache-dir= sets cache directory for cross-TU resource summaries (default: .cache/resource-lifetime) diff --git a/include/StackUsageAnalyzer.hpp b/include/StackUsageAnalyzer.hpp index 7c9ccbb4..27d99883 100644 --- a/include/StackUsageAnalyzer.hpp +++ b/include/StackUsageAnalyzer.hpp @@ -93,6 +93,8 @@ namespace ctrace::stack std::string smtSecondaryBackend; std::string smtBackend = "interval"; std::string dumpIRPath; + // The directory relative input paths resolve from; empty: the current one at load. + std::string inputBaseDir; std::string escapeModelPath; std::string bufferModelPath; std::string resourceModelPath; diff --git a/include/analysis/ownership/OwnershipEngine.hpp b/include/analysis/ownership/OwnershipEngine.hpp index 7212a415..750384b5 100644 --- a/include/analysis/ownership/OwnershipEngine.hpp +++ b/include/analysis/ownership/OwnershipEngine.hpp @@ -6,6 +6,7 @@ #include "analysis/ownership/OwnershipDomain.hpp" #include "analysis/ownership/OwnershipFacts.hpp" +#include #include #include #include @@ -29,6 +30,7 @@ namespace ctrace::stack::analysis::ownership std::vector in; // by block std::vector out; // by block std::vector exits; // in block/event order; empty when incomplete + std::uint64_t blockVisits = 0; // blocks the worklist processed; observation only bool incomplete = false; // budget exhausted: nothing below may be trusted std::uint8_t reservedPadding[7] = {}; }; @@ -62,6 +64,38 @@ namespace ctrace::stack::analysis::ownership /// the exits of each kind. Fresh resources: `returns` is Guaranteed when every normal /// exit returns exactly a fresh resource, Conditional when some do, Unknown otherwise /// (likewise `outArgs` for ArgPointee locations). + /// The work of one computeSummary, for measurement only: it never changes the summary. + struct SummaryWork + { + std::uint64_t blockVisits = 0; + std::uint32_t solves = 0; + std::uint32_t reservedPadding = 0; + }; + FunctionOwnershipSummary computeSummary(const OwnershipFacts& facts, - unsigned iterationLimit = 0); + unsigned iterationLimit = 0, + SummaryWork* work = nullptr); + /// Whether two summaries are the same, incompleteness included. + bool sameSummary(const FunctionOwnershipSummary& a, const FunctionOwnershipSummary& b); + + /// The work of one computeSummaryFixpoint, for measurement only: it never changes a summary. + struct SummaryFixpointWork + { + std::uint64_t computations = 0; // summaries computed + std::uint64_t reused = 0; // summaries kept: none of those they consulted changed + std::uint32_t rounds = 0; + std::uint32_t reservedPadding = 0; + }; + + using ConsultSummary = std::function; + + /// The summaries of count functions that may consult each other's. Round by round, in index + /// order, compute(i, consult) gives the summary of function i, reading the current summary of + /// function j through consult(j), until a round changes none; past maxRounds rounds, every + /// summary is incomplete. Every summary starts empty. compute must read the summaries of these + /// functions through consult only, and depend on nothing else that changes. + std::vector computeSummaryFixpoint( + std::size_t count, + const std::function& compute, + unsigned maxRounds, SummaryFixpointWork* work = nullptr); } // namespace ctrace::stack::analysis::ownership diff --git a/run_test.py b/run_test.py index 02c0eee9..538241e7 100755 --- a/run_test.py +++ b/run_test.py @@ -2073,6 +2073,160 @@ def check_unknown_alloca_virtual_callback_escape() -> bool: return True +def check_resource_model_across_compile_directories() -> bool: + """ + A resource model given by a relative path is loaded, and used, whatever the job count, while + the files compile from compile-command directories of their own. Each directory holds the one + header its file includes through a relative -I, so a file compiles only from its own + directory; the analyzer must come back to the directory it started from, where the model and + the relative cache directories are. Every run starts with empty caches, and a leak that only + the model defines must be reported in every file. The relative paths must be anchored to the + start directory. The start directory also holds a header of the same name for every file, + one that drops the leak: a file compiled from there instead of its own directory loses it. + The files are given by absolute paths, then by paths relative to the start directory, which + must resolve there whatever directory another file is compiling from at the time; the + results name each file as it was given. + """ + print("=== Testing the resource model across compile-command directories ===") + unit_count = 8 + ok = True + with tempfile.TemporaryDirectory(prefix="ct_resource_model_cwd_") as tmp: + root = Path(tmp) + project = root / "project" + entries = [] + for i in range(unit_count): + unit_dir = project / f"unit{i}" + (unit_dir / "include").mkdir(parents=True) + (unit_dir / "include" / f"unit{i}.h").write_text( + "void* ct_open_widget(void);\nvoid ct_close_widget(void* w);\n" + ) + source = unit_dir / f"unit{i}.c" + source.write_text( + f'#include "unit{i}.h"\n\n' + f"int leak_widget_{i}(void)\n{{\n void* w = ct_open_widget();\n" + " return w != 0;\n}\n\n" + f"int keep_widget_{i}(void)\n{{\n void* w = ct_open_widget();\n" + " ct_close_widget(w);\n return 0;\n}\n" + ) + entries.append( + { + "directory": str(unit_dir), + "file": str(source), + "arguments": ["cc", "-Iinclude", "-c", str(source), "-o", f"unit{i}.o"], + } + ) + compdb = project / "compile_commands.json" + compdb.write_text(json.dumps(entries, indent=2)) + run_dir = root / "run" + (run_dir / "include").mkdir(parents=True) + for i in range(unit_count): + (run_dir / "include" / f"unit{i}.h").write_text( + "#define ct_open_widget() ((void*)0)\nvoid ct_close_widget(void* w);\n" + ) + (run_dir / "widget-model.txt").write_text( + "acquire_ret ct_open_widget Widget\nrelease_arg ct_close_widget 0 Widget\n" + ) + sources = [entry["file"] for entry in entries] + expected = {f"leak_widget_{i}" for i in range(unit_count)} + + analyzer = str(Path(RUN_CONFIG.analyzer).resolve()) + + def run_from_start_dir(args): + try: + return subprocess.run( + [analyzer, *args], + cwd=run_dir, + capture_output=True, + text=True, + timeout=RUN_CONFIG.analyzer_timeout, + ) + except subprocess.TimeoutExpired: + return None + + start_dir = run_dir.resolve() + result = run_from_start_dir( + [ + "--print-effective-config", + f"--compile-commands={compdb}", + "--resource-model=widget-model.txt", + "--compile-ir-cache-dir=caches-config/compile-ir", + sources[0], + ] + ) + printed = (result.stdout + result.stderr) if result else "" + for line in ( + f"resource-model: {start_dir / 'widget-model.txt'}", + f"compile-ir-cache-dir: {start_dir / 'caches-config' / 'compile-ir'}", + ): + if line in printed: + print(f" ✅ effective configuration: {line.split(':')[0]} anchored to start") + else: + print(f" ❌ effective configuration lacks '{line}'") + ok = False + + relative_sources = [os.path.relpath(source, run_dir) for source in sources] + runs = [ + (paths, given, mode) + for paths, given in (("absolute paths", sources), ("relative paths", relative_sources)) + for mode in ([], ["--jobs=1"], ["--jobs=4"]) + ] + for paths, given, mode in runs: + label = f"{paths}, {mode[0] if mode else 'default jobs'}" + for attempt in range(1, 4): + caches = f"caches-{label.replace('=', '').replace(', ', '-').replace(' ', '-')}-{attempt}" + cmd = [ + analyzer, + "--format=json", + f"--compile-commands={compdb}", + "--resource-model=widget-model.txt", + f"--compile-ir-cache-dir={caches}/compile-ir", + f"--resource-summary-cache-dir={caches}/resource", + *mode, + *given, + ] + result = run_from_start_dir(cmd[1:]) + if result is None: + print(f" ❌ {label}, run {attempt}: timed out") + ok = False + continue + problems = [] + if "cannot open model file" in result.stderr: + problems.append("the model failed to load") + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + diagnostics = None + problems.append(f"no JSON output (exit {result.returncode})") + if diagnostics is not None: + found = { + d.get("location", {}).get("function") + for d in diagnostics + if d.get("ruleId") == "ResourceLifetime.MissingRelease" + } + if found != expected: + problems.append(f"leaks reported in {sorted(found)}") + files = { + d.get("location", {}).get("file") + for d in diagnostics + if d.get("ruleId") == "ResourceLifetime.MissingRelease" + } + if found == expected and files != set(given): + problems.append(f"leaks not named as given: {sorted(files)}") + for cache in ("compile-ir", "resource"): + if not (run_dir / caches / cache).is_dir(): + problems.append(f"no {cache} cache under the start directory") + strays = sorted(str(p.relative_to(root)) for p in project.rglob(caches)) + if strays: + problems.append(f"caches under the compile directories: {strays}") + if problems: + print(f" ❌ {label}, run {attempt}: {'; '.join(problems)}") + ok = False + else: + print(f" ✅ {label}, run {attempt}: model used in {unit_count} files") + print() + return ok + + def check_resource_lifetime_cross_tu() -> bool: """ Regression: cross-TU resource summaries must propagate acquire/release effects @@ -3363,6 +3517,120 @@ def check_escape_model_rejects_unsupported_brackets() -> bool: return True +def check_compile_ir_cache_rebuilds_previous_schema() -> bool: + """ + A file used to be compiled from the current directory, and from its compile command's only + when that failed: with a header of the same name in both, the compile IR cache kept the IR + built with the wrong one. Compiling from the command's directory changes the IR of an + unchanged command, so the cache schema changed. + + The schema is in two places: the cache key, and the metadata of each entry. An entry stored + under a previous key is never looked up again; this check covers the other one. It rewrites + the entry of the current key as the previous pipeline left it: the previous schema, and the + IR built with the start directory's header (7, where the command's header gives 42), with + the sources and dependencies left valid. The next run must compile again and give 42, and + rewrite the entry; the run after it must reuse that entry and give 42 again. + """ + print("=== Testing that compile IR cached under a previous schema is rebuilt ===") + ok = True + with tempfile.TemporaryDirectory(prefix="ct_compile_ir_schema_") as tmp: + root = Path(tmp) + (root / "start" / "include").mkdir(parents=True) + (root / "start" / "include" / "config.h").write_text("#define VALUE 7\n") + (root / "project" / "build" / "include").mkdir(parents=True) + (root / "project" / "build" / "include" / "config.h").write_text("#define VALUE 42\n") + source = root / "project" / "value.c" + source.write_text("#include \nint value(void) { return VALUE; }\n") + compdb = root / "project" / "compile_commands.json" + compdb.write_text(json.dumps([{ + "directory": str(root / "project" / "build"), + "file": str(source), + "arguments": ["cc", "-Iinclude", "-c", str(source)], + }])) + cache = root / "compile-ir" + analyzer = str(Path(RUN_CONFIG.analyzer).resolve()) + + def run(name): + dump = root / f"ir-{name}" + try: + result = subprocess.run( + [analyzer, "--timing", f"--compile-commands={compdb}", + "--compile-ir-format=ll", f"--compile-ir-cache-dir={cache}", + f"--dump-ir={dump}/", str(source)], + cwd=root / "start", capture_output=True, text=True, + timeout=RUN_CONFIG.analyzer_timeout, + ) + except subprocess.TimeoutExpired: + return None, False + returned = re.findall(r"ret i32 (\d+)", "".join(p.read_text() for p in dump.glob("*.ll"))) + return (returned[0] if returned else None), "Compilation cache hit" in result.stderr + + value, _ = run("empty") + entries = sorted(cache.glob("*.json")) + if value != "42" or len(entries) != 1: + print(f" ❌ with an empty cache: returns {value}, {len(entries)} cache entries") + return False + metadata = json.loads(entries[0].read_text()) + current = metadata["schema"] + ir_file = entries[0].with_suffix(".ll") + metadata["schema"] = "compile-ir-cache-v2" + entries[0].write_text(json.dumps(metadata)) + ir_file.write_text(ir_file.read_text().replace("ret i32 42", "ret i32 7")) + + value, hit = run("previous") + rewritten = json.loads(entries[0].read_text())["schema"] + if value == "42" and not hit and rewritten == current: + print(" ✅ an entry of the previous schema is compiled again and rewritten") + else: + ok = False + print(f" ❌ with the previous schema: returns {value}, cache hit {hit}, entry schema {rewritten}") + value, hit = run("rebuilt") + if value == "42" and hit: + print(" ✅ the rewritten entry is reused") + else: + ok = False + print(f" ❌ after the rebuild: returns {value}, cache hit {hit}") + print() + return ok + + +def check_unreadable_model_fails() -> bool: + """ + A model the command line asks for, but that cannot be read, is a configuration error: the + analyzer must stop with a nonzero code naming the file, and run_code_analysis.py must fail, + instead of analyzing without the model and reporting success. + """ + print("=== Testing that a requested model that cannot be read fails the run ===") + ok = True + with tempfile.TemporaryDirectory(prefix="ct_unreadable_model_") as tmp: + tmpdir = Path(tmp) + source = tmpdir / "leak.c" + source.write_text("void* acquire_handle(void);\nvoid leak(void) { (void)acquire_handle(); }\n") + missing = tmpdir / "missing-model.txt" + for option in ("--resource-model", "--escape-model", "--buffer-model"): + for path, what in ((missing, "missing file"), (tmpdir, "directory")): + result = run_analyzer_uncached([f"{option}={path}", str(source)]) + output = (result.stdout or "") + (result.stderr or "") + if result.returncode != 0 and str(path) in output: + print(f" ✅ {option}, {what}: code {result.returncode}, file named") + else: + print(f" ❌ {option}, {what}: code {result.returncode}, file named: {str(path) in output}") + ok = False + script = _run_ci_script( + ["--analyzer", str(RUN_CONFIG.analyzer), "--fail-on", "warning", + f"--analyzer-arg=--resource-model={missing}", str(source)], + jobs=1, + ) + if script.returncode != 0: + print(f" ✅ run_code_analysis.py with a missing model: code {script.returncode}") + else: + print(" ❌ run_code_analysis.py passed with a missing model") + print((script.stdout or "") + (script.stderr or "")) + ok = False + print() + return ok + + def check_docker_entrypoint_guardrails() -> bool: """ Regression: docker wrapper should only create compatibility symlinks under @@ -5055,6 +5323,7 @@ def record_ok(ok: bool): check_exclude_dir_filter, check_multi_tu_folder_analysis, check_resource_lifetime_cross_tu, + check_resource_model_across_compile_directories, check_ownership_cross_tu, check_ownership_wrapper_metadata, check_uninitialized_cross_tu, @@ -5068,6 +5337,8 @@ def record_ok(ok: bool): check_noreturn_cross_tu, check_use_after_free_advanced_inter_tu, check_escape_model_rejects_unsupported_brackets, + check_compile_ir_cache_rebuilds_previous_schema, + check_unreadable_model_fails, check_human_vs_json_parity, check_diagnostic_rule_coverage_regression, check_diagnostic_cwe_coverage, diff --git a/src/analysis/InputPipeline.cpp b/src/analysis/InputPipeline.cpp index e08272e4..b19df826 100644 --- a/src/analysis/InputPipeline.cpp +++ b/src/analysis/InputPipeline.cpp @@ -38,23 +38,16 @@ namespace ctrace::stack::analysis { namespace { - std::mutex gCompileWorkingDirMutex; // compilerlib drives an in-process clang CompilerInstance. clang/LLVM keep global // mutable state (target registry, cl options, timers); two worker threads compiling // at once hung intermittently on the first compile of a run. Compilation is // serialised here; module analysis stays parallel, and the IR cache makes the - // compile step cheap on repeated runs. + // compile step cheap on repeated runs. A compile that needs its compile command's + // directory changes the working directory of the whole process: it holds the same + // lock from before that change until it is undone, so no other compile runs in a + // directory that is not its own, and two changes never interleave. std::mutex gCompileInvokeMutex; - std::string makeAbsolutePath(const std::string& path) - { - std::error_code ec; - std::filesystem::path absPath = std::filesystem::absolute(path, ec); - if (ec) - return path; - return absPath.lexically_normal().generic_string(); - } - void appendIfMissing(std::vector& args, const std::string& flag) { if (std::find(args.begin(), args.end(), flag) == args.end()) @@ -199,7 +192,7 @@ namespace ctrace::stack::analysis } } - constexpr llvm::StringLiteral kCompileIRCacheSchema = "compile-ir-cache-v2"; + constexpr llvm::StringLiteral kCompileIRCacheSchema = "compile-ir-cache-v3"; struct FileSnapshot { @@ -659,14 +652,15 @@ namespace ctrace::stack::analysis return true; } - bool buildCompileArgs(const std::string& filename, LanguageType language, - const AnalysisConfig& config, std::vector& args, - std::string& workingDir, std::string& error) + bool buildCompileArgs(const std::string& filename, const std::string& inputPath, + LanguageType language, const AnalysisConfig& config, + std::vector& args, std::string& workingDir, + std::string& error) { const CompileCommand* command = nullptr; if (config.compilationDatabase) { - command = config.compilationDatabase->findCommandForFile(filename); + command = config.compilationDatabase->findCommandForFile(inputPath); } if (command) @@ -711,7 +705,7 @@ namespace ctrace::stack::analysis args.push_back("-g"); appendIfMissing(args, "-fno-discard-value-names"); const bool useAbsolutePath = (command != nullptr); - args.push_back(useAbsolutePath ? makeAbsolutePath(filename) : filename); + args.push_back(useAbsolutePath ? inputPath : filename); return true; } @@ -820,13 +814,19 @@ namespace ctrace::stack::analysis using ctrace::stack::analyzer::ScopedHotspot; ModuleLoadResult result; const ScopedHotspot totalHotspot(config.timing, "input.load_module.total"); + // A relative input resolves from the directory the analysis started in, not from the + // current one: another thread may be compiling from its compile command's directory, + // which the whole process shares meanwhile. std::error_code cwdErr; - std::filesystem::path baseDir = std::filesystem::current_path(cwdErr); + const std::filesystem::path baseDir = config.inputBaseDir.empty() + ? std::filesystem::current_path(cwdErr) + : std::filesystem::path(config.inputBaseDir); + const std::string inputPath = makeAbsolutePathFrom(filename, baseDir.string()); using Clock = std::chrono::steady_clock; auto compileStart = Clock::now(); { const ScopedHotspot hotspot(config.timing, "input.detect_language"); - result.language = detectLanguageFromFile(filename, ctx); + result.language = detectLanguageFromFile(inputPath, ctx); } if (result.language == LanguageType::Unknown) @@ -844,8 +844,8 @@ namespace ctrace::stack::analysis bool compileArgsReady = false; { const ScopedHotspot hotspot(config.timing, "input.build_compile_args"); - compileArgsReady = buildCompileArgs(filename, result.language, config, args, - workingDir, compileError); + compileArgsReady = buildCompileArgs(filename, inputPath, result.language, config, + args, workingDir, compileError); } if (!compileArgsReady) { @@ -857,7 +857,7 @@ namespace ctrace::stack::analysis coretrace::log(coretrace::Level::Info, "Compiling {}...\n", filename); const bool preferBitcodeCompile = (config.compileIRFormat == CompileIRFormat::BC); const CompileIRCachePaths cachePaths = - buildCompileIRCachePaths(config, filename, result.language, args, workingDir); + buildCompileIRCachePaths(config, inputPath, result.language, args, workingDir); std::error_code tempDirErr; std::filesystem::path tempDir = std::filesystem::temp_directory_path(tempDirErr); @@ -872,14 +872,16 @@ namespace ctrace::stack::analysis const std::vector bitcodeArgs = buildBitcodeCompileArgs(args, tempBitcodePath); - auto compileWithOptionalWorkingDir = - [&](const std::vector& compileArgs, compilerlib::OutputMode outputMode, - bool useWorkingDir) -> std::optional + // A compile command's relative paths are relative to its directory: compile there, + // even when the current directory also resolves them, to another header. + auto compileInWorkingDir = + [&](const std::vector& compileArgs, + compilerlib::OutputMode outputMode) -> std::optional { - if (!useWorkingDir) + std::lock_guard compileLock(gCompileInvokeMutex); + if (workingDir.empty()) { const ScopedHotspot hotspot(config.timing, "input.compiler.invoke"); - std::lock_guard compileLock(gCompileInvokeMutex); return compilerlib::compile(compileArgs, outputMode); } @@ -891,38 +893,9 @@ namespace ctrace::stack::analysis return std::nullopt; } const ScopedHotspot hotspot(config.timing, "input.compiler.invoke.cwd"); - std::lock_guard compileLock(gCompileInvokeMutex); return compilerlib::compile(compileArgs, outputMode); }; - auto compileWithConfiguredWorkingDir = - [&](const std::vector& compileArgs, compilerlib::OutputMode outputMode, - bool& retriedWithWorkingDir) -> std::optional - { - retriedWithWorkingDir = false; - std::optional res; - const bool hasWorkingDir = !workingDir.empty(); - if (config.jobs > 1 && hasWorkingDir) - { - // Optimistic fast path for multi-job runs: most compdb commands use absolute - // paths. - res = compileWithOptionalWorkingDir(compileArgs, outputMode, false); - if (!res || !res->success) - { - // Fallback keeps correctness for relative include paths and avoids process - // cwd races. - std::lock_guard lock(gCompileWorkingDirMutex); - res = compileWithOptionalWorkingDir(compileArgs, outputMode, true); - retriedWithWorkingDir = true; - } - } - else - { - res = compileWithOptionalWorkingDir(compileArgs, outputMode, hasWorkingDir); - } - return res; - }; - if (cachePaths.enabled) { auto cached = [&]() -> std::optional @@ -1044,7 +1017,6 @@ namespace ctrace::stack::analysis } } - bool retriedWithWorkingDir = false; bool compiledViaBitcode = false; std::optional res; std::optional sourceSnapshot; @@ -1057,20 +1029,14 @@ namespace ctrace::stack::analysis preferBitcodeCompile ? bitcodeArgs : args; appendDependencyCaptureArgs(cacheCompileArgs, cachePaths.depFile); - bool retriedForDependencyCompile = false; - res = compileWithConfiguredWorkingDir(cacheCompileArgs, - preferBitcodeCompile - ? compilerlib::OutputMode::ToFile - : compilerlib::OutputMode::ToMemory, - retriedForDependencyCompile); - retriedWithWorkingDir = retriedForDependencyCompile; + res = compileInWorkingDir(cacheCompileArgs, + preferBitcodeCompile ? compilerlib::OutputMode::ToFile + : compilerlib::OutputMode::ToMemory); if (preferBitcodeCompile && (!res || !res->success)) { - bool retriedForFallbackCompile = false; - auto fallbackResult = compileWithConfiguredWorkingDir( - args, compilerlib::OutputMode::ToMemory, retriedForFallbackCompile); - retriedWithWorkingDir = retriedWithWorkingDir || retriedForFallbackCompile; + auto fallbackResult = + compileInWorkingDir(args, compilerlib::OutputMode::ToMemory); if (fallbackResult) res = std::move(fallbackResult); } @@ -1083,7 +1049,7 @@ namespace ctrace::stack::analysis "input.cache.parse_dependencies"); return parseDepfileDependencies(cachePaths.depFile, workingDir); }(); - const std::string sourcePath = makeAbsolutePathFrom(filename, workingDir); + const std::string& sourcePath = inputPath; sourceSnapshot = [&]() -> std::optional { const ScopedHotspot hotspot(config.timing, @@ -1100,21 +1066,17 @@ namespace ctrace::stack::analysis } else { - res = compileWithConfiguredWorkingDir(preferBitcodeCompile ? bitcodeArgs : args, - preferBitcodeCompile - ? compilerlib::OutputMode::ToFile - : compilerlib::OutputMode::ToMemory, - retriedWithWorkingDir); + res = compileInWorkingDir(preferBitcodeCompile ? bitcodeArgs : args, + preferBitcodeCompile ? compilerlib::OutputMode::ToFile + : compilerlib::OutputMode::ToMemory); if (res && res->success) { compiledViaBitcode = preferBitcodeCompile; } else if (preferBitcodeCompile) { - bool retriedForFallbackCompile = false; - auto fallbackResult = compileWithConfiguredWorkingDir( - args, compilerlib::OutputMode::ToMemory, retriedForFallbackCompile); - retriedWithWorkingDir = retriedWithWorkingDir || retriedForFallbackCompile; + auto fallbackResult = + compileInWorkingDir(args, compilerlib::OutputMode::ToMemory); if (fallbackResult) res = std::move(fallbackResult); } @@ -1140,8 +1102,7 @@ namespace ctrace::stack::analysis auto ms = std::chrono::duration_cast(compileEnd - compileStart) .count(); - coretrace::log(coretrace::Level::Info, "Compilation done in {} ms{}\n", ms, - retriedWithWorkingDir ? " (retry with working directory)" : ""); + coretrace::log(coretrace::Level::Info, "Compilation done in {} ms\n", ms); } std::string llvmIRForCache; @@ -1195,10 +1156,8 @@ namespace ctrace::stack::analysis { if (compiledViaBitcode) { - bool retriedForTextFallback = false; - auto textFallback = compileWithConfiguredWorkingDir( - args, compilerlib::OutputMode::ToMemory, retriedForTextFallback); - retriedWithWorkingDir = retriedWithWorkingDir || retriedForTextFallback; + auto textFallback = + compileInWorkingDir(args, compilerlib::OutputMode::ToMemory); if (!textFallback) return result; res = std::move(textFallback); @@ -1296,7 +1255,7 @@ namespace ctrace::stack::analysis cwdErr ? std::string() : baseDir.lexically_normal().generic_string(); const CompileIRCachePaths cachePaths = isTextIRInput - ? buildCompileIRCachePaths(config, filename, result.language, {}, cacheWorkingDir) + ? buildCompileIRCachePaths(config, inputPath, result.language, {}, cacheWorkingDir) : CompileIRCachePaths{}; if (isTextIRInput && cachePaths.enabled) @@ -1398,7 +1357,7 @@ namespace ctrace::stack::analysis const auto parseStart = Clock::now(); { const ScopedHotspot hotspot(config.timing, "input.parse_ir_file"); - result.module = llvm::parseIRFile(filename, err, ctx); + result.module = llvm::parseIRFile(inputPath, err, ctx); } if (config.timing) { @@ -1412,10 +1371,10 @@ namespace ctrace::stack::analysis { if (isTextIRInput && cachePaths.enabled) { - if (const auto sourceSnapshot = captureFileSnapshot(filename)) + if (const auto sourceSnapshot = captureFileSnapshot(inputPath)) { std::string sourceIR; - (void)readTextFile(filename, sourceIR); + (void)readTextFile(inputPath, sourceIR); std::string llvmBitcode; llvm::raw_string_ostream bitcodeStream(llvmBitcode); diff --git a/src/analysis/ResourceLifetimeAnalysis.cpp b/src/analysis/ResourceLifetimeAnalysis.cpp index 0734d893..8751790b 100644 --- a/src/analysis/ResourceLifetimeAnalysis.cpp +++ b/src/analysis/ResourceLifetimeAnalysis.cpp @@ -2116,18 +2116,10 @@ namespace ctrace::stack::analysis return true; } - static bool exitTransformerEquals(const ownership::ExitTransformer& a, - const ownership::ExitTransformer& b) - { - return a.present == b.present && a.returns == b.returns && a.outArgs == b.outArgs && - a.params == b.params && a.pointeeParams == b.pointeeParams; - } - bool ownershipSummaryEquals(const ownership::FunctionOwnershipSummary& lhs, const ownership::FunctionOwnershipSummary& rhs) { - return lhs.incomplete == rhs.incomplete && exitTransformerEquals(lhs.normal, rhs.normal) && - exitTransformerEquals(lhs.exceptional, rhs.exceptional); + return ownership::sameSummary(lhs, rhs); } /// Transformer summaries of every analysed definition, iterated to a fixpoint so that @@ -2153,43 +2145,36 @@ namespace ctrace::stack::analysis if (!F.isDeclaration() && shouldAnalyze(F)) functions.push_back(&F); } - // Start from "no effect" transformers so a recursive callee is optimistic first - // and widens monotonically. - for (llvm::Function* F : functions) - summaries[F] = FunctionOwnershipSummary{}; - - const ownership::SummaryLookup lookup{ - [&](const llvm::Function& callee) -> const FunctionOwnershipSummary* - { - if (const auto it = summaries.find(&callee); it != summaries.end()) - return &it->second; - const auto ext = externalByName.find( - ctrace_tools::canonicalizeMangledName(callee.getName().str())); - return ext == externalByName.end() ? nullptr : ext->second; - }}; + std::unordered_map indexOf; + for (std::size_t i = 0; i < functions.size(); ++i) + indexOf.emplace(functions[i], i); + // Every summary starts as "no effect", so a recursive callee is optimistic first and + // widens monotonically. The summaries of this module are read through consult, which + // lets the fixed point know what each computation consulted; those of other modules do + // not change here. const llvm::DataLayout& DL = mod.getDataLayout(); constexpr unsigned kMaxRounds = 16; - for (unsigned round = 0; round < kMaxRounds; ++round) - { - bool changed = false; - for (llvm::Function* F : functions) + const std::vector computed = ownership::computeSummaryFixpoint( + functions.size(), + [&](std::size_t i, const ownership::ConsultSummary& consult) { + const ownership::SummaryLookup lookup{ + [&](const llvm::Function& callee) -> const FunctionOwnershipSummary* + { + if (const auto it = indexOf.find(&callee); it != indexOf.end()) + return &consult(it->second); + const auto ext = externalByName.find( + ctrace_tools::canonicalizeMangledName(callee.getName().str())); + return ext == externalByName.end() ? nullptr : ext->second; + }}; const ownership::CollectedFunction collected = - ownership::collectOwnershipFacts(*F, model, lookup, DL); - FunctionOwnershipSummary next = ownership::computeSummary(collected.facts); - if (!ownershipSummaryEquals(summaries[F], next)) - { - summaries[F] = std::move(next); - changed = true; - } - } - if (!changed) - return summaries; - } - // No fixpoint within budget: nothing computed here may be trusted. - for (auto& [F, summary] : summaries) - summary.incomplete = true; + ownership::collectOwnershipFacts(*functions[i], model, lookup, DL); + return ownership::computeSummary(collected.facts); + }, + kMaxRounds); + for (std::size_t i = 0; i < functions.size(); ++i) + summaries[functions[i]] = computed[i]; return summaries; } diff --git a/src/analysis/ownership/OwnershipEngine.cpp b/src/analysis/ownership/OwnershipEngine.cpp index 82ecbc55..31577958 100644 --- a/src/analysis/ownership/OwnershipEngine.cpp +++ b/src/analysis/ownership/OwnershipEngine.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include namespace ctrace::stack::analysis::ownership { @@ -304,6 +306,7 @@ namespace ctrace::stack::analysis::ownership const std::uint32_t block = worklist.front(); worklist.pop_front(); queued[block] = false; + ++result.blockVisits; AbstractState state = result.in[block]; for (const Event& e : facts.blocks[block].events) @@ -476,9 +479,20 @@ namespace ctrace::stack::analysis::ownership } } // namespace - FunctionOwnershipSummary computeSummary(const OwnershipFacts& facts, unsigned iterationLimit) + FunctionOwnershipSummary computeSummary(const OwnershipFacts& facts, unsigned iterationLimit, + SummaryWork* work) { FunctionOwnershipSummary summary; + const auto solveCounted = [&](const AbstractState& entry) + { + OwnershipResult res = solve(facts, entry, iterationLimit); + if (work) + { + ++work->solves; + work->blockVisits += res.blockVisits; + } + return res; + }; // Parameters (by value, then pointees) get their own resources, numbered after the // function's sites. @@ -492,66 +506,72 @@ namespace ctrace::stack::analysis::ownership : facts.pointeeLocations[p - facts.paramLocations.size()].second; }; - const auto entryWith = [&](std::size_t paramIndex, OwnState state) + // Every parameter gets a resource of its own, in the given entry state. + const auto entryWith = [&](OwnState state) { AbstractState entry = AbstractState::entry(resourceCount, facts.locations.size()); for (std::size_t p = 0; p < paramTotal; ++p) { const ResourceId r = newInstanceOf(paramSiteBase + static_cast(p)); entry.locations[paramLocationAt(p)].add(r); - entry.resources[r] = StateSet::of(p == paramIndex ? state : OwnState::Owned); + entry.resources[r] = StateSet::of(state); } return entry; }; + // One solve per entry state, every parameter in that state. An event acts on a resource + // from that resource's own state, and what a location holds never depends on any state; + // paths join resource by resource. So each parameter ends as it would in a solve where + // only it starts in that state and the others start Owned. The solve with every + // parameter Owned also gives the fresh resources; without parameters it is the only one. bool anyNormal = false; bool anyExceptional = false; - for (std::size_t p = 0; p < paramTotal; ++p) + std::vector normal(paramTotal); + std::vector exceptional(paramTotal); + OwnershipResult res; + for (const OwnState state : kAllStates) { - const ResourceId r = newInstanceOf(paramSiteBase + static_cast(p)); - ParamTransformer normal{}; - ParamTransformer exceptional{}; - for (const OwnState state : kAllStates) + if (paramTotal == 0 && state != OwnState::Owned) + continue; + OwnershipResult solved = solveCounted(entryWith(state)); + if (solved.incomplete) { - const OwnershipResult res = solve(facts, entryWith(p, state), iterationLimit); - if (res.incomplete) - { - summary.incomplete = true; - return summary; - } - for (const ExitRecord& exit : res.exits) + summary.incomplete = true; + return summary; + } + for (const ExitRecord& exit : solved.exits) + { + (exit.exceptional ? anyExceptional : anyNormal) = true; + for (std::size_t p = 0; p < paramTotal; ++p) { - ParamTransformer& t = exit.exceptional ? exceptional : normal; + const ResourceId r = + newInstanceOf(paramSiteBase + static_cast(p)); + ParamTransformer& t = exit.exceptional ? exceptional[p] : normal[p]; t[static_cast(state)] |= exit.state.resources[r]; if (exit.state.uncertain[r]) t.uncertainInputs |= StateSet::of(state); - (exit.exceptional ? anyExceptional : anyNormal) = true; } } + if (state == OwnState::Owned) + res = std::move(solved); + } + for (std::size_t p = 0; p < paramTotal; ++p) + { if (p < facts.paramLocations.size()) { const unsigned argIndex = facts.paramLocations[p].first; - summary.normal.params[argIndex] = normal; - summary.exceptional.params[argIndex] = exceptional; + summary.normal.params[argIndex] = normal[p]; + summary.exceptional.params[argIndex] = exceptional[p]; } else { const ArgPath& path = facts.pointeeLocations[p - facts.paramLocations.size()].first; - summary.normal.pointeeParams[path] = normal; - summary.exceptional.pointeeParams[path] = exceptional; + summary.normal.pointeeParams[path] = normal[p]; + summary.exceptional.pointeeParams[path] = exceptional[p]; } } - // Fresh resources: one solve with every parameter Owned (or none). - const OwnershipResult res = - solve(facts, entryWith(paramTotal, OwnState::Owned), iterationLimit); - if (res.incomplete) - { - summary.incomplete = true; - return summary; - } - for (const ExitRecord& exit : res.exits) - (exit.exceptional ? anyExceptional : anyNormal) = true; + // Fresh resources: read off the solve with every parameter Owned. const ResourceId paramResourceLowerBound = newInstanceOf(paramSiteBase); summary.normal.returns.certainty = freshResourceCertainty( facts, res, false, LocationKind::Return, ArgPath{}, paramResourceLowerBound); @@ -582,4 +602,68 @@ namespace ctrace::stack::analysis::ownership summary.exceptional.present = anyExceptional; return summary; } + + bool sameSummary(const FunctionOwnershipSummary& a, const FunctionOwnershipSummary& b) + { + const auto sameExit = [](const ExitTransformer& x, const ExitTransformer& y) + { + return x.present == y.present && x.returns == y.returns && x.outArgs == y.outArgs && + x.params == y.params && x.pointeeParams == y.pointeeParams; + }; + return a.incomplete == b.incomplete && sameExit(a.normal, b.normal) && + sameExit(a.exceptional, b.exceptional); + } + + std::vector computeSummaryFixpoint( + std::size_t count, + const std::function& compute, + unsigned maxRounds, SummaryFixpointWork* work) + { + std::vector summaries(count); + // A summary's version grows each time it changes. A computation records the version of + // every summary it consults; while none of them has changed since, computing again would + // give the same summary, so it is kept. + std::vector version(count, 0); + std::vector>>> consulted( + count); + for (unsigned round = 0; round < maxRounds; ++round) + { + if (work) + ++work->rounds; + bool changed = false; + for (std::size_t i = 0; i < count; ++i) + { + if (consulted[i] && + std::all_of(consulted[i]->begin(), consulted[i]->end(), [&](const auto& input) + { return version[input.first] == input.second; })) + { + if (work) + ++work->reused; + continue; + } + std::vector> inputs; + const ConsultSummary consult = [&](std::size_t j) -> const FunctionOwnershipSummary& + { + inputs.emplace_back(j, version[j]); + return summaries[j]; + }; + FunctionOwnershipSummary next = compute(i, consult); + if (work) + ++work->computations; + consulted[i] = std::move(inputs); + if (!sameSummary(summaries[i], next)) + { + summaries[i] = std::move(next); + ++version[i]; + changed = true; + } + } + if (!changed) + return summaries; + } + // No fixpoint within budget: nothing computed here may be trusted. + for (FunctionOwnershipSummary& summary : summaries) + summary.incomplete = true; + return summaries; + } } // namespace ctrace::stack::analysis::ownership diff --git a/src/app/AnalyzerApp.cpp b/src/app/AnalyzerApp.cpp index 7a037ede..ed682262 100644 --- a/src/app/AnalyzerApp.cpp +++ b/src/app/AnalyzerApp.cpp @@ -835,6 +835,26 @@ static AppStatus configureDumpIRPath(const std::vector& inputFilena return AppStatus::success(); } +// A model the command line asks for is read by the analyses that use it, which only log a +// failure and run without it: an unreadable model would pass for a run that found nothing. +static AppStatus checkRequestedModelsReadable(const AnalysisConfig& cfg) +{ + const std::pair models[] = { + {"resource", &cfg.resourceModelPath}, + {"stack escape", &cfg.escapeModelPath}, + {"buffer", &cfg.bufferModelPath}, + }; + for (const auto& [kind, path] : models) + { + if (path->empty()) + continue; + std::error_code fsErr; + if (!std::filesystem::is_regular_file(*path, fsErr) || !std::ifstream(*path)) + return AppStatus::failure(std::string("Cannot read ") + kind + " model file: " + *path); + } + return AppStatus::success(); +} + static void printInterprocStatus(const AnalysisConfig& cfg, std::size_t inputCount, bool needsCrossTUResourceSummaries, bool needsCrossTUUninitializedSummaries, @@ -2616,6 +2636,10 @@ class RunPlanBuilder plan.sarifBaseDir = std::move(parsedArgs_.sarifBaseDir); plan.sarifOutPath = std::move(parsedArgs_.sarifOutPath); + AppStatus modelStatus = checkRequestedModelsReadable(plan.cfg); + if (!modelStatus.isOk()) + return AppResult::failure(std::move(modelStatus.error)); + if (parsedArgs_.compileCommandsExplicit) { AppStatus loadStatus = diff --git a/src/cli/ArgParser.cpp b/src/cli/ArgParser.cpp index d2677864..9348d6a1 100644 --- a/src/cli/ArgParser.cpp +++ b/src/cli/ArgParser.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1658,6 +1659,22 @@ namespace ctrace::stack::cli parsed.inputFilenames.emplace_back(std::move(argStr)); } + // The analysis opens these while other threads may be compiling from the directory of + // their compile command: they are anchored to the directory the analyzer starts from. + std::error_code startDirError; + const std::filesystem::path startDir = std::filesystem::current_path(startDirError); + if (!startDirError) + { + for (std::string* path : + {&cfg.escapeModelPath, &cfg.bufferModelPath, &cfg.resourceModelPath, + &cfg.resourceSummaryCacheDir, &cfg.compileIRCacheDir, &cfg.dumpIRPath, + &parsed.compileCommandsPath}) + { + *path = resolveConfigRelativePath(*path, startDir); + } + cfg.inputBaseDir = startDir.string(); + } + return result; } diff --git a/test/unit/ownership_engine_unit_tests.cpp b/test/unit/ownership_engine_unit_tests.cpp index decd634c..b3150cda 100644 --- a/test/unit/ownership_engine_unit_tests.cpp +++ b/test/unit/ownership_engine_unit_tests.cpp @@ -5,7 +5,11 @@ #include "analysis/ownership/OwnershipEngine.hpp" #include "analysis/ownership/OwnershipFacts.hpp" +#include +#include +#include #include +#include #include namespace @@ -561,6 +565,422 @@ namespace } return r.failures == 0; } + // ---- summaries of several parameters at once ------------------------------------------- + + // The transformers of the parameters as computeSummary built them before it solved the + // parameters together: one solve per parameter and entry state, the other parameters Owned, + // and one solve with every parameter Owned for the fresh resources. + struct ReferenceTransformers + { + std::map normalParams; + std::map exceptionalParams; + std::map normalPointees; + std::map exceptionalPointees; + bool anyNormal = false; + bool anyExceptional = false; + bool incomplete = false; + }; + + AbstractState summaryEntry(const OwnershipFacts& f, std::size_t paramIndex, OwnState state) + { + const std::size_t total = f.paramLocations.size() + f.pointeeLocations.size(); + AbstractState entry = AbstractState::entry(2u * (f.siteCount + total), f.locations.size()); + for (std::size_t p = 0; p < total; ++p) + { + const ResourceId r = newInstanceOf(f.siteCount + static_cast(p)); + const LocationId loc = p < f.paramLocations.size() + ? f.paramLocations[p].second + : f.pointeeLocations[p - f.paramLocations.size()].second; + entry.locations[loc].add(r); + entry.resources[r] = StateSet::of(p == paramIndex ? state : OwnState::Owned); + } + return entry; + } + + ReferenceTransformers referenceTransformers(const OwnershipFacts& f, unsigned limit) + { + ReferenceTransformers ref; + const std::size_t total = f.paramLocations.size() + f.pointeeLocations.size(); + for (std::size_t p = 0; p < total; ++p) + { + const ResourceId r = newInstanceOf(f.siteCount + static_cast(p)); + ParamTransformer normal{}; + ParamTransformer exceptional{}; + for (const OwnState state : + {OwnState::NotOwned, OwnState::Owned, OwnState::Released, OwnState::Escaped}) + { + const OwnershipResult res = solve(f, summaryEntry(f, p, state), limit); + if (res.incomplete) + { + ref.incomplete = true; + return ref; + } + for (const ExitRecord& exit : res.exits) + { + ParamTransformer& t = exit.exceptional ? exceptional : normal; + t[static_cast(state)] |= exit.state.resources[r]; + if (exit.state.uncertain[r]) + t.uncertainInputs |= StateSet::of(state); + (exit.exceptional ? ref.anyExceptional : ref.anyNormal) = true; + } + } + if (p < f.paramLocations.size()) + { + ref.normalParams[f.paramLocations[p].first] = normal; + ref.exceptionalParams[f.paramLocations[p].first] = exceptional; + } + else + { + const ArgPath& path = f.pointeeLocations[p - f.paramLocations.size()].first; + ref.normalPointees[path] = normal; + ref.exceptionalPointees[path] = exceptional; + } + } + const OwnershipResult fresh = solve(f, summaryEntry(f, total, OwnState::Owned), limit); + if (fresh.incomplete) + { + ref.incomplete = true; + return ref; + } + for (const ExitRecord& exit : fresh.exits) + (exit.exceptional ? ref.anyExceptional : ref.anyNormal) = true; + return ref; + } + + // computeSummary gives what one solve per parameter gives, and is incomplete exactly when + // that is. + void expectSameAsReference(TestReport& r, const OwnershipFacts& f, unsigned limit, + const std::string& name) + { + const ReferenceTransformers ref = referenceTransformers(f, limit); + const FunctionOwnershipSummary s = computeSummary(f, limit); + r.expect(s.incomplete == ref.incomplete, + "Joint summary: " + name + ": incomplete exactly when one solve per parameter is"); + if (s.incomplete || ref.incomplete) + return; + r.expect( + s.normal.params == ref.normalParams && s.exceptional.params == ref.exceptionalParams && + s.normal.pointeeParams == ref.normalPointees && + s.exceptional.pointeeParams == ref.exceptionalPointees && + s.normal.present == ref.anyNormal && s.exceptional.present == ref.anyExceptional, + "Joint summary: " + name + ": same transformers as one solve per parameter"); + } + + ArgPath pathOf(unsigned argIndex, std::uint64_t offset) + { + ArgPath path; + path.argIndex = argIndex; + path.offset = offset; + return path; + } + + // Parameters 0 and 1 by value in locations 0 and 1; the pointee of argument 2 at offset 0 in + // location 2, at offset 8 in location 3. Location 4 is a local. + OwnershipFacts severalParams(std::size_t blocks, std::vector edges) + { + OwnershipFacts f = facts(blocks, std::move(edges), 5, 1); + f.paramLocations = {{0, 0}, {1, 1}}; + f.locations[2].kind = LocationKind::ArgPointee; + f.locations[2].path = pathOf(2, 0); + f.locations[3].kind = LocationKind::ArgPointee; + f.locations[3].path = pathOf(2, 8); + f.pointeeLocations = {{pathOf(2, 0), 2}, {pathOf(2, 8), 3}}; + return f; + } + + Event unknownCallOn(LocationId loc) + { + Event e; + e.kind = Event::Kind::UnknownCall; + e.args = {loc}; + return e; + } + + bool testJointSummaries(TestReport& r) + { + { + // Aliases: parameter 0 is stored into the pointee of argument 2, then released + // through the local that copies it; parameter 1 goes to an unknown call. + OwnershipFacts f = severalParams(1, {}); + f.blocks[0].events = {copy(4, 0), copy(2, 0, /*strong=*/false), + release(4), unknownCallOn(1), + copy(3, 1, false), exit()}; + expectSameAsReference(r, f, 0, "aliases"); + } + { + // Pointee paths: offset 8 is released, offset 0 escapes through its address, and a + // fresh resource is acquired into the local. + OwnershipFacts f = severalParams(1, {}); + f.blocks[0].events = {release(3), addressEscape(2), acquire(0, 4), release(4), exit()}; + expectSameAsReference(r, f, 0, "pointee paths"); + } + { + // A loop that may release parameter 0 on each turn, overwrites parameter 1, and + // copies the pointee at offset 0 into the local. + OwnershipFacts f = severalParams(3, {edge(0, 1), edge(1, 1), edge(1, 2)}); + f.blocks[1].events = { + callWith(0, StateSet::of(OwnState::Owned) | StateSet::of(OwnState::Released)), + copy(4, 2, false), overwrite(1, true, false)}; + f.blocks[2].events = {release(4), exit()}; + expectSameAsReference(r, f, 0, "loop"); + } + { + // Exceptional exits: an unknown call on parameter 1 may throw before parameter 0 + // is released on the normal path; the pointee at offset 8 is released on both. + OwnershipFacts f = severalParams(3, {edge(0, 1), edge(0, 2, {release(3), exit(true)})}); + f.blocks[0].events = {unknownCallOn(1)}; + f.blocks[1].events = {release(0), release(3), exit()}; + expectSameAsReference(r, f, 0, "exceptional exits"); + } + { + // Convergence budget: below the smallest budget at which one solve per parameter + // converges, both are incomplete; at that budget, the joint solves converge too. + OwnershipFacts f = severalParams(4, {edge(0, 1), edge(1, 2), edge(2, 1), edge(2, 3)}); + f.blocks[1].events = {copy(4, 0, false), callWith(1, StateSet::of(OwnState::Released))}; + f.blocks[2].events = {callWith(4, StateSet::of(OwnState::Escaped)), copy(0, 1, false)}; + f.blocks[3].events = {exit()}; + unsigned smallest = 0; + for (unsigned limit = 1; limit <= 64 && smallest == 0; ++limit) + { + if (!referenceTransformers(f, limit).incomplete) + smallest = limit; + } + r.expect(smallest > 1, + "Joint summary: the budget case converges, after more than one visit"); + if (smallest > 1) + { + expectSameAsReference(r, f, smallest - 1, "below the smallest converging budget"); + expectSameAsReference(r, f, smallest, "at the smallest converging budget"); + } + } + { + // Fresh resources beside several parameters: one returned, one handed through the + // pointee of argument 2 at offset 0; parameter 0 is released, parameter 1 escapes. + OwnershipFacts f = severalParams(1, {}); + f.siteCount = 2; + f.siteKinds = {"Widget", "Socket"}; + f.locations[4].kind = LocationKind::Return; + f.blocks[0].events = {release(0), unknownCallOn(1), acquire(0, 4), + acquire(1, 2), ret(4), exit()}; + expectSameAsReference(r, f, 0, "fresh resources"); + const FunctionOwnershipSummary s = computeSummary(f); + r.expect(s.normal.returns.certainty == Certainty::Guaranteed && + s.normal.returns.kind == "Widget", + "Joint summary: a fresh resource returned beside several parameters"); + r.expect(s.normal.outArgs.count(pathOf(2, 0)) == 1 && + s.normal.outArgs.at(pathOf(2, 0)).certainty == Certainty::Guaranteed && + s.normal.outArgs.at(pathOf(2, 0)).kind == "Socket" && + s.normal.outArgs.count(pathOf(2, 8)) == 0, + "Joint summary: a fresh resource handed through one pointee only"); + + // The same acquisitions, conditional. + f.blocks[0].events = {release(0), + unknownCallOn(1), + acquire(0, 4, true, Certainty::Conditional), + acquire(1, 2, true, Certainty::Conditional), + ret(4), + exit()}; + const FunctionOwnershipSummary c = computeSummary(f); + r.expect(c.normal.returns.certainty == Certainty::Conditional && + c.normal.outArgs.at(pathOf(2, 0)).certainty == Certainty::Conditional, + "Joint summary: conditional fresh resources beside several parameters"); + } + return r.failures == 0; + } + + bool testJointSummaryWork(TestReport& r) + { + // Six parameters and pointees through a loop: four solves, one per entry state, instead + // of four per parameter and one more for the fresh resources. + OwnershipFacts f = severalParams(3, {edge(0, 1), edge(1, 1), edge(1, 2)}); + f.paramLocations.push_back({3, 4}); + f.locations.resize(6); + f.locations[5].kind = LocationKind::ArgPointee; + f.locations[5].path = pathOf(4, 0); + f.pointeeLocations.push_back({pathOf(4, 0), 5}); + f.blocks[1].events = {callWith(0, StateSet::of(OwnState::Released)), copy(2, 1, false)}; + f.blocks[2].events = {exit()}; + expectSameAsReference(r, f, 0, "six parameters and pointees"); + + SummaryWork work; + (void)computeSummary(f, 0, &work); + const OwnershipResult allOwned = + solve(f, summaryEntry(f, f.paramLocations.size() + f.pointeeLocations.size(), + OwnState::Owned)); + r.expect(work.solves == 4, "Joint summary: four solves for six parameters and pointees (" + + std::to_string(work.solves) + ")"); + r.expect(allOwned.blockVisits > 0 && work.blockVisits <= 4 * allOwned.blockVisits, + "Joint summary: at most four times the block visits of one solve (" + + std::to_string(work.blockVisits) + " for " + + std::to_string(allOwned.blockVisits) + " in one solve)"); + + OwnershipFacts none = facts(1, {}, 1, 1); + none.blocks[0].events = {acquire(0, 0), release(0), exit()}; + SummaryWork noneWork; + (void)computeSummary(none, 0, &noneWork); + r.expect(noneWork.solves == 1, "Joint summary: one solve without parameters (" + + std::to_string(noneWork.solves) + ")"); + return r.failures == 0; + } + // ---- summaries of functions that consult each other's ------------------------------------- + + // A small integer carried by a summary, so that a synthetic computation can depend on the + // summaries it consults. + int valueOf(const FunctionOwnershipSummary& s) + { + return s.normal.returns.kind.empty() ? 0 : std::stoi(s.normal.returns.kind); + } + FunctionOwnershipSummary withValue(int value, bool incomplete = false) + { + FunctionOwnershipSummary s; + s.normal.returns.kind = std::to_string(value); + s.incomplete = incomplete; + return s; + } + + using Compute = std::function; + + // The fixed point as computeOwnershipSummaries ran it before it reused summaries: every + // function recomputed in index order each round until a round changes none; past maxRounds + // rounds, every summary is incomplete. + std::vector referenceFixpoint(std::size_t count, + const Compute& compute, + unsigned maxRounds, + std::uint64_t& computations) + { + std::vector summaries(count); + const ConsultSummary consult = [&](std::size_t j) -> const FunctionOwnershipSummary& + { return summaries[j]; }; + for (unsigned round = 0; round < maxRounds; ++round) + { + bool changed = false; + for (std::size_t i = 0; i < count; ++i) + { + FunctionOwnershipSummary next = compute(i, consult); + ++computations; + if (!sameSummary(summaries[i], next)) + { + summaries[i] = std::move(next); + changed = true; + } + } + if (!changed) + return summaries; + } + for (FunctionOwnershipSummary& s : summaries) + s.incomplete = true; + return summaries; + } + + // computeSummaryFixpoint gives what the reference gives, with fewer computations or as many. + // Returns its work. + SummaryFixpointWork expectSameFixpoint(TestReport& r, std::size_t count, const Compute& compute, + unsigned maxRounds, const std::string& name) + { + std::uint64_t referenceComputations = 0; + const auto expected = referenceFixpoint(count, compute, maxRounds, referenceComputations); + SummaryFixpointWork work; + const auto got = computeSummaryFixpoint(count, compute, maxRounds, &work); + bool same = got.size() == expected.size(); + for (std::size_t i = 0; same && i < got.size(); ++i) + same = sameSummary(got[i], expected[i]); + r.expect(same, + "Summary fixpoint: " + name + ": the summaries of recomputing every function"); + r.expect(work.computations <= referenceComputations, + "Summary fixpoint: " + name + ": no more computations than recomputing all (" + + std::to_string(work.computations) + " for " + + std::to_string(referenceComputations) + ")"); + return work; + } + + bool testSummaryFixpoint(TestReport& r) + { + { + // Independent functions: one round computes them, the next changes nothing; none + // of them consulted a summary, so none is computed twice. + const Compute compute = [](std::size_t i, const ConsultSummary&) + { return withValue(static_cast(i)); }; + const SummaryFixpointWork work = expectSameFixpoint(r, 5, compute, 16, "independent"); + r.expect(work.computations == 5 && work.rounds == 2, + "Summary fixpoint: independent functions are computed once (" + + std::to_string(work.computations) + " computations, " + + std::to_string(work.rounds) + " rounds)"); + } + { + // A chain against the index order: function i consults function i + 1, which a round + // only reaches after it, so the value moves one function per round. A function is + // computed again only once the one it consults has changed. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { return withValue(i == 5 ? 1 : valueOf(consult(i + 1)) + 1); }; + const SummaryFixpointWork work = expectSameFixpoint(r, 6, compute, 16, "chain"); + r.expect(work.computations < 6 * work.rounds, + "Summary fixpoint: a chain recomputes fewer than every function each round (" + + std::to_string(work.computations) + " computations, " + + std::to_string(work.rounds) + " rounds)"); + } + { + // Recursion: a function that consults itself until it reaches 3, and one that copies it. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { + return i == 0 ? withValue(std::min(valueOf(consult(0)) + 1, 3)) + : withValue(valueOf(consult(0))); + }; + expectSameFixpoint(r, 2, compute, 16, "recursion"); + } + { + // Mutual recursion: each consults the other. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { + return i == 0 ? withValue(std::min(valueOf(consult(1)) + 1, 4)) + : withValue(valueOf(consult(0))); + }; + expectSameFixpoint(r, 2, compute, 16, "mutual recursion"); + } + { + // Only the incomplete status changes: function 1 becomes incomplete, its value kept, + // once function 2 reaches 2; function 0 copies the status of function 1. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { + if (i == 2) + return withValue(std::min(valueOf(consult(2)) + 1, 2)); + if (i == 1) + return withValue(7, valueOf(consult(2)) >= 2); + return withValue(0, consult(1).incomplete); + }; + std::uint64_t ignored = 0; + const auto expected = referenceFixpoint(3, compute, 16, ignored); + r.expect(expected[0].incomplete, + "Summary fixpoint: the incomplete case does reach function 0"); + expectSameFixpoint(r, 3, compute, 16, "incomplete status"); + } + { + // A summary consulted, then changed later in the same round: function 0 consults + // function 1, which a round reaches after it, and keeps its initial summary until + // function 1 reaches 2. Its computations leave its summary unchanged until then, and + // the change of function 1 comes after them in each round: function 0 must still be + // computed again. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { + if (i == 1) + return withValue(std::min(valueOf(consult(1)) + 1, 2)); + return valueOf(consult(1)) >= 2 ? withValue(20) : FunctionOwnershipSummary{}; + }; + std::uint64_t ignored = 0; + r.expect(valueOf(referenceFixpoint(2, compute, 16, ignored)[0]) == 20, + "Summary fixpoint: the changed-input case does reach function 0"); + expectSameFixpoint(r, 2, compute, 16, "input changed later in the round"); + } + { + // No fixed point within the budget: every summary ends incomplete, after as many + // rounds as the budget allows. + const Compute compute = [](std::size_t i, const ConsultSummary& consult) + { return i == 0 ? withValue(valueOf(consult(0)) + 1) : withValue(5); }; + const SummaryFixpointWork work = expectSameFixpoint(r, 2, compute, 16, "budget"); + r.expect(work.rounds == 16, "Summary fixpoint: the budget bounds the rounds (" + + std::to_string(work.rounds) + ")"); + } + return r.failures == 0; + } } // namespace int main(int, char**) @@ -573,6 +993,9 @@ int main(int, char**) (void)testExitUncertainty(report); (void)testSummaries(report); (void)testSummaryMetadata(report); + (void)testJointSummaries(report); + (void)testJointSummaryWork(report); + (void)testSummaryFixpoint(report); if (report.failures == 0) { std::cout << "All ownership engine unit tests passed.\n";