fix(core): correct WAAPI rediscovery seek baselines and preview reuse invalidation

This commit is contained in:
func25
2026-05-25 17:51:47 +07:00
parent 68fce93a49
commit e10ec358f8
7 changed files with 287 additions and 16 deletions
+10 -2
View File
@@ -398,7 +398,8 @@ async function runEmbeddedMode(
userDataDir?: string;
},
): Promise<void> {
const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js");
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
await import("../server/studioServer.js");
const pName = options?.projectName ?? basename(dir);
const studioBundle = resolveStudioBundle();
@@ -422,10 +423,17 @@ async function runEmbeddedMode(
}
const { app } = createStudioServer({ projectDir: dir, projectName: pName });
const serverBuildSignature = await loadPreviewServerBuildSignature();
let result: FindPortResult;
try {
result = await findPortAndServe(app.fetch, startPort, dir, !!options?.forceNew);
result = await findPortAndServe(
app.fetch,
startPort,
dir,
!!options?.forceNew,
serverBuildSignature,
);
} catch (err: unknown) {
s.stop(c.error("Failed to start studio"));
console.error();
+56 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createServer, type Server } from "node:net";
import { PORT_PROBE_HOSTS, testPortOnAllHosts } from "./portUtils.js";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
import { PORT_PROBE_HOSTS, detectHyperframesServer, testPortOnAllHosts } from "./portUtils.js";
const openServers: Server[] = [];
const openHttpServers: HttpServer[] = [];
async function allocFreePort(): Promise<number> {
const srv = createServer();
@@ -24,9 +26,30 @@ afterEach(async () => {
}),
),
);
await Promise.all(
openHttpServers.splice(0).map(
(s) =>
new Promise<void>((resolve) => {
s.close(() => resolve());
}),
),
);
vi.restoreAllMocks();
});
async function startConfigProbeServer(payload: Record<string, unknown>): Promise<number> {
const server = createHttpServer((_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(payload));
});
openHttpServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
return (server.address() as import("node:net").AddressInfo).port;
}
describe("testPortOnAllHosts — real-socket behaviour (OS-dependent)", () => {
// These exercise the real network stack. On Linux the buggy parallel
// implementation reliably fails the first test (issue #309 repro); on
@@ -97,3 +120,35 @@ describe("testPortOnAllHosts — sequential contract (platform-agnostic)", () =>
expect(hostsProbed).toEqual(["127.0.0.1", "0.0.0.0"]);
});
});
describe("detectHyperframesServer", () => {
it("treats same-project servers with a different server build signature as mismatch", async () => {
const projectDir = "/tmp/demo-project";
const port = await startConfigProbeServer({
isHyperframes: true,
projectName: "demo-project",
projectDir,
serverBuildSignature: "old-build",
version: "0.6.42",
});
const result = await detectHyperframesServer(port, projectDir, "new-build");
expect(result).toEqual({ type: "mismatch", projectName: "demo-project" });
});
it("treats same-project servers with the same server build signature as match", async () => {
const projectDir = "/tmp/demo-project";
const port = await startConfigProbeServer({
isHyperframes: true,
projectName: "demo-project",
projectDir,
serverBuildSignature: "same-build",
version: "0.6.42",
});
const result = await detectHyperframesServer(port, projectDir, "same-build");
expect(result).toEqual({ type: "match" });
});
});
+14 -1
View File
@@ -99,6 +99,7 @@ interface HyperframesConfigResponse {
isHyperframes: boolean;
projectName: string;
projectDir: string;
serverBuildSignature?: string | null;
version: string;
}
@@ -114,6 +115,7 @@ export type DetectionResult =
export function detectHyperframesServer(
port: number,
normalizedProjectDir: string,
expectedServerBuildSignature: string | null = null,
): Promise<DetectionResult> {
return new Promise<DetectionResult>((resolveResult) => {
const req = http.get(
@@ -152,6 +154,12 @@ export function detectHyperframesServer(
const normalize = (p: string) => resolve(p).replace(/\\/g, "/").toLowerCase();
if (normalize(json.projectDir) === normalizedProjectDir) {
if (
expectedServerBuildSignature !== null &&
json.serverBuildSignature !== expectedServerBuildSignature
) {
return resolveResult({ type: "mismatch", projectName: json.projectName });
}
return resolveResult({ type: "match" });
}
@@ -327,6 +335,7 @@ export async function findPortAndServe(
startPort: number,
projectDir: string,
forceNew: boolean,
expectedServerBuildSignature: string | null = null,
): Promise<FindPortResult> {
const { createAdaptorServer } = await import("@hono/node-server");
const normalizedDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase();
@@ -366,7 +375,11 @@ export async function findPortAndServe(
// Port is occupied — probe for existing HyperFrames instance
if (!forceNew) {
const detection = await detectHyperframesServer(port, normalizedDir);
const detection = await detectHyperframesServer(
port,
normalizedDir,
expectedServerBuildSignature,
);
if (detection.type === "match") {
return { type: "already-running", port };
}
+16
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
@@ -16,6 +17,21 @@ export async function loadRuntimeSource(): Promise<string | null> {
return (await buildFromSource()) ?? (await getInlinedRuntime()) ?? readPrebuiltArtifact();
}
export async function loadRuntimeSourceSignature(): Promise<string | null> {
const source = await loadRuntimeSource();
if (!source) return null;
return createHash("sha256").update(source).digest("hex");
}
export function hashSignatureParts(parts: Array<string | null | undefined>): string {
const hash = createHash("sha256");
for (const part of parts) {
hash.update(part ?? "");
hash.update("\n--hf-signature-part--\n");
}
return hash.digest("hex");
}
// ── Strategy 1: live build from source (dev only) ──────────────────────────
const ENTRY_TS = resolve(__dirname, "..", "..", "..", "core", "src", "runtime", "entry.ts");
+35 -7
View File
@@ -10,7 +10,11 @@ import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import { loadRuntimeSource } from "./runtimeSource.js";
import {
hashSignatureParts,
loadRuntimeSource,
loadRuntimeSourceSignature,
} from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
import {
@@ -184,6 +188,25 @@ export interface StudioServer {
watcher: ProjectWatcher;
}
export async function loadPreviewServerBuildSignature(): Promise<string> {
const runtimeSignature = await loadRuntimeSourceSignature();
const studioBundle = resolveStudioBundle();
const studioIndex =
studioBundle.available && existsSync(studioBundle.indexPath)
? readFileSync(studioBundle.indexPath, "utf-8")
: "";
return hashSignatureParts([
version,
runtimeSignature,
studioIndex,
createStudioServer.toString(),
createStudioApi.toString(),
createProjectSignature.toString(),
getMimeType.toString(),
getElementScreenshotClip.toString(),
]);
}
export function createStudioServer(options: StudioServerOptions): StudioServer {
const { projectDir, projectName } = options;
const projectId = projectName || basename(projectDir);
@@ -445,12 +468,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// HyperFrames instances and reuse them instead of spawning duplicates.
// See portUtils.ts detectHyperframesServer() for the consumer.
app.get("/__hyperframes_config", (c) => {
return c.json({
isHyperframes: true,
projectName: projectId,
projectDir: projectDir,
version,
});
const serve = async () => {
const serverBuildSignature = await loadPreviewServerBuildSignature();
return c.json({
isHyperframes: true,
projectName: projectId,
projectDir: projectDir,
serverBuildSignature,
version,
});
};
return serve();
});
// CLI-specific routes (before shared API)
@@ -1,7 +1,24 @@
import { describe, it, expect, vi } from "vitest";
import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
import { createWaapiAdapter } from "./waapi";
describe("waapi adapter", () => {
const originalDocument = (globalThis as { document?: unknown }).document;
beforeEach(() => {
(globalThis as { document?: unknown }).document = {
getAnimations: vi.fn(() => []),
};
});
afterEach(() => {
if (originalDocument === undefined) {
delete (globalThis as { document?: unknown }).document;
return;
}
(globalThis as { document?: unknown }).document = originalDocument;
});
it("has correct name", () => {
expect(createWaapiAdapter().name).toBe("waapi");
});
@@ -87,4 +104,76 @@ describe("waapi adapter", () => {
const adapter = createWaapiAdapter();
expect(() => adapter.discover()).not.toThrow();
});
it("anchors newly discovered WAAPI animations to the seek where they first appear", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 0 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
adapter.seek({ time: 0.7 });
expect(existing.currentTime).toBe(700);
expect(dynamic.currentTime).toBe(0);
adapter.seek({ time: 0.8 });
expect(dynamic.currentTime).toBe(100);
delete (document as any).getAnimations;
});
it("rebases newly discovered WAAPI animations that inherit absolute composition time", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 700 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(0);
adapter.seek({ time: 0.8 });
expect(dynamic.currentTime).toBe(100);
delete (document as any).getAnimations;
});
it("does not double-count inherited absolute time when discover runs again after time has advanced", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 700 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
adapter.discover();
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(200);
delete (document as any).getAnimations;
});
});
+66 -4
View File
@@ -2,15 +2,77 @@ import type { RuntimeDeterministicAdapter } from "../types";
import { swallow } from "../diagnostics";
export function createWaapiAdapter(): RuntimeDeterministicAdapter {
let didDiscover = false;
let lastSeekTimeMs = 0;
const baselines = new WeakMap<
Animation,
{
compositionTimeMs: number;
animationTimeMs: number;
}
>();
const snapshotAnimations = () => {
if (!document.getAnimations) return [];
try {
return document.getAnimations();
} catch {
return [];
}
};
const readAnimationTimeMs = (animation: Animation) => {
const raw = Number(animation.currentTime);
return Number.isFinite(raw) && raw > 0 ? raw : 0;
};
const normalizeInitialAnimationTimeMs = (animationTimeMs: number, compositionTimeMs: number) => {
if (compositionTimeMs <= 0) {
return animationTimeMs;
}
if (animationTimeMs >= compositionTimeMs) {
return Math.max(0, animationTimeMs - compositionTimeMs);
}
return animationTimeMs;
};
const ensureBaseline = (animation: Animation, compositionTimeMs: number) => {
const existing = baselines.get(animation);
if (existing) {
return existing;
}
const baseline = {
compositionTimeMs,
animationTimeMs: didDiscover
? normalizeInitialAnimationTimeMs(readAnimationTimeMs(animation), compositionTimeMs)
: readAnimationTimeMs(animation),
};
baselines.set(animation, baseline);
return baseline;
};
return {
name: "waapi",
discover: () => {},
discover: () => {
didDiscover = true;
for (const animation of snapshotAnimations()) {
ensureBaseline(animation, lastSeekTimeMs);
}
},
seek: (ctx) => {
if (!document.getAnimations) return;
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
for (const animation of document.getAnimations()) {
lastSeekTimeMs = timeMs;
for (const animation of snapshotAnimations()) {
const baseline = didDiscover
? ensureBaseline(animation, timeMs)
: ensureBaseline(animation, 0);
const localTimeMs =
baseline.animationTimeMs + Math.max(0, timeMs - baseline.compositionTimeMs);
try {
animation.currentTime = timeMs;
animation.currentTime = localTimeMs;
} catch (err) {
// ignore animations that reject currentTime writes
swallow("runtime.adapters.waapi.site1", err);