Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 47 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,56 +329,54 @@ jobs:
name: ${{ matrix.sanitizer }} Sanitizer
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: ${{ matrix.allow_failure == true }}
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
strategy:
# Legs report independently; one leg's failure must not cancel the
# others' evidence (the TSan suppression bring-up depends on it).
fail-fast: false
matrix:
sanitizer: [address, undefined, thread, memory]
sanitizer: [address, undefined, thread]
include:
# Note: -stdlib=libc++ is CXX-only; c_flags omit it to avoid -Wunused-command-line-argument
# alloc_dealloc_mismatch=0: the runners' libc++/libc++abi DSO pair is
# uninstrumented, so exception what()-strings allocate via operator new
# in libc++.so and free in libc++abi.so — a documented ASan false
# positive, not a project bug (ASan's own hint recommends this flag).
- sanitizer: address
flags: "-stdlib=libc++ -fsanitize=address -fno-omit-frame-pointer"
c_flags: "-fsanitize=address -fno-omit-frame-pointer"
env: "ASAN_OPTIONS=detect_leaks=1:halt_on_error=1"
env: "ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:alloc_dealloc_mismatch=0"
- sanitizer: undefined
flags: "-stdlib=libc++ -fsanitize=undefined -fno-omit-frame-pointer"
c_flags: "-fsanitize=undefined -fno-omit-frame-pointer"
env: "UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1"
# TSan detects pre-existing data races in engine global state
# (g_active_instance), CrashBreadcrumb::add(), and cross-thread
# tests that intentionally exercise wrong-thread paths.
# Mark allow-failure until REQ-TH-004 mixer/global state fixes land.
# Exit plan (2026-06-10): Sprint 7 tracks removing allow_failure
# after the remaining engine global-state races are fixed.
# Known races are suppressed via the issue-linked tsan-suppressions.txt
# at repo root (one entry per root cause; see issues #38, #39).
# Intentional wrong-thread tests skip under TSan in code. The leg is
# enforced: bring-up run 27304193585 passed 4511/4511 under
# suppressions. New races get a suppression entry + issue in a
# small PR — never a revert to allow_failure (CONTRIBUTING.md,
# gate demotion rule).
- sanitizer: thread
flags: "-stdlib=libc++ -fsanitize=thread -fno-omit-frame-pointer"
c_flags: "-fsanitize=thread -fno-omit-frame-pointer"
env: "TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1"
allow_failure: true
# MSan requires all code (including libc++) to be instrumented.
# CI uses stock libc++ which is NOT MSan-instrumented, so test
# executables crash on startup. Mark as allow-failure until we
# build an instrumented libc++ or switch to a pre-built one.
# Exit plan (2026-06-10): Sprint 7 tracks replacing stock libc++
# with an instrumented runtime or retiring the MSan gate.
- sanitizer: memory
flags: "-stdlib=libc++ -fsanitize=memory -fPIE -fno-omit-frame-pointer"
c_flags: "-fsanitize=memory -fPIE -fno-omit-frame-pointer"
linker_flags: "-pie"
env: "MSAN_OPTIONS=halt_on_error=1"
allow_failure: true
env: "TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:suppressions=$GITHUB_WORKSPACE/tsan-suppressions.txt"
# The MSan leg is retired: stock libc++ is not MSan-instrumented, so
# test executables crash on startup and the leg verifies nothing.
# Re-entry condition (instrumented libc++ + dependency surface,
# nightly-only) is tracked in issue #40.

steps:
- uses: actions/checkout@v4

- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake ninja-build clang-18 libc++-18-dev libc++abi-18-dev
sudo apt-get install -y cmake ninja-build clang-18 libc++-18-dev libc++abi-18-dev llvm-18

- name: Configure
run: |
Expand All @@ -388,7 +386,7 @@ jobs:
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="${{ matrix.flags }}" \
-DCMAKE_C_FLAGS="${{ matrix.c_flags }}" \
-DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }} ${{ matrix.linker_flags }}" \
-DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}" \
-DLEGENDS_BUILD_TESTS=ON \
-DLEGENDS_HEADLESS=ON

Expand Down Expand Up @@ -492,14 +490,20 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake ninja-build clang-18
sudo apt-get install -y cmake ninja-build clang-18 libc++-18-dev libc++abi-18-dev

