Skip to content

Close #506 with a reproduction, and establish that the mutation survivor list is not trustworthy (#453, #504) - #511

Merged
Yaraslaut merged 3 commits into
masterfrom
resolve-remaining-issues
Sep 10, 2026
Merged

Close #506 with a reproduction, and establish that the mutation survivor list is not trustworthy (#453, #504)#511
Yaraslaut merged 3 commits into
masterfrom
resolve-remaining-issues

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Second pass at the remaining audit issues. Closes #506, and advances #453 and #504 with a finding that changes what item A is worth doing at all.

#506 — fixed, with the reproduction it never had

The hazard is real: sendFrame holds _socketMtx across sendAll, which loops on a blocking ::send with no timeout, so a peer that accepts and stops reading parks ~SocketBackend on that lock.

Two candidate fixes were rejected on evidence, not taste:

  • Drop the lock — tried last week, reverted: it races onDisconnected() reassigning _socket. 25 TSan reports vs 0.
  • Atomic fd shadowing _socket, so the destructor can ::shutdown without the mutex — carries an fd-reuse hazard, since the fd can be closed and recycled between the load and the shutdown.

So it is closed from the other end: SO_SNDTIMEO via a new SocketBackendConfig::sendTimeout (30s default). A send making no progress returns EAGAIN, sendAll throws as it already does for any other send error, and the lock is released. The lock stays where it is, so the data race stays closed.

The issue had no reproduction. It has one now, written before the fix:

result
without the timeout exit 124 under an external timeout 120 — it hangs
with the timeout throws and completes, well inside the 20s bound

#453 — the finding that matters more than the classification

Item A asks for the campaign's 199 survivors classified. Before classifying, I sampled six by applying each reported mutation by hand. Five are killed by the existing suite despite being reported as survived:

site mutation result
remote.hpp:1031 if (env.kind == "register") forced true 140 assertion failures
remote.hpp handleInline if (env.kind == "execute") forced true 84
bridge.hpp whenBound if (isBound(binding)) forced true 6
bridge.hpp:1683 _lifetime->alive = falsetrue suite hangs
forms.hpp:1794 bool found = falsetrue 2
bridge.hpp:584 bool started = falsetrue unchanged — correctly survived

The first was measured on a worktree at adfe8e5f — the campaign's own commit, with its own build — against that revision's 21985-passing baseline.

Ruled out before filing: not a test-scope mismatch (core-forms runs tests/morph_tests, same binary, no filter), not a revision mismatch, not a wholesale mutant-selection failure (574 of 773 were killed in the same run).

I corrected myself mid-investigation. My first draft blamed two whole mutator families and put "117 of 199, 59%" on it. Continuing to sample disproved that: cxx_init_const produced one genuine equivalent and one mis-report. The defect is per-mutant, so the percentage is withdrawn. Filed as #510.

Consequence for item A: classifying 199 entries by hand against a list where 5 of 6 samples are false measures Mull, not the suite — the "control that reports a result while measuring nothing" this repo keeps catching. scripts/mutation_survivors.json records the six samples and this reasoning instead of a classification.

#504 — five more stale comments

_executeGate's comment that negated itself mid-parenthesis; guard()'s "rejected at compile time" (the static_assert is in the wrapper body, so it fires on invocation); two @p tags naming things that are not parameters; a reference to an operator<< the same file says does not exist; and rational.hpp's unused <atomic>/<stdexcept> alongside a <string_view> that was only ever transitive — removal verified against the whole tree including the ladder, after the model.hpp lesson.

Verification

Run locally before pushing this time, which caught two clang-tidy findings that would otherwise have cost a CI round trip: tree-wide clang-format, -Wdocumentation -Werror, spec-citation lint, full build, both suites (22021 and 987 assertions), TSan on the SocketBackend cases (0 reports), and clang-tidy filtered to changed lines.

Closes #506.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv

Yaraslaut and others added 3 commits September 10, 2026 12:12
…killed (#453, #510)

morph#453 item A asks for run[2]'s 199 survivors classified. Before classifying
them I sampled six, applying each reported mutation by hand and running the
suite. Five are detected by the existing tests despite being reported as
survived:

  remote.hpp:1031   cxx_replace_scalar_call   140 assertion failures
  remote.hpp handleInline                      84 assertion failures
  bridge.hpp whenBound                          6 assertion failures
  bridge.hpp:1683   cxx_assign_const           suite hangs (a timeout is detected)
  forms.hpp:1794    cxx_init_const              2 assertion failures
  bridge.hpp:584    cxx_init_const              unchanged -- correctly survived

The first was measured on a worktree at adfe8e5 -- the exact commit the
campaign ran -- with its own build, so the quoted source line matches the report
character-for-character and the 140 failures are against that revision's own
21985-passing baseline.

Ruled out before filing: not a test-scope mismatch (the core-forms scope runs
tests/morph_tests, the same binary and full suite, no filter passed to
mull-runner); not a revision mismatch (see above); not a wholesale
mutant-selection failure (574 of 773 were killed in the same run).

**The defect is per-mutant, not per-family.** My first draft of this blamed two
whole mutator families and put "117 of 199, 59%" on it. Continuing to sample
disproved that: cxx_init_const produced one genuine equivalent (bridge.hpp:584 --
the initialiser is dead, overwritten on the next statement) *and* one mis-report
(forms.hpp:1794 -- the accumulator idiom, where the initial value is read). Same
mutator, both outcomes. The percentage is withdrawn; six samples support only
the narrow claim that the list cannot be worked from.

Mechanism is NOT established. morph#434 confirmed its defect by disassembling the
mutant; Mull is not installed on this workstation, so this is behavioural
evidence only, and the file says so.

What this does to item A: classifying 199 entries by hand against a list where
5 of 6 samples are false would be measuring Mull rather than the suite -- the
"control that reports a result while measuring nothing" this repository keeps
catching. Filed as morph#510; the tooling needs characterising first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
)

