From 1a93086e2d4c3ec592bd3cafe9d792f7cab4bfcd Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 26 Oct 2026 08:35:15 +0800 Subject: [PATCH] Fix service-event and vsync worker lifetimes (#202) Apply PR #202's host-thread skip and duplicate vsync suppression. Give bindings unique lifetime IDs so a retired worker cannot signal another service's event after guest storage and kernel handles are reused. Replace the eight-entry worker table with allocated records so rapid replacement does not abort while retired workers wait for their next tick. Add deterministic regression cases through real event binding and SVCs, and document the mechanism with an updated symptom index. Both the capacity abort and stale signal reproduce against the original PR. Co-authored-by: Aleksandr Svidchenkov --- dm/host/dm.h | 2 + dm/host/hle_sdk16.c | 39 ++++++++-- dm/host/hle_thread.c | 30 +++++++- dm/host/tests/Makefile | 7 ++ dm/host/tests/t_shared_font.c | 2 + dm/host/tests/t_system_event.c | 135 +++++++++++++++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/reference/threads.md | 8 ++ 8 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 dm/host/tests/t_system_event.c diff --git a/dm/host/dm.h b/dm/host/dm.h index 12b3d91..4afbf96 100644 --- a/dm/host/dm.h +++ b/dm/host/dm.h @@ -100,6 +100,8 @@ void hle_signal_system_event(void *g); // hle_thread.c: signal a service-layer void hle_bind_system_event(void *g, int autoclear); // On a guest thread, bind a guest SystemEvent to a kernel event void hle_clear_system_event(void *g); // hle_thread.c: clear a bound service-layer SystemEvent (any thread) int hle_signal_bound_system_event(void *g); // hle_thread.c: signal a service-layer SystemEvent only if it is bound (any thread); 0 when it is not +uint64_t hle_system_event_binding(void *g); // Opaque lifetime ID of a binding, 0 if unbound; a rebind gets a new ID even if its address and handle are reused +int hle_signal_system_event_binding(uint64_t binding); // Signal only this binding lifetime (any thread); 0 after its write handle closes // Page fault hook / hook before the host writes guest memory (crash.c; used for write-protect dirty page tracking) void dm_fault_hook_register(int (*fn)(void *addr, int is_write, void *uc)); void dm_guest_write_prep_register(void (*fn)(void *addr, size_t len)); diff --git a/dm/host/hle_sdk16.c b/dm/host/hle_sdk16.c index 61ed9fe..74f2598 100644 --- a/dm/host/hle_sdk16.c +++ b/dm/host/hle_sdk16.c @@ -215,19 +215,36 @@ HLE_FORCE("_ZN2nn3nfp29StartNicknameAndOwnerSettingsEPNS0_12DeviceHandleEPbPNS0_ // ---------- vi: vsync event ---------- // The game passes a SystemEventType; after binding, a host thread fires it at 60 Hz (DM_VSYNC_HZ can change it). While the display link presents, the event follows its ticks, as a // Switch's follows its display: Super Mario Galaxy 2 runs the Wii's VI retrace (and with it each emulated frame) on this event, and on a clock of the host's own the frames drifted -// against the screen's vsyncs, two in some display-link ticks and none in others (iPhone: about one tick in seven, a stutter the frame-rate counter showed as 50 to 60) +// against the screen's vsyncs, two in some display-link ticks and none in others (iPhone: about one tick in seven, a stutter the frame-rate counter showed as 50 to 60). +// One thread per binding lifetime: repeated requests must not double its rate. +// A destroyed binding retires its worker even if another service reuses the same +// guest storage and handle number before the next tick (MacNativeGames dab728a). void dm_host_helper_qos(qos_class_t q); // hle_thread.c +typedef struct vsync_event { void *ev; uint64_t binding; struct vsync_event *next; } vsync_event_t; +static pthread_mutex_t g_vs_mu = PTHREAD_MUTEX_INITIALIZER; +static vsync_event_t *g_vs_events; +static int vsync_tick(vsync_event_t *v) { // 0: the event is gone and this thread has left the table + if (hle_signal_system_event_binding(v->binding)) return 1; + pthread_mutex_lock(&g_vs_mu); + vsync_event_t **p = &g_vs_events; while (*p && *p != v) p = &(*p)->next; + if (*p) *p = v->next; + pthread_mutex_unlock(&g_vs_mu); + LOG("vi: vsync event %p destroyed by the game, its thread ends", v->ev); + return 0; +} __attribute__((weak)) int mtl_link_wait_vsync(uint64_t *seen, uint64_t timeout_ns) { (void)seen; (void)timeout_ns; return 0; } // nvn_mtl.m -static void *vsync_thread(void *ev) { +static void *vsync_thread(void *arg) { + vsync_event_t *v = arg; dm_host_helper_qos(QOS_CLASS_USER_INTERACTIVE); // the game paces frames on this event const char *e = getenv("DM_VSYNC_HZ"); double hz = e ? atof(e) : 60.0; if (hz <= 0) hz = 60.0; uint64_t period = (uint64_t)(1e9 / hz), next = now_ns() + period, seen = 0; for (;;) { - if (!e && mtl_link_wait_vsync(&seen, 2 * period)) { hle_signal_system_event(ev); next = now_ns() + period; continue; } + if (!e && mtl_link_wait_vsync(&seen, 2 * period)) { if (!vsync_tick(v)) break; next = now_ns() + period; continue; } uint64_t t = now_ns(); if (next > t) { struct timespec ts = {(time_t)((next - t) / 1000000000ull), (long)((next - t) % 1000000000ull)}; nanosleep(&ts, NULL); } - hle_signal_system_event(ev); next += period; if (now_ns() > next + 4 * period) next = now_ns() + period; // do not try to catch up when far behind + if (!vsync_tick(v)) break; + next += period; if (now_ns() > next + 4 * period) next = now_ns() + period; // do not try to catch up when far behind } - return NULL; + free(v); return NULL; } // Image memory needed for an indirect layer (an applet's picture handed to the game to draw, such as the inline keyboard): on hardware it asks the vi service. Computed as linear RGBA8, 4 KB aligned (Super Mario 3D World + Bowser's Fury's inline keyboard asks for 1280x720) HLE_FORCE("_ZN2nn2vi33GetIndirectImageMemoryRequirementEPmS1_ii", "indirect layer image memory: RGBA8 linear, 4 KB aligned", Result, h_vi_IndirectReq, size_t *size, size_t *align, int w, int h) { @@ -235,8 +252,16 @@ HLE_FORCE("_ZN2nn2vi33GetIndirectImageMemoryRequirementEPmS1_ii", "indirect laye } HLE_FORCE("_ZN2nn2vi20GetDisplayVsyncEventEPNS_2os15SystemEventTypeEPNS0_7DisplayE", "vsync event: a host thread signals it at 60 Hz", Result, h_vi_VsyncEvent, void *ev, void *disp) { (void)disp; hle_bind_system_event(ev, 1); - pthread_t th; if (pthread_create(&th, NULL, vsync_thread, ev)) die("cannot start the vsync thread"); pthread_detach(th); - LOG("vi::GetDisplayVsyncEvent: %p signalled by the host at 60 Hz", ev); return RES_OK; + uint64_t binding = hle_system_event_binding(ev); + pthread_mutex_lock(&g_vs_mu); + int running = 0; for (vsync_event_t *v = g_vs_events; v; v = v->next) running |= v->binding == binding; + if (!running) { + vsync_event_t *v = calloc(1, sizeof *v); if (!v) die("cannot allocate a vsync event"); + v->ev = ev; v->binding = binding; v->next = g_vs_events; g_vs_events = v; + pthread_t th; if (pthread_create(&th, NULL, vsync_thread, v)) die("cannot start the vsync thread"); pthread_detach(th); + } + pthread_mutex_unlock(&g_vs_mu); + LOG("vi::GetDisplayVsyncEvent: %p signalled by the host at 60 Hz%s", ev, running ? " (its thread already runs)" : ""); return RES_OK; } // ---------- diag ---------- diff --git a/dm/host/hle_thread.c b/dm/host/hle_thread.c index 3cfc6b5..a51dcf5 100644 --- a/dm/host/hle_thread.c +++ b/dm/host/hle_thread.c @@ -33,8 +33,9 @@ enum { O_FREE, O_THREAD, O_EVENT_R, O_EVENT_W }; // An entry lives as long as that handle: CloseHandle on it removes the entry (se_unbind), so a SystemEvent destroyed and created again at the same address gets a new kernel event. // Entries used to stay forever: the second bind returned early, the new event was never created and signals went to the closed handle (or to whatever event reused its number). // Lock order: G, then g_se_lock (nothing takes G while holding g_se_lock) -static struct { void *ev; uint32_t wh; } g_se[512]; +static struct { void *ev; uint32_t wh; uint64_t id; } g_se[512]; static int g_nse; +static uint64_t g_next_se_id = 1; // g_se_lock: binding identity survives neither address nor handle reuse static pthread_mutex_t g_se_lock = PTHREAD_MUTEX_INITIALIZER; static uint32_t se_find(void *ev) { // caller holds g_se_lock for (int i = 0; i < g_nse; i++) if (g_se[i].ev == ev) return g_se[i].wh; @@ -1015,10 +1016,30 @@ void hle_bind_system_event(void *ev, int autoclear) { // Must be called on wh = getw(ev); pthread_mutex_lock(&g_se_lock); if (g_nse >= (int)(sizeof g_se / sizeof g_se[0])) die("SystemEvent binding table full"); - g_se[g_nse].ev = ev; g_se[g_nse++].wh = wh; + g_se[g_nse].ev = ev; g_se[g_nse].wh = wh; g_se[g_nse++].id = g_next_se_id++; pthread_mutex_unlock(&g_se_lock); } -void hle_signal_system_event(void *ev) { // An unbound one is bound on the spot as manual-clear (can only happen on a guest thread) +uint64_t hle_system_event_binding(void *ev) { + uint64_t id = 0; + pthread_mutex_lock(&g_se_lock); + for (int i = 0; i < g_nse; i++) if (g_se[i].ev == ev) { id = g_se[i].id; break; } + pthread_mutex_unlock(&g_se_lock); + return id; +} +int hle_signal_system_event_binding(uint64_t binding) { + // Match the lifetime and signal under G, just as CloseHandle removes it under + // G: a stale producer cannot signal a new event that reuses either number. + pthread_mutex_lock(&G); + uint32_t wh = 0; + pthread_mutex_lock(&g_se_lock); + for (int i = 0; i < g_nse; i++) if (g_se[i].id == binding) { wh = g_se[i].wh; break; } + pthread_mutex_unlock(&g_se_lock); + event_t *e = wh ? h_get(wh, O_EVENT_W) : NULL; + if (e) { e->signaled = 1; wake_sync_waiters(e); } + pthread_mutex_unlock(&G); + return e != NULL; +} +void hle_signal_system_event(void *ev) { // An unbound one is bound on the spot as manual-clear (only on a guest thread: off one it is skipped) // Lookup and signal under one hold of G: CloseHandle (which unbinds) also runs under G, so the handle cannot be closed and its number reused in between for (int bound = 0; ; bound = 1) { pthread_mutex_lock(&G); @@ -1032,6 +1053,9 @@ void hle_signal_system_event(void *ev) { // An unbound one is bound on the spo } pthread_mutex_unlock(&G); if (bound) die("SystemEvent %p still not found after binding", ev); + // A host thread (audio, vsync) signalling an event the game has destroyed: binding needs SDK code, which only a guest thread can run, and the event is gone anyway + // (MacNativeGames dab728a: skipped, not fatal) + if (!pthread_getspecific(g_keythr)) { static _Atomic int once; if (!atomic_exchange(&once, 1)) LOG("[RED] SystemEvent %p signalled off a guest thread after the game destroyed it: skipped", ev); return; } hle_bind_system_event(ev, 0); // guest code (SVCs take G): outside the lock } } diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile index 9b5ba53..00c7349 100644 --- a/dm/host/tests/Makefile +++ b/dm/host/tests/Makefile @@ -25,6 +25,7 @@ run: $(B)/t_heap_size $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_ $(B)/t_pctl $(B)/t_time $(B)/t_shared_font + $(B)/t_system_event $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid @@ -32,6 +33,12 @@ run: $(B)/t_heap_size $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_ $(B)/t_haptics_apple $(B)/t_nro run: $(B)/t_nro +run: $(B)/t_system_event +$(B)/t_system_event: t_system_event.c ../hle_thread.c ../hle_sdk16.c ../hle.h ../dm.h $(GAME_H) | $(B) + clang -O1 -g -Wall -Werror -Wno-unused-function -Wno-frame-address -I.. $(GINC) -o $@ t_system_event.c +.PHONY: system-event +system-event: $(B)/t_system_event + $(B)/t_system_event $(B)/t_nro: t_nro.c ../loader.c ../hle_ro.c ../hle.h ../dm.h $(GAME_H) | $(B) clang -O1 -g -Wall -Wno-unused-function -Wno-deprecated-declarations -I.. $(GINC) -o $@ t_nro.c .PHONY: nro diff --git a/dm/host/tests/t_shared_font.c b/dm/host/tests/t_shared_font.c index ff7af21..7fd9f7a 100644 --- a/dm/host/tests/t_shared_font.c +++ b/dm/host/tests/t_shared_font.c @@ -31,6 +31,8 @@ module_t *module_of(const void *p) { (void)p; return NULL; } void dm_backtrace(uint64_t fp) { (void)fp; } void hle_bind_system_event(void *ev, int autoclear) { (void)ev; (void)autoclear; } void hle_signal_system_event(void *ev) { (void)ev; } +uint64_t hle_system_event_binding(void *ev) { (void)ev; return 1; } +int hle_signal_system_event_binding(uint64_t binding) { (void)binding; return 1; } uint32_t hle_hid_connected_npad(void) { return 0; } void dm_host_helper_qos(qos_class_t q) { (void)q; } extern const struct mach_header_64 _mh_execute_header; diff --git a/dm/host/tests/t_system_event.c b/dm/host/tests/t_system_event.c new file mode 100644 index 0000000..1ec4289 --- /dev/null +++ b/dm/host/tests/t_system_event.c @@ -0,0 +1,135 @@ +// Service-event lifetimes through real binding / CloseHandle SVCs and the vi +// hook. Hold worker startup and advance its fallback clock explicitly so event +// destruction and address / handle reuse always happen before the first tick. +#include "../hle_thread.c" +#include +#include +#include +#include + +static void *(*jobs[32])(void *); +static void *args[32]; +static int njobs, sleeps, observed_signal; +static uint64_t clock_ns; +typedef struct { uint32_t wh, rh; } test_event; +static test_event *observe; +static void destroy_event(test_event *ev); +static int test_create(pthread_t *th, const pthread_attr_t *attr, void *(*fn)(void *), void *arg) { + (void)attr; assert(njobs < 32); *th = (pthread_t)(uintptr_t)(njobs + 1); + jobs[njobs] = fn; args[njobs++] = arg; return 0; +} +static int test_detach(pthread_t th) { (void)th; return 0; } +static int test_sleep(const struct timespec *ts, struct timespec *left) { + (void)left; + // Bound an old worker that incorrectly follows a replacement event: inspect + // its first tick, then destroy the replacement so even that worker can exit. + if (sleeps++ && observe && observe->wh) { + pthread_mutex_lock(&G); observed_signal |= ((event_t *)h_get(observe->wh, O_EVENT_W))->signaled; pthread_mutex_unlock(&G); + destroy_event(observe); + } + assert(sleeps < 4); + clock_ns += (uint64_t)ts->tv_sec * 1000000000ull + ts->tv_nsec; + return 0; +} +#define pthread_create test_create +#define pthread_detach test_detach +#define nanosleep test_sleep +#include "../hle_sdk16.c" +#undef pthread_create +#undef pthread_detach +#undef nanosleep + +struct dm_flags g_flag; +module_t g_mod[MAX_MOD]; +int g_nmod; +uint64_t g_t0_ns; +__thread _Atomic int dm_host_work; +pthread_key_t g_key18; +void crash_thread_init(void) {} +void guest_thread_init(void) {} +void dm_thread_enter(uint64_t entry, uint64_t arg, uint64_t sp) { (void)entry; (void)arg; (void)sp; abort(); } +uint64_t now_ns(void) { return clock_ns; } +uint64_t guest_ns(void) { return now_ns(); } +int dm_detclock_enabled(void) { return 0; } +int dm_sdk_major(void) { return 20; } +int dm_main_imports(const char *prefix) { (void)prefix; return 1; } +module_t *module_of(const void *p) { (void)p; return NULL; } +void dm_backtrace(uint64_t fp) { (void)fp; } +uint32_t hle_hid_connected_npad(void) { return 0; } +static uint32_t create_event(void *p, int mode, int interprocess) { + (void)mode; assert(interprocess); + uint64_t r[32] = {0}; assert(svc_thread(0x45, r) && !r[0]); + test_event *ev = p; ev->wh = r[1]; ev->rh = r[2]; return 0; +} +static uint32_t writable_event(const void *p) { return ((const test_event *)p)->wh; } +void *guest_sym(const char *name) { + if (!strcmp(name, "_ZN2nn2os17CreateSystemEventEPNS0_15SystemEventTypeENS0_14EventClearModeEb")) return create_event; + if (!strcmp(name, "_ZN2nn2os30GetWritableHandleOfSystemEventEPKNS0_15SystemEventTypeE")) return writable_event; + return NULL; +} +static void destroy_event(test_event *ev) { + uint64_t r[32] = {ev->wh}; assert(ev->wh && svc_thread(0x16, r) && !r[0]); + r[0] = ev->rh; assert(svc_thread(0x16, r) && !r[0]); + ev->wh = ev->rh = 0; +} +static int signalled(test_event *ev) { + pthread_mutex_lock(&G); int signalled = ((event_t *)h_get(ev->wh, O_EVENT_W))->signaled; pthread_mutex_unlock(&G); + return signalled; +} +static void run_job(int i) { + void *guest = pthread_getspecific(g_keythr); pthread_setspecific(g_keythr, NULL); + sleeps = 0; jobs[i](args[i]); + pthread_setspecific(g_keythr, guest); +} +static void duplicate(void) { + test_event ev = {0}; + assert(!h_vi_VsyncEvent(&ev, NULL)); assert(!h_vi_VsyncEvent(&ev, NULL)); + assert(njobs == 1); destroy_event(&ev); run_job(0); +} +static void capacity(void) { + test_event ev[9] = {0}; + for (unsigned i = 0; i < 9; i++) { assert(!h_vi_VsyncEvent(&ev[i], NULL)); destroy_event(&ev[i]); } + for (int i = 0; i < njobs; i++) run_job(i); + assert(g_nse == 0); +} +static void reuse(void) { + test_event ev = {0}; assert(!h_vi_VsyncEvent(&ev, NULL)); + uint32_t old_wh = ev.wh; destroy_event(&ev); hle_bind_system_event(&ev, 0); + assert(ev.wh == old_wh); // Force reuse of both guest storage and the kernel handle number. + observe = &ev; run_job(0); + assert(!observed_signal && (!ev.wh || !signalled(&ev))); + if (ev.wh) destroy_event(&ev); +} +static void rebind(void) { + test_event ev = {0}; assert(!h_vi_VsyncEvent(&ev, NULL)); destroy_event(&ev); + assert(!h_vi_VsyncEvent(&ev, NULL)); + // The replacement vi registration must still tick after the old lifetime + // retires, including when it uses exactly the same storage and handles. + observe = &ev; + for (int i = 0; i < njobs; i++) run_job(i); + assert(observed_signal); +} +static void host_signal(void) { + test_event ev = {0}; hle_bind_system_event(&ev, 0); + void *guest = pthread_getspecific(g_keythr); pthread_setspecific(g_keythr, NULL); + hle_signal_system_event(&ev); assert(signalled(&ev)); + destroy_event(&ev); hle_signal_system_event(&ev); hle_signal_system_event(&ev); + assert(g_nse == 0); pthread_setspecific(g_keythr, guest); + // Guest callers retain lazy creation for service events not yet bound. + hle_signal_system_event(&ev); assert(signalled(&ev)); destroy_event(&ev); +} +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + struct rlimit lim = {0, 0}; setrlimit(RLIMIT_CORE, &lim); + void (*tests[])(void) = {duplicate, capacity, reuse, rebind, host_signal}; + const char *names[] = {"duplicate vi registration", "rapid vi event replacement", "storage and handle reuse by another service", "vi rebind at reused storage", "host skip and guest lazy binding"}; + int failed = 0; + for (unsigned i = 0; i < sizeof tests / sizeof *tests; i++) { + pid_t pid = fork(); assert(pid >= 0); + if (!pid) { alarm(5); setenv("DM_VSYNC_HZ", "60", 1); svc_thread_main_init(); tests[i](); _exit(0); } + int status; assert(waitpid(pid, &status, 0) == pid); + int ok = WIFEXITED(status) && !WEXITSTATUS(status); failed |= !ok; + printf("%s: %s\n", ok ? "PASS" : "FAIL", names[i]); + } + return failed; +} diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 24e07e6..a5285ac 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -78,6 +78,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a 30 Hz game thread runs at 27 Hz, and whatever it paces comes out slow (cutscenes at 36 ms per frame instead of 33.3; gameplay was fine at 60). → [Timer slack in guest sleeps (`sleep_precise`)](../reference/threads.md#timer-slack-in-guest-sleeps-sleep_precise) - a guest thread keeps a core at 100% with billions of `SleepThread` calls. → [Sleeps under 20 µs take the yield path](../reference/threads.md#sleeps-under-20-µs-take-the-yield-path) - a loading screen sits for a minute with the CPU nearly idle; one thread yields thousands of times a second, every yield a back-off sleep (the power line's top yielder, with as many back-off sleeps). → [Yields that make progress return at once (`thr_yield`)](../reference/threads.md#yields-that-make-progress-return-at-once-thr_yield) +- a host helper thread aborts when signalling a destroyed `SystemEvent`; repeated `vi::GetDisplayVsyncEvent` calls double the event's tick rate. Rapid replacement can abort with `too many vsync events`, or a retired vsync worker can signal another service's event at reused storage. → [Service-event producers follow the binding lifetime](../reference/threads.md#service-event-producers-follow-the-binding-lifetime) - the main thread waits forever on a job's done event at boot while every worker sleeps, two of them on one event (`main+0x1426c` waiting on `Job::m_doneEvent`); 4 of 6 Mac runs. → [One auto-clear event shared by a pool's workers](../reference/threads.md#one-auto-clear-event-shared-by-a-pools-workers) - abort in `os::WaitSystemEvent` under `oe::PopNotificationMessage` (The Legend of Zelda: Link's Awakening); one guest thread at about 1000 ms/s on the iPhone only, the phone hot, CPU energy 5–7 W (Monster Hunter Rise's `MessageSuperviseThread`, priority 32, in the `[nvn] threads` lines). → [The oe notification event and its queue](../reference/threads.md#the-oe-notification-event-and-its-queue) diff --git a/docs/reference/threads.md b/docs/reference/threads.md index a97a39f..8cde367 100644 --- a/docs/reference/threads.md +++ b/docs/reference/threads.md @@ -109,6 +109,14 @@ The yield back-off (`DM_YIELD_BACKOFF`) and the rewriter's spin waits (`patch_ns ## Events and notifications +### Service-event producers follow the binding lifetime + +- **Symptom:** a host helper thread aborts when signalling a destroyed `SystemEvent`; repeated `vi::GetDisplayVsyncEvent` calls double the event's tick rate. Rapid replacement can abort with `too many vsync events`, or a retired vsync worker can signal another service's event at reused storage. +- **Cause:** lazy binding calls the SDK's `CreateSystemEvent`, which requires a guest thread. Checking only the guest address also mistakes a replacement binding for the original event, even if its kernel handle number is checked: both can be reused before the next tick. An eight-slot worker table counts destroyed events until their workers next run. +- **Fix:** `hle_signal_system_event` retains lazy binding on guest threads; host threads skip an unbound event and log once. Each service-event binding gets a monotonically increasing identity. `hle_signal_system_event_binding` matches that identity and signals under the same lock as `CloseHandle`, so a stale producer cannot signal a replacement. The vi registry keeps one worker per binding identity in an allocated list; a worker whose binding closed removes only its own entry and exits. A new vi binding at reused storage gets its own worker, while repeated requests for a live binding share the existing one. +- **Found on:** synthetic host regression cases in [`t_system_event.c`](../../dm/host/tests/t_system_event.c), using real binding and event SVCs with worker startup held until after destruction and handle reuse; no game failure is claimed. +- **Check:** duplicate vi registration, nine rapid replacements before the first tick, storage and handle reuse by a different service, vi rebinding at reused storage, host signalling after destruction, and guest lazy binding. The capacity and unrelated-service cases fail on the original PR #202 and pass with lifetime identities and the allocated registry. + ### One auto-clear event shared by a pool's workers - **Symptom:** the main thread waits forever on a job's done event at boot while every worker sleeps, two of them on one event (`main+0x1426c` waiting on `Job::m_doneEvent`); 4 of 6 Mac runs.