Check a system event's kept handle under the kernel lock before signalling it, and keep host-memory events in a full table.

kern_signal_system_event read the table entry and the state byte without the kernel lock, then kern_event_signal took
the lock and ended the process when the kept writable handle was no longer an event. A host thread (the vsync thread
signals every bound vsync event 60 times a second) racing the guest's DestroySystemEvent, which zeroes the byte and
then closes both handles under the kernel lock, hit that fatal; a probe built on kernel.c reached it after 437
destroy and bind rounds. A stale entry whose byte reads nonzero again ended the process at once, and since h_new hands
a closed number to the next new object, a stale entry could also signal or clear an unrelated event.

Each Event now gets a serial number, and kern_bind_system_event records the serial of the Event its writable handle
names. The new system_event_set reads the entry again under the kernel lock and sets or clears the event only while
the handle still names that same Event; a stale entry is skipped and logged once. kern_clear_system_event goes through
it as well. The lock order is G before g_se_lock; the binder never holds both.

The full-table path of kern_bind_system_event took any entry whose object mem_readable did not know, which includes
the live events host services keep in their own memory (ov_movie2.c Video.in_ev/out_ev, net_offline.c
HostTask.event). The service's next signal from its host thread then found no entry and tried to bind there, which is
fatal. Entries now record whether the object lies in guest memory, and only guest entries that were destroyed or
unmapped give their slot.

tests/system_event_check.py builds kernel.c with stubs and checks a destroyed event with its byte nonzero again, a
closed number taken by an unrelated event, two seconds of a host thread signalling and clearing against a guest
thread destroying and binding again, a full table with the movie's two host-memory events, and the "table full"
fatal with every entry live. Against the previous kernel.c it ends with "kern_event_signal: 0x1001 is not a writable
event handle". sync_wake_check and thread_end_check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 17:04:11 -05:00
parent 78bfb83b2b
commit 465ffe67bb
3 changed files with 343 additions and 16 deletions

View File

@@ -49,7 +49,9 @@ enum { W_NONE = KERN_WAIT_NONE, W_LOCK = KERN_WAIT_LOCK, W_CONDVAR = KERN_WAIT_C
enum { S_OFF, S_RUN, S_CORE, S_HOST, S_SLEEP, S_SYNC, S_CV, S_LOCK, S_ADDR, S_N };
static const char *const s_name[S_N] = {"off", "run", "core", "host", "sleep", "sync", "cv", "lock", "addr"};
typedef struct Event { int signaled, refs; } Event;
// serial: a number no other Event of the run gets (set holding G), so a kept writable handle can be checked to still
// name the Event it named when it was kept (kern_bind_system_event, system_event_set).
typedef struct Event { int signaled, refs; uint64_t serial; } Event;
typedef struct Fiber Fiber;
struct RtThread {
@@ -125,6 +127,7 @@ _Static_assert(offsetof(RtThread, preempt) == 0, "host.h kern_preempt_pending re
_Static_assert(offsetof(RtThread, host_depth) == sizeof(int), "host.h kern_host_depth reads the second int");
static RtThread *g_all, *g_wait, *g_main;
static uint64_t g_next_id = 0x51, g_wseq, g_created;
static uint64_t g_ev_serial; // the last Event's serial number (holding G)
static uint64_t g_main_stack;
static void lock(void) { plat_mutex_lock(&G); }
@@ -1940,6 +1943,7 @@ static int kern_svc_inner(Cpu *c, uint32_t imm) {
if (!e) rt_fatal("kernel: event allocation");
e->refs = 2;
lock();
e->serial = ++g_ev_serial;
uint32_t hw = h_new(O_EVENT_W, e), hr = h_new(O_EVENT_R, e);
unlock();
x[0] = R_OK;
@@ -2091,6 +2095,7 @@ uint32_t kern_event_new(void) {
if (!e) rt_fatal("kernel: event allocation");
e->refs = 1;
lock();
e->serial = ++g_ev_serial;
uint32_t hw = h_new(O_EVENT_W, e);
unlock();
return hw;
@@ -2130,8 +2135,13 @@ void kern_event_clear(uint32_t hw) {
// activate and deactivate events before each detection. nn::os::DestroySystemEvent zeroes the state byte at +0x28
// (0 not initialised, 1 event, 2 inter-process event; nn::os::TryWaitSystemEvent aborts on 0) and closes both
// handles, so a kept handle counts only while that byte is nonzero.
// Each entry also keeps the serial number of the Event its handle named at bind time (h_new hands a closed number to
// the next new object at once, so the number alone does not say it is still that event), and whether the object lies
// in guest memory: host services keep some in their own allocations (ov_movie2.c Video.in_ev/out_ev, net_offline.c
// HostTask.event), which mem_readable does not know and the SDK never destroys.
// Lock order: G before g_se_lock (system_event_set); kern_bind_system_event never holds both.
#define SE_STATE 0x28
static struct { uint64_t ev; uint32_t wh; } g_se[512];
static struct { uint64_t ev, serial; uint32_t wh; uint8_t guest; } g_se[512];
static int g_nse;
static PlatMutex g_se_lock = PLAT_MUTEX_INIT;
@@ -2139,18 +2149,23 @@ static int se_live(uint64_t ev) {
return __atomic_load_n((const uint8_t *)(uintptr_t)(ev + SE_STATE), __ATOMIC_RELAXED) != 0;
}
static uint32_t se_find(uint64_t ev) {
// The entry's writable handle, 0 when ev is not bound; *serial (when not NULL) gets the Event's serial number.
static uint32_t se_find(uint64_t ev, uint64_t *serial) {
plat_mutex_lock_shared(&g_se_lock);
uint32_t wh = 0;
for (int i = 0; i < g_nse && !wh; i++)
if (g_se[i].ev == ev) wh = g_se[i].wh;
for (int i = 0; i < g_nse; i++)
if (g_se[i].ev == ev) {
wh = g_se[i].wh;
if (serial) *serial = g_se[i].serial;
break;
}
plat_mutex_unlock_shared(&g_se_lock);
return wh;
}
// A bound event the guest destroyed since is created again and its table entry takes the new writable handle.
void kern_bind_system_event(Cpu *c, uint64_t ev, int autoclear) {
if (se_find(ev) && se_live(ev)) return;
if (se_find(ev, NULL) && se_live(ev)) return;
if (!tcur || !c) rt_fatal("kern_bind_system_event(%#llx) on a host thread", (unsigned long long)ev);
static uint64_t create, getw;
if (!create) {
@@ -2161,39 +2176,67 @@ void kern_bind_system_event(Cpu *c, uint64_t ev, int autoclear) {
uint32_t r = (uint32_t)rt_call(c, create, 3, ev, (uint64_t)(autoclear ? 1 : 0), (uint64_t)1);
if (r) rt_fatal("kern_bind_system_event: CreateSystemEvent(%#llx) failed %#x", (unsigned long long)ev, r);
uint32_t wh = (uint32_t)rt_call(c, getw, 1, ev);
lock();
Event *e = h_peek(wh, O_EVENT_W);
uint64_t serial = e ? e->serial : 0; // 0 matches no Event: such an entry is never signalled
unlock();
uint8_t guest = (uint8_t)mem_readable(ev + SE_STATE, 1);
plat_mutex_lock(&g_se_lock);
int i = 0;
while (i < g_nse && g_se[i].ev != ev) i++;
if (i == g_nse && g_nse == (int)(sizeof g_se / sizeof *g_se)) {
// Full: entries are never removed, and a long session binds events at new addresses (two per reload of a
// save). An entry whose event the guest destroyed (its state byte is 0, or its memory is gone) gives its slot.
// save). An entry in guest memory whose event the guest destroyed (its state byte is 0, or its memory is gone)
// gives its slot. Entries in host memory count as live: their services never destroy them through the SDK, and
// taking a live one would make the service's next signal bind again on a host thread, which is fatal.
i = 0;
while (i < g_nse && mem_readable(g_se[i].ev + SE_STATE, 1) && se_live(g_se[i].ev)) i++;
while (i < g_nse && (!g_se[i].guest || (mem_readable(g_se[i].ev + SE_STATE, 1) && se_live(g_se[i].ev)))) i++;
if (i == g_nse) rt_fatal("kern_bind_system_event: table full (%d live system events)", g_nse);
g_se[i].ev = ev;
} else if (i == g_nse) {
g_se[g_nse++].ev = ev;
}
g_se[i].wh = wh;
g_se[i].serial = serial;
g_se[i].guest = guest;
plat_mutex_unlock(&g_se_lock);
}
// Sets (set 1, waking its waiters) or clears a bound event, holding G from reading the entry to the change. It acts
// only while the entry's writable handle still names the Event the binder saw: DestroySystemEvent closes both handles
// under G, and the closed number goes to the next new object at once, so a stale entry would otherwise end the process
// (kern_event_signal's fatal on a closed handle) or set an unrelated event. A stale entry is skipped and logged once.
static void system_event_set(uint64_t ev, int set) {
static int told;
int tell = 0;
lock();
uint64_t serial = 0;
uint32_t wh = se_find(ev, &serial);
Event *e = wh ? h_peek(wh, O_EVENT_W) : NULL;
if (e && e->serial == serial) {
e->signaled = set;
if (set) wake_sync_waiters(e);
} else if (wh && !told) {
told = tell = 1;
}
unlock();
if (tell)
rt_log("kernel: system event %#llx: its kept handle %#x no longer names its event; %s skipped (logged once)",
(unsigned long long)ev, wh, set ? "signal" : "clear");
}
// An event not bound yet is bound on the spot with manual clear, which needs a guest thread. A bound event the guest
// destroyed is not signalled: its handles are closed and the kernel hands their numbers to new objects.
void kern_signal_system_event(uint64_t ev) {
uint32_t wh = se_find(ev);
uint32_t wh = se_find(ev, NULL);
if (wh && !se_live(ev)) return;
if (!wh) {
kern_bind_system_event(kern_cpu(), ev, 0);
wh = se_find(ev);
}
kern_event_signal(wh);
if (!wh) kern_bind_system_event(kern_cpu(), ev, 0);
system_event_set(ev, 1);
}
// Clears a bound event (one never bound or since destroyed is left alone).
void kern_clear_system_event(uint64_t ev) {
uint32_t wh = se_find(ev);
if (wh && se_live(ev)) kern_event_clear(wh);
if (se_find(ev, NULL) && se_live(ev)) system_event_set(ev, 0);
}
// ---- fibers

222
tests/system_event_check.c Normal file
View File

@@ -0,0 +1,222 @@
// runtime/kernel.c: the system-event table (kern_bind_system_event, kern_signal_system_event, kern_clear_system_event)
// with the platform layer linked and the rest of the runtime stubbed. Built and run by tests/system_event_check.py;
// prints each failed check and exits 1 on any.
// (no argument) a kept handle the guest closed (DestroySystemEvent) is neither signalled nor cleared, also when its
// number went to an unrelated event or the state byte reads nonzero again; a host thread signalling
// while a guest thread destroys and binds the event again never ends the process; with the table full,
// a new bind takes a destroyed guest-memory entry and keeps the entries in host memory, which a host
// thread then still signals
// full every entry live: the next bind ends with "table full" (the script checks the exit code and text)
#include "kernel.c"
#include <stdarg.h>
static int fails;
#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL line %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0)
// ---- the runtime kernel.c reaches
unsigned rt_trace;
uint64_t rt_guest_start_ns;
volatile uint32_t prof_replay_tid, prof_main_tid;
// Guest memory is this block; anything else (the "host-memory" events below) is not, as memory.c answers.
static uint8_t guest[600][0x40] __attribute__((aligned(16)));
int mem_readable(uint64_t a, uint64_t n) {
uint64_t lo = (uint64_t)(uintptr_t)guest, hi = lo + sizeof guest;
return a >= lo && a + n <= hi;
}
const char *cfg_get(const char *name) { (void)name; return NULL; }
const char *cfg_value(const char *name) { (void)name; return "0"; }
int cfg_bool(const char *name) { (void)name; return 0; }
int rt_ab(void) { return 0; }
int rt_knob(const char *name, const char *def) { (void)name; return strcmp(def, "0") != 0; }
int64_t frame_step_world_us(void) { return -1; }
void ipc_handle_closed(int kind, void *obj) { (void)kind, (void)obj; }
const char *ldr_describe(uint64_t a, char *buf, int n) { snprintf(buf, (size_t)n, "%#llx", (unsigned long long)a); return buf; }
// The SDK's two functions kern_bind_system_event calls: 1 CreateSystemEvent, 2 GetWritableHandleOfSystemEvent.
uint64_t ldr_symbol(const char *name) { return strstr(name, "CreateSystemEvent") ? 1 : 2; }
void perf_main_thread(void) {}
void perf_size(PerfSizes *s, const char *name, uint64_t count, uint64_t limit, uint64_t bytes) {
(void)s, (void)name, (void)count, (void)limit, (void)bytes; // kern_sizes is not called here
}
int rt_linked_modules(RtModule *out, int max) { (void)out, (void)max; return 0; }
const RtGuestLayout *mem_layout(void) { return NULL; }
void *mem_kernel_alloc(uint64_t size, uint64_t align) {
void *p = plat_aligned_alloc(size, align < 64 ? 64 : align);
if (!p) rt_fatal("out of memory");
memset(p, 0, size);
return p;
}
void mem_kernel_free(void *p, uint64_t size) { (void)size; plat_aligned_free(p); }
void rt_log(const char *fmt, ...) { (void)fmt; }
void rt_logf(const char *fmt, ...) { (void)fmt; }
void rt_fatal(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
printf("FATAL: ");
vprintf(fmt, ap);
printf("\n");
va_end(ap);
fflush(stdout);
_Exit(3);
}
void rt_dispatch(Cpu *c) { (void)c; rt_fatal("rt_dispatch in the test"); }
void rt_svc(Cpu *c, uint32_t imm, uint64_t pc) { (void)pc; kern_svc(c, imm); }
// CreateSystemEvent(ev, mode, ipc) makes a kernel event, keeps its writable handle at +0 of the SystemEventType and its
// readable one at +4, and sets the state byte; GetWritableHandleOfSystemEvent(ev) reads +0.
uint64_t rt_call(Cpu *c, uint64_t fn, int n, ...) {
(void)c;
va_list ap;
va_start(ap, n);
uint64_t ev = va_arg(ap, uint64_t);
va_end(ap);
uint8_t *p = (uint8_t *)(uintptr_t)ev;
if (fn == 1) {
uint32_t wh = kern_event_new(), rh = kern_event_readable(wh);
memcpy(p, &wh, 4);
memcpy(p + 4, &rh, 4);
__atomic_store_n(p + SE_STATE, 1, __ATOMIC_RELEASE);
return 0;
}
uint32_t wh;
memcpy(&wh, p, 4);
return wh;
}
// ---- helpers
static RtThread me; // the main thread plays a guest thread for the binds
static Cpu cpu;
static uint64_t ev_of(void *p) { return (uint64_t)(uintptr_t)p; }
static uint32_t wh_of(const void *p) { uint32_t h; memcpy(&h, p, 4); return h; }
static uint32_t rh_of(const void *p) { uint32_t h; memcpy(&h, (const uint8_t *)p + 4, 4); return h; }
static void bind(void *p) { kern_bind_system_event(&cpu, ev_of(p), 0); }
// nn::os::DestroySystemEvent: the state byte first, then both handles (SVC 0x16).
static void destroy(void *p) {
__atomic_store_n((uint8_t *)p + SE_STATE, 0, __ATOMIC_RELEASE);
kern_handle_close(rh_of(p));
kern_handle_close(wh_of(p));
}
static int signaled(uint32_t wh) {
lock();
Event *e = h_peek(wh, O_EVENT_W);
int s = e ? e->signaled : -1;
unlock();
return s;
}
static void stale_and_reuse(void) {
uint8_t *ev = guest[0];
bind(ev);
uint32_t wh = wh_of(ev);
kern_signal_system_event(ev_of(ev));
CHECK(signaled(wh) == 1, "a live bound event is signalled");
kern_clear_system_event(ev_of(ev));
CHECK(signaled(wh) == 0, "a live bound event is cleared");
destroy(ev);
kern_signal_system_event(ev_of(ev)); // state byte 0: skipped before the kernel lock
// The byte reads nonzero again with the handles closed (the memory reused, or the SDK made the event again
// without the binder): the signal must neither end the process nor reach anything.
ev[SE_STATE] = 1;
kern_signal_system_event(ev_of(ev));
kern_clear_system_event(ev_of(ev));
// The closed number goes to the next new event at once: the stale entry must not set or clear it.
uint32_t other = kern_event_new();
CHECK(other == wh, "the next event takes the closed number (%#x, closed %#x)", other, wh);
kern_signal_system_event(ev_of(ev));
CHECK(signaled(other) == 0, "an unrelated event that took the closed number is not signalled by the stale entry");
lock();
((Event *)h_peek(other, O_EVENT_W))->signaled = 1;
unlock();
kern_clear_system_event(ev_of(ev));
CHECK(signaled(other) == 1, "an unrelated event that took the closed number is not cleared by the stale entry");
kern_handle_close(other);
// Bound again after the destroy (state byte 0 first, as the guest leaves it): signals again.
ev[SE_STATE] = 0;
bind(ev);
kern_signal_system_event(ev_of(ev));
CHECK(signaled(wh_of(ev)) == 1, "the event bound again is signalled");
destroy(ev);
}
// A host thread signals in a loop while the guest thread destroys the event and binds it again (the override called
// again after a DestroySystemEvent), as gfx_present.c vsync_main does with a vsync event.
static volatile int stop;
static volatile long signals;
static uint8_t *race_ev;
static void signaller(void *arg) {
(void)arg;
while (!stop) {
kern_signal_system_event(ev_of(race_ev));
kern_clear_system_event(ev_of(race_ev));
__atomic_add_fetch(&signals, 1, __ATOMIC_RELAXED);
}
}
static void race(void) {
race_ev = guest[1];
bind(race_ev);
void *t = plat_thread_new(signaller, NULL, "signaller");
uint64_t t0 = plat_now_ns();
long rounds = 0;
while (plat_now_ns() - t0 < 2000000000ull || rounds < 1000) {
destroy(race_ev);
bind(race_ev);
rounds++;
}
stop = 1;
plat_thread_join(t);
printf("race: %ld destroy and bind rounds against %ld signals, no fatal\n", rounds, signals);
destroy(race_ev);
}
// ov_movie2.c update_events on a movie decoder thread (a host thread).
static uint8_t *movie_in, *movie_out;
static void decoder(void *arg) {
(void)arg;
kern_signal_system_event(ev_of(movie_in));
kern_signal_system_event(ev_of(movie_out));
}
static void full_table(int all_live) {
int n = (int)(sizeof g_se / sizeof *g_se);
// the guest entries 0 and 1 of the checks above are destroyed; fill the rest of the table with live ones and the
// movie's two events, which live in host memory
for (int i = 2; i < n - 2; i++) bind(guest[i]);
movie_in = calloc(1, 0x40), movie_out = calloc(1, 0x40);
bind(movie_in);
bind(movie_out);
CHECK(g_nse == n, "the table is full (%d of %d)", g_nse, n);
if (all_live) {
bind(guest[0]); // the two destroyed guest entries are made again: every entry is live
bind(guest[1]);
printf("full: binding one more with every entry live\n");
bind(guest[n]);
printf("full: no fatal\n");
return;
}
bind(guest[n]);
CHECK(se_find(ev_of(movie_in), NULL) && se_find(ev_of(movie_out), NULL),
"the movie's host-memory events keep their entries (in %s, out %s)",
se_find(ev_of(movie_in), NULL) ? "kept" : "taken", se_find(ev_of(movie_out), NULL) ? "kept" : "taken");
CHECK(se_find(ev_of(guest[n]), NULL) != 0, "the new event is bound");
CHECK(se_find(ev_of(guest[0]), NULL) == 0, "it took the first destroyed guest entry");
bind(guest[n + 1]);
CHECK(se_find(ev_of(guest[1]), NULL) == 0, "the next takes the second destroyed guest entry");
void *t = plat_thread_new(decoder, NULL, "decoder");
plat_thread_join(t);
CHECK(signaled(wh_of(movie_in)) == 1 && signaled(wh_of(movie_out)) == 1,
"a host thread signals the movie's events with the table full");
}
int main(int argc, char **argv) {
setvbuf(stdout, NULL, _IONBF, 0);
me.cur = &cpu;
tcur = &me;
int full = argc > 1 && !strcmp(argv[1], "full");
stale_and_reuse();
race();
full_table(full);
if (full) return 0;
printf(fails ? "system_event_check: %d failed\n" : "system_event_check: ok\n", fails);
return fails ? 1 : 0;
}

View File

@@ -0,0 +1,62 @@
"""Build and run tests/system_event_check.c: the system-event table of runtime/kernel.c (kern_bind_system_event,
kern_signal_system_event, kern_clear_system_event) against destroyed events, reused handle numbers, a host thread racing
a destroy and bind, and a full table holding events in host memory; with the platform layer linked and the rest of the
runtime stubbed. No game files: the module layout the runtime includes is a stand-in written to the test's build folder.
Usage: system_event_check.py
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
def compiler():
if os.environ.get('CC'):
return os.environ['CC']
if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists():
return str(MSYS_BIN / 'clang.exe')
return shutil.which('clang') or 'clang'
LAYOUT = '''// A stand-in for the generated layout.h (tests/system_event_check.py).
#pragma once
#define MOD_MAIN_BASE 0x7000000000ull
#define MOD_MAIN_SIZE 0x4ea0000ull
'''
def main():
work = ROOT / 'build' / 'test' / 'system_event'
work.mkdir(parents=True, exist_ok=True)
(work / 'layout.h').write_text(LAYOUT)
win = os.name == 'nt'
exe = work / ('system_event_check.exe' if win else 'system_event_check')
env = dict(os.environ)
if win and MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
rt = ROOT / 'runtime'
plat = [rt / 'platform' / ('win32.c' if win else 'linux.c'), rt / 'platform' / 'common.c']
libs = ['-lbcrypt', '-lws2_32', '-lpsapi', '-lwinmm', '-ldbghelp', '-lshell32', '-lole32', '-luser32', '-ladvapi32'] if win \
else ['-lpthread']
subprocess.check_call([compiler(), '-O1', '-g', '-Wall', '-Wno-unused-function', '-fno-strict-aliasing',
'-march=x86-64-v3', '-I', str(rt), '-I', str(work),
str(ROOT / 'tests' / 'system_event_check.c'), *map(str, plat), '-o', str(exe), '-lm', *libs],
env=env)
rc = subprocess.run([str(exe)], env=env, timeout=300).returncode
# Every entry live: the next bind must end the process with "table full" (rt_fatal's stub exits 3).
p = subprocess.run([str(exe), 'full'], env=env, timeout=300, capture_output=True, text=True)
sys.stdout.write(p.stdout)
if p.returncode != 3 or 'table full' not in p.stdout:
print(f'FAIL: a bind with every entry live must end with "table full" (exit {p.returncode})')
rc = rc or 1
else:
print('system_event_check full: ok (table full with every entry live)')
return rc
if __name__ == '__main__':
sys.exit(main())