@polyengine/runtime 0.1.0-pre.g633468a

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.
Files changed (126) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +23 -0
  3. package/esm/cabi/async_values.js +162 -0
  4. package/esm/cabi/bulk_lists.js +198 -0
  5. package/esm/cabi/context.js +42 -0
  6. package/esm/cabi/flatten.js +145 -0
  7. package/esm/cabi/float.js +67 -0
  8. package/esm/cabi/handles.js +253 -0
  9. package/esm/cabi/layout.js +149 -0
  10. package/esm/cabi/lift.js +196 -0
  11. package/esm/cabi/load.js +146 -0
  12. package/esm/cabi/lower.js +141 -0
  13. package/esm/cabi/memory.js +182 -0
  14. package/esm/cabi/mod.js +22 -0
  15. package/esm/cabi/store.js +186 -0
  16. package/esm/cabi/strings.js +336 -0
  17. package/esm/cabi/trap.js +38 -0
  18. package/esm/cabi/types.js +264 -0
  19. package/esm/cabi/values.js +64 -0
  20. package/esm/cache/core.js +156 -0
  21. package/esm/cache/dir.js +170 -0
  22. package/esm/cache/mod.js +4 -0
  23. package/esm/cache/web.js +136 -0
  24. package/esm/digest/digest.js +332 -0
  25. package/esm/digest/mod.js +3 -0
  26. package/esm/digest/verify.js +129 -0
  27. package/esm/embedder/casing.js +56 -0
  28. package/esm/embedder/copy.js +42 -0
  29. package/esm/embedder/errors.js +26 -0
  30. package/esm/embedder/imports.js +63 -0
  31. package/esm/embedder/instantiate.js +978 -0
  32. package/esm/embedder/mod.js +40 -0
  33. package/esm/embedder/resources.js +406 -0
  34. package/esm/embedder/streams.js +770 -0
  35. package/esm/embedder/values.js +450 -0
  36. package/esm/embedder/version.js +273 -0
  37. package/esm/exec/boundary.js +1902 -0
  38. package/esm/exec/executor.js +1004 -0
  39. package/esm/exec/host_streams.js +818 -0
  40. package/esm/exec/mod.js +4 -0
  41. package/esm/intrinsics/async_builtins.js +510 -0
  42. package/esm/intrinsics/context.js +90 -0
  43. package/esm/intrinsics/errors.js +13 -0
  44. package/esm/intrinsics/fact_calls.js +865 -0
  45. package/esm/intrinsics/mod.js +564 -0
  46. package/esm/intrinsics/stream_builtins.js +578 -0
  47. package/esm/intrinsics/transcode.js +443 -0
  48. package/esm/jspi/bridge.js +579 -0
  49. package/esm/jspi/mechanics.js +89 -0
  50. package/esm/jspi/mod.js +5 -0
  51. package/esm/jspi/suspending.js +15 -0
  52. package/esm/jspi/types.js +29 -0
  53. package/esm/package.json +3 -0
  54. package/esm/plan/format.js +5 -0
  55. package/esm/plan/loader.js +657 -0
  56. package/esm/plan/mod.js +3 -0
  57. package/esm/shim/mod.js +2 -0
  58. package/esm/shim/translator.js +94 -0
  59. package/esm/task/mod.js +483 -0
  60. package/esm/task/scheduler.js +1028 -0
  61. package/esm/task/streams.js +786 -0
  62. package/esm/task/subtask.js +135 -0
  63. package/esm/task/thread.js +255 -0
  64. package/esm/task/waitable.js +144 -0
  65. package/package.json +91 -0
  66. package/types/cabi/async_values.d.ts +35 -0
  67. package/types/cabi/bulk_lists.d.ts +18 -0
  68. package/types/cabi/context.d.ts +59 -0
  69. package/types/cabi/flatten.d.ts +14 -0
  70. package/types/cabi/float.d.ts +14 -0
  71. package/types/cabi/handles.d.ts +70 -0
  72. package/types/cabi/layout.d.ts +13 -0
  73. package/types/cabi/lift.d.ts +25 -0
  74. package/types/cabi/load.d.ts +14 -0
  75. package/types/cabi/lower.d.ts +10 -0
  76. package/types/cabi/memory.d.ts +58 -0
  77. package/types/cabi/mod.d.ts +15 -0
  78. package/types/cabi/store.d.ts +12 -0
  79. package/types/cabi/strings.d.ts +23 -0
  80. package/types/cabi/trap.d.ts +11 -0
  81. package/types/cabi/types.d.ts +206 -0
  82. package/types/cabi/values.d.ts +5 -0
  83. package/types/cache/core.d.ts +97 -0
  84. package/types/cache/dir.d.ts +6 -0
  85. package/types/cache/mod.d.ts +3 -0
  86. package/types/cache/web.d.ts +10 -0
  87. package/types/digest/digest.d.ts +17 -0
  88. package/types/digest/mod.d.ts +2 -0
  89. package/types/digest/verify.d.ts +48 -0
  90. package/types/embedder/casing.d.ts +40 -0
  91. package/types/embedder/copy.d.ts +24 -0
  92. package/types/embedder/errors.d.ts +11 -0
  93. package/types/embedder/imports.d.ts +47 -0
  94. package/types/embedder/instantiate.d.ts +88 -0
  95. package/types/embedder/mod.d.ts +11 -0
  96. package/types/embedder/resources.d.ts +158 -0
  97. package/types/embedder/streams.d.ts +202 -0
  98. package/types/embedder/values.d.ts +70 -0
  99. package/types/embedder/version.d.ts +85 -0
  100. package/types/exec/boundary.d.ts +360 -0
  101. package/types/exec/executor.d.ts +125 -0
  102. package/types/exec/host_streams.d.ts +165 -0
  103. package/types/exec/mod.d.ts +3 -0
  104. package/types/intrinsics/async_builtins.d.ts +69 -0
  105. package/types/intrinsics/context.d.ts +28 -0
  106. package/types/intrinsics/errors.d.ts +5 -0
  107. package/types/intrinsics/fact_calls.d.ts +120 -0
  108. package/types/intrinsics/mod.d.ts +187 -0
  109. package/types/intrinsics/stream_builtins.d.ts +113 -0
  110. package/types/intrinsics/transcode.d.ts +21 -0
  111. package/types/jspi/bridge.d.ts +227 -0
  112. package/types/jspi/mechanics.d.ts +50 -0
  113. package/types/jspi/mod.d.ts +3 -0
  114. package/types/jspi/suspending.d.ts +1 -0
  115. package/types/jspi/types.d.ts +26 -0
  116. package/types/plan/format.d.ts +369 -0
  117. package/types/plan/loader.d.ts +113 -0
  118. package/types/plan/mod.d.ts +2 -0
  119. package/types/shim/mod.d.ts +1 -0
  120. package/types/shim/translator.d.ts +55 -0
  121. package/types/task/mod.d.ts +257 -0
  122. package/types/task/scheduler.d.ts +421 -0
  123. package/types/task/streams.d.ts +370 -0
  124. package/types/task/subtask.d.ts +96 -0
  125. package/types/task/thread.d.ts +73 -0
  126. package/types/task/waitable.d.ts +67 -0
@@ -0,0 +1,264 @@
1
+ // Canonical ABI — provisional component value-type model.
2
+ //
3
+ // This is the v1 sketch of the "CABI descriptor IR" of docs/architecture.md §8: the type
4
+ // information the host-boundary lift/lower interpreter walks. It mirrors the
5
+ // type classes of the executable spec
6
+ // (third_party/component-model/design/mvp/canonical-abi/definitions.py) as a
7
+ // TypeScript discriminated union, holding only what lift/lower needs.
8
+ //
9
+ // PROVISIONAL — expected to change when the translator shim (docs/architecture.md §4.2)
10
+ // defines the real plan format. Open questions, tracked in runtime/README.md:
11
+ // - Serialized encoding (this in-memory shape vs the plan's wire format).
12
+ // - Whether labels stay strings or become interned indices in the IR.
13
+ // - Host-facing value representations for tuple/variant/option/result
14
+ // (currently the despecialized definitions.py shapes; bindgen §9 will
15
+ // want arrays / tagged unions / undefined-based options).
16
+ // - Resource types: here an opaque token; the plan will carry resource-type
17
+ // indices + dtor references instead.
18
+ export function coreFuncTypeEquals(a, b) {
19
+ return a.params.length === b.params.length &&
20
+ a.results.length === b.results.length &&
21
+ a.params.every((p, i) => p === b.params[i]) &&
22
+ a.results.every((r, i) => r === b.results[i]);
23
+ }
24
+ /**
25
+ * definitions.py `ResourceType`: identity + implementing instance + optional
26
+ * destructor. Compared by object identity everywhere.
27
+ *
28
+ * `dtorHost` is the **host-initiated**-drop entry (#85, reshaped by #160):
29
+ * the dtor built as a fully LIFTED sync function — definitions.py
30
+ * `canon_resource_drop` (line 2319) `inst.store.lift(dtor, ft, opts,
31
+ * rt.impl)` — so the destructor's activation gets a real Task/Thread, the
32
+ * reentrance bracket is released at its first park, and its suspension
33
+ * points are resumable by the scheduler. It is wired by exec/executor.ts
34
+ * (jspi-`promising` entry only when the dtor is suspension-capable, docs §7)
35
+ * and called through `hostDtorCall` (exec/boundary.ts), which also fills it
36
+ * in lazily for tokens built directly. It returns `undefined` or a Promise,
37
+ * so it is NOT callable from inside a guest activation.
38
+ *
39
+ * Guest-initiated drops (`callDtorGated`) always use `dtor` directly: they
40
+ * must complete synchronously (reference lifts the dtor with
41
+ * `async_ = False`), and any thenable there is a trap.
42
+ */
43
+ export class ResourceTypeInfo {
44
+ impl;
45
+ dtor;
46
+ dtorHost;
47
+ constructor(impl, dtor = null, dtorHost = null) {
48
+ this.impl = impl;
49
+ this.dtor = dtor;
50
+ this.dtorHost = dtorHost;
51
+ }
52
+ }
53
+ export function despecialize(t) {
54
+ switch (t.kind) {
55
+ case "tuple":
56
+ return {
57
+ kind: "record",
58
+ fields: t.elements.map((e, i) => ({ label: String(i), type: e })),
59
+ };
60
+ case "enum":
61
+ return {
62
+ kind: "variant",
63
+ cases: t.labels.map((l) => ({ label: l, type: null })),
64
+ };
65
+ case "option":
66
+ return {
67
+ kind: "variant",
68
+ cases: [{ label: "none", type: null }, { label: "some", type: t.type }],
69
+ };
70
+ case "result":
71
+ return {
72
+ kind: "variant",
73
+ cases: [{ label: "ok", type: t.ok }, { label: "error", type: t.error }],
74
+ };
75
+ case "map":
76
+ return {
77
+ kind: "list",
78
+ element: despecialize({
79
+ kind: "tuple",
80
+ elements: [t.key, t.value],
81
+ }),
82
+ };
83
+ default:
84
+ return t;
85
+ }
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Discriminants (definitions.py `discriminant_type`)
89
+ // ---------------------------------------------------------------------------
90
+ export function discriminantType(cases) {
91
+ const n = cases.length;
92
+ if (!(0 < n && n < 2 ** 32))
93
+ throw new Error("assertion failed: case count");
94
+ // mirrors math.ceil(log2(n)/8): 0|1 -> u8, 2 -> u16, 3 -> u32
95
+ if (n <= 256)
96
+ return { kind: "u8" };
97
+ if (n <= 65536)
98
+ return { kind: "u16" };
99
+ return { kind: "u32" };
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Type predicates (definitions.py `contains_borrow` etc.)
103
+ // ---------------------------------------------------------------------------
104
+ export function containsBorrow(t) {
105
+ return contains(t, (u) => u.kind === "borrow");
106
+ }
107
+ export function containsAsyncValue(t) {
108
+ return contains(t, (u) => u.kind === "stream" || u.kind === "future");
109
+ }
110
+ export function contains(t, p) {
111
+ if (t === null)
112
+ return false;
113
+ const d = despecialize(t);
114
+ switch (d.kind) {
115
+ case "list":
116
+ return p(d) || contains(d.element, p);
117
+ case "stream":
118
+ case "future":
119
+ return p(d) || contains(d.element, p);
120
+ case "record":
121
+ return p(d) || d.fields.some((f) => contains(f.type, p));
122
+ case "variant":
123
+ return p(d) || d.cases.some((c) => contains(c.type, p));
124
+ default:
125
+ return p(d);
126
+ }
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Structural ValType equality and display
130
+ // ---------------------------------------------------------------------------
131
+ /**
132
+ * Structural `ValType` equality.
133
+ *
134
+ * CONTRACT (bugfix, TRACK C2-D; generalized during the #18 tls smoke): naive
135
+ * `JSON.stringify(a) === JSON.stringify(b)` recurses into `own`/`borrow`'s
136
+ * `ResourceTypeInfo` — a class whose `impl` field is documented "Compared by
137
+ * object identity everywhere" (see `ResourceTypeInfo` above) and which cycles
138
+ * back to the owning instance state (`impl.handles` holds live resource
139
+ * tables that reference their types), so `JSON.stringify` throws
140
+ * `TypeError: Converting circular structure to JSON` on ANY type containing
141
+ * `own<R>`/`borrow<R>` at any depth. First hit by `task.return` result types
142
+ * (C2-D, polymorph-test's `list<own<test-case>>`), then by stream/future
143
+ * element types (polymorph-tls streams carrying resource-bearing payloads).
144
+ * Object-identity types (`ResourceTypeInfo`) are compared by reference, per
145
+ * the documented invariant.
146
+ */
147
+ export function valTypesEqual(a, b) {
148
+ return a.length === b.length && a.every((t, i) => valTypeEqual(t, b[i]));
149
+ }
150
+ export function valTypeEqual(a, b) {
151
+ if (a === b)
152
+ return true;
153
+ if (a.kind !== b.kind)
154
+ return false;
155
+ switch (a.kind) {
156
+ case "list": {
157
+ const bb = b;
158
+ return a.length === bb.length && valTypeEqual(a.element, bb.element);
159
+ }
160
+ case "record": {
161
+ const bb = b;
162
+ return a.fields.length === bb.fields.length &&
163
+ a.fields.every((f, i) => f.label === bb.fields[i].label && valTypeEqual(f.type, bb.fields[i].type));
164
+ }
165
+ case "tuple": {
166
+ const bb = b;
167
+ return a.elements.length === bb.elements.length &&
168
+ a.elements.every((e, i) => valTypeEqual(e, bb.elements[i]));
169
+ }
170
+ case "variant": {
171
+ const bb = b;
172
+ return a.cases.length === bb.cases.length &&
173
+ a.cases.every((c, i) => {
174
+ const other = bb.cases[i];
175
+ if (c.label !== other.label)
176
+ return false;
177
+ if (c.type === null || other.type === null)
178
+ return c.type === other.type;
179
+ return valTypeEqual(c.type, other.type);
180
+ });
181
+ }
182
+ case "enum":
183
+ case "flags": {
184
+ const bb = b;
185
+ return a.labels.length === bb.labels.length &&
186
+ a.labels.every((l, i) => l === bb.labels[i]);
187
+ }
188
+ case "option": {
189
+ const bb = b;
190
+ return valTypeEqual(a.type, bb.type);
191
+ }
192
+ case "result": {
193
+ const bb = b;
194
+ if ((a.ok === null) !== (bb.ok === null))
195
+ return false;
196
+ if ((a.error === null) !== (bb.error === null))
197
+ return false;
198
+ return (a.ok === null || valTypeEqual(a.ok, bb.ok)) &&
199
+ (a.error === null || valTypeEqual(a.error, bb.error));
200
+ }
201
+ case "map": {
202
+ const bb = b;
203
+ return valTypeEqual(a.key, bb.key) && valTypeEqual(a.value, bb.value);
204
+ }
205
+ case "own":
206
+ case "borrow": {
207
+ const bb = b;
208
+ // Object-identity type (documented invariant): reference equality only.
209
+ return a.rt === bb.rt;
210
+ }
211
+ case "stream":
212
+ case "future": {
213
+ const bb = b;
214
+ if ((a.element === null) !== (bb.element === null))
215
+ return false;
216
+ return a.element === null || valTypeEqual(a.element, bb.element);
217
+ }
218
+ case "error-context":
219
+ return true;
220
+ default:
221
+ // Remaining kinds (primitives) carry no extra fields beyond `kind`.
222
+ return true;
223
+ }
224
+ }
225
+ /**
226
+ * Cycle-safe display form for diagnostics. `JSON.stringify(t)` is UNSAFE on
227
+ * any resource-bearing type (see `valTypeEqual`'s contract note); this prints
228
+ * the structural shape and elides `ResourceTypeInfo` identities.
229
+ */
230
+ export function fmtValType(t) {
231
+ if (t === null)
232
+ return "_";
233
+ switch (t.kind) {
234
+ case "list":
235
+ return t.length === undefined
236
+ ? `list<${fmtValType(t.element)}>`
237
+ : `list<${fmtValType(t.element)}, ${t.length}>`;
238
+ case "record":
239
+ return `record{${t.fields.map((f) => `${f.label}: ${fmtValType(f.type)}`).join(", ")}}`;
240
+ case "tuple":
241
+ return `tuple<${t.elements.map(fmtValType).join(", ")}>`;
242
+ case "variant":
243
+ return `variant{${t.cases.map((c) => c.type === null ? c.label : `${c.label}(${fmtValType(c.type)})`)
244
+ .join(", ")}}`;
245
+ case "enum":
246
+ return `enum{${t.labels.join(", ")}}`;
247
+ case "flags":
248
+ return `flags{${t.labels.join(", ")}}`;
249
+ case "option":
250
+ return `option<${fmtValType(t.type)}>`;
251
+ case "result":
252
+ return `result<${fmtValType(t.ok)}, ${fmtValType(t.error)}>`;
253
+ case "map":
254
+ return `map<${fmtValType(t.key)}, ${fmtValType(t.value)}>`;
255
+ case "own":
256
+ case "borrow":
257
+ return `${t.kind}<resource>`;
258
+ case "stream":
259
+ case "future":
260
+ return `${t.kind}<${fmtValType(t.element)}>`;
261
+ default:
262
+ return t.kind;
263
+ }
264
+ }
@@ -0,0 +1,64 @@
1
+ // Lifting and lowering of full value lists with spilling (definitions.py
2
+ // `## Lifting and Lowering Values`): parameter/result sequences that exceed
3
+ // the flat maximum are passed indirectly through a tuple in linear memory.
4
+ import { trapIf } from "./trap.js";
5
+ import { alignment, alignTo, elemSize } from "./layout.js";
6
+ import { load } from "./load.js";
7
+ import { store } from "./store.js";
8
+ import { liftFlat } from "./lift.js";
9
+ import { lowerFlat } from "./lower.js";
10
+ import { flattenTypes } from "./flatten.js";
11
+ import { requireMemory } from "./context.js";
12
+ import { asIndex } from "./memory.js";
13
+ export function liftFlatValues(cx, maxFlat, vi, ts) {
14
+ const flatTypes = flattenTypes(ts, cx.opts);
15
+ if (flatTypes.length > maxFlat) {
16
+ const mem = requireMemory(cx.opts);
17
+ const ptrRaw = vi.next(mem.ptrType());
18
+ const tupleType = { kind: "tuple", elements: ts };
19
+ const align = alignment(tupleType, mem.ptrType());
20
+ const size = elemSize(tupleType, mem.ptrType());
21
+ trapIf(BigInt(ptrRaw) % BigInt(align) !== 0n, "misaligned spill pointer");
22
+ trapIf(BigInt(ptrRaw) + BigInt(size) > BigInt(mem.length), "spill tuple out of bounds");
23
+ const ptr = asIndex(ptrRaw);
24
+ const tuple = load(cx, ptr, tupleType);
25
+ return Object.values(tuple);
26
+ }
27
+ else {
28
+ return ts.map((t) => liftFlat(cx, vi, t));
29
+ }
30
+ }
31
+ export function lowerFlatValues(cx, maxFlat, vs, ts, outParam = null) {
32
+ const flatTypes = flattenTypes(ts, cx.opts);
33
+ if (flatTypes.length > maxFlat) {
34
+ const mem = requireMemory(cx.opts);
35
+ const tupleType = { kind: "tuple", elements: ts };
36
+ const tupleValue = {};
37
+ vs.forEach((v, i) => {
38
+ tupleValue[String(i)] = v;
39
+ });
40
+ let ptr;
41
+ let flatVals;
42
+ const align = alignment(tupleType, mem.ptrType());
43
+ const size = elemSize(tupleType, mem.ptrType());
44
+ if (outParam === null) {
45
+ ptr = cx.allocate(align, size);
46
+ flatVals = mem.ptrType() === "i32" ? [ptr] : [BigInt(ptr)];
47
+ }
48
+ else {
49
+ ptr = asIndex(outParam.next(mem.ptrType()));
50
+ flatVals = [];
51
+ }
52
+ trapIf(ptr !== alignTo(ptr, align), "misaligned spill pointer");
53
+ trapIf(ptr + size > mem.length, "spill tuple out of bounds");
54
+ store(cx, tupleValue, tupleType, ptr);
55
+ return flatVals;
56
+ }
57
+ else {
58
+ const flatVals = [];
59
+ for (let i = 0; i < vs.length; i++) {
60
+ flatVals.push(...lowerFlat(cx, vs[i], ts[i]));
61
+ }
62
+ return flatVals;
63
+ }
64
+ }
@@ -0,0 +1,156 @@
1
+ // Artifact cache — platform-neutral core (docs/architecture.md §10, layer 1: ours, bytes
2
+ // only; nothing here depends on the engine's own compiled-module cache,
3
+ // i.e. layer 2).
4
+ //
5
+ // GOAL: content-address a shim translation by
6
+ // `(component sha256, translator build hash, feature flags)` so a reload
7
+ // (same component, same shim build, same features) can skip the
8
+ // translate-with-the-shim step entirely.
9
+ //
10
+ // CACHE FAILURES ARE NEVER FATAL (issue #196): the cache is a pure
11
+ // optimization layered over `Translator.translateRaw` + `loadEnvelope`, both
12
+ // of which already succeed/fail on their own terms. A `get`/`put`/internal
13
+ // self-heal failure — including a read-only or otherwise unwritable cache
14
+ // root — must never turn into a failed translation; at worst it turns into
15
+ // a fresh (uncached) translation. `translateCached` swallows `get`/`put`
16
+ // failures (surfaced only via the opt-in `onCacheError` callback below);
17
+ // the backends (dir.ts, web.ts) swallow their internal self-heal evictions
18
+ // and turn any `get`-path I/O failure into a `null` (miss) rather than a
19
+ // throw. The one exception, by design, is `TranslateError` from
20
+ // `loadEnvelope`: that is a verdict about the *input component*, not a
21
+ // cache failure, and keeps propagating uncached. The public `evict()` also
22
+ // keeps throwing — an explicit caller asked for that specific effect and
23
+ // deserves to know if it didn't happen.
24
+ //
25
+ // PERSISTED-ARTIFACT-SET DECISION (governing: contracts/plan-format.md
26
+ // "Artifact set" + "No duplicate bytes" — plan-format.md:24-31,63-64):
27
+ // we persist `plan.json` (the wire `WirePlan`) and the FACT adapter modules
28
+ // only. We do NOT persist the original component bytes.
29
+ //
30
+ // Evidence this is correct, not merely convenient:
31
+ // - plan-format.md is explicit: "The original component binary is the
32
+ // third input at instantiation time; the plan never embeds it" and
33
+ // "Embedded core modules are referenced as `[offset, len)` byte ranges
34
+ // into the original component binary — the executor slices them
35
+ // itself" (decision 3). `instantiateComponent`/`Facade.instantiate`
36
+ // (runtime/src/embedder/instantiate.ts `ComponentArtifacts`) take
37
+ // `componentBytes` as a caller-supplied field *alongside* `plan` and
38
+ // `adapters` — never as something the plan or its loader manufacture.
39
+ // - Operationally: whoever calls `translateCached` already holds the
40
+ // component bytes (that's how they'd have a sha256 to form a cache key
41
+ // in the first place, and how `verifyComponent`'s length check runs
42
+ // without a bytes store at all). Reload use cases (browser page reload,
43
+ // Deno process restart) re-fetch/re-read the *component* from its own
44
+ // source of truth (network, disk) every time; only the *translation*
45
+ // (the expensive shim call) is worth skipping. Storing componentBytes a
46
+ // second time would be pure duplication with no consumer.
47
+ // - `get()` therefore verifies integrity using only the requested
48
+ // `CacheKey.componentSha256` against the value that was true at `put()`
49
+ // time (recorded in the stored metadata and cross-checked against the
50
+ // embedded `plan.component.sha256`) — never against fresh bytes, which
51
+ // this layer never sees.
52
+ import { loadEnvelope, PlanError, TranslateError } from "../plan/loader.js";
53
+ /** Cache layout version. Bumped on any incompatible on-disk/on-Cache-API
54
+ * schema change; an unrecognized version is read back as a miss (never a
55
+ * crash) so stale caches from an older build self-heal by re-translating —
56
+ * and this self-healing holds even when the cache root itself is
57
+ * unwritable (issue #196): the eviction attempt that a layout mismatch
58
+ * triggers is swallowed internally by the backend, so a read-only
59
+ * pre-warmed cache from an older layout degrades to "always miss, always
60
+ * re-translate" rather than throwing. */
61
+ export const CACHE_LAYOUT_VERSION = 1;
62
+ async function sha256Hex(bytes) {
63
+ const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
64
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0"))
65
+ .join("");
66
+ }
67
+ /**
68
+ * The stable cache key string: sha256 over a canonical JSON encoding of the
69
+ * `CacheKey` (features sorted so order never causes a spurious miss).
70
+ */
71
+ export async function keyHex(key) {
72
+ const canonical = JSON.stringify({
73
+ componentSha256: key.componentSha256,
74
+ translatorBuildHash: key.translatorBuildHash,
75
+ features: [...key.features].sort(),
76
+ });
77
+ return await sha256Hex(new TextEncoder().encode(canonical));
78
+ }
79
+ /** Compute the `CacheKey` for a `(translator, componentBytes)` pair. Throws
80
+ * if the translator has no `buildHash` (constructed from a pre-compiled
81
+ * `WebAssembly.Module` with no bytes available — see `Translator.create`). */
82
+ export async function keyFor(translator, componentBytes, features = []) {
83
+ if (translator.buildHash === null) {
84
+ throw new PlanError("artifact cache: translator has no buildHash (constructed from a " +
85
+ "WebAssembly.Module without source bytes); pass raw wasm bytes to " +
86
+ "Translator.create to enable caching");
87
+ }
88
+ return {
89
+ componentSha256: await sha256Hex(componentBytes),
90
+ translatorBuildHash: translator.buildHash,
91
+ features,
92
+ };
93
+ }
94
+ function reportCacheError(opts, op, err) {
95
+ try {
96
+ opts.onCacheError?.(op, err);
97
+ }
98
+ catch {
99
+ // A diagnostic callback throwing must not fail the translation either
100
+ // (issue #196): the caller asked for a diagnostic, not a veto.
101
+ }
102
+ }
103
+ /**
104
+ * Orchestration helper: cache hit -> stored artifacts, cache miss ->
105
+ * translate with the shim, store, return.
106
+ */
107
+ export async function translateCached(translator, componentBytes, cache, opts = {}) {
108
+ const key = await keyFor(translator, componentBytes, opts.features ?? []);
109
+ // A `get` failure (issue #196) reads as a miss: the cache is a pure
110
+ // optimization, so any way it fails to answer degrades to "translate
111
+ // fresh" rather than failing the whole translation.
112
+ let hit;
113
+ try {
114
+ hit = await cache.get(key);
115
+ }
116
+ catch (e) {
117
+ reportCacheError(opts, "get", e);
118
+ hit = null;
119
+ }
120
+ if (hit !== null) {
121
+ return { plan: hit.plan, adapters: hit.adapters, fromCache: true };
122
+ }
123
+ const first = translator.translateRaw(componentBytes);
124
+ if (opts.verifyDeterminism) {
125
+ const second = translator.translateRaw(componentBytes);
126
+ if (first !== second) {
127
+ throw new Error("artifact cache: determinism guard failed — translating the same " +
128
+ "component bytes twice produced different envelopes");
129
+ }
130
+ }
131
+ // loadEnvelope both validates (throws TranslateError for a validation
132
+ // verdict — must propagate uncached, per TranslateError's docs: a
133
+ // validation verdict is a judgment about the *input component*, not
134
+ // something to cache-and-replay) and gives us the split plan/adapters.
135
+ let wire;
136
+ let adapters;
137
+ try {
138
+ ({ wire, adapters } = loadEnvelope(first));
139
+ }
140
+ catch (e) {
141
+ if (e instanceof TranslateError)
142
+ throw e;
143
+ throw e;
144
+ }
145
+ // A `put` failure (issue #196) is swallowed: the translation already
146
+ // succeeded and was already validated above by `loadEnvelope` — failing
147
+ // to *store* it says nothing about the result. Return the fresh
148
+ // artifacts anyway.
149
+ try {
150
+ await cache.put(key, { plan: wire, adapters });
151
+ }
152
+ catch (e) {
153
+ reportCacheError(opts, "put", e);
154
+ }
155
+ return { plan: wire, adapters, fromCache: false };
156
+ }
@@ -0,0 +1,170 @@
1
+ // Deno filesystem backend for the artifact cache (docs/architecture.md §10).
2
+ //
3
+ // Layout, under `<path>/<keyhex>/`:
4
+ // meta.json CacheMeta (layoutVersion, componentSha256,
5
+ // translatorBuildHash, features)
6
+ // plan.json the wire WirePlan, JSON-serialized
7
+ // adapters/<name> one file per adapter, name = the tail of the wire
8
+ // plan's `modules[].file` (which is already
9
+ // `adapters/<idx>.wasm` shaped upstream — see
10
+ // contracts/plan-format.md "Artifact set" — so this
11
+ // backend nests one more `adapters/` level under the
12
+ // key directory: `<path>/<keyhex>/adapters/<idx>.wasm`)
13
+ //
14
+ // Deliberately does NOT store component bytes — see core.ts's
15
+ // "PERSISTED-ARTIFACT-SET DECISION" docs for why that's sound, not a gap.
16
+ import { CACHE_LAYOUT_VERSION, keyHex } from "./core.js";
17
+ import { loadPlan, PlanError } from "../plan/loader.js";
18
+ /** Reject any adapter file name that isn't a plain, non-traversing
19
+ * relative path — defense in depth even though the shim is trusted (a
20
+ * poisoned/tampered cache directory must not escape it). */
21
+ function safeRelName(name) {
22
+ if (name.length === 0 ||
23
+ name.startsWith("/") ||
24
+ name.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) {
25
+ throw new PlanError(`artifact cache: unsafe adapter file name '${name}'`);
26
+ }
27
+ return name;
28
+ }
29
+ async function exists(path) {
30
+ try {
31
+ await Deno.stat(path);
32
+ return true;
33
+ }
34
+ catch (e) {
35
+ if (e instanceof Deno.errors.NotFound)
36
+ return false;
37
+ throw e;
38
+ }
39
+ }
40
+ async function rmIfExists(path) {
41
+ try {
42
+ await Deno.remove(path, { recursive: true });
43
+ }
44
+ catch (e) {
45
+ if (!(e instanceof Deno.errors.NotFound))
46
+ throw e;
47
+ }
48
+ }
49
+ class DirCache {
50
+ root;
51
+ constructor(root) {
52
+ this.root = root;
53
+ }
54
+ async entryDir(key) {
55
+ return `${this.root}/${await keyHex(key)}`;
56
+ }
57
+ /** Internal self-heal eviction (issue #196): the caller is `get`'s own
58
+ * recovery path for a poisoned/stale entry, not an explicit caller of
59
+ * `evict()` — so a failure here (e.g. an unwritable cache root) must not
60
+ * escape and fail what would otherwise be a clean miss. The public
61
+ * `evict()` below keeps throwing; only this internal path swallows. */
62
+ async #tryEvict(key) {
63
+ try {
64
+ await this.evict(key);
65
+ }
66
+ catch {
67
+ // Swallowed: see docs above.
68
+ }
69
+ }
70
+ async get(key) {
71
+ try {
72
+ const dir = await this.entryDir(key);
73
+ if (!(await exists(dir)))
74
+ return null;
75
+ const metaRaw = await Deno.readTextFile(`${dir}/meta.json`);
76
+ const meta = JSON.parse(metaRaw);
77
+ if (meta.layoutVersion !== CACHE_LAYOUT_VERSION) {
78
+ await this.#tryEvict(key);
79
+ return null;
80
+ }
81
+ // Integrity: the entry must agree with the *requested* key on every
82
+ // field, not just live at the expected directory name (belt-and-
83
+ // suspenders against a hand-edited or corrupted cache).
84
+ if (meta.componentSha256 !== key.componentSha256 ||
85
+ meta.translatorBuildHash !== key.translatorBuildHash ||
86
+ JSON.stringify([...meta.features].sort()) !==
87
+ JSON.stringify([...key.features].sort())) {
88
+ await this.#tryEvict(key);
89
+ return null;
90
+ }
91
+ const planRaw = await Deno.readTextFile(`${dir}/plan.json`);
92
+ const plan = JSON.parse(planRaw);
93
+ if (plan.component.sha256 !== key.componentSha256) {
94
+ await this.#tryEvict(key);
95
+ return null;
96
+ }
97
+ loadPlan(plan); // structural validation; throws on a corrupted plan
98
+ const adapters = new Map();
99
+ for (const m of plan.modules) {
100
+ if (m.kind !== "adapter")
101
+ continue;
102
+ const name = safeRelName(m.file);
103
+ adapters.set(m.file, await Deno.readFile(`${dir}/adapters/${name}`));
104
+ }
105
+ return { plan, adapters };
106
+ }
107
+ catch {
108
+ // Any parse/read/structural/I-O failure = a poisoned entry (or an
109
+ // unreadable/unwritable cache root, issue #196): miss + best-effort
110
+ // evict, never trust and never throw out of `get` (dispatch
111
+ // requirement). This also covers `exists()`'s rethrow of non-
112
+ // `NotFound` stat errors (ENOTDIR, EACCES, ...).
113
+ await this.#tryEvict(key);
114
+ return null;
115
+ }
116
+ }
117
+ async put(key, artifacts) {
118
+ const dir = await this.entryDir(key);
119
+ // Write to a temp sibling then rename, so a crash mid-write never
120
+ // leaves a partially-written entry that `get` would (try to) read.
121
+ const tmp = `${dir}.tmp-${crypto.randomUUID()}`;
122
+ await rmIfExists(tmp);
123
+ try {
124
+ await Deno.mkdir(`${tmp}/adapters`, { recursive: true });
125
+ const meta = {
126
+ layoutVersion: CACHE_LAYOUT_VERSION,
127
+ componentSha256: key.componentSha256,
128
+ translatorBuildHash: key.translatorBuildHash,
129
+ features: key.features,
130
+ };
131
+ await Deno.writeTextFile(`${tmp}/meta.json`, JSON.stringify(meta));
132
+ await Deno.writeTextFile(`${tmp}/plan.json`, JSON.stringify(artifacts.plan));
133
+ for (const [file, bytes] of artifacts.adapters) {
134
+ const name = safeRelName(file);
135
+ await Deno.writeFile(`${tmp}/adapters/${name}`, bytes);
136
+ }
137
+ await rmIfExists(dir);
138
+ await Deno.rename(tmp, dir);
139
+ }
140
+ catch (e) {
141
+ // A `put` failure is non-fatal at the `translateCached` layer
142
+ // (issue #196), but repeated failures must not litter the cache
143
+ // root with orphaned `.tmp-<uuid>` scratch directories. Cleanup
144
+ // failures here are themselves swallowed — `put` still throws its
145
+ // original error either way.
146
+ try {
147
+ await rmIfExists(tmp);
148
+ }
149
+ catch {
150
+ // Swallowed: best-effort cleanup only.
151
+ }
152
+ throw e;
153
+ }
154
+ }
155
+ async evict(key) {
156
+ await rmIfExists(await this.entryDir(key));
157
+ }
158
+ }
159
+ /** A filesystem-backed `ArtifactCache` rooted at `path` (created on first
160
+ * `put` if missing). Deno only: every method calls `Deno.*` directly. On other
161
+ * platforms use `webCache()` (./web.ts), or supply your own `ArtifactCache` —
162
+ * the interface is three methods (./core.ts). */
163
+ export function dirCache(path) {
164
+ if (typeof Deno === "undefined") {
165
+ throw new Error("dirCache() is the Deno filesystem backend and this is not Deno — " +
166
+ "use webCache() (Cache API), or implement ArtifactCache over your " +
167
+ "platform's storage (see @polyengine/runtime/cache core.ts)");
168
+ }
169
+ return new DirCache(path);
170
+ }
@@ -0,0 +1,4 @@
1
+ // Artifact cache — public surface (docs/architecture.md §10).
2
+ export * from "./core.js";
3
+ export * from "./dir.js";
4
+ export * from "./web.js";