@hew-lang/sandbox-vm 0.7.1 → 0.9.1

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.
@@ -16,6 +16,7 @@ import { StructuralRenderer } from "./structural.js";
16
16
  import { timestampBinary } from "./timestamp.js";
17
17
  import { selectedValue, collectionOperation, sharedOperation, } from "./value-operations.js";
18
18
  import { Pipes } from "./pipes.js";
19
+ import { Semaphores } from "./semaphores.js";
19
20
  import { FrameScheduler } from "./scheduler.js";
20
21
  import { DeterministicIds } from "../../scheduler/ids.js";
21
22
  import { Mt19937 } from "./mt19937.js";
@@ -128,6 +129,7 @@ class ExecutorV1 {
128
129
  rootFault = null;
129
130
  pkg;
130
131
  directories;
132
+ semaphores;
131
133
  constructor(admission, trace, stdin, policy, options) {
132
134
  this.trace = trace;
133
135
  this.stdin = stdin;
@@ -136,10 +138,16 @@ class ExecutorV1 {
136
138
  this.pkg = pkg;
137
139
  this.directories = new BrowserDirectories(options.directoryRoots);
138
140
  this.scheduler = new FrameScheduler(trace, policy);
141
+ this.semaphores = new Semaphores((nanos, ready) => this.scheduler.after(nanos, ready));
139
142
  this.ids = new DeterministicIds(trace.replay.seed);
140
143
  this.host = {
141
144
  argv: [...(options.argv ?? ["main.hew"])],
142
145
  unixTimeMillis: () => this.trace.virtualTimeMs,
146
+ monotonicTimeNanos: () => {
147
+ const millis = this.trace.virtualTimeMs;
148
+ return (BigInt(Math.trunc(millis)) * 1000000n +
149
+ BigInt(Math.round((millis % 1) * 1_000_000)));
150
+ },
143
151
  calendarBridge: options.calendarBridge,
144
152
  codecBridge: options.codecBridge,
145
153
  encodingError: (format, message) => {
@@ -151,6 +159,10 @@ class ExecutorV1 {
151
159
  errors[format] = message;
152
160
  return errors[format] ?? "";
153
161
  },
162
+ setFileError: (message) => {
163
+ this.current.context.fileError =
164
+ message === null ? null : { kind: 0, message };
165
+ },
154
166
  fileError: (operation) => {
155
167
  const error = this.current.context.fileError;
156
168
  switch (operation) {
@@ -168,6 +180,7 @@ class ExecutorV1 {
168
180
  }
169
181
  },
170
182
  pipes: this.pipes,
183
+ semaphores: this.semaphores,
171
184
  generatorStream: (value) => {
172
185
  const id = this.ids.channel();
173
186
  this.generatorStreams.set(id, {
@@ -1158,7 +1171,15 @@ class ExecutorV1 {
1158
1171
  // `self`. A plain function value carries none.
1159
1172
  const callee = this.boundary(act, term.callee);
1160
1173
  const args = term.args.map((operand) => this.boundary(act, operand));
1161
- this.current = this.activate(this.functionAt(calleeFunction(callee, term.specialization)), callee.kind === "closure" ? [callee.environment, ...args] : args, act, term.result, term.normal, term.unwind);
1174
+ const body = this.functionAt(calleeFunction(callee, term.specialization));
1175
+ this.current = this.activate(body, callee.kind === "closure" ? [callee.environment, ...args] : args, act, term.result, term.normal, term.unwind);
1176
+ // SIR already distinguishes a consuming body from a borrowed body
1177
+ // exposed through a once-callable view. Only the latter leaves its
1178
+ // receiver cleanup to this invocation boundary.
1179
+ if (callee.kind === "closure" &&
1180
+ term.callee.decision === "move" &&
1181
+ body.params[0]?.own !== "owned")
1182
+ this.current.consumedCallable = callee;
1162
1183
  return;
1163
1184
  }
1164
1185
  case "actor.call":
@@ -1176,6 +1197,23 @@ class ExecutorV1 {
1176
1197
  if (registration.kind === "refused")
1177
1198
  throw new Error(`runtime family ${registration.capability} was refused after admission`);
1178
1199
  switch (registration.handler) {
1200
+ case "context_actor_id":
1201
+ this.completeShim(act, term, {
1202
+ kind: "i64",
1203
+ value: act.context.actor?.identity ?? 0n,
1204
+ });
1205
+ return;
1206
+ case "context_supervisor": {
1207
+ const parent = act.context.actor?.supervisor?.owner;
1208
+ this.completeShim(act, term, parent
1209
+ ? { kind: "supervisor", id: parent.id }
1210
+ : { kind: "i64", value: 0n });
1211
+ return;
1212
+ }
1213
+ case "context_trace_span":
1214
+ // The sandbox installs no external tracing span in actor turns.
1215
+ this.completeShim(act, term, { kind: "i64", value: 0n });
1216
+ return;
1179
1217
  case "actor_caller": {
1180
1218
  const caller = act.context.caller ?? { kind: "Task" };
1181
1219
  const payload = [];
@@ -1679,6 +1717,8 @@ class ExecutorV1 {
1679
1717
  return true;
1680
1718
  }
1681
1719
  returnFrom(act, value) {
1720
+ if (this.finishConsumedCallable(act, value, () => this.returnFrom(act, value)))
1721
+ return;
1682
1722
  if (this.finishConstruction(act, () => this.returnFrom(act, value)))
1683
1723
  return;
1684
1724
  const caller = act.caller;
@@ -1740,6 +1780,8 @@ class ExecutorV1 {
1740
1780
  this.takeEdge(act, unwind);
1741
1781
  }
1742
1782
  resumeUnwind(act, handback) {
1783
+ if (this.finishConsumedCallable(act, null, () => this.resumeUnwind(act, handback)))
1784
+ return;
1743
1785
  if (this.finishConstruction(act, () => this.resumeUnwind(act, handback)))
1744
1786
  return;
1745
1787
  const fault = act.fault;
@@ -1770,6 +1812,34 @@ class ExecutorV1 {
1770
1812
  this.current = caller;
1771
1813
  this.takeEdge(caller, act.unwind);
1772
1814
  }
1815
+ finishConsumedCallable(act, value, done) {
1816
+ const callable = act.consumedCallable;
1817
+ if (!callable)
1818
+ return false;
1819
+ act.consumedCallable = undefined;
1820
+ this.running = false;
1821
+ this.closeValueAsync(callable, this.faultText(act.fault), (fault) => {
1822
+ act.fault = combineFaults(act.fault, fault);
1823
+ const resume = () => {
1824
+ if (value !== null && act.fault)
1825
+ this.resumeUnwind(act, null);
1826
+ else
1827
+ done();
1828
+ const current = this.current;
1829
+ this.scheduler.enqueue(current.context.id, () => this.runFrame(current));
1830
+ };
1831
+ // A successful body result is not observable until receiver cleanup
1832
+ // succeeds. A cleanup fault must release that unpublished result.
1833
+ if (value !== null && act.fault)
1834
+ this.closeValueAsync(value, this.faultText(act.fault), (resultFault) => {
1835
+ act.fault = combineFaults(act.fault, resultFault);
1836
+ resume();
1837
+ }, act.context.actor, act.rcOwner);
1838
+ else
1839
+ resume();
1840
+ }, act.context.actor, act.rcOwner);
1841
+ return true;
1842
+ }
1773
1843
  suspend(act, input) {
1774
1844
  const registration = resolveSuspension(input);
1775
1845
  if (registration.kind === "refused")
@@ -2042,6 +2112,7 @@ class ExecutorV1 {
2042
2112
  // The record is the raw line,
2043
2113
  // so the records concatenate to the stdin the program consumed.
2044
2114
  const line = this.stdin.readLine() ?? new Uint8Array();
2115
+ act.context.fileError = null;
2045
2116
  this.trace.recordReplayInput({ kind: "stdin", data: new TextDecoder().decode(line) }, false);
2046
2117
  this.park(act, term)(bytesValue([...line]));
2047
2118
  return;
@@ -2080,6 +2151,25 @@ class ExecutorV1 {
2080
2151
  }
2081
2152
  return;
2082
2153
  }
2154
+ case "SemaphoreAcquire": {
2155
+ const handle = this.boundary(act, term.inputs[0]);
2156
+ const timeout = term.detail.operation === "AcquireTimeout"
2157
+ ? this.boundary(act, term.inputs[1])
2158
+ : null;
2159
+ if (timeout !== null && timeout.kind !== "i64")
2160
+ throw new Error("semaphore timeout must be an integer");
2161
+ let operation;
2162
+ const wake = this.park(act, term, () => { }, () => operation?.take());
2163
+ operation = this.semaphores.acquire(handle, timeout?.value ?? null, (acquired) => {
2164
+ wake(timeout === null ? UNIT : { kind: "bool", value: acquired });
2165
+ });
2166
+ const cancel = act.context.cancel;
2167
+ act.context.cancel = (fault) => {
2168
+ operation?.cancel();
2169
+ cancel?.(fault);
2170
+ };
2171
+ return;
2172
+ }
2083
2173
  case "Sleep": {
2084
2174
  const duration = term.inputs[0]
2085
2175
  ? this.boundary(act, term.inputs[0])
@@ -2113,7 +2203,7 @@ class ExecutorV1 {
2113
2203
  message: `local actor wait cycle at ${operation}: ${path.map((actor) => actor.identity).join(" -> ")}`,
2114
2204
  });
2115
2205
  }
2116
- park(act, term, cleanup = () => { }) {
2206
+ park(act, term, cleanup = () => { }, accepted = () => { }) {
2117
2207
  this.running = false;
2118
2208
  act.context.parked = true;
2119
2209
  let pending = null;
@@ -2138,6 +2228,7 @@ class ExecutorV1 {
2138
2228
  : term.unwind);
2139
2229
  }
2140
2230
  else {
2231
+ accepted();
2141
2232
  if (term.result && term.result !== "never")
2142
2233
  this.define(act, term.result.value, outcome.value);
2143
2234
  const edge = term.resumes[outcome.edge];
@@ -2480,6 +2571,9 @@ class ExecutorV1 {
2480
2571
  value.cell.weak -= 1;
2481
2572
  }
2482
2573
  break;
2574
+ case "semaphore":
2575
+ this.semaphores.close(value);
2576
+ break;
2483
2577
  case "host_resource": {
2484
2578
  if (value.owner.closed)
2485
2579
  break;
@@ -3114,12 +3208,29 @@ class ExecutorV1 {
3114
3208
  const count = isPool ? args[args.length - 1] : undefined;
3115
3209
  if (isPool && count?.kind !== "i64")
3116
3210
  throw new Error("pool construction has no count");
3117
- const config = isPool ? args.slice(0, -1) : args;
3118
- if (count?.kind === "i64" &&
3119
- (count.value <= 0n || count.value > 2147483647n)) {
3211
+ const configured = isPool ? args.slice(0, -1) : args;
3212
+ const initial = layout.backoff
3213
+ ? configured[configured.length - 2]
3214
+ : undefined;
3215
+ const maximum = layout.backoff
3216
+ ? configured[configured.length - 1]
3217
+ : undefined;
3218
+ const backoff = initial?.kind === "i64" && maximum?.kind === "i64"
3219
+ ? { initial: initial.value, maximum: maximum.value }
3220
+ : null;
3221
+ const validBackoff = !layout.backoff ||
3222
+ (backoff !== null &&
3223
+ backoff.initial > 0n &&
3224
+ backoff.maximum >= backoff.initial);
3225
+ const config = layout.backoff ? configured.slice(0, -2) : configured;
3226
+ if (!validBackoff ||
3227
+ (count?.kind === "i64" &&
3228
+ (count.value <= 0n || count.value > 2147483647n))) {
3120
3229
  const fault = {
3121
3230
  kind: "panic",
3122
- message: "supervisor pool count must be between 1 and 2147483647",
3231
+ message: !validBackoff
3232
+ ? "supervisor backoff requires positive initial and maximum >= initial"
3233
+ : "supervisor pool count must be between 1 and 2147483647",
3123
3234
  };
3124
3235
  this.running = false;
3125
3236
  this.closeValueAsync({ kind: "record", typeId: "", fields: config }, fault.message, (closeFault) => {
@@ -3147,6 +3258,9 @@ class ExecutorV1 {
3147
3258
  teardownChild: null,
3148
3259
  teardownCancelTimer: null,
3149
3260
  restartTimes: [],
3261
+ backoff,
3262
+ restartDelays: new Map(),
3263
+ restartTimers: new Map(),
3150
3264
  closed: [],
3151
3265
  };
3152
3266
  this.supervisors.set(owner.id, owner);
@@ -3628,6 +3742,30 @@ class ExecutorV1 {
3628
3742
  for (const timer of [...actor.timers])
3629
3743
  timer.cancel();
3630
3744
  }
3745
+ bindActorStream(context, sink) {
3746
+ let unwatch = () => { };
3747
+ let active = true;
3748
+ const detach = () => {
3749
+ active = false;
3750
+ unwatch();
3751
+ };
3752
+ const closed = () => {
3753
+ if (!active || !this.pipes.peerClosed(sink))
3754
+ return;
3755
+ // Bind the reader to this exact producer turn, including while it waits
3756
+ // on another operation. Later reader closes must not cancel a new turn.
3757
+ detach();
3758
+ context.pendingCancellation ??= {
3759
+ kind: "panic",
3760
+ message: "actor stream reader closed",
3761
+ cancelled: true,
3762
+ };
3763
+ context.cancel?.(context.pendingCancellation);
3764
+ };
3765
+ unwatch = this.pipes.observe(sink, closed);
3766
+ closed();
3767
+ return detach;
3768
+ }
3631
3769
  dispatchActor(actor) {
3632
3770
  if (actor.busy || !actor.alive || actor.drainingMailbox)
3633
3771
  return;
@@ -3696,6 +3834,7 @@ class ExecutorV1 {
3696
3834
  actor_id: actor.id,
3697
3835
  handler: message.handler.name,
3698
3836
  });
3837
+ let unwatchStream = () => { };
3699
3838
  const frame = this.activate(this.functionAt(message.handler.callable), [actor.state, ...message.payload], null, null, undefined, null, {
3700
3839
  id: actor.id,
3701
3840
  actor,
@@ -3703,6 +3842,7 @@ class ExecutorV1 {
3703
3842
  requestDeadline: message.requestDeadline,
3704
3843
  reply: message.reply,
3705
3844
  returned: (value) => {
3845
+ unwatchStream();
3706
3846
  actor.active = null;
3707
3847
  const drained = (fault) => {
3708
3848
  actor.busy = false;
@@ -3738,6 +3878,7 @@ class ExecutorV1 {
3738
3878
  }
3739
3879
  },
3740
3880
  failed: (fault) => {
3881
+ unwatchStream();
3741
3882
  actor.active = null;
3742
3883
  actor.busy = false;
3743
3884
  if (fault.kind === "trap" &&
@@ -3758,6 +3899,13 @@ class ExecutorV1 {
3758
3899
  },
3759
3900
  });
3760
3901
  actor.active = frame.context;
3902
+ if (message.handler.streams) {
3903
+ // StreamStart's checked producer protocol appends its output sink.
3904
+ const sink = message.payload.at(-1);
3905
+ if (sink?.kind !== "sink")
3906
+ throw new Error("actor stream producer has no final sink");
3907
+ unwatchStream = this.bindActorStream(frame.context, sink);
3908
+ }
3761
3909
  this.runFrame(frame);
3762
3910
  });
3763
3911
  }
@@ -4011,7 +4159,7 @@ class ExecutorV1 {
4011
4159
  wake();
4012
4160
  }
4013
4161
  }
4014
- restartChild(owner, child, debts) {
4162
+ restartChild(owner, child, debts, delayElapsed = false) {
4015
4163
  if (!owner.alive ||
4016
4164
  owner.specs[child].restart === "temporary" ||
4017
4165
  owner.spent.has(child)) {
@@ -4025,6 +4173,24 @@ class ExecutorV1 {
4025
4173
  return;
4026
4174
  }
4027
4175
  const now = this.trace.virtualTimeMs;
4176
+ if (owner.backoff && debts.length > 0 && !delayElapsed) {
4177
+ const previous = owner.restartDelays.get(child);
4178
+ const healthy = previous?.publishedAt != null &&
4179
+ now - previous.publishedAt >= owner.layout.window_secs * 1000;
4180
+ const doubled = previous ? previous.delay * 2n : owner.backoff.initial;
4181
+ const delay = !previous || healthy
4182
+ ? owner.backoff.initial
4183
+ : doubled < owner.backoff.maximum
4184
+ ? doubled
4185
+ : owner.backoff.maximum;
4186
+ owner.restartDelays.set(child, { delay, publishedAt: null });
4187
+ owner.recovering.add(child);
4188
+ owner.restartTimers.set(child, this.scheduler.after(delay, () => {
4189
+ owner.restartTimers.delete(child);
4190
+ this.restartChild(owner, child, debts, true);
4191
+ }));
4192
+ return;
4193
+ }
4028
4194
  owner.restartTimes = owner.restartTimes.filter((at) => now - at < owner.layout.window_secs * 1000);
4029
4195
  if (owner.restartTimes.length >= owner.layout.max_restarts) {
4030
4196
  this.escalateSupervisor(owner, debts);
@@ -4079,7 +4245,12 @@ class ExecutorV1 {
4079
4245
  this.restartChild(owner, pending.child, pending.debts);
4080
4246
  return;
4081
4247
  }
4082
- this.startChild(owner, index, start, () => {
4248
+ this.startChild(owner, index, () => {
4249
+ const delay = owner.restartDelays.get(index);
4250
+ if (delay)
4251
+ delay.publishedAt = this.trace.virtualTimeMs;
4252
+ start();
4253
+ }, () => {
4083
4254
  owner.restartBusy = false;
4084
4255
  this.restartChild(owner, index, debts);
4085
4256
  });
@@ -4155,6 +4326,9 @@ class ExecutorV1 {
4155
4326
  if (!owner.alive)
4156
4327
  return;
4157
4328
  owner.alive = false;
4329
+ for (const cancel of owner.restartTimers.values())
4330
+ cancel();
4331
+ owner.restartTimers.clear();
4158
4332
  if (owner.constructor) {
4159
4333
  owner.constructor.pendingCancellation ??= {
4160
4334
  kind: "panic",
@@ -659,7 +659,7 @@ export type RuntimeCallFamily = {
659
659
  format: EncodingFormat;
660
660
  op: EncodingOp;
661
661
  };
662
- } | "ActorDemonitor" | "ActorLink" | "ActorMonitor" | "ActorCaller" | "ActorRefusalLog" | "ActorUnlink" | "AutoMutexAlloc" | "AutoMutexFree" | "AutoMutexLock" | "AutoMutexUnlock" | "DequePopFront" | "DequePopBack" | "BytesAppend" | "BytesClear" | "BytesConcat" | "BytesContains" | "BytesDecodeUtf8" | "BytesDecodeUtf8Lossy" | "BytesEndsWith" | "BytesGet" | "BytesIndex" | "BytesIsEmpty" | "BytesLen" | "BytesPop" | "BytesPush" | "BytesSet" | "BytesSlice" | "BytesSliceFrom" | "BytesStartsWith" | "BytesNew" | "CancelTokenIsRequested" | "CancelTokenRelease" | "CancelTokenRetain" | "ActorRequestRelease" | "ActorCallFree" | "ActorRequestTake" | "DurationAbs" | "DurationHours" | "DurationIsZero" | "DurationMicros" | "DurationMillis" | "DurationMins" | "DurationNanos" | "DurationSecs" | "DynBoxAlloc" | "DynBoxFree" | "HashMapContainsKeyLayout" | "HashMapClearLayout" | "HashMapCloneLayout" | "HashMapEntriesLayout" | "HashMapFreeLayout" | "HashMapGetLayout" | "HashMapInsertLayout" | "HashMapKeysLayout" | "HashMapLenLayout" | "HashMapNew" | "HashMapNewWithLayout" | "HashMapRemoveLayout" | "HashMapValuesLayout" | "HashSetContainsLayout" | "HashSetClearLayout" | "HashSetCloneLayout" | "HashSetFreeLayout" | "HashSetInsertLayout" | "HashSetIsEmptyLayout" | "HashSetLenLayout" | "HashSetNew" | "HashSetNewWithLayout" | "HashSetRemoveLayout" | "HashSetToVecLayout" | "InstantDurationSince" | "InstantElapsed" | "InstantNow" | "TimestampNow" | {
662
+ } | "ActorDemonitor" | "ActorLink" | "ActorMonitor" | "ActorCaller" | "ActorRefusalLog" | "ActorUnlink" | "AutoMutexAlloc" | "AutoMutexFree" | "AutoMutexLock" | "AutoMutexUnlock" | "DequePopFront" | "DequePopBack" | "BytesAppend" | "BytesClear" | "BytesConcat" | "BytesContains" | "BytesDecodeUtf8" | "BytesDecodeUtf8Lossy" | "BytesEndsWith" | "BytesGet" | "BytesIndex" | "BytesIsEmpty" | "BytesLen" | "BytesPop" | "BytesPush" | "BytesSet" | "BytesSlice" | "BytesSliceFrom" | "BytesStartsWith" | "BytesNew" | "CancelTokenIsRequested" | "CancelTokenRelease" | "CancelTokenRetain" | "ContextActorId" | "ContextSupervisor" | "ContextTraceSpan" | "ActorRequestRelease" | "ActorCallFree" | "ActorRequestTake" | "DurationAbs" | "DurationHours" | "DurationIsZero" | "DurationMicros" | "DurationMillis" | "DurationMins" | "DurationNanos" | "DurationSecs" | "DynBoxAlloc" | "DynBoxFree" | "HashMapContainsKeyLayout" | "HashMapClearLayout" | "HashMapCloneLayout" | "HashMapEntriesLayout" | "HashMapFreeLayout" | "HashMapGetLayout" | "HashMapInsertLayout" | "HashMapKeysLayout" | "HashMapLenLayout" | "HashMapNew" | "HashMapNewWithLayout" | "HashMapRemoveLayout" | "HashMapValuesLayout" | "HashSetContainsLayout" | "HashSetClearLayout" | "HashSetCloneLayout" | "HashSetFreeLayout" | "HashSetInsertLayout" | "HashSetIsEmptyLayout" | "HashSetLenLayout" | "HashSetNew" | "HashSetNewWithLayout" | "HashSetRemoveLayout" | "HashSetToVecLayout" | "InstantDurationSince" | "InstantElapsed" | "InstantNow" | "TimestampNow" | {
663
663
  MathIntrinsic: MathIntrinsic;
664
664
  } | {
665
665
  IntMethod: [IntBitOp, IntMethodWidth];
@@ -791,6 +791,7 @@ export type SupervisorShape = {
791
791
  strategy: RestartStrategy;
792
792
  max_restarts: number;
793
793
  window_secs: number;
794
+ backoff?: boolean;
794
795
  children: Array<SupervisorChild>;
795
796
  startup_order?: Array<number>;
796
797
  };