From 6bdbc27c1c3a0d5226fc91a15710a79d58f16173 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 01:45:14 +0200 Subject: [PATCH] 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 --- deployment/idunn/recipe.toml | 9 +++++++++ scripts/vili-daemon.mjs | 29 +++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index 9fed140..6dfafef 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -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" diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index ba781f6..908ca98 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -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 // _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) {