# clang-18 against the runner's libstdc++ lacks <expected> (libstdc++
# gates it on __cpp_concepts >= 202002L, which clang-18 doesn't
# advertise), so this lane builds with libc++ like every other clang
# lane. The flag is CXX-only. The libFuzzer-runtime/libstdc++ interop
# is handled in tests/fuzz/CMakeLists.txt (explicit runtime link).
- name: Configure
run: |
cmake -B build -G Ninja \
-DCMAKE_C_COMPILER=clang-18 \
-DCMAKE_CXX_COMPILER=clang++-18 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-stdlib=libc++" \
-DENABLE_FUZZING=ON \
-DENABLE_ASAN=ON \
-DLEGENDS_BUILD_TESTS=ON \
Expand Down Expand Up @@ -767,7 +771,7 @@ jobs:
# REQ-SEC-028: Dependency Vulnerability Scanning
# ─────────────────────────────────────────────────────────────────────────────
dependency-scan:
name: Optional Dependency Scan
name: Dependency Scan
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
Expand All @@ -780,11 +784,22 @@ jobs:
chmod +x osv-scanner

- name: Scan dependencies
run: |
./osv-scanner --lockfile cmake/dependencies.cmake || true
# Also scan for known CVEs in vendored code
./osv-scanner -r engine/ || true
continue-on-error: true
# The former `--lockfile cmake/dependencies.cmake` invocation was
# unparseable by osv-scanner and never produced a result. The scan
# covers the vendored trees recursively (git-hash detection found
# fluidsynth/libchdr/decoders on run 27304208837) and emits JSON for
# the artifact step. Found vulnerabilities (exit 1) fail the job
# unless baselined in osv-scanner.toml with a tracked issue (#43).
# Exit 128 ("no package sources") is tolerated pending the SBOM that
# gives this gate manifest-level input (issue #42).
run: |
rc=0
./osv-scanner scan --recursive --config osv-scanner.toml --format json --output osv-results.json ./engine ./src ./tests || rc=$?
if [ "$rc" -eq 128 ]; then
echo "osv-scanner: no supported package sources in scanned trees (known C++-tree baseline, issue #42)"
exit 0
fi
exit "$rc"

- name: Upload scan results
if: always()
Expand Down
2 changes: 1 addition & 1 deletion CI-THESIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ All 36 candidate recommendations from the gap analyses survived an adversarial p
## Recommendations

**R1 — Stabilize the mandatory lanes; end silent failure. Major.** (G-1, G-7)
Triage the sanitizer and fuzz lanes to deterministic green: a checked-in `tsan-suppressions.txt` with one issue-linked entry per known race, then drop `allow_failure` from the TSan leg; retire the MSan leg with a tracked re-entry condition (instrumented libc++) — it crashes on startup by construction and verifies nothing; fix the broken osv-scanner invocation before unmuting dependency-scan. Files: `.github/workflows/ci.yml` sanitizers/fuzz/dependency-scan jobs. Evidence: [Verification Lanes](audit-wiki/wiki/entities/Verification%20Lanes%20(Sanitizers,%20Fuzz,%20Coverage,%20Determinism).md), [Sanitizer Lane Strategy](audit-wiki/wiki/sources/Sanitizer%20Lane%20Strategy%20(2026-06).md). No lane is ever demoted again without a tracked exit criterion.
Triage the sanitizer and fuzz lanes to deterministic green: a checked-in `tsan-suppressions.txt` with one issue-linked entry per known race, then drop `allow_failure` from the TSan leg; retire the MSan leg with a tracked re-entry condition (instrumented libc++) — it crashes on startup by construction and verifies nothing; fix the broken osv-scanner invocation before unmuting dependency-scan. Status (2026-06-10): implemented on PR #41 — all mandatory lanes green, TSan enforced under issue-linked suppressions, MSan retired (#40), dependency scan unmuted from a triaged baseline (#43). Files: `.github/workflows/ci.yml` sanitizers/fuzz/dependency-scan jobs. Evidence: [Verification Lanes](audit-wiki/wiki/entities/Verification%20Lanes%20(Sanitizers,%20Fuzz,%20Coverage,%20Determinism).md), [Sanitizer Lane Strategy](audit-wiki/wiki/sources/Sanitizer%20Lane%20Strategy%20(2026-06).md). No lane is ever demoted again without a tracked exit criterion.

