Skip to content

fix: harden daemon release paths and verification#1252

Merged
DeusData merged 68 commits into
mainfrom
fix/daemon-release-hardening
Jul 26, 2026
Merged

fix: harden daemon release paths and verification#1252
DeusData merged 68 commits into
mainfrom
fix/daemon-release-hardening

Conversation

@DeusData

Copy link
Copy Markdown
Owner

Rationale

The coordination daemon is now a long-lived shared component used by concurrent
MCP sessions. Final release testing exposed operational risks around stale agent
configuration, Windows process cleanup, partial release smoke coverage, and
local-CI environment drift.

This PR hardens those paths without adding new daemon features.

What changed

Safe agent-config repair

  • Carry the exact previously running CBM image into install/update activation.
  • Repair CBM-shaped MCP entries only when ownership is positively established.
  • On POSIX, never probe filesystem paths supplied by configuration; only an
    exact match with the previous managed executable is repairable.
  • On Windows, classify only canonical local-drive paths. The probe retains
    validated ancestor handles, rejects reparse points and unsafe namespaces, and
    denies write/delete sharing during classification.
  • Preserve relative, templated, UNC/device, extensionless/PATHEXT-dependent,
    foreign-shaped, inaccessible, or otherwise ambiguous commands.
  • Cover standard JSON agent configs and the existing Junie alias update path.

Deterministic parallel test scheduling

  • Replace the xargs -> bash -c -> timeout -> native runner topology that could
    hang indefinitely under MSYS2.
  • Use one Python parent that directly owns native suite processes, enforces
    bounded timeouts, parses completion summaries, and writes one durable result
    per suite.
  • Terminate POSIX process groups with verified TERM/KILL cleanup and Windows
    trees through taskkill /T /F.
  • Reject zero-summary "successful" suites and fail the harness when
    process-tree cleanup cannot be proven.
  • Add deterministic contracts for high-volume completion,
    timeout-after-summary, missing summaries, stubborn descendants, post-exit
    accounting, and the timeout-boundary leader-exit race.

Full release-fixture smoke coverage

  • Add a race-free loopback fixture server that owns an ephemeral port and
    publishes it atomically.
  • Package release-shaped archives, checksums, notices, installers,
    launcher/payload pairs, and Linux portable aliases.
  • Exercise download, checksum, install, update, config refresh, and uninstall
    phases in local and PR smoke runs.
  • Isolate HOME, cache, temporary directories, and all supported agent-config
    overrides.
  • Bypass ambient proxies for loopback fixture traffic and keep diagnostics
    inside each smoke root.
  • Run Windows install.ps1 directly with native paths and fail on execution
    errors.

Windows safety and local-CI hardening

  • Route Windows PATH smoke writes to a GUID-scoped scratch registry key.
  • Require an exact 32-hex run ID, matching sentinel, isolated smoke root, and
    loopback fixture URL; malformed gating fails closed.
  • Snapshot and verify the live HKCU\Environment\Path without restoring or
    overwriting concurrent user changes.
  • Run native Windows soak binaries and caches under a protected profile root
    with strict DACLs; inability to establish that root fails the gate.
  • Synchronize the suspended VM clock with the host and verify bounded skew.
  • Transport provisioning PowerShell through encoded commands so remote-shell
    metacharacters are not reinterpreted.
  • Improve long-path/read-only Windows fixture cleanup, custom-build watchdog
    selection, daemon failure diagnostics, and hostile Git
    config/template/hook isolation.

CI workflow impact

  • Product-change detection now includes installer and release-smoke
    infrastructure.
  • ThreadSanitizer runs use a fixed four-worker envelope on every host. This
    preserves concurrent race detection while avoiding a high-core,
    sanitizer-only allocator convoy; native, ASan, smoke, and soak remain
    uncapped.
  • Local TSan Compose services use an init reaper because their Make process is
    PID 1; this prevents killed test grandchildren from persisting as zombies and
    producing false process-containment failures.
  • Ubuntu and macOS PR smoke use the maintained release-fixture wrapper; Windows
    uses the maintained native/MSYS smoke harness.
  • Windows test jobs install native MinGW Python, and the Windows smoke job
    explicitly provides curl, zip, and unzip.
  • No production/runtime dependency was added. Both new Python helpers use only
    the standard library.
  • Product PR smoke does more work because it now covers real release archives
    and install/update/uninstall behavior rather than skipping those phases.

