Fix: a listed BVH is opened non-blocking, and the work directory is a declared state slot swept at start (Soul s2 readinside-opens-before-checking, work-dir-undeclared-unswept)

readInside opens O_NONBLOCK|O_NOFOLLOW and checks the open descriptor before reading, so a FIFO is refused at once and holds no filesystem thread. work/ is a slot in the recipe; a daemon that holds the state root creates it and removes stale export-XXXXXX directories from it, nothing else, before it serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-25 01:45:14 +02:00
commit 6bdbc27c1c
2 changed files with 32 additions and 6 deletions

View file

@ -369,6 +369,15 @@ writer = "process-bound-single-writer"
recovery = "preserve"
startup = "create-or-open-after-write-lease"
[[state.slots]]
id = "work"
relative_path = "work"
kind = "external-directory"
schema = "gamecult.vili.export_scratch.v0"
writer = "process-bound-single-writer"
recovery = "rebuildable"
startup = "create-or-open-after-write-lease"
[[state.slots]]
id = "logs"
relative_path = "logs"

View file

@ -3,7 +3,7 @@ import http from "node:http";
import os from "node:os";
import { createHash, randomBytes, randomInt } from "node:crypto";
import { closeSync, constants, lstatSync, openSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
import { copyFile, mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, mkdtemp, open, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import net from "node:net";
@ -495,6 +495,11 @@ function regularFileInside(file, directory) {
}
const exportScript = path.join(__dirname, "bvh-to-fbx.py");
// The state slot "work" (deployment/idunn/recipe.toml): one private directory per export, removed by the export itself
// and, if the daemon died mid-export, swept by the next daemon that holds the state root (sweepWork).
const WORK_DIR = "work";
const EXPORT_PREFIX = "export-";
const EXPORT_NAME = new RegExp(`^${EXPORT_PREFIX}[A-Za-z0-9]{6}$`);
const EXPORT_SETTINGS = "standard-tpose soma77 metres hashseed0 time1970";
const EXPORT_TIMEOUT_MS = 120000;
let exportScriptDigest = null;
@ -565,11 +570,12 @@ async function probeBlender() {
}
// The bytes of a regular file strictly inside `directory`, or null. The file is opened without following a symlink
// and the open file is the one checked, so a path swapped after the check cannot redirect the read.
// and without blocking (a FIFO or a device would otherwise hold the open, and a filesystem thread, indefinitely), and
// the open file is the one checked, so a path swapped after the check cannot redirect the read.
async function readInside(file, directory) {
let handle = null;
try {
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const opened = await handle.stat();
if (!opened.isFile() || !regularFileInside(file, directory)) return null;
const named = lstatSync(file);
@ -591,9 +597,7 @@ async function exportClip(bvh, fbx, clipName, timeoutMs, artifactDir) {
const input = await readInside(bvh, artifactDir);
if (input === null) return { error: "fbx-export-refused" };
guard();
await mkdir(path.join(args.stateRoot, "work"), { recursive: true });
guard();
const work = await mkdtemp(path.join(args.stateRoot, "work", "export-"));
const work = await mkdtemp(path.join(args.stateRoot, WORK_DIR, EXPORT_PREFIX));
try {
const privateBvh = path.join(work, "in.bvh");
const privateFbx = path.join(work, "out.fbx");
@ -631,6 +635,17 @@ async function exportClip(bvh, fbx, clipName, timeoutMs, artifactDir) {
}
}
// The export directories a killed daemon left: only directories named as mkdtemp names them, only under work/. Called
// once, after the runtime holds the state root, so a live daemon's export is never swept.
async function sweepWork() {
const root = path.join(args.stateRoot, WORK_DIR);
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory() || !EXPORT_NAME.test(entry.name)) continue;
guard();
await rm(path.join(root, entry.name), { recursive: true, force: true });
}
}
// Every BVH the worker listed becomes an FBX, or the job fails. A listed BVH must be a regular file inside the job's
// artifact directory, and that is decided when its export begins (exportClip), never earlier. Sample N of several is
// <clipName>_NN.
@ -1184,6 +1199,8 @@ export async function serve(rt) {
try {
guard();
await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true });
await mkdir(path.join(args.stateRoot, WORK_DIR), { recursive: true });
await sweepWork();
await persistSurfaces();
await probeBlender();
} catch (error) {