diff --git a/dm/host/hle_svc.c b/dm/host/hle_svc.c
index 4e1e061..f934829 100644
--- a/dm/host/hle_svc.c
+++ b/dm/host/hle_svc.c
@@ -147,6 +147,7 @@ uint64_t svc_dispatch(int num, uint64_t *regs, uint64_t orig_addr) {
int rc = 0;
if (size > g_heap_cur) rc = mprotect(g_heap + g_heap_cur, size - g_heap_cur, PROT_READ | PROT_WRITE);
else if (size < g_heap_cur) rc = mprotect(g_heap + size, g_heap_cur - size, PROT_NONE);
+ if (rc) die("SetHeapSize mprotect failed: req=%#zx errno=%d", size, errno);
if (g_flag.svc) LOG("SetHeapSize %#zx -> base %p mprotect=%d errno=%d", size, g_heap, rc, errno);
else if (size > g_heap_cur) LOG("[mem] heap %.2f GB (heap region %.2f GB)", size / 1073741824.0, g_heap_cap / 1073741824.0);
g_heap_cur = size;
diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile
index 4b46e2d..0ad6110 100644
--- a/dm/host/tests/Makefile
+++ b/dm/host/tests/Makefile
@@ -4,7 +4,8 @@ B = build
# tests that use the game profile need GAME= (there is no default game); GINC only expands when used, so tests without the profile build as before
GINC = $(if $(strip $(GAME)),-I../gen/$(GAME) -I../../games/$(GAME),$(error GAME is not set: make GAME=: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}')))
GAME_H = ../gen/$(GAME)/game_profile.h
-run: $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/t_thread_leak $(B)/t_fs_case $(B)/t_audio $(B)/t_audio_spsc $(B)/t_swkbd $(B)/t_altstack $(B)/t_condvar $(B)/t_condvar_old $(B)/t_pctl $(B)/t_time $(B)/t_shared_font $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid $(B)/t_haptics $(B)/t_haptics_apple
+run: $(B)/t_heap_size $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/t_thread_leak $(B)/t_fs_case $(B)/t_audio $(B)/t_audio_spsc $(B)/t_swkbd $(B)/t_altstack $(B)/t_condvar $(B)/t_condvar_old $(B)/t_pctl $(B)/t_time $(B)/t_shared_font $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid $(B)/t_haptics $(B)/t_haptics_apple
+ $(B)/t_heap_size
$(B)/t_touch
$(B)/t_gesture
$(B)/t_oe 2>/dev/null
@@ -35,6 +36,11 @@ nro: $(B)/t_nro
$(B)/t_nro
$(GAME_H):
$(MAKE) -C .. game_profile GAME=$(GAME)
+$(B)/t_heap_size: t_heap_size.c ../hle_svc.c ../dm.h $(GAME_H) | $(B)
+ clang -O1 -Wall -Werror -Wno-unused-function -fsanitize=undefined -I.. $(GINC) -o $@ t_heap_size.c
+.PHONY: heap_size
+heap_size: $(B)/t_heap_size
+ $(B)/t_heap_size
$(B)/t_touch: t_touch.c ../hle_touch.c ../front.h ../dm.h $(GAME_H) | $(B)
clang -O1 -Wno-unused-function -I.. $(GINC) -o $@ t_touch.c ../hle_touch.c
$(B)/t_gesture: t_gesture.c ../hle_touch.c ../front.h ../dm.h $(GAME_H) | $(B)
diff --git a/dm/host/tests/t_heap_size.c b/dm/host/tests/t_heap_size.c
new file mode 100644
index 0000000..47722c9
--- /dev/null
+++ b/dm/host/tests/t_heap_size.c
@@ -0,0 +1,120 @@
+// Exercise the production SVC dispatcher, including its fatal path, without game files.
+#include "../dm.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+static int heap_mprotect(void *addr, size_t size, int prot);
+static _Noreturn void heap_abort(void);
+// Substitution is confined to this translation unit; production needs no test API.
+#define mprotect heap_mprotect
+#define abort heap_abort
+#ifndef HLE_SVC_SOURCE
+#define HLE_SVC_SOURCE "../hle_svc.c"
+#endif
+#include HLE_SVC_SOURCE
+#undef abort
+#undef mprotect
+
+module_t g_mod[MAX_MOD];
+int g_nmod;
+struct dm_flags g_flag;
+void hlecount_svc(int num) { (void)num; }
+int svc_thread(int num, uint64_t *regs) { (void)num; (void)regs; return 0; }
+module_t *module_of(const void *p) { (void)p; return NULL; }
+void dm_backtrace(uint64_t fp) { (void)fp; }
+void guest_clock_read_wait(void) {}
+uint64_t guest_ns(void) { return 0; }
+uint64_t dm_entropy64(void) { return 0; }
+int thr_limit_used(void) { return 0; }
+
+static size_t page, old_size, requested;
+static int calls, injected_error, last_prot;
+static volatile sig_atomic_t abort_validated;
+static void *last_addr;
+static size_t last_size;
+static uint64_t regs[31];
+static const uint64_t sentinel = 0xabcdef1234567890ull;
+
+static int heap_mprotect(void *addr, size_t size, int prot) {
+ calls++; last_addr = addr; last_size = size; last_prot = prot;
+ // The production state must not be published before the host operation.
+ assert(g_heap_cur == old_size && regs[0] == sentinel && regs[1] == requested);
+ if (injected_error) { errno = injected_error; return -1; }
+ return mprotect(addr, size, prot);
+}
+static int correct_call(void) {
+ int grow = requested > old_size;
+ return calls == 1 && last_addr == g_heap + (grow ? old_size : requested) &&
+ last_size == (grow ? requested - old_size : old_size - requested) &&
+ last_prot == (grow ? PROT_READ | PROT_WRITE : PROT_NONE);
+}
+static void aborted(int sig) { _exit(sig == SIGABRT && abort_validated ? 77 : 78); }
+static _Noreturn void heap_abort(void) {
+ // Observe the exact state at die(), before letting the real abort run.
+ if (!injected_error || errno != injected_error || !correct_call() ||
+ g_heap_cur != old_size || regs[0] != sentinel || regs[1] != requested ||
+ !dm_guest_heap_readable((uint64_t)g_heap, old_size) ||
+ dm_guest_heap_readable((uint64_t)g_heap, old_size + 1)) _exit(78);
+ abort_validated = 1; // An unrelated assertion abort must not pass the failure test.
+ abort();
+}
+static void request(size_t size) {
+ old_size = g_heap_cur; requested = size; calls = 0;
+ memset(regs, 0, sizeof regs); regs[0] = sentinel; regs[1] = size;
+ assert(svc_dispatch(0x01, regs, 0) == 0);
+}
+static void success(size_t size) {
+ injected_error = 0;
+ request(size);
+ assert(g_heap_cur == size && regs[0] == 0 && regs[1] == (uint64_t)g_heap);
+ assert(size == old_size ? calls == 0 : correct_call());
+ assert(dm_guest_heap_readable((uint64_t)g_heap, size));
+ assert(!dm_guest_heap_readable((uint64_t)g_heap, size + 1));
+ if (size) { g_heap[0] = 0x5a; g_heap[size - 1] = 0xa5; }
+}
+static void failure(size_t size, int error) {
+ fflush(NULL);
+ pid_t p = fork(); assert(p >= 0);
+ if (!p) {
+ signal(SIGABRT, aborted); alarm(5);
+ injected_error = error;
+ request(size);
+ _exit(79); // Any return to the guest, even with untouched registers, is wrong.
+ }
+ int status; assert(waitpid(p, &status, 0) == p);
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 77) {
+ fprintf(stderr, "SetHeapSize failure did not preserve state and abort (status=%d)\n", status);
+ exit(1);
+ }
+}
+static void used_memory(size_t heap_size) {
+ uint64_t r[31] = {0, I_UsedMem};
+ assert(svc_dispatch(0x29, r, 0) == 0);
+ assert(r[0] == 0 && r[1] == heap_size + DM_GAME_MEM_USED);
+}
+int main(void) {
+ page = (size_t)getpagesize(); g_heap_cap = 4 * page;
+ assert(g_heap_cap < APP_POOL);
+ g_heap = mmap(NULL, g_heap_cap, PROT_NONE, MAP_PRIVATE | MAP_ANON, -1, 0);
+ assert(g_heap != MAP_FAILED);
+ g_as = g_heap; // Skip the multi-GB profile reservation; use only these synthetic pages.
+ success(2 * page); used_memory(2 * page); // initial growth
+ success(3 * page); used_memory(3 * page); // further growth
+ success(page); used_memory(page); // shrink
+ injected_error = EACCES; errno = EACCES; request(page); // unchanged: no mprotect despite stale errno
+ assert(calls == 0 && g_heap_cur == page && regs[0] == 0 && regs[1] == (uint64_t)g_heap);
+ failure(3 * page, ENOMEM); failure(3 * page, EACCES); // failed growth
+ success(3 * page); // successful retry from the unchanged parent state
+ failure(page, ENOMEM); failure(page, EACCES); // failed shrink
+ failure(0, EACCES); // failed release of the whole heap
+ success(0); used_memory(0);
+ success(0); // unchanged empty heap
+ success(page); success(0); // regrowth after release
+ assert(munmap(g_heap, g_heap_cap) == 0);
+ puts("SetHeapSize production dispatcher: growth, shrink, unchanged, zero, failure state and fail-fast passed");
+ return 0;
+}