Write the flight dump and the memory map through a temporary file and a rename.

The window's close ends the process under the detached threads, so a dump file the flight recorder's thread was
writing stayed cut short under its own name. Its three files are now written as .part and renamed when complete
(runtime/part_file.h, with a check that kills a writer in the middle), and the allocator's map goes through
plat_file_write_cached. The shader identities and the other cache files already went through an atomic write.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:14:32 -05:00
parent cc0e2c4bf4
commit c5cb67645e
6 changed files with 237 additions and 10 deletions

View File

@@ -501,6 +501,14 @@ A change to what a host thread gives back when it ends (`runtime/platform/win32.
sleep, read a file and end, half joined and half detached; the end hook runs once on each, and on Windows the process
holds as many handles afterwards as before (each thread's sleep timer and read event are closed). No game files.
A file a detached thread writes piece by piece (the flight recorder's dump, `runtime/flight.c` `dump_main`) goes
through `runtime/part_file.h`: it is written as `<name>.part` and renamed when complete, because the window's close
ends the process under such a thread and a file cut short there would read as a whole one. A file made in memory
first uses `plat_file_write_cached` or `plat_file_write_atomic`, which do the same in one call. A change to the
helper passes `python tests\part_file_check.py`, which builds it with the platform layer: nothing stands under the
name while a file is written, an older file stays until the new one is complete and after a write that was given up,
and a writer killed in the middle leaves the older file whole and the half as `.part`. No game files.
A change to the log queue (`runtime/logq.c`) passes `python tests\logq_check.py`, which builds it with the platform
layer and a destination of its own: 160,000 lines from eight threads arrive whole and in each thread's order, a line
logged while the queue's lock is taken waits and does not pass the lines queued before it, a destination that takes

View File

@@ -20,6 +20,7 @@
#include "gfx.h"
#include "host.h"
#include "input.h"
#include "part_file.h"
#include "vk.h"
#include "vk_exec.h"
#include "platform/platform.h"
@@ -200,11 +201,12 @@ static void copy_events(DumpJob *j, uint64_t first) {
static const char *const tex_kind_names[FLIGHT_TEX_N] = {"new", "up", "reup", "release", "takeback", "drop", "bytes",
"reg", "blank", "toblank", "blanksub", "direct", "unreg"};
// <dir>/events.tsv: one line per texture event of the dump's frames (flight_tex).
// <dir>/events.tsv: one line per texture event of the dump's frames (flight_tex). Like the dump's other files it is
// written as a part file and renamed when complete (part_file.h): the game may close under the dump thread.
static void write_events(const DumpJob *j) {
char path[PLAT_PATH_MAX + 32];
snprintf(path, sizeof path, "%s/events.tsv", j->dir);
FILE *f = fopen(path, "w");
FILE *f = part_file_open(path, "w");
if (!f) {
rt_log("flight: cannot write %s", path);
return;
@@ -253,7 +255,7 @@ static void write_events(const DumpJob *j) {
e->kind < FLIGHT_TEX_N ? tex_kind_names[e->kind] : "?", (unsigned long long)e->addr, e->format, e->w,
e->h, e->layers, fl, detail, e->us);
}
fclose(f);
if (!part_file_close(f, path, 1)) rt_log("flight: cannot write %s", path);
}
// ---------------------------------------------------------------- PNG (RGB, Paeth rows, fixed-Huffman deflate)
@@ -369,7 +371,7 @@ static int write_png(const char *path, const uint8_t *rgba, PngBufs *pb) {
}
}
size_t zn = deflate_fixed(pb->raw, (size_t)row * h, pb->z, pb->head);
FILE *f = fopen(path, "wb");
FILE *f = part_file_open(path, "wb");
if (!f) return 0;
static const uint8_t sig[8] = {0x89, 'P', 'N', 'G', 13, 10, 26, 10};
uint8_t ihdr[13] = {0, 0, (uint8_t)(w >> 8), (uint8_t)w, 0, 0, (uint8_t)(h >> 8), (uint8_t)h, 8, 2, 0, 0, 0};
@@ -377,7 +379,7 @@ static int write_png(const char *path, const uint8_t *rgba, PngBufs *pb) {
chunk(f, "IHDR", ihdr, 13);
chunk(f, "IDAT", pb->z, (uint32_t)zn);
chunk(f, "IEND", NULL, 0);
return fclose(f) == 0;
return part_file_close(f, path, 1);
}
// ---------------------------------------------------------------- dump thread
@@ -419,7 +421,7 @@ static void dump_main(void *arg) {
}
char path[PLAT_PATH_MAX + 32];
snprintf(path, sizeof path, "%s/frames.tsv", j->dir);
FILE *f = fopen(path, "w");
FILE *f = part_file_open(path, "w");
if (!f) {
rt_log("flight: cannot write %s", path);
free(have), free(j->ev), free(j);
@@ -439,7 +441,7 @@ static void dump_main(void *arg) {
fprintf(f, "\t%.4f\t%.4f\t%llu", r->step_page, r->step_game, (unsigned long long)r->guest_presents);
fprintf(f, "\t%d\n", have ? have[i] : 0);
}
fclose(f);
if (!part_file_close(f, path, 1)) rt_log("flight: cannot write %s", path);
rt_log("flight: dump %s: %s at frame %llu (%.1f ms), frames %llu-%llu, %d thumbnails, %zu texture events, written "
"in %llu ms; recorder %.1f us per frame (max %.0f us)", j->dir,
j->why, (unsigned long long)j->trig_frame, j->trig_interval_us / 1000.0, (unsigned long long)j->r[0].frame,

41
runtime/part_file.h Normal file
View File

@@ -0,0 +1,41 @@
// A file written piece by piece on a thread nobody waits for when the process ends.
//
// The window's close ends the process with plat_exit while the detached threads still run (gfx_present.c
// gfx_window_closed), and a kill or a crash ends it anywhere. A file such a thread was in the middle of then stays
// cut short under its own name, and whoever opens it later takes it for a whole one. So the file is written as
// <path>.part and gets its name with one rename once it is complete: a reader finds the whole file or none, and a
// .part file says what it is. A file made in memory first goes through plat_file_write_cached or
// plat_file_write_atomic instead, which do the same in one call.
#pragma once
#include <stdio.h>
#include "platform/platform.h"
#define PART_FILE_SUFFIX ".part"
// <path>.part into out; 0 when it does not fit.
static inline int part_file_name(const char *path, char *out, size_t cap) {
int n = snprintf(out, cap, "%s" PART_FILE_SUFFIX, path);
return n > 0 && (size_t)n < cap;
}
// Opens <path>.part for writing (mode: "w" or "wb"). NULL when it cannot be made.
static inline FILE *part_file_open(const char *path, const char *mode) {
char part[PLAT_PATH_MAX + 64];
return part_file_name(path, part, sizeof part) ? fopen(part, mode) : NULL;
}
// Closes f, the file part_file_open(path) gave, and renames it to path, over an older file of that name. With ok 0,
// after a write error on f or when the close or the rename fails, the part is deleted and path stays as it was.
// 1 when path now holds the whole new file.
static inline int part_file_close(FILE *f, const char *path, int ok) {
char part[PLAT_PATH_MAX + 64];
if (!f) return 0;
ok = ok && !ferror(f);
ok = !fclose(f) && ok;
if (!part_file_name(path, part, sizeof part)) return 0;
if (ok && plat_file_replace(part, path, 0)) return 1;
plat_file_delete(part);
return 0;
}

View File

@@ -4497,9 +4497,9 @@ static void census_main(void *arg) {
snprintf(path, sizeof path, "%.*svma-frame%llu.json", sl ? (int)(sl - lp) + 1 : 0, lp, (unsigned long long)j.frame);
char *json = NULL;
vmaBuildStatsString(j.c->vma, &json, VK_TRUE);
FILE *f = json ? fopen(path, "wb") : NULL;
int ok = f && fputs(json, f) >= 0;
if (f && fclose(f)) ok = 0;
// Through a temporary file and a rename: nobody waits for this thread when the game closes, and a map cut short
// there would read as a whole one
int ok = json && plat_file_write_cached(path, json, strlen(json));
rt_log("mem: vma detailed map %s %s", ok ? "written to" : "not written:", path);
if (json) vmaFreeStatsString(j.c->vma, json);
}

100
tests/part_file_check.c Normal file
View File

@@ -0,0 +1,100 @@
// runtime/part_file.h: a file written piece by piece gets its name only when it is complete. Built with the platform
// layer and run by tests/part_file_check.py.
//
// part_file_check <folder> the checks below, in <folder>
// part_file_check <folder> hang writes half of <folder>/killed.tsv, prints "writing" and sleeps: the Python side
// kills it there, as a closed game ends the thread that writes a dump
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "part_file.h"
static int fails;
#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL line %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0)
void rt_log(const char *fmt, ...) { // the platform layer's lines
va_list ap;
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fputc('\n', stderr);
}
// The text of the file at path ("" when it holds none), or NULL when there is no such file.
static char *text_of(const char *path) {
if (!plat_file_exists(path)) return NULL;
uint64_t size = 0;
char *t = (char *)plat_file_read(path, &size);
return t ? t : calloc(1, 1);
}
static int holds(const char *path, const char *want) {
char *t = text_of(path);
int same = t && !strcmp(t, want);
free(t);
return same;
}
int main(int argc, char **argv) {
if (argc < 2) return 2;
char path[PLAT_PATH_MAX], part[PLAT_PATH_MAX + 64];
if (argc > 2 && !strcmp(argv[2], "hang")) {
snprintf(path, sizeof path, "%s/killed.tsv", argv[1]);
FILE *f = part_file_open(path, "wb");
if (!f) return 3;
fputs("the first half of the new file\n", f);
fflush(f);
puts("writing");
fflush(stdout);
for (;;) plat_sleep_ms(1000);
}
// a new file: nothing under its name while it is written, the whole file after
snprintf(path, sizeof path, "%s/new.tsv", argv[1]);
CHECK(part_file_name(path, part, sizeof part), "the part name fits");
FILE *f = part_file_open(path, "wb");
CHECK(f != NULL, "the part file opens");
if (!f) return 1;
fputs("line 1\n", f);
fflush(f);
CHECK(!plat_file_exists(path), "no file under the name while it is written");
CHECK(holds(part, "line 1\n"), "what was written so far is in the part file");
fputs("line 2\n", f);
CHECK(part_file_close(f, path, 1) == 1, "the complete file gets its name");
CHECK(holds(path, "line 1\nline 2\n"), "the file holds everything written");
CHECK(!plat_file_exists(part), "no part file stays");
// over an older file: the old one stays whole until the new one is complete
f = part_file_open(path, "wb");
CHECK(f != NULL, "the part file opens beside the older file");
if (!f) return 1;
fputs("the new text\n", f);
fflush(f);
CHECK(holds(path, "line 1\nline 2\n"), "the older file is untouched while the new one is written");
CHECK(part_file_close(f, path, 1) == 1, "the new file replaces the older one");
CHECK(holds(path, "the new text\n"), "the name holds the new file");
// given up (the writer met an error): the older file stays, the part goes
f = part_file_open(path, "wb");
if (!f) return 1;
fputs("half of a third", f);
CHECK(part_file_close(f, path, 0) == 0, "a file given up is not put in place");
CHECK(holds(path, "the new text\n"), "the older file stays after a write that was given up");
CHECK(!plat_file_exists(part), "the part of a write that was given up is deleted");
// a folder that is not there: nothing opens, and closing nothing is no success
snprintf(path, sizeof path, "%s/no such folder/x.tsv", argv[1]);
f = part_file_open(path, "wb");
CHECK(f == NULL, "no part file in a folder that does not exist");
CHECK(part_file_close(f, path, 1) == 0, "closing a file that never opened reports failure");
// a name that does not fit the path buffers
static char longp[PLAT_PATH_MAX + 200];
memset(longp, 'a', sizeof longp - 1);
CHECK(part_file_open(longp, "wb") == NULL, "a path too long for its part name is refused");
printf(fails ? "part_file_check: %d FAILED\n" : "part_file_check: all checks passed\n", fails);
return fails != 0;
}

76
tests/part_file_check.py Normal file
View File

@@ -0,0 +1,76 @@
"""Build and run tests/part_file_check.c: runtime/part_file.h, which the runtime's detached threads write their files
through (the flight recorder's dump). A file gets its name only when it is complete, so a game that closes under the
writing thread leaves the older file or none, never one cut short.
The C side checks the helper itself. This side then kills a process in the middle of a write, as the window's close
ends the dump thread, and looks at what it left: the older file whole under its name, the half in the .part file.
No game files, no GPU.
Usage: part_file_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'
def main():
work = ROOT / 'build' / 'test' / 'part_file'
shutil.rmtree(work / 'files', ignore_errors=True)
(work / 'files').mkdir(parents=True)
win = os.name == 'nt'
exe = work / ('part_file_check.exe' if win else 'part_file_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),
str(ROOT / 'tests' / 'part_file_check.c'), *map(str, plat), '-o', str(exe), '-lm', *libs],
env=env)
files = work / 'files'
code = subprocess.run([str(exe), str(files)], env=env, timeout=120).returncode
if code:
return code
# a writer killed in the middle: the file of the dump before it is whole, the half is named as a part
killed = files / 'killed.tsv'
killed.write_text('the whole older file\n', encoding='utf-8')
p = subprocess.Popen([str(exe), str(files), 'hang'], env=env, stdout=subprocess.PIPE, text=True)
try:
said = p.stdout.readline().strip()
finally:
p.kill()
p.wait()
p.stdout.close()
fails = []
if said != 'writing':
fails.append(f'the writer did not start ({said!r})')
if killed.read_text(encoding='utf-8') != 'the whole older file\n':
fails.append('the older file changed although the new one was never complete')
part = killed.with_name('killed.tsv.part')
if not part.is_file() or part.read_text(encoding='utf-8') != 'the first half of the new file\n':
fails.append('the half that was written is not in killed.tsv.part')
for f in fails:
print('FAIL:', f)
print('part_file_check: the killed writer left %s' % ('a wrong file' if fails else 'the older file whole'))
return 1 if fails else 0
if __name__ == '__main__':
sys.exit(main())