**R2 — Bind merging to green. Major.** (G-2, G-4)
Active ruleset on master: require PRs, require the five exact-name checks that already run unconditionally (`Linux (gcc)`, `Linux (clang)`, `Linux IPC (gcc)`, `Windows (MSVC)`, `C ABI Verification`), require branches up to date, forbid force pushes. Defer the merge queue: at this PR volume, require-up-to-date delivers the never-merge-red invariant, and no workflow has a `merge_group` trigger anyway. Server-side setting plus a documented policy file. Evidence: [CI Workflows](audit-wiki/wiki/entities/CI%20Workflows%20(GitHub%20Actions).md), [Merge Queues & Required Checks](audit-wiki/wiki/sources/Merge%20Queues%20&%20Required%20Checks%20(2026-06).md). Sequenced strictly after R1 — protection before green freezes all merging. The required-check names must be updated in the same change whenever later consolidation (R8) renames jobs.
Expand Down
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
endif()
elseif(MSVC)
target_compile_options(legends_compile_options INTERFACE /W4 /permissive- /utf-8)
# MSVC 19.51+ (VS 18 2026) deprecates non-string-literal [[gsl::suppress]]
# args (C4875); the pinned gsl-lite still emits them (gsl-lite.hpp:2218),
# fatal under /WX. Drop /wd4875 when the gsl-lite pin advances (issue #44).
target_compile_options(legends_compile_options INTERFACE /wd4875)
target_compile_definitions(legends_compile_options INTERFACE _CRT_SECURE_NO_WARNINGS)
# Security hardening (H3)
target_link_options(legends_compile_options INTERFACE /GUARD:CF)
Expand Down
4 changes: 2 additions & 2 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"LEGENDS_BUILD_TESTS": "ON"
},
"environment": {
"TSAN_OPTIONS": "halt_on_error=1:second_deadlock_stack=1"
"TSAN_OPTIONS": "halt_on_error=1:second_deadlock_stack=1:suppressions=${sourceDir}/tsan-suppressions.txt"
}
},
{
Expand Down Expand Up @@ -198,7 +198,7 @@
"outputOnFailure": true
},
"environment": {
"TSAN_OPTIONS": "halt_on_error=1:second_deadlock_stack=1"
"TSAN_OPTIONS": "halt_on_error=1:second_deadlock_stack=1:suppressions=${sourceDir}/tsan-suppressions.txt"
}
},
{
Expand Down
25 changes: 22 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,32 @@ in CI on release tags.

PRs run the following CI jobs:
- Linux headless (GCC-13, Clang-18)
- Linux IPC mode (GCC, `LEGENDS_USE_IPC=ON`)
- Windows headless (MSVC)
- macOS headless (AppleClang)
- C ABI verification
- Static analysis (clang-tidy)
- Sanitizer builds (ASan, UBSan, TSan — TSan uses the issue-linked
`tsan-suppressions.txt` at repo root)
- Fuzz testing (30s smoke)
- Coverage (lcov)

Merge-to-main additionally runs sanitizer builds (ASan, UBSan, TSan, MSan).
Nightly/dispatch additionally runs macOS, SDL3 backends, static analysis
(clang-tidy), TLA+ model checking, and the dependency scan.

### Gate demotion rule

No CI gate is weakened without a tracked exit criterion. Any of the
following requires an open issue stating the condition under which the
demotion is reversed, linked from the change that introduces it:

- adding `allow_failure`/`continue-on-error` to a job or matrix leg
- muting a command (`|| true`) in a gate step
- retiring a lane or narrowing its trigger tier
- relaxing or deleting a test assertion to make CI pass

YAML comments do not count as exit criteria. Suppression files (e.g.
`tsan-suppressions.txt`) follow the same rule: one entry per root cause,
each entry linked to its issue, and deleting the entry is the issue's
exit criterion.

---

Expand Down
1 change: 1 addition & 0 deletions audit-wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ Append-only. One line per event: `## [YYYY-MM-DD] <op> | <title>`.
- 2026-06-10 — ingest: Recommendation Review (2026-06) — adversarial pass over M/A/T/G candidates.
- 2026-06-10 — synthesis: CI-THESIS.md written at repo root; overview updated.
- 2026-06-10 — lint: frontmatter added to 5 pages, CI-audit log heading, overview MOC gains CI-audit section, concept statuses reconciled, Maintainability citation style normalized.
- 2026-06-10 — update: Verification Lanes entity gains R1 implementation state (PR #41: all mandatory lanes green, TSan enforced, MSan retired, dependency scan unmuted).
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,16 @@ All four sanitizer lanes share one CI job (`sanitizers`, `.github/workflows/ci.y
- [[CI Run History (2026-06)]] — empirical pass/fail per lane
- [[Determinism Oracle Weakness]] — why the determinism lane's green is not trustworthy
- [[Quality Gate Demotion (2026-06-08)]] — the event that reshaped which lanes gate at all

## R1 implementation state (2026-06-10, branch ci/r1-stabilize-lanes, PR #41)

The lane facts above describe master before the R1 change. As of PR #41:

- **ASan/UBSan**: green and enforced. The 191-failure alloc-dealloc-mismatch class was an uninstrumented-system-libc++/libc++abi false positive (`alloc_dealloc_mismatch=0`, ci.yml address leg); two real defects fixed: DOSBoxContext move ctor/assignment dropped memory/dma/dos/dos_filesystem ownership (engine/src/misc/dosbox_context.cpp), and the dosbox_error_code/dosbox_log_level FFI enums had UB value ranges (FORCE_INT sentinels).
- **TSan**: green and **enforced** — `allow_failure` removed after run 27304193585 passed 4511/4511 under the issue-linked `tsan-suppressions.txt` (#38, #39; both entries currently inert). Intentional wrong-thread tests skip under TSan in code. Matrix runs `fail-fast: false` so legs report independently.
- **MSan**: retired (matrix is address/undefined/thread); re-entry condition (instrumented libc++, nightly-only) tracked in #40.
- **Fuzz**: green — first actual execution of all five targets since the lane was made mandatory by `ee8a9e2`. Five successive latent build defects fixed (libc++ packages/flag, gsl-lite link, libFuzzer-runtime/libstdc++ interop, pal/platform_dirs link closures, missing config corpus). Zero crashes in the 30s smokes.
- **Dependency scan**: unmuted and renamed (no "Optional"); honest invocation found vendored fluidsynth CVEs (CVE-2021-21417, CVE-2025-56225) → #43, baselined in `osv-scanner.toml`; SBOM input tracked in #42; baseline dispatch 27316418663 green.
- **Demotion rule**: recorded in CONTRIBUTING.md — no allow-failure/mute/retirement/assertion-relaxation without a tracked exit criterion.

Coverage and determinism lanes are unchanged by R1 (see R9/R12 in `CI-THESIS.md`).
8 changes: 7 additions & 1 deletion engine/include/dosbox/error_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ typedef enum dosbox_error_code {
/* Fatal errors (900-999) */
DOSBOX_ERR_PANIC = 900,
DOSBOX_ERR_TRAP = 901,
DOSBOX_ERR_FATAL = 999
DOSBOX_ERR_FATAL = 999,

/* Forces the enum's value range to full int so that arbitrary values
* arriving over the FFI boundary are representable without undefined
* behavior (an unfixed-underlying-type enum only spans its enumerators'
* bit range). Never a valid code; handled by default: arms. */
DOSBOX_ERROR_CODE_FORCE_INT = 0x7FFFFFFF
} dosbox_error_code;

/**
Expand Down
7 changes: 6 additions & 1 deletion engine/include/dosbox/logging.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ typedef enum dosbox_log_level {
DOSBOX_LOG_WARN = 1, /**< Warnings about potential issues */
DOSBOX_LOG_INFO = 2, /**< Informational messages */
DOSBOX_LOG_DEBUG = 3, /**< Debug information */
DOSBOX_LOG_TRACE = 4 /**< Detailed trace for debugging */
DOSBOX_LOG_TRACE = 4, /**< Detailed trace for debugging */

/* Forces the enum's value range to full int so that arbitrary values
* arriving over the FFI boundary are representable without undefined
* behavior. Never a valid level; handled by default: arms. */
DOSBOX_LOG_LEVEL_FORCE_INT = 0x7FFFFFFF
} dosbox_log_level;

/**
Expand Down
31 changes: 25 additions & 6 deletions engine/src/misc/dosbox_context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1108,18 +1108,29 @@ DOSBoxContext::~DOSBoxContext() {
}

DOSBoxContext::DOSBoxContext(DOSBoxContext&& other) noexcept
: config_(std::move(other.config_))
, initialized_(other.initialized_)
, stop_requested_(other.stop_requested_.load())
, last_error_(std::move(other.last_error_))
, timing(std::move(other.timing))
: timing(std::move(other.timing))
, cpu_state(std::move(other.cpu_state))
, mixer(std::move(other.mixer))
, vga(std::move(other.vga))
, pic(std::move(other.pic))
, keyboard(std::move(other.keyboard))
, input(std::move(other.input))
, memory(other.memory)
, dma(other.dma)
, dos(other.dos)
, dos_filesystem(other.dos_filesystem)
, config_(std::move(other.config_))
, initialized_(other.initialized_)
, stop_requested_(other.stop_requested_.load())
, last_error_(std::move(other.last_error_))
{
// The owning-raw-pointer aggregates (memory.base, dma.controllers,
// dos_filesystem.files/drives/devices) were copied above; reset the
// source so its destructor/shutdown cannot free what we now own.
other.memory = MemoryState{};
other.dma = DmaState{};
other.dos = DosState{};
other.dos_filesystem = DosFilesystemState{};
other.initialized_ = false;
}

Expand All @@ -1140,7 +1151,15 @@ DOSBoxContext& DOSBoxContext::operator=(DOSBoxContext&& other) noexcept {
pic = std::move(other.pic);
keyboard = std::move(other.keyboard);
input = std::move(other.input);

memory = other.memory;
dma = other.dma;
dos = other.dos;
dos_filesystem = other.dos_filesystem;

other.memory = MemoryState{};
other.dma = DmaState{};
other.dos = DosState{};
other.dos_filesystem = DosFilesystemState{};
other.initialized_ = false;
}
return *this;
Expand Down
Loading
Loading