## #506 -- closed from the other end

The hazard is real: `sendFrame` holds `_socketMtx` across `sendAll`, which loops
on a blocking `::send` with no timeout, so a peer that accepts and stops reading
fills the kernel send buffer and parks `~SocketBackend` on that lock, with
nothing able to release it.

The obvious fix -- dropping the lock -- is wrong, and was reverted after CI
proved it: it races `onDisconnected()` reassigning `_socket` (25 ThreadSanitizer
reports against 0). The other candidate, an atomic fd shadowing `_socket` so the
destructor can `::shutdown` without the mutex, carries an fd-reuse hazard of its
own -- the fd can be closed and recycled between the load and the shutdown.

So: `TcpSocket::setSendTimeout` (SO_SNDTIMEO), applied on connect from a new
`SocketBackendConfig::sendTimeout` (30s default, zero disables). A send making no
progress now returns EAGAIN, `sendAll` throws as it already does for every other
send error, and `_socketMtx` is released. The lock stays exactly where it was, so
the data race stays closed. The timeout is deliberately generous and is not a
slow-link cutoff -- it bounds one `send` syscall making *no* progress.

morph#506 had no reproduction. It has one now, written before the fix rather than
after: a listener that accepts and never reads a byte, with the sender pushing
until the buffers fill. Measured both ways --

    without the timeout: exit 124 under an external `timeout 120` -- it hangs
    with the timeout:    throws and completes, well inside the 20s bound

## #504 -- five more comments that describe something the code does not do

- `remote.hpp` -- the `_executeGate` comment claimed the fast-reject path "never
  touches this gate at all" and then negated itself in the same parenthesis
  ("...except it does get a ticket"). `handleImpl` takes a ticket for any
  well-formed `execute` with a non-zero modelId, long before the registry lookup
  that finds the model gone, so the self-correction was the true half.
- `callback_scope.hpp` -- "rejected at compile time" overstates it: the
  `static_assert` sits inside the returned wrapper's body, so it fires when the
  wrapper is *invoked*. A guard built from a value-returning callable and never
  called compiles cleanly. Corrected in the header and in
  docs/spec/core/callback_scope.md.
- `backend.hpp` -- `@p typeId` and `@p mid` both marked as parameters of a
  function that has neither: `typeId` is unnamed in the signature, and the id is
  a local inside `createAndTrack`.
- `quantity.hpp` -- referred to an `operator<<(std::format)` that does not exist,
  in a file that says so itself at :1161; the referent is
  `std::formatter<Quantity>`. Its sibling cross-reference pointed "above" at
  something ~90 lines below.
- `rational.hpp` -- `<atomic>` and `<stdexcept>` are unused (no `std::atomic`, no
  `throw`, no `*_error`), while `<string_view>` is used four times and came in
  only transitively. Removal verified by building the whole tree including the
  ladder, after the model.hpp lesson that an unused include can still be
  load-bearing for consumers.

docs/spec/util/quantity_type.md also records that `formatRationalDecimal` takes
its magnitude through `absU64`, and why that path escaped the clamp (morph#496).

Local pre-flight before pushing, rather than discovering it in CI: tree-wide
clang-format, `-Wdocumentation -Werror`, spec-citation lint, full build, both
suites (22021 and 987 assertions), TSan on the SocketBackend cases (0 reports),
and clang-tidy filtered to changed lines -- which caught two findings
(`setSendTimeout` wanting `const`, an implicit widening in the test) that would
otherwise have been a CI round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
…wn pre-flight

Both CI failures were in the function this branch added, and both were things my
local checks could not see:

- **GCC's `-Werror=useless-cast`** rejected `static_cast<decltype(tv.tv_sec)>(...)`:
  `milliseconds::rep` and `timeval`'s members are both `long` here, so the cast
  is an identity cast. clang has no such warning, and I had only built with
  clang -- while the Valgrind leg builds with GCC, which is why it failed there
  at 2m11s (a compile error, not a memory finding). Casts dropped.
- **clang-tidy's `readability-identifier-length`** wanted three characters, so
  the conventional `tv` is now `timeoutVal`.

The pre-flight gaps, both now closed:

1. I built only with clang. Added a GCC build (`build/gcc-net`, the compiler the
   Valgrind and gcc legs use) and ran the suite under it.
2. My clang-tidy filter diffed the *working tree* (`git diff HEAD`), which is
   empty once the work is committed -- so it filtered 428 findings against zero
   changed lines and reported clean regardless. It now diffs
   `origin/master...HEAD`, matching what CI's clang-tidy-diff compares. Against
   the branch's real 107 changed lines: clean.

Verified: clang and GCC builds both clean, net suite 987 assertions under each,
the new #506 case passing under Valgrind (the leg that failed), tree-wide
clang-format clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit c1ffb62 into master Sep 10, 2026
49 checks passed
@Yaraslaut
Yaraslaut deleted the resolve-remaining-issues branch September 10, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

net: ~SocketBackend takes _socketMtx across shutdownBoth(), the deadlock SocketServer::close() documents avoiding

1 participant