Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/windows-render.yml
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,11 @@ jobs:
shell: pwsh
run: bun run --cwd packages/studio-server test -- src/helpers/waveform.test.ts src/helpers/waveform.file-race.test.ts --maxWorkers=2

- name: Verify producer lint entry reads on Windows
if: matrix.lane == 'studio-engine-cli'
shell: pwsh
run: bunx vitest run packages/producer/src/services/hyperframeLint.file-race.test.ts --maxWorkers=2

- name: Run runtime contract test
if: matrix.runtime_contract
shell: pwsh
Expand Down
1 change: 1 addition & 0 deletions packages/producer/scripts/test-classification.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const INTEGRATION_TEST_FILES = new Set([
"src/services/fileServer.test.ts",
"src/services/fileServer.file-race.test.ts",
"src/services/healthWorker.test.ts",
"src/services/hyperframeLint.file-race.test.ts",
"src/services/assetMediaType.test.ts",
"src/services/htmlCompiler.mediaType.test.ts",
"src/services/htmlCompiler.naturalDuration.test.ts",
Expand Down
164 changes: 164 additions & 0 deletions packages/producer/src/services/hyperframeLint.file-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import { prepareHyperframeLintBody } from "./hyperframeLint.js";

const hooks = vi.hoisted(() => {
const state: {
swap?: () => void;
target: string;
descriptors: Set<number>;
failStat: boolean;
failRead: boolean;
} = {
target: "",
descriptors: new Set(),
failStat: false,
failRead: false,
};
return state;
});
vi.mock("node:fs", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs")>();
const swap = () => {
const run = hooks.swap;
hooks.swap = undefined;
run?.();
};
return {
...fs,
openSync: (...args: Parameters<typeof fs.openSync>) => {
const fd = fs.openSync(...args);
hooks.descriptors.add(fd);
return fd;
},
statSync: (...args: Parameters<typeof fs.statSync>) => {
const stat = fs.statSync(...args);
if (args[0] === hooks.target) swap();
return stat;
},
fstatSync: (...args: Parameters<typeof fs.fstatSync>) => {
if (hooks.failStat) throw new Error("stat failure");
const stat = fs.fstatSync(...args);
swap();
return stat;
},
readFileSync: (...args: Parameters<typeof fs.readFileSync>) => {
if (hooks.failRead) throw new Error("read failure");
return fs.readFileSync(...args);
},
closeSync: (fd: number) => {
fs.closeSync(fd);
hooks.descriptors.delete(fd);
},
};
});
vi.mock("@hyperframes/lint", () => ({ lintHyperframeHtml: vi.fn() }));
const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
let root: string;
let projectDir: string;
beforeEach(() => {
root = fs.mkdtempSync(join(tmpdir(), "hf-lint-entry-"));
projectDir = join(root, "project");
fs.mkdirSync(projectDir);
Object.assign(hooks, { target: "", swap: undefined, failStat: false, failRead: false });
});
afterEach(() => {
const leaked = [...hooks.descriptors];
for (const fd of leaked) fs.closeSync(fd);
hooks.descriptors.clear();
fs.rmSync(root, { recursive: true, force: true });
expect(leaked).toEqual([]);
});
function writeEntry(name: string, html = "<html>checked</html>") {
const path = join(projectDir, name);
fs.mkdirSync(resolve(path, ".."), { recursive: true });
fs.writeFileSync(path, html);
return path;
}
function prepare(entryFile?: string) {
return prepareHyperframeLintBody({ projectDir, entryFile });
}
function expected(entryFile: string, html = "<html>checked</html>") {
return { prepared: { entryFile, html, source: "projectDir" } };
}

describe("project entry reads", () => {
it.each(["preferred.html", "index.html", "src/index.html"])(
"reads the checked file when %s is replaced",
(entry) => {
hooks.target = writeEntry(entry);
const replacement = join(root, "replacement.html");
fs.writeFileSync(replacement, "<html>unchecked</html>");
hooks.swap = () => fs.renameSync(replacement, hooks.target);

Check failure on line 94 in packages/producer/src/services/hyperframeLint.file-race.test.ts

View workflow job for this annotation

GitHub Actions / Tests on windows-latest: studio-engine-cli

packages/producer/src/services/hyperframeLint.file-race.test.ts > project entry reads > reads the checked file when src/index.html is replaced

Error: EPERM: operation not permitted, rename 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-JQ4MSD\replacement.html' -> 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-JQ4MSD\project\src\index.html' ❯ hooks.swap packages/producer/src/services/hyperframeLint.file-race.test.ts:94:29 ❯ swap packages/producer/src/services/hyperframeLint.file-race.test.ts:27:5 ❯ fstatSync packages/producer/src/services/hyperframeLint.file-race.test.ts:44:7 ❯ readEntryHtml packages/producer/src/services/hyperframeLint.ts:66:10 ❯ readProjectEntryFile packages/producer/src/services/hyperframeLint.ts:96:18 ❯ Module.prepareHyperframeLintBody packages/producer/src/services/hyperframeLint.ts:120:22 ❯ prepare packages/producer/src/services/hyperframeLint.file-race.test.ts:81:10 ❯ packages/producer/src/services/hyperframeLint.file-race.test.ts:95:14 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4048, code: 'EPERM', syscall: 'rename', path: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-JQ4MSD\replacement.html', dest: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-JQ4MSD\project\src\index.html' }

Check failure on line 94 in packages/producer/src/services/hyperframeLint.file-race.test.ts

View workflow job for this annotation

GitHub Actions / Tests on windows-latest: studio-engine-cli

packages/producer/src/services/hyperframeLint.file-race.test.ts > project entry reads > reads the checked file when index.html is replaced

Error: EPERM: operation not permitted, rename 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-UMEONe\replacement.html' -> 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-UMEONe\project\index.html' ❯ hooks.swap packages/producer/src/services/hyperframeLint.file-race.test.ts:94:29 ❯ swap packages/producer/src/services/hyperframeLint.file-race.test.ts:27:5 ❯ fstatSync packages/producer/src/services/hyperframeLint.file-race.test.ts:44:7 ❯ readEntryHtml packages/producer/src/services/hyperframeLint.ts:66:10 ❯ readProjectEntryFile packages/producer/src/services/hyperframeLint.ts:96:18 ❯ Module.prepareHyperframeLintBody packages/producer/src/services/hyperframeLint.ts:120:22 ❯ prepare packages/producer/src/services/hyperframeLint.file-race.test.ts:81:10 ❯ packages/producer/src/services/hyperframeLint.file-race.test.ts:95:14 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4048, code: 'EPERM', syscall: 'rename', path: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-UMEONe\replacement.html', dest: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-UMEONe\project\index.html' }

Check failure on line 94 in packages/producer/src/services/hyperframeLint.file-race.test.ts

View workflow job for this annotation

GitHub Actions / Tests on windows-latest: studio-engine-cli

packages/producer/src/services/hyperframeLint.file-race.test.ts > project entry reads > reads the checked file when preferred.html is replaced

Error: EPERM: operation not permitted, rename 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-y72L6f\replacement.html' -> 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-y72L6f\project\preferred.html' ❯ hooks.swap packages/producer/src/services/hyperframeLint.file-race.test.ts:94:29 ❯ swap packages/producer/src/services/hyperframeLint.file-race.test.ts:27:5 ❯ fstatSync packages/producer/src/services/hyperframeLint.file-race.test.ts:44:7 ❯ readEntryHtml packages/producer/src/services/hyperframeLint.ts:66:10 ❯ readProjectEntryFile packages/producer/src/services/hyperframeLint.ts:96:18 ❯ Module.prepareHyperframeLintBody packages/producer/src/services/hyperframeLint.ts:120:22 ❯ prepare packages/producer/src/services/hyperframeLint.file-race.test.ts:81:10 ❯ packages/producer/src/services/hyperframeLint.file-race.test.ts:95:14 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4048, code: 'EPERM', syscall: 'rename', path: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-y72L6f\replacement.html', dest: 'C:\Users\RUNNER~1\AppData\Local\Temp\hf-lint-entry-y72L6f\project\preferred.html' }
expect(prepare(entry === "preferred.html" ? entry : undefined)).toEqual(expected(entry));
expect(hooks.swap).toBeUndefined();
},
);
it("prefers requested entries and accepts empty HTML", () => {
writeEntry("preferred.html", "");
writeEntry("index.html");
expect(prepare("preferred.html")).toEqual(expected("preferred.html", ""));
});
it.each(["missing", "directory", "dangling", "non-directory-parent"])(
"falls back from a %s entry",
(kind) => {
writeEntry("index.html");
let preferred = "preferred.html";
if (kind === "directory") fs.mkdirSync(join(projectDir, preferred));
if (kind === "dangling")
fs.symlinkSync(join(root, "absent"), join(projectDir, preferred), "file");
if (kind === "non-directory-parent") {
writeEntry("parent");
preferred = "parent/file.html";
}
expect(prepare(preferred)).toEqual(expected("index.html"));
},
);
it.skipIf(process.platform === "win32")("skips a FIFO without waiting for a writer", () => {
execFileSync("mkfifo", [join(projectDir, "pipe")]);
writeEntry("index.html");
expect(prepare("pipe")).toEqual(expected("index.html"));
});
it("keeps project and entry symlinks working", () => {
const target = join(root, "linked.html");
fs.writeFileSync(target, "linked");
fs.symlinkSync(target, join(projectDir, "index.html"), "file");
const linkedProject = join(root, "linked-project");
fs.symlinkSync(projectDir, linkedProject, process.platform === "win32" ? "junction" : "dir");
expect(prepareHyperframeLintBody({ projectDir: linkedProject })).toEqual(
expected("index.html", "linked"),
);
});
it("rejects a sibling directory sharing the project name prefix", () => {
const sibling = join(root, "project-other");
fs.mkdirSync(sibling);
fs.writeFileSync(join(sibling, "index.html"), "outside");
expect(prepare("../project-other/index.html")).toEqual({
error: "Entry file must stay inside project directory: ../project-other/index.html",
});
});
it("preserves absent-project and absent-entry errors", () => {
expect(prepare()).toEqual({
error: `No HTML entry file found in project directory: ${join(projectDir, "index.html")}`,
});
fs.rmdirSync(projectDir);
expect(prepare()).toEqual({ error: `Project directory not found: ${projectDir}` });
});
it.each(["stat", "read"])("propagates %s failures and closes the descriptor", (failure) => {
writeEntry("index.html");
hooks.failStat = failure === "stat";
hooks.failRead = failure === "read";
expect(() => prepare()).toThrow(`${failure} failure`);
expect(hooks.descriptors.size).toBe(0);
});
it("keeps inline HTML and files-payload entry selection unchanged", () => {
expect(prepareHyperframeLintBody({ html: "", entryFile: " custom.html " })).toEqual({
prepared: { html: "", entryFile: "custom.html", source: "html" },
});
expect(
prepareHyperframeLintBody({ files: { "src/index.html": "source", "index.html": "" } }),
).toEqual({ prepared: { html: "source", entryFile: "src/index.html", source: "files" } });
});
});
48 changes: 43 additions & 5 deletions packages/producer/src/services/hyperframeLint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve, join } from "node:path";
import {
existsSync,
readFileSync,
statSync,
openSync,
fstatSync,
closeSync,
constants,
} from "node:fs";
import { resolve, join, relative, isAbsolute, sep } from "node:path";
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";

export interface PreparedHyperframeLintInput {
Expand Down Expand Up @@ -38,6 +46,30 @@ function pickEntryFile(files: Record<string, string>, preferredEntryFile?: strin
return null;
}

function readEntryHtml(filePath: string): string | null {
let fd: number;
try {
// Opening a named pipe must not wait for a writer before fstat can reject it.
fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
)
return null;
// Classify platform-specific directory/socket errors only after open fails.
if (!statSync(filePath, { throwIfNoEntry: false })?.isFile()) return null;
throw error;
}
try {
if (!fstatSync(fd).isFile()) return null;
return readFileSync(fd, "utf-8");
} finally {
closeSync(fd);
}
}

function readProjectEntryFile(
projectDir: string,
preferredEntryFile?: string,
Expand All @@ -53,13 +85,19 @@ function readProjectEntryFile(

for (const entryFile of entryCandidates) {
const absoluteEntryPath = resolve(absProjectDir, entryFile);
if (!absoluteEntryPath.startsWith(absProjectDir)) {
const entryRelativePath = relative(absProjectDir, absoluteEntryPath);
if (
entryRelativePath === ".." ||
entryRelativePath.startsWith(`..${sep}`) ||
isAbsolute(entryRelativePath)
) {
return { error: `Entry file must stay inside project directory: ${entryFile}` };
}
if (existsSync(absoluteEntryPath) && statSync(absoluteEntryPath).isFile()) {
const html = readEntryHtml(absoluteEntryPath);
if (html !== null) {
return {
entryFile,
html: readFileSync(absoluteEntryPath, "utf-8"),
html,
source: "projectDir",
};
}
Expand Down
Loading