From f63221e577053e86c2a673adec20e43d7b81988d Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Wed, 1 Jul 2026 17:12:43 -0600 Subject: [PATCH] Replace all sync functions with async to allow more parallel api calls --- ui/src/app/api/caption/get/route.ts | 17 ++++++----- ui/src/app/api/caption/getBatch/route.ts | 24 ++++++++------- ui/src/app/api/datasets/list/route.ts | 9 +++--- ui/src/app/api/datasets/listImages/route.ts | 22 ++++++++++---- ui/src/app/api/files/[...filePath]/route.ts | 10 +++---- ui/src/app/api/files/delete/route.ts | 12 ++++---- ui/src/app/api/gpu/route.ts | 4 +-- ui/src/app/api/img/caption/route.ts | 6 ++-- ui/src/app/api/img/delete/route.ts | 10 ++++--- ui/src/app/api/jobs/[jobID]/delete/route.ts | 5 ++-- ui/src/app/api/jobs/[jobID]/files/route.ts | 31 ++++++++++++-------- ui/src/app/api/jobs/[jobID]/log/route.ts | 19 +++++++----- ui/src/app/api/jobs/[jobID]/loss/route.ts | 4 ++- ui/src/app/api/jobs/[jobID]/plugin/route.ts | 6 ++-- ui/src/app/api/jobs/[jobID]/samples/route.ts | 7 +++-- ui/src/app/api/jobs/[jobID]/stop/route.ts | 6 ++-- ui/src/app/api/scripts/route.ts | 10 +++++-- ui/src/app/api/zip/route.ts | 6 ++-- 18 files changed, 123 insertions(+), 85 deletions(-) diff --git a/ui/src/app/api/caption/get/route.ts b/ui/src/app/api/caption/get/route.ts index 887e70fd..339f7048 100644 --- a/ui/src/app/api/caption/get/route.ts +++ b/ui/src/app/api/caption/get/route.ts @@ -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) { diff --git a/ui/src/app/api/caption/getBatch/route.ts b/ui/src/app/api/caption/getBatch/route.ts index 5968fbe1..8fbc7756 100644 --- a/ui/src/app/api/caption/getBatch/route.ts +++ b/ui/src/app/api/caption/getBatch/route.ts @@ -30,17 +30,21 @@ export async function POST(request: NextRequest) { const allowedDir = await getDatasetsRoot(); const captions: Record = {}; - 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 }); } diff --git a/ui/src/app/api/datasets/list/route.ts b/ui/src/app/api/datasets/list/route.ts index dc829c65..dd884eb7 100644 --- a/ui/src/app/api/datasets/list/route.ts +++ b/ui/src/app/api/datasets/list/route.ts @@ -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); diff --git a/ui/src/app/api/datasets/listImages/route.ts b/ui/src/app/api/datasets/listImages/route.ts index b1e70dba..61594f3e 100644 --- a/ui/src/app/api/datasets/listImages/route.ts +++ b/ui/src/app/api/datasets/listImages/route.ts @@ -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 { 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; } diff --git a/ui/src/app/api/files/[...filePath]/route.ts b/ui/src/app/api/files/[...filePath]/route.ts index 3ecaf66f..f9ebba12 100644 --- a/ui/src/app/api/files/[...filePath]/route.ts +++ b/ui/src/app/api/files/[...filePath]/route.ts @@ -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 }); } diff --git a/ui/src/app/api/files/delete/route.ts b/ui/src/app/api/files/delete/route.ts index 55a21efd..d751296b 100644 --- a/ui/src/app/api/files/delete/route.ts +++ b/ui/src/app/api/files/delete/route.ts @@ -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) { diff --git a/ui/src/app/api/gpu/route.ts b/ui/src/app/api/gpu/route.ts index 53f06fd9..67093e6e 100644 --- a/ui/src/app/api/gpu/route.ts +++ b/ui/src/app/api/gpu/route.ts @@ -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 { // 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 }, ); diff --git a/ui/src/app/api/img/caption/route.ts b/ui/src/app/api/img/caption/route.ts index 7aa82e95..99bb79be 100644 --- a/ui/src/app/api/img/caption/route.ts +++ b/ui/src/app/api/img/caption/route.ts @@ -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) { diff --git a/ui/src/app/api/img/delete/route.ts b/ui/src/app/api/img/delete/route.ts index b6f56d69..dc6dcb63 100644 --- a/ui/src/app/api/img/delete/route.ts +++ b/ui/src/app/api/img/delete/route.ts @@ -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 }); diff --git a/ui/src/app/api/jobs/[jobID]/delete/route.ts b/ui/src/app/api/jobs/[jobID]/delete/route.ts index 626c0053..e2d3a0c0 100644 --- a/ui/src/app/api/jobs/[jobID]/delete/route.ts +++ b/ui/src/app/api/jobs/[jobID]/delete/route.ts @@ -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 }, diff --git a/ui/src/app/api/jobs/[jobID]/files/route.ts b/ui/src/app/api/jobs/[jobID]/files/route.ts index bc4cc69c..c0642d75 100644 --- a/ui/src/app/api/jobs/[jobID]/files/route.ts +++ b/ui/src/app/api/jobs/[jobID]/files/route.ts @@ -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 }); diff --git a/ui/src/app/api/jobs/[jobID]/log/route.ts b/ui/src/app/api/jobs/[jobID]/log/route.ts index 4691a3e0..4748524e 100644 --- a/ui/src/app/api/jobs/[jobID]/log/route.ts +++ b/ui/src/app/api/jobs/[jobID]/log/route.ts @@ -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 => { 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); diff --git a/ui/src/app/api/jobs/[jobID]/loss/route.ts b/ui/src/app/api/jobs/[jobID]/loss/route.ts index 3aaeb50e..e6437a28 100644 --- a/ui/src/app/api/jobs/[jobID]/loss/route.ts +++ b/ui/src/app/api/jobs/[jobID]/loss/route.ts @@ -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: [] }); } diff --git a/ui/src/app/api/jobs/[jobID]/plugin/route.ts b/ui/src/app/api/jobs/[jobID]/plugin/route.ts index 83f09e5d..cadcf00b 100644 --- a/ui/src/app/api/jobs/[jobID]/plugin/route.ts +++ b/ui/src/app/api/jobs/[jobID]/plugin/route.ts @@ -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 }); diff --git a/ui/src/app/api/jobs/[jobID]/samples/route.ts b/ui/src/app/api/jobs/[jobID]/samples/route.ts index 05918bd6..d5f9bd82 100644 --- a/ui/src/app/api/jobs/[jobID]/samples/route.ts +++ b/ui/src/app/api/jobs/[jobID]/samples/route.ts @@ -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'); }) diff --git a/ui/src/app/api/jobs/[jobID]/stop/route.ts b/ui/src/app/api/jobs/[jobID]/stop/route.ts index 417a5597..8b49f533 100644 --- a/ui/src/app/api/jobs/[jobID]/stop/route.ts +++ b/ui/src/app/api/jobs/[jobID]/stop/route.ts @@ -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'); } diff --git a/ui/src/app/api/scripts/route.ts b/ui/src/app/api/scripts/route.ts index bab8af49..80a805a6 100644 --- a/ui/src/app/api/scripts/route.ts +++ b/ui/src/app/api/scripts/route.ts @@ -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 => { 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/.' }, diff --git a/ui/src/app/api/zip/route.ts b/ui/src/app/api/zip/route.ts index fc4b946d..04941ea8 100644 --- a/ui/src/app/api/zip/route.ts +++ b/ui/src/app/api/zip/route.ts @@ -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((resolve, reject) => {