Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 23 additions & 17 deletions packages/core/src/temp-file-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* `useTempFileCompiler()`.
*/

import { call } from "effection";
import { ensure, scoped, until } from "effection";
import type { Operation } from "effection";
import type { EvalBlock } from "@executablemd/runtime";
import { API } from "@executablemd/runtime";
Expand All @@ -23,7 +23,7 @@
// the packages from the binary and every eval block using them fails.
import "@effectionx/converge";
import "@effectionx/fetch";
import { writeFile, unlink, mkdir } from "node:fs/promises";
import { ensureDir, rm, writeTextFile } from "@effectionx/fs";
import { resolve } from "node:path";
import { randomUUID } from "node:crypto";

Expand All @@ -45,26 +45,34 @@
*
* Every host can load a file, which is what makes this the portable one;
* Node's tsx loader in particular rejects the data: URI alternative.
*
* The generated file belongs to one private scope. Its removal is registered
* against the path before anything can create it, so the file is gone before
* this operation returns a block, throws, or finishes halting — and a removal
* that fails for any reason other than the file already being absent leaves
* that scope rather than being discarded.
*/
export function* compileTempFile(
export function compileTempFile(
source: string,
options?: { imports: string[] },
): Operation<EvalBlock> {
yield* call(() => mkdir(EVAL_DIR, { recursive: true }));
return scoped(function* () {
yield* ensureDir(EVAL_DIR);

const userImports = options?.imports ?? [];
const allImports = [...STANDARD_IMPORTS, ...userImports];

const userImports = options?.imports ?? [];
const allImports = [...STANDARD_IMPORTS, ...userImports];
const importLines = allImports.join("\n");

const importLines = allImports.join("\n");
const moduleSource = [importLines, `export default function*(env) {`, source, `}`].join("\n");

const moduleSource = [importLines, `export default function*(env) {`, source, `}`].join("\n");
const tmpPath = resolve(EVAL_DIR, `${randomUUID()}.ts`);
yield* ensure(() => rm(tmpPath, { force: true }));

const tmpPath = resolve(EVAL_DIR, `${randomUUID()}.ts`);
yield* writeTextFile(tmpPath, moduleSource);

yield* call(() => writeFile(tmpPath, moduleSource, "utf-8"));
try {
const fileUrl = new URL(`file://${tmpPath}`).href;
const mod: { default: EvalBlock } = yield* call(() => import(fileUrl));
const mod: { default: EvalBlock } = yield* until(import(fileUrl));

Check warning on line 75 in packages/core/src/temp-file-compiler.ts

View workflow job for this annotation

GitHub Actions / jsr

unable to analyze dynamic import

if (typeof mod.default !== "function") {
throw new Error(
Expand All @@ -73,9 +81,7 @@
}

return mod.default;
} finally {
unlink(tmpPath).catch(() => {});
}
});
}

/**
Expand All @@ -87,8 +93,8 @@
export function* useTempFileCompiler(): Operation<void> {
yield* API.Env.around(
{
*compile([source, options]) {
return yield* compileTempFile(source, options);
compile([source, options]) {
return compileTempFile(source, options);
},
},
{ at: "min" },
Expand Down
200 changes: 200 additions & 0 deletions packages/core/tests/temp-file-compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Tier TC — the temp-file compiler's generated file.
*
* What these assert is ownership rather than compilation: the file
* `compileTempFile` generates belongs to one private scope, and that scope has
* finished with it before compilation settles — on success, on a failing
* import, and on cancellation.
*
* `@effectionx/fs` is a contextual Api, so `FsApi.around()` observes the real
* write and the real removal as they happen. The recorded log is what makes
* "before it returned" falsifiable: a removal launched and left running is
* absent from it at the moment the assertion reads it.
*
* `.xmd-eval` is shared, and other tests compile into it concurrently, so every
* assertion here names the one file this compilation generated.
*/

import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { ensure, scoped, spawn, suspend, withResolvers } from "effection";
import type { Operation } from "effection";
import { FsApi, exists, rm, toPath } from "@effectionx/fs";
import { basename, dirname } from "node:path";
import { compileTempFile } from "../src/temp-file-compiler.ts";

const GENERATED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.ts$/;

/** The name of a generated eval module, or nothing for any other path. */
function generated(pathOrUrl: string | URL): string | undefined {
const path = toPath(pathOrUrl);
if (basename(dirname(path)) !== ".xmd-eval") {
return undefined;
}
return basename(path);
}

interface Recorder {
/** `write`/`wrote` and `remove`/`removed`, in the order they happened. */
readonly log: string[];
/** Every generated file this test caused, for the safety cleanup. */
readonly written: string[];
}

/**
* Observe the generated files of the compilations that run inside this scope.
*
* The removal that guards against a leak is registered before the observer, so
* a run that fails its assertions — or a mutant that never removes anything —
* still leaves `.xmd-eval` as it found it.
*/
function* useRecorder(): Operation<Recorder> {
const log: string[] = [];
const written: string[] = [];

yield* ensure(function* () {
for (const path of written) {
yield* rm(path, { force: true });
}
});

yield* FsApi.around({
*writeTextFile([path, content], next) {
const name = generated(path);
if (name === undefined) {
return yield* next(path, content);
}
written.push(toPath(path));
log.push(`write ${name}`);
yield* next(path, content);
log.push(`wrote ${name}`);
},
*rm([path, options], next) {
const name = generated(path);
if (name === undefined) {
return yield* next(path, options);
}
log.push(`remove ${name}`);
yield* next(path, options);
log.push(`removed ${name}`);
},
});

return { log, written };
}

/** The one generated file a log describes, refusing a log about several. */
function only(log: string[]): string {
const names = new Set(log.map((entry) => entry.split(" ")[1]));
expect([...names].length).toBe(1);
const [name] = names;
expect(name).toMatch(GENERATED);
return name ?? "";
}

describe("Tier TC — temp-file compiler lifecycle", () => {
// TC1: the ordinary path. The write and the removal name the same generated
// file, and the removal has completed by the time a block comes back.
it("TC1: a successful compilation removes its generated file before it returns", function* () {
const recorder = yield* useRecorder();

const block = yield* compileTempFile("env.compiled = true;");
const log = [...recorder.log];

const name = only(log);
expect(log).toEqual([`write ${name}`, `wrote ${name}`, `remove ${name}`, `removed ${name}`]);
expect(recorder.written.length).toBe(1);
expect(yield* exists(recorder.written[0] ?? "")).toBe(false);

// The block that came back is the compiled one, not a leftover.
const env: Record<string, unknown> = {};
yield* block(env);
expect(env["compiled"]).toBe(true);
});

// TC2: a failing import still propagates, and it arrives at the caller after
// the file is gone rather than instead of removing it.
it("TC2: a failing import propagates with the generated file already removed", function* () {
const recorder = yield* useRecorder();

let outcome: unknown = "never settled";
try {
yield* compileTempFile("const broken = ;");
outcome = "returned a block";
} catch (error) {
outcome = error;
}
const log = [...recorder.log];

expect(outcome).not.toBe("never settled");
expect(outcome).not.toBe("returned a block");

const name = only(log);
expect(log).toEqual([`write ${name}`, `wrote ${name}`, `remove ${name}`, `removed ${name}`]);
expect(yield* exists(recorder.written[0] ?? "")).toBe(false);
});

// TC3: cancellation, waited for rather than timed. The write delegates so the
// real file exists, tells the test so, and then suspends where a halt lands
// between creating the file and doing anything else with it.
it("TC3: halting a compilation removes its generated file before the halt settles", function* () {
const recorder = yield* useRecorder();
const created = withResolvers<string>();

yield* scoped(function* () {
yield* FsApi.around({
*writeTextFile([path, content], next) {
yield* next(path, content);
created.resolve(toPath(path));
yield* suspend();
},
});

const compilation = yield* spawn(() => compileTempFile("env.unreachable = true;"));
const path = yield* created.operation;

expect(generated(path)).toMatch(GENERATED);
expect(yield* exists(path)).toBe(true);

yield* compilation.halt();
expect(yield* exists(path)).toBe(false);
});

expect(recorder.written.length).toBe(1);
});

// TC4: the removal is not best-effort. A cleanup that fails for any reason
// other than the file already being absent decides the compilation, so a
// caller cannot be handed a block whose scratch state is still on disk.
it("TC4: a failing removal fails the compilation instead of being discarded", function* () {
const recorder = yield* useRecorder();
const refused = new Error("TC4 sentinel: removal refused");
let outcome: unknown = "never settled";

// The interceptor lives inside this scope so the safety cleanup registered
// by the recorder — which runs outside it — can still remove what it must.
yield* scoped(function* () {
yield* FsApi.around({
*rm([path, options], next) {
if (generated(path) === undefined) {
return yield* next(path, options);
}
yield* next(path, options);
throw refused;
},
});

try {
yield* compileTempFile("env.compiled = true;");
outcome = "returned a block";
} catch (error) {
outcome = error;
}
});

// The exact error, neither swallowed nor replaced with one of its own.
expect(outcome).toBe(refused);
expect(recorder.written.length).toBe(1);
expect(yield* exists(recorder.written[0] ?? "")).toBe(false);
});
});
25 changes: 25 additions & 0 deletions specs/executable-mdx-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,22 @@ wraps it rather than racing it. The behavior-document policy in
block, rejects static and dynamic imports, and delegates the rest to whatever
the entrypoint installed.

#### The generated file belongs to the compilation

`useTempFileCompiler()` writes a file, so it owns one. Each compilation runs in
a private scope, and the removal of `.xmd-eval/<uuid>.ts` is registered against
that path before anything can create it. The file is therefore gone before the
compilation settles, whichever way it settles: before a compiled block is
returned, before a failing import reaches the caller, and before a cancelled
compilation finishes halting. A removal that fails for any reason other than
the file already being absent leaves that scope rather than being discarded.

`.xmd-eval` is a relative literal, resolved against the host process's current
working directory when a compilation chooses the path. Running
`path/to/document.md` does not itself move that directory to the document's
directory, and the contextual `API.Env.cwd` does not control it, because this
compiler does not consult that Api.

#### Standard imports

Every generated eval module is prepended with standard imports:
Expand Down Expand Up @@ -6289,6 +6305,15 @@ visible warning blocks, gather into a separate error report).
| CB2 | Eval block, no compiler | An eval block with no middleware installed fails with `compiler not installed — install platform-specific middleware via API.Env.around()` |
| CB3 | Caller's compiler wins | A compiler installed before `execute()` receives the block source; `execute()` neither replaces nor shadows it |

### Tier TC — Temp-file compiler lifecycle (`temp-file-compiler`)

| # | Test | Verify |
|---|------|--------|
| TC1 | Success removes the generated file | The write and the removal observed through `FsApi.around()` name the same `.xmd-eval/<uuid>.ts`, and the removal has completed when `compileTempFile()` returns the block |
| TC2 | A failing import removes it first | Generated code that does not parse still propagates its import failure, and the removal has completed when that failure reaches the caller |
| TC3 | Cancellation removes it first | A compilation halted while its write is suspended has no generated file left once `halt()` settles |
| TC4 | A failing removal is not discarded | A removal that performs the real deletion and then fails leaves that exact error as the compilation's outcome, rather than a returned block or a substituted error |

### Tier I — Middleware conformance (eval modifiers)

| # | Test | Verify |
Expand Down
Loading