@uniflowed/host 0.0.0-alpha.2 → 0.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,46 +11,36 @@
11
11
  // left to Node.
12
12
  //
13
13
  // Transforms are cached on disk under `.uf/cache/transform/` keyed by a hash
14
- // of the source, so a second run of the same file is a read rather than a
15
- // round trip. The cache is content-addressed: an edited file hashes
16
- // differently, so there is no invalidation to get wrong.
14
+ // of the source *and* of the `uf` that compiled it, so a second run of the
15
+ // same file is a read rather than a round trip.
16
+ //
17
+ // Both halves are load-bearing. The key was the source alone at first, on the
18
+ // reasoning that a content-addressed cache has no invalidation to get wrong —
19
+ // which quietly assumed the compiler was a constant. It is not: edit
20
+ // `crates/uf_transform` or `crates/uf_stylex`, rebuild, run `uf test`, and
21
+ // every module whose *source* had not changed came back as the previous
22
+ // binary had compiled it. The suite then passed, or failed, for the previous
23
+ // build's reasons, and the only symptom was an answer that made no sense.
24
+ // `rm -rf .uf/cache/transform` was the cure, and finding that out cost a
25
+ // debugging session while `@uniflowed/stylex`'s preset was being written.
17
26
 
18
27
  import { createHash } from "node:crypto";
19
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
28
+ import { readFileSync } from "node:fs";
20
29
  import path from "node:path";
21
30
  import { fileURLToPath } from "node:url";
22
31
 
23
- import { isFlowModule, transformFlow } from "../transform.js";
32
+ import { isFlowModule, sharedService, transformFlow, ufBinaryIdentity } from "../transform.js";
33
+ import { writeAtomically } from "../write-atomically.js";
24
34
 
25
35
  /**
26
- * Write `contents` to `target` so a concurrent reader never sees half of it.
27
- *
28
- * `uf test` runs one of these processes per core and they all import the same
29
- * few modules at once, so two writers and a reader meet on the same cache
30
- * entry constantly. `writeFileSync` is not atomic — a reader can observe a
31
- * truncated file and report a module that "does not provide an export" — so
32
- * the content goes to a private temporary name first and is then renamed,
33
- * which is atomic within a filesystem.
36
+ * Bumped whenever *this file's* framing of the output changes, to retire old
37
+ * entries.
34
38
  *
35
- * A failure here is not a failure: a read-only checkout still runs, just
36
- * without the cache.
39
+ * Not the compiler's version, which is `ufBinaryIdentity` and which nobody
40
+ * has to remember. What is left for this to cover is what the loader adds
41
+ * around a transform — the appended source map, the module format it forces —
42
+ * and that is all it should ever be bumped for.
37
43
  */
38
- function writeAtomically(target, contents) {
39
- const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
40
- try {
41
- mkdirSync(cacheDirectory, { recursive: true });
42
- writeFileSync(temporary, contents);
43
- renameSync(temporary, target);
44
- } catch {
45
- try {
46
- unlinkSync(temporary);
47
- } catch {
48
- // Nothing to clean up.
49
- }
50
- }
51
- }
52
-
53
- /** Bumped whenever the transform's output shape changes, to retire old entries. */
54
44
  const CACHE_VERSION = "2";
55
45
 
56
46
  let cacheDirectory = null;
@@ -82,15 +72,56 @@ export async function load(url, context, nextLoad) {
82
72
  return { format: "module", source: code, shortCircuit: true };
83
73
  }
84
74
 
