Skip to content

Batch: thirteen audit findings — data loss, a security gap, two leaks, and UB (#493-#502, #505-#507) - #509

Merged
Yaraslaut merged 12 commits into
masterfrom
batch-audit-fixes
Sep 10, 2026
Merged

Batch: thirteen audit findings — data loss, a security gap, two leaks, and UB (#493-#502, #505-#507)#509
Yaraslaut merged 12 commits into
masterfrom
batch-audit-fixes

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 9, 2026

Copy link
Copy Markdown
Member

Resolves thirteen issues from the framework audit in one PR, batched to keep CI runs down. One commit per coherent fix, so the history stays reviewable.

Issue Fix
#493 FileActionLog::repairTornTail() truncating the whole journal to zero
#494 FileOfflineQueue's constructor compacting an unreadable file away, + two write-ordering inversions
#495 The three async control paths reaching an authorizing server unauthenticated
#496 INT64_MIN negation UB in formatRationalDecimal
#497 normalizeLocaleNumber accepting a sign after the decimal separator
#498 SocketServer leaking an fd + thread per connection ever accepted
#499 CallbackScope::reset() racing every other member
#500 IAuthorizer::authorize's contract not describing its own call sites
#501 MainThreadExecutor letting a non-std::exception escape the pump
#502 In-flight counters leaking permanently on a throw
#505 registerHandlerImpl's unlocked contextKey read
#506 ~SocketBackend taking the lock SocketServer::close() documents as a deadlock
#507 TcpSocket::connect's per-address timeout and EINTR handling

Two results worth reading before the diff

#505 resolved the opposite way to the obvious one. The issue offered two readings: the _attachMtx invariant holds and the code breaks it, or the invariant is overstated. I implemented the first — copy contextKey out under the lock — and it failed an existing test: "Bridge: an in-flight shared attach does not block unrelated handler registration", whose own comment says the dedicated attach mutex exists so registerHandler() "no longer contends for the same lock as a slow shared attach". Taking the lock during registration reproduces exactly the regression that test was written to catch. So reading 1 is ruled out by evidence, the unlocked read stays, and what was actually missing — the ordering argument that makes it safe, and the requirement it places on callers of the pre-built-binding overload — is now stated at the read, on the member as an explicit carve-out, and in the spec.

#497 was half-reverted after checking the other side. The issue reported both a sign-injection bug and a looser-than-documented output grammar. I fixed both, then checked DynamicForm.qml — documented as mirroring this function — and found it accepts .5, 5. and .. Tightening only the C++ would have created a new divergence, which is the very thing the issue is about, and rejecting .5 is a UX regression on ordinary input. The sign fix stays (QML already rejected those); the grammar is documented rather than narrowed, and narrowing both edges together is left as a maintainer call.

Verification

Every fix has a regression test, and each was checked to fail without its fix — invariant 7. Measured, not asserted:

Suites: main 22016 assertions, net 983, qt 550. clang-format --dry-run -Werror over all 803 tracked files: clean. check_spec_citations.sh: clean. Branch-coverage allowlist audited both ways, with two now-vanished entries dropped rather than re-pinned.

Not resolved here

#345 (upstream Lightweight, parked), #453 (needs a scheduled mutation run to classify against), #489 site 4 (no safe fix known), #504 (comment backlog).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv

Also in this PR

#504 (comment backlog), partially. The "public macro surface" paragraph was copy-pasted 17 times across six headers — well past rule-of-three, and provably drifting: it claims the formatter "broke a token-paste invocation apart", and four of those six files contain no ## at all. Collapsed to one canonical statement in CONTRIBUTING.md plus a one-line pointer at each site. Eight further stale comments fixed (wire.hpp's discriminator list omitting "hello"; execute_order_gate's "should not happen" over a branch its own contract and a named test both cover; logger.hpp calling an atomic read a data race; and others).

One #504 item turned out not to be a defect: model.hpp's #include "strand.hpp" is unused by that header, but removing it broke tests/test_model.cpp, which reached morph::exec::detail::ModelId through it. For a header-only public library that is a source-breaking change for consumers; the include stays, with a comment saying why. The <concepts> gap the same item flagged is real and is fixed.

#453 item A, partially. Recorded the scheduled campaign's run (773 mutants, 574 killed, 199 survived, 74.26%) and classified 16 of the 199 — thirteen provably equivalent (mutations inside the Feistel round function, which is invertible for any F, and hash-combine sites where killing them would mean asserting a hash constant), and three that were a genuine hole.

That hole is worth calling out: the existing OpaqueIdGenerator bijection test drives counters 1..20000, all with a zero high half, so it passes whether or not permute() uses the top 32 bits. Simulated against the real round function over counters that do exercise it, >><< collapses 20000 ids to 1 and the Feistel ^| collapses them to 1256 — catastrophic collisions in ids that exist to stop one client guessing another's. New test closes it. 183 of 199 remain unclassified and the file says so.

Closes

Closes #493, closes #494, closes #495, closes #496, closes #497, closes #498, closes #499, closes #500, closes #501, closes #502, closes #505, closes #507.

#506 is deliberately NOT closed. I implemented its fix, and CI'''s clang-tsan leg proved it wrong: removing the destructor'''s _socketMtx introduced a real data race, because onDisconnected() reassigns _socket rather than merely using it — so the unlocked _socket.valid() races the I/O thread replacing the object. Measured locally both ways: 25 TSan reports without the lock, 0 with it. Reverted; the issue stays open with that evidence, since its underlying hazard is real but needs an atomic fd shadowing _socket, not a dropped lock.

#453 and #504 stay open for the parts listed above as remaining.

Yaraslaut and others added 8 commits September 9, 2026 22:05
…n one (#493, #494)

Two file-backed stores scanned with an `ifstream` whose open they never checked,
and both then committed the resulting empty read over the real file.

`FileActionLog::repairTornTail()` (#493) probes with `_io.canOpenForRead` and
then opens a *separate*, unchecked stream. When that open fails, `getline` runs
zero times, `intactEnd` stays 0, and `resizeFile(_path, 0)` truncates the whole
journal -- reported through the ordinary "discarded N byte(s) of a torn trailing
record" warning, so it reads as a successful repair. Measured: three complete,
fsynced entries (648 bytes) went to 0. The comment above the truncate argued the
discard is safe because "whatever follows the final newline is by construction
an incomplete record" -- true only of bytes the scan actually read, which is the
assumption that was missing. `core/file_io_ops.hpp:76-78` already described the
absent check as present ("stands in for repairTornTail()'s `if (!input)`").

Now: bail on a failed open, and bail on `input.bad()` -- a read error mid-scan
leaves everything past `intactEnd` unread rather than established to be torn,
and truncating there discards complete records too. `entries()` gets the same
treatment, distinguishing "no journal yet" (absent, legitimately empty, which
the constructor's dedup rebuild depends on) from "present but unreadable", which
previously emptied the idempotencyKey dedup set OutboxRelay relies on and turned
at-least-once-plus-dedup into duplicates with no diagnostic.

`FileOfflineQueue::load()` (#494) is the same defect and needed no fault
injection to reach: it bypasses the `FileIoOps` seam entirely with a raw
`std::ifstream`, and the constructor calls `compact()` immediately after, which
rewrites the file from the empty `_items`. Measured: three pending items (306
bytes) went to 0 with the constructor returning normally and the queue reporting
an empty backlog. Both the failed-open and mid-read-error cases now throw, so
compact() cannot run on a load that did not succeed.

Also fixes the write ordering #494 reported alongside: `markDone()` erased from
`_items` *before* `appendDone()`, so a throwing append (short write, failed
fflush, failed fsync) left the item gone from memory with no tombstone on disk
-- this process never replays it, and a restart resurrects and re-applies it.
`enqueue()` already had the right order. `setAttempts()` had the same inversion.
Now durable-first in both: a failure leaves the item live in both places, which
replays once too often at worst, and `idempotencyKey` exists to absorb that.

Regression tests for both, POSIX-only (making a file unreadable-but-writable is
what reproduces the window; Windows maps permissions onto the read-only
attribute alone) and skipped for a root euid. Verified they fail without the
header changes and pass with them -- a test asserting only the happy path would
have been green either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
… fix (#496, #497, #499, #501)

**#496 -- `formatRationalDecimal` negated `INT64_MIN` in `int64_t`.** The comment
above it said "widen before taking the absolute value so the magnitude is always
representable"; the expression nested the casts so the unary `-` ran on
`int64_t`, and the widening cast happened after the UB rather than before it.
Confirmed by UBSan at quantity.hpp:101. `INT64_MIN` reaches this function because
the whole-integer `Rational{value, DecimalPlaces{n}}` constructor does not
canonicalise, so the clamp in `canonicalise()` never runs on that path, and
`numerator` is public. Now uses `detail::absU64`, the shared helper that does
exactly what the comment claimed, already reachable from this header.

**#497 -- a sign after the decimal separator was accepted.** `sawAnyOutput` was
set only at the bottom of the loop and the decimal-separator branch `continue`d
past it, so after a separator the sign guard still believed nothing had been
emitted: `normalizeLocaleNumber(",-5", ",", ".")` returned ".-5". The guard's own
comment states the intent exactly ("a stripped group separator before the sign
would otherwise make an injected sign look leading"). The QML mirror in
DynamicForm.qml, documented as mirroring this function, has always rejected it --
so the two control edges disagreed on the same input, which is the more serious
half.

Deliberately *not* narrowed further: the header also promised output matching
`-?[0-9]+(\.[0-9]+)?`, which "`.`", "`.5`" and "`5.`" do not satisfy, and I
first tightened the final check to enforce it. That was wrong -- the QML mirror
accepts all three, so tightening one edge alone puts them back out of step, and
rejecting ".5" is a UX regression on ordinary input. The shape is documented
instead; narrowing both edges together is a separate call for the maintainer.

**#499 -- `CallbackScope::reset()` raced every other member.** `_state` was a
plain `shared_ptr` written by `reset()` and read by `token()`, `guard()`,
`requestStop()` and `stopRequested()`; the control block's atomic refcount
protects the pointee, not the handle. Made `std::atomic<std::shared_ptr<...>>`
rather than narrowing the documented contract, because the guarantee is
deliberate: the class documents every member as concurrently safe, and
`reset()`'s own doc turns on a token holder "that pinned it while racing this
call". Measured: 18 ThreadSanitizer reports before, 0 after. The two
`_state != nullptr` guards went with it -- provably unreachable (sole
constructor make_shared's, class non-copyable and non-movable, reset() always
assigns a fresh value), so two permanently-surviving mutants under the gate
morph#408 just brought online.

**#501 -- `MainThreadExecutor` let a non-`std::exception` escape the pump.**
`ThreadPoolExecutor::loop` has caught `...` all along; this one did not, and
three doc claims on the same class depended on the missing arm -- `runOnce()`
promises to return `true` "whether or not that task threw" and did not return at
all. Added the mirroring catch-all.

Regression tests for all four. #497 and #501 fail without their fix directly;
#496 and #499 are sanitizer-dependent by nature -- value-stable UB and a data
race -- and are caught by the existing `clang-ubsan` and `clang-tsan` CI legs,
verified by hand both ways rather than assumed. Full suite: 22011 assertions,
no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
…ere authorize is called (#495, #500)

**#495 -- the three async control builders never stamped `env.session`.**
`registerModelSharedAsync`, `attachModelAsync` and `assignPrimaryAsync` each
built their envelope with `makeRegisterShared`/`makeAttach`/`makeAssign` and
encoded it with no session assignment, while all three synchronous counterparts
(`registerModelShared`, `attachModel`, `assignPrimary`) stamped it immediately
before encode, as does every equivalent site in `net::SocketBackend`. So it was a
Qt-side omission, not a protocol choice.

`RemoteServer` authenticates and authorizes from `env.session` --
`stampVerifiedPrincipal`, and the register/attach/assign authorization sites --
so those three verbs arrived with a default-constructed session and could not be
authenticated at all. The exposure is the async path, which is opt-in behind
`asyncRegistrationEnabled` but is also the *only* path a WASM main thread can
use, since the synchronous ones block on a nested QEventLoop.

The regression test records what the **server** saw, not that the call
succeeded -- the latter was already true before the fix. It asserts on
`ctx.token` rather than `ctx.principal`, because `stampVerifiedPrincipal`
deliberately clears a client-asserted principal that `authenticate()` cannot
vouch for, so principal is "" either way and proves nothing. Measured: "" before,
"tok-495" after.

While in `assignPrimaryAsync`, also moved `wire::encode` above the
`_pendingAssigns` insertion. `registerModelAsync` states that invariant at length
and the other two async hooks point back at it and follow it; this one did the
opposite, so a throwing encode would park its onRegistered/onError in the pending
map forever with no message ever sent.

**#500 -- `IAuthorizer::authorize`'s contract did not describe its own call
sites.** It said "Called once per `execute` envelope". It is also called for
`instances` and `schemas`, both passing an **empty** `actionType`. The server is
right and deliberate -- the comments at those sites explain that gating the two
read channels lets a deployer refuse enumeration or schema disclosure without
refusing use, and `schemas` discloses field names, bounds, rules and the payload
fingerprint of every action. The stale artefact was the interface doc, which is
why this is a contract correction and not a behaviour change.

An implementor matching on `actionType` would hit its default arm on exactly
those two disclosure verbs, and whether that fails open or closed was being
decided without being told the case exists. Now stated as a table on the
interface, on `@param actionType`, on `SigningAuthorizer::Policy` (same gap), and
in docs/spec/session/session.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
…her way (#502, #505)

**#502 -- both in-flight counters leaked permanently on a throw.**

`RemoteServer::dispatchExecute` reserves a slot in `_inFlightExecutes` and then
runs a stretch of non-`noexcept` code before anything can decrement it:
`emitMetric`, two `make_shared`, `TimeoutScheduler::schedule`, `awaitTurn`'s
mutex, and `_strand.post`. The reservation's own comment ("needs no unwind on the
early-return paths above") is true of *returns* and silent about *throws*. One
throw left the counter permanently over-counted, and `drainedWithin()` predicates
on it reaching zero -- so graceful shutdown could never succeed again for that
server, and with `maxInFlightExecutes` set a slot was gone for good.

Fixed with an RAII reservation that claims the same `finished` flag `complete`
uses. Claiming rather than replying is the point: `dispatchMessage`'s catch is
what replies on that path, so replying here too would break `handle()`'s
reply-exactly-once contract -- and claiming the flag also makes an already-armed
timeout a no-op, closing the second half of the bug, where a throw after the
timer was scheduled produced a second `err "timeout"` for the same callId.
`finished` moved above the reservation so one guard covers the whole window.

`Bridge::executeVia` had the same shape: `_pendingCalls` incremented and the
client deadline armed before an untried `backend->execute(...)`, which is
genuinely throwing code (`serializeAction()` runs user `toJson` and glaze;
`wire::encode` runs glaze). Now undone on unwind before the exception continues.

**#505 -- resolved the opposite way to the obvious one, on evidence.**

The issue offered two readings: the `_attachMtx` invariant holds and
`registerHandlerImpl` breaks it, or the invariant is overstated. I implemented
the first -- copy `contextKey` out under the lock -- and it failed
"Bridge: an in-flight shared attach does not block unrelated handler
registration" (tests/test_shared_instances.cpp), whose own comment says the
dedicated attach mutex exists so `registerHandler()` "no longer contends for the
same lock as a slow shared attach". Acquiring `_attachMtx` during registration,
even briefly, reproduces exactly the regression that test was written to catch.

So reading 1 is ruled out by test, and the unlocked read stays. What was missing
was never the lock -- it was the statement of why the read is safe without one:
the writers all operate on an already-registered binding, while this read happens
*during* registration, which puts a requirement on the caller of the pre-built
`registerHandler(binding)` overload (set `contextKey` first; do not mutate it
concurrently with that call). Now stated at the read, on the `_attachMtx` member
as an explicit carve-out from an otherwise absolute rule, and in
docs/spec/core/bridge.md.

Regression test for the `Bridge` half of #502 (1 before, 0 after). The
`RemoteServer` half is structural: reaching it needs a throw from inside the
reserved window, which no in-tree backend does on demand. Full suite: 22015
assertions, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
…#498, #506, #507)

**#498 -- SocketServer leaked an fd and a thread handle per connection ever
accepted.** `_clients`/`_clientThreads` were only ever pushed to in `acceptLoop`
and cleared in `close()`; nothing removed a connection whose `clientLoop` had
returned. The surviving `shared_ptr` kept the `ClientConnection` -- and its
`TcpSocket` -- alive, so the fd stayed open, and every `std::thread` stayed
joinable. The accumulation was per connection *ever accepted*, not per live one.

`ClientConnection` now carries a `finished` flag set on every exit path, and
`acceptLoop` reaps before taking on the next connection. Threads are moved out
and joined after `_clientsMtx` is released -- `clientLoop`'s own teardown takes
that mutex through `sendText`, so joining under it would deadlock -- and the flag
is raised only after the scope guard has reclaimed the connection's models,
which is why `FinishedFlag` is declared *before* the guard so it destructs last.
(I had it the other way round first; the comment said "destroyed first" while
C++ destroys in reverse declaration order.)

The class doc and docs/spec/core/backend.md both say destruction leaves no
dangling threads, and both are true -- at teardown, which is why the leak was
invisible. The regression test therefore samples `/proc/self/fd` **while the
server is still running**: 25 connect/disconnect cycles took the count from 11 to
38 before the fix and leave it flat after. A test that opened N connections and
then destroyed the server would have passed either way.

**#506 -- `~SocketBackend` took `_socketMtx` around `shutdownBoth()`.**
`sendFrame` holds that lock across `_socket.sendAll()`, which loops on a blocking
`::send` with no timeout, so a thread stalled against a peer that stopped reading
holds it indefinitely -- and the destructor then waited on the one lock whose
release requires the `shutdownBoth()` it could not reach. `SocketServer::close()`
documents this exact trap and deliberately does not take its own write mutex;
this is the same trap on the client side. `shutdownBoth()` is documented safe
from any thread, so the lock was unnecessary as well as harmful. Not reproduced
-- a stalled-peer teardown needs a peer that accepts and never reads.

**#507 -- `TcpSocket::connect` was unbounded and signal-fragile.** The `::poll`
sat inside the candidate loop, so `connectTimeout` was applied *per resolved
address*; with `ai_family = AF_UNSPEC` several candidates are the norm
("localhost" is both ::1 and 127.0.0.1), making the worst case N x timeout while
two doc comments state it as a single bound. Now one deadline for the whole call.
The same poll treated `EINTR` as connect failure, so an ordinary delivered signal
abandoned a candidate -- every other blocking syscall in the file already retries,
and the accept loop's own comment argues for exactly that. Also clamps the
`int` millisecond conversion instead of truncating a large timeout into a garbage
(possibly infinite) value, and checks `getsockopt`'s return, which on failure left
`soErr` at 0 and handed back a broken socket as connected.

Full net suite: 983 assertions. Main suite: 22016 assertions. No regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
Formatting: one comment I inserted joined onto the sentence that followed it,
leaving an over-long line in bridge.hpp's `_attachMtx` block. Ran CI's exact
command (`git ls-files -z '*.hpp' '*.cpp' | xargs -0 clang-format --dry-run
-Werror`) over all 803 tracked files; clean.

Allowlist, audited both ways (every `source` pin lands on its own text, every
"line N" in `reason` prose resolves to a real entry):

- **Dropped two entries** rather than re-pinning them: callback_scope.hpp's
  `if (_state != nullptr)` and `_state != nullptr && ...` branches no longer
  exist, having been removed with #499's atomic conversion. Re-pinning a
  vanished branch would have quietly kept a dead exemption alive.
- Re-pinned eight that merely moved.
- One needed care: this branch *adds* a second `if (deadlineHandle &&
  schedulerRef)` to `executeVia` (the #502 unwind guard), placed ahead of the
  existing one, so nearest-occurrence matching pinned the entry to the new line.
  The entry's own `reason` describes the `.then` disarm, so it is pinned there
  (1589) instead. Left the new guard unlisted deliberately -- if the coverage
  job reports it as partial, it should earn its own entry with its own
  reasoning rather than inherit someone else's.

All three suites after the reformat: main 22016 assertions, net 983, qt 550.

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

**The "public macro surface" paragraph appeared 17 times** -- registry.hpp x8,
model_key.hpp x4, quantity.hpp x2, and one each in views/app/flows -- six
identical lines explaining why those macros carry `// clang-format off`. Well
past the rule-of-three, and provably drifting: it claims the formatter "broke a
token-paste invocation apart", and four of the six files contain no `##` at all.
Now one canonical statement in CONTRIBUTING.md under Formatting/linting, with a
one-line pointer at each site.

Eight comments that assert something the code does not do:

- `wire.hpp` -- the `Envelope` discriminator list is the declared authority
  ("see class docstring for valid values") and omitted `"hello"`, which
  `makeHello()` produces and `interpretHelloReply()` consumes;
  docs/spec/core/wire.md does list it. Also: `interpretHelloReply` documents
  requiring an `"err"` kind but matches on the message alone -- documented as
  deliberate rather than silently tightened, since a peer old enough not to know
  `hello` is one whose error shape we should not depend on.
- `execute_order_gate.hpp` -- "Defensive; should not happen" over a branch the
  file's own contract lists as a first-class element ("tolerate a gate already
  erased") and which tests/test_execute_order_gate.cpp names in a test title.
- `logger.hpp` -- called an unlocked read of `minLevel` a data race; it is a
  `std::atomic`. Only `sink` (a `std::function`) is. The real reason to take the
  lock is that the two must be captured as a *pair*, since `setLogger` and
  `setLogLevel` are separate calls.
- `backend.hpp` -- `_changeAware` is inserted by `createAndTrack`, which
  `registerModelShared` calls directly without going through `registerModel`.
- `reply_router.hpp` -- "allocated and stored under different locks" describes a
  relationship that does not exist; the call-id counter is a lock-free atomic
  read outside `_mtx`, deliberately.
- `ws_handshake.hpp` -- the "64 KiB safety cap" is checked before each recv, so
  the real bound is one 4 KiB chunk higher, which the in-tree test already says.
- `socket_server.hpp` -- replies are not "marshalled back onto the owning
  connection's own write path"; they are written inline on the worker-pool
  thread under the per-connection write mutex, as the spec correctly states.

**One item was reported as a defect and turned out not to be.** #504 lists
`model.hpp`'s `#include "strand.hpp"` as unused. It is unused *by model.hpp* --
but removing it broke tests/test_model.cpp, which reached
`morph::exec::detail::ModelId` through it. For a header-only public library,
dropping a transitive include is a source-breaking change for consumers and not
worth the tidiness, so the include stays with a comment saying why. The `<concepts>`
gap that item flagged alongside it is real and is fixed: this file uses
`std::same_as` and `concept` without including it.

Suites unchanged: main 22015 assertions, net 983. Tree-wide clang-format clean.
Allowlist re-pinned, audited both ways.

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

Item A asks for the scheduled campaign's survivors classified rather than
counted. This does the part that can be done from evidence and says plainly how
much is left.

**Recorded run[2]** in scripts/mutation_survivors.json from the first
scheduled-gate campaign (run 34349442137): 773 mutants, 574 killed, 199
survived, 74.26%, with per-file and per-mutator breakdowns. Not comparable to
the 352-survivor figure above it -- that was measured over a larger population,
before cxx_remove_void_call was excluded.

**Classified 16 of 199, and closed a real gap.** Clustering by (mutator, source
shape) rather than assessing them one at a time put sixteen bitwise survivors in
one place, and they split two ways:

- **Thirteen are provably equivalent.** Four sit inside
  `OpaqueIdGenerator::mix`, the Feistel round function -- and the Feistel
  construction is invertible for *any* round function, which `permute()`'s own
  doc comment already states. Mutating F preserves the only property the code
  claims. The other nine are hash-combine sites; nothing in the contract fixes a
  hash *value*, so killing them would mean asserting a hash constant, which is
  the "test with no reason to exist" this issue explicitly warns against.

- **Three were a genuine hole, now closed.** They sit on the Feistel *structure*
  rather than inside it, and they survive because the existing bijection test
  drives counters 1..20000 -- every one of which has a **zero high half**. That
  test therefore passes whether or not `permute()` uses the top 32 bits at all.
  Simulated against the real round function over counters that do exercise the
  high half: `>>` -> `<<` collapses 20000 ids to **1**, and the Feistel `^` -> `|`
  collapses them to **1256**. Catastrophic collisions in opaque model ids --
  which exist to stop a client guessing another client's id -- and invisible to
  the sample. New test drives counters strided by 2^32; it kills the first two.
  The third (`hi << 32` -> `>>`) still survives: the id degenerates to its low
  half, which stays distinct for these inputs, so killing it needs a probe on
  the id's high bits rather than a distinctness count. Recorded as such.

**183 of 199 remain unclassified**, and the file says so rather than implying a
finished pass. remote.hpp (82) and bridge.hpp (33) hold 58% of them and have not
been assessed; the largest cluster is cxx_replace_scalar_call (92), concentrated
on `.empty()`/`.size()` guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
**WASM build — `std::atomic<std::shared_ptr<T>>` is not portable here.**
libstdc++ implements the C++20 partial specialisation; libc++ as shipped with
emscripten does not, so it falls back to the primary template and hard-errors
on `is_trivially_copyable`. Both WASM legs failed. This project builds for WASM,
so that type is simply unavailable, and #499's fix had to change shape.

The alternatives were a mutex — which costs `token()` and `stopRequested()`
their `noexcept`, on a path the Bridge calls per registration — or narrowing the
contract. Narrowed it: `token()`, `guard()`, `requestStop()`, `stopRequested()`
and the destructor stay mutually concurrent; **`reset()` must be externally
synchronised**, which matches the usage it exists for (`onNewQuery() {
_callbacks.reset(); }`, the owner thread's supersede verb) and matches what
every in-tree caller already does. The within-a-generation guarantee is
unchanged. `_state`'s comment records that this is a portability constraint
rather than a preference, so the next reader does not "fix" it back.

The regression test was asserting the guarantee that just went away, so it now
pins the one actually delivered: concurrent `token()`/`stopRequested()` against
`requestStop()`, with `reset()` called unraced. Verified TSan-clean (0 reports);
the old racing pattern still reports 26, which is now documented caller error
rather than a defect.

**Header ↔ spec sync** wanted four sub-domains. All four got real content, not
filler: journal.md and offline.md document the unreadable-file behaviour these
commits introduce (and offline.md the durable-first mutation ordering);
rational.md records that `formatRationalDecimal` is no longer one of its listed
`INT64_MIN` negation sites and why that path escaped the clamp; forms.md notes
where the macro-formatting rationale now lives.

**clang-tidy-diff** — nine findings on changed lines, all mine: a non-const
`scoped_lock`, two unchecked `operator[]` (now iterator-based), three
too-short identifiers, two `#if !defined(X)` that want `#ifndef`, and a missing
parenthesis in `+`/`/`. Normalised the `_WIN32` guards across all three test
files while there.

Suites after: main 22021 assertions, net 983. Tree-wide clang-format clean.
Allowlist 23 entries, 0 stale.

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

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Yaraslaut and others added 3 commits September 10, 2026 02:30
…a race

CI's clang-tsan leg failed on "SocketBackend: action result delivered via then",
and the report points straight at the change I made for morph#506:

    #0 TcpSocket::closeNow()            tcp_socket.hpp:434
    #1 TcpSocket::operator=(TcpSocket&&) tcp_socket.hpp:82
    #2 SocketBackend::onDisconnected()   socket_backend.hpp:494
  vs
    #0 TcpSocket::valid()                tcp_socket.hpp:395
    #1 SocketBackend::~SocketBackend()   socket_backend.hpp:100

My reasoning was wrong in a specific way worth recording. I argued the lock was
unnecessary because `shutdownBoth()` is documented safe from any thread. That is
true, and it is not what `_socketMtx` was protecting at this site:
`onDisconnected()` **reassigns** `_socket` (`_socket = TcpSocket{}`, a
move-assign that closes the old fd), so an unlocked `_socket.valid()` in the
destructor races the I/O thread replacing the object, not merely using it.
`SocketServer::close()`, which I cited as precedent, does not have this shape --
it never reassigns the socket it shuts down.

Measured both ways locally under TSan, rather than taking CI's word for it:
**25 race reports without the lock, 0 with it.**

So the lock is restored and #506 stays open. Its underlying hazard is still real
-- `sendFrame` holds `_socketMtx` across a blocking, un-timed `sendAll`, so a
peer that stops reading can park the destructor -- but closing it needs a way to
reach the fd without the mutex (an atomic fd shadowing `_socket`, with its own
fd-reuse story), not simply dropping the lock. Both the code comment and the
issue now say that.

Also fixes the flaky test I added for #499, which failed on clang-debug, Windows
cl-debug, clang-asan and Valgrind. Two separate defects in it, both mine:

- `observed == 0`: a fixed 2000-iteration loop of relaxed stores finishes in
  microseconds, so the reader thread could still be starting when `stop` went
  up. Now driven by the reader's own progress against a deadline, and asserts
  the interleaving count so a clean TSan run means something rather than
  reporting success having measured nothing.
- `stopRequested() == false`: the replacement used `while`, whose condition is
  checked *first* -- if the reader raced past the threshold before the first
  iteration, `requestStop()` was never called at all. `do`/`while` fixes it.
  This one only appeared under heavy oversubscription.

Stress-verified: 192 runs at 48x oversubscription, 0 failures (the first fix
failed 8 of 72 at 24x).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
- `test_callback_scope.cpp` -- my own `do`/`while` (the fix for the zero-iteration
  bug) trips `cppcoreguidelines-avoid-do-while`. Same guarantee without it: one
  unconditional `requestStop()`, then the bounded progress loop. Re-stressed at
  48x oversubscription, 0 failures.
- `test_opaque_model_ids.cpp`, `test_file_offline_queue.cpp` -- two locals that
  can be `const` (`misc-const-correctness`).

Checked the whole branch locally this time rather than discovering these one CI
run at a time: ran clang-tidy over every changed .cpp against the repo's
.clang-tidy and compile_commands.json, then filtered its 428 findings down to
the 888 lines this branch actually touches. Zero remain, Qt and net sources
included.

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

Valgrind was the last failing leg, and it was this test again -- `observed == 0`
after a full ten-second deadline. Not a memory finding: all three memcheck runs
reported 0 errors and 0 bytes lost, and the step failed only because a Catch2
case did.

Cause: Valgrind serialises threads, switching at syscalls and similar points. My
main thread spun on `requestStop()`, which is a relaxed atomic store and offers
the scheduler nothing to switch on, so the reader never ran at all. The 10s
deadline expired with the two threads never having interleaved.

Two changes, both about making the interleaving *happen* rather than hoping for
it:

- A `started` handshake, so the main thread does not begin until the reader is
  provably inside its loop.
- `std::this_thread::yield()` in both loops. `sched_yield(2)` is a real syscall,
  which is exactly the switch point a serialising scheduler needs. Deadline
  raised to 30s, since under Valgrind everything is ~20-50x slower.

This is the third distinct failure mode in one test -- thread-start latency, a
zero-iteration `while`, and now scheduler starvation -- so it is verified across
every environment that has bitten it rather than just the one that last failed:
Valgrind 5/5 clean, 192 runs at 48x oversubscription with 0 failures, TSan clean
with 0 race reports, clang-tidy clean on the new lines, full suite 22021
assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxkvtHvan2fWKDDaBpqkgv
@Yaraslaut
Yaraslaut merged commit 136146c into master Sep 10, 2026
49 checks passed
@Yaraslaut
Yaraslaut deleted the batch-audit-fixes branch September 10, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant