diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 12e122e0..2c71d314 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -47,9 +47,16 @@ export const POST = withAdmin( const { swfDir, nitroDir } = await getFurniAssetDirs(); const encoder = new TextEncoder(); + // Wire client disconnect to abort controller + let aborted = false; + request.signal.addEventListener("abort", () => { + aborted = true; + }); + const stream = new ReadableStream({ async start(controller) { const send = (data: unknown) => { + if (aborted) return; try { controller.enqueue( encoder.encode(`data: ${JSON.stringify(data)}\n\n`), @@ -240,6 +247,18 @@ export const POST = withAdmin( await new Promise((r) => setImmediate(r)); } + if (aborted) { + send({ + type: "regen_complete", + succeeded, + failed, + skipped, + aborted: true, + }); + controller.close(); + return; + } + // Batch write FurnitureData.json if (furniDataEntries.length > 0) { try { @@ -291,25 +310,27 @@ export const POST = withAdmin( ); } - send({ - type: "regen_complete", - succeeded, - failed, - skipped, - furniDataAdded: furniDataEntries.length, - ownershipFixed: ownership.fixed, - nitrosSynced: assetsSynced.copiedNitros, - iconsSynced: assetsSynced.copiedIcons, - furniDataFixedIds: furniReconcile.fixedIds, - furniDataMissing: furniReconcile.missing, - furniDataConflicts: furniReconcile.conflicts, - offerRebuildChecked: offerRebuild.checked, - offerRebuildFixed: offerRebuild.fixed, - spriteChecked: spriteVerify.checked, - spriteFixed: spriteVerify.fixed, - languages, - duration: Date.now() - startTime, - }); + if (!aborted) { + send({ + type: "regen_complete", + succeeded, + failed, + skipped, + furniDataAdded: furniDataEntries.length, + ownershipFixed: ownership.fixed, + nitrosSynced: assetsSynced.copiedNitros, + iconsSynced: assetsSynced.copiedIcons, + furniDataFixedIds: furniReconcile.fixedIds, + furniDataMissing: furniReconcile.missing, + furniDataConflicts: furniReconcile.conflicts, + offerRebuildChecked: offerRebuild.checked, + offerRebuildFixed: offerRebuild.fixed, + spriteChecked: spriteVerify.checked, + spriteFixed: spriteVerify.fixed, + languages, + duration: Date.now() - startTime, + }); + } controller.close(); }, diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 1b3378f4..6f5499f8 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -184,14 +184,42 @@ export async function findFurniDataIdConflict( } } +const CHAIN_TIMEOUT_MS = 60_000; + +/** + * Wait for the previous lock holder with a timeout. + * If the holder stalled (e.g. process crash, unhandled rejection), + * we break the chain instead of waiting forever. + */ +async function waitForFurniDataLock(): Promise { + const prev = furniDataLock; + if (prev === Promise.resolve()) return; + + try { + await Promise.race([ + prev, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("furni-data chain timeout")), + CHAIN_TIMEOUT_MS, + ), + ), + ]); + } catch { + // Chain holder timed out or crashed — break the chain + furniDataLock = Promise.resolve(); + } +} + export async function withFurniDataLock(fn: () => Promise): Promise { let release!: () => void; const acquired = new Promise((r) => { release = r; }); - const prev = furniDataLock; + + await waitForFurniDataLock(); furniDataLock = acquired; - await prev; + const lockBase = await getFurnitureDataReadPath(); await fs.mkdir(path.dirname(lockBase), { recursive: true }); const lockPath = `${lockBase}.lock`; @@ -200,6 +228,7 @@ export async function withFurniDataLock(fn: () => Promise): Promise { return await fn(); } finally { await fs.unlink(lockPath).catch(() => {}); + furniDataLock = Promise.resolve(); release?.(); } } diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 055c26a3..44ae8c57 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -1128,83 +1128,84 @@ export async function verifyAndFixInteractionModesCount(): Promise<{ let fixed = 0; let checked = 0; let skipped = 0; + const BATCH_SIZE = 500; try { - const allItems = await db - .select({ - id: ItemsBase.id, - itemName: ItemsBase.itemName, - publicName: ItemsBase.publicName, - allowSit: ItemsBase.allowSit, - allowLay: ItemsBase.allowLay, - allowWalk: ItemsBase.allowWalk, - interactionType: ItemsBase.interactionType, - interactionModesCount: ItemsBase.interactionModesCount, - }) - .from(ItemsBase); + let offset = 0; + while (true) { + const batch = await db + .select({ + id: ItemsBase.id, + itemName: ItemsBase.itemName, + publicName: ItemsBase.publicName, + allowSit: ItemsBase.allowSit, + allowLay: ItemsBase.allowLay, + allowWalk: ItemsBase.allowWalk, + interactionType: ItemsBase.interactionType, + interactionModesCount: ItemsBase.interactionModesCount, + }) + .from(ItemsBase) + .limit(BATCH_SIZE) + .offset(offset); - for (const item of allItems) { - checked++; - const realFlags = await lookupRealFurniFlags(item.itemName); + if (batch.length === 0) break; + offset += batch.length; + checked += batch.length; - // Real data absent → do not guess, leave the item untouched. - if (realFlags.source === "none") { - skipped++; - continue; - } + for (const item of batch) { + const realFlags = await lookupRealFurniFlags(item.itemName); - const animationModes = await realNitroModes(item.itemName); - const auto = autoDetectInteraction(item.itemName, item.publicName, { - cansiton: realFlags.cansiton, - canlayon: realFlags.canlayon, - canstandon: realFlags.canstandon, - // Furnidata flags are curated → authoritative over keywords. - hasActionData: true, - animationStates: animationModes, - }); + if (realFlags.source === "none") { + skipped++; + continue; + } - const changes: Partial> = {}; - // allow_sit/lay/walk of 2 is emulator-special (e.g. tents lay=2, - // walk-through walk=2) — never overwrite those with a 0/1. - if ( - item.allowSit !== 2 && - (item.allowSit ? 1 : 0) !== (auto.canSit ? 1 : 0) - ) - changes.allowSit = auto.canSit ? 1 : 0; - if ( - item.allowLay !== 2 && - (item.allowLay ? 1 : 0) !== (auto.canLay ? 1 : 0) - ) - changes.allowLay = auto.canLay ? 1 : 0; - if ( - item.allowWalk !== 2 && - (item.allowWalk ? 1 : 0) !== (auto.canStand ? 1 : 0) - ) - changes.allowWalk = auto.canStand ? 1 : 0; - if ((item.interactionModesCount ?? 0) !== auto.interactionModesCount) - changes.interactionModesCount = auto.interactionModesCount; - // Only fill interaction_type when the DB still has the generic - // "default" (nothing emulator-specific configured). Never overwrite - // an existing handler type (e.g. intelligence_bookcase, nest, wf_*) - // — those are emulator semantics that real furnidata cannot replace. - if ( - (item.interactionType === "default" || !item.interactionType) && - auto.interactionType !== "default" - ) { - changes.interactionType = auto.interactionType; - } + const animationModes = await realNitroModes(item.itemName); + const auto = autoDetectInteraction(item.itemName, item.publicName, { + cansiton: realFlags.cansiton, + canlayon: realFlags.canlayon, + canstandon: realFlags.canstandon, + hasActionData: true, + animationStates: animationModes, + }); - if (Object.keys(changes).length > 0) { - try { - await db - .update(ItemsBase) - .set(changes) - .where(eq(ItemsBase.id, item.id)); - fixed++; - } catch (err) { - errors.push( - `Failed to fix ${item.itemName}: ${(err as Error).message}`, - ); + const changes: Partial> = {}; + if ( + item.allowSit !== 2 && + (item.allowSit ? 1 : 0) !== (auto.canSit ? 1 : 0) + ) + changes.allowSit = auto.canSit ? 1 : 0; + if ( + item.allowLay !== 2 && + (item.allowLay ? 1 : 0) !== (auto.canLay ? 1 : 0) + ) + changes.allowLay = auto.canLay ? 1 : 0; + if ( + item.allowWalk !== 2 && + (item.allowWalk ? 1 : 0) !== (auto.canStand ? 1 : 0) + ) + changes.allowWalk = auto.canStand ? 1 : 0; + if ((item.interactionModesCount ?? 0) !== auto.interactionModesCount) + changes.interactionModesCount = auto.interactionModesCount; + if ( + (item.interactionType === "default" || !item.interactionType) && + auto.interactionType !== "default" + ) { + changes.interactionType = auto.interactionType; + } + + if (Object.keys(changes).length > 0) { + try { + await db + .update(ItemsBase) + .set(changes) + .where(eq(ItemsBase.id, item.id)); + fixed++; + } catch (err) { + errors.push( + `Failed to fix ${item.itemName}: ${(err as Error).message}`, + ); + } } } } diff --git a/src/lib/services/import/core/gamedata-json.ts b/src/lib/services/import/core/gamedata-json.ts index 99ac40a1..2e99962f 100644 --- a/src/lib/services/import/core/gamedata-json.ts +++ b/src/lib/services/import/core/gamedata-json.ts @@ -2,10 +2,12 @@ import { promises as fs } from "node:fs"; const LOCK_STALE_MS = 60_000; const LOCK_ACQUIRE_TIMEOUT_MS = 30_000; +const CHAIN_TIMEOUT_MS = 60_000; // In-process serialization, keyed by absolute file path, layered on top of the // on-disk lock so multiple awaiters in the same Node process queue cleanly. const chains = new Map>(); +const chainTimestamps = new Map(); async function acquireDiskLock(lockPath: string): Promise { const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS; @@ -34,6 +36,48 @@ async function acquireDiskLock(lockPath: string): Promise { } } +/** + * Wait for the previous chain holder with a timeout. + * If the chain holder stalled (e.g. process crash, unhandled rejection), + * we break the chain instead of waiting forever. + */ +async function waitForChain(filePath: string): Promise { + const prev = chains.get(filePath); + if (!prev) return; + + const timestamp = chainTimestamps.get(filePath) ?? 0; + const elapsed = Date.now() - timestamp; + + if (elapsed > CHAIN_TIMEOUT_MS) { + // Previous holder took too long — break the chain + console.warn( + `[gamedata-lock] Breaking stale chain for ${filePath} (held for ${elapsed}ms)`, + ); + chains.delete(filePath); + chainTimestamps.delete(filePath); + return; + } + + try { + await Promise.race([ + prev, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("chain timeout")), + Math.max(1, CHAIN_TIMEOUT_MS - elapsed), + ), + ), + ]); + } catch { + // Chain holder timed out or crashed — break the chain + console.warn( + `[gamedata-lock] Chain timeout for ${filePath}, breaking chain`, + ); + chains.delete(filePath); + chainTimestamps.delete(filePath); + } +} + export async function withGamedataLock( filePath: string, fn: () => Promise, @@ -42,15 +86,20 @@ export async function withGamedataLock( const acquired = new Promise((r) => { release = r; }); - const prev = chains.get(filePath) ?? Promise.resolve(); + + await waitForChain(filePath); + chains.set(filePath, acquired); - await prev; + chainTimestamps.set(filePath, Date.now()); + const lockPath = `${filePath}.lock`; await acquireDiskLock(lockPath); try { return await fn(); } finally { await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {}); + chains.delete(filePath); + chainTimestamps.delete(filePath); release(); } } diff --git a/src/lib/services/swf/conversion-pool.ts b/src/lib/services/swf/conversion-pool.ts index 41476dfc..58821516 100644 --- a/src/lib/services/swf/conversion-pool.ts +++ b/src/lib/services/swf/conversion-pool.ts @@ -87,6 +87,8 @@ function pump() { } } +const CONVERSION_TIMEOUT_MS = 60_000; + function run(req: Omit): Promise { if (!ensurePool()) { try { @@ -118,6 +120,33 @@ function run(req: Omit): Promise { }; queue.push(job); pump(); + + // Timeout: if a job takes too long, remove it from the queue + // and reject so the caller can fall back to main-thread conversion. + const timer = setTimeout(() => { + const idx = queue.findIndex((j) => j.req.id === job.req.id); + if (idx !== -1) { + queue.splice(idx, 1); + pending.delete(job.req.id); + reject(new Error("Conversion timed out")); + } else { + // Already dispatched to a worker — the worker is + // responsible for timing out its own task. + reject(new Error("Conversion timed out")); + } + }, CONVERSION_TIMEOUT_MS); + + // Clear the timer once the job resolves/rejects + const origResolve = resolve; + const origReject = reject; + job.resolve = (r) => { + clearTimeout(timer); + origResolve(r); + }; + job.reject = (e) => { + clearTimeout(timer); + origReject(e); + }; }); }