page: a dropped unit's pages are forgotten again, not left numbered with no directory entry

The translator numbers the pages it claims in the page table it is handed,
and that table comes back with the result; their directory entries are
written only when the unit is installed. A dropped unit left them numbered
and unpublished. The next lift there saw an old page and wrote no entry for
it either, so every block running onto such a page (an instruction across
the boundary) failed its page check against the empty entry, asked to be
translated again, and got the same check: a miss loop for ever. That was the
Undertale freeze at its first frame with the drop-on-retire path (76fb3b09):
the dropped unit at 0x76645fa0 numbered 0x76646, and 0x76645ffd then missed
440 times and on.

With fe7057c6 a unit is dropped only across an execve (whose table is the
new program's) or a retire log overflow, so this closes the overflow case.

retire-during-lift now puts the last block of every page across the
boundary. On a build dropping on every retire it hung 2 runs of 2 (14-16
drops, missing at 0xf748ffd and 0xf757ffd); with this, 6 of 6 pass with
4-8 drops each.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_ea9f71488b6e448da93d6c2dabf4879c
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-12 05:37:31 -04:00
1 parent 064a344a9f
commit 5892193eeb
2 files changed
+35 -10

No files matched your search

+20 -3
View File
@@ -12931,12 +12931,29 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
.collect()
})
};
if retired_since.is_none()
|| jitout::EXEC_EPOCH.load(std::sync::atomic::Ordering::Acquire) != epoch.1
{
let execd = jitout::EXEC_EPOCH.load(std::sync::atomic::Ordering::Acquire) != epoch.1;
if retired_since.is_none() || execd {
for a in &fresh {
h.jit.translated.remove(a);
}
// And the pages it numbered. The translator gives a page its base
// in the page table it was handed, which came back with the
// result; the directory entry is written only when the unit is
// installed. Kept numbered with the entry never written, the next
// lift there sees an old page, writes no entry for it either, and
// every block of it that runs into that page (an instruction
// across the boundary) fails its page check against the empty
// entry, asks to be translated again, and gets the same check: a
// miss loop for ever (Undertale, frozen at its first frame, when
// any retire dropped the unit). After an `execve` the table is
// the new program's, and nothing of this unit is in it.
if !execd {
for (p, b) in &unit.dir_entries {
if h.jit.page_table.lookup(*p) == Some(*b) {
h.jit.page_table.forget(*p);
}
}
}
static DROPPED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
if tracing() || n <= 4 || n.is_power_of_two() {
+15 -7
View File
@@ -1,13 +1,16 @@
/* Code retired while other code is being translated. One thread runs a long
* chain of small blocks (8,000 of them, each `add eax, 1; jmp next`), which
* chain of small blocks (8,000 of them, each `add eax, 1; jmp next`, the last
* of each page running onto the next one), which
* takes many dispatch misses and long lifts to translate; another keeps
* flipping a separate code page (a function it also calls) to
* read-write-execute and back, rewriting a byte with the value it already
* holds. Every flip retires that page's translations (`retire_code`), and in
* the page the flips land while the chain is being lifted outside the kernel
* lock (docs/kernel-locking.md, step 5). The chain must add up every round
* and the run must end: dropping a unit lifted across a retire once froze
* Undertale.
* and the run must end. A unit dropped for a retire once left the pages it
* had numbered with no directory entry, and a block running onto one of them
* then failed its page check for ever: Undertale froze at its first frame, and
* this froze the same way (2 runs of 2, a build dropping on every retire).
*
* Not named *smc*: run.sh would run it under --fjit as well, which livelocks
* natively once a chain page is retired (a separate bug).
@@ -26,6 +29,10 @@
#define BLOCKS 8000
#define STRIDE 16
#define ROUNDS 20
/* Where the first block starts: 13 bytes into every 16, so the last block of
each page has its `add` in the page's last three bytes and its `jmp` on the
next page. */
#define FIRST 13
#ifndef FLIP_US
#define FLIP_US 200
#endif
@@ -80,20 +87,21 @@ static void *flipper(void *arg) {
}
int main(void) {
size_t size = (2 + BLOCKS * STRIDE + 1 + 4095) & ~(size_t)4095;
size_t size = (FIRST + BLOCKS * STRIDE + 1 + 4095) & ~(size_t)4095;
unsigned char *code = map_code(size);
flip_page = map_code(4096);
if (!code || !flip_page) return 2;
memset(code, 0xcc, size);
code[0] = 0x31; code[1] = 0xc0; /* xor eax, eax */
code[0] = 0x31; code[1] = 0xc0; /* xor eax, eax */
code[2] = 0xeb; code[3] = FIRST - 4; /* jmp to the first block */
for (int i = 0; i < BLOCKS; i++) {
unsigned char *b = code + 2 + i * STRIDE;
unsigned char *b = code + FIRST + i * STRIDE;
b[0] = 0x83; b[1] = 0xc0; b[2] = 0x01; /* add eax, 1 */
int32_t rel = STRIDE - 8; /* jmp to the next block */
b[3] = 0xe9;
memcpy(b + 4, &rel, 4);
}
code[2 + BLOCKS * STRIDE] = 0xc3; /* ret */
code[FIRST + BLOCKS * STRIDE] = 0xc3; /* ret */
memset(flip_page, 0xcc, 4096);
memcpy(flip_page, "\xb8\x07\x00\x00\x00\xc3", 6); /* mov eax, 7; ret */
if (mprotect(code, size, PROT_READ | PROT_EXEC) != 0) return 2;