From 4491145cac1ab1d1a15b5b4b61a1c5a0cb975736 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Thu, 7 Jan 2021 23:12:30 -0800 Subject: [PATCH] feat(xsnap): Add Node.js shell --- package.json | 1 + packages/xsnap/.gitignore | 1 + packages/xsnap/README.md | 40 ++++ packages/xsnap/jsconfig.json | 18 ++ packages/xsnap/package.json | 58 +++++ packages/xsnap/src/build.js | 35 +++ packages/xsnap/src/defer.js | 33 +++ packages/xsnap/src/netstring.js | 117 ++++++++++ packages/xsnap/src/node-stream.js | 71 ++++++ packages/xsnap/src/stream.js | 87 ++++++++ packages/xsnap/src/xsnap.js | 229 ++++++++++++++++++++ packages/xsnap/src/xsrepl.js | 76 +++++++ packages/xsnap/test/.gitignore | 1 + packages/xsnap/test/fixture-xsnap-script.js | 4 + packages/xsnap/test/test-netstring.js | 103 +++++++++ packages/xsnap/test/test-xsnap.js | 154 +++++++++++++ 16 files changed, 1028 insertions(+) create mode 100644 packages/xsnap/.gitignore create mode 100644 packages/xsnap/README.md create mode 100644 packages/xsnap/jsconfig.json create mode 100644 packages/xsnap/package.json create mode 100644 packages/xsnap/src/build.js create mode 100644 packages/xsnap/src/defer.js create mode 100644 packages/xsnap/src/netstring.js create mode 100644 packages/xsnap/src/node-stream.js create mode 100644 packages/xsnap/src/stream.js create mode 100644 packages/xsnap/src/xsnap.js create mode 100755 packages/xsnap/src/xsrepl.js create mode 100644 packages/xsnap/test/.gitignore create mode 100644 packages/xsnap/test/fixture-xsnap-script.js create mode 100644 packages/xsnap/test/test-netstring.js create mode 100644 packages/xsnap/test/test-xsnap.js diff --git a/package.json b/package.json index f80e96b3228..611a4afadd7 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "packages/deployment", "packages/notifier", "packages/xs-vat-worker", + "packages/xsnap", "packages/deploy-script-support" ], "devDependencies": { diff --git a/packages/xsnap/.gitignore b/packages/xsnap/.gitignore new file mode 100644 index 00000000000..378eac25d31 --- /dev/null +++ b/packages/xsnap/.gitignore @@ -0,0 +1 @@ +build diff --git a/packages/xsnap/README.md b/packages/xsnap/README.md new file mode 100644 index 00000000000..6a164691d5f --- /dev/null +++ b/packages/xsnap/README.md @@ -0,0 +1,40 @@ +# xsnap + +Xsnap is a utility for taking resumable snapshots of a running JavaScript +worker, using Moddable’s XS JavaScript engine. + +Xsnap provides a Node.js API for controlling Xsnap workers. + +```js +const worker = xsnap(); +await worker.evaluate(` + // Incrementer, running on XS. + function answerSysCall(message) { + const number = Number(String.fromArrayBuffer(message)); + return ArrayBuffer.fromString(String(number + 1)); + } +`); +await worker.snapshot('bootstrap.xss'); +await worker.close(); +``` + +Some time later, possibly on a different computer… + +```js +const decoder = new TextDecoder(); +const worker = xsnap({ snapshot: 'bootstrap.xss' }); +const answer = await worker.sysCall('1'); +console.log(decoder.decode(answer)); // 2 +await worker.close(); +``` + +The parent and child communicate using "syscalls". + +- The XS child uses the synchronous `sysCall` function to send a request and + receive as response from the Node.js parent. +- The XS child can implement a synchronous `answserSysCall` function to respond + to syscalls from the Node.js parent. +- The Node.js parent uses an asynchronous `sysCall` method to send a request + and receive a response from the XS child. +- The Node.js parent can implement an asynchronous `answerSysCall` function to + respond to syscalls from the XS child. diff --git a/packages/xsnap/jsconfig.json b/packages/xsnap/jsconfig.json new file mode 100644 index 00000000000..a882e0a3ced --- /dev/null +++ b/packages/xsnap/jsconfig.json @@ -0,0 +1,18 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + + "noEmit": true, +/* + // The following flags are for creating .d.ts files: + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, +*/ + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node", + }, + "include": ["src/**/*.js", "exported.js", "tools/**/*.js"], +} diff --git a/packages/xsnap/package.json b/packages/xsnap/package.json new file mode 100644 index 00000000000..f72be34958e --- /dev/null +++ b/packages/xsnap/package.json @@ -0,0 +1,58 @@ +{ + "name": "@agoric/xsnap", + "version": "0.0.0+1-dev", + "description": "Description forthcoming.", + "author": "Agoric", + "license": "Apache-2.0", + "parsers": { + "js": "mjs" + }, + "main": "./src/xsnap.js", + "scripts": { + "build": "node -r esm src/build.js", + "clean": "rm -rf build", + "lint": "yarn lint:js && yarn lint:types", + "lint:js": "eslint 'src/**/*.js'", + "lint:types": "tsc -p jsconfig.json", + "lint-fix": "eslint --fix 'src/**/*.js'", + "lint-check": "yarn lint", + "test": "ava", + "postinstall": "yarn build" + }, + "dependencies": {}, + "devDependencies": { + "@rollup/plugin-node-resolve": "^6.1.0", + "ava": "^3.12.1", + "esm": "^3.2.5", + "rollup-plugin-terser": "^5.1.3" + }, + "files": [ + "LICENSE*", + "makefiles", + "src" + ], + "publishConfig": { + "access": "public" + }, + "eslintConfig": { + "extends": [ + "@agoric" + ], + "ignorePatterns": [ + "examples/**/*.js" + ] + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "require": [ + "esm" + ], + "timeout": "2m" + }, + "prettier": { + "trailingComma": "all", + "singleQuote": true + } +} diff --git a/packages/xsnap/src/build.js b/packages/xsnap/src/build.js new file mode 100644 index 00000000000..81a51920265 --- /dev/null +++ b/packages/xsnap/src/build.js @@ -0,0 +1,35 @@ +import * as childProcess from 'child_process'; +import os from 'os'; + +function exec(command, cwd) { + const child = childProcess.spawn(command, { + cwd, + stdio: ['inherit', 'inherit', 'inherit'], + }); + return new Promise((resolve, reject) => { + child.on('close', () => { + resolve(); + }); + child.on('error', err => { + reject(new Error(`${command} error ${err}`)); + }); + child.on('exit', code => { + if (code !== 0) { + reject(new Error(`${command} exited with code ${code}`)); + } + }); + }); +} + +(async () => { + // Run command depending on the OS + if (os.type() === 'Linux') { + await exec('make', 'makefiles/lin'); + } else if (os.type() === 'Darwin') { + await exec('make', 'makefiles/mac'); + } else if (os.type() === 'Windows_NT') { + await exec('nmake', 'makefiles/win'); + } else { + throw new Error(`Unsupported OS found: ${os.type()}`); + } +})(); diff --git a/packages/xsnap/src/defer.js b/packages/xsnap/src/defer.js new file mode 100644 index 00000000000..0a3ae491ded --- /dev/null +++ b/packages/xsnap/src/defer.js @@ -0,0 +1,33 @@ +// @ts-check + +// eslint-disable-next-line jsdoc/require-returns-check +/** + * @param {boolean} _flag + * @returns {asserts _flag} + */ +function assert(_flag) {} + +/** + * @template T + * @typedef {{ + * resolve(value?: T | Promise): void, + * reject(error: Error): void, + * promise: Promise + * }} Deferred + */ + +/** + * @template T + * @returns {Deferred} + */ +export function defer() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + assert(resolve !== undefined); + assert(reject !== undefined); + return { promise, resolve, reject }; +} diff --git a/packages/xsnap/src/netstring.js b/packages/xsnap/src/netstring.js new file mode 100644 index 00000000000..2391c915f3e --- /dev/null +++ b/packages/xsnap/src/netstring.js @@ -0,0 +1,117 @@ +// @ts-check + +/** + * @template T + * @template U + * @template V + * @typedef {import('./stream.js').Stream} Stream + */ + +const COLON = ':'.charCodeAt(0); +const COMMA = ','.charCodeAt(0); + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * @param {AsyncIterable} input + * @param {string=} name + * @param {number=} capacity + * @returns {AsyncIterableIterator} input + */ +export async function* reader(input, name = '', capacity = 1024) { + let length = 0; + let buffer = new Uint8Array(capacity); + let offset = 0; + + for await (const chunk of input) { + if (length + chunk.byteLength >= capacity) { + while (length + chunk.byteLength >= capacity) { + capacity *= 2; + } + const replacement = new Uint8Array(capacity); + replacement.set(buffer, 0); + buffer = replacement; + } + buffer.set(chunk, length); + length += chunk.byteLength; + + let drained = false; + while (!drained && length > 0) { + const colon = buffer.indexOf(COLON); + if (colon === 0) { + throw new Error( + `Expected number before colon at offset ${offset} of ${name}`, + ); + } else if (colon > 0) { + const prefixBytes = buffer.subarray(0, colon); + const prefixString = decoder.decode(prefixBytes); + const contentLength = +prefixString; + if (Number.isNaN(contentLength)) { + throw new Error( + `Invalid netstring prefix length ${prefixString} at offset ${offset} of ${name}`, + ); + } + const messageLength = colon + contentLength + 2; + if (messageLength <= length) { + yield buffer.subarray(colon + 1, colon + 1 + contentLength); + buffer.copyWithin(0, messageLength); + length -= messageLength; + offset += messageLength; + } else { + drained = true; + } + } else { + drained = true; + } + } + } + + if (length > 0) { + throw new Error( + `Unexpected dangling message at offset ${offset} of ${name}`, + ); + } +} + +/** + * @param {Stream} output + * @returns {Stream} + */ +export function writer(output) { + const scratch = new Uint8Array(8); + + return { + async next(message) { + const { written: length = 0 } = encoder.encodeInto( + `${message.byteLength}`, + scratch, + ); + scratch[length] = COLON; + + const { done: done1 } = await output.next( + scratch.subarray(0, length + 1), + ); + if (done1) { + return output.return(); + } + + const { done: done2 } = await output.next(message); + if (done2) { + return output.return(); + } + + scratch[0] = COMMA; + return output.next(scratch.subarray(0, 1)); + }, + async return() { + return output.return(); + }, + async throw(error) { + return output.throw(error); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/packages/xsnap/src/node-stream.js b/packages/xsnap/src/node-stream.js new file mode 100644 index 00000000000..e9dcf51c69b --- /dev/null +++ b/packages/xsnap/src/node-stream.js @@ -0,0 +1,71 @@ +// @ts-check + +/** + * @template T + * @template U + * @template V + * @typedef {import('./stream.js').Stream} Stream + */ + +/** + * @template T + * @typedef {import('./defer.js').Deferred} Deferred + */ +import { defer } from './defer'; + +const continues = { value: undefined }; + +/** + * Adapts a Node.js writable stream to a JavaScript + * async iterator of Uint8Array data chunks. + * Back pressure emerges from awaiting on the promise + * returned by `next` before calling `next` again. + * + * @param {NodeJS.WritableStream} output + * @returns {Stream} + */ +export function writer(output) { + /** + * @type {Deferred>} + */ + let drained = defer(); + drained.resolve(continues); + + output.on('error', err => { + console.log('err', err); + drained.reject(err); + }); + + output.on('drain', () => { + drained.resolve(continues); + drained = defer(); + }); + + return { + /** + * @param {Uint8Array} [chunk] + * @returns {Promise>} + */ + async next(chunk) { + if (!chunk) { + return continues; + } + if (!output.write(chunk)) { + drained = defer(); + return drained.promise; + } + return continues; + }, + async return() { + output.end(); + return drained.promise; + }, + async throw() { + output.end(); + return drained.promise; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/packages/xsnap/src/stream.js b/packages/xsnap/src/stream.js new file mode 100644 index 00000000000..06eb0e3fb80 --- /dev/null +++ b/packages/xsnap/src/stream.js @@ -0,0 +1,87 @@ +// @ts-check + +import { defer } from './defer'; + +/** + * @template T + * @typedef {{ + * put(value: T | Promise): void, + * get(): Promise + * }} AsyncQueue + */ + +/** + * @template T + * @returns {AsyncQueue} + */ +export function queue() { + const ends = defer(); + return { + put(value) { + const next = defer(); + const promise = next.promise; + ends.resolve({ value, promise }); + ends.resolve = next.resolve; + }, + get() { + const promise = ends.promise.then(next => next.value); + ends.promise = ends.promise.then(next => next.promise); + return promise; + }, + }; +} + +/** + * @template T + * @template U + * @template V + * @typedef {{ + * next(value: U): Promise>, + * return(value: V): Promise>, + * throw(error: Error): Promise>, + * [Symbol.asyncIterator](): Stream + * }} Stream + */ + +/** + * @template T + * @template U + * @template V + * @param {AsyncQueue>} acks + * @param {AsyncQueue>} data + * @returns {Stream} + */ +export function stream(acks, data) { + return { + next(value) { + data.put({ value, done: false }); + return acks.get(); + }, + return(value) { + data.put({ value, done: true }); + return acks.get(); + }, + throw(error) { + data.put(Promise.reject(error)); + return acks.get(); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** + * @template T + * @template U + * @template TReturn + * @template UReturn + * @returns {[Stream, Stream]} + */ +export function pipe() { + const syn = queue(); + const ack = queue(); + const input = stream(syn, ack); + const output = stream(ack, syn); + return [input, output]; +} diff --git a/packages/xsnap/src/xsnap.js b/packages/xsnap/src/xsnap.js new file mode 100644 index 00000000000..b04fbd19725 --- /dev/null +++ b/packages/xsnap/src/xsnap.js @@ -0,0 +1,229 @@ +// @ts-check +/* eslint no-await-in-loop: ["off"] */ + +/** + * @template T + * @typedef {import('./defer').Deferred} Deferred + */ + +import { spawn } from 'child_process'; +import { type as getOsType } from 'os'; +import { defer } from './defer'; +import * as netstring from './netstring'; +import * as node from './node-stream'; + +const OK = '.'.charCodeAt(0); +const ERROR = '!'.charCodeAt(0); +const QUERY = '?'.charCodeAt(0); + +const importMetaUrl = `file://${__filename}`; + +const osType = getOsType(); +const platform = { + Linux: 'lin', + Darwin: 'mac', + Windows_NT: 'win', +}[osType]; + +if (platform === undefined) { + throw new Error(`xsnap does not support platform ${osType}`); +} + +const xsnapBin = new URL( + `../build/bin/${platform}/release/xsnap`, + importMetaUrl, +).pathname; +const xsnapDebugBin = new URL( + `../build/bin/${platform}/debug/xsnap`, + importMetaUrl, +).pathname; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * @param {Uint8Array} arg + * @returns {Uint8Array} + */ +function echoSysCall(arg) { + return arg; +} + +/** + * @param {Object} options + * @param {(request:Uint8Array) => Promise} [options.answerSysCall] + * @param {string=} [options.name] + * @param {boolean=} [options.debug] + * @param {string=} [options.snapshot] + * @param {'ignore' | 'inherit'} [options.stdout] + * @param {'ignore' | 'inherit'} [options.stderr] + */ +export function xsnap(options) { + const { + name = '', + answerSysCall = echoSysCall, + debug = false, + snapshot = undefined, + stdout = 'inherit', + stderr = 'inherit', + } = options; + + /** @type {Deferred} */ + const vatExit = defer(); + + const args = snapshot ? ['-r', snapshot] : []; + + const bin = debug ? xsnapDebugBin : xsnapBin; + + const xsnapProcess = spawn(bin, args, { + stdio: ['ignore', stdout, stderr, 'pipe', 'pipe'], + }); + + xsnapProcess.on('exit', code => { + if (code === 0 || code === null) { + vatExit.resolve(null); + } else { + vatExit.reject(new Error(`${name} exited with code ${code}`)); + } + }); + + const vatCancelled = vatExit.promise.then(() => + Promise.reject(new Error(`${name} exited`)), + ); + + const messagesToXsnap = netstring.writer( + node.writer(/** @type {NodeJS.WritableStream} */ (xsnapProcess.stdio[3])), + ); + const messagesFromXsnap = netstring.reader( + /** @type {AsyncIterable} */ (xsnapProcess.stdio[4]), + ); + + /** @type {Promise} */ + let baton = Promise.resolve(null); + + /** + * @returns {Promise} + */ + async function runToIdle() { + for (;;) { + const { done, value: message } = await messagesFromXsnap.next(); + if (done) { + xsnapProcess.kill(); + throw new Error('xsnap protocol error: unexpected end of output'); + } + if (message.byteLength === 0) { + // A protocol error kills the xsnap child process and breaks the baton + // chain with a terminal error. + xsnapProcess.kill(); + throw new Error('xsnap protocol error: received empty message'); + } else if (message[0] === OK) { + return message.subarray(1); + } else if (message[0] === ERROR) { + throw new Error(`Uncaught exception in ${name}`); + } else if (message[0] === QUERY) { + await messagesToXsnap.next(await answerSysCall(message.subarray(1))); + } + } + } + + /** + * @param {string} code + * @returns {Promise} + */ + async function evaluate(code) { + const result = baton.then(async () => { + await messagesToXsnap.next(encoder.encode(`e${code}`)); + await runToIdle(); + return null; + }); + baton = result.catch(() => null); + return Promise.race([vatCancelled, result]); + } + + /** + * @param {string} fileName + * @returns {Promise} + */ + async function execute(fileName) { + const result = baton.then(async () => { + await messagesToXsnap.next(encoder.encode(`s${fileName}`)); + await runToIdle(); + return null; + }); + baton = result.catch(() => null); + return Promise.race([vatCancelled, result]); + } + + /** + * @param {string} fileName + * @returns {Promise} + */ + async function importModule(fileName) { + const result = baton.then(async () => { + await messagesToXsnap.next(encoder.encode(`m${fileName}`)); + await runToIdle(); + return null; + }); + baton = result.catch(() => null); + return Promise.race([vatCancelled, result]); + } + + /** + * @param {Uint8Array} message + * @returns {Promise} + */ + async function sysCall(message) { + const result = baton.then(async () => { + const request = new Uint8Array(message.length + 1); + request[0] = QUERY; + request.set(message, 1); + await messagesToXsnap.next(request); + return runToIdle(); + }); + baton = result.then( + () => {}, + err => err, + ); + return Promise.race([vatCancelled, result]); + } + + /** + * @param {string} message + * @returns {Promise} + */ + async function stringSysCall(message) { + return decoder.decode(await sysCall(encoder.encode(message))); + } + + /** + * @param {string} file + * @returns {Promise} + */ + async function writeSnapshot(file) { + baton = baton.then(async () => { + await messagesToXsnap.next(encoder.encode(`w${file}`)); + return runToIdle().catch(err => err); + }); + return Promise.race([vatExit.promise, baton]); + } + + /** + * @returns {Promise} + */ + async function close() { + xsnapProcess.kill(); + baton = Promise.reject(new Error(`xsnap closed`)); + baton.catch(() => {}); // Suppress Node.js unhandled exception warning. + return vatExit.promise; + } + + return { + sysCall, + stringSysCall, + close, + evaluate, + execute, + import: importModule, + snapshot: writeSnapshot, + }; +} diff --git a/packages/xsnap/src/xsrepl.js b/packages/xsnap/src/xsrepl.js new file mode 100755 index 00000000000..25d2b799356 --- /dev/null +++ b/packages/xsnap/src/xsrepl.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// @ts-check +/* eslint no-await-in-loop: ["off"] */ + +/** + * @template T + * @typedef {import('./defer').Deferred} Deferred + */ +import * as readline from 'readline'; +import { xsnap } from './xsnap'; +import { defer } from './defer'; + +const decoder = new TextDecoder(); + +async function main() { + /** + * For the purposes of the REPL, the only syscall is effectively `print`. + * + * @param {Uint8Array} message + * @returns {Promise} + */ + async function answerSysCall(message) { + console.log(decoder.decode(message)); + return new Uint8Array(); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let vat = xsnap({ answerSysCall }); + + await vat.evaluate(` + const compartment = new Compartment(); + function answerSysCall(request) { + const command = String.fromArrayBuffer(request); + let result = compartment.evaluate(command); + if (result === undefined) { + result = null; + } + sysCall(ArrayBuffer.fromString(JSON.stringify(result, null, 4))); + } + `); + + /** + * @param {string} prompt + * @returns {Promise} + */ + function ask(prompt) { + const { promise, resolve } = /** @type {Deferred} */ (defer()); + rl.question(prompt, resolve); + return promise; + } + + for (;;) { + const answer = await ask('xs> '); + if (answer === 'exit' || answer === 'quit') { + break; + } else if (answer === 'load') { + const file = await ask('file> '); + await vat.close(); + vat = xsnap({ answerSysCall, snapshot: file }); + } else if (answer === 'save') { + const file = await ask('file> '); + await vat.snapshot(file); + } else { + await vat.send(answer); + } + } + + rl.close(); + return vat.close(); +} + +main(); diff --git a/packages/xsnap/test/.gitignore b/packages/xsnap/test/.gitignore new file mode 100644 index 00000000000..e55b8d8fe0a --- /dev/null +++ b/packages/xsnap/test/.gitignore @@ -0,0 +1 @@ +fixture-snapshot.xss diff --git a/packages/xsnap/test/fixture-xsnap-script.js b/packages/xsnap/test/fixture-xsnap-script.js new file mode 100644 index 00000000000..4d6347e7ac2 --- /dev/null +++ b/packages/xsnap/test/fixture-xsnap-script.js @@ -0,0 +1,4 @@ +/* global sysCall */ +(async () => { + sysCall(ArrayBuffer.fromString('Hello, World!')); +})(); diff --git a/packages/xsnap/test/test-netstring.js b/packages/xsnap/test/test-netstring.js new file mode 100644 index 00000000000..ae963d6af06 --- /dev/null +++ b/packages/xsnap/test/test-netstring.js @@ -0,0 +1,103 @@ +import test from 'ava'; +import * as netstring from '../src/netstring'; +import { pipe } from '../src/stream'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +async function read(source) { + const array = []; + for await (const chunk of source) { + // Capture current state, allocating a copy. + array.push(chunk.slice()); + } + return array; +} + +test('read short messages', async t => { + const r = netstring.reader([encoder.encode('0:,1:A,')], '', 1); + const array = await read(r); + t.deepEqual( + ['', 'A'], + array.map(chunk => decoder.decode(chunk)), + ); +}); + +test('read messages divided over chunk boundaries', async t => { + const r = netstring.reader( + [encoder.encode('5:hel'), encoder.encode('lo,')], + '', + 1, + ); + const array = await read(r); + t.deepEqual( + ['hello'], + array.map(chunk => decoder.decode(chunk)), + ); +}); + +function delay(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +test('round-trip short messages', async t => { + const array = []; + const w = netstring.writer({ + async next(value) { + // Provide some back pressure to give the producer an opportunity to make + // the mistake of overwriting the given slice. + await delay(10); + // slice to capture before yielding. + array.push(value.slice()); + return { done: false }; + }, + async return() { + return { done: true }; + }, + async throw() { + return { done: true }; + }, + }); + await w.next(encoder.encode('')); + await w.next(encoder.encode('A')); + await w.next(encoder.encode('hello')); + await w.return(); + + t.deepEqual( + [encoder.encode(''), encoder.encode('A'), encoder.encode('hello')], + await read(netstring.reader(array)), + ); +}); + +test('round-trip varying messages', async t => { + const array = ['', 'A', 'hello']; + t.plan(array.length); + + const [input, output] = pipe(); + + const producer = (async () => { + const w = netstring.writer(output); + for (let i = 0; i < array.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + await w.next(encoder.encode(array[i])); + // eslint-disable-next-line no-await-in-loop + await delay(10); + } + await w.return(); + })(); + + const consumer = (async () => { + const r = netstring.reader(input); + let i = 0; + for await (const message of r) { + await delay(10); + t.is(array[i], decoder.decode(message)); + i += 1; + } + t.log('end'); + })(); + + await Promise.all([producer, consumer]); +}); diff --git a/packages/xsnap/test/test-xsnap.js b/packages/xsnap/test/test-xsnap.js new file mode 100644 index 00000000000..f07dfdd9993 --- /dev/null +++ b/packages/xsnap/test/test-xsnap.js @@ -0,0 +1,154 @@ +import test from 'ava'; +import { xsnap } from '../src/xsnap'; + +const importMetaUrl = `file://${__filename}`; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +test('evaluate and sysCall', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(`sysCall(ArrayBuffer.fromString("Hello, World!"));`); + await vat.close(); + t.deepEqual(['Hello, World!'], messages); +}); + +test('evaluate until idle', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(` + (async () => { + sysCall(ArrayBuffer.fromString("Hello, World!")); + })(); + `); + await vat.close(); + t.deepEqual(['Hello, World!'], messages); +}); + +test('run script until idle', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.execute(new URL('fixture-xsnap-script.js', importMetaUrl).pathname); + await vat.close(); + t.deepEqual(['Hello, World!'], messages); +}); + +test('sysCall is synchronous inside, async outside', async t => { + const messages = []; + async function answerSysCall(request) { + const number = +decoder.decode(request); + await Promise.resolve(null); + messages.push(number); + await Promise.resolve(null); + return encoder.encode(`${number + 1}`); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(` + const response = sysCall(ArrayBuffer.fromString('0')); + const number = +String.fromArrayBuffer(response); + sysCall(ArrayBuffer.fromString(String(number + 1))); + `); + await vat.close(); + t.deepEqual([0, 2], messages); +}); + +test('deliver a message', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(+decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(` + function answerSysCall(message) { + const number = +String.fromArrayBuffer(message); + sysCall(ArrayBuffer.fromString(String(number + 1))); + }; + `); + await vat.stringSysCall('0'); + await vat.stringSysCall('1'); + await vat.stringSysCall('2'); + await vat.close(); + t.deepEqual([1, 2, 3], messages); +}); + +test.only('receive a response', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(+decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(` + function answerSysCall(message) { + const number = +String.fromArrayBuffer(message); + return ArrayBuffer.fromString(String(number + 1)); + }; + `); + t.is('1', await vat.stringSysCall('0')); + t.is('2', await vat.stringSysCall('1')); + t.is('3', await vat.stringSysCall('2')); + await vat.close(); +}); + +function* count(end, start = 0, stride = 1) { + for (; start < end; start += stride) { + yield start; + } +} + +test('serialize concurrent messages', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(+decoder.decode(message)); + return new Uint8Array(); + } + const vat = xsnap({ answerSysCall }); + await vat.evaluate(` + globalThis.answerSysCall = message => { + const number = +String.fromArrayBuffer(message); + sysCall(ArrayBuffer.fromString(String(number + 1))); + }; + `); + await Promise.all([...count(100)].map(n => vat.stringSysCall(`${n}`))); + await vat.close(); + t.deepEqual([...count(101, 1)], messages); +}); + +test('write and read snapshot', async t => { + const messages = []; + async function answerSysCall(message) { + messages.push(decoder.decode(message)); + return new Uint8Array(); + } + + const snapshot = new URL('fixture-snapshot.xss', importMetaUrl).pathname; + + const vat0 = xsnap({ answerSysCall }); + await vat0.evaluate(` + globalThis.hello = "Hello, World!"; + `); + await vat0.snapshot(snapshot); + await vat0.close(); + + const vat1 = xsnap({ answerSysCall, snapshot }); + await vat1.evaluate(` + sysCall(ArrayBuffer.fromString(hello)); + `); + await vat1.close(); + + t.deepEqual(['Hello, World!'], messages); +});