From 33a3c100930f2ee2aa5bde8939bc5c4542442db8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:13:46 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Give=20every=20generat?= =?UTF-8?q?ed=20eval=20module=20a=20scope-owned=20lifetime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temp-file compiler wrote `.xmd-eval/.ts` and then dropped its removal into a `finally` as a fire-and-forget promise, so the file outlived the compilation by an unbounded amount and a removal that failed was discarded unread. Each compilation now runs in a private scope, and the removal is registered against the UUID path before the write can begin. The file is gone before the compilation settles — before a block is returned, before a failing import reaches the caller, and before a cancelled compilation finishes halting — and a removal that fails for any reason other than the file already being absent leaves that scope. Filesystem work goes through `@effectionx/fs`, and the dynamic import through `until`, so no `node:fs` operation and no `call()`-wrapped promise remain. --- packages/core/src/temp-file-compiler.ts | 40 +++-- .../core/tests/temp-file-compiler.test.ts | 165 ++++++++++++++++++ specs/executable-mdx-spec.md | 21 +++ 3 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 packages/core/tests/temp-file-compiler.test.ts diff --git a/packages/core/src/temp-file-compiler.ts b/packages/core/src/temp-file-compiler.ts index 01cf2075..69643088 100644 --- a/packages/core/src/temp-file-compiler.ts +++ b/packages/core/src/temp-file-compiler.ts @@ -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"; @@ -23,7 +23,7 @@ import { API } from "@executablemd/runtime"; // 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"; @@ -45,26 +45,34 @@ const EVAL_DIR = ".xmd-eval"; * * 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 { - 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)); if (typeof mod.default !== "function") { throw new Error( @@ -73,9 +81,7 @@ export function* compileTempFile( } return mod.default; - } finally { - unlink(tmpPath).catch(() => {}); - } + }); } /** @@ -87,8 +93,8 @@ export function* compileTempFile( export function* useTempFileCompiler(): Operation { yield* API.Env.around( { - *compile([source, options]) { - return yield* compileTempFile(source, options); + compile([source, options]) { + return compileTempFile(source, options); }, }, { at: "min" }, diff --git a/packages/core/tests/temp-file-compiler.test.ts b/packages/core/tests/temp-file-compiler.test.ts new file mode 100644 index 00000000..6a8715bd --- /dev/null +++ b/packages/core/tests/temp-file-compiler.test.ts @@ -0,0 +1,165 @@ +/** + * 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 { + 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 = {}; + 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(); + + 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); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 69eeec40..8025b8c2 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1122,6 +1122,19 @@ 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/.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 resolved against the current working directory, so it is the +running document's directory rather than a fixed location. + #### Standard imports Every generated eval module is prepended with standard imports: @@ -6289,6 +6302,14 @@ 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/.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 | + ### Tier I — Middleware conformance (eval modifiers) | # | Test | Verify | From 5c432f133385a64fb5ca3881207cb0940a9e4fb3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:30:02 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20State=20the=20compiler's=20c?= =?UTF-8?q?wd=20precisely=20and=20cover=20a=20failing=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec implied `.xmd-eval` follows the running document. It does not: `compileTempFile()` resolves the relative literal against the host process's working directory, which running `path/to/document.md` never moves and which the contextual `API.Env.cwd` does not control. The claim that a removal failure other than an already-absent file leaves the private scope had no test behind it. TC4 performs the real removal and then fails, and holds that exact error to be the compilation's outcome. --- .../core/tests/temp-file-compiler.test.ts | 35 +++++++++++++++++++ specs/executable-mdx-spec.md | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/core/tests/temp-file-compiler.test.ts b/packages/core/tests/temp-file-compiler.test.ts index 6a8715bd..a20dff0d 100644 --- a/packages/core/tests/temp-file-compiler.test.ts +++ b/packages/core/tests/temp-file-compiler.test.ts @@ -162,4 +162,39 @@ describe("Tier TC — temp-file compiler lifecycle", () => { 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); + }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 8025b8c2..48ea4b89 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1132,8 +1132,10 @@ 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 resolved against the current working directory, so it is the -running document's directory rather than a fixed location. +`.xmd-eval` is a relative literal, resolved against the host process's current +working directory. That is the directory the process was started in: running +`path/to/document.md` does not move it to the document's directory, and it is +not the contextual `API.Env.cwd`, which this compiler does not consult. #### Standard imports @@ -6309,6 +6311,7 @@ visible warning blocks, gather into a separate error report). | TC1 | Success removes the generated file | The write and the removal observed through `FsApi.around()` name the same `.xmd-eval/.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) From 94f85b74ba5abb12ebe34450e7881ea821deb967 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:42:34 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Read=20the=20compiler's=20wo?= =?UTF-8?q?rking=20directory=20at=20the=20moment=20it=20chooses=20a=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node:path.resolve()` reads the process working directory when it is called, and `compileTempFile()` is publicly callable, so a host may have moved that directory since startup. Saying `.xmd-eval` follows the directory the process was started in claimed an immutability the compiler does not have. --- specs/executable-mdx-spec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 48ea4b89..3d25f0eb 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1133,9 +1133,10 @@ 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. That is the directory the process was started in: running -`path/to/document.md` does not move it to the document's directory, and it is -not the contextual `API.Env.cwd`, which this compiler does not consult. +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