Replace all sync functions with async to allow more parallel api calls

This commit is contained in:
Jaret Burkett 2026-07-01 17:12:43 -06:00
parent 48781f900b
commit f63221e577
18 changed files with 123 additions and 85 deletions

View File

@ -46,15 +46,18 @@ export async function POST(request: NextRequest) {
return new NextResponse('Access denied', { status: 403 });
}
// Check if file exists
if (!fs.existsSync(captionPath)) {
// send back blank string if caption file does not exist
return new NextResponse('');
// Read caption file; a missing file just means no caption yet
let caption: string;
try {
caption = await fs.promises.readFile(captionPath, 'utf-8');
} catch (err: any) {
if (err?.code === 'ENOENT') {
// send back blank string if caption file does not exist
return new NextResponse('');
}
throw err;
}
// Read caption file
const caption = fs.readFileSync(captionPath, 'utf-8');
// Return caption
return new NextResponse(caption);
} catch (error) {

View File

@ -30,17 +30,21 @@ export async function POST(request: NextRequest) {
const allowedDir = await getDatasetsRoot();
const captions: Record<string, string> = {};
for (const imgPath of imgPaths) {
if (typeof imgPath !== 'string') continue;
if (!isUnderRoot(imgPath, allowedDir)) continue;
// Read every caption file concurrently instead of blocking on each one in turn.
await Promise.all(
imgPaths.map(async imgPath => {
if (typeof imgPath !== 'string') return;
if (!isUnderRoot(imgPath, allowedDir)) return;
const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.' + captionExt;
try {
captions[imgPath] = fs.existsSync(captionPath) ? fs.readFileSync(captionPath, 'utf-8') : '';
} catch {
captions[imgPath] = '';
}
}
const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.' + captionExt;
try {
// Missing file (ENOENT) or any read error falls back to an empty caption.
captions[imgPath] = await fs.promises.readFile(captionPath, 'utf-8');
} catch {
captions[imgPath] = '';
}
}),
);
return NextResponse.json({ captions });
}

View File

@ -7,13 +7,14 @@ export async function GET() {
let datasetsPath = await getDatasetsRoot();
// if folder doesnt exist, create it
if (!fs.existsSync(datasetsPath)) {
fs.mkdirSync(datasetsPath);
try {
await fs.promises.access(datasetsPath);
} catch {
await fs.promises.mkdir(datasetsPath);
}
// find all the folders in the datasets folder
let folders = fs
.readdirSync(datasetsPath, { withFileTypes: true })
let folders = (await fs.promises.readdir(datasetsPath, { withFileTypes: true }))
.filter(dirent => dirent.isDirectory())
.filter(dirent => !dirent.name.startsWith('.'))
.map(dirent => dirent.name);

View File

@ -16,12 +16,14 @@ export async function POST(request: Request) {
try {
// Check if folder exists
if (!fs.existsSync(datasetFolder)) {
try {
await fs.promises.access(datasetFolder);
} catch {
return NextResponse.json({ error: `Folder '${datasetName}' not found` }, { status: 404 });
}
// Find all images recursively
const imageFiles = findImagesRecursively(datasetFolder);
const imageFiles = await findImagesRecursively(datasetFolder);
// Sort server-side so the client doesn't have to sort large lists
imageFiles.sort((a, b) => a.localeCompare(b));
@ -64,13 +66,15 @@ export async function POST(request: Request) {
* @param dir Directory to search
* @returns Array of absolute paths to image files
*/
function findImagesRecursively(dir: string): string[] {
async function findImagesRecursively(dir: string): Promise<string[]> {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv', '.mp3', '.wav', '.flac', '.ogg'];
let results: string[] = [];
// withFileTypes avoids a separate statSync per entry — a big win on large datasets
const entries = fs.readdirSync(dir, { withFileTypes: true });
// withFileTypes avoids a separate stat per entry — a big win on large datasets.
// Async readdir yields between directories so other requests aren't blocked.
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
const subdirs: string[] = [];
for (const entry of entries) {
const name = entry.name;
if (name.startsWith('.')) continue;
@ -78,7 +82,7 @@ function findImagesRecursively(dir: string): string[] {
if (entry.isDirectory()) {
if (name === '_controls') continue;
results = results.concat(findImagesRecursively(itemPath));
subdirs.push(itemPath);
} else if (entry.isFile()) {
const ext = path.extname(name).toLowerCase();
if (imageExtensions.includes(ext)) {
@ -87,5 +91,11 @@ function findImagesRecursively(dir: string): string[] {
}
}
// Recurse into subdirectories concurrently.
const nested = await Promise.all(subdirs.map(subdir => findImagesRecursively(subdir)));
for (const list of nested) {
results = results.concat(list);
}
return results;
}

View File

@ -28,14 +28,14 @@ export async function GET(request: NextRequest, { params }: { params: { filePath
return new NextResponse('Access denied', { status: 403 });
}
// Check if file exists
if (!fs.existsSync(resolvedFilePath)) {
// Check it exists and grab file info in one stat
let stat;
try {
stat = await fs.promises.stat(resolvedFilePath);
} catch {
console.warn(`File not found: ${resolvedFilePath}`);
return new NextResponse('File not found', { status: 404 });
}
// Get file info
const stat = fs.statSync(resolvedFilePath);
if (!stat.isFile()) {
return new NextResponse('Not a file', { status: 400 });
}

View File

@ -34,19 +34,19 @@ export async function POST(request: NextRequest) {
return new NextResponse('Access denied', { status: 403 });
}
// Check if file exists
if (!fs.existsSync(resolvedFilePath)) {
// Check if file exists and grab file info in one stat
let stat;
try {
stat = await fs.promises.stat(resolvedFilePath);
} catch {
console.warn(`File not found: ${resolvedFilePath}`);
return new NextResponse('File not found', { status: 404 });
}
// Get file info
const stat = fs.statSync(resolvedFilePath);
if (!stat.isFile()) {
return new NextResponse('Not a file', { status: 400 });
}
fs.unlinkSync(resolvedFilePath);
await fs.promises.unlink(resolvedFilePath);
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { exec, execSync } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
import { createRequire } from 'module';
import os from 'os';
@ -23,7 +23,7 @@ async function getMacGpuInfo(): Promise<MacGpuResult | null> {
// Get GPU name and core count from system_profiler
let gpuName = 'Apple GPU';
try {
const spOut = execSync(
const { stdout: spOut } = await execAsync(
'system_profiler SPDisplaysDataType 2>/dev/null | grep -E "Chipset Model|Total Number of Cores"',
{ encoding: 'utf-8', timeout: 5000 },
);

View File

@ -13,7 +13,9 @@ export async function POST(request: Request) {
}
// if img doesnt exist, ignore
if (!fs.existsSync(imgPath)) {
try {
await fs.promises.access(imgPath);
} catch {
return NextResponse.json({ error: 'Image does not exist' }, { status: 404 });
}
@ -21,7 +23,7 @@ export async function POST(request: Request) {
const captionExt = ((ext || 'txt') as string).replace(/^\.+/, '').trim() || 'txt';
const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.' + captionExt;
// save caption to file
fs.writeFileSync(captionPath, caption);
await fs.promises.writeFile(captionPath, caption);
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import fs from 'fs';
import { getDatasetsRoot, getTrainingFolder } from '@/server/settings';
const fileExists = (p: string) => fs.promises.access(p).then(() => true).catch(() => false);
export async function POST(request: Request) {
try {
const body = await request.json();
@ -20,18 +22,18 @@ export async function POST(request: Request) {
}
// if img doesnt exist, ignore
if (!fs.existsSync(imgPath)) {
if (!(await fileExists(imgPath))) {
return NextResponse.json({ success: true });
}
// delete it and return success
fs.unlinkSync(imgPath);
await fs.promises.unlink(imgPath);
// check for caption
const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.txt';
if (fs.existsSync(captionPath)) {
if (await fileExists(captionPath)) {
// delete caption file
fs.unlinkSync(captionPath);
await fs.promises.unlink(captionPath);
}
return NextResponse.json({ success: true });

View File

@ -20,9 +20,8 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const trainingRoot = await getTrainingFolder();
const trainingFolder = path.join(trainingRoot, job.name);
if (fs.existsSync(trainingFolder)) {
fs.rmSync(trainingFolder, { recursive: true, force: true });
}
// force:true makes this a no-op if the folder is already gone
await fs.promises.rm(trainingFolder, { recursive: true, force: true });
await prisma.job.delete({
where: { id: jobID },

View File

@ -20,13 +20,14 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const trainingFolder = await getTrainingFolder();
const jobFolder = path.join(trainingFolder, job.name);
if (!fs.existsSync(jobFolder)) {
try {
await fs.promises.access(jobFolder);
} catch {
return NextResponse.json({ files: [] });
}
// find all safetensors files in the job folder
let files = fs
.readdirSync(jobFolder)
let files = (await fs.promises.readdir(jobFolder))
.filter(file => {
return file.endsWith('.safetensors');
})
@ -35,23 +36,27 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
})
.sort();
// get the file size for each file
const fileObjects = files.map(file => {
const stats = fs.statSync(file);
return {
path: file,
size: stats.size,
};
});
// get the file size for each file (stat all in parallel)
const fileObjects = await Promise.all(
files.map(async file => {
const stats = await fs.promises.stat(file);
return {
path: file,
size: stats.size,
};
}),
);
// include the optimizer state if it exists
const optimizerPath = path.join(jobFolder, 'optimizer.pt');
if (fs.existsSync(optimizerPath)) {
const stats = fs.statSync(optimizerPath);
try {
const stats = await fs.promises.stat(optimizerPath);
fileObjects.push({
path: optimizerPath,
size: stats.size,
});
} catch {
// no optimizer state present, skip it
}
return NextResponse.json({ files: fileObjects });

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import path from 'path';
import fs from 'fs';
import type { FileHandle } from 'fs/promises';
import { getTrainingFolder } from '@/server/settings';
const prisma = new PrismaClient();
@ -21,7 +22,9 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const jobFolder = path.join(trainingFolder, job.name);
const logPath = path.join(jobFolder, 'log.txt');
if (!fs.existsSync(logPath)) {
try {
await fs.promises.access(logPath);
} catch {
return NextResponse.json({ log: '', offset: 0, reset: true });
}
@ -31,28 +34,28 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const offsetParam = request.nextUrl.searchParams.get('offset');
const offset = offsetParam === null ? NaN : parseInt(offsetParam, 10);
const readRange = (fd: number, start: number, end: number): string => {
const readRange = async (fh: FileHandle, start: number, end: number): Promise<string> => {
const length = end - start;
if (length <= 0) return '';
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, start);
await fh.read(buffer, 0, length, start);
return buffer.toString('utf-8');
};
try {
const stats = fs.statSync(logPath);
const stats = await fs.promises.stat(logPath);
const size = stats.size;
// If the client's offset is past the current end, the log was reset/truncated
// (e.g. a fresh run overwrote it) — fall back to a fresh tail load.
const isReset = Number.isNaN(offset) || offset > size;
const fd = fs.openSync(logPath, 'r');
const fh = await fs.promises.open(logPath, 'r');
try {
if (isReset) {
// Read only the tail of the file to avoid loading huge logs into memory.
// Assume an average line length so we grab enough bytes to cover MAX_LINES.
const start = Math.max(0, size - MAX_LINES * 512);
let log = readRange(fd, start, size);
let log = await readRange(fh, start, size);
// Drop a partial first line if we started mid-file.
if (start > 0) {
const newlineIdx = log.indexOf('\n');
@ -67,10 +70,10 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
return NextResponse.json({ log, offset: size, reset: true });
}
// Incremental: return only the bytes appended since the last offset.
const log = readRange(fd, offset, size);
const log = await readRange(fh, offset, size);
return NextResponse.json({ log, offset: size, reset: false });
} finally {
fs.closeSync(fd);
await fh.close();
}
} catch (error) {
console.error('Error reading log file:', error);

View File

@ -42,7 +42,9 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const jobFolder = path.join(trainingFolder, job.name);
const logPath = path.join(jobFolder, 'loss_log.db');
if (!fs.existsSync(logPath)) {
try {
await fs.promises.access(logPath);
} catch {
return NextResponse.json({ keys: [], key: 'loss', points: [] });
}

View File

@ -21,7 +21,9 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const jobFolder = path.join(trainingFolder, job.name);
const pluginPath = path.join(jobFolder, 'plugin.html');
if (!fs.existsSync(pluginPath)) {
try {
await fs.promises.access(pluginPath);
} catch {
return NextResponse.json({ exists: false, html: null });
}
@ -33,7 +35,7 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
// serve the raw html so it can be loaded directly as an iframe src
let html = '';
try {
html = fs.readFileSync(pluginPath, 'utf-8');
html = await fs.promises.readFile(pluginPath, 'utf-8');
} catch (error) {
console.error('Error reading plugin file:', error);
return NextResponse.json({ error: 'Error reading plugin file' }, { status: 500 });

View File

@ -21,13 +21,14 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
const trainingFolder = await getTrainingFolder();
const samplesFolder = path.join(trainingFolder, job.name, 'samples');
if (!fs.existsSync(samplesFolder)) {
try {
await fs.promises.access(samplesFolder);
} catch {
return NextResponse.json({ samples: [] });
}
// find all img (png, jpg, jpeg) files in the samples folder
const samples = fs
.readdirSync(samplesFolder)
const samples = (await fs.promises.readdir(samplesFolder))
.filter(file => {
return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4') || file.endsWith('mp3') || file.endsWith('wav') || file.endsWith('flac') || file.endsWith('ogg');
})

View File

@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const prisma = new PrismaClient();
const isWindows = process.platform === 'win32';
@ -30,8 +33,7 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
if (isWindows) {
// Windows doesn't support SIGINT for arbitrary processes.
// Use taskkill with /T (tree) to send a CTRL+C-like termination.
const { execSync } = require('child_process');
execSync(`taskkill /PID ${job.pid} /T /F`, { stdio: 'ignore' });
await execAsync(`taskkill /PID ${job.pid} /T /F`, { windowsHide: true });
} else {
process.kill(job.pid, 'SIGINT');
}

View File

@ -15,7 +15,7 @@ const UI_SCRIPTS_ROOT = path.join(TOOLKIT_ROOT, 'ui_scripts');
// Only allow flat script names (no path separators, no traversal).
const SCRIPT_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.py$/;
const resolveScriptPath = (rawName: unknown): string | null => {
const resolveScriptPath = async (rawName: unknown): Promise<string | null> => {
if (typeof rawName !== 'string') return null;
const name = rawName.trim();
if (!SCRIPT_NAME_RE.test(name)) return null;
@ -23,7 +23,11 @@ const resolveScriptPath = (rawName: unknown): string | null => {
const target = path.resolve(UI_SCRIPTS_ROOT, name);
const rootWithSep = UI_SCRIPTS_ROOT.endsWith(path.sep) ? UI_SCRIPTS_ROOT : UI_SCRIPTS_ROOT + path.sep;
if (!target.startsWith(rootWithSep)) return null;
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null;
try {
if (!(await fs.promises.stat(target)).isFile()) return null;
} catch {
return null;
}
return target;
};
@ -225,7 +229,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const scriptPath = resolveScriptPath(body?.script);
const scriptPath = await resolveScriptPath(body?.script);
if (!scriptPath) {
return NextResponse.json(
{ error: 'Invalid or unknown script. Must be a *.py file inside ui_scripts/.' },

View File

@ -41,10 +41,8 @@ export async function POST(request: NextRequest) {
return new NextResponse('Not a directory', { status: 400 });
}
// delete current one if it exists
if (fs.existsSync(outputPath)) {
await fsp.unlink(outputPath);
}
// delete current one if it exists (force:true => no error if missing)
await fsp.rm(outputPath, { force: true });
// Create write stream & archive
await new Promise<void>((resolve, reject) => {