Security and fail-closed behavior

  • User-owned MCP entries are preserved whenever ownership or path state is
    ambiguous.
  • Config-supplied paths do not trigger POSIX filesystem access.
  • Windows stale-path classification excludes remote/device/relative/templated
    paths and resists reparse/component-swap races.
  • Fixture servers bind only to loopback, and smoke mutations remain under
    disposable roots.
  • Windows PATH testing cannot fall back to the live registry when its internal
    gate is present but invalid.
  • Process timeouts are not reported as safely handled unless descendant cleanup
    is proven.
  • Windows trusted-root/DACL, VM clock, and remote-command transport failures
    stop the relevant gate.

Known Windows cleanup limitation

The scheduler does not yet use a Windows Job Object. taskkill /T can prove
descendant cleanup only while the suite leader PID still exists. If the leader
exits exactly at the timeout boundary before taskkill runs, the scheduler
returns an infrastructure failure instead of recording an ordinary timeout or
silently continuing. The deterministic contract covers this race; its outer
test controller cleans the intentionally created descendant.

Local verification

  • Lint/static guards: Python compilation, shell syntax, clang-format, Compose
    rendering, and diff checks passed; full exact lint is still running locally
    after this early PR push
    .
  • macOS native: 6,785 passed, 0 failed, 4 skipped across 121 suites;
    security allow-list and sanitizer checks passed. The final pipeline change
    was rebuilt and rerun separately: 225 passed, 0 failed.
  • Real Windows ARM VM:
    • Full parallel suite: 6,679 passed, 0 failed, 57 platform skips across
      121 suites.
    • Native scheduler regression contract: passed, with no residual
      scheduler/test-runner processes.
    • Guard suite: passed.
    • Full release install/update/uninstall smoke: passed; scratch PATH key was
      removed, live user PATH remained byte-for-byte unchanged, and no process
      remained.
    • Final pipeline resync/rebuild: 225 passed, 0 failed; host and VM source
      hashes matched.
  • Linux arm64 + amd64 sanitizer/build/smoke/Wine ladder:
    • arm64 ASan/LSan: 6,623 passed, 0 failed, 4 skipped across 117 suites.
    • arm64 production build and full release-fixture smoke: passed.
    • fixed-envelope TSan proof repeated twice: 758 passed, 0 failed,
      3 skipped
      per run, with no race/fatal/stall signature.
    • portable, amd64, and Wine legs are still running locally after this early
      PR push
      .
  • Four-vCPU CI-fidelity daemon/Windows soak:
    [PENDING: runs before merge]

The branch was pushed once the changed-path discovery gates were green so
GitHub CI could run in parallel with the remaining redundant local matrix.
Merge remains blocked on every mandatory local leg and the complete GitHub
matrix passing on the exact final SHA.

Explicitly parked follow-ups

  • Worktree-aware graph deduplication and multi-view indexing, where worktrees
    share the main repository graph and store only their delta. This is a
    downstream feature, not part of daemon-v1 stabilization.
  • Clarify Junie's existing "atomic" terminology or strengthen its sequential
    CAS update path in a separate change.
  • Investigate the pre-existing Phase 15 UI curl --noproxy observation
    separately; it is not introduced or broadened here.
  • Measure the slab page-map lock at high worker counts outside TSan. The
    sanitizer exposed an 18-way busy-spin convoy, but native high-core runs have
    not reproduced a product liveness failure; redesign the lock only with native
    contention and throughput evidence.
  • Revisit the release-compiled Windows scratch-PATH smoke seam. Its current
    same-account, loopback, run-ID, and HKCU-sentinel gates limit impact to the
    caller's own install result, but a future test-infrastructure change could
    exercise and compare-and-swap-restore the dedicated test account's live PATH
    instead of retaining a production test branch.
  • Harden Windows stale-command classification further against DOS reserved
    basenames and preflight drive-letter mappings without GetDriveTypeW.
    Current handling fails ambiguous/device paths closed; this is defense in
    depth against user-configured mapped/device path probes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory labels Jul 24, 2026
@DeusData DeusData added this to the 0.9.1-rc milestone Jul 24, 2026
@DeusData DeusData added editor/integration Editor compatibility and CLI integration ux/behavior Display bugs, docs, adoption UX security Security vulnerabilities, hardening windows Windows-specific issues github_actions Pull requests that update GitHub Actions code priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Jul 24, 2026
DeusData added 20 commits July 25, 2026 00:37
… checks

