parallelize script loads

This commit is contained in:
Brian Beck 2025-12-02 22:06:20 -08:00
parent 9d3554de02
commit 2f23934de0
17 changed files with 561 additions and 68 deletions

View file

@ -1,7 +1,8 @@
{{ {{
// Collect exec() script paths during parsing (deduplicated) // Collect exec() script paths during parsing (deduplicated)
const execScriptPathsSet = new Set(); // These are reset in the per-parse initializer below
let hasDynamicExec = false; let execScriptPathsSet;
let hasDynamicExec;
function buildBinaryExpression(head, tail) { function buildBinaryExpression(head, tail) {
return tail.reduce((left, [op, right]) => ({ return tail.reduce((left, [op, right]) => ({
@ -41,6 +42,12 @@
} }
}} }}
// Per-parse initializer - reset state for each parse call
{
execScriptPathsSet = new Set();
hasDynamicExec = false;
}
// Main entry point // Main entry point
Program Program
= ws items:((Comment / Statement) ws)* { = ws items:((Comment / Statement) ws)* {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
2:I[9766,[],""] 2:I[9766,[],""]
3:I[8924,[],""] 3:I[8924,[],""]
4:I[1959,[],"ClientPageRoot"] 4:I[1959,[],"ClientPageRoot"]
5:I[8283,["367","static/chunks/b536a0f1-05ee2c75df4a3b9d.js","831","static/chunks/bd904a5c-3aea2adebde6f067.js","664","static/chunks/a3cd4a83-5c5b758da206345b.js","794","static/chunks/f6211eb1-4f3105d2434536dc.js","413","static/chunks/1329d575-16915d95397758f8.js","331","static/chunks/331-37e8e553d8a20c21.js","974","static/chunks/app/page-9325599301306e96.js"],"default"] 5:I[8283,["367","static/chunks/b536a0f1-05ee2c75df4a3b9d.js","831","static/chunks/bd904a5c-3aea2adebde6f067.js","664","static/chunks/a3cd4a83-5c5b758da206345b.js","794","static/chunks/f6211eb1-4f3105d2434536dc.js","413","static/chunks/1329d575-16915d95397758f8.js","331","static/chunks/331-37e8e553d8a20c21.js","974","static/chunks/app/page-992a02d96730d72d.js"],"default"]
8:I[4431,[],"OutletBoundary"] 8:I[4431,[],"OutletBoundary"]
a:I[5278,[],"AsyncMetadataOutlet"] a:I[5278,[],"AsyncMetadataOutlet"]
c:I[4431,[],"ViewportBoundary"] c:I[4431,[],"ViewportBoundary"]
@ -10,7 +10,7 @@ e:I[4431,[],"MetadataBoundary"]
f:"$Sreact.suspense" f:"$Sreact.suspense"
11:I[7150,[],""] 11:I[7150,[],""]
:HL["/t2-mapper/_next/static/css/9e91738631ff0ad7.css","style"] :HL["/t2-mapper/_next/static/css/9e91738631ff0ad7.css","style"]
0:{"P":null,"b":"hNI-Z4JlDIdVyOrrvkkaL","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/css/9e91738631ff0ad7.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],null,["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[]],"s":false,"S":true} 0:{"P":null,"b":"tafDAgWrdAOojPa0uDzEv","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/css/9e91738631ff0ad7.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],null,["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[]],"s":false,"S":true}
6:{} 6:{}
7:"$0:f:0:1:2:children:1:props:children:0:props:params" 7:"$0:f:0:1:2:children:1:props:children:0:props:params"
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]

View file

@ -6,8 +6,9 @@
// Collect exec() script paths during parsing (deduplicated) // Collect exec() script paths during parsing (deduplicated)
const execScriptPathsSet = new Set(); // These are reset in the per-parse initializer below
let hasDynamicExec = false; let execScriptPathsSet;
let hasDynamicExec;
function buildBinaryExpression(head, tail) { function buildBinaryExpression(head, tail) {
return tail.reduce((left, [op, right]) => ({ return tail.reduce((left, [op, right]) => ({
@ -6407,6 +6408,10 @@ function peg$parse(input, options) {
return s0; return s0;
} }
execScriptPathsSet = new Set();
hasDynamicExec = false;
peg$result = peg$startRuleFunction(); peg$result = peg$startRuleFunction();
const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length);

View file

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Html } from "@react-three/drei";
import picomatch from "picomatch"; import picomatch from "picomatch";
import { loadMission } from "../loaders"; import { loadMission } from "../loaders";
import { type ParsedMission } from "../mission"; import { type ParsedMission } from "../mission";
@ -12,7 +13,12 @@ import {
runServer, runServer,
TorqueObject, TorqueObject,
} from "../torqueScript"; } from "../torqueScript";
import { getResourceKey, getResourceList, getResourceMap } from "../manifest"; import {
getResourceKey,
getResourceList,
getResourceMap,
getSourceAndPath,
} from "../manifest";
const loadScript = createScriptLoader(); const loadScript = createScriptLoader();
// Shared cache for parsed scripts - survives runtime restarts // Shared cache for parsed scripts - survives runtime restarts
@ -20,7 +26,12 @@ const scriptCache = createScriptCache();
const fileSystem: FileSystemHandler = { const fileSystem: FileSystemHandler = {
findFiles: (pattern) => { findFiles: (pattern) => {
const isMatch = picomatch(pattern, { nocase: true }); const isMatch = picomatch(pattern, { nocase: true });
return getResourceList().filter((path) => isMatch(path)); return getResourceList()
.filter((path) => isMatch(path))
.map((resourceKey) => {
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
return actualPath;
});
}, },
isFile: (resourcePath) => { isFile: (resourcePath) => {
const resourceKeys = getResourceMap(); const resourceKeys = getResourceMap();
@ -75,12 +86,34 @@ function useExecutedMission(
return missionGroup; return missionGroup;
} }
function LoadingSpinner() {
return (
<Html>
<div
style={{
position: "fixed",
top: "50%",
left: "50%",
width: 48,
height: 48,
border: "4px solid rgba(255, 255, 255, 0.2)",
borderTopColor: "white",
borderRadius: "50%",
animation: "spin 1s linear infinite",
pointerEvents: "none",
}}
/>
<style>{`@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } from { transform: translate(-50%, -50%) rotate(0deg); } }`}</style>
</Html>
);
}
export const Mission = memo(function Mission({ name }: { name: string }) { export const Mission = memo(function Mission({ name }: { name: string }) {
const { data: parsedMission } = useParsedMission(name); const { data: parsedMission } = useParsedMission(name);
const missionGroup = useExecutedMission(name, parsedMission); const missionGroup = useExecutedMission(name, parsedMission);
if (!missionGroup) { if (!missionGroup) {
return null; return <LoadingSpinner />;
} }
return <TickProvider>{renderObject(missionGroup)}</TickProvider>; return <TickProvider>{renderObject(missionGroup)}</TickProvider>;

View file

@ -601,6 +601,15 @@ export function createBuiltins(
console.debug( console.debug(
`exec(${JSON.stringify(pathString)}): preparing to execute…`, `exec(${JSON.stringify(pathString)}): preparing to execute…`,
); );
// Engine requires an extension - check for a '.' in the filename
if (!pathString.includes(".")) {
console.error(
`exec: invalid script file name ${JSON.stringify(pathString)}.`,
);
return false;
}
const normalizedPath = normalizePath(pathString); const normalizedPath = normalizePath(pathString);
const rt = runtime(); const rt = runtime();
const { executedScripts, scripts } = rt.state; const { executedScripts, scripts } = rt.state;

View file

@ -74,7 +74,7 @@ export interface RunServerResult {
export function runServer(options: RunServerOptions): RunServerResult { export function runServer(options: RunServerOptions): RunServerResult {
const { missionName, missionType, runtimeOptions, onMissionLoadDone } = const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
options; options;
const { signal } = runtimeOptions ?? {}; const { signal, fileSystem } = runtimeOptions ?? {};
const runtime = createRuntime({ const runtime = createRuntime({
...runtimeOptions, ...runtimeOptions,
@ -87,16 +87,36 @@ export function runServer(options: RunServerOptions): RunServerResult {
const gameTypeName = `${missionType}Game`; const gameTypeName = `${missionType}Game`;
const gameTypeScript = `scripts/${gameTypeName}.cs`; const gameTypeScript = `scripts/${gameTypeName}.cs`;
const ready = (async () => { const ready = (async function createServer() {
try { try {
// Load all required scripts // Load all required scripts
const serverScript = await runtime.loadFromPath("scripts/server.cs"); const serverScript = await runtime.loadFromPath("scripts/server.cs");
signal?.throwIfAborted(); signal?.throwIfAborted();
// These are dynamic exec() calls in server.cs since their paths are
// computed based on the game type and mission. So, we need to load them // server.cs has a glob loop that does: findFirstFile("scripts/*Game.cs")
// ahead of time so they're available to execute. // and then exec()s each result dynamically. Since we can't statically
await runtime.loadFromPath(gameTypeScript); // analyze dynamic exec paths, we need to either preload all game scripts
// in the same way (so they're available when exec() is called) or just
// exec() the ones we know we need...
//
// To load them all, do:
// if (fileSystem) {
// const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
// await Promise.all(
// gameScripts.map((path) => runtime.loadFromPath(path)),
// );
// signal?.throwIfAborted();
// }
await runtime.loadFromPath("scripts/DefaultGame.cs");
signal?.throwIfAborted(); signal?.throwIfAborted();
try {
await runtime.loadFromPath(gameTypeScript);
} catch (err) {
// It's OK if that one fails. Not every game type needs its own script.
}
signal?.throwIfAborted();
// Also preload the mission file (another dynamic exec path)
await runtime.loadFromPath(`missions/${missionName}.mis`); await runtime.loadFromPath(`missions/${missionName}.mis`);
signal?.throwIfAborted(); signal?.throwIfAborted();
@ -112,7 +132,7 @@ export function runServer(options: RunServerOptions): RunServerResult {
// which we added specifically to solve this problem. // which we added specifically to solve this problem.
if (onMissionLoadDone) { if (onMissionLoadDone) {
runtime.$.onMethodCalled( runtime.$.onMethodCalled(
gameTypeName, "DefaultGame",
"missionLoadDone", "missionLoadDone",
onMissionLoadDone, onMissionLoadDone,
); );

View file

@ -1353,7 +1353,9 @@ describe("TorqueScript Runtime", () => {
function createLoader( function createLoader(
files: Record<string, string>, files: Record<string, string>,
): (path: string) => Promise<string | null> { ): (path: string) => Promise<string | null> {
return async (path: string) => files[path.toLowerCase()] ?? null; // Loader normalizes paths itself (slashes + lowercase)
return async (path: string) =>
files[path.replace(/\\/g, "/").toLowerCase()] ?? null;
} }
it("executes scripts via exec()", async () => { it("executes scripts via exec()", async () => {
@ -1419,6 +1421,202 @@ describe("TorqueScript Runtime", () => {
expect(runtime.$g.get("Loaded")).toBe(true); expect(runtime.$g.get("Loaded")).toBe(true);
}); });
describe("path normalization for state tracking", () => {
it("treats different cases as the same file for loaded scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.toLowerCase() === "scripts/test.cs") {
return "$LoadCount = $LoadCount + 1;";
}
return null;
},
});
// Load with different cases - should only call loader once
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("Scripts/Test.cs");
await runtime.loadFromPath("SCRIPTS/TEST.CS");
expect(loadCount).toBe(1);
});
it("treats different slashes as the same file for loaded scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$LoadCount = $LoadCount + 1;";
}
return null;
},
});
// Load with different slash styles - should only call loader once
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("scripts\\test.cs");
expect(loadCount).toBe(1);
});
it("treats different cases as the same file for failed scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async () => {
loadCount++;
return null; // Script not found
},
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Try to load with different cases - should only call loader once
const script = await runtime.loadFromSource(`
exec("scripts/missing.cs");
exec("Scripts/Missing.cs");
exec("SCRIPTS/MISSING.CS");
`);
script.execute();
warnSpy.mockRestore();
// Loader should only be called once since subsequent attempts
// see it's already in failedScripts
expect(loadCount).toBe(1);
});
it("treats different slashes as the same file for failed scripts", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async () => {
loadCount++;
return null; // Script not found
},
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Try to load with different slash styles
const script = await runtime.loadFromSource(`
exec("scripts/missing.cs");
exec("scripts\\\\missing.cs");
`);
script.execute();
warnSpy.mockRestore();
expect(loadCount).toBe(1);
});
it("treats different cases as the same file for executed scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/counter.cs": "$Counter = $Counter + 1;",
}),
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/counter.cs");
exec("Scripts/Counter.cs");
exec("SCRIPTS/COUNTER.CS");
`);
script.execute();
// Script should only execute once despite different cases
expect(runtime.$g.get("Counter")).toBe(1);
});
it("treats different slashes as the same file for executed scripts", async () => {
const runtime = createRuntime({
loadScript: createLoader({
"scripts/counter.cs": "$Counter = $Counter + 1;",
}),
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/counter.cs");
exec("scripts\\\\counter.cs");
`);
script.execute();
// Script should only execute once despite different slashes
expect(runtime.$g.get("Counter")).toBe(1);
});
it("caches sequential loads with different cases", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.toLowerCase() === "scripts/test.cs") {
return "$Loaded = true;";
}
return null;
},
});
// Load same file with different cases sequentially
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("Scripts/Test.cs");
await runtime.loadFromPath("SCRIPTS/TEST.CS");
// Should only load once since cached after first load
expect(loadCount).toBe(1);
});
it("caches sequential loads with different slashes", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$Loaded = true;";
}
return null;
},
});
// Load same file with different slashes sequentially
await runtime.loadFromPath("scripts/test.cs");
await runtime.loadFromPath("scripts\\test.cs");
// Should only load once since cached after first load
expect(loadCount).toBe(1);
});
it("handles combined case and slash differences", async () => {
let loadCount = 0;
const runtime = createRuntime({
loadScript: async (path) => {
loadCount++;
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
return "$Counter = $Counter + 1;";
}
return null;
},
});
runtime.$g.set("Counter", 0);
const script = await runtime.loadFromSource(`
exec("scripts/test.cs");
exec("Scripts\\\\Test.cs");
exec("SCRIPTS/TEST.CS");
exec("scripts\\\\TEST.CS");
`);
script.execute();
// All variations should be treated as the same file
expect(loadCount).toBe(1);
expect(runtime.$g.get("Counter")).toBe(1);
});
});
it("returns false when script is not found", async () => { it("returns false when script is not found", async () => {
const runtime = createRuntime({ const runtime = createRuntime({
loadScript: async () => null, loadScript: async () => null,
@ -1462,6 +1660,24 @@ describe("TorqueScript Runtime", () => {
expect(runtime.$g.get("Result")).toBe(false); expect(runtime.$g.get("Result")).toBe(false);
}); });
it("returns false and logs error when exec called without extension", async () => {
// Engine requires file extension (matches tribes2-engine/TorqueSDK-1.2 behavior)
const runtime = createRuntime();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const script = await runtime.loadFromSource(
'$Result = exec("scripts/noextension");',
);
script.execute();
expect(errorSpy).toHaveBeenCalledWith(
'exec: invalid script file name "scripts/noextension".',
);
errorSpy.mockRestore();
debugSpy.mockRestore();
expect(runtime.$g.get("Result")).toBe(false);
});
it("shares globals between exec'd scripts", async () => { it("shares globals between exec'd scripts", async () => {
const runtime = createRuntime({ const runtime = createRuntime({
loadScript: createLoader({ loadScript: createLoader({
@ -1659,6 +1875,177 @@ describe("TorqueScript Runtime", () => {
expect(loadCount).toBe(1); // Only loaded once expect(loadCount).toBe(1); // Only loaded once
}); });
it("handles diamond dependencies (parallel deduplication)", async () => {
// A depends on B and C, both B and C depend on D
// D should only be loaded once
let dLoadCount = 0;
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/d.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/d.cs"); $C = 1;',
"scripts/d.cs": "$D = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
if (path === "scripts/d.cs") dLoadCount++;
return sources[path] ?? null;
},
});
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(dLoadCount).toBe(1); // D loaded only once despite being dep of both B and C
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
expect(runtime.$g.get("C")).toBe(1);
expect(runtime.$g.get("D")).toBe(1);
});
it("handles longer cycles not involving main script (A → B → C → B)", async () => {
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/c.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/b.cs"); $C = 1;', // Cycle back to B
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
// Should not hang
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("B")).toBe(1);
expect(runtime.$g.get("C")).toBe(1);
});
it("handles multiple scripts depending on each other in complex patterns", async () => {
// A → [B, C], B → [D, E], C → [E, F], D → G, E → G, F → G
// G should only be loaded once
let gLoadCount = 0;
const sources: Record<string, string> = {
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
"scripts/b.cs": 'exec("scripts/d.cs"); exec("scripts/e.cs"); $B = 1;',
"scripts/c.cs": 'exec("scripts/e.cs"); exec("scripts/f.cs"); $C = 1;',
"scripts/d.cs": 'exec("scripts/g.cs"); $D = 1;',
"scripts/e.cs": 'exec("scripts/g.cs"); $E = 1;',
"scripts/f.cs": 'exec("scripts/g.cs"); $F = 1;',
"scripts/g.cs": "$G = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
if (path === "scripts/g.cs") gLoadCount++;
return sources[path] ?? null;
},
});
const script = await runtime.loadFromPath("scripts/a.cs");
script.execute();
expect(gLoadCount).toBe(1); // G loaded only once
expect(runtime.$g.get("A")).toBe(1);
expect(runtime.$g.get("G")).toBe(1);
});
it("continues loading other scripts when one fails", async () => {
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/good.cs"); exec("scripts/bad.cs"); exec("scripts/also-good.cs"); $Main = 1;',
"scripts/good.cs": "$Good = 1;",
// bad.cs is missing
"scripts/also-good.cs": "$AlsoGood = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
// Good scripts should still be loaded and executed
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Good")).toBe(1);
expect(runtime.$g.get("AlsoGood")).toBe(1);
});
it("handles parse errors gracefully without blocking other scripts", async () => {
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/good.cs"); exec("scripts/bad-syntax.cs"); $Main = 1;',
"scripts/good.cs": "$Good = 1;",
"scripts/bad-syntax.cs": "this is not valid { syntax",
};
const runtime = createRuntime({
loadScript: async (path) => sources[path] ?? null,
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
warnSpy.mockRestore();
expect(runtime.$g.get("Main")).toBe(1);
expect(runtime.$g.get("Good")).toBe(1);
});
it("respects pre-aborted signal when loading dependencies", async () => {
const controller = new AbortController();
controller.abort(); // Abort before starting
let loaderCalled = false;
const runtime = createRuntime({
loadScript: async () => {
loaderCalled = true;
return "$X = 1;";
},
signal: controller.signal,
});
// Load a script that has dependencies - should throw when trying to load deps
await expect(
runtime.loadFromSource('exec("scripts/dep.cs"); $Main = 1;'),
).rejects.toThrow();
expect(loaderCalled).toBe(false); // Loader should never be called
});
it("loads scripts in parallel (not serially)", async () => {
const loadOrder: string[] = [];
const sources: Record<string, string> = {
"scripts/main.cs":
'exec("scripts/a.cs"); exec("scripts/b.cs"); exec("scripts/c.cs");',
"scripts/a.cs": "$A = 1;",
"scripts/b.cs": "$B = 1;",
"scripts/c.cs": "$C = 1;",
};
const runtime = createRuntime({
loadScript: async (path) => {
loadOrder.push(`start:${path}`);
// Small delay to allow interleaving
await new Promise((resolve) => setTimeout(resolve, 10));
loadOrder.push(`end:${path}`);
return sources[path] ?? null;
},
});
await runtime.loadFromPath("scripts/main.cs");
// If parallel: starts happen before all ends
// If serial: each end would happen before the next start
const aStart = loadOrder.indexOf("start:scripts/a.cs");
const bStart = loadOrder.indexOf("start:scripts/b.cs");
const cStart = loadOrder.indexOf("start:scripts/c.cs");
const aEnd = loadOrder.indexOf("end:scripts/a.cs");
// All starts should happen before any of them ends (parallel behavior)
expect(bStart).toBeLessThan(aEnd);
expect(cStart).toBeLessThan(aEnd);
});
}); });
describe("path option", () => { describe("path option", () => {

View file

@ -1066,67 +1066,85 @@ export function createRuntime(
} }
async function loadDependencies( async function loadDependencies(
ast: Program, scriptsToLoad: string[],
loading: Set<string>, loadingPromises: Map<string, Promise<void>>,
includePreload: boolean = false, ancestors: Set<string>,
): Promise<void> { ): Promise<void> {
const loader = options.loadScript; const loader = options.loadScript;
if (!loader) { if (!loader) {
// No loader, can't resolve dependencies if (scriptsToLoad.length > 0) {
if (ast.execScriptPaths.length > 0) {
console.warn( console.warn(
`Script has exec() calls but no loadScript provided:`, `Script has exec() calls but no loadScript provided:`,
ast.execScriptPaths, scriptsToLoad,
); );
} }
return; return;
} }
// Combine static exec() paths with preload scripts (on first call only) async function loadSingleScript(ref: string): Promise<void> {
const scriptsToLoad = includePreload
? [...ast.execScriptPaths, ...(options.preloadScripts ?? [])]
: ast.execScriptPaths;
for (const ref of scriptsToLoad) {
options.signal?.throwIfAborted(); options.signal?.throwIfAborted();
const normalized = normalizePath(ref); const normalized = normalizePath(ref);
// Skip if already loaded, failed, or currently loading (cycle detection) // Skip if already loaded or failed
if ( if (
state.scripts.has(normalized) || state.scripts.has(normalized) ||
state.failedScripts.has(normalized) || state.failedScripts.has(normalized)
loading.has(normalized)
) { ) {
continue; return;
} }
loading.add(normalized); // If this script is an ancestor in our load chain, it's a cycle - skip
// (awaiting would cause deadlock)
const source = await loader(ref); if (ancestors.has(normalized)) {
if (source == null) { return;
console.warn(`Script not found: ${ref}`);
state.failedScripts.add(normalized);
loading.delete(normalized);
continue;
} }
let depAst: Program; // If already loading from a parallel branch, wait for it to complete
try { const existingPromise = loadingPromises.get(normalized);
depAst = parse(source, { filename: ref }); if (existingPromise) {
} catch (err) { await existingPromise;
console.warn(`Failed to parse script: ${ref}`, err); return;
state.failedScripts.add(normalized);
loading.delete(normalized);
continue;
} }
// Recursively load this script's dependencies first const loadPromise = (async () => {
await loadDependencies(depAst, loading); // Pass original path to loader - it handles its own normalization
const source = await loader(ref);
if (source == null) {
console.warn(`Script not found: ${ref}`);
state.failedScripts.add(normalized);
return;
}
// Store the parsed AST let depAst: Program;
state.scripts.set(normalized, depAst); try {
loading.delete(normalized); depAst = parse(source, { filename: ref });
} catch (err) {
console.warn(`Failed to parse script: ${ref}`, err);
state.failedScripts.add(normalized);
return;
}
// Add this script to ancestors for nested loads (cycle detection)
const newAncestors = new Set(ancestors);
newAncestors.add(normalized);
// Recursively load this script's dependencies in parallel
await loadDependencies(
depAst.execScriptPaths,
loadingPromises,
newAncestors,
);
// Store the parsed AST
state.scripts.set(normalized, depAst);
})();
loadingPromises.set(normalized, loadPromise);
await loadPromise;
} }
// Load all scripts in parallel
await Promise.all(scriptsToLoad.map(loadSingleScript));
} }
async function loadFromPath(path: string): Promise<LoadedScript> { async function loadFromPath(path: string): Promise<LoadedScript> {
@ -1135,12 +1153,12 @@ export function createRuntime(
throw new Error("loadFromPath requires loadScript option to be set"); throw new Error("loadFromPath requires loadScript option to be set");
} }
// Check if already loaded (avoid unnecessary fetch)
const normalized = normalizePath(path); const normalized = normalizePath(path);
if (state.scripts.has(normalized)) { if (state.scripts.has(normalized)) {
return createLoadedScript(state.scripts.get(normalized)!, path); return createLoadedScript(state.scripts.get(normalized)!, path);
} }
// Pass original path to loader - it handles its own normalization
const source = await loader(path); const source = await loader(path);
if (source == null) { if (source == null) {
throw new Error(`Script not found: ${path}`); throw new Error(`Script not found: ${path}`);
@ -1172,14 +1190,21 @@ export function createRuntime(
ast: Program, ast: Program,
loadOptions?: LoadScriptOptions, loadOptions?: LoadScriptOptions,
): Promise<LoadedScript> { ): Promise<LoadedScript> {
// Load dependencies (include preload scripts on initial load) const loadingPromises = new Map<string, Promise<void>>();
const loading = new Set<string>(); const ancestors = new Set<string>();
if (loadOptions?.path) { if (loadOptions?.path) {
const normalized = normalizePath(loadOptions.path); const normalized = normalizePath(loadOptions.path);
loading.add(normalized); // Mark the main script as loaded to prevent cycles back to it
state.scripts.set(normalized, ast); state.scripts.set(normalized, ast);
ancestors.add(normalized);
} }
await loadDependencies(ast, loading, true);
// Load dependencies and any preload scripts
const scriptsToLoad = [
...ast.execScriptPaths,
...(options.preloadScripts ?? []),
];
await loadDependencies(scriptsToLoad, loadingPromises, ancestors);
return createLoadedScript(ast, loadOptions?.path); return createLoadedScript(ast, loadOptions?.path);
} }

View file

@ -11,19 +11,20 @@ export function createScriptLoader(): ScriptLoader {
try { try {
url = getUrlForPath(path); url = getUrlForPath(path);
} catch (err) { } catch (err) {
console.warn(`Script not in manifest: ${path}`, err); console.warn(`Script not in manifest: ${path} (${err})`);
return null; return null;
} }
try { try {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
console.warn(`Script fetch failed: ${path} (${response.status})`); console.error(`Script fetch failed: ${path} (${response.status})`);
return null; return null;
} }
return await response.text(); return await response.text();
} catch (err) { } catch (err) {
console.warn(`Script fetch error: ${path}`, err); console.error(`Script fetch error: ${path}`);
console.error(err);
return null; return null;
} }
}; };

View file

@ -133,6 +133,12 @@ export class CaseInsensitiveSet {
} }
} }
export function normalizePath(path: string): string { /** Normalize path separators only (backslashes to forward slashes). */
return path.replace(/\\/g, "/").toLowerCase(); export function normalizeSlashes(path: string): string {
return path.replace(/\\/g, "/");
}
/** Normalize path for use as a cache key (slashes + lowercase). */
export function normalizePath(path: string): string {
return normalizeSlashes(path).toLowerCase();
} }