Source changes (dormant parity infra, retained from iterate 2.AI/2.AO): - xenia-kernel/exports.rs: nt_create_event manual_reset polarity + related event wiring - xenia-gpu/mmio_region.rs: D1MODE_VBLANK_VLINE_STATUS hardcode parity Also lands the audit-runs/ analysis notes (.md/.txt/.json digests) for the iterate 2.x VSync/0x10e8/0x1004 wedge investigation. Raw trace dumps (.jsonl/.gz/.csv/.stdout) and agent worktrees (.claude/) are gitignored as regenerable local artifacts — see memory + HANDOFF for the running findings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.4 KiB
Phase C+17 Investigation — KeWait native-object handle synthesis (2026-05-14)
Framing verification (reading-error #28 discipline)
C+15-α / C+16 catalog D-2/D-3/D-4 hypothesis: ours's KeWait* doesn't emit
handle.create when passed a raw native dispatcher object pointer (PKEVENT /
PKSEMAPHORE), while canary's xeKeWaitForSingleObject /
KeWaitForMultipleObjects_entry call XObject::GetNativeObject which
lazy-synthesizes an XEvent/XSemaphore/XMutant/XTimer wrapper and
inserts it in the object table — ObjectTable::AddHandle fires
phase_a::EmitHandleCreateAuto (object_table.cc:191-198).
Canary's GetNativeObject semantics (xobject.cc:397-483)
Triggered by: KeWait* (and family) is called with a raw kernel-object
pointer. The first action of xeKeWaitForSingleObject is to call
XObject::GetNativeObject<XObject>(kernel_state, object_ptr)
(threading.cc:972, threading.cc:1070).
GetNativeObject(kernel_state, native_ptr, as_type=-1, already_locked=false):
- Read
X_DISPATCH_HEADERatnative_ptr.as_typedefaults toheader->type(the dispatcher-type byte: 0=manual event, 1=auto event, 2=mutant, 5=semaphore, 8/9=timer). - Check the
wait_list.flink_ptrmagic: if it equalskXObjSignature('X','E','N','\0'= 0x58454E00) the dispatcher has already been adopted; read the existing handle fromwait_list.blink_ptrand return the existingXObjectviaLookupObject<XObject>(handle, true). - Otherwise FIRST USE — synthesize:
- case 0 / 1:
new XEvent(kernel_state)→ callsXEvent::InitializeNative(native_ptr, header)then assigns to result. - case 2:
new XMutant+InitializeNative(but body asserts — unsupported). - case 5:
new XSemaphore+InitializeNative(semaphore->limit / signal_state). - case 3/4/6/7/8/9/18..24:
assert_always(). Timer not handled here.
- case 0 / 1:
- After construction, call
StashHandle(header, object->handle())— writeskXObjSignaturetowait_list.flink_ptrand the new handle towait_list.blink_ptr. This guarantees idempotency: next call returns the same handle.
Crucially, the XObject ctor XObject(KernelState*, Type, host_object)
(xobject.cc:35-48) always calls kernel_state->object_table()->AddHandle(this, nullptr),
which (C+15-α-wired) emits handle.create via
phase_a::EmitHandleCreateAuto (object_table.cc:148-201).
So: first call → 1× handle.create emit; subsequent calls (signature
matches) → 0 emits.
Canary KeWaitForSingleObject entry ordering (threading.cc:969-1013)
xeKeWaitForSingleObject(object_ptr, ...):
auto object = XObject::GetNativeObject<XObject>(kernel_state(), object_ptr);
^^^ emits handle.create on first use (object_type=1 / 3 / etc)
if (!object) { return X_STATUS_ABANDONED_WAIT_0; }
if (phase_a::IsEnabled()) {
uint64_t sid = 0;
if (!object->handles().empty()) {
sid = phase_a::LookupHandleSemanticId(object->handles()[0]);
}
phase_a::EmitWaitBegin(&sid, 1, ...); // wait.begin with real SID
}
result = object->Wait(...);
So canary's emit order on first use is: handle.create → wait.begin,
exactly as observed on the cold log (idx=102171 → 102172).
Lifetime / refcount
The synthesized XObject lives until its handle_ref_count reaches 0. Since
AddHandle initializes it to 1, and there's no balancing RemoveHandle
elsewhere in the lazy-wrap path, the wrapper survives for the rest of the
session (no handle.destroy is emitted by canary either — confirmed by
absence in canary's log post-102171). This is structurally consistent with
canary's "stash the handle in the dispatcher; reuse forever" pattern.
For ours we mirror this: emit one handle.create on first
ensure_dispatcher_object adoption; no handle.destroy thereafter.
Object-type mapping
| dispatcher header.type | canary symbol | ours KernelObject variant |
ours object_type code (event_log) |
|---|---|---|---|
| 0 (manual event) | XEvent (notification) | Event { manual_reset=true } | EVENT = 1 |
| 1 (auto event) | XEvent (synchronization) | Event { manual_reset=false } | EVENT = 1 |
| 5 (semaphore) | XSemaphore | Semaphore { .. } | SEMAPHORE = 3 |
| 8 (notif timer) | XTimer (canary asserts) | Timer { manual_reset=true } | TIMER = 4 |
| 9 (sync timer) | XTimer (canary asserts) | Timer { manual_reset=false } | TIMER = 4 |
| 2 (mutant) | XMutant (canary asserts) | (no shadow — return early) | n/a |
Note canary's GetNativeObject assert_always()s for timer types 8/9 — it
panics on unsupported dispatcher types. Sylpheed apparently never hits these
in canary (canary keeps running, so the assert is never tripped in our cold
log). Ours's ensure_dispatcher_object historically supports timer/8/9 via
the shadow path; we keep that for ours's robustness and emit
object_type=TIMER for them. Cross-engine SID matching only matters for
codes both engines emit; ours's extra timer emits would surface as new
divergences (acceptable per the catalog).
Ours's pre-fix behavior
resolve_pseudo_handle(exports.rs:4321): only translates the magic0xFFFF_FFFF/0xFFFF_FFFEself-handle. For any other value it's a pass-through. Native dispatcher pointers and real handles both reach the next step unchanged.ensure_dispatcher_object(exports.rs:4363): on first encounter of a guest pointer (ptr >= 0x1_0000and not already instate.objects), reads the dispatcher header, creates the shadowKernelObject::{Event, Semaphore, Timer}, inserts intostate.objects, stampskXObjSignatureat+0x08/+0x0C. Does NOT emithandle.create. Does NOT bumphandle_refcount(entry stays absent).ke_wait_for_single_object(exports.rs:4954): callsresolve_pseudo_handle→ensure_dispatcher_object→refresh_pkevent_shadow_from_guest→ emitswait.beginwithlookup_handle_semantic_id(handle) = 0(since no SID was ever registered) → callsdo_wait_single.
Result observed at idx=102171: ours emits wait.begin handles_semantic_ids=['0000000000000000'] and zero handle.create events.
Fix shape
Symmetric: extend ensure_dispatcher_object to do the equivalent of
canary's XObject::AddHandle post-construction emit. Specifically:
- After inserting the shadow into
state.objects(existing line ~4409), and when this is a fresh adoption (the inserted-before check is the guard at line 4367), seedhandle_refcount.insert(ptr, 1)for lifecycle symmetry (no canary-sidehandle.destroyis expected, but consistency withalloc_handle_foris worth ~1 LOC). - When
event_log::is_enabled(), callevent_log::emit_handle_create_auto(tid, cycle, /* pc */ 0, object_type, raw_handle_id=ptr, object_name=None). The chosenobject_typematches the variant: Event=1, Semaphore=3, Timer=4. This both emits the event AND registers the SID in the registry so the subsequentwait.beginresolves non-zero.
Order in ke_wait_for_single_object already matches canary: synth (now
emits handle.create) before wait.begin. No re-ordering needed.
For ke_wait_for_multiple_objects the same applies — the loop already calls
ensure_dispatcher_object per pointer (exports.rs:5022). Each first
adoption emits one handle.create and the SID array used by wait.begin
becomes non-zero per element.
Idempotency / refcount lifecycle
- First-touch: shadow inserted +
handle_refcount[ptr] = 1+ emithandle.create. - Re-touch (same pointer): early return at the
contains_keyguard → no emit, no refcount change. Matches canary's "already-initialized" branch. - Destroy: there is no path that destroys these shadows in ours today
(parity with canary). If someone later wires
handle.destroyon shadow-removal, the refcount will be present and decrement-to-zero will fire the symmetric event. Not in scope here.
Scope
C+17 strictly addresses D-2/D-3/D-4. We do not touch:
NtWait*(handle-based; already SID-resolves through the registry once the underlyingNt*Create*emit fireshandle.create).Ke{Set,Reset,Pulse}Event/KeReleaseSemaphorepaths that also callensure_dispatcher_object. These will now emithandle.createon their first-touch — that's EXPECTED engine-symmetric behavior, and matches canary (every entry intoGetNativeObjectmay emit). The wait-side has pre-context emits in both engines, so observable order is preserved.
Tripstone register
- Reading-error #28 (canary semantics first): VERIFIED.
- Reading-error #23 (widely-used primitive flip): MITIGATED via cold-vs-cold gate and HARD-REVERT-IF-MAIN-REGRESSES discipline.
- Reading-error #19 (host-side emits): event_log::is_enabled() guard preserved on every new emit — default-off zero cost.
- Refcount semantics: matches canary's "stash forever" lazy-wrap pattern;
not symmetric with
alloc_handle_for's NtClose-balanced lifecycle (which is correct — these are different kinds of handles).
Cascade prediction (for the run)
A=verify canary's GetNativeObject semantics: DONE. B=land symmetric ~30-50 LOC fix: PENDING. C=main matched-prefix > 102,171: ~75%. D=sister chains advance (4 chains): ~75%. E=NEW divergences surface (downstream): ~80% (intended).