@hew-lang/sandbox-vm 0.6.0 → 0.6.2

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,7 +11,7 @@
11
11
  import { SUPPORTED_NATIVE_IO, SUPPORTED_SUSPEND_KINDS, resolveExternShim, resolveRuntimeShim, } from "./shims.js";
12
12
  const UNSUPPORTED = "sandbox.capability.unsupported";
13
13
  const NATIVE_FAMILIES = new Set(["AsyncIo", "FileRead", "Tcp"]);
14
- const NATIVE_SUSPENSIONS = new Set(["NativeIo", "RemoteAsk", "Read", "Accept"]);
14
+ const NATIVE_SUSPENSIONS = new Set(["NativeIo", "Read", "Accept"]);
15
15
  const OPS = new Set([
16
16
  "coverage.hit",
17
17
  "const.int",
@@ -64,6 +64,7 @@ const OPS = new Set([
64
64
  "task_scope.enter",
65
65
  "task_scope.close",
66
66
  "task.spawn",
67
+ "task.invoke",
67
68
  "task.race",
68
69
  ]);
69
70
  const TERMS = new Set([
@@ -92,6 +93,7 @@ const TERMS = new Set([
92
93
  "unreachable",
93
94
  ]);
94
95
  const ACTOR_OPS = new Set([
96
+ "identity",
95
97
  "spawn",
96
98
  "self_handle",
97
99
  "stop",
@@ -102,6 +104,7 @@ const ACTOR_OPS = new Set([
102
104
  "call_take",
103
105
  "submit",
104
106
  "stream_start",
107
+ "timer_schedule",
105
108
  "supervisor_spawn",
106
109
  "supervisor_child",
107
110
  "supervisor_pool_view",
@@ -114,13 +117,11 @@ function capabilityMessage(capability, native) {
114
117
  NativeIo: "Host input and output",
115
118
  Read: "Host input and output",
116
119
  Accept: "Accepting network connections",
117
- RemoteAsk: "Remote actor calls",
118
120
  };
119
121
  const features = {
120
122
  Vector: "This vector operation",
121
123
  Map: "This map operation",
122
124
  Set: "This set operation",
123
- "actor.periodic": "Periodic actor handlers",
124
125
  "actor.local_observation": "Actor links and monitors",
125
126
  "actor.heap_limit": "Per-actor heap limits",
126
127
  "actor.ingress_adapter": "Actor ingress adapters",
@@ -150,6 +151,7 @@ export function admitPackage(pkg) {
150
151
  span: null,
151
152
  };
152
153
  }
154
+ const timerResources = new Set();
153
155
  for (const function_ of pkg.functions) {
154
156
  for (const block of function_.blocks) {
155
157
  if (block.ops.some((op) => op.op === "actor.ingress_adapter"))
@@ -182,6 +184,66 @@ export function admitPackage(pkg) {
182
184
  span: null,
183
185
  };
184
186
  }
187
+ const term = block.term;
188
+ if (term.op === "runtime.call" &&
189
+ pkg.runtime_families[term.family]?.family === "BytesDecodeUtf8") {
190
+ const shapes = term.variant_result_shapes;
191
+ const error = shapes && pkg.aggregates[shapes.error];
192
+ const length = shapes && pkg.variants[shapes.error_len];
193
+ const result = term.result_shape === null
194
+ ? undefined
195
+ : pkg.variants[term.result_shape];
196
+ if (!error ||
197
+ error.fields.length !== 2 ||
198
+ !length ||
199
+ !result ||
200
+ length.runtime_tags?.OptionSome === undefined ||
201
+ length.runtime_tags?.OptionNone === undefined ||
202
+ result.runtime_tags?.ResultOk === undefined ||
203
+ result.runtime_tags?.ResultErr === undefined)
204
+ return {
205
+ category: "invalid_package",
206
+ code: "sandbox.package.variant_result_shapes",
207
+ capability: null,
208
+ message: "UTF-8 decode lacks its checked result carrier",
209
+ span: null,
210
+ };
211
+ }
212
+ if (term.op === "actor.call" && term.operation.op === "timer_schedule") {
213
+ const operation = term.operation;
214
+ const handler = pkg.actors
215
+ ?.find((actor) => actor.id === operation.actor)
216
+ ?.handlers.find((handler) => handler.message_id === operation.message);
217
+ const resource = pkg.resources?.[operation.resource];
218
+ if (!handler ||
219
+ handler.streams ||
220
+ term.args.length !== 5 ||
221
+ !["reject", "drop_newest"].includes(operation.policy) ||
222
+ resource?.kind !== "opaque" ||
223
+ resource.close === undefined ||
224
+ !pkg.functions.some((fn) => fn.id === resource.close))
225
+ return {
226
+ category: "invalid_package",
227
+ code: "sandbox.package.invalid_timer",
228
+ capability: "actor.timer_schedule",
229
+ message: "timer schedule lacks its checked handler, timing or non-waiting admission",
230
+ span: null,
231
+ };
232
+ timerResources.add(operation.resource);
233
+ }
234
+ if (term.op === "suspend") {
235
+ const mandatoryClose = (pkg.resources ?? []).some((resource) => resource.close === function_.id);
236
+ if (term.kind === "Safepoint"
237
+ ? (term.cancel == null) !== mandatoryClose
238
+ : term.cancel == null)
239
+ return {
240
+ category: "invalid_package",
241
+ code: "sandbox.package.cancellation_ancestry",
242
+ capability: null,
243
+ message: "safepoint cancellation ancestry must match its authored close lifecycle",
244
+ span: null,
245
+ };
246
+ }
185
247
  const unknown = block.ops.find((op) => !OPS.has(op.op))?.op ??
186
248
  (!TERMS.has(block.term.op) ? block.term.op : null);
187
249
  if (unknown)
@@ -194,7 +256,7 @@ export function admitPackage(pkg) {
194
256
  };
195
257
  }
196
258
  }
197
- for (const resource of pkg.resources ?? []) {
259
+ for (const [index, resource] of (pkg.resources ?? []).entries()) {
198
260
  if (resource.kind === "record" &&
199
261
  (resource.shape === undefined ||
200
262
  !pkg.aggregates.some((shape) => shape.id === resource.shape)))
@@ -205,15 +267,49 @@ export function admitPackage(pkg) {
205
267
  message: "record resource has no exact aggregate shape",
206
268
  span: null,
207
269
  };
208
- if (resource.kind === "opaque")
270
+ if (resource.kind === "opaque" && !timerResources.has(index))
209
271
  return unavailable("resource.opaque_close");
210
272
  if (resource.kind === "nominal" &&
211
273
  !resolveExternShim(resource.release ?? ""))
212
274
  return unavailable("resource.external_close");
213
275
  }
214
276
  for (const actor of pkg.actors ?? []) {
215
- if (actor.handlers.some((handler) => handler.every_ns != null))
216
- return unavailable("actor.periodic");
277
+ if (typeof actor.name !== "string" || actor.name.length === 0)
278
+ return {
279
+ category: "invalid_package",
280
+ code: "sandbox.package.actor_name",
281
+ capability: null,
282
+ message: "actor lacks its declared diagnostic name",
283
+ span: null,
284
+ };
285
+ if (actor.crash != null &&
286
+ (actor.crash_info == null ||
287
+ !pkg.aggregates[actor.crash_info] ||
288
+ actor.crash_kind == null ||
289
+ !pkg.variants[actor.crash_kind] ||
290
+ actor.crash_action == null ||
291
+ !pkg.variants[actor.crash_action]))
292
+ return {
293
+ category: "invalid_package",
294
+ code: "sandbox.package.crash_hook_shapes",
295
+ capability: null,
296
+ message: "crash hook lacks its exact info, kind or action shape",
297
+ span: null,
298
+ };
299
+ if (actor.handlers.some((handler) => handler.every_ns != null &&
300
+ (!Number.isInteger(handler.every_ns) ||
301
+ handler.every_ns < 1_000_000 ||
302
+ handler.params !== 0 ||
303
+ handler.streams ||
304
+ handler.fallible ||
305
+ !pkg.functions.some((fn) => fn.id === handler.callable && fn.params.length === 1))))
306
+ return {
307
+ category: "invalid_package",
308
+ code: "sandbox.package.invalid_periodic",
309
+ capability: "actor.periodic",
310
+ message: "periodic handler lacks its checked interval or zero-argument body",
311
+ span: null,
312
+ };
217
313
  if (actor.exit != null || actor.down != null)
218
314
  return unavailable("actor.local_observation");
219
315
  if (actor.max_heap_bytes != null)
@@ -1,6 +1,9 @@
1
- import { type VmValue } from "../values.js";
1
+ import { type VmValue, type RcOwner } from "../values.js";
2
2
  import { type PackageV1 } from "./package.js";
3
3
  export type ValueRequest = {
4
+ kind: "fault";
5
+ message: string;
6
+ } | {
4
7
  kind: "call";
5
8
  callee: number;
6
9
  args: VmValue[];
@@ -13,7 +16,7 @@ export type ValueProgram = Generator<ValueRequest, VmValue, VmValue>;
13
16
  /** Execute the exact checked plan, including every derived component selection.
14
17
  * A user method yields to an ordinary VM frame and can suspend or fail. */
15
18
  export declare function selectedValue(pkg: PackageV1, id: number, args: VmValue[]): ValueProgram;
16
- export declare function sharedOperation(operation: string, args: VmValue[]): ValueProgram;
19
+ export declare function sharedOperation(operation: string, args: VmValue[], owner: RcOwner | null, typeName: string, option: (value: VmValue | null) => VmValue): ValueProgram;
17
20
  /** Open-addressed collection operations preserve native callback ordering:
18
21
  * growth hashes occupied slots before probing the incoming key. Staged slots
19
22
  * borrow their owners until every fallible callback has completed. */
@@ -1,6 +1,7 @@
1
- import { UNIT, cloneValue } from "../values.js";
1
+ import { UNIT, cloneValue, compareUtf8, } from "../values.js";
2
2
  import { runtimeTag, } from "./package.js";
3
3
  import { ShimFault } from "./shims.js";
4
+ import { actorIdentical } from "./actor-identity.js";
4
5
  const OFFSET = 0xcbf29ce484222325n;
5
6
  const PRIME = 0x100000001b3n;
6
7
  const integer = (value) => ({
@@ -32,16 +33,6 @@ function orderingResult(capability, comparison) {
32
33
  return boolean(comparison >= 0);
33
34
  }
34
35
  }
35
- function compareBytes(left, right) {
36
- const length = Math.min(left.length, right.length);
37
- for (let i = 0; i < length; i++) {
38
- if (left[i] < right[i])
39
- return -1;
40
- if (left[i] > right[i])
41
- return 1;
42
- }
43
- return left.length < right.length ? -1 : left.length > right.length ? 1 : 0;
44
- }
45
36
  function scalarOrdering(left, right, ty, capability) {
46
37
  if (left.kind === "f64") {
47
38
  if (right?.kind !== "f64" ||
@@ -113,7 +104,7 @@ export function* selectedValue(pkg, id, args) {
113
104
  return boolean(false);
114
105
  if (equality)
115
106
  return boolean(left.value === right.value);
116
- return orderingResult(ordering, compareBytes(new TextEncoder().encode(left.value), new TextEncoder().encode(right.value)));
107
+ return orderingResult(ordering, compareUtf8(left.value, right.value));
117
108
  }
118
109
  if (plan.ty === "bytes") {
119
110
  if (left.kind !== "vector")
@@ -127,10 +118,16 @@ export function* selectedValue(pkg, id, args) {
127
118
  bytes.every((value, i) => BigInt(value) === bits(right.items[i], "u8")));
128
119
  throw new Error("bytes ordering is outside the checked capability domain");
129
120
  }
121
+ if (left.kind === "actor" || left.kind === "supervisor") {
122
+ if (equality)
123
+ return boolean(actorIdentical(left, right));
124
+ throw new Error("actor capability is outside the checked equality domain");
125
+ }
130
126
  const components = plan.components ?? [];
131
127
  let fields;
132
128
  let others = [];
133
129
  let selections;
130
+ let vectorHashLength;
134
131
  if (left.kind === "record") {
135
132
  fields = left.fields;
136
133
  selections = components;
@@ -154,13 +151,20 @@ export function* selectedValue(pkg, id, args) {
154
151
  selections = plan.variants?.[left.tag] ?? [];
155
152
  }
156
153
  else if (left.kind === "vector") {
157
- if (hash)
158
- throw new Error("derived vector Hash is outside the checked capability domain");
159
- if (right?.kind !== "vector" || left.items.length !== right.items.length)
160
- return boolean(false);
161
154
  fields = left.items;
162
- others = right.items;
163
- selections = fields.map(() => components[0]);
155
+ if (hash) {
156
+ const elementSelection = components[0];
157
+ if (!elementSelection)
158
+ throw new Error("derived vector Hash lacks its checked element selection");
159
+ selections = fields.map(() => elementSelection);
160
+ vectorHashLength = fields.length;
161
+ }
162
+ else {
163
+ if (right?.kind !== "vector" || left.items.length !== right.items.length)
164
+ return boolean(false);
165
+ others = right.items;
166
+ selections = fields.map(() => components[0]);
167
+ }
164
168
  }
165
169
  else {
166
170
  if (hash)
@@ -169,7 +173,9 @@ export function* selectedValue(pkg, id, args) {
169
173
  return boolean(!!right && bits(left, plan.ty) === bits(right, plan.ty));
170
174
  return scalarOrdering(left, right, plan.ty, ordering);
171
175
  }
172
- let state = OFFSET;
176
+ let state = vectorHashLength === undefined
177
+ ? OFFSET
178
+ : mix(OFFSET, BigInt(vectorHashLength));
173
179
  for (const [at, field] of fields.entries()) {
174
180
  const selection = selections[at];
175
181
  if (!selection)
@@ -257,15 +263,44 @@ function option(pkg, shape, value) {
257
263
  payload: value === undefined ? [] : [value],
258
264
  };
259
265
  }
260
- export function* sharedOperation(operation, args) {
261
- if (operation === "RcNew")
262
- return { kind: "rc", cell: { value: args[0], refs: 1 }, closed: false };
266
+ export function* sharedOperation(operation, args, owner, typeName, option) {
267
+ if (operation === "RcNew") {
268
+ if (owner?.closed)
269
+ throw new Error("Rc allocation after actor cleanup quiesced");
270
+ const cell = {
271
+ value: args[0],
272
+ refs: 1,
273
+ weak: 0,
274
+ dying: false,
275
+ owner,
276
+ typeName,
277
+ };
278
+ owner?.blocks.add(cell);
279
+ return { kind: "rc", cell, closed: false };
280
+ }
263
281
  const receiver = args[0];
282
+ if (operation === "WeakCloneRc" || operation === "WeakUpgradeRc") {
283
+ if (receiver?.kind !== "weak" || receiver.closed)
284
+ throw new Error("shared operation has no live Weak");
285
+ if (operation === "WeakCloneRc")
286
+ return cloneValue(receiver);
287
+ if (receiver.cell.dying || receiver.cell.refs === 0)
288
+ return option(null);
289
+ receiver.cell.refs += 1;
290
+ return option({ kind: "rc", cell: receiver.cell, closed: false });
291
+ }
264
292
  if (receiver?.kind !== "rc" || receiver.closed)
265
293
  throw new Error("shared operation has no live Rc");
294
+ if (!receiver.cell || receiver.cell.dying) {
295
+ yield { kind: "fault", message: "Rc payload is being released" };
296
+ return UNIT;
297
+ }
266
298
  switch (operation) {
267
299
  case "RcClone":
268
300
  return cloneValue(receiver);
301
+ case "RcDowngrade":
302
+ receiver.cell.weak += 1;
303
+ return { kind: "weak", cell: receiver.cell, closed: false };
269
304
  case "RcGet":
270
305
  return cloneValue(receiver.cell.value);
271
306
  case "RcIsUnique":
@@ -273,7 +308,7 @@ export function* sharedOperation(operation, args) {
273
308
  case "RcStrongCount":
274
309
  return integer(BigInt(receiver.cell.refs));
275
310
  case "RcWeakCount":
276
- return integer(0n);
311
+ return integer(BigInt(receiver.cell.weak));
277
312
  case "RcSet": {
278
313
  const old = receiver.cell.value;
279
314
  receiver.cell.value = args[1];
@@ -1,10 +1,26 @@
1
1
  import type { JsonValue } from "./types.js";
2
+ import { IndexedString } from "./strings.js";
3
+ export { stringValue } from "./strings.js";
4
+ /** One creator-owned membership list; payload destruction uses the ordinary VM release path. */
5
+ export interface RcOwner {
6
+ blocks: Set<RcCell>;
7
+ closed: boolean;
8
+ }
9
+ export interface RcCell {
10
+ value: VmValue;
11
+ refs: number;
12
+ weak: number;
13
+ dying: boolean;
14
+ owner: RcOwner | null;
15
+ typeName: string;
16
+ }
2
17
  export type VmValue = {
3
18
  kind: "rc";
4
- cell: {
5
- value: VmValue;
6
- refs: number;
7
- };
19
+ cell: RcCell | null;
20
+ closed: boolean;
21
+ } | {
22
+ kind: "weak";
23
+ cell: RcCell;
8
24
  closed: boolean;
9
25
  } | {
10
26
  kind: "unit";
@@ -17,10 +33,7 @@ export type VmValue = {
17
33
  } | {
18
34
  kind: "f64";
19
35
  value: number;
20
- } | {
21
- kind: "string";
22
- value: string;
23
- } | {
36
+ } | IndexedString | {
24
37
  kind: "actor";
25
38
  id: string;
26
39
  } | {
@@ -38,6 +51,11 @@ export type VmValue = {
38
51
  } | {
39
52
  kind: "task";
40
53
  id: string;
54
+ } | {
55
+ kind: "timer";
56
+ resource: number;
57
+ cancel(): void;
58
+ closed: boolean;
41
59
  } | {
42
60
  kind: "generator";
43
61
  id: string;
@@ -102,10 +120,15 @@ export type VmValue = {
102
120
  * owned by the closure, so `cloneValue` clones it. */
103
121
  | {
104
122
  kind: "closure";
105
- body: number;
123
+ entries: ReadonlyArray<{
124
+ body: number;
125
+ specialization: number | null;
126
+ }>;
127
+ specialization: number | null;
106
128
  environment: VmValue;
107
129
  };
108
130
  export declare const UNIT: VmValue;
131
+ export declare function compareUtf8(a: string, b: string): number;
109
132
  export declare function cloneValue(value: VmValue): VmValue;
110
133
  export declare function valueFromLiteral(value: JsonValue): VmValue;
111
134
  export declare function renderF64(value: number): string;
@@ -1,18 +1,41 @@
1
+ import { stringValue } from "./strings.js";
2
+ export { stringValue } from "./strings.js";
1
3
  export const UNIT = { kind: "unit" };
4
+ const UTF8_ENCODER = new TextEncoder();
5
+ export function compareUtf8(a, b) {
6
+ const left = UTF8_ENCODER.encode(a);
7
+ const right = UTF8_ENCODER.encode(b);
8
+ const length = Math.min(left.length, right.length);
9
+ for (let i = 0; i < length; i++) {
10
+ if (left[i] < right[i])
11
+ return -1;
12
+ if (left[i] > right[i])
13
+ return 1;
14
+ }
15
+ return left.length < right.length ? -1 : left.length > right.length ? 1 : 0;
16
+ }
2
17
  const I64_LITERAL_DECIMAL = /^(?:0|-?[1-9]\d*)$/;
3
18
  export function cloneValue(value) {
4
19
  switch (value.kind) {
5
20
  case "rc":
6
21
  if (value.closed)
7
22
  throw new Error("cannot copy a released Rc");
23
+ if (!value.cell || value.cell.dying)
24
+ return { kind: "rc", cell: null, closed: false };
8
25
  value.cell.refs += 1;
9
26
  return { kind: "rc", cell: value.cell, closed: false };
27
+ case "weak":
28
+ if (value.closed)
29
+ throw new Error("cannot copy a released Weak");
30
+ value.cell.weak += 1;
31
+ return { kind: "weak", cell: value.cell, closed: false };
32
+ case "timer":
33
+ throw new Error("a timer cancellation handle cannot be copied");
10
34
  case "unit":
11
35
  return UNIT;
12
36
  case "bool":
13
37
  case "i64":
14
38
  case "f64":
15
- case "string":
16
39
  case "actor":
17
40
  case "supervisor":
18
41
  case "monitor":
@@ -25,6 +48,8 @@ export function cloneValue(value) {
25
48
  case "duplex":
26
49
  case "function": // function values are immutable — identity clone
27
50
  return { ...value };
51
+ case "string":
52
+ return value.clone();
28
53
  case "regex":
29
54
  return {
30
55
  kind: "regex",
@@ -61,7 +86,8 @@ export function cloneValue(value) {
61
86
  case "closure":
62
87
  return {
63
88
  kind: "closure",
64
- body: value.body,
89
+ entries: value.entries,
90
+ specialization: value.specialization,
65
91
  environment: cloneValue(value.environment),
66
92
  };
67
93
  case "map":
@@ -92,9 +118,9 @@ export function valueFromLiteral(value) {
92
118
  ? { kind: "i64", value: BigInt(value) }
93
119
  : { kind: "f64", value };
94
120
  case "string":
95
- return { kind: "string", value };
121
+ return stringValue(value);
96
122
  default:
97
- return { kind: "string", value: canonicalJson(value) };
123
+ return stringValue(canonicalJson(value));
98
124
  }
99
125
  }
100
126
  function taggedI64Literal(value) {
@@ -178,6 +204,8 @@ export function renderF64(value) {
178
204
  }
179
205
  export function renderStdout(value) {
180
206
  switch (value.kind) {
207
+ case "timer":
208
+ return "Timer";
181
209
  case "unit":
182
210
  return "()";
183
211
  case "bool":
@@ -218,7 +246,9 @@ export function renderStdout(value) {
218
246
  case "vector":
219
247
  return canonicalJson(value.items.map((item) => toJsonValue(item)));
220
248
  case "rc":
221
- return renderStdout(value.cell.value);
249
+ return "Rc";
250
+ case "weak":
251
+ return "Weak";
222
252
  case "map":
223
253
  return canonicalJson(mapEntriesJson(value));
224
254
  case "dyn":
@@ -226,11 +256,13 @@ export function renderStdout(value) {
226
256
  case "function":
227
257
  return value.id;
228
258
  case "closure":
229
- return `closure:${value.body}`;
259
+ return `closure:${value.entries.find((entry) => entry.specialization === value.specialization)?.body ?? "generic"}`;
230
260
  }
231
261
  }
232
262
  export function toJsonValue(value) {
233
263
  switch (value.kind) {
264
+ case "timer":
265
+ return "Timer";
234
266
  case "unit":
235
267
  return null;
236
268
  case "bool":
@@ -266,7 +298,9 @@ export function toJsonValue(value) {
266
298
  case "vector":
267
299
  return value.items.map(toJsonValue);
268
300
  case "rc":
269
- return toJsonValue(value.cell.value);
301
+ return "Rc";
302
+ case "weak":
303
+ return "Weak";
270
304
  case "map":
271
305
  return mapEntriesJson(value);
272
306
  case "dyn":
@@ -274,7 +308,7 @@ export function toJsonValue(value) {
274
308
  case "function":
275
309
  return value.id;
276
310
  case "closure":
277
- return `closure:${value.body}`;
311
+ return `closure:${value.entries.find((entry) => entry.specialization === value.specialization)?.body ?? "generic"}`;
278
312
  }
279
313
  }
280
314
  /// A map renders as its entries in insertion order, each a `[key, value]` pair.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@hew-lang/sandbox-vm",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "hewSource": {
5
- "commit": "c4b08fc6215201308794f6093aa67eb173eda1fd",
6
- "tag": "v0.6.0",
5
+ "commit": "dd0911184af12136eda7e89052e5b564d56ba643",
6
+ "tag": "v0.6.2",
7
7
  "dirty": false
8
8
  },
9
9
  "description": "Deterministic TypeScript interpreter for the Hew educational sandbox",
@@ -21,7 +21,7 @@
21
21
  "cli/"
22
22
  ],
23
23
  "dependencies": {
24
- "@hew-lang/wasm": "0.6.0"
24
+ "@hew-lang/wasm": "0.6.2"
25
25
  },
26
26
  "keywords": [
27
27
  "hew",