summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 02:59:53 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 02:59:53 +0800
commit822c092790bad4b1c0d780c1fafee063d70c91f6 (patch)
treec4a7d9f740cc1c8bba39c4ded0fc3da15fcc5a5f
parent183c4869e6baf03ad12d10ab8deeaec656a7d348 (diff)
downloadsomhairles-dream-fsharp-822c092790bad4b1c0d780c1fafee063d70c91f6.tar.gz
fix(viewer): guard step loads by generation and run identity, add stale-load regression
-rw-r--r--README.md10
-rw-r--r--public/App.js132
-rw-r--r--src/SomhairlesDream.Frontend/App.fs89
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs4
-rwxr-xr-xtools/acceptance/e2e_acceptance.py195
5 files changed, 389 insertions, 41 deletions
diff --git a/README.md b/README.md
index 89c962c..09f6d2a 100644
--- a/README.md
+++ b/README.md
@@ -188,11 +188,17 @@ dotnet run --project src/SomhairlesDream.Cli -- run \
reference run; a failure run (pre-existing run directory) shows status
`失败` with the error surfaced in `run-message`; zero console errors on
all pages.
-- Browser acceptance driver (see above), 45/45 checks green on desktop and
+- Browser acceptance driver (see above), 53/53 checks green on desktop and
narrow viewports: framing asserted numerically from the projected scene
bounding box (not by trusting whole-canvas hashes alone), frozen
screenshots byte-deterministic, GLB hash equality, failure surfacing,
- zero console errors. The frontend refits the camera to the loaded model
+ zero console errors. A stale-load regression intercepts step GLB
+ responses and fulfills them late, reordered, or with errors across six
+ runs, asserting via actual scene geometry (`THREE.Object3D` types/colors)
+ and the visible note that superseded runs' responses never repaint the
+ viewport, same-path reruns still reload, run switches to empty state
+ fall back to the replay frame, and failed current loads retry. The
+ frontend refits the camera to the loaded model
(`THREE.Box3`-based framing) whenever the canvas resizes, and a resize
never reverts the viewport to replay placeholder geometry.
- NOT verified: production deployment of the old site remains untouched;
diff --git a/public/App.js b/public/App.js
index f953832..4b842d1 100644
--- a/public/App.js
+++ b/public/App.js
@@ -1,5 +1,5 @@
-import { Record } from "./fable_modules/fable-library-js.4.24.0/Types.js";
-import { class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js";
+import { Union, Record } from "./fable_modules/fable-library-js.4.24.0/Types.js";
+import { union_type, class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js";
import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js";
import { equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js";
import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js";
@@ -40,6 +40,34 @@ export function ViewState_$reflection() {
return record_type("SomhairlesDream.Frontend.App.ViewState", [], ViewState, () => [["Renderer", string_type], ["Scene", string_type], ["Camera", string_type], ["Group", string_type], ["Canvas", class_type("Browser.Types.HTMLCanvasElement", undefined)]]);
}
+export class LoadOwner extends Record {
+ constructor(ProjectId, RunId, Path) {
+ super();
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.Path = Path;
+ }
+}
+
+export function LoadOwner_$reflection() {
+ return record_type("SomhairlesDream.Frontend.App.LoadOwner", [], LoadOwner, () => [["ProjectId", string_type], ["RunId", string_type], ["Path", string_type]]);
+}
+
+export class ViewMode extends Union {
+ constructor(tag, fields) {
+ super();
+ this.tag = tag;
+ this.fields = fields;
+ }
+ cases() {
+ return ["FrameMode", "StepMode"];
+ }
+}
+
+export function ViewMode_$reflection() {
+ return union_type("SomhairlesDream.Frontend.App.ViewMode", [], ViewMode, () => [[], []]);
+}
+
export const frames = Replay_frames();
export let viewState = createAtom(undefined);
@@ -56,7 +84,13 @@ export let currentProjectId = createAtom("");
export let currentRunId = createAtom("");
-export let loadedStepPath = createAtom(undefined);
+export let loadGeneration = createAtom(0);
+
+export let pendingLoad = createAtom(undefined);
+
+export let loadedStep = createAtom(undefined);
+
+export let viewMode = createAtom(new ViewMode(0, []));
export let displayedNode = createAtom(undefined);
@@ -173,12 +207,27 @@ export function addMesh(view, frame) {
view.Group.add(mesh);
}
+export function clearGroup(group) {
+ const array = group.children;
+ array.forEach((node) => {
+ node.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); });
+ });
+ group.clear();
+}
+
+export function invalidateLoads() {
+ loadGeneration(loadGeneration() + 1);
+ pendingLoad(undefined);
+}
+
export function renderFrame(frame) {
selectedFrame(frame);
displayedNode(undefined);
+ invalidateLoads();
+ viewMode(new ViewMode(0, []));
if (viewState() != null) {
const view = viewState();
- view.Group.clear();
+ clearGroup(view.Group);
addMesh(view, frame);
view.Renderer.render(view.Scene, view.Camera);
}
@@ -218,7 +267,7 @@ export function resizeView(view) {
view.Camera.aspect = (width / height);
view.Camera.updateProjectionMatrix();
if (displayedNode() == null) {
- renderFrame(selectedFrame());
+ view.Renderer.render(view.Scene, view.Camera);
}
else {
frameObject(view, displayedNode());
@@ -289,27 +338,67 @@ export function setManifestLink(payload) {
}
}
-export function loadStepArtifact(view, stepPath) {
- loadedStepPath(stepPath);
+export function startStepLoad(view, owner) {
+ const generation = (loadGeneration() + 1) | 0;
+ loadGeneration(generation);
+ pendingLoad([generation, owner]);
+ viewMode(new ViewMode(1, []));
const loader = new GLTFLoader();
- const projectId = encodeURIComponent(currentProjectId());
- const runId = encodeURIComponent(currentRunId());
+ const projectId = encodeURIComponent(owner.ProjectId);
+ const runId = encodeURIComponent(owner.RunId);
let url;
- const arg_2 = encodeURIComponent(stepPath);
+ const arg_2 = encodeURIComponent(owner.Path);
url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s"))(projectId)(runId)(arg_2);
- setText("viewport-note", toText(printf("管线几何加载中 · %s"))(stepPath));
+ setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path));
loader.load(url, ((gltf) => {
- view.Group.clear();
- view.Group.add(gltf.scene);
- displayedNode(gltf.scene);
- frameObject(view, gltf.scene);
- view.Renderer.render(view.Scene, view.Camera);
- setText("viewport-note", toText(printf("管线几何 · %s"))(stepPath));
+ if (equals(pendingLoad(), [generation, owner])) {
+ pendingLoad(undefined);
+ clearGroup(view.Group);
+ view.Group.add(gltf.scene);
+ displayedNode(gltf.scene);
+ loadedStep(owner);
+ frameObject(view, gltf.scene);
+ view.Renderer.render(view.Scene, view.Camera);
+ setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path));
+ }
+ else {
+ gltf.scene.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); });
+ }
}), null, ((_arg) => {
- setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(stepPath));
+ if (equals(pendingLoad(), [generation, owner])) {
+ pendingLoad(undefined);
+ loadedStep(undefined);
+ setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path));
+ }
}));
}
+export function loadStepArtifact(view, stepPath) {
+ const owner = new LoadOwner(currentProjectId(), currentRunId(), stepPath);
+ let matchResult, pending_1;
+ if (pendingLoad() != null) {
+ if (equals(pendingLoad()[1], owner)) {
+ matchResult = 0;
+ pending_1 = pendingLoad()[1];
+ }
+ else {
+ matchResult = 1;
+ }
+ }
+ else {
+ matchResult = 1;
+ }
+ switch (matchResult) {
+ case 0: {
+ break;
+ }
+ case 1: {
+ startStepLoad(view, owner);
+ break;
+ }
+ }
+}
+
export function updateState(payload) {
let arg, arg_3;
setStatusClass(statusName(payload));
@@ -317,6 +406,10 @@ export function updateState(payload) {
setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message);
setText("project-id", payload.projectId);
setText("run-id", payload.runId);
+ if ((currentProjectId() !== payload.projectId) ? true : (currentRunId() !== payload.runId)) {
+ invalidateLoads();
+ displayedNode(undefined);
+ }
currentProjectId(payload.projectId);
currentRunId(payload.runId);
const currentVersion = max(0, payload.currentStepIndex + 1) | 0;
@@ -348,7 +441,8 @@ export function updateState(payload) {
}
else {
const latest = item(steps.length - 1, steps);
- if (!equals(loadedStepPath(), latest)) {
+ const owner = new LoadOwner(payload.projectId, payload.runId, latest);
+ if (!equals(loadedStep(), owner)) {
loadStepArtifact(view, latest);
}
setText("mesh-label", latest);
diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs
index 64fb61f..6e07798 100644
--- a/src/SomhairlesDream.Frontend/App.fs
+++ b/src/SomhairlesDream.Frontend/App.fs
@@ -37,6 +37,15 @@ type ViewState =
Group: ThreeNode
Canvas: HTMLCanvasElement }
+type LoadOwner =
+ { ProjectId: string
+ RunId: string
+ Path: string }
+
+type ViewMode =
+ | FrameMode
+ | StepMode
+
let frames = Replay.frames ()
let mutable viewState: ViewState option = None
let mutable selectedFrame = frames[0]
@@ -45,7 +54,12 @@ let mutable replayIndex = 0
let mutable eventSource: EventSource option = None
let mutable currentProjectId = ""
let mutable currentRunId = ""
-let mutable loadedStepPath: string option = None
+/// Bumped on every new step request and on every invalidation; only the
+/// response matching the latest generation may repaint the viewport.
+let mutable loadGeneration = 0
+let mutable pendingLoad: (int * LoadOwner) option = None
+let mutable loadedStep: LoadOwner option = None
+let mutable viewMode = FrameMode
let mutable displayedNode: ThreeNode option = None
let mutable freezeRotation = false
@@ -111,14 +125,24 @@ let addMesh (view: ViewState) (frame: ReplayFrame) =
let mesh = createMesh(geometry, material)
view.Group.add(mesh)
+let clearGroup (group: ThreeNode) =
+ group.children |> Array.iter disposeNodeResources
+ group.clear ()
+
+let invalidateLoads () =
+ loadGeneration <- loadGeneration + 1
+ pendingLoad <- None
+
let renderFrame (frame: ReplayFrame) =
selectedFrame <- frame
displayedNode <- None
+ invalidateLoads ()
+ viewMode <- FrameMode
match viewState with
| None -> ()
| Some view ->
- view.Group.clear()
+ clearGroup view.Group
addMesh view frame
view.Renderer.render(view.Scene, view.Camera)
@@ -164,7 +188,7 @@ let resizeView (view: ViewState) =
| Some node ->
frameObject view node
view.Renderer.render(view.Scene, view.Camera)
- | None -> renderFrame selectedFrame
+ | None -> view.Renderer.render(view.Scene, view.Camera)
let initializeView () =
let canvas = document.getElementById("viewport-canvas") :?> HTMLCanvasElement
@@ -233,23 +257,45 @@ let setManifestLink (payload: SnapshotPayload) =
link.setAttribute("href", value)
link.classList.remove("is-hidden")
-let loadStepArtifact (view: ViewState) (stepPath: string) =
- loadedStepPath <- Some stepPath
+let startStepLoad (view: ViewState) (owner: LoadOwner) =
+ let generation = loadGeneration + 1
+ loadGeneration <- generation
+ pendingLoad <- Some(generation, owner)
+ viewMode <- StepMode
let loader = createGLTFLoader ()
- let projectId = JS.encodeURIComponent currentProjectId
- let runId = JS.encodeURIComponent currentRunId
- let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent stepPath)
- setText "viewport-note" (sprintf "管线几何加载中 · %s" stepPath)
+ let projectId = JS.encodeURIComponent owner.ProjectId
+ let runId = JS.encodeURIComponent owner.RunId
+ let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent owner.Path)
+ setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path)
loadGLTF loader url
(fun gltf ->
- view.Group.clear()
- view.Group.add gltf.scene
- displayedNode <- Some gltf.scene
- frameObject view gltf.scene
- view.Renderer.render(view.Scene, view.Camera)
- setText "viewport-note" (sprintf "管线几何 · %s" stepPath))
- (fun _ -> setText "viewport-note" (sprintf "管线几何加载失败 · %s" stepPath))
+ if pendingLoad = Some(generation, owner) then
+ pendingLoad <- None
+ clearGroup view.Group
+ view.Group.add gltf.scene
+ displayedNode <- Some gltf.scene
+ loadedStep <- Some owner
+ frameObject view gltf.scene
+ view.Renderer.render(view.Scene, view.Camera)
+ setText "viewport-note" (sprintf "管线几何 · %s" owner.Path)
+ else
+ disposeNodeResources gltf.scene)
+ (fun _ ->
+ if pendingLoad = Some(generation, owner) then
+ pendingLoad <- None
+ loadedStep <- None
+ setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path))
+
+let loadStepArtifact (view: ViewState) (stepPath: string) =
+ let owner =
+ { ProjectId = currentProjectId
+ RunId = currentRunId
+ Path = stepPath }
+
+ match pendingLoad with
+ | Some (_, pending) when pending = owner -> ()
+ | _ -> startStepLoad view owner
let updateState (payload: SnapshotPayload) =
let status = statusName payload
@@ -265,6 +311,11 @@ let updateState (payload: SnapshotPayload) =
setText "run-message" message
setText "project-id" payload.projectId
setText "run-id" payload.runId
+
+ if currentProjectId <> payload.projectId || currentRunId <> payload.runId then
+ invalidateLoads ()
+ displayedNode <- None
+
currentProjectId <- payload.projectId
currentRunId <- payload.runId
@@ -297,8 +348,12 @@ let updateState (payload: SnapshotPayload) =
renderFrame frame
else
let latest = steps[steps.Length - 1]
+ let owner =
+ { ProjectId = payload.projectId
+ RunId = payload.runId
+ Path = latest }
- if loadedStepPath <> Some latest then
+ if loadedStep <> Some owner then
loadStepArtifact view latest
setText "mesh-label" latest
diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs
index a0276cc..2822e33 100644
--- a/src/SomhairlesDream.Frontend/Bindings.fs
+++ b/src/SomhairlesDream.Frontend/Bindings.fs
@@ -18,6 +18,10 @@ type ThreeNode =
abstract position: ThreeVector with get
abstract rotation: ThreeVector with get
abstract background: obj with get, set
+ abstract children: ThreeNode array with get
+
+[<Emit("$0.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); })")>]
+let disposeNodeResources (node: ThreeNode) : unit = jsNative
[<Erase>]
type ThreeBox3 =
diff --git a/tools/acceptance/e2e_acceptance.py b/tools/acceptance/e2e_acceptance.py
index f792923..ee75ee6 100755
--- a/tools/acceptance/e2e_acceptance.py
+++ b/tools/acceptance/e2e_acceptance.py
@@ -17,7 +17,7 @@ Usage:
Preconditions:
dotnet tool restore (if tools are pinned in .config/dotnet-tools.json)
- dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj --outDir public
+ dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj --outDir public --noRestore --noCache
dotnet build SomhairlesDream.sln -c Release
real bpy interpreter available (default /tmp/bpyenv/bin/python)
"""
@@ -87,6 +87,32 @@ GEOMETRY_JS = """
"""
+SCENE_JS = """
+() => {
+ const v = window.SOMHAIRLES_VIEWER;
+ if (!v || !v.group) return { ready: false, reason: 'viewer handle missing' };
+ const kids = Array.from(v.group.children).map((c) => ({
+ type: c.type,
+ isMesh: c.isMesh === true,
+ color: c.material && c.material.color && c.material.color.getHexString
+ ? c.material.color.getHexString() : null,
+ childCount: c.children ? c.children.length : 0
+ }));
+ const box = new THREE.Box3().setFromObject(v.group);
+ const size = new THREE.Vector3();
+ if (!box.isEmpty()) box.getSize(size);
+ return {
+ ready: true,
+ childCount: v.group.children.length,
+ kids: kids,
+ boxSize: box.isEmpty() ? null : [size.x, size.y, size.z],
+ note: (document.querySelector('#viewport-note') || {}).textContent || '',
+ meshLabel: (document.querySelector('#mesh-label') || {}).textContent || ''
+ };
+}
+"""
+
+
def log(msg):
print(f"[acceptance] {msg}", flush=True)
@@ -222,6 +248,164 @@ def run_flow(page, base_url, run_id, viewport_label, results, evidence, shot_pre
results.append((bool(href), f"{viewport_label}: manifest link exposed ({href})"))
+def stale_load_regression(browser, base_url, track, results, evidence):
+ """Regression for async step-load ownership across runs.
+
+ Intercepts steps GLB requests and fulfills them late, out of order, or
+ with errors to prove stale responses from superseded runs never repaint
+ geometry or notes, while the current owner's loads keep working.
+ """
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+
+ def ignore_intentional_500(m):
+ return "status of 500" in m.text \
+ and "/api/artifacts/steps" in (m.location or {}).get("url", "")
+
+ track(page, ignore=ignore_intentional_500)
+ stamp = int(time.time() * 1000)
+ held = []
+
+ def hold_route(route):
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
+ held.append({
+ "route": route,
+ "project": (q.get("projectId") or [""])[0],
+ "run": (q.get("runId") or [""])[0],
+ "path": (q.get("path") or [""])[0],
+ "done": False,
+ })
+
+ page.route("**/api/artifacts/steps*", hold_route)
+ page.goto(f"{base_url}/?freeze=1", wait_until="domcontentloaded")
+ wait_text(page, "#status-label", "待机")
+
+ def start_run(run_id):
+ page.fill("#run-id-input", run_id)
+ render_box = page.query_selector("#run-render-input")
+ if render_box and render_box.is_checked():
+ render_box.set_checked(False)
+ page.click("#run-start-button")
+ wait_text(page, "#stream-state", "已连接")
+
+ def held_for(run_id, path, timeout_s=120):
+ deadline = time.time() + timeout_s
+ while time.time() < deadline:
+ for entry in held:
+ if not entry["done"] and entry["run"] == run_id and entry["path"] == path:
+ return entry
+ page.wait_for_timeout(300)
+ raise AssertionError(
+ f"no held steps request for run={run_id} path={path}; "
+ f"held={[(e['run'], e['path'], e['done']) for e in held]}")
+
+ def release(entry, status=200):
+ if status == 200:
+ q = urllib.parse.urlencode({
+ "projectId": entry["project"], "runId": entry["run"], "path": entry["path"],
+ })
+ resp_status, ctype, body = fetch_bytes(f"{base_url}/api/artifacts/steps?{q}")
+ if resp_status != 200:
+ raise AssertionError(f"artifact fetch for fulfill failed ({resp_status})")
+ else:
+ ctype, body = "text/plain", b"boom"
+ entry["route"].fulfill(status=status, content_type=ctype, body=body)
+ entry["done"] = True
+
+ def is_frame(g):
+ return bool(g.get("kids")) and g["kids"][0]["type"] == "Mesh" \
+ and g["kids"][0]["color"] == "d7a948"
+
+ def is_glb(g):
+ return bool(g.get("kids")) and g["kids"][0]["type"] == "Group"
+
+ def box_ok(g):
+ return g.get("boxSize") and all(
+ isinstance(s, (int, float)) and s > 0 for s in g["boxSize"])
+
+ # Scenario A: same path across two distinct runs must trigger a reload.
+ run_a = f"run-stale-{stamp}-a"
+ start_run(run_a)
+ release(held_for(run_a, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ ga = page.evaluate(SCENE_JS)
+ results.append((ga["ready"] and is_glb(ga) and box_ok(ga),
+ f"stale-load: run A displays GLB scene (kids={ga.get('kids')})"))
+ (evidence / "stale-scene-a.json").write_text(json.dumps(ga, indent=2, ensure_ascii=False))
+
+ run_b = f"run-stale-{stamp}-b"
+ start_run(run_b)
+ eb = held_for(run_b, "steps/01-foundation.glb")
+ results.append((True, f"stale-load: same path across runs reloads for run B ({eb['run']})"))
+ release(eb)
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ gb = page.evaluate(SCENE_JS)
+ results.append((gb["ready"] and is_glb(gb) and box_ok(gb),
+ f"stale-load: run B repaints GLB after same-path switch (kids={gb.get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-b.png"), full_page=True)
+
+ # Scenario B: late success from a superseded run must be ignored.
+ run_c = f"run-stale-{stamp}-c"
+ start_run(run_c)
+ held_for(run_c, "steps/01-foundation.glb")
+
+ run_d = f"run-stale-{stamp}-d"
+ start_run(run_d)
+ frame_geo = None
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ g = page.evaluate(SCENE_JS)
+ if g["ready"] and is_frame(g):
+ frame_geo = g
+ break
+ page.wait_for_timeout(300)
+ results.append((frame_geo is not None,
+ "stale-load: run switch with empty steps repaints replay frame mesh "
+ f"({(frame_geo or {}).get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-d-frame.png"), full_page=True)
+
+ release(held_for(run_c, "steps/01-foundation.glb"))
+ page.wait_for_timeout(600)
+ g2 = page.evaluate(SCENE_JS)
+ results.append((g2["ready"] and is_frame(g2)
+ and g2["note"] != "管线几何 · steps/01-foundation.glb",
+ f"stale-load: late old success ignored (note='{g2['note'][:40]}', "
+ f"kids={g2['kids']})"))
+
+ release(held_for(run_d, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ g3 = page.evaluate(SCENE_JS)
+ results.append((g3["ready"] and is_glb(g3) and box_ok(g3),
+ "stale-load: run D current load displays after late-old-success"))
+
+ # Scenario C: late error from a superseded run must not fail the current load.
+ run_e = f"run-stale-{stamp}-e"
+ start_run(run_e)
+ ee = held_for(run_e, "steps/01-foundation.glb")
+
+ run_f = f"run-stale-{stamp}-f"
+ start_run(run_f)
+ ef = held_for(run_f, "steps/01-foundation.glb")
+
+ release(ee, status=500)
+ page.wait_for_timeout(600)
+ g4 = page.evaluate(SCENE_JS)
+ results.append(("加载失败" not in g4["note"],
+ f"stale-load: late old error ignored (note='{g4['note'][:40]}')"))
+
+ release(ef)
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ g5 = page.evaluate(SCENE_JS)
+ results.append((g5["ready"] and is_glb(g5) and box_ok(g5) and "加载失败" not in g5["note"],
+ "stale-load: current owner load unaffected by stale error"))
+ (evidence / "stale-scene-final.json").write_text(json.dumps(g5, indent=2, ensure_ascii=False))
+ page.screenshot(path=str(evidence / "stale-page-final.png"), full_page=True)
+ page.close()
+
+
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", type=int, default=18201)
@@ -274,8 +458,11 @@ def main():
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--enable-unsafe-swiftshader"])
- def track(page):
- page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
+ def track(page, ignore=None):
+ def on_console(m):
+ if m.type == "error" and not (ignore and ignore(m)):
+ console_errors.append(m.text)
+ page.on("console", on_console)
page.on("pageerror", lambda e: console_errors.append(str(e)))
desktop_ms = int(time.time() * 1000)
@@ -302,6 +489,8 @@ def main():
expected = REFERENCE_SHA[step_path]
results.append((digest == expected, f"sha256 {step_path} -> {digest[:12]}… (expect {expected[:12]}…)"))
+ stale_load_regression(browser, base_url, track, results, evidence)
+
fail_ms = int(time.time() * 1000) + 2
fail_run_id = f"run-acc-{fail_ms}-failure"
(artifact_root / project_id / fail_run_id).mkdir(parents=True, exist_ok=True)