85
- async function cachedTransform(source, filename) {
75
+ /**
76
+ * The file this module's compiled form belongs in under `identity`, or `null`
77
+ * when it must not be cached at all.
78
+ *
79
+ * `null` when there is no cache directory, and — the case worth spelling out —
80
+ * when the caller has no identity to give: nothing is read and nothing is
81
+ * written. Hashing the rest anyway would give every build of `uf` one key
82
+ * again, and writing under it would leave an entry for the next run to trust.
83
+ * A host that cannot name its compiler compiles everything, every time, which
84
+ * is slower and is never wrong.
85
+ */
86
+ function cacheEntryFor(identity, source, filename) {
87
+ if (cacheDirectory == null || identity == null) return null;
86
88
  const key = createHash("sha256")
87
89
  .update(CACHE_VERSION)
88
90
  .update("\0")
91
+ .update(identity)
92
+ .update("\0")
89
93
  .update(filename)
90
94
  .update("\0")
91
95
  .update(source)
92
96
  .digest("hex");
93
- const entry = cacheDirectory ? path.join(cacheDirectory, `${key}.mjs`) : null;
97
+ return path.join(cacheDirectory, `${key}.mjs`);
98
+ }
99
+
100
+ /**
101
+ * The compiled form of one module, from disk if some build already produced
102
+ * it and from `uf` otherwise.
103
+ *
104
+ * The two keys are computed from two different identities on purpose.
105
+ *
106
+ * The **read** is keyed by the binary as it is *now*, stat'd per module rather
107
+ * than once when the hooks were installed. A rebuild between installing them
108
+ * and loading the first Flow module would otherwise serve the old build's
109
+ * output while the new one is what would run — the same staleness this key
110
+ * exists to remove, with a smaller window. A stat is a microsecond and a
111
+ * fully warm run still spawns nothing, which is the property that decided the
112
+ * key's shape in the first place.
113
+ *
114
+ * The **write** is keyed by the binary the compiler process is actually
115
+ * executing, which `sharedService` read before it spawned and which cannot
116
+ * change afterwards. Keying the write by the file's current state would file
117
+ * this build's output under the next build's name if the rebuild landed while
118
+ * the module was being compiled — the same lie pointing the other way.
119
+ *
120
+ * They are usually the same string. When they are not, a rebuild happened
121
+ * during this run, and each half is right about its own half.
122
+ */
123
+ async function cachedTransform(source, filename) {
124
+ const entry = cacheEntryFor(ufBinaryIdentity(), source, filename);
94
125
 
95
126
  if (entry) {
96
127
  try {
@@ -106,8 +137,11 @@ async function cachedTransform(source, filename) {
106
137
  ? `${out.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(out.map).toString("base64")}\n`
107
138
  : out.code;
108
139
 
109
- if (entry) {
110
- writeAtomically(entry, output);
140
+ const written = cacheEntryFor(sharedService(root).identity, source, filename);
141
+ if (written) {
142
+ // Tolerant: a cache that cannot be written is a slower run, not a
143
+ // failed one — a read-only checkout still works.
144
+ writeAtomically(written, output, { tolerant: true });
111
145
  }
112
146
  return output;
113
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/host",
3
- "version": "0.0.0-alpha.2",
3
+ "version": "0.0.0-alpha.5",
4
4
  "description": "Running Flow on a Capability JS Host, with no bundler in the way.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,10 +11,11 @@
11
11
  "directory": "packages/host"
12
12
  },
13
13
  "exports": {
14
- "./register": "./register.js",
15
14
  "./bun-preload": "./bun-preload.js",
15
+ "./internal/node-hooks.js": "./internal/node-hooks.js",
16
+ "./register": "./register.js",
16
17
  "./transform": "./transform.js",
17
- "./internal/node-hooks.js": "./internal/node-hooks.js"
18
+ "./write-atomically": "./write-atomically.js"
18
19
  },
19
20
  "files": [
20
21
  "register.js",
package/transform.js CHANGED
@@ -14,6 +14,8 @@
14
14
  // all produce the same module from the same source.
15
15
 
16
16
  import { spawn } from "node:child_process";
17
+ import { accessSync, constants, statSync } from "node:fs";
18
+ import path from "node:path";
17
19
  import { createInterface } from "node:readline";
18
20
 
19
21
  /** File extensions uf treats as Flow source. */
@@ -60,6 +62,91 @@ export function ufBinary() {
60
62
  return process.env.UF_BINARY ?? "uf";
61
63
  }
62
64
 
65
+ /**
66
+ * Which *build* of `uf` a host will transform through, or `null` when that
67
+ * cannot be established.
68
+ *
69
+ * `ufBinary()` names the compiler; this identifies it. Anything kept across
70
+ * runs needs the second, because the first does not change when the compiler
71
+ * does: `crates/uf_transform` is edited, `cargo build` writes a new binary
72
+ * over the old one, and every answer already on disk is now wrong while the
73
+ * name that produced them is unchanged. A version string is the same promise
74
+ * one step removed — every build between two releases shares one.
75
+ *
76
+ * So: the size and modification time of the file that will be executed. They
77
+ * move together on every rebuild, they are one `stat` away, and — this is the
78
+ * part that decided it — reading them does not require starting `uf`. A run
79
+ * that finds everything already compiled must not have to spawn the compiler
80
+ * to learn that it does not need it, which is what asking the running
81
+ * `uf transform` to introduce itself would have cost.
82
+ *
83
+ * The same test is applied to a path as to a bare name: a regular file with
84
+ * the execute bit. Size and mtime do not move when a binary loses that bit, so
85
+ * without the test a chmod produced the same identity as before, a warm cache
86
+ * went on serving, and a cold one failed to start `uf` — the answer depending
87
+ * on how warm the cache was, which is the class of bug this key exists to
88
+ * remove.
89
+ *
90
+ * `null` means the question could not be answered. It is not an invitation to
91
+ * hash the rest anyway: a key that leaves the compiler out is one key for
92
+ * every build of it, which is the whole defect.
93
+ *
94
+ * @param {string} [command] the binary; `ufBinary()` by default
95
+ * @returns {string | null} an opaque identity, stable while that build is
96
+ */
97
+ export function ufBinaryIdentity(command = ufBinary()) {
98
+ const binary = resolveExecutable(command);
99
+ if (binary == null) return null;
100
+ try {
101
+ const stats = statSync(binary);
102
+ if (!stats.isFile()) return null;
103
+ accessSync(binary, constants.X_OK);
104
+ return `${binary}\0${stats.size}\0${stats.mtimeMs}`;
105
+ } catch {
106
+ // Named a binary that is not there, or is not one. The caller gets `null`
107
+ // and stops trusting the cache, which is right: nothing can be compiled
108
+ // either.
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The file `spawn` will execute for `command`, or `null` when there is none.
115
+ *
116
+ * A bare name is searched along PATH the way `execvp` searches for it — the
117
+ * first regular, executable file wins — so that the identity above describes
118
+ * the binary that actually runs rather than some other `uf` further down the
119
+ * list. Getting this wrong is not a slow cache but a silently stale one, which
120
+ * is why a directory named `uf` is skipped here as `execvp` skips it, rather
121
+ * than being accepted because `access` says a directory is executable.
122
+ *
123
+ * Windows resolves a bare name by rules of its own — `PATHEXT`, the current
124
+ * directory — which this does not implement. There a bare name is `null` and
125
+ * the caller falls back to not caching, rather than to caching under the
126
+ * identity of a file that may not be the one that ran. `UF_BINARY`, which is
127
+ * how every uf-started host arrives here, is an absolute path on every
128
+ * platform and never takes this path at all.
129
+ */
130
+ function resolveExecutable(command) {
131
+ // A path is taken as given — `spawn` will execute exactly it — and
132
+ // `ufBinaryIdentity` applies the file-and-executable test to the result
133
+ // either way, so a path that is a directory or is not executable is no more
134
+ // trusted than a bare name that resolves to one.
135
+ if (path.basename(command) !== command) return command;
136
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
137
+ if (directory === "") continue;
138
+ const candidate = path.join(directory, command);
139
+ try {
140
+ if (!statSync(candidate).isFile()) continue;
141
+ accessSync(candidate, constants.X_OK);
142
+ return candidate;
143
+ } catch {
144
+ // Not in this directory. Keep looking, exactly as the shell would.
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
63
150
  /**
64
151
  * An error the transform reported for one module, with its position when
65
152
  * the parser or the lowering rules gave one.
@@ -86,6 +173,7 @@ export class TransformError extends Error {
86
173
  export class TransformService {
87
174
  #child;
88
175
  #pending = [];
176
+ #identity;
89
177
  #failure = null;
90
178
 
91
179
  /**
@@ -96,6 +184,14 @@ export class TransformService {
96
184
  constructor(options = {}) {
97
185
  const command = options.command ?? ufBinary();
98
186
  const root = options.root ?? process.cwd();
187
+ // Read before the spawn and kept: this is the identity of the build that
188
+ // answers every request this service ever serves, because a child goes on
189
+ // executing the binary it started from however many times that file is
190
+ // rewritten underneath it. Anything written to disk from an answer of
191
+ // this service belongs under *this* identity — a caller that stat'd the
192
+ // binary earlier and wrote under that would file build B's output under
193
+ // build A's name, which is the original defect with a smaller window.
194
+ this.#identity = ufBinaryIdentity(command);
99
195
  this.#child = spawn(command, ["--cwd", root, "transform"], {
100
196
  stdio: ["pipe", "pipe", "inherit"],
101
197
  });
@@ -166,6 +262,21 @@ export class TransformService {
166
262
  });
167
263
  }
168
264
 
265
+ /**
266
+ * The build of `uf` this service's child is executing, or `null` when that
267
+ * could not be established.
268
+ *
269
+ * Read once, before the spawn, and never again: the child goes on executing
270
+ * the binary it started from however many times that file is rewritten
271
+ * underneath it. Anything kept from an answer of this service belongs under
272
+ * this identity and not under whatever the file says now.
273
+ *
274
+ * @returns {string | null}
275
+ */
276
+ get identity() {
277
+ return this.#identity;
278
+ }
279
+
169
280
  /** Stop the process. Outstanding requests are rejected. */
170
281
  close() {
171
282
  this.#child.stdin.end();