@pulse-compute/wasm-host-runtime 1.0.0-beta.4 → 1.0.0-beta.6
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.
- package/README.md +2 -2
- package/package.json +7 -7
- package/src/runtime/canonical-api-runtime.js +77 -14
- package/src/runtime/canonical-native-host.js +252 -48
- package/src/runtime/effect-invocations.js +52 -0
- package/src/runtime/native-crypto-verifier.js +17 -1
- package/src/runtime/native-value-budget.js +64 -0
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
> **Supported entry points:** `None for application authors.`<br>
|
|
8
8
|
> **Stability:** Internal compiler/runtime interface synchronized with this release set.<br>
|
|
9
9
|
> **npm:** [`@pulse-compute/wasm-host-runtime`](https://www.npmjs.com/package/@pulse-compute/wasm-host-runtime)<br>
|
|
10
|
-
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.
|
|
10
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/)
|
|
11
11
|
>
|
|
12
|
-
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.
|
|
12
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` package policy.
|
|
13
13
|
<!-- pulse-package-status:end -->
|
|
14
14
|
|
|
15
15
|
Package-owned PulseWasm host-runtime build-time builders and local runtime helpers.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulse-compute/wasm-host-runtime",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^22.14.0 || ^24.0.0"
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"./runtime/canonical-native-host": "./src/runtime/canonical-native-host.js"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@pulse-compute/crypto": "1.0.0-beta.
|
|
32
|
-
"@pulse-compute/runtime": "1.0.0-beta.
|
|
33
|
-
"@pulse-compute/wasm-build-support": "1.0.0-beta.
|
|
34
|
-
"@pulse-compute/wasm-contracts": "1.0.0-beta.
|
|
35
|
-
"@pulse-compute/wasm-schema-json": "1.0.0-beta.
|
|
31
|
+
"@pulse-compute/crypto": "1.0.0-beta.6",
|
|
32
|
+
"@pulse-compute/runtime": "1.0.0-beta.6",
|
|
33
|
+
"@pulse-compute/wasm-build-support": "1.0.0-beta.6",
|
|
34
|
+
"@pulse-compute/wasm-contracts": "1.0.0-beta.6",
|
|
35
|
+
"@pulse-compute/wasm-schema-json": "1.0.0-beta.6"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
49
49
|
"directory": "wasm/packages/host-runtime"
|
|
50
50
|
},
|
|
51
|
-
"homepage": "https://pulsecompute.io/v1.0.0-beta.
|
|
51
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/",
|
|
52
52
|
"bugs": {
|
|
53
53
|
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
54
54
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { createEffectInvocations, normalizeMaxEffects } = require('./effect-invocations.js');
|
|
2
3
|
const portableKv = require('@pulse-compute/runtime/host');
|
|
3
4
|
|
|
4
5
|
|
|
@@ -262,7 +263,10 @@ function redactRuntimeError(error, sensitiveValues = new Set(), seen = new WeakS
|
|
|
262
263
|
const rawDetail = ownDataValue(error, 'detail');
|
|
263
264
|
const message = redactString(typeof rawMessage === 'string' ? rawMessage : 'Pulse runtime failure.', sensitiveValues);
|
|
264
265
|
const name = redactString(typeof rawName === 'string' ? rawName : 'Error', sensitiveValues);
|
|
265
|
-
|
|
266
|
+
// Keep the finite admitted discriminator intact; messages, causes and arbitrary
|
|
267
|
+
// provider codes still pass through ordinary sensitive-value redaction.
|
|
268
|
+
const code = portableKv.isApplicationError(error) ? rawCode
|
|
269
|
+
: typeof rawCode === 'string' ? redactString(rawCode, sensitiveValues) : undefined;
|
|
266
270
|
const detail = rawDetail === undefined ? undefined : redactRuntimeValue(rawDetail, sensitiveValues, seen, 'detail', depth + 1);
|
|
267
271
|
|
|
268
272
|
const safe = code === undefined
|
|
@@ -390,6 +394,7 @@ function assertSchemaBodySize(schemaCodecs, text, schemaId, source) {
|
|
|
390
394
|
const runtimeValueErrorCodes = Object.freeze({
|
|
391
395
|
BodyTooLargeError: 'PULSE_BODY_TOO_LARGE',
|
|
392
396
|
BodyDecodeError: 'PULSE_BODY_DECODE',
|
|
397
|
+
RequestBodyInvalidUtf8Error: 'PULSE_REQUEST_BODY_INVALID_UTF8',
|
|
393
398
|
BodyUnavailableError: 'PULSE_BODY_UNAVAILABLE',
|
|
394
399
|
OpaqueBodyInspectionError: 'PULSE_OPAQUE_BODY_INSPECTION',
|
|
395
400
|
ResponseEncodeError: 'PULSE_RESPONSE_ENCODE',
|
|
@@ -431,7 +436,7 @@ function normalizeRequest(options = {}) {
|
|
|
431
436
|
function createRequestSurface(request, options = {}, schemaCodecs, trace) {
|
|
432
437
|
const body = runtimeApi.body.createValue(
|
|
433
438
|
{ body: request.body, headers: request.headers },
|
|
434
|
-
{ maxBytes: options.maxRequestBodyBytes || options.maxBodyBytes || options.maxStructuredBodyBytes }
|
|
439
|
+
{ maxBytes: options.maxRequestBodyBytes || options.maxBodyBytes || options.maxStructuredBodyBytes, strictUtf8: true }
|
|
435
440
|
);
|
|
436
441
|
const schemaMemo = new Map();
|
|
437
442
|
return Object.freeze({
|
|
@@ -634,6 +639,30 @@ function createCanonicalContext(options, trace) {
|
|
|
634
639
|
}
|
|
635
640
|
|
|
636
641
|
const ctx = Object.freeze({
|
|
642
|
+
decodeJson(text, schemaId) {
|
|
643
|
+
const key = requireSchemaId(schemaCodecs, schemaId, 'application-text');
|
|
644
|
+
const event = {
|
|
645
|
+
boundary: 'application-text', schemaId: key, responseCaseId: null,
|
|
646
|
+
operationId: `application-text:${key}`, status: null,
|
|
647
|
+
contentType: null, headers: [], bodyOwnership: 'application-owned',
|
|
648
|
+
target: options.target || null, provider: options.provider || null,
|
|
649
|
+
effectId: null, groupId: null
|
|
650
|
+
};
|
|
651
|
+
try {
|
|
652
|
+
if (typeof text !== 'string') {
|
|
653
|
+
throw new CanonicalRuntimeError('SchemaDecodeError', 'PULSE_SCHEMA_DECODE',
|
|
654
|
+
'Application JSON text must be a string.',
|
|
655
|
+
{ schemaId: key, source: 'application-text', expected: 'string', actualKind: typeof text });
|
|
656
|
+
}
|
|
657
|
+
assertSchemaBodySize(schemaCodecs, text, key, 'application-text');
|
|
658
|
+
const value = schemaCodecs.decodeJsonText(key, text, 'application-text');
|
|
659
|
+
jsonTrace(trace, { ...event, kind: 'json.decode.text' }, value);
|
|
660
|
+
return value;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
jsonTrace(trace, { ...event, kind: 'json.decode.error', ...jsonTraceErrorDetail(error) });
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
},
|
|
637
666
|
encodeJson(value, schemaId) {
|
|
638
667
|
const key = requireSchemaId(schemaCodecs, schemaId, 'application-value');
|
|
639
668
|
const event = {
|
|
@@ -761,11 +790,12 @@ function normalizedInit(init = {}, schemaCodecs, options = {}) {
|
|
|
761
790
|
let body;
|
|
762
791
|
let bodyMode = 'none';
|
|
763
792
|
if (hasBody) {
|
|
764
|
-
|
|
793
|
+
const incoming = options.incomingBody && value.body === options.incomingBody.marker();
|
|
794
|
+
if (typeof value.body !== 'string' && !incoming) {
|
|
765
795
|
throw new CanonicalRuntimeError('FetchRequestError', 'PULSE_FETCH_BODY_INVALID', 'Canonical fetch body must be a string.', { valueType: typeof value.body });
|
|
766
796
|
}
|
|
767
797
|
body = value.body;
|
|
768
|
-
bodyMode = 'text';
|
|
798
|
+
bodyMode = incoming ? 'incoming-request-v1' : 'text';
|
|
769
799
|
} else if (hasJson) {
|
|
770
800
|
if (!headerValue(headers, 'content-type')) headers.push(['content-type', 'application/json; charset=utf-8']);
|
|
771
801
|
if (hasSchema) {
|
|
@@ -911,6 +941,9 @@ function normalizeProviderEffect(effect, schemaCodecs, options = {}) {
|
|
|
911
941
|
if (effect.kind === 'fetch') return normalizeFetchEffect(effect, schemaCodecs, options);
|
|
912
942
|
const id = String(effect.id || '');
|
|
913
943
|
if (!id) throw new CanonicalRuntimeError('CanonicalEffectProtocolError', 'PULSE_CANONICAL_EFFECT_PROTOCOL', 'Canonical provider effect requires an id.', { effect });
|
|
944
|
+
if (effect.kind === 'output.readTextChunk') return Object.freeze({ id, kind: effect.kind, providerKind: 'output', operation: 'readTextChunk', capability: 'request.body.transform' });
|
|
945
|
+
if (effect.kind === 'output.start' || effect.kind === 'output.write') return Object.freeze({ id, kind: effect.kind, providerKind: 'output', operation: effect.kind.slice(7), capability: 'response.output', argument0: effect.argument0 });
|
|
946
|
+
if (effect.kind === 'time.now') return Object.freeze({ id, kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock', source: effect.source, ...(effect.groupKey === undefined ? {} : { groupKey: String(effect.groupKey) }) });
|
|
914
947
|
if (effect.kind === 'config.get' || effect.kind === 'secret.get') {
|
|
915
948
|
return Object.freeze({ id, kind: effect.kind, name: String(effect.name), ...(effect.groupKey === undefined ? {} : { groupKey: String(effect.groupKey) }), source: effect.source });
|
|
916
949
|
}
|
|
@@ -1324,10 +1357,23 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1324
1357
|
const registry = options.continuationRegistry || createContinuationRegistry({ ttlMs: options.continuationTtlMs, clock: options.clock });
|
|
1325
1358
|
|
|
1326
1359
|
async function execute(programModule, executionOptions = {}) {
|
|
1360
|
+
const input = { ...options, ...executionOptions };
|
|
1361
|
+
const budget = portableKv.createRequestBudget(input);
|
|
1362
|
+
try {
|
|
1363
|
+
budget.check();
|
|
1364
|
+
return await executeInvocation(programModule, { ...executionOptions, requestBudget: budget, signal: budget.signal,
|
|
1365
|
+
deadlineMonotonicMs: budget.deadlineMonotonicMs ?? input.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? input.kvClock : budget.clock });
|
|
1366
|
+
} finally { if (!input.requestBudget) budget.close(); }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
async function executeInvocation(programModule, executionOptions) {
|
|
1327
1370
|
if (!programModule || typeof programModule.createHandler !== 'function') throw new TypeError('Canonical runtime requires a compiled canonical module.');
|
|
1328
1371
|
const metadata = assertCanonicalProgramCompatibility(programModule);
|
|
1329
1372
|
executionSequence += 1;
|
|
1330
1373
|
const executionId = String(executionOptions.executionId || `canonical-${adapter.id}-${executionSequence}`);
|
|
1374
|
+
const maxEffects = normalizeMaxEffects(executionOptions.maxEffects ?? options.maxEffects);
|
|
1375
|
+
const invocations = createEffectInvocations(() => executionOptions.requestBudget.check());
|
|
1376
|
+
const continuationIds = [];
|
|
1331
1377
|
const sensitiveValues = initialSensitiveValues({ ...options, ...executionOptions });
|
|
1332
1378
|
let executionStatus = 'failed';
|
|
1333
1379
|
let executionFailure;
|
|
@@ -1380,21 +1426,24 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1380
1426
|
let dispatchedEffects = 0;
|
|
1381
1427
|
|
|
1382
1428
|
function executionContinuations() {
|
|
1383
|
-
|
|
1384
|
-
return registry.list().filter((entry) => entry.id.startsWith(prefix));
|
|
1429
|
+
return continuationIds.map(id => registry.get(id));
|
|
1385
1430
|
}
|
|
1386
1431
|
|
|
1387
1432
|
async function dispatchOne(effect) {
|
|
1433
|
+
executionOptions.requestBudget.check();
|
|
1434
|
+
const ticket = invocations.open(effect.id, effect.id);
|
|
1388
1435
|
const normalized = normalizeProviderEffect(effect, schemaCodecs, runtimeOptions);
|
|
1389
1436
|
const conditional = portableKv.isConditionalKv(normalized.kind);
|
|
1390
1437
|
if (conditional) portableKv.registerKvRedactions(normalized, (value) => sensitiveValues.add(value));
|
|
1391
|
-
recordTrace(effectTraceStart(adapter.id, executionId, normalized, sensitiveValues));
|
|
1438
|
+
recordTrace({ ...effectTraceStart(adapter.id, executionId, normalized, sensitiveValues), invocationId: ticket.invocationId });
|
|
1392
1439
|
dispatchedEffects += 1;
|
|
1393
1440
|
try {
|
|
1394
|
-
const effectExecution = { ...options, ...executionOptions, metadata, executionId, trace: traceSink, registerRedactionValue: (value) => sensitiveValues.add(value), onKvObservation: recordTrace };
|
|
1441
|
+
const effectExecution = { ...options, ...executionOptions, metadata, executionId, invocationId: ticket.invocationId, trace: traceSink, registerRedactionValue: (value) => sensitiveValues.add(value), onKvObservation: recordTrace };
|
|
1395
1442
|
let snapshot = conditional
|
|
1396
1443
|
? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)), effectExecution, runtimeOptions)
|
|
1397
|
-
: await adapter.dispatchEffect(normalized, effectExecution);
|
|
1444
|
+
: await executionOptions.requestBudget.race(adapter.dispatchEffect(normalized, effectExecution));
|
|
1445
|
+
executionOptions.requestBudget.check();
|
|
1446
|
+
invocations.assertPending(ticket);
|
|
1398
1447
|
if (normalized.kind === 'config.get' || normalized.kind === 'secret.get') {
|
|
1399
1448
|
if (snapshot !== undefined && typeof snapshot !== 'string') {
|
|
1400
1449
|
throw new CanonicalRuntimeError(
|
|
@@ -1415,6 +1464,7 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1415
1464
|
{ receivedType: Array.isArray(snapshot) ? 'array' : snapshot === null ? 'null' : typeof snapshot }
|
|
1416
1465
|
);
|
|
1417
1466
|
}
|
|
1467
|
+
if (normalized.kind === 'time.now') snapshot = require('@pulse-compute/runtime/host').normalizeTimeResult(snapshot);
|
|
1418
1468
|
if (normalized.kind === 'event.emit' && snapshot !== undefined) {
|
|
1419
1469
|
throw new CanonicalRuntimeError(
|
|
1420
1470
|
'EventAcceptanceError',
|
|
@@ -1441,9 +1491,13 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1441
1491
|
})
|
|
1442
1492
|
: snapshot;
|
|
1443
1493
|
resolutionOrder.push(effect.id);
|
|
1444
|
-
recordTrace(effectTraceResolved(adapter.id, executionId, normalized, value));
|
|
1445
|
-
return value;
|
|
1494
|
+
recordTrace({ ...effectTraceResolved(adapter.id, executionId, normalized, value), invocationId: ticket.invocationId });
|
|
1495
|
+
return invocations.settle(ticket, () => value);
|
|
1446
1496
|
} catch (error) {
|
|
1497
|
+
// A request deadline outranks a capability's concurrent abort result.
|
|
1498
|
+
try { executionOptions.requestBudget.check(); } catch (failure) {
|
|
1499
|
+
if (typeof failure?.code === 'string' && failure.code.startsWith('PULSE_REQUEST_')) error = failure;
|
|
1500
|
+
}
|
|
1447
1501
|
recordTrace(Object.freeze({ type: 'effect-failed', provider: adapter.id, executionId, effectId: effect.id, kind: normalized.kind, error: error.name || 'Error', code: error.code }));
|
|
1448
1502
|
throw redactRuntimeError(error, sensitiveValues);
|
|
1449
1503
|
}
|
|
@@ -1487,20 +1541,27 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1487
1541
|
const generator = programModule.createHandler()(context.ctx, pulse);
|
|
1488
1542
|
if (!generator || typeof generator.next !== 'function') throw new CanonicalRuntimeError('CanonicalHandlerProtocolError', 'PULSE_CANONICAL_HANDLER_PROTOCOL', 'Compiled canonical handler did not return a generator.');
|
|
1489
1543
|
let step;
|
|
1490
|
-
try { step = generator.next(); }
|
|
1544
|
+
try { executionOptions.requestBudget.check(); step = generator.next(); }
|
|
1491
1545
|
catch (error) { error.execution = { executionId, trace: [...trace], continuations: executionContinuations() }; throw error; }
|
|
1492
1546
|
|
|
1493
1547
|
while (!step.done) {
|
|
1494
1548
|
const marker = step.value;
|
|
1495
1549
|
if (!marker || marker.protocol !== canonicalRuntimeContract.CANONICAL_RUNTIME_PROTOCOL_VERSION || !canonicalRuntimeContract.CANONICAL_EFFECT_MARKER_KINDS.includes(marker.kind)) throw new CanonicalRuntimeError('CanonicalEffectProtocolError', 'PULSE_CANONICAL_EFFECT_PROTOCOL', 'Canonical handler yielded an unsupported runtime marker.', { marker });
|
|
1496
1550
|
const effects = marker.kind === 'group' ? marker.effects : [marker.effect];
|
|
1497
|
-
const continuationId = `${executionId}:${marker.continuationId}`;
|
|
1551
|
+
const continuationId = `${executionId}:${invocations.execution}:${marker.continuationId}:${continuationIds.length + 1}`;
|
|
1498
1552
|
registry.create({ id: continuationId, branchPoint: marker.continuationId, effectIds: effects.map((effect) => effect.id), ttlMs: executionOptions.continuationTtlMs || options.continuationTtlMs });
|
|
1553
|
+
continuationIds.push(continuationId);
|
|
1499
1554
|
registry.wait(continuationId);
|
|
1500
1555
|
recordTrace(Object.freeze({ type: 'continuation-waiting', provider: adapter.id, executionId, continuationId, effectIds: effects.map((effect) => effect.id) }));
|
|
1501
1556
|
let value;
|
|
1502
1557
|
try {
|
|
1558
|
+
if (dispatchedEffects + effects.length > maxEffects) {
|
|
1559
|
+
throw new CanonicalRuntimeError('EffectLimitError', 'PULSE_RUNTIME_EFFECT_LIMIT_EXCEEDED',
|
|
1560
|
+
`Pulse request exceeded the maximum of ${maxEffects} request-owned effects.`,
|
|
1561
|
+
{ effectCount: dispatchedEffects, pendingEffects: effects.length, maxEffects });
|
|
1562
|
+
}
|
|
1503
1563
|
value = marker.kind === 'group' ? await dispatchGroup(effects) : await dispatchOne(effects[0]);
|
|
1564
|
+
executionOptions.requestBudget.check();
|
|
1504
1565
|
registry.resume(continuationId);
|
|
1505
1566
|
recordTrace(Object.freeze({ type: 'continuation-resumed', provider: adapter.id, executionId, continuationId, branchPoint: marker.continuationId }));
|
|
1506
1567
|
step = generator.next(value);
|
|
@@ -1517,7 +1578,7 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1517
1578
|
}
|
|
1518
1579
|
|
|
1519
1580
|
let response;
|
|
1520
|
-
try { response = finalResponse(step.value, context.request.method); }
|
|
1581
|
+
try { executionOptions.requestBudget.check(); response = finalResponse(step.value, context.request.method); }
|
|
1521
1582
|
catch (error) {
|
|
1522
1583
|
error.execution = Object.freeze({ executionId, trace: Object.freeze([...trace]), continuations: Object.freeze(executionContinuations()), resolutionOrder: Object.freeze([...resolutionOrder]) });
|
|
1523
1584
|
throw error;
|
|
@@ -1544,6 +1605,7 @@ function createCanonicalHostRuntime(options = {}) {
|
|
|
1544
1605
|
executionFailure = redactRuntimeError(error, sensitiveValues);
|
|
1545
1606
|
throw executionFailure;
|
|
1546
1607
|
} finally {
|
|
1608
|
+
invocations.close();
|
|
1547
1609
|
try { adapter.disposeExecution(Object.freeze({ executionId, metadata, status: executionStatus, error: executionFailure })); }
|
|
1548
1610
|
catch (_) { /* provider cleanup must not mask execution results */ }
|
|
1549
1611
|
}
|
|
@@ -1570,6 +1632,7 @@ module.exports = {
|
|
|
1570
1632
|
CanonicalRuntimeError,
|
|
1571
1633
|
assertCanonicalProgramCompatibility,
|
|
1572
1634
|
createCanonicalHostRuntime,
|
|
1635
|
+
createRequestBudget: portableKv.createRequestBudget,
|
|
1573
1636
|
executeCanonicalProgram,
|
|
1574
1637
|
createCanonicalContext,
|
|
1575
1638
|
normalizeFetchResponse,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const portableKv = require('@pulse-compute/runtime/host');
|
|
3
|
+
const { createEffectInvocations, normalizeMaxEffects } = require('./effect-invocations.js');
|
|
4
|
+
const { NativeValueBudget } = require('./native-value-budget.js');
|
|
3
5
|
|
|
4
6
|
const crypto = require('node:crypto');
|
|
5
7
|
const {
|
|
@@ -45,7 +47,6 @@ const canonicalRuntime = loadCanonicalRuntime();
|
|
|
45
47
|
const schemaCodecTools = loadSchemaCodecTools();
|
|
46
48
|
const eventContract = loadEventContract();
|
|
47
49
|
let executionSequence = 0;
|
|
48
|
-
const DEFAULT_MAX_EFFECTS = 1024;
|
|
49
50
|
|
|
50
51
|
class CanonicalNativeHostError extends Error {
|
|
51
52
|
constructor(message, code = 'PULSE_CANONICAL_NATIVE_HOST_FAILED', detail = {}) {
|
|
@@ -60,14 +61,6 @@ function sha256(value) {
|
|
|
60
61
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
function normalizeMaxEffects(value) {
|
|
64
|
-
const maxEffects = value === undefined ? DEFAULT_MAX_EFFECTS : Number(value);
|
|
65
|
-
if (!Number.isSafeInteger(maxEffects) || maxEffects <= 0) {
|
|
66
|
-
throw new TypeError('Pulse maxEffects must be a positive safe integer.');
|
|
67
|
-
}
|
|
68
|
-
return maxEffects;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
64
|
function staticDecoderArgument(expression, effect) {
|
|
72
65
|
if (expression && expression.kind === 'literal' && ['string', 'number', 'boolean'].includes(typeof expression.value)) return expression.value;
|
|
73
66
|
if (expression && expression.kind === 'literal' && expression.value === null) return null;
|
|
@@ -117,13 +110,40 @@ function createSchemaCodecs(registryInput = {}) {
|
|
|
117
110
|
}
|
|
118
111
|
|
|
119
112
|
class ValueHeap {
|
|
120
|
-
constructor() {
|
|
113
|
+
constructor(budget) {
|
|
114
|
+
this.budget = budget;
|
|
121
115
|
this.values = new Map();
|
|
122
116
|
this.next = 1;
|
|
117
|
+
// Immutable scalars have value semantics. Reuse common handles rather than
|
|
118
|
+
// retaining a new boxed value for every literal/property read in a loop.
|
|
119
|
+
// Bound the index itself and rotate its oldest mapping as the working set
|
|
120
|
+
// changes. Eviction never releases a handle or refunds its cumulative charge.
|
|
121
|
+
this.scalars = budget ? new Map() : undefined;
|
|
122
|
+
this.identities = budget ? new WeakMap() : undefined;
|
|
123
|
+
this.identityCount = 0;
|
|
124
|
+
this.negativeZero = Symbol('negative-zero');
|
|
123
125
|
}
|
|
124
126
|
put(value) {
|
|
127
|
+
this.budget?.charge(0, 0); // Cached reads cannot recover a latched failure.
|
|
128
|
+
const type = typeof value;
|
|
129
|
+
const scalar = value === null || type === 'undefined' || type === 'boolean' || type === 'number' || type === 'string';
|
|
130
|
+
const object = value !== null && type === 'object';
|
|
131
|
+
if (object && this.identities?.has(value)) return this.identities.get(value);
|
|
132
|
+
const key = Object.is(value, -0) ? this.negativeZero : value;
|
|
133
|
+
if (scalar && this.scalars?.has(key)) return this.scalars.get(key);
|
|
134
|
+
const cache = scalar && this.scalars;
|
|
135
|
+
const cacheIdentity = object && this.identities && this.identityCount < 8192;
|
|
136
|
+
// Charge index growth once. Replacing a scalar mapping reuses its bounded
|
|
137
|
+
// capacity; every new value handle below remains cumulatively charged.
|
|
138
|
+
if ((cache && this.scalars.size < 8192) || cacheIdentity) this.budget.charge(1, this.budget.policy.edgeBytes * 2);
|
|
139
|
+
this.budget?.retain(value);
|
|
125
140
|
const handle = this.next++;
|
|
126
141
|
this.values.set(handle, value);
|
|
142
|
+
if (cache) {
|
|
143
|
+
if (this.scalars.size === 8192) this.scalars.delete(this.scalars.keys().next().value);
|
|
144
|
+
this.scalars.set(key, handle);
|
|
145
|
+
}
|
|
146
|
+
if (cacheIdentity) { this.identities.set(value, handle); this.identityCount++; }
|
|
127
147
|
return handle;
|
|
128
148
|
}
|
|
129
149
|
has(handle) { return this.values.has(Number(handle)); }
|
|
@@ -244,6 +264,8 @@ function nativeEffect(planEffect, payload) {
|
|
|
244
264
|
resource: planEffect.resource,
|
|
245
265
|
source: planEffect.source
|
|
246
266
|
};
|
|
267
|
+
if (planEffect.kind === 'output.start' || planEffect.kind === 'output.write') return { ...base, argument0: payload.argument0 };
|
|
268
|
+
if (planEffect.kind === 'time.now') return { ...base, providerKind: 'time', operation: 'now', capability: 'time.wall-clock' };
|
|
247
269
|
if (planEffect.kind === 'fetch') {
|
|
248
270
|
const response = nativeFetchResponseContract(planEffect, payload);
|
|
249
271
|
return { ...base, url: payload.url, init: payload.init, ...response };
|
|
@@ -347,6 +369,9 @@ function validateNativeModule(module, plan) {
|
|
|
347
369
|
const missing = runtimeContract.CANONICAL_NATIVE_EXPORT_NAMES.filter((name) => !exportNames.has(name));
|
|
348
370
|
if (missing.length > 0) throw new CanonicalNativeHostError('Native module is missing required ABI exports.', 'PULSE_CANONICAL_NATIVE_ABI_EXPORT_MISMATCH', { missing });
|
|
349
371
|
if (!plan || typeof plan !== 'object' || !plan.planHash) throw new TypeError('Native module execution requires its canonical native plan.');
|
|
372
|
+
if (plan.packages?.application?.effectFailure === 'package-completion' && !exports.some(entry => entry.name === 'pulse_package_set_effect_failure' && entry.kind === 'function')) {
|
|
373
|
+
throw new CanonicalNativeHostError('Native package application is missing its effect failure ABI.', 'PULSE_CANONICAL_NATIVE_ABI_EXPORT_MISMATCH');
|
|
374
|
+
}
|
|
350
375
|
const eventReachable = Boolean(plan.events && plan.events.catalog && plan.events.catalog.events.length > 0);
|
|
351
376
|
const eventExportNames = eventContract.EVENT_NATIVE_ABI_EXTENSION.exports.map((entry) => entry.name);
|
|
352
377
|
const presentEventExports = eventExportNames.filter((name) => exportNames.has(name));
|
|
@@ -370,9 +395,32 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
370
395
|
const moduleShape = validateNativeModule(module, plan);
|
|
371
396
|
const adapter = normalizeProviderAdapter(options.providerAdapter || options.provider);
|
|
372
397
|
const executionPlane = options.executionPlane === 'event' ? 'event' : 'http';
|
|
373
|
-
const
|
|
398
|
+
const memoryBudget = (runtimeContract.hasBoundedReadLoop(plan) || plan.capabilities.includes('response.output') || plan.capabilities.includes('request.body.transform'))
|
|
399
|
+
? new NativeValueBudget(runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY, close) : undefined;
|
|
400
|
+
const heap = new ValueHeap(memoryBudget);
|
|
374
401
|
const pending = [];
|
|
402
|
+
const invocations = createEffectInvocations(() => {
|
|
403
|
+
options.requestBudget?.check();
|
|
404
|
+
if (options.signal?.aborted) throw nativeRequestCancelled(options.signal);
|
|
405
|
+
});
|
|
406
|
+
function close() {
|
|
407
|
+
invocations.close();
|
|
408
|
+
pending.length = 0;
|
|
409
|
+
options.signal?.removeEventListener('abort', close);
|
|
410
|
+
}
|
|
411
|
+
function run(operation) {
|
|
412
|
+
try {
|
|
413
|
+
invocations.assertOpen();
|
|
414
|
+
const status = operation();
|
|
415
|
+
if (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.COMPLETE || status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.FAILED) close();
|
|
416
|
+
return status;
|
|
417
|
+
} catch (error) { close(); throw error; }
|
|
418
|
+
}
|
|
375
419
|
const trace = [];
|
|
420
|
+
if (memoryBudget) Object.defineProperty(trace, 'push', { value(...entries) {
|
|
421
|
+
for (const entry of entries) memoryBudget.retain(entry);
|
|
422
|
+
return Array.prototype.push.apply(this, entries);
|
|
423
|
+
} });
|
|
376
424
|
const sensitiveValues = new Set();
|
|
377
425
|
const configuredSecrets = options.secrets && typeof options.secrets === 'object' ? options.secrets : {};
|
|
378
426
|
for (const value of Object.values(configuredSecrets)) if (typeof value === 'string' && value.length > 0) sensitiveValues.add(value);
|
|
@@ -568,6 +616,15 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
568
616
|
}
|
|
569
617
|
|
|
570
618
|
const pulseHost = {
|
|
619
|
+
value_json(handle) {
|
|
620
|
+
const text = JSON.stringify(value(handle) ?? null);
|
|
621
|
+
if (Buffer.byteLength(text, 'utf8') > runtimeContract.CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES) throw new CanonicalNativeHostError('Native value transfer exceeds 1 MiB.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
|
|
622
|
+
const exports = instance.exports;
|
|
623
|
+
const pointer = exports.__new(text.length * 2, exports.pulse_schema_string_id());
|
|
624
|
+
const view = new Uint16Array(memory().buffer, pointer, text.length);
|
|
625
|
+
for (let index = 0; index < text.length; index++) view[index] = text.charCodeAt(index);
|
|
626
|
+
return pointer;
|
|
627
|
+
},
|
|
571
628
|
value_undefined() { return put(undefined); },
|
|
572
629
|
value_null() { return put(null); },
|
|
573
630
|
value_boolean(input) { return put(Number(input) !== 0); },
|
|
@@ -577,26 +634,43 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
577
634
|
value_array_push(arrayHandle, valueHandle) {
|
|
578
635
|
const target = value(arrayHandle);
|
|
579
636
|
if (!Array.isArray(target)) throw new CanonicalNativeHostError('value_array_push target is not an array.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { arrayHandle });
|
|
637
|
+
memoryBudget?.write(target, String(target.length), value(valueHandle));
|
|
580
638
|
target.push(value(valueHandle));
|
|
581
639
|
},
|
|
582
640
|
value_array_spread(arrayHandle, valueHandle) {
|
|
583
641
|
const target = value(arrayHandle);
|
|
584
642
|
const source = value(valueHandle);
|
|
585
643
|
if (!Array.isArray(target) || !source || typeof source[Symbol.iterator] !== 'function') throw new CanonicalNativeHostError('value_array_spread requires iterable input.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
|
|
586
|
-
|
|
644
|
+
const append = item => {
|
|
645
|
+
memoryBudget?.write(target, String(target.length), item);
|
|
646
|
+
target.push(item);
|
|
647
|
+
};
|
|
648
|
+
if (Array.isArray(source)) {
|
|
649
|
+
const length = source.length;
|
|
650
|
+
for (let index = 0; index < length; index++) append(source[index]);
|
|
651
|
+
} else for (const item of source) append(item);
|
|
587
652
|
},
|
|
588
653
|
value_object() { return put({}); },
|
|
589
654
|
value_object_set(objectHandle, keyHandle, valueHandle) {
|
|
590
655
|
const target = value(objectHandle);
|
|
591
656
|
if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_set target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
|
|
592
|
-
|
|
657
|
+
const key = String(value(keyHandle));
|
|
658
|
+
memoryBudget?.write(target, key, value(valueHandle));
|
|
659
|
+
target[key] = value(valueHandle);
|
|
593
660
|
},
|
|
594
661
|
value_object_spread(objectHandle, valueHandle) {
|
|
595
662
|
const target = value(objectHandle);
|
|
596
663
|
const source = value(valueHandle);
|
|
597
664
|
if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_spread target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
|
|
598
665
|
if (source === null || source === undefined) return;
|
|
599
|
-
|
|
666
|
+
// String spreading materializes one property per UTF-16 code unit.
|
|
667
|
+
// Reserve that capacity before asking the engine to enumerate its keys.
|
|
668
|
+
if (typeof source === 'string') memoryBudget?.charge(source.length,
|
|
669
|
+
source.length * (runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY.valueBytes + runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY.edgeBytes));
|
|
670
|
+
for (const key of Object.keys(source)) {
|
|
671
|
+
memoryBudget?.write(target, key, source[key]);
|
|
672
|
+
target[key] = source[key];
|
|
673
|
+
}
|
|
600
674
|
},
|
|
601
675
|
value_property(objectHandle, keyHandle) {
|
|
602
676
|
const target = value(objectHandle);
|
|
@@ -607,6 +681,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
607
681
|
const target = value(objectHandle);
|
|
608
682
|
if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write a property on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
|
|
609
683
|
const assigned = value(valueHandle);
|
|
684
|
+
memoryBudget?.write(target, String(value(keyHandle)), assigned);
|
|
610
685
|
target[String(value(keyHandle))] = assigned;
|
|
611
686
|
return valueHandle;
|
|
612
687
|
},
|
|
@@ -619,6 +694,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
619
694
|
const target = value(objectHandle);
|
|
620
695
|
if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write an element on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
|
|
621
696
|
const assigned = value(valueHandle);
|
|
697
|
+
memoryBudget?.write(target, value(keyHandle), assigned);
|
|
622
698
|
target[value(keyHandle)] = assigned;
|
|
623
699
|
return valueHandle;
|
|
624
700
|
},
|
|
@@ -631,6 +707,11 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
631
707
|
return put(unary(operator, value(valueHandle)));
|
|
632
708
|
},
|
|
633
709
|
value_truthy(valueHandle) { return value(valueHandle) ? 1 : 0; },
|
|
710
|
+
value_string_trim(valueHandle) {
|
|
711
|
+
const input = value(valueHandle);
|
|
712
|
+
if (typeof input !== 'string') throw new CanonicalNativeHostError('String trim requires a string value.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
|
|
713
|
+
return put(input.trim());
|
|
714
|
+
},
|
|
634
715
|
value_nullish(valueHandle) { return value(valueHandle) === null || value(valueHandle) === undefined ? 1 : 0; },
|
|
635
716
|
log(level, messageHandle) {
|
|
636
717
|
const methods = Object.freeze({ 1: 'error', 2: 'warn', 3: 'info', 4: 'debug' });
|
|
@@ -645,10 +726,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
645
726
|
request_url() { requireHttpSurface('ctx.req.url'); return put(context.ctx.req.url); },
|
|
646
727
|
request_path() { requireHttpSurface('ctx.req.path'); return put(context.ctx.req.path); },
|
|
647
728
|
request_headers() { requireHttpSurface('ctx.req.headers'); return put(context.ctx.req.headers); },
|
|
648
|
-
request_header(nameHandle) {
|
|
649
|
-
|
|
729
|
+
request_header(nameHandle) {
|
|
730
|
+
requireHttpSurface('ctx.req.header');
|
|
731
|
+
const name = String(value(nameHandle)).toLowerCase();
|
|
732
|
+
const header = context.ctx.req.headers.find(([key]) => String(key).toLowerCase() === name);
|
|
733
|
+
return put(header ? header[1] : undefined);
|
|
734
|
+
},
|
|
735
|
+
request_body() {
|
|
736
|
+
requireHttpSurface('ctx.req.body');
|
|
737
|
+
if (!options.incomingBody || adapter.id !== 'node') throw new CanonicalNativeHostError('Incoming forwarding requires an admitted Node request owner.', 'PULSE_REQUEST_FORWARDING_UNAVAILABLE');
|
|
738
|
+
return put(options.incomingBody.marker());
|
|
739
|
+
},
|
|
740
|
+
request_text() {
|
|
741
|
+
requireHttpSurface('ctx.req.text');
|
|
742
|
+
if (options.incomingBody) throw new CanonicalNativeHostError('Native forwarding/transform admission cannot also project structured request bytes.', 'PULSE_REQUEST_BODY_OWNERSHIP');
|
|
743
|
+
return put(context.ctx.req.text());
|
|
744
|
+
},
|
|
650
745
|
request_json(schemaHandle) {
|
|
651
746
|
requireHttpSurface('ctx.req.json');
|
|
747
|
+
if (options.incomingBody) throw new CanonicalNativeHostError('Native forwarding/transform admission cannot also project structured request bytes.', 'PULSE_REQUEST_BODY_OWNERSHIP');
|
|
652
748
|
const schema = value(schemaHandle);
|
|
653
749
|
return put(schema === undefined ? context.ctx.req.json() : context.ctx.req.json(schema));
|
|
654
750
|
},
|
|
@@ -656,6 +752,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
656
752
|
router_param(pathHandle, patternHandle, nameHandle) { return put(routerParam(value(pathHandle), value(patternHandle), value(nameHandle))); },
|
|
657
753
|
response_json(valueHandle, optionsHandle) { requireHttpSurface('ctx.json'); return put(context.ctx.json(value(valueHandle), value(optionsHandle))); },
|
|
658
754
|
schema_encode(valueHandle, schemaHandle) { return put(context.ctx.encodeJson(value(valueHandle), value(schemaHandle))); },
|
|
755
|
+
schema_decode(textHandle, schemaHandle) { return put(context.ctx.decodeJson(value(textHandle), value(schemaHandle))); },
|
|
659
756
|
response_text(valueHandle, optionsHandle) { requireHttpSurface('ctx.text'); return put(context.ctx.text(value(valueHandle), value(optionsHandle))); },
|
|
660
757
|
response_custom(valueHandle) { requireHttpSurface('ctx.response'); return put(context.ctx.response(value(valueHandle))); },
|
|
661
758
|
grip_is_websocket() {
|
|
@@ -724,14 +821,26 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
724
821
|
if (!response || typeof response.header !== 'function') throw new CanonicalNativeHostError('fetch_header requires a fetch response.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
|
|
725
822
|
return put(response.header(value(nameHandle)));
|
|
726
823
|
},
|
|
824
|
+
output_close() {
|
|
825
|
+
if (!options.outputExecution) throw new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires Node HTTP execution.');
|
|
826
|
+
return put(options.outputExecution.close());
|
|
827
|
+
},
|
|
727
828
|
effect_begin(effectIndex, payloadHandle) {
|
|
829
|
+
options.outputExecution?.assertEffect();
|
|
728
830
|
const index = Number(effectIndex);
|
|
729
831
|
const effect = plan.effects && plan.effects[index];
|
|
730
832
|
if (!effect) throw new CanonicalNativeHostError(`Native module requested unknown effect index ${index}.`, 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: index });
|
|
731
833
|
const payload = value(payloadHandle);
|
|
834
|
+
// Admission happens while the guest builds its suspension group. A bad
|
|
835
|
+
// later member throws before the host dispatch loop starts any member.
|
|
836
|
+
if (effect.kind === 'fetch' && options.incomingBody) {
|
|
837
|
+
const admitted = canonicalRuntime.normalizeProviderEffect(nativeEffect(effect, payload), schemaCodecs, { ...options, strict, target: 'native', provider: adapter.id });
|
|
838
|
+
if (admitted.init.bodyMode === 'incoming-request-v1') options.incomingBody.claim(admitted.init.body, admitted.init);
|
|
839
|
+
}
|
|
732
840
|
const kvAdmission = portableKv.isConditionalKv(effect.kind) ? portableKv.admitConditionalKv(nativeEffect(effect, payload), options) : undefined;
|
|
733
841
|
if (kvAdmission) portableKv.registerKvRedactions(kvAdmission, (value) => sensitiveValues.add(value));
|
|
734
|
-
|
|
842
|
+
const ticket = invocations.open(index, effect.id);
|
|
843
|
+
pending.push(Object.freeze({ index, effect, payload, ticket, ...(kvAdmission ? { kvAdmission } : {}) }));
|
|
735
844
|
}
|
|
736
845
|
};
|
|
737
846
|
|
|
@@ -748,6 +857,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
748
857
|
else env[item.name] = () => { throw new CanonicalNativeHostError(`Unsupported AssemblyScript env import ${item.name}.`, 'PULSE_CANONICAL_NATIVE_ENV_IMPORT_UNSUPPORTED', { name: item.name }); };
|
|
749
858
|
}
|
|
750
859
|
|
|
860
|
+
let applicationError = 0;
|
|
861
|
+
const applicationErrors = executionPlane === 'http' && (plan.routing?.entries || []).some(entry => entry.kind === 'error');
|
|
862
|
+
function captureApplicationError(error) {
|
|
863
|
+
if (options.outputExecution?.started) return false;
|
|
864
|
+
if (!applicationErrors || options.signal?.aborted || !portableKv.isApplicationError(error)) return false;
|
|
865
|
+
if (!applicationError) {
|
|
866
|
+
const safe = canonicalRuntime.redactRuntimeError(error, sensitiveValues);
|
|
867
|
+
applicationError = put(Object.freeze({ name: safe.name, code: safe.code, message: safe.message }));
|
|
868
|
+
}
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
if (applicationErrors) for (const [name, implementation] of Object.entries(pulseHost)) {
|
|
872
|
+
pulseHost[name] = (...args) => {
|
|
873
|
+
if (applicationError) return 0;
|
|
874
|
+
try { return implementation(...args); }
|
|
875
|
+
catch (error) { if (!captureApplicationError(error)) throw error; return 0; }
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
pulseHost.router_error_take = () => { const error = applicationError; applicationError = 0; return error; };
|
|
751
879
|
instance = new WebAssembly.Instance(module, { pulse_host: pulseHost, env });
|
|
752
880
|
if (schemaIndexes.size > 0) {
|
|
753
881
|
const available = new Set(moduleShape.exports.map((entry) => entry.name));
|
|
@@ -790,6 +918,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
790
918
|
}
|
|
791
919
|
|
|
792
920
|
let result = portableKv.isConditionalKv(effect.kind) ? portableKv.normalizeConditionalKvResult(effect, rawResult, options) : rawResult;
|
|
921
|
+
if (effect.kind === 'time.now') result = require('@pulse-compute/runtime/host').normalizeTimeResult(rawResult);
|
|
793
922
|
if (effect.kind === 'event.emit' && result !== undefined) {
|
|
794
923
|
throw new canonicalRuntime.CanonicalRuntimeError(
|
|
795
924
|
'EventAcceptanceError',
|
|
@@ -826,13 +955,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
826
955
|
return result;
|
|
827
956
|
}
|
|
828
957
|
|
|
829
|
-
function setEffectResult(
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
958
|
+
function setEffectResult(ticket, result) {
|
|
959
|
+
return invocations.settle(ticket, () => {
|
|
960
|
+
const handle = put(result);
|
|
961
|
+
const accepted = instance.exports.pulse_set_effect_result(ticket.slot, handle);
|
|
962
|
+
if (accepted !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
|
|
963
|
+
close();
|
|
964
|
+
throw new CanonicalNativeHostError('Native module rejected an invocation result.', 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: ticket.slot, errorCode: instance.exports.pulse_last_error_code() });
|
|
965
|
+
}
|
|
966
|
+
return handle;
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function setEffectFailure(ticket) {
|
|
971
|
+
return invocations.settle(ticket, () => {
|
|
972
|
+
if (instance.exports.pulse_package_set_effect_failure(ticket.slot) !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
|
|
973
|
+
close();
|
|
974
|
+
throw new CanonicalNativeHostError('Native package rejected an invocation failure.', 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID');
|
|
975
|
+
}
|
|
976
|
+
});
|
|
836
977
|
}
|
|
837
978
|
|
|
838
979
|
function resultValue() {
|
|
@@ -872,9 +1013,11 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
872
1013
|
schemaId: selected.schemaId,
|
|
873
1014
|
payloadHandle
|
|
874
1015
|
});
|
|
875
|
-
return instance.exports.pulse_event_start(selected.runtimeId, payloadHandle);
|
|
1016
|
+
return run(() => instance.exports.pulse_event_start(selected.runtimeId, payloadHandle));
|
|
876
1017
|
}
|
|
877
1018
|
|
|
1019
|
+
options.signal?.addEventListener('abort', close, { once: true });
|
|
1020
|
+
if (options.signal?.aborted) close();
|
|
878
1021
|
return Object.freeze({
|
|
879
1022
|
version: runtimeContract.CANONICAL_NATIVE_HOST_VERSION,
|
|
880
1023
|
plan,
|
|
@@ -891,13 +1034,17 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
|
|
|
891
1034
|
cryptoVerifier: nativeCrypto.verifier,
|
|
892
1035
|
cryptoRealization: nativeCrypto.realization,
|
|
893
1036
|
executionPlane,
|
|
894
|
-
start() { requireExecutionPlane('http'); return instance.exports.pulse_start(); },
|
|
1037
|
+
start() { requireExecutionPlane('http'); return run(() => instance.exports.pulse_start()); },
|
|
895
1038
|
startEvent,
|
|
896
1039
|
eventSelection() { return activeEvent; },
|
|
897
|
-
resume() { return instance.exports.pulse_resume(); },
|
|
1040
|
+
resume() { return run(() => instance.exports.pulse_resume()); },
|
|
1041
|
+
close,
|
|
1042
|
+
assertPendingEffect: invocations.assertPending,
|
|
898
1043
|
pendingEffects: takePending,
|
|
899
1044
|
prepareEffectResult,
|
|
1045
|
+
captureApplicationError,
|
|
900
1046
|
setEffectResult,
|
|
1047
|
+
setEffectFailure,
|
|
901
1048
|
resultValue,
|
|
902
1049
|
response() { requireHttpSurface('HTTP completion'); return canonicalRuntime.finalResponse(resultValue(), context.ctx.req.method); },
|
|
903
1050
|
programCounter() { return instance.exports.pulse_program_counter(); },
|
|
@@ -915,9 +1062,18 @@ function nativeEventCancelled(message) {
|
|
|
915
1062
|
return new CanonicalNativeHostError(message, 'PULSE_CANONICAL_NATIVE_EVENT_CANCELLED');
|
|
916
1063
|
}
|
|
917
1064
|
|
|
918
|
-
function
|
|
1065
|
+
function nativeRequestCancelled(signal) {
|
|
1066
|
+
if (['PULSE_REQUEST_DEADLINE_EXCEEDED', 'PULSE_REQUEST_CLOCK_INVALID'].includes(signal.reason?.code)) return signal.reason;
|
|
1067
|
+
return new portableKv.PulseRuntimeContractError('PULSE_RUNTIME_EFFECT_ABORTED',
|
|
1068
|
+
signal.reason instanceof Error ? signal.reason.message : 'Native execution was cancelled.',
|
|
1069
|
+
{ cause: signal.reason });
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function raceNativeSignal(value, signal, eventMode = true) {
|
|
919
1073
|
if (!signal) return Promise.resolve(value);
|
|
920
|
-
|
|
1074
|
+
const cancelled = () => eventMode ? nativeEventCancelled('Native event execution was cancelled during effect dispatch.')
|
|
1075
|
+
: nativeRequestCancelled(signal);
|
|
1076
|
+
if (signal.aborted) return Promise.reject(cancelled());
|
|
921
1077
|
return new Promise((resolve, reject) => {
|
|
922
1078
|
let settled = false;
|
|
923
1079
|
const finish = (callback, result) => {
|
|
@@ -926,7 +1082,7 @@ function raceNativeEventSignal(value, signal) {
|
|
|
926
1082
|
signal.removeEventListener('abort', onAbort);
|
|
927
1083
|
callback(result);
|
|
928
1084
|
};
|
|
929
|
-
const onAbort = () => finish(reject,
|
|
1085
|
+
const onAbort = () => finish(reject, cancelled());
|
|
930
1086
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
931
1087
|
Promise.resolve(value).then(
|
|
932
1088
|
(result) => finish(resolve, result),
|
|
@@ -936,6 +1092,13 @@ function raceNativeEventSignal(value, signal) {
|
|
|
936
1092
|
}
|
|
937
1093
|
|
|
938
1094
|
async function executeCanonicalNativeInvocation(compiled, options = {}, invocation = Object.freeze({ plane: 'http' })) {
|
|
1095
|
+
const ownedResponseBodies = new Set();
|
|
1096
|
+
let responseBodyTransferred, invocationClosed = false;
|
|
1097
|
+
const cancelBody = body => { try { void body.cancel().catch(() => {}); } catch (_) {} };
|
|
1098
|
+
options = { ...options, registerResponseBody(body) {
|
|
1099
|
+
if (invocationClosed) cancelBody(body);
|
|
1100
|
+
else ownedResponseBodies.add(body);
|
|
1101
|
+
} };
|
|
939
1102
|
const eventMode = invocation && invocation.plane === 'event';
|
|
940
1103
|
const maxEffects = normalizeMaxEffects(options.maxEffects);
|
|
941
1104
|
const controller = instantiateCanonicalNativeModule(compiled, { ...options, executionPlane: eventMode ? 'event' : 'http' });
|
|
@@ -955,14 +1118,17 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
955
1118
|
let executionFailure;
|
|
956
1119
|
let activeContinuation;
|
|
957
1120
|
try {
|
|
1121
|
+
if (!eventMode && options.signal?.aborted) throw nativeRequestCancelled(options.signal);
|
|
958
1122
|
if (eventMode && options.signal && options.signal.aborted) {
|
|
959
1123
|
throw nativeEventCancelled('Native event execution was cancelled before handler entry.');
|
|
960
1124
|
}
|
|
1125
|
+
options.requestBudget?.check();
|
|
961
1126
|
status = eventMode ? controller.startEvent(invocation.frame) : controller.start();
|
|
962
1127
|
while (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.SUSPENDED) {
|
|
963
1128
|
if (eventMode && options.signal && options.signal.aborted) {
|
|
964
1129
|
throw nativeEventCancelled('Native event execution was cancelled while suspended.');
|
|
965
1130
|
}
|
|
1131
|
+
options.requestBudget?.check();
|
|
966
1132
|
const pending = controller.pendingEffects();
|
|
967
1133
|
if (pending.length !== controller.exports.pulse_pending_count()) {
|
|
968
1134
|
throw new CanonicalNativeHostError('Native pending-effect count differs from host queue.', 'PULSE_CANONICAL_NATIVE_PENDING_MISMATCH', { moduleCount: controller.exports.pulse_pending_count(), hostCount: pending.length });
|
|
@@ -976,6 +1142,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
976
1142
|
}
|
|
977
1143
|
activeContinuation = {
|
|
978
1144
|
id: continuation.id,
|
|
1145
|
+
invocationId: `continuation-${pending[0].ticket.invocationId}`,
|
|
979
1146
|
kind: continuation.kind,
|
|
980
1147
|
effectIds: Object.freeze([...pendingEffectIds]),
|
|
981
1148
|
stateIndex: continuationState,
|
|
@@ -983,7 +1150,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
983
1150
|
states: ['created', 'waiting']
|
|
984
1151
|
};
|
|
985
1152
|
continuations.push(activeContinuation);
|
|
986
|
-
controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-waiting', executionId, continuationId: continuation.id, continuationState, effectIds: Object.freeze([...pendingEffectIds]) }, controller.sensitiveValues)));
|
|
1153
|
+
controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-waiting', executionId, continuationId: continuation.id, invocationId: activeContinuation.invocationId, continuationState, effectIds: Object.freeze([...pendingEffectIds]) }, controller.sensitiveValues)));
|
|
987
1154
|
if (effectCount + pending.length > maxEffects) {
|
|
988
1155
|
throw new CanonicalNativeHostError(
|
|
989
1156
|
eventMode
|
|
@@ -995,13 +1162,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
995
1162
|
}
|
|
996
1163
|
effectCount += pending.length;
|
|
997
1164
|
|
|
998
|
-
|
|
1165
|
+
if (pending.length > 1 && pending.some(entry => entry.effect.kind.startsWith('output.'))) throw new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_PARALLEL_FORBIDDEN', 'Output effects cannot be grouped.');
|
|
1166
|
+
const settled = await raceNativeSignal(Promise.allSettled(pending.map(async (entry) => {
|
|
1167
|
+
options.requestBudget?.check();
|
|
999
1168
|
if (eventMode && options.signal && options.signal.aborted) {
|
|
1000
1169
|
throw nativeEventCancelled('Native event execution was cancelled before effect dispatch.');
|
|
1001
1170
|
}
|
|
1002
1171
|
const rawEffect = { ...nativeEffect(entry.effect, entry.payload), ...entry.kvAdmission };
|
|
1003
1172
|
const conditional = portableKv.isConditionalKv(rawEffect.kind);
|
|
1004
|
-
const privateEffect = conditional || ((controller.plan.packages && controller.plan.packages.effects) || []).some((item) =>
|
|
1173
|
+
const privateEffect = rawEffect.kind.startsWith('output.') || conditional || ((controller.plan.packages && controller.plan.packages.effects) || []).some((item) =>
|
|
1005
1174
|
item.package === entry.effect.package && item.contractId === entry.effect.contractId && item.operation === entry.effect.operation
|
|
1006
1175
|
&& item.redaction && Object.keys(item.redaction).length > 0);
|
|
1007
1176
|
const normalized = canonicalRuntime.normalizeProviderEffect(rawEffect, controller.schemaCodecs, {
|
|
@@ -1013,10 +1182,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1013
1182
|
target: 'native',
|
|
1014
1183
|
provider: adapter.id
|
|
1015
1184
|
});
|
|
1016
|
-
controller.
|
|
1185
|
+
controller.assertPendingEffect(entry.ticket);
|
|
1186
|
+
controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-start', executionId, invocationId: entry.ticket.invocationId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, payload: privateEffect ? '<redacted>' : entry.payload }, controller.sensitiveValues)));
|
|
1017
1187
|
const effectExecution = {
|
|
1018
1188
|
...options,
|
|
1019
1189
|
executionId,
|
|
1190
|
+
invocationId: entry.ticket.invocationId,
|
|
1020
1191
|
metadata: compiled && compiled.plan ? compiled.plan.canonical : controller.plan.canonical,
|
|
1021
1192
|
plan: controller.plan,
|
|
1022
1193
|
packageArtifacts,
|
|
@@ -1030,22 +1201,40 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1030
1201
|
return controller.schemaCodecs.decode(String(schemaId), value, context.source || 'package-effect');
|
|
1031
1202
|
}
|
|
1032
1203
|
};
|
|
1033
|
-
|
|
1204
|
+
options.outputExecution?.assertEffect();
|
|
1205
|
+
const rawResult = normalized.kind.startsWith('output.')
|
|
1206
|
+
? await (options.outputExecution ? options.outputExecution.dispatch(normalized) : Promise.reject(new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires Node HTTP execution.')))
|
|
1207
|
+
: conditional
|
|
1034
1208
|
? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)),
|
|
1035
1209
|
{ ...effectExecution, onKvObservation: (event) => controller.trace.push(event) }, options)
|
|
1036
|
-
: await
|
|
1037
|
-
|
|
1038
|
-
controller.
|
|
1210
|
+
: await raceNativeSignal(adapter.dispatchEffect(normalized, effectExecution), options.signal, eventMode);
|
|
1211
|
+
options.requestBudget?.check();
|
|
1212
|
+
controller.assertPendingEffect(entry.ticket);
|
|
1213
|
+
// executeConditionalKv already validates, detaches, freezes and registers
|
|
1214
|
+
// redactions using these execution limits. Reuse only that internal
|
|
1215
|
+
// result; raw controller callers still use prepareEffectResult.
|
|
1216
|
+
const result = conditional ? rawResult : controller.prepareEffectResult(entry.index, rawResult);
|
|
1217
|
+
controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-resolved', executionId, invocationId: entry.ticket.invocationId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, result: privateEffect || entry.effect.kind === 'secret.get' ? '<redacted>' : result && typeof result.toJSON === 'function' ? result.toJSON() : result }, controller.sensitiveValues)));
|
|
1039
1218
|
resolutionOrder.push(entry.effect.id);
|
|
1040
1219
|
return { entry, result };
|
|
1041
|
-
}));
|
|
1220
|
+
})), options.signal, eventMode);
|
|
1042
1221
|
|
|
1043
1222
|
if (eventMode && options.signal && options.signal.aborted) {
|
|
1044
1223
|
throw nativeEventCancelled('Native event execution was cancelled while suspended.');
|
|
1045
1224
|
}
|
|
1046
|
-
|
|
1047
|
-
if (
|
|
1048
|
-
|
|
1225
|
+
options.requestBudget?.check();
|
|
1226
|
+
if (!eventMode && options.signal?.aborted) throw nativeRequestCancelled(options.signal);
|
|
1227
|
+
const failures = settled.filter(entry => entry.status === 'rejected');
|
|
1228
|
+
const failed = failures.find(entry => !portableKv.isApplicationError(entry.reason)) || failures[0];
|
|
1229
|
+
const packageFailure = controller.plan.packages?.application?.effectFailure === 'package-completion';
|
|
1230
|
+
if (failed && !packageFailure && (!failures.every(entry => portableKv.isApplicationError(entry.reason))
|
|
1231
|
+
|| !controller.captureApplicationError(failed.reason))) {
|
|
1232
|
+
throw canonicalRuntime.redactRuntimeError(failed.reason, controller.sensitiveValues);
|
|
1233
|
+
}
|
|
1234
|
+
settled.forEach((item, index) => {
|
|
1235
|
+
if (item.status === 'rejected' && packageFailure) controller.setEffectFailure(pending[index].ticket);
|
|
1236
|
+
else controller.setEffectResult(pending[index].ticket, item.status === 'fulfilled' ? item.value.result : undefined);
|
|
1237
|
+
});
|
|
1049
1238
|
const previousPc = controller.programCounter();
|
|
1050
1239
|
activeContinuation.state = 'resumed';
|
|
1051
1240
|
activeContinuation.states.push('resumed');
|
|
@@ -1053,9 +1242,9 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1053
1242
|
if (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.INVALID_RESUME) {
|
|
1054
1243
|
throw new CanonicalNativeHostError('Native module rejected a complete effect-result set.', 'PULSE_CANONICAL_NATIVE_INVALID_RESUME', { programCounter: previousPc, errorCode: controller.lastErrorCode() });
|
|
1055
1244
|
}
|
|
1056
|
-
activeContinuation.state = 'completed';
|
|
1057
|
-
activeContinuation.states.push(
|
|
1058
|
-
controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, continuationState }, controller.sensitiveValues)));
|
|
1245
|
+
activeContinuation.state = failed ? 'failed' : 'completed';
|
|
1246
|
+
activeContinuation.states.push(activeContinuation.state);
|
|
1247
|
+
controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, invocationId: activeContinuation.invocationId, continuationState }, controller.sensitiveValues)));
|
|
1059
1248
|
activeContinuation = undefined;
|
|
1060
1249
|
}
|
|
1061
1250
|
|
|
@@ -1083,10 +1272,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1083
1272
|
resolutionOrder: Object.freeze([...resolutionOrder]),
|
|
1084
1273
|
continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
|
|
1085
1274
|
trace: Object.freeze([...controller.trace]),
|
|
1086
|
-
valueHandleCount: controller.heap.size()
|
|
1275
|
+
valueHandleCount: controller.heap.size(),
|
|
1276
|
+
...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
|
|
1087
1277
|
});
|
|
1088
1278
|
}
|
|
1279
|
+
if (options.signal?.aborted) throw nativeRequestCancelled(options.signal);
|
|
1280
|
+
options.requestBudget?.check();
|
|
1281
|
+
options.outputExecution?.validateResult(controller.resultValue());
|
|
1089
1282
|
const response = controller.response();
|
|
1283
|
+
responseBodyTransferred = response.bodyStream;
|
|
1090
1284
|
controller.trace.push(Object.freeze(redactValue({ type: 'native-execution-completed', executionId, provider: adapter.id, status: response.status, bodyClass: response.bodyClass }, controller.sensitiveValues)));
|
|
1091
1285
|
return Object.freeze({
|
|
1092
1286
|
status: 'completed',
|
|
@@ -1100,10 +1294,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1100
1294
|
wasmSha256: controller.wasmSha256,
|
|
1101
1295
|
response,
|
|
1102
1296
|
effectCount,
|
|
1297
|
+
...((options.incomingBody || options.outputExecution) ? {guestMemoryBytes:controller.exports.memory.buffer.byteLength} : {}),
|
|
1103
1298
|
resolutionOrder: Object.freeze([...resolutionOrder]),
|
|
1104
1299
|
continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
|
|
1105
1300
|
trace: Object.freeze([...controller.trace]),
|
|
1106
|
-
valueHandleCount: controller.heap.size()
|
|
1301
|
+
valueHandleCount: controller.heap.size(),
|
|
1302
|
+
...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
|
|
1107
1303
|
});
|
|
1108
1304
|
} catch (error) {
|
|
1109
1305
|
executionFailure = canonicalRuntime.redactRuntimeError(error, controller.sensitiveValues);
|
|
@@ -1120,13 +1316,21 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
|
|
|
1120
1316
|
}) });
|
|
1121
1317
|
throw executionFailure;
|
|
1122
1318
|
} finally {
|
|
1319
|
+
invocationClosed = true;
|
|
1320
|
+
for (const body of ownedResponseBodies) if (body !== responseBodyTransferred) cancelBody(body);
|
|
1321
|
+
ownedResponseBodies.clear();
|
|
1322
|
+
controller.close();
|
|
1123
1323
|
try { adapter.disposeExecution(Object.freeze({ executionId, metadata: controller.plan.canonical, plan: controller.plan, status: executionFailure ? 'failed' : 'completed', error: executionFailure })); }
|
|
1124
1324
|
catch (_) { /* provider cleanup must not mask execution */ }
|
|
1125
1325
|
}
|
|
1126
1326
|
}
|
|
1127
1327
|
|
|
1128
1328
|
async function executeCanonicalNativeModule(compiled, options = {}) {
|
|
1129
|
-
|
|
1329
|
+
const budget = portableKv.createRequestBudget(options);
|
|
1330
|
+
try {
|
|
1331
|
+
budget.check();
|
|
1332
|
+
return await executeCanonicalNativeInvocation(compiled, { ...options, responseSignal: options.signal, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock }, Object.freeze({ plane: 'http' }));
|
|
1333
|
+
} finally { if (!options.requestBudget) budget.close(); }
|
|
1130
1334
|
}
|
|
1131
1335
|
|
|
1132
1336
|
function nativeEventFailure(error) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CANONICAL_EFFECT_INVOCATION_POLICY } = require('@pulse-compute/wasm-contracts/handler/canonical-runtime');
|
|
4
|
+
let executionSequence = 0;
|
|
5
|
+
|
|
6
|
+
function normalizeMaxEffects(value) {
|
|
7
|
+
const max = value === undefined ? CANONICAL_EFFECT_INVOCATION_POLICY.defaultMaxEffects : Number(value);
|
|
8
|
+
if (!Number.isSafeInteger(max) || max <= 0) throw new TypeError('Pulse maxEffects must be a positive safe integer.');
|
|
9
|
+
return max;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A ticket is authenticated by object identity, never by caller-supplied fields.
|
|
13
|
+
// Only pending slots are retained; the sequence is cumulative across loop visits.
|
|
14
|
+
function createEffectInvocations(check = () => {}) {
|
|
15
|
+
const execution = ++executionSequence;
|
|
16
|
+
const active = new Map();
|
|
17
|
+
let sequence = 0;
|
|
18
|
+
let closed = false;
|
|
19
|
+
function invalid() {
|
|
20
|
+
const error = new Error('Effect invocation is stale, already settled, foreign, or closed.');
|
|
21
|
+
error.name = 'EffectInvocationError';
|
|
22
|
+
error.code = 'PULSE_EFFECT_INVOCATION_INVALID';
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
function assertOpen() { if (closed) invalid(); check(); }
|
|
26
|
+
function assertPending(ticket) {
|
|
27
|
+
assertOpen();
|
|
28
|
+
if (!ticket || active.get(ticket.slot) !== ticket) invalid();
|
|
29
|
+
return ticket;
|
|
30
|
+
}
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
execution,
|
|
33
|
+
open(slot, siteId) {
|
|
34
|
+
assertOpen();
|
|
35
|
+
if (active.has(slot) || !Number.isSafeInteger(sequence + 1)) invalid();
|
|
36
|
+
const ticket = Object.freeze({ slot, siteId, sequence: ++sequence, invocationId: `effect-${execution}-${sequence}` });
|
|
37
|
+
active.set(slot, ticket);
|
|
38
|
+
return ticket;
|
|
39
|
+
},
|
|
40
|
+
assertOpen,
|
|
41
|
+
assertPending,
|
|
42
|
+
settle(ticket, consume) {
|
|
43
|
+
assertPending(ticket);
|
|
44
|
+
active.delete(ticket.slot); // Consume before calling into the guest.
|
|
45
|
+
return consume();
|
|
46
|
+
},
|
|
47
|
+
close() { closed = true; active.clear(); },
|
|
48
|
+
get closed() { return closed; }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { createEffectInvocations, normalizeMaxEffects };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
CRYPTO_ES256_GUEST_LINKED_IMPLEMENTATION,
|
|
5
|
+
CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION, CRYPTO_RS256_GUEST_LINKED_REALIZATION,
|
|
5
6
|
CRYPTO_ES256_GUEST_LINKED_REALIZATION,
|
|
6
7
|
CRYPTO_GUEST_SOURCE_IMPLEMENTATION,
|
|
7
8
|
CRYPTO_REALIZATION_PLAN_VERSION
|
|
@@ -230,6 +231,9 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
|
|
|
230
231
|
const moduleExports = input.exports;
|
|
231
232
|
const selected = selectedHs256(plan);
|
|
232
233
|
const selectedSignature = selectedEs256(plan);
|
|
234
|
+
const selectedRsa = selectedAlgorithm(plan, 'RS256', CRYPTO_RS256_GUEST_LINKED_REALIZATION, 'guest-linked', CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION);
|
|
235
|
+
let rsa;
|
|
236
|
+
if (selectedRsa) { try { rsa = require('@pulse-compute/crypto/pulsewasm-native').bindNativeRs256(moduleExports); } catch {} }
|
|
233
237
|
const memory = moduleExports && moduleExports.memory;
|
|
234
238
|
const verifyHs256 = moduleExports && moduleExports[HS256_EXPORT];
|
|
235
239
|
const verifyEs256 = moduleExports && moduleExports[ES256_EXPORT];
|
|
@@ -249,6 +253,7 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
|
|
|
249
253
|
const digestMac = byteAlgorithms.length > 0
|
|
250
254
|
? require('@pulse-compute/crypto/pulsewasm-native').bindNativeDigestMac(moduleExports) : undefined;
|
|
251
255
|
const selectedRealizations = Object.freeze([
|
|
256
|
+
...(selectedRsa ? [{ algorithm: 'RS256', realization: CRYPTO_RS256_GUEST_LINKED_REALIZATION, implementation: CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION, guestUnitRequired: true, available: Boolean(rsa) }] : []),
|
|
252
257
|
...byteAlgorithms.map((algorithm) => Object.freeze({ algorithm, realization: HS256_REALIZATION,
|
|
253
258
|
implementation: CRYPTO_GUEST_SOURCE_IMPLEMENTATION, guestUnitRequired: false, available: Boolean(digestMac) })),
|
|
254
259
|
...(selected ? [Object.freeze({
|
|
@@ -276,8 +281,12 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
|
|
|
276
281
|
...(selectedRealizations.length === 1 ? selectedRealizations[0] : {})
|
|
277
282
|
});
|
|
278
283
|
|
|
284
|
+
const signer = es256Available && typeof moduleExports.pulse_crypto_es256_sign === 'function'
|
|
285
|
+
? require('@pulse-compute/crypto/pulsewasm-native').bindNativeEs256Signer(moduleExports) : undefined;
|
|
279
286
|
const verifier = Object.freeze({
|
|
280
|
-
...(digestMac ? { bytes: Object.freeze({
|
|
287
|
+
...(digestMac || signer || rsa ? { bytes: Object.freeze({
|
|
288
|
+
...(signer ? { es256Sign: signer } : {}),
|
|
289
|
+
...(rsa ? { rs256Sign: rsa.sign } : {}),
|
|
281
290
|
...(byteAlgorithms.includes('SHA-256') ? { sha256: digestMac.sha256 } : {}),
|
|
282
291
|
...(byteAlgorithms.includes('HMAC-SHA256') ? { hmacSha256: digestMac.hmacSha256 } : {})
|
|
283
292
|
}) } : {}),
|
|
@@ -313,6 +322,13 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
|
|
|
313
322
|
}),
|
|
314
323
|
signature: Object.freeze({
|
|
315
324
|
verify(request) {
|
|
325
|
+
if (ownData(request, 'algorithm') === 'RS256') {
|
|
326
|
+
const key = ownData(request, 'key');
|
|
327
|
+
if (ownData(key, 'type') !== 'rsa-public-key-bytes') return result('invalid-key');
|
|
328
|
+
if (!rsa) return result('realization-failure');
|
|
329
|
+
try { return rsa.verify(ownData(key, 'bytes'), ownData(request, 'data'), ownData(request, 'signature')); }
|
|
330
|
+
catch (error) { return result(error.code === 'PULSE_CRYPTO_KEY_INVALID' ? 'invalid-key' : 'invalid-input'); }
|
|
331
|
+
}
|
|
316
332
|
const normalized = normalizeSignatureRequest(request);
|
|
317
333
|
if (normalized.result) return normalized.result;
|
|
318
334
|
if (!es256Available) return result('realization-failure');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A conservative cumulative charge, deliberately never refunded. Counting a
|
|
4
|
+
// primitive again on a property read can overestimate retention. Object graphs
|
|
5
|
+
// are visited once; guest mutations are charged separately at the host imports.
|
|
6
|
+
// No charge releases an alias, pending payload, schema value, or returned root.
|
|
7
|
+
class NativeValueBudget {
|
|
8
|
+
constructor(policy, onFailure = () => {}) {
|
|
9
|
+
this.policy = policy;
|
|
10
|
+
this.onFailure = onFailure;
|
|
11
|
+
this.bytes = 0;
|
|
12
|
+
this.values = 0;
|
|
13
|
+
this.failure = undefined;
|
|
14
|
+
this.objects = new WeakSet();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
charge(values, bytes) {
|
|
18
|
+
if (this.failure) throw this.failure;
|
|
19
|
+
if (values > this.policy.maxValues - this.values || bytes > this.policy.maxBytes - this.bytes) {
|
|
20
|
+
const error = new Error('Native read-loop execution exceeded its retained-value budget.');
|
|
21
|
+
error.code = 'PULSE_RUNTIME_MEMORY_LIMIT_EXCEEDED';
|
|
22
|
+
error.detail = Object.freeze({ bytes: this.bytes, values: this.values,
|
|
23
|
+
maxBytes: this.policy.maxBytes, maxValues: this.policy.maxValues });
|
|
24
|
+
this.failure = error;
|
|
25
|
+
this.onFailure();
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
this.values += values;
|
|
29
|
+
this.bytes += bytes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
retain(value) {
|
|
33
|
+
const visit = input => {
|
|
34
|
+
this.charge(1, this.policy.valueBytes);
|
|
35
|
+
if (typeof input === 'string') this.charge(0, input.length * 2);
|
|
36
|
+
else if (input && typeof input === 'object' && !this.objects.has(input)) {
|
|
37
|
+
this.objects.add(input);
|
|
38
|
+
// Charge sparse capacity before visiting elements. Avoid allocating an
|
|
39
|
+
// intermediate array proportional to an untrusted container's length.
|
|
40
|
+
if (Array.isArray(input)) this.charge(input.length, input.length * this.policy.edgeBytes);
|
|
41
|
+
for (const key in input) if (Object.hasOwn(input, key)) {
|
|
42
|
+
this.charge(1, this.policy.edgeBytes + key.length * 2);
|
|
43
|
+
visit(input[key]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
visit(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
write(target, key, value) {
|
|
51
|
+
this.charge(1, this.policy.edgeBytes + String(key).length * 2);
|
|
52
|
+
if (Array.isArray(target)) {
|
|
53
|
+
const index = Number(key);
|
|
54
|
+
const length = key === 'length' ? Number(value) :
|
|
55
|
+
Number.isInteger(index) && index >= 0 && String(index) === String(key) ? index + 1 : target.length;
|
|
56
|
+
if (length > target.length) this.charge(length - target.length, (length - target.length) * this.policy.edgeBytes);
|
|
57
|
+
}
|
|
58
|
+
this.retain(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
snapshot() { return Object.freeze({ version: this.policy.version, bytes: this.bytes, values: this.values }); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { NativeValueBudget };
|