diff --git a/docs/runtime/polkavm-app-abi-v1.md b/docs/runtime/polkavm-app-abi-v1.md index 25a8190..df98aaa 100644 --- a/docs/runtime/polkavm-app-abi-v1.md +++ b/docs/runtime/polkavm-app-abi-v1.md @@ -53,9 +53,18 @@ The Host instantiates a fresh program, calls `init` exactly once, and calls `update` zero or more times while the App is running. Calls are serialized; the Host MUST NOT enter the same program concurrently. -The Host selects and enforces a nonzero gas budget for each call. A trap, gas -exhaustion, invalid guest-memory access, or Host-call budget failure fails the -current execution. ABI v1 does not restart a failed program transparently. +The Host selects and enforces a nonzero gas budget for each call. It MAY +execute that budget as smaller internal quanta, returning to its scheduler and +resuming the same call between quanta. This preserves the program counter, +registers, memory, remaining call budget, scheduling requests, and per-call +Host-service bounds; it is not a transparent restart. A hostcall scheduling +quantum ending MUST NOT refill the guest's gas. The translated browser runtime +keeps the VM's remaining gas across hostcall yields and charges each exhausted +gas quantum's refill against the call's configured slice allowance. Reduced +initialization budgets do not receive additional gas quanta. +Exhausting the complete call budget, a trap, invalid guest-memory access, or a +Host-call budget failure fails the current execution. ABI v1 does not restart +a failed program transparently. The Host owns scheduling and presentation. Returning from `update` yields control to the Host; it does not imply that a frame was presented. @@ -115,17 +124,24 @@ host_update_after(delay_ms: u32) -> () Importing `host_update_after` opts a cooperative application guest into demand-driven updates. The Host performs the first `update` after `init` -automatically. Before each later update, the Host clears the previous request. -Calls made during that Host update select the smallest requested delay. +automatically, including when initialization completes through continuations; +an initialization scheduling request does not postpone that first update. +Before each later logical update, the Host clears the previous request. +Calls made during that update, including all of its gas and hostcall +continuations, select the smallest requested delay. The CoreVM compatibility path recognizes the same import and applies equivalent -behavior to the initial `_pvm_start` slice and each later resume. This is Host -compatibility behavior, not part of the portable CoreVM contract. +behavior between intentional frame yields: a frame yield starts a new +scheduling and resource-budget boundary, but a gas or hostcall quantum does +not. This is Host compatibility behavior, not part of the portable CoreVM +contract. `delay_ms == 0` requests another update as soon as the Host can schedule it. `delay_ms == u32::MAX` requests no timer; the Host waits until input, a Host-frame response, a GPU event, or another external event is queued for the -guest. Every such event MUST wake an opted-in guest promptly. +guest. Every such event MUST wake an opted-in guest promptly. A foreground wake +received between execution quanta MUST remain pending until a new logical +update starts; completing the interrupted call does not consume that wake. A guest that does not import this call retains Host-defined continuous scheduling for compatibility. Scheduling does not weaken per-update gas or @@ -1255,8 +1271,10 @@ conforming Host must provide. ## Failure and shutdown A successful `init` does not guarantee that later updates will succeed. The -Host stops the execution on an unhandled guest trap, gas exhaustion, invalid -memory access, unrecoverable profile error, or Host transport failure. +Host stops the execution on an unhandled guest trap, exhaustion of the complete +call gas budget, invalid memory access, unrecoverable profile error, or Host +transport failure. An internal execution quantum ending is not gas exhaustion +at this contract boundary. The Host may stop an execution when its App surface closes, the Product is replaced, the user selects a file for a relaunch registration, or platform diff --git a/js/packages/polkavm-browser-runtime/src/polkavm-runtime-core.js b/js/packages/polkavm-browser-runtime/src/polkavm-runtime-core.js index 307a3eb..5d239b5 100644 --- a/js/packages/polkavm-browser-runtime/src/polkavm-runtime-core.js +++ b/js/packages/polkavm-browser-runtime/src/polkavm-runtime-core.js @@ -22,6 +22,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { const LEGACY_FRAME_INTERVAL_MS = 1000 / 60; const MAX_GAS_PER_UPDATE = 10_000_000_000; const MAX_TRANSLATED_LOOPS_PER_UPDATE = 50_000_000; + const MAX_TRANSLATED_GAS_SLICES_PER_UPDATE = + MAX_GAS_PER_UPDATE / MAX_TRANSLATED_LOOPS_PER_UPDATE; const MAX_PROGRAM_BYTES = 64 * 1024 * 1024; const MAX_ASSET_FILES = 2048; const MAX_ASSET_NAME_BYTES = 1024; @@ -80,6 +82,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { let pendingFrame = null; let demandDriven = false; let tickPending = false; + let updateRequested = false; let motionAvailability = 0; let pendingMotionSample = null; let pointerCaptureSupported = false; @@ -653,8 +656,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } function hasBackgroundWork() { - // Only cooperative hostcall-budget yields are continuations. CoreVM's - // frame yield is an update boundary, never a reason for an idle spin. + // Gas and hostcall scheduling yields are continuations. CoreVM's frame + // yield is an update boundary, never a reason for an idle spin. if (translated?.hasPendingContinuation()) { return backgroundContinuationTicks > 0; } @@ -689,6 +692,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { function wake() { if (demandDriven && !backgrounded) { + updateRequested = true; scheduleTick(0); } } @@ -701,8 +705,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { ? Math.min(backgroundServiceTicks + 1, MAX_BACKGROUND_SERVICE_TICKS) : MAX_BACKGROUND_SERVICE_TICKS; backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; - if (backgrounded || demandDriven) { + if (backgrounded) { scheduleTick(0); + } else { + wake(); } } @@ -764,6 +770,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { backgroundServiceTicks = pendingHostFrameResponses(); backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; } + if (!backgrounded) { + updateRequested = true; + } if (!backgrounded && pendingFrame !== null) { const { output, transfers } = pendingFrame; pendingFrame = null; @@ -791,6 +800,11 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { if (!running || paused || (backgrounded && !hasBackgroundWork())) { return; } + if (!translated?.hasPendingContinuation()) { + // An external wake belongs to the next logical call, not a continuation + // of a call that may already have polled before that event arrived. + updateRequested = false; + } const firstUpdate = updateCount === 0; if (firstUpdate) { postMessage({ type: "startup", stage: "first-update-started" }); @@ -854,6 +868,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } return; } + if (translated?.hasPendingContinuation() || updateRequested) { + scheduleTick(0); + return; + } const requestedDelay = requestedUpdateDelay(completedAt); if (requestedDelay !== null) { scheduleTick(requestedDelay); @@ -1304,6 +1322,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { motionAvailability, message.mediatedInputKinds ?? [], message.fileInput ?? null, + MAX_TRANSLATED_GAS_SLICES_PER_UPDATE, ); const relaunch = relaunchFile(message); if (relaunch !== null) { @@ -1504,6 +1523,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { for (const { output, transfers } of pendingOutputs) { postRuntimeOutput(output, transfers); } + // Initialization may still be suspended. Its completion cannot consume + // the automatic first update or make that update wait on an init deadline. + updateRequested = true; scheduleTick(0); } diff --git a/js/packages/polkavm-browser-runtime/src/polkavm-wasm-translated.js b/js/packages/polkavm-browser-runtime/src/polkavm-wasm-translated.js index 6b9b23b..dff4e22 100644 --- a/js/packages/polkavm-browser-runtime/src/polkavm-wasm-translated.js +++ b/js/packages/polkavm-browser-runtime/src/polkavm-wasm-translated.js @@ -915,6 +915,7 @@ motionAvailability = MOTION_STATUS_UNAVAILABLE, mediatedInputKinds = [], fileInput = null, + maxGasSlices = 1, ) { if (!TranslatedPolkaVmRuntime.isCompiledProgram(program)) { throw new TypeError("invalid translated PolkaVM compiled program"); @@ -1006,6 +1007,11 @@ this.uiSemanticsSubmitted = false; this.uiOutputSubmitted = false; this.maxGas = BigInt(maxGas); + if (!Number.isSafeInteger(maxGasSlices) || maxGasSlices < 1) { + throw new Error("translated PolkaVM runtime has invalid gas slice count"); + } + this.maxGasSlices = maxGasSlices; + this.remainingGasSlices = 0; this.input = []; this.coreInput = []; this.epocaInput = []; @@ -1026,6 +1032,7 @@ this.hostcalls = 0; this.hostcallBytes = 0; this.resumePending = false; + this.continuationPending = false; this.stopped = false; this.coreVm = this.metadata.exports.has("_pvm_start"); if (this.coreVm && graphicsProfile !== "framebuffer") { @@ -1080,7 +1087,7 @@ } hasPendingContinuation() { - return !this.coreVm && this.resumePending; + return this.continuationPending; } pendingHostFrameResponses() { @@ -1141,17 +1148,16 @@ return; } this.timeMs = Math.max(this.timeMs ?? 0, timeMs); - this.updateAfterMs = null; - this.gpuSubmits = 0; - this.hostFrameRequests = 0; - this.uiSemanticsSubmitted = false; - this.uiOutputSubmitted = false; - this.hostFrameRequestBytes = 0; - this.#resetBudget( - this.coreVm && !this.coreVmStarted - ? MAX_HOSTCALLS_PER_INIT - : MAX_HOSTCALLS_PER_UPDATE, - ); + const hostcalls = this.coreVm && !this.coreVmStarted + ? MAX_HOSTCALLS_PER_INIT + : MAX_HOSTCALLS_PER_UPDATE; + if (this.continuationPending) { + // A worker tick is not a new guest call. Only the hostcall scheduling + // quantum restarts; gas, scheduling requests and call bounds survive. + this.hostcalls = hostcalls; + } else { + this.#resetBudget(hostcalls); + } if (this.coreVm) { this.#run(this.exports.get("_pvm_start"), true); this.coreVmStarted = true; @@ -2022,6 +2028,12 @@ #resetBudget(hostcalls, gas = this.maxGas) { this.hostcalls = hostcalls; + this.updateAfterMs = null; + this.gpuSubmits = 0; + this.hostFrameRequests = 0; + this.uiSemanticsSubmitted = false; + this.uiOutputSubmitted = false; + this.hostFrameRequestBytes = 0; this.hostcallBytes = MAX_HOSTCALL_BYTES; this.tri2dSubmitted = false; this.mediatedInputCommands = 0; @@ -2032,11 +2044,17 @@ let status; if (this.resumePending) { this.resumePending = false; + if (!this.continuationPending) { + this.remainingGasSlices = this.maxGasSlices - 1; + } + this.continuationPending = false; status = this.pvm.pvm_resume(); } else { if (entry === undefined) { throw new Error("translated PolkaVM entrypoint is missing"); } + this.remainingGasSlices = + gas === this.maxGas ? this.maxGasSlices - 1 : 0; status = this.pvm.pvm_begin(entry, gas); } for (;;) { @@ -2052,7 +2070,16 @@ ); } if (status === STATUS_OUT_OF_GAS) { - throw new Error("translated PolkaVM guest ran out of gas"); + if (this.remainingGasSlices === 0) { + throw new Error("translated PolkaVM guest ran out of gas"); + } + this.remainingGasSlices--; + // Hostcall yields retain the gas already in the VM. Only an exhausted + // gas quantum gets a refill, charged against the complete call budget. + this.pvm.pvm_set_gas(this.maxGas); + this.resumePending = true; + this.continuationPending = true; + return; } if (status !== STATUS_ECALL) { throw new Error( @@ -2072,6 +2099,7 @@ : this.#handleCooperativeCall(name); if (yielded && yieldOnFrame) { this.resumePending = true; + this.continuationPending = false; return; } if (this.hostcalls === 0) { @@ -2079,6 +2107,7 @@ // assets can require more than one bounded hostcall slice, while // returning here keeps each slice capped and the worker responsive. this.resumePending = true; + this.continuationPending = true; return; } status = this.pvm.pvm_resume(); diff --git a/js/packages/polkavm-browser-runtime/test/runtime-core.test.mjs b/js/packages/polkavm-browser-runtime/test/runtime-core.test.mjs index b502d93..4f3aaf9 100644 --- a/js/packages/polkavm-browser-runtime/test/runtime-core.test.mjs +++ b/js/packages/polkavm-browser-runtime/test/runtime-core.test.mjs @@ -16,7 +16,10 @@ function bytesBuffer(bytes) { ); } -function partitionedGuestBytes() { +function partitionedGuestBytes({ + outOfGasOnBegin = false, + meteredHostcalls = null, +} = {}) { const uleb = (value) => { const bytes = []; do { @@ -26,6 +29,17 @@ function partitionedGuestBytes() { } while (value); return bytes; }; + const sleb = (value) => { + const bytes = []; + for (;;) { + const byte = value & 0x7f; + value >>= 7; + const done = (value === 0 && !(byte & 0x40)) || + (value === -1 && (byte & 0x40)); + bytes.push(byte | (done ? 0 : 0x80)); + if (done) return bytes; + } + }; const string = (value) => { const bytes = [...new TextEncoder().encode(value)]; return [...uleb(bytes.length), ...bytes]; @@ -75,15 +89,51 @@ function partitionedGuestBytes() { const metadataExport = (name) => [ name.length, 0, ...new TextEncoder().encode(name), 0, 0, 0, 0, ]; + const metadataString = (name) => [ + name.length, 0, ...new TextEncoder().encode(name), + ]; const metadata = [ ...new TextEncoder().encode("EPM2"), 1, 0, 0, 0, // 64-bit registers - ...new Array(11 * 4).fill(0), // no hostcall address translation needed - 0, 0, 0, 0, // imports - 2, 0, 0, 0, // exports - ...metadataExport("init"), - ...metadataExport("update"), + ...new Array(9 * 4).fill(0), + 0, 0, 1, 0, // stackHigh = 65536, identity-mapped memory + 0, 0, 0, 0, // stackPhysical + ...(meteredHostcalls + ? [ + meteredHostcalls.frames ? 2 : 1, 0, 0, 0, + ...metadataString("host_update_after"), + ...(meteredHostcalls.frames ? metadataString("pvm_display") : []), + meteredHostcalls.entry === "init" ? 2 : 1, 0, 0, 0, + ...(meteredHostcalls.entry === "init" ? metadataExport("init") : []), + ...metadataExport(meteredHostcalls.frames ? "_pvm_start" : "update"), + ] + : [ + 0, 0, 0, 0, // imports + 2, 0, 0, 0, // exports + ...metadataExport("init"), + ...metadataExport("update"), + ]), ]; + // A real Wasm dispatcher: each ECALL costs gas, resumes at the next call, + // and leaves its remaining gas in the same exported global as the compiler. + const meteredResume = meteredHostcalls ? [ + 0x23, 14, 0x41, ...sleb(meteredHostcalls.count), 0x4f, // calls >= count + 0x04, 0x40, 0x41, 0x7f, 0x0f, 0x0b, // return FINISHED + 0x23, 13, 0x42, ...sleb(meteredHostcalls.cost), 0x53, // gas < cost + 0x04, 0x40, 0x41, 0x7c, 0x0f, 0x0b, // return OUT_OF_GAS + 0x23, 13, 0x42, ...sleb(meteredHostcalls.cost), 0x7d, 0x24, 13, + ...(meteredHostcalls.idle + ? [0x42, 0x7f, 0x24, 7] // idle + : [0x42, 23, 0x42, 50, 0x23, 14, 0x45, 0x1b, 0x24, 7]), // first delay 23, then 50 + ...(meteredHostcalls.frames ? [ + 0x23, 14, 0x41, 1, 0x71, 0x24, 15, // odd calls display, even calls schedule + 0x23, 15, 0x04, 0x40, + 0x42, 1, 0x24, 7, 0x42, 1, 0x24, 8, // 1x1 frame at address 0 + 0x0b, + ] : []), + 0x23, 14, 0x41, 1, 0x6a, 0x24, 14, // calls++ + 0x41, 0x7e, // ECALL + ] : null; const rootSections = [ section(0, [...string("epoca.pvm.meta"), ...metadata]), section( @@ -94,12 +144,17 @@ function partitionedGuestBytes() { [0x60, 1, 0x7e, 0], // set_gas(i64) ]), ), - section(3, [3, 0, 1, 2]), + section(3, outOfGasOnBegin || meteredHostcalls + ? [4, 0, 1, 2, 0] : [3, 0, 1, 2]), section(4, [1, 0x70, 0, 2]), section(5, [1, 0, 1]), section( 6, - vector(Array.from({ length: 14 }, () => [0x7e, 1, 0x42, 0, 0x0b])), + vector([ + ...Array.from({ length: 14 }, () => [0x7e, 1, 0x42, 0, 0x0b]), + [0x7f, 1, 0x41, 0, 0x0b], // completed hostcalls + [0x7f, 1, 0x41, 0, 0x0b], // ecall import index + ]), ), section( 7, @@ -110,6 +165,11 @@ function partitionedGuestBytes() { exportEntry("__helper0", 0, 0), exportEntry("pvm_begin", 0, 1), exportEntry("pvm_set_gas", 0, 2), + ...(outOfGasOnBegin || meteredHostcalls + ? [exportEntry("pvm_resume", 0, 3)] : []), + exportEntry("gas", 3, 13), + exportEntry("calls", 3, 14), + exportEntry("ecall", 3, 15), ...Array.from({ length: 13 }, (_, index) => exportEntry(`r${index}`, 3, index), ), @@ -119,8 +179,19 @@ function partitionedGuestBytes() { 10, vector([ body([0x41, 0, 0x28, 2, 0]), // helper reads memory[0] - body([0x20, 1, 0x24, 13, 0x20, 0, 0x13, 0, 0]), + body(meteredHostcalls + ? [ + 0x20, 1, 0x24, 13, // gas = begin argument + 0x41, 0, 0x24, 14, // calls = 0 + 0x12, 3, // tail-call metered dispatcher + ] + : outOfGasOnBegin + ? [0x41, 0x7c] // STATUS_OUT_OF_GAS + : [0x20, 1, 0x24, 13, 0x20, 0, 0x13, 0, 0]), body([0x20, 0, 0x24, 13]), + ...(meteredHostcalls + ? [body(meteredResume)] + : outOfGasOnBegin ? [body([0x41, 0, 0x13, 0, 0])] : []), ]), ), ]; @@ -168,6 +239,162 @@ test("translated code parts share guest memory, registers, helpers and control f other.stop(); }); +test("translated execution resumes after exhausting a gas slice", async () => { + const Runtime = globalThis.TranslatedPolkaVmRuntime; + const { partitioned } = partitionedGuestBytes({ outOfGasOnBegin: true }); + const program = await Runtime.compile(partitioned); + const translated = new Runtime( + program, [], () => {}, 1_000_000, false, "framebuffer", null, 0, [], null, + 2, + ); + const state = () => ({ + register: translated.pvm.r0.value, + memory: [...new Uint32Array(translated.memory.buffer, 0, 2)], + }); + + translated.initialize(); + assert.equal(translated.hasPendingContinuation(), true); + assert.deepEqual(state(), { register: 0n, memory: [0, 0] }); + + translated.update(1); + assert.equal(translated.hasPendingContinuation(), false); + assert.deepEqual(state(), { register: 7n, memory: [5, 12] }); + + translated.update(2); + assert.equal(translated.hasPendingContinuation(), true); + translated.update(3); + assert.equal(translated.hasPendingContinuation(), false); + assert.deepEqual(state(), { register: 14n, memory: [10, 24] }); + translated.stop(); + + const bounded = new Runtime( + program, [], () => {}, 1_000_000, false, "framebuffer", + ); + assert.throws( + () => bounded.initialize(), + /translated PolkaVM guest ran out of gas/, + ); + bounded.stop(); +}); + +async function meteredRuntime({ count, cost, entry, frames, gas, slices = 1 }) { + const Runtime = globalThis.TranslatedPolkaVmRuntime; + const { root } = partitionedGuestBytes({ + meteredHostcalls: { count, cost, entry, frames }, + }); + return new Runtime( + await Runtime.compile(root), [], () => {}, gas, false, "framebuffer", + null, 0, [], null, slices, + ); +} + +test("translated scheduling requests and call bounds survive gas continuations", async () => { + const runtime = await meteredRuntime({ + count: 2, cost: 100, gas: 100, slices: 2, + }); + runtime.initialize(); + runtime.update(1); + assert.equal(runtime.hasPendingContinuation(), true); + assert.equal(runtime.updateAfterMilliseconds(), 23); + // These bounds belong to the logical call, not the worker's scheduling tick. + runtime.gpuSubmits = 1; + runtime.hostFrameRequests = 2; + runtime.hostFrameRequestBytes = 3; + runtime.uiSemanticsSubmitted = true; + runtime.uiOutputSubmitted = true; + runtime.tri2dSubmitted = true; + runtime.mediatedInputCommands = 4; + runtime.hostcallBytes = 5; + runtime.update(2); + assert.equal(runtime.hasPendingContinuation(), false); + assert.equal(runtime.updateAfterMilliseconds(), 23, "retain the earliest request"); + const callBounds = () => [ + runtime.gpuSubmits, runtime.hostFrameRequests, runtime.hostFrameRequestBytes, + runtime.uiSemanticsSubmitted, runtime.uiOutputSubmitted, runtime.tri2dSubmitted, + runtime.mediatedInputCommands, runtime.hostcallBytes, + ]; + assert.deepEqual(callBounds(), [1, 2, 3, true, true, true, 4, 5]); + runtime.update(3); + assert.deepEqual(callBounds(), [0, 0, 0, false, false, false, 0, 32 * 1024 * 1024]); + assert.equal(runtime.pvm.calls.value, 1, "the next update starts a new call"); + runtime.stop(); + runtime.update(4); + assert.equal(runtime.pvm.calls.value, 1, "stop must not resume a pending call"); +}); + +test("translated hostcall yields cannot refill the complete-call gas budget", async () => { + const runtime = await meteredRuntime({ + count: 4 * 65536 - 1, cost: 100, gas: 10_000_000, slices: 2, + }); + runtime.initialize(); + runtime.update(1); + assert.equal(runtime.pvm.calls.value, 65536); + assert.equal(runtime.pvm.gas.value, 3_446_400n); + runtime.update(2); + assert.equal(runtime.pvm.calls.value, 100000, "exhaust the first gas quantum"); + runtime.update(3); + assert.equal(runtime.pvm.calls.value, 165536); + assert.throws(() => runtime.update(4), /guest ran out of gas/); + assert.equal(runtime.pvm.calls.value, 200000, "at most two gas quanta are spent"); + runtime.stop(); +}); + +test("translated single-slice calls retain unused gas through hostcall yields", async () => { + const runtime = await meteredRuntime({ + count: 65537, cost: 100, gas: 10_000_000, + }); + runtime.initialize(); + runtime.update(1); + assert.equal(runtime.hasPendingContinuation(), true); + runtime.update(2); + assert.equal(runtime.hasPendingContinuation(), false); + assert.equal(runtime.pvm.gas.value, 3_446_300n); + assert.equal(runtime.updateAfterMilliseconds(), 23); + runtime.stop(); +}); + +test("translated initialization continues without losing its remaining gas", async () => { + const runtime = await meteredRuntime({ + entry: "init", count: 150000, cost: 100, gas: 10_000_000, slices: 2, + }); + runtime.initialize(); + assert.equal(runtime.pvm.calls.value, 100000); + runtime.update(1); + assert.equal(runtime.hasPendingContinuation(), false); + assert.equal(runtime.pvm.calls.value, 150000); + assert.equal(runtime.pvm.gas.value, 5_000_000n); + runtime.stop(); +}); + +test("translated reduced initialization budgets survive hostcall yields", async () => { + const runtime = await meteredRuntime({ + entry: "init", count: 1024 * 1024 + 2, cost: 1, gas: 2_000_000, slices: 4, + }); + runtime.initialize(1024 * 1024 + 1); + assert.equal(runtime.hasPendingContinuation(), true); + assert.equal(runtime.pvm.gas.value, 1n); + assert.throws(() => runtime.update(1), /guest ran out of gas/); + assert.equal(runtime.pvm.calls.value, 1024 * 1024 + 1); + runtime.stop(); +}); + +test("translated CoreVM frame boundaries start fresh scheduling and gas budgets", async () => { + const runtime = await meteredRuntime({ + frames: true, count: 4, cost: 100, gas: 200, + }); + runtime.initialize(); + runtime.update(1); + assert.equal(runtime.pvm.calls.value, 2); + assert.equal(runtime.pvm.gas.value, 0n); + assert.equal(runtime.hasPendingContinuation(), false, "a frame is not a gas continuation"); + assert.equal(runtime.updateAfterMilliseconds(), 23); + runtime.update(2); + assert.equal(runtime.pvm.calls.value, 4, "resume rather than restarting _pvm_start"); + assert.equal(runtime.pvm.gas.value, 0n, "the next frame gets a fresh gas budget"); + assert.equal(runtime.updateAfterMilliseconds(), 50, "the old frame's request is cleared"); + runtime.stop(); +}); + test("compiled programs accept root-only modules but reject malformed parts and bare modules", async () => { const Runtime = globalThis.TranslatedPolkaVmRuntime; const { root } = partitionedGuestBytes(); @@ -945,6 +1172,174 @@ test("background framebuffer retention resumes once after acknowledgment and nev } }); +test("initialization continuations cannot consume the automatic first update", async (t) => { + const runtime = await readFile(resolve(packageRoot, "dist/polkavm-browser-runtime.wasm")); + const Runtime = globalThis.TranslatedPolkaVmRuntime; + for (const yielding of [false, true]) { + for (const idle of [false, true]) { + await t.test(`yielding=${yielding}, idle=${idle}`, async (t) => { + const { root } = partitionedGuestBytes({ + meteredHostcalls: { entry: "init", count: 2, cost: 100, idle }, + }); + const compiledProgram = await Runtime.compile(root); + let calls = 0; + globalThis.TranslatedPolkaVmRuntime = class extends Runtime { + constructor(...args) { + super(...args); + const exports = this.pvm; + this.pvm = { + ...exports, + pvm_begin: (...args) => { + calls++; + if (yielding && calls === 1) this.hostcalls = 1; + return exports.pvm_begin(...args); + }, + }; + } + }; + const scheduling = controlledTicks(t); + const { messages, receiver } = endpoint(); + try { + receiver.onmessage({ data: { + type: "start", runtime: bytesBuffer(runtime), program: new Uint8Array([1]), + compiledProgram, assets: [], graphicsProfile: "framebuffer", audioEnabled: false, + cacheKey: `initial-update-${yielding}-${idle}`, + } }); + assert.equal((await waitForMessage(messages, "ready")).backend, "compiler"); + scheduling.controlTimers(); + assert.equal(calls, 1, "only init has started"); + if (yielding) { + scheduling.ticks.shift()(); + assert.equal(calls, 1, "the first tick finishes init instead of restarting it"); + assert.equal(scheduling.ticks.length, 1, "first update remains pending after init finishes"); + assert.equal(scheduling.timers.size, 0, "init cannot postpone the first update"); + } + assert.equal(scheduling.drain(), 1, "the first real update runs exactly once"); + assert.equal(calls, 2); + assert.equal(scheduling.timers.size, idle ? 0 : 1, "the real update's deadline is honored"); + assert.equal(messages.some((message) => message.type === "error"), false); + } finally { + receiver.onmessage?.({ data: { type: "stop" } }); + globalThis.TranslatedPolkaVmRuntime = Runtime; + } + }); + } + } +}); + +test("foreground wakes survive gas continuations and coalesce at the next call", async (t) => { + const runtime = await readFile(resolve(packageRoot, "dist/polkavm-browser-runtime.wasm")); + const Runtime = globalThis.TranslatedPolkaVmRuntime; + const { root } = partitionedGuestBytes({ + meteredHostcalls: { count: 2, cost: 100 }, + }); + const compiledProgram = await Runtime.compile(root); + for (const event of ["input", "host-response", "resume", "foreground"]) { + await t.test(event, async (t) => { + let calls = 0; + globalThis.TranslatedPolkaVmRuntime = class extends Runtime { + constructor(...args) { + super(...args); + this.maxGas = 100n; + const exports = this.pvm; + this.pvm = { + ...exports, + pvm_begin: (...args) => { + calls++; + return exports.pvm_begin(...args); + }, + }; + } + }; + const scheduling = controlledTicks(t); + const { messages, receiver } = endpoint(); + const send = (data) => receiver.onmessage({ data }); + try { + send({ + type: "start", runtime: bytesBuffer(runtime), program: new Uint8Array([1]), + compiledProgram, assets: [], graphicsProfile: "framebuffer", audioEnabled: false, + cacheKey: `foreground-gas-wake-${event}`, + }); + assert.equal((await waitForMessage(messages, "ready")).backend, "compiler"); + scheduling.controlTimers(); + scheduling.ticks.shift()(); + assert.equal(calls, 1); + assert.equal(scheduling.ticks.length, 1, "gas exhaustion schedules a continuation"); + for (let repeat = 0; repeat < 2; repeat++) { + if (event === "input") { + send({ type: "input", bytes: new Uint8Array([1, 4, 0, 0, 0, 0, 0, 0]) }); + } else if (event === "host-response") { + send({ type: "host-frame-response", bytes: new Uint8Array([42]) }); + } else if (event === "resume") { + send({ type: "pause", paused: true }); + send({ type: "pause", paused: false }); + } else { + send({ type: "background", backgrounded: true }); + send({ type: "background", backgrounded: false }); + } + } + assert.equal(scheduling.ticks.length, 1, "wakes share the pending continuation tick"); + assert.equal(scheduling.drain(), 3, "finish the old call, then run one new two-quantum call"); + assert.equal(calls, 2, "external wakes cannot be spent finishing the old call"); + assert.equal(scheduling.timers.size, 1, "ordinary requested pacing resumes after the wake"); + assert.equal(messages.some((message) => message.type === "error"), false); + } finally { + receiver.onmessage?.({ data: { type: "stop" } }); + globalThis.TranslatedPolkaVmRuntime = Runtime; + } + }); + } +}); + +test("foreground host responses arriving after a poll survive hostcall continuations and pause", async (t) => { + const runtime = await readFile(resolve(packageRoot, "dist/polkavm-browser-runtime.wasm")); + const Runtime = globalThis.TranslatedPolkaVmRuntime; + globalThis.TranslatedPolkaVmRuntime = class extends Runtime { + constructor(...args) { + super(...args); + const exports = this.pvm; + this.pvm = { + ...exports, + pvm_begin: (...args) => { + this.hostcalls = 1; + return exports.pvm_begin(...args); + }, + pvm_resume: () => { + this.hostcalls = 1; + return exports.pvm_resume(); + }, + }; + } + }; + const scheduling = controlledTicks(t); + const { messages, receiver } = endpoint(); + const send = (data) => receiver.onmessage({ data }); + try { + send({ + type: "start", runtime: bytesBuffer(runtime), program: bytesBuffer(hostResponseEchoGuest(true)), + assets: [], graphicsProfile: "framebuffer", audioEnabled: false, + cacheKey: "foreground-hostcall-wake", + }); + assert.equal((await waitForMessage(messages, "ready")).backend, "compiler"); + scheduling.controlTimers(); + scheduling.ticks.shift()(); // The first call has already polled an empty response queue. + send({ type: "host-frame-response", bytes: new Uint8Array([42]) }); + send({ type: "pause", paused: true }); + scheduling.drain(); + assert.equal(messages.some((message) => message.type === "save"), false); + send({ type: "pause", paused: false }); + scheduling.drain(); + assert.deepEqual(messages.filter((message) => message.type === "save").map((message) => message.bytes[0]), + [0, 42], "the queued response must be observed by a fresh update despite the old call requesting idle"); + assert.equal(scheduling.ticks.length, 0); + assert.equal(scheduling.timers.size, 0, "consuming a wake cannot create an idle spin"); + assert.equal(messages.some((message) => message.type === "error"), false); + } finally { + receiver.onmessage?.({ data: { type: "stop" } }); + globalThis.TranslatedPolkaVmRuntime = Runtime; + } +}); + test("translated background continuations complete bounded hostcall slices without idle spinning", async (t) => { const runtime = await readFile(resolve(packageRoot, "dist/polkavm-browser-runtime.wasm")); const Runtime = globalThis.TranslatedPolkaVmRuntime; diff --git a/rust/crates/polkavm-host-runtime-assets/assets/SHA256SUMS b/rust/crates/polkavm-host-runtime-assets/assets/SHA256SUMS index c82e996..f31d2b2 100644 --- a/rust/crates/polkavm-host-runtime-assets/assets/SHA256SUMS +++ b/rust/crates/polkavm-host-runtime-assets/assets/SHA256SUMS @@ -1,8 +1,8 @@ dc224c5d1f4cc109af7747ddae03abd73217031c982123d75f186aa4cb7a15fc polkavm-browser-runtime.wasm -dde78a7576061d0a23ab59f6b9711c0d016dc7e69cbd5e298569520a9de79ed5 polkavm-worker.js +92f25922c91fccadb7abeef64679a0c036c32e12f97fa80bcf328387566c8dab polkavm-worker.js 4a69ffb026d58ea9353929a67b2aba403e0e82c3e23c3b61b2d425fa91241fad polkavm-gpu-worker.js -881b7a297ebc3d2849cf4801aa20c2c71a4e2208e63ac74b82f5b9e17d25818f polkavm-wasm-translated.js -9a359677d4f4b4cd821f433d8f3881b3261183397c674661d1de043697253556 polkavm-runtime-core.js +c5b1783067d5db58a8d0a34dd0649a7d04e7cd23cd86a46368c8959820b55234 polkavm-wasm-translated.js +1a7b738cd04d84be9b654bc1329f63e573957d7cea88cd58b30c33294cc8f4d2 polkavm-runtime-core.js fa600faff369b09eae5a50dd4b08445b7762d89d6db269b70230ad5a8bf67951 polkavm-wasm-worker-entry.js edb78a7e5973b92c36231e1383fb384f400ba277e9158e5228f39b7234ad4e29 polkavm-computer.js caa19c63fae1fd4efd59b9b49f9fbd8425cebc8d744d6512f1d92fae24a96943 session.js diff --git a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-runtime-core.js b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-runtime-core.js index 307a3eb..5d239b5 100644 --- a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-runtime-core.js +++ b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-runtime-core.js @@ -22,6 +22,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { const LEGACY_FRAME_INTERVAL_MS = 1000 / 60; const MAX_GAS_PER_UPDATE = 10_000_000_000; const MAX_TRANSLATED_LOOPS_PER_UPDATE = 50_000_000; + const MAX_TRANSLATED_GAS_SLICES_PER_UPDATE = + MAX_GAS_PER_UPDATE / MAX_TRANSLATED_LOOPS_PER_UPDATE; const MAX_PROGRAM_BYTES = 64 * 1024 * 1024; const MAX_ASSET_FILES = 2048; const MAX_ASSET_NAME_BYTES = 1024; @@ -80,6 +82,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { let pendingFrame = null; let demandDriven = false; let tickPending = false; + let updateRequested = false; let motionAvailability = 0; let pendingMotionSample = null; let pointerCaptureSupported = false; @@ -653,8 +656,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } function hasBackgroundWork() { - // Only cooperative hostcall-budget yields are continuations. CoreVM's - // frame yield is an update boundary, never a reason for an idle spin. + // Gas and hostcall scheduling yields are continuations. CoreVM's frame + // yield is an update boundary, never a reason for an idle spin. if (translated?.hasPendingContinuation()) { return backgroundContinuationTicks > 0; } @@ -689,6 +692,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { function wake() { if (demandDriven && !backgrounded) { + updateRequested = true; scheduleTick(0); } } @@ -701,8 +705,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { ? Math.min(backgroundServiceTicks + 1, MAX_BACKGROUND_SERVICE_TICKS) : MAX_BACKGROUND_SERVICE_TICKS; backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; - if (backgrounded || demandDriven) { + if (backgrounded) { scheduleTick(0); + } else { + wake(); } } @@ -764,6 +770,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { backgroundServiceTicks = pendingHostFrameResponses(); backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; } + if (!backgrounded) { + updateRequested = true; + } if (!backgrounded && pendingFrame !== null) { const { output, transfers } = pendingFrame; pendingFrame = null; @@ -791,6 +800,11 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { if (!running || paused || (backgrounded && !hasBackgroundWork())) { return; } + if (!translated?.hasPendingContinuation()) { + // An external wake belongs to the next logical call, not a continuation + // of a call that may already have polled before that event arrived. + updateRequested = false; + } const firstUpdate = updateCount === 0; if (firstUpdate) { postMessage({ type: "startup", stage: "first-update-started" }); @@ -854,6 +868,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } return; } + if (translated?.hasPendingContinuation() || updateRequested) { + scheduleTick(0); + return; + } const requestedDelay = requestedUpdateDelay(completedAt); if (requestedDelay !== null) { scheduleTick(requestedDelay); @@ -1304,6 +1322,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { motionAvailability, message.mediatedInputKinds ?? [], message.fileInput ?? null, + MAX_TRANSLATED_GAS_SLICES_PER_UPDATE, ); const relaunch = relaunchFile(message); if (relaunch !== null) { @@ -1504,6 +1523,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { for (const { output, transfers } of pendingOutputs) { postRuntimeOutput(output, transfers); } + // Initialization may still be suspended. Its completion cannot consume + // the automatic first update or make that update wait on an init deadline. + updateRequested = true; scheduleTick(0); } diff --git a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-wasm-translated.js b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-wasm-translated.js index 6b9b23b..dff4e22 100644 --- a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-wasm-translated.js +++ b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-wasm-translated.js @@ -915,6 +915,7 @@ motionAvailability = MOTION_STATUS_UNAVAILABLE, mediatedInputKinds = [], fileInput = null, + maxGasSlices = 1, ) { if (!TranslatedPolkaVmRuntime.isCompiledProgram(program)) { throw new TypeError("invalid translated PolkaVM compiled program"); @@ -1006,6 +1007,11 @@ this.uiSemanticsSubmitted = false; this.uiOutputSubmitted = false; this.maxGas = BigInt(maxGas); + if (!Number.isSafeInteger(maxGasSlices) || maxGasSlices < 1) { + throw new Error("translated PolkaVM runtime has invalid gas slice count"); + } + this.maxGasSlices = maxGasSlices; + this.remainingGasSlices = 0; this.input = []; this.coreInput = []; this.epocaInput = []; @@ -1026,6 +1032,7 @@ this.hostcalls = 0; this.hostcallBytes = 0; this.resumePending = false; + this.continuationPending = false; this.stopped = false; this.coreVm = this.metadata.exports.has("_pvm_start"); if (this.coreVm && graphicsProfile !== "framebuffer") { @@ -1080,7 +1087,7 @@ } hasPendingContinuation() { - return !this.coreVm && this.resumePending; + return this.continuationPending; } pendingHostFrameResponses() { @@ -1141,17 +1148,16 @@ return; } this.timeMs = Math.max(this.timeMs ?? 0, timeMs); - this.updateAfterMs = null; - this.gpuSubmits = 0; - this.hostFrameRequests = 0; - this.uiSemanticsSubmitted = false; - this.uiOutputSubmitted = false; - this.hostFrameRequestBytes = 0; - this.#resetBudget( - this.coreVm && !this.coreVmStarted - ? MAX_HOSTCALLS_PER_INIT - : MAX_HOSTCALLS_PER_UPDATE, - ); + const hostcalls = this.coreVm && !this.coreVmStarted + ? MAX_HOSTCALLS_PER_INIT + : MAX_HOSTCALLS_PER_UPDATE; + if (this.continuationPending) { + // A worker tick is not a new guest call. Only the hostcall scheduling + // quantum restarts; gas, scheduling requests and call bounds survive. + this.hostcalls = hostcalls; + } else { + this.#resetBudget(hostcalls); + } if (this.coreVm) { this.#run(this.exports.get("_pvm_start"), true); this.coreVmStarted = true; @@ -2022,6 +2028,12 @@ #resetBudget(hostcalls, gas = this.maxGas) { this.hostcalls = hostcalls; + this.updateAfterMs = null; + this.gpuSubmits = 0; + this.hostFrameRequests = 0; + this.uiSemanticsSubmitted = false; + this.uiOutputSubmitted = false; + this.hostFrameRequestBytes = 0; this.hostcallBytes = MAX_HOSTCALL_BYTES; this.tri2dSubmitted = false; this.mediatedInputCommands = 0; @@ -2032,11 +2044,17 @@ let status; if (this.resumePending) { this.resumePending = false; + if (!this.continuationPending) { + this.remainingGasSlices = this.maxGasSlices - 1; + } + this.continuationPending = false; status = this.pvm.pvm_resume(); } else { if (entry === undefined) { throw new Error("translated PolkaVM entrypoint is missing"); } + this.remainingGasSlices = + gas === this.maxGas ? this.maxGasSlices - 1 : 0; status = this.pvm.pvm_begin(entry, gas); } for (;;) { @@ -2052,7 +2070,16 @@ ); } if (status === STATUS_OUT_OF_GAS) { - throw new Error("translated PolkaVM guest ran out of gas"); + if (this.remainingGasSlices === 0) { + throw new Error("translated PolkaVM guest ran out of gas"); + } + this.remainingGasSlices--; + // Hostcall yields retain the gas already in the VM. Only an exhausted + // gas quantum gets a refill, charged against the complete call budget. + this.pvm.pvm_set_gas(this.maxGas); + this.resumePending = true; + this.continuationPending = true; + return; } if (status !== STATUS_ECALL) { throw new Error( @@ -2072,6 +2099,7 @@ : this.#handleCooperativeCall(name); if (yielded && yieldOnFrame) { this.resumePending = true; + this.continuationPending = false; return; } if (this.hostcalls === 0) { @@ -2079,6 +2107,7 @@ // assets can require more than one bounded hostcall slice, while // returning here keeps each slice capped and the worker responsive. this.resumePending = true; + this.continuationPending = true; return; } status = this.pvm.pvm_resume(); diff --git a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-worker.js b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-worker.js index e556e9c..fd8ded5 100644 --- a/rust/crates/polkavm-host-runtime-assets/assets/polkavm-worker.js +++ b/rust/crates/polkavm-host-runtime-assets/assets/polkavm-worker.js @@ -915,6 +915,7 @@ motionAvailability = MOTION_STATUS_UNAVAILABLE, mediatedInputKinds = [], fileInput = null, + maxGasSlices = 1, ) { if (!TranslatedPolkaVmRuntime.isCompiledProgram(program)) { throw new TypeError("invalid translated PolkaVM compiled program"); @@ -1006,6 +1007,11 @@ this.uiSemanticsSubmitted = false; this.uiOutputSubmitted = false; this.maxGas = BigInt(maxGas); + if (!Number.isSafeInteger(maxGasSlices) || maxGasSlices < 1) { + throw new Error("translated PolkaVM runtime has invalid gas slice count"); + } + this.maxGasSlices = maxGasSlices; + this.remainingGasSlices = 0; this.input = []; this.coreInput = []; this.epocaInput = []; @@ -1026,6 +1032,7 @@ this.hostcalls = 0; this.hostcallBytes = 0; this.resumePending = false; + this.continuationPending = false; this.stopped = false; this.coreVm = this.metadata.exports.has("_pvm_start"); if (this.coreVm && graphicsProfile !== "framebuffer") { @@ -1080,7 +1087,7 @@ } hasPendingContinuation() { - return !this.coreVm && this.resumePending; + return this.continuationPending; } pendingHostFrameResponses() { @@ -1141,17 +1148,16 @@ return; } this.timeMs = Math.max(this.timeMs ?? 0, timeMs); - this.updateAfterMs = null; - this.gpuSubmits = 0; - this.hostFrameRequests = 0; - this.uiSemanticsSubmitted = false; - this.uiOutputSubmitted = false; - this.hostFrameRequestBytes = 0; - this.#resetBudget( - this.coreVm && !this.coreVmStarted - ? MAX_HOSTCALLS_PER_INIT - : MAX_HOSTCALLS_PER_UPDATE, - ); + const hostcalls = this.coreVm && !this.coreVmStarted + ? MAX_HOSTCALLS_PER_INIT + : MAX_HOSTCALLS_PER_UPDATE; + if (this.continuationPending) { + // A worker tick is not a new guest call. Only the hostcall scheduling + // quantum restarts; gas, scheduling requests and call bounds survive. + this.hostcalls = hostcalls; + } else { + this.#resetBudget(hostcalls); + } if (this.coreVm) { this.#run(this.exports.get("_pvm_start"), true); this.coreVmStarted = true; @@ -2022,6 +2028,12 @@ #resetBudget(hostcalls, gas = this.maxGas) { this.hostcalls = hostcalls; + this.updateAfterMs = null; + this.gpuSubmits = 0; + this.hostFrameRequests = 0; + this.uiSemanticsSubmitted = false; + this.uiOutputSubmitted = false; + this.hostFrameRequestBytes = 0; this.hostcallBytes = MAX_HOSTCALL_BYTES; this.tri2dSubmitted = false; this.mediatedInputCommands = 0; @@ -2032,11 +2044,17 @@ let status; if (this.resumePending) { this.resumePending = false; + if (!this.continuationPending) { + this.remainingGasSlices = this.maxGasSlices - 1; + } + this.continuationPending = false; status = this.pvm.pvm_resume(); } else { if (entry === undefined) { throw new Error("translated PolkaVM entrypoint is missing"); } + this.remainingGasSlices = + gas === this.maxGas ? this.maxGasSlices - 1 : 0; status = this.pvm.pvm_begin(entry, gas); } for (;;) { @@ -2052,7 +2070,16 @@ ); } if (status === STATUS_OUT_OF_GAS) { - throw new Error("translated PolkaVM guest ran out of gas"); + if (this.remainingGasSlices === 0) { + throw new Error("translated PolkaVM guest ran out of gas"); + } + this.remainingGasSlices--; + // Hostcall yields retain the gas already in the VM. Only an exhausted + // gas quantum gets a refill, charged against the complete call budget. + this.pvm.pvm_set_gas(this.maxGas); + this.resumePending = true; + this.continuationPending = true; + return; } if (status !== STATUS_ECALL) { throw new Error( @@ -2072,6 +2099,7 @@ : this.#handleCooperativeCall(name); if (yielded && yieldOnFrame) { this.resumePending = true; + this.continuationPending = false; return; } if (this.hostcalls === 0) { @@ -2079,6 +2107,7 @@ // assets can require more than one bounded hostcall slice, while // returning here keeps each slice capped and the worker responsive. this.resumePending = true; + this.continuationPending = true; return; } status = this.pvm.pvm_resume(); @@ -3286,6 +3315,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { const LEGACY_FRAME_INTERVAL_MS = 1000 / 60; const MAX_GAS_PER_UPDATE = 10_000_000_000; const MAX_TRANSLATED_LOOPS_PER_UPDATE = 50_000_000; + const MAX_TRANSLATED_GAS_SLICES_PER_UPDATE = + MAX_GAS_PER_UPDATE / MAX_TRANSLATED_LOOPS_PER_UPDATE; const MAX_PROGRAM_BYTES = 64 * 1024 * 1024; const MAX_ASSET_FILES = 2048; const MAX_ASSET_NAME_BYTES = 1024; @@ -3344,6 +3375,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { let pendingFrame = null; let demandDriven = false; let tickPending = false; + let updateRequested = false; let motionAvailability = 0; let pendingMotionSample = null; let pointerCaptureSupported = false; @@ -3917,8 +3949,8 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } function hasBackgroundWork() { - // Only cooperative hostcall-budget yields are continuations. CoreVM's - // frame yield is an update boundary, never a reason for an idle spin. + // Gas and hostcall scheduling yields are continuations. CoreVM's frame + // yield is an update boundary, never a reason for an idle spin. if (translated?.hasPendingContinuation()) { return backgroundContinuationTicks > 0; } @@ -3953,6 +3985,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { function wake() { if (demandDriven && !backgrounded) { + updateRequested = true; scheduleTick(0); } } @@ -3965,8 +3998,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { ? Math.min(backgroundServiceTicks + 1, MAX_BACKGROUND_SERVICE_TICKS) : MAX_BACKGROUND_SERVICE_TICKS; backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; - if (backgrounded || demandDriven) { + if (backgrounded) { scheduleTick(0); + } else { + wake(); } } @@ -4028,6 +4063,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { backgroundServiceTicks = pendingHostFrameResponses(); backgroundContinuationTicks = MAX_BACKGROUND_CONTINUATION_TICKS; } + if (!backgrounded) { + updateRequested = true; + } if (!backgrounded && pendingFrame !== null) { const { output, transfers } = pendingFrame; pendingFrame = null; @@ -4055,6 +4093,11 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { if (!running || paused || (backgrounded && !hasBackgroundWork())) { return; } + if (!translated?.hasPendingContinuation()) { + // An external wake belongs to the next logical call, not a continuation + // of a call that may already have polled before that event arrived. + updateRequested = false; + } const firstUpdate = updateCount === 0; if (firstUpdate) { postMessage({ type: "startup", stage: "first-update-started" }); @@ -4118,6 +4161,10 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { } return; } + if (translated?.hasPendingContinuation() || updateRequested) { + scheduleTick(0); + return; + } const requestedDelay = requestedUpdateDelay(completedAt); if (requestedDelay !== null) { scheduleTick(requestedDelay); @@ -4568,6 +4615,7 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { motionAvailability, message.mediatedInputKinds ?? [], message.fileInput ?? null, + MAX_TRANSLATED_GAS_SLICES_PER_UPDATE, ); const relaunch = relaunchFile(message); if (relaunch !== null) { @@ -4768,6 +4816,9 @@ globalThis.createPolkaVmRuntime = (endpoint, options = {}) => { for (const { output, transfers } of pendingOutputs) { postRuntimeOutput(output, transfers); } + // Initialization may still be suspended. Its completion cannot consume + // the automatic first update or make that update wait on an init deadline. + updateRequested = true; scheduleTick(0); } diff --git a/rust/crates/polkavm-host-runtime-assets/src/lib.rs b/rust/crates/polkavm-host-runtime-assets/src/lib.rs index 406a836..4f7d809 100644 --- a/rust/crates/polkavm-host-runtime-assets/src/lib.rs +++ b/rust/crates/polkavm-host-runtime-assets/src/lib.rs @@ -32,7 +32,7 @@ const ASSETS: [BrowserAsset; 15] = [ path: "SHA256SUMS", content_type: "text/plain", bytes: include_bytes!("../assets/SHA256SUMS"), - sha256: "be9a0285186b281c6ae2e307a1aed9d2366ae81dd74403da5da852a8e2eed8c4", + sha256: "55c09c035418ae8e7c3ad3d2957ff3bacbf44f95f6433f9d05058c159b8d5396", }, BrowserAsset { path: "THIRD_PARTY_LICENSES.txt", @@ -80,13 +80,13 @@ const ASSETS: [BrowserAsset; 15] = [ path: "polkavm-runtime-core.js", content_type: "text/javascript", bytes: include_bytes!("../assets/polkavm-runtime-core.js"), - sha256: "9a359677d4f4b4cd821f433d8f3881b3261183397c674661d1de043697253556", + sha256: "1a7b738cd04d84be9b654bc1329f63e573957d7cea88cd58b30c33294cc8f4d2", }, BrowserAsset { path: "polkavm-wasm-translated.js", content_type: "text/javascript", bytes: include_bytes!("../assets/polkavm-wasm-translated.js"), - sha256: "881b7a297ebc3d2849cf4801aa20c2c71a4e2208e63ac74b82f5b9e17d25818f", + sha256: "c5b1783067d5db58a8d0a34dd0649a7d04e7cd23cd86a46368c8959820b55234", }, BrowserAsset { path: "polkavm-wasm-worker-entry.js", @@ -98,7 +98,7 @@ const ASSETS: [BrowserAsset; 15] = [ path: "polkavm-worker.js", content_type: "text/javascript", bytes: include_bytes!("../assets/polkavm-worker.js"), - sha256: "dde78a7576061d0a23ab59f6b9711c0d016dc7e69cbd5e298569520a9de79ed5", + sha256: "92f25922c91fccadb7abeef64679a0c036c32e12f97fa80bcf328387566c8dab", }, BrowserAsset { path: "session.d.ts",