Files
NX2navi/tests/submit_clone_check.c
chrg6 58fad9128d Give a synchronous submission of a recording copy that another thread's submission still replays a clone of its own for its deferred binds, instead of rewriting the copy under that replay.
nvnQueueSubmitCommands resolves a copy's deferred binds at the submit call: the handles the game's arrays hold now are
written into the recording copy (nvn_api_cmd.c resolve_deferred). With the replay on the submitting thread, a second
guest thread that submitted the same copy while the first one replayed it, or while the first copied it for a held
continuation (vk_exec.c cont_new), rewrote those handles under that replay: the rest of it bound the next frame's
textures and samplers, and a held continuation could be copied torn (F-2-03's part left after the private copies).

Now each copy counts the synchronous submissions between their prepare and the end of their replay (copy_prefix.busy,
which shares the 8 bytes pins had, so the prefix keeps its 56 bytes). nvn_submit_prepare takes copy_lock shared once
for all handles: the first submission in flight resolves the copy in place, as before; one that finds another in
flight, unless the copy is known to hold no deferred binds, resolves a clone made under that lock, with its texture
pins counted, and nvn_submit_done frees it after the replay, as the asynchronous path does for a copy a queued
submission still reads. A copy the game releases while a submission of it is in flight becomes a zombie and is freed at
the next sweep after it ends (release_copies, zombie_sweep). The bind loop moved unchanged into
runtime/nvn_submit_guard.h (nvn_resolve_binds) so a check can build it; it reports a corrupt record or a call past 16
deep to resolve_deferred, which ends the run with the messages it had, and keeps the first six changes' trace line.
Being code the front end carries under its MIT header, the new header keeps that line and THIRD_PARTY_NOTICES.md's row
names it. A clone is logged the first four times and at each power of two ("submits the recording ... while another
thread's submission of it is replayed"). No timing change and no wait: the clone path runs only when two threads have
one copy in flight at once.

Not reproduced before the change; nobody knows yet whether the game ever submits one copy from two threads at once (the
new line will say). Checks that passed: the new tests/submit_clone_check (four threads submitting two copies 200,000
times each while their arrays change: no replay saw its handles change, both paths ran, the counts ended at 0; the
unguarded 0.10.1 shape through the same harness: about 450,000 of 800,000 replays saw them change; nvn_resolve_binds
step by step), copy_sweep_check (its stand-in prefix follows the new layout), pass_cc_check on every runtime file, no new
-Wall -Wextra warnings in nvn_api_cmd.c and nvn_api_core.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00

191 lines
9.6 KiB
C

// tests/submit_clone_check.py builds this: runtime/nvn_submit_guard.h, a synchronous submission's deferred binds when
// another guest thread may be replaying the same recording copy. Four threads submit two hand-built copies while a
// "game" thread rewrites the handle arrays their deferred binds read: a replay must see its copy's handles unchanged
// from its resolve to its end, the in-flight count must be back to 0, and both the in-place and the clone path must
// run. Then nvn_resolve_binds step by step. "old" resolves in place without the guard (0.10.1) and only prints what it
// sees, to show that the harness catches the race.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "platform/platform.h"
#include "nvn_submit_guard.h"
#define COPIES 2
#define BINDS 6
#define THREADS 4
#define ROUNDS 200000
static int fails;
#define CHECK(c, ...) do { if (!(c)) { printf("submit_clone_check: FAIL: " __VA_ARGS__); printf("\n"); fails++; } } while (0)
static int old_mode;
static uint64_t tables[COPIES][BINDS][32];
static uint8_t *copy[COPIES];
static size_t copy_bytes;
static uint32_t busy[COPIES];
static volatile int32_t stop;
static uint64_t torn, clones, in_place, reads, changed;
static uint8_t *bind_rec(uint8_t *q, uint32_t op, int first, int n, const uint64_t *src) {
*(op_hdr *)q = (op_hdr){op, (uint32_t)(8 + sizeof(p_bind_handles))};
p_bind_handles *h = (p_bind_handles *)(q + 8);
memset(h, 0, sizeof *h);
h->stage = 4, h->first = first, h->n = n, h->src = (uint64_t)(uintptr_t)src;
return q + 8 + sizeof(p_bind_handles);
}
static uint8_t *make_copy(int c) {
size_t sz = 8 + BINDS * (8 + sizeof(p_bind_handles)) + 8;
uint8_t *p = calloc(1, sz), *q = p;
*(op_hdr *)q = (op_hdr){OP_BEGIN, 8}, q += 8;
for (int b = 0; b < BINDS; b++) q = bind_rec(q, OP_BIND_TEX, b * 4, 32, tables[c][b]);
*(op_hdr *)q = (op_hdr){OP_END, 8};
copy_bytes = sz;
return p;
}
static void game(void *arg) {
(void)arg;
for (uint64_t gen = 1; !__atomic_load_n(&stop, __ATOMIC_ACQUIRE); gen++)
for (int c = 0; c < COPIES; c++)
for (int b = 0; b < BINDS; b++)
for (int i = 0; i < 32; i++) __atomic_store_n(&tables[c][b][i], gen, __ATOMIC_RELAXED);
}
static void snap(const uint8_t *p, uint64_t out[BINDS][32]) {
const uint8_t *q = p + 8;
for (int b = 0; b < BINDS; b++, q += 8 + sizeof(p_bind_handles))
for (int i = 0; i < 32; i++)
out[b][i] = __atomic_load_n(&((const p_bind_handles *)(q + 8))->h[i], __ATOMIC_RELAXED);
}
// nvn_api_cmd.c nvn_submit_prepare, the replay, nvn_submit_done
static void submitter(void *arg) {
uint64_t r = (uint64_t)(uintptr_t)arg * 0x2545F4914F6CDD1Dull + 7;
static uint64_t a[THREADS][BINDS][32], b2[THREADS][BINDS][32];
int me = (int)(uintptr_t)arg - 1;
for (int k = 0; k < ROUNDS; k++) {
r ^= r << 13, r ^= r >> 7, r ^= r << 17;
int c = (int)(r % COPIES);
uint8_t *use = copy[c], *clone = NULL;
int alone = old_mode ? 1 : nvn_copy_enter(&busy[c]);
if (!alone) {
clone = malloc(copy_bytes);
memcpy(clone, copy[c], copy_bytes);
nvn_copy_leave(&busy[c]);
use = clone;
__atomic_add_fetch(&clones, 1, __ATOMIC_RELAXED);
} else {
__atomic_add_fetch(&in_place, 1, __ATOMIC_RELAXED);
}
const uint8_t *bad = NULL;
nvn_resolve_binds(use, 0, &reads, &changed, NULL, &bad);
snap(use, a[me]); // the replay reads the binds at its start ...
for (volatile int s = 0; s < 300; s++) {}
snap(use, b2[me]); // ... and at its end (or a held continuation copies them)
if (memcmp(a[me], b2[me], sizeof a[me])) __atomic_add_fetch(&torn, 1, __ATOMIC_RELAXED);
if (clone) free(clone);
else if (!old_mode) nvn_copy_leave(&busy[c]);
}
}
static void stress(void) {
for (int c = 0; c < COPIES; c++) copy[c] = make_copy(c);
PlatThread *g = plat_thread_new(game, NULL, "game"), *t[THREADS];
for (int i = 0; i < THREADS; i++) t[i] = plat_thread_new(submitter, (void *)(uintptr_t)(i + 1), "submit");
for (int i = 0; i < THREADS; i++) plat_thread_join(t[i]);
__atomic_store_n(&stop, 1, __ATOMIC_RELEASE);
plat_thread_join(g);
printf("submit_clone_check: %s: %d submissions, %llu in place, %llu clones; %llu deferred binds read, %llu changed; "
"%llu replays saw their handles change under them\n", old_mode ? "old (prints only)" : "new",
THREADS * ROUNDS, (unsigned long long)in_place, (unsigned long long)clones, (unsigned long long)reads,
(unsigned long long)changed, (unsigned long long)torn);
if (old_mode) return;
for (int c = 0; c < COPIES; c++) CHECK(!busy[c], "copy %d left with %u submissions in flight", c, busy[c]);
CHECK(!torn, "%llu replays saw their handles change under them", (unsigned long long)torn);
CHECK(in_place && clones, "a path never ran (%llu in place, %llu clones)", (unsigned long long)in_place,
(unsigned long long)clones);
}
// ---- nvn_resolve_binds, one thread
static uint64_t noted, noted_nth[8];
static void note(const p_bind_handles *b, const uint64_t *now, uint64_t nth) {
if (noted < 8) noted_nth[noted] = nth;
noted++;
if (b->h[0] == now[0]) printf("submit_clone_check: FAIL: a note came after the handles were written\n"), fails++;
}
static void steps(void) {
static uint64_t arr[4][32], called_arr[32];
static uint8_t buf[2048], callee[512], chain[18][16];
for (int i = 0; i < 32; i++) arr[0][i] = 100 + i, arr[1][i] = 200 + i, called_arr[i] = 300 + i;
uint8_t *q = buf;
*(op_hdr *)q = (op_hdr){OP_BEGIN, 8}, q += 8;
uint8_t *changed_rec = q;
q = bind_rec(q, OP_BIND_TEX, 0, 4, arr[0]); // its handles differ from the game's: read and written
uint8_t *same_rec = q;
q = bind_rec(q, OP_BIND_SAMP, 0, 4, arr[1]); // made equal below: read, not counted as changed
uint8_t *nosrc = q;
q = bind_rec(q, OP_BIND_IMG, 0, 4, NULL); // immediate: untouched
uint8_t *zero = q;
q = bind_rec(q, OP_BIND_TEX, 0, 0, arr[0]); // n 0: untouched
uint8_t *big = q;
q = bind_rec(q, OP_BIND_TEX, 0, 33, arr[0]); // n over 32: untouched
*(op_hdr *)q = (op_hdr){OP_CALL, 16}, memcpy(q + 8, &(uint64_t){(uint64_t)(uintptr_t)callee}, 8), q += 16;
*(op_hdr *)q = (op_hdr){OP_END, 8};
uint8_t *c = callee;
*(op_hdr *)c = (op_hdr){OP_BEGIN, 8}, c += 8;
uint8_t *called_rec = c;
c = bind_rec(c, OP_BIND_TEX, 8, 2, called_arr); // reached through the call
*(op_hdr *)c = (op_hdr){OP_END, 8};
memcpy(((p_bind_handles *)(same_rec + 8))->h, arr[1], 4 * 8);
for (int i = 0; i < 32; i++) ((p_bind_handles *)(nosrc + 8))->h[i] = 7, ((p_bind_handles *)(zero + 8))->h[i] = 7;
((p_bind_handles *)(big + 8))->h[0] = 7;
uint64_t rd = 0, ch = 5;
const uint8_t *bad = NULL;
int nd = nvn_resolve_binds(buf, 0, &rd, &ch, note, &bad);
const p_bind_handles *bc = (const p_bind_handles *)(changed_rec + 8), *bs = (const p_bind_handles *)(same_rec + 8);
const p_bind_handles *bn = (const p_bind_handles *)(nosrc + 8), *bz = (const p_bind_handles *)(zero + 8);
const p_bind_handles *bb = (const p_bind_handles *)(big + 8), *bk = (const p_bind_handles *)(called_rec + 8);
CHECK(nd == 3 && rd == 3, "%d deferred binds, %llu read; 3 and 3 wanted", nd, (unsigned long long)rd);
CHECK(!memcmp(bc->h, arr[0], 4 * 8) && bc->h[4] == 0, "a changed array was not copied in, or more than n");
CHECK(!memcmp(bs->h, arr[1], 4 * 8), "an unchanged array was changed");
CHECK(bn->h[0] == 7 && bz->h[0] == 7 && bb->h[0] == 7, "a bind without src, with n 0 or n over 32 was written");
CHECK(bk->h[0] == 300 && bk->h[1] == 301 && bk->h[2] == 0, "the bind behind OP_CALL was not resolved");
CHECK(ch == 7 && noted == 2 && noted_nth[0] == 5 && noted_nth[1] == 6, "changed %llu, noted %llu (%llu, %llu): two "
"changes counted from 5 wanted", (unsigned long long)ch, (unsigned long long)noted,
(unsigned long long)noted_nth[0], (unsigned long long)noted_nth[1]);
rd = 0, noted = 0;
nd = nvn_resolve_binds(buf, 0, &rd, NULL, note, &bad);
CHECK(nd == 3 && rd == 3 && noted == 0, "a second resolve with nothing changed noted %llu", (unsigned long long)noted);
// 17 calls deep: the 17th call stops it
for (int i = 0; i < 17; i++) {
*(op_hdr *)chain[i] = (op_hdr){OP_CALL, 16};
memcpy(chain[i] + 8, &(uint64_t){(uint64_t)(uintptr_t)chain[i + 1]}, 8);
}
*(op_hdr *)chain[17] = (op_hdr){OP_END, 8};
bad = NULL;
CHECK(nvn_resolve_binds(chain[0], 0, NULL, NULL, NULL, &bad) == -1 && bad == chain[16],
"a call 17 deep was followed (bad %p, chain[16] %p)", (const void *)bad, (const void *)chain[16]);
*(op_hdr *)chain[16] = (op_hdr){OP_END, 8};
CHECK(nvn_resolve_binds(chain[0], 0, NULL, NULL, NULL, &bad) == 0, "calls 16 deep were refused");
uint8_t corrupt[32] = {0};
*(op_hdr *)corrupt = (op_hdr){OP_BEGIN, 8};
*(op_hdr *)(corrupt + 8) = (op_hdr){OP_BIND_TEX, 12}; // a size not a multiple of 8
bad = NULL;
CHECK(nvn_resolve_binds(corrupt, 0, NULL, NULL, NULL, &bad) == -1 && bad == corrupt + 8,
"a corrupt record was not reported where it is");
*(op_hdr *)(corrupt + 8) = (op_hdr){OP_COUNT_, 8};
CHECK(nvn_resolve_binds(corrupt, 0, NULL, NULL, NULL, &bad) == -1 && bad == corrupt + 8, "an unknown op passed");
}
int main(int argc, char **argv) {
old_mode = argc > 1 && !strcmp(argv[1], "old");
if (!old_mode) steps();
stress();
if (old_mode) return 0;
printf("submit_clone_check: %s\n", fails ? "FAILED" : "passed");
return fails ? 1 : 0;
}