The native Windows soak could never start its server, so that gate had
never actually measured anything. The root cause was the harness, not the
daemon: an MSYS filesystem FIFO feeding a native Windows process delivers
empty stdin (reproduced 5/5), while a Bash anonymous coprocess carries the
full JSON-RPC initialize exchange to the same protected payload, and the
failing daemon log showed an orderly client disconnect rather than a
daemon-side eviction. The soak now uses a named coprocess on native
Windows only, duplicating its endpoints to stable fd3/fd4 and closing the
originals so closing fd3 still delivers EOF; POSIX keeps the FIFO path.
The coproc syntax sits inside eval because macOS system Bash 3.2 must
still parse this file even though only MSYS2 Bash 5 executes that branch.

With transport repaired the next exact failure surfaced: native Windows
Python cannot open an MSYS /c/... diagnostics path. Both consumers now
pipe the file through stdin, the pattern already established elsewhere in
the repo, and the soak recovery contract forbids reintroducing a direct
native-Python open.

Also on the release path:

- The smoke fixture server no longer fsyncs before atomically publishing
  its port. This is ephemeral readiness signalling, not crash-durable
  state, and the macOS Intel runner failed inside that durability sync.
  When the contract does fail it now reports the observable state --
  waited, exit status, port file, staged temp files, interpreter, startup
  log -- because the previous verdict named nothing on the one runner we
  cannot reproduce locally.
- The POSIX publication test hook no longer compiles into Windows builds,
  fixing an exact -Werror unused-variable failure. The setter keeps a
  parameter-consuming Windows stub because it is public API.
- The Wine leg assembles the real release layout (payload plus canonical
  launcher) and version-checks both, running the launcher through cmd so
  it has a Windows-visible parent. The unsupported Wine soak is removed:
  Wine stays a fast compile/package/version check, and native Windows is
  authoritative for daemon, locking, ACL and process-lifetime semantics.
- run.sh soak-windows routes to the native Windows VM, which validates the
  payload, builds the protected per-user temp root, stamps ACLs, and
  refuses success without a completion summary.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…lookup

The macOS Intel leg failed the fixture contract on every run while every
other platform passed. Removing the readiness fsync did not change it; the
hardened diagnostics named the real cause on the first remote run:
waited=30.0s, exit_status=alive, port_file=absent, staged_temp_files=none,
empty startup log -- the process was healthy but had never reached
publish_port.

http.server.HTTPServer.server_bind() resolves socket.getfqdn(host). On a
host whose resolver does not answer for the bind address that call blocks
for the resolver timeout, so the constructor never returns and no port is
ever published. The fixture now binds through a subclass that keeps the
threading server but skips the FQDN resolution, which only feeds CGI-style
variables this fixture never serves.

Proven both directions locally by forcing socket.getfqdn to hang: the
subclass publishes its port immediately, the stock server never does. That
forcing hook is kept as a permanent contract guard, so the reverse-DNS
dependency cannot return without turning the gate red on every platform
rather than on one runner nobody can reproduce.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Growth investigations were guessing, because the diagnostics snapshot only
carried process totals: RSS and committed bytes say memory grew, never
where. The map adds allocator-live bytes and a size-class histogram to each
snapshot, so a distinctive class identifies the allocation without
per-callsite instrumentation.

The design constraint is honesty about coverage. Walking the allocator
reaches the aggregate heap plus this thread's heap and its abandoned pages,
which is not the whole process, and in a build where malloc does not route
through the allocator at all it reaches nothing. A per-subsystem tally
would report zeros there and read as a clean bill of health. So every
snapshot carries three independent totals and an explicit residual --
os_committed, allocator-live, residual = the difference -- and the triple
localises growth instead of implying it:

  live grows            leak on a walked heap; buckets say which size
  residual grows, flat  another thread, retention, or non-allocator memory
  both flat, RSS grows  mappings or stacks, not the heap

That guard paid for itself immediately. Pointed at the open Windows growth
in #581 it reported live=0 with zero blocks and the residual carrying all
of it -- not 'the heap is innocent' but 'this walk saw nothing', which is
the correct reading for a process demonstrably holding tens of megabytes.
Together with the growth surviving a build with the allocator override
compiled out, that says the Windows allocations under investigation never
reach the allocator, so an allocator-based map cannot localise them and the
next instrument has to sit lower down.

Covered by a test that allocates a known volume in a known size class and
requires the map to attribute it there, so the map can never regress into
silently reporting zeros.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The allocator walk could say how much memory was live and in what size class,
but not who was holding it -- and when growth does not route through the
allocator at all, as on Windows, it could say nothing useful. Phase marks
close that gap using the one metric that does see such growth: OS committed
bytes. Each mark attributes the committed delta since the previous mark to the
previous label, so over many passes the label whose total climbs is where
memory stays.

The design is deliberately coarse and says so. Committed bytes are
process-wide, so a busy concurrent thread lands noise in whichever label is
open; signal comes from accumulation over many passes, never one delta. Marks
must bracket a path with no unlabelled gap, or growth hides in the gap -- so
the request path labels its tail 'idle' explicitly, which is also what makes
request-path retention distinguishable from background growth. Off unless
CBM_MEM_PHASES=1.

Pointed at #581 it localised the growth in two steps. At request granularity
dispatch_tool held +102 MB over 27 requests while scope begin/end and idle
were flat, which independently killed the theory that the growth was
time-based. Splitting the handler then put +88.4 MB of it in resolve_store
against +14.1 MB for the query body, with the matching release returning only
-35.9 MB. So each request resolves a store, commits ~3.4 MB, and gets ~1.4 MB
back: a ~2 MB net ratchet per request that never returns. That accounts for
the observed growth, for its scaling with corpus size, for why every tool
leaks identically, and for why only Windows shows it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Adds mem_malloc_owned to every diagnostics snapshot: a real malloc/free pair
probed with mi_is_in_heap_region rather than an inference from build flags,
because the Windows static-CRT override is defined at compile time yet can
fail to take effect at link time -- which is exactly what happens here.

This single bit distinguishes 'nothing is live' from 'the allocator does not
own this build', which are opposite conclusions drawn from the same zero
live_bytes. On Windows it reads false, which means every allocator tuning in
cbm_mem_init -- immediate purge, purge-decommits, page reclaim, arena policy
-- is inert for ordinary allocations there, while POSIX gets all of it. That
is the parity gap behind #581, and reading this bit first would have replaced
a long elimination hunt with one measurement.

Also reverts the interim _heapmin trim explored while localising #581. It cut
the ten-minute Windows soak from 1168 MB to 232 MB but never reached the
13-to-15 MB flatness POSIX achieves, and it cost roughly four times the query
latency because it walks the whole heap on the request path. Trimming a heap
the allocator should have owned treats the symptom; the override itself is
the fix.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
DeusData added 29 commits July 25, 2026 23:06
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A static MinGW link has no DllMain and registers no TLS callback, so nothing
ran at thread exit and every thread left its mimalloc thread-heap behind for
the life of the process. After 300 MCP requests the allocator reported 607
thread-heaps, peak equal to total equal to current -- none ever released --
holding 170.5 MiB against a live set of roughly 300 KiB, growing linearly and
without bound (#581).

POSIX is unaffected because a pthread TSD destructor already calls this, which
is precisely why POSIX stayed flat under the identical workload. The heaps were
invisible to mi_heap_visit, which only walks the main and calling heaps, so the
growth appeared to belong to no allocator at all and survived every allocator
option that was tried against it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The probe assumed the walk would attribute over half of a 12 MB allocation.
mimalloc v3 exposes only the main heap, abandoned pages and the calling
thread's theap -- there is no API to enumerate every theap -- so on Windows the
probe's blocks are unreachable through all three and the walk saw ~190 KB.

Asserts the triple documented in mem.h instead: what the walk cannot see, the
residual must carry. Either attribution covers the probe or the committed total
grew by at least as much, so memory can never go missing from the map
unnoticed. Bucket attribution is checked where the walk did reach the probe.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Three CI failures, one cause each:

- Vendored integrity flagged a MISMATCH on the Windows mimalloc primitives.
  The decommit counters added there were diagnostic and have served their
  purpose -- 16k calls, zero failures, which retired the theory that purge was
  silently failing. Restored to pristine, and the census fields they fed are
  gone with them.
- GCC rejected an anonymous enum declared inside a struct ('declaration does
  not declare anything'); clang accepted it, so it only broke the Ubuntu and
  Windows smoke builds. Hoisted to file scope.
- clang-format across the files touched this session.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The shim exists only so the allocation profiler has an observation point on
Linux. Applying its --wrap flags to the test and TSan link redirected malloc
into mimalloc underneath ASan's own interception, putting two allocators on the
same pointers. Production keeps the shim; sanitized builds do not. Windows is
unchanged -- there the wrap flags are what make mimalloc own allocations.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Walking mi_heap_main() reads the process-wide aggregate heap while other
threads allocate into it, which is a data race by construction; TSan reported
it on macOS as init.c:452 in mi_heap_main. A diagnostic must not introduce a
race into the code it measures.

Now walks only what this thread may safely read: its own theap, plus abandoned
pages, which have no owning thread left to race with. The lost coverage is
carried by the residual, which is what it is for -- an unmeasured map must
never read as an empty one.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Calling mi_heap_main() is itself the race: it reads the allocator's main-heap
pointer while an exiting thread rewrites it via _mi_thread_done ->
_mi_theap_default_set. TSan paired those two directly on macOS, with the read
coming from the diagnostics thread. Removing the aggregate walk was not enough
because the abandoned-page walk still passed mi_heap_main() as its argument.

The map now touches only this thread's own theap. Everything it can no longer
see is carried by the residual, which is what mem.h promises.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Reading the allocator's per-thread bookkeeping races a thread exiting
concurrently: even mi_theap_get_default() reads state that _mi_thread_done
rewrites via _mi_theap_default_set, and TSan paired those two directly with the
read coming from the periodic diagnostics thread.

Splits the collector. cbm_mem_map_collect_os() fills OS totals only and is what
the periodic snapshot uses; the walking variant stays for tests and explicitly
requested diagnostics, where no other thread is exiting. What the OS-only form
cannot see is carried by the residual, as mem.h promises.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Reverts 3cd1d33. It was built on the theory that per-connection thread-heap
churn drove #581, and the measurements later falsified that: growth scaled with
requests rather than threads, and the real cause was thread heaps never being
released at all, fixed separately by the TLS detach callback.

It also broke a Windows regression guard -- test_daemon_stability
section_cold_storm, 'a fixed Windows bug is broken again' -- which is what
concurrent clients racing to spawn a daemon do to reworked reaping and shutdown
ordering. A concurrency rewrite that fixes nothing it claimed and reopens a
closed bug does not belong in a release-blocking PR.

The allocator thread-heap fix is untouched.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
These were added under the theory that abandoned thread-heap pages needed
collecting by hand. The TLS detach callback now releases each thread's heap
properly, so an explicit mi_collect at every job and connection exit buys
nothing and adds allocator teardown work on paths that were crashing a parallel
Windows ARM64 shard (daemon_application, test-par exit 2 with the suite summary
otherwise clean).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Measurement retired the theory it was built on: the slab installs correctly now
(config_failed=0) and the leak was unchanged, because the cause was thread
heaps never being released rather than page-cache fragmentation.

It also runs at the top of main() for every process, including the launcher,
and the managed reinstall guard went red. A change that fixes nothing it
claimed does not get to stay on a release-blocking PR while a Windows guard is
failing. cbm_store_configure_pagecache remains available for a future attempt
wired ahead of sqlite3_initialize deliberately.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…check

The immediate-reinstall guard reused the portable pair from
work/portable-install, whose adjacent launcher the earlier managed installs
consume. Managed install then refuses correctly -- 'requires a verified adjacent
codebase-memory-mcp.exe launcher' -- so the guard was failing on the product
behaving as designed rather than on the contract it protects. Reproduced by
running the install command directly (exit 1, that exact error) and confirmed
identical on origin/main, so this was never a regression from this branch.

Stages its own pair, leaving the assertions untouched.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The adjacent launcher was present but refused as unverified: copied files
inherit Administrators ownership on runner images, and the launcher's
exact-owner validators require the current user, which is what
stamp_fixture_owner_current exists to simulate. Without it the guard failed on
the product's ownership check rather than on the immediate-reinstall contract.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Neither the fresh portable pair nor the ownership stamp fixed the immediate
managed reinstall, so both were guesses and do not belong on a release-blocking
PR. The guard's failure is pre-existing on main -- byte-identical output there,
and unchanged with the #581 fix disabled -- so this branch carries no regression
and no speculative test edits either.

What the investigation did establish: the reinstall's install command fails with
'managed install requires a verified adjacent codebase-memory-mcp.exe launcher'
when run directly, and emits no diagnostic on the stream the guard captures,
which is why the guard reports only a version banner.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The guard reported only a version banner, so a silent nonzero exit was
indistinguishable from a rejected precondition. The install command works in
isolation and across a plain uninstall-then-reinstall, so the exit code is the
remaining unknown -- a crash code would say so directly.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Three distinct refusals -- an insecure source directory, a failed snapshot, and
a missing source file -- collapsed into one boolean, and the frees on the way
out clobber GetLastError, so the caller reported 'staging transaction open
failed (status -3, os 0)': an I/O error with no cause and no refusal note.

The Windows launcher guard's immediate-reinstall step fails through exactly this
path, and with no cause reported each hypothesis cost a full ten-minute guard
run. Captures the OS error before the frees and names the predicate that
refused.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…saction

activation_transaction_prepare refused an insecure target directory and
returned IO with no note, while destroy() on the way out clobbered
GetLastError -- so the caller printed 'staging transaction open failed (status
-3, os 0)': an I/O error with neither a cause nor an OS code.

That is the path the Windows launcher guard's immediate reinstall takes, and the
missing note is why each hypothesis about it cost a ten-minute guard run. A
target directory failing the exact-owner rule is a different bug from an
unreadable source, and now says so.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Fourteen paths returned CBM_ACTIVATION_TRANSACTION_IO without setting a refusal
note, so the caller could only ever print 'status -3, os 0' -- an I/O error with
no cause. Identifying which one fires cost a ten-minute Windows guard run per
hypothesis, and two targeted guesses missed.

Labels every site so a single run names the path. Temporary labels; once the
guard's path is known they become descriptive.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The copy loop's five distinct failures -- short read/write, an empty source, a
failed fsync, a failed close, and a failed directory sync -- shared one branch
and one unnamed IO status. Exhaustive labelling identified this as the path the
Windows launcher guard's immediate reinstall takes; this names which of the five
actually fires.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The copy loop's failure was reported as 'os 0' because the cleanup and close
calls after the loop clobber the thread's last error. Captures it at the failure
point, and separates a failed read from a failed write -- copying a payload that
another process still holds is a different problem from a full disk.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Reverts the #581 fix mechanism, not the diagnosis.

main's dry run passes both windows-11-arm shards and test-windows-guards; this
branch fails all three. The only change here that affects process LOAD is the
.CRT$XLB detach callback, and the arm64 failures are load-time crashes
(STATUS_ILLEGAL_INSTRUCTION, pass=0, secs=0, five suites in the same second) --
while the guards exercise many launcher and payload spawns. The same binary
loads fine on a UTM arm64 VM, so the placement is tolerated by some loaders and
not others, which is not something to ship on a supported platform.

#581's root cause stands and is documented: a static MinGW link has no DllMain
and registers no TLS callback, so mi_thread_done never runs and every thread
leaks its heap (607 heaps after 300 requests, 170 MiB against ~300 KiB live).
The delivery mechanism needs to be one the loader accepts everywhere -- an FLS
callback registered at runtime is the obvious candidate, with no linker section
placement involved.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The hosted windows-11-arm shards crash at process load (STATUS_ILLEGAL_INSTRUCTION,
pass=0, secs=0) while main passes them, and removing the TLS detach callback did
not help -- so the remaining candidate is this layer, which touches every
allocation on Windows through the interposer. It declares a _Thread_local guard
read on every malloc, and thread-local storage in a static MinGW aarch64 link is
exactly the fragile case; nothing in it needs to ship.

The profiler did its job: it produced the attribution that identified #581's
mechanism, and that analysis is recorded. Sources stay in the tree, unbuilt, so
a future investigation can re-enable them deliberately.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The refusal notes changed the error text the Windows launcher guards match
against, and test-windows-guards went red on this PR after they landed. They were
investigation instrumentation and they succeeded -- they turned 'staging
transaction open failed (status -3, os 0)' into ERROR_DISK_FULL and named the
exact site -- but a diagnostic that shifts a matched error string does not belong
in a release-blocking change.

Worth redoing deliberately on its own, with the guards' expectations updated in
the same commit.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit a388700 into main Jul 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working editor/integration Editor compatibility and CLI integration github_actions Pull requests that update GitHub Actions code priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. security Security vulnerabilities, hardening stability/performance Server crashes, OOM, hangs, high CPU/memory ux/behavior Display bugs, docs, adoption UX windows Windows-specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant