diff --git a/runtime/kernel.c b/runtime/kernel.c index b0683a14..41b946e0 100644 --- a/runtime/kernel.c +++ b/runtime/kernel.c @@ -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 diff --git a/tests/system_event_check.c b/tests/system_event_check.c new file mode 100644 index 00000000..b41c627f --- /dev/null +++ b/tests/system_event_check.c @@ -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 + +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; +} diff --git a/tests/system_event_check.py b/tests/system_event_check.py new file mode 100644 index 00000000..8bc59c09 --- /dev/null +++ b/tests/system_event_check.py @@ -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())