From e9caee8cb4808fa3f445bf09b018cfbe576439e1 Mon Sep 17 00:00:00 2001 From: Tyler Sommer Date: Sun, 31 May 2020 11:24:20 -0600 Subject: [PATCH] Resolve Promises with AsyncResult, work on net node_compat --- .dockerignore | 1 + .gitignore | 3 + Makefile | 9 + go.mod | 1 + go.sum | 2 + plugin/plugin.go | 5 + plugins/babel/plugin.go | 7 +- plugins/babel/{ => transformer}/babel.go | 4 +- plugins/babel/transformer/babel_test.go | 71 ++ plugins/node_compat/child_process.go | 4 +- plugins/node_compat/crypto.go | 7 +- plugins/node_compat/event_emitter.go | 17 +- plugins/node_compat/exec.go | 3 +- plugins/node_compat/http.go | 120 +++ plugins/node_compat/internal/net.go | 166 +++ plugins/node_compat/net.go | 225 +++++ plugins/node_compat/net_test.go | 173 ++++ plugins/node_compat/plugin.go | 30 +- plugins/node_compat/stream.go | 142 +++ plugins/script/plugin.go | 4 +- plugins/squircy2_compat/plugin.go | 4 +- plugins/squircy2_compat/runtime.go | 4 - testdata/es6-promise.js | 1174 ++++++++++++++++++++++ testdata/package.json | 15 + vm/require.go | 5 +- vm/result.go | 354 +++++++ vm/result_test.go | 85 ++ vm/scheduler.go | 54 +- vm/vm.go | 112 +-- vm/vm_test.go | 28 + 30 files changed, 2690 insertions(+), 139 deletions(-) rename plugins/babel/{ => transformer}/babel.go (90%) create mode 100644 plugins/babel/transformer/babel_test.go create mode 100644 plugins/node_compat/http.go create mode 100644 plugins/node_compat/internal/net.go create mode 100644 plugins/node_compat/net.go create mode 100644 plugins/node_compat/net_test.go create mode 100644 plugins/node_compat/stream.go create mode 100644 testdata/es6-promise.js create mode 100644 testdata/package.json create mode 100644 vm/result.go create mode 100644 vm/result_test.go diff --git a/.dockerignore b/.dockerignore index 2fac200..7d8dcb1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ out/* node_modules/* +testdata/node_modules/* Dockerfile \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9c345c5..e9488f7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,8 @@ !.dockerignore out/* node_modules/* +testdata/node_modules/* +testdata/yarn-error.log +testdata/yarn.lock yarn.lock package-lock.json diff --git a/Makefile b/Makefile index a4c3041..70fb43e 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ OUTPUT_BASE := out PLUGIN_TARGETS := $(patsubst %,$(OUTPUT_BASE)/%.so,$(PLUGINS)) SQUIRCY_TARGET := $(OUTPUT_BASE)/squircy +TESTDATA_NODEMODS_TARGET := testdata/node_modules + .PHONY: all build generate run squircy plugins clean all: build @@ -31,6 +33,13 @@ plugins: $(PLUGIN_TARGETS) run: build $(SQUIRCY_TARGET) +test: $(TESTDATA_NODEMODS_TARGET) + go test --tags netgo -race ./... + +$(TESTDATA_NODEMODS_TARGET): + cd testdata && \ + yarn install + .SECONDEXPANSION: $(PLUGIN_TARGETS): $(OUTPUT_BASE)/%.so: $$(wildcard plugins/%/*) $(SOURCES) go build -tags netgo -race -o $@ -buildmode=plugin plugins/$*/*.go diff --git a/go.mod b/go.mod index e29f2ad..1a37a55 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v0.3.1 github.com/dlclark/regexp2 v1.2.0 // indirect github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733 + github.com/dop251/goja_nodejs v0.0.0-20200128125109-2d688c7e0ac4 // indirect github.com/fatih/structtag v1.0.0 github.com/go-sourcemap/sourcemap v2.1.2+incompatible // indirect github.com/kr/pretty v0.1.0 // indirect diff --git a/go.sum b/go.sum index 601c704..2000de4 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733 h1:cyNc40Dx5YNEO94idePU8rhVd3dn+sd04Arh0kDBAaw= github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733/go.mod h1:Mw6PkjjMXWbTj+nnj4s3QPXq1jaT0s5pC0iFD4+BOAA= +github.com/dop251/goja_nodejs v0.0.0-20200128125109-2d688c7e0ac4 h1:L3xoE2+R67n8YEoNBB9K5h9CYJd3QbD8iYAjnuqFNK8= +github.com/dop251/goja_nodejs v0.0.0-20200128125109-2d688c7e0ac4/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/fatih/structtag v1.0.0 h1:pTHj65+u3RKWYPSGaU290FpI/dXxTaHdVwVwbcPKmEc= github.com/fatih/structtag v1.0.0/go.mod h1:IKitwq45uXL/yqi5mYghiD3w9H6eTOvI9vnk8tXMphA= github.com/go-sourcemap/sourcemap v2.1.2+incompatible h1:0b/xya7BKGhXuqFESKM4oIiRo9WOt2ebz7KxfreD6ug= diff --git a/plugin/plugin.go b/plugin/plugin.go index 0ffbefe..b238f8d 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -1,6 +1,7 @@ package plugin // import "code.dopame.me/veonik/squircy3/plugin" import ( + "fmt" "plugin" "sync" @@ -140,3 +141,7 @@ func (m *Manager) Configure() []error { } return errs } + +func Main(pluginName string) { + fmt.Println(pluginName, "- a plugin for squircy3") +} diff --git a/plugins/babel/plugin.go b/plugins/babel/plugin.go index 183e455..fc7f4ff 100644 --- a/plugins/babel/plugin.go +++ b/plugins/babel/plugin.go @@ -1,10 +1,9 @@ package main // import "code.dopame.me/veonik/squircy3/plugins/babel" import ( - "fmt" - "code.dopame.me/veonik/squircy3/config" "code.dopame.me/veonik/squircy3/plugin" + babel "code.dopame.me/veonik/squircy3/plugins/babel/transformer" "code.dopame.me/veonik/squircy3/vm" "github.com/dop251/goja" @@ -15,7 +14,7 @@ import ( const pluginName = "babel" func main() { - fmt.Println(pluginName, "- a plugin for squircy3") + plugin.Main(pluginName) } func Initialize(m *plugin.Manager) (plugin.Plugin, error) { @@ -53,7 +52,7 @@ func (p *babelPlugin) HandleRuntimeInit(gr *goja.Runtime) { return } p.vm.SetTransformer(nil) - b, err := NewBabel(gr) + b, err := babel.New(gr) if err != nil { logrus.Warnln("unable to run babel init script:", err) return diff --git a/plugins/babel/babel.go b/plugins/babel/transformer/babel.go similarity index 90% rename from plugins/babel/babel.go rename to plugins/babel/transformer/babel.go index cb00bd3..f11eebe 100644 --- a/plugins/babel/babel.go +++ b/plugins/babel/transformer/babel.go @@ -1,4 +1,4 @@ -package main +package transformer // import "code.dopame.me/veonik/squircy3/plugins/babel/transformer" import ( "github.com/dop251/goja" @@ -11,7 +11,7 @@ type Babel struct { transform goja.Callable } -func NewBabel(r *goja.Runtime) (*Babel, error) { +func New(r *goja.Runtime) (*Babel, error) { b := &Babel{runtime: r} v, err := b.runtime.RunString(` this.global = this.global || this; diff --git a/plugins/babel/transformer/babel_test.go b/plugins/babel/transformer/babel_test.go new file mode 100644 index 0000000..b311d56 --- /dev/null +++ b/plugins/babel/transformer/babel_test.go @@ -0,0 +1,71 @@ +package transformer_test + +import ( + "os" + "testing" + + "github.com/dop251/goja" + "github.com/sirupsen/logrus" + + "code.dopame.me/veonik/squircy3/plugins/babel/transformer" + "code.dopame.me/veonik/squircy3/vm" +) + +func init() { + if _, err := os.Stat("../../../testdata/node_modules"); os.IsNotExist(err) { + panic("tests in this package require node dependencies to be installed in the testdata directory") + } +} + +func HandleRuntimeInit(vmp *vm.VM) func(*goja.Runtime) { + return func(gr *goja.Runtime) { + vmp.SetTransformer(nil) + b, err := transformer.New(gr) + if err != nil { + logrus.Warnln("unable to run babel init script:", err) + return + } + vmp.SetTransformer(b.Transform) + } +} + +var registry = vm.NewRegistry("../../../testdata") + +func TestBabel_Transform(t *testing.T) { + vmp, err := vm.New(registry) + if err != nil { + t.Fatalf("unexpected error creating VM: %s", err) + } + // vmp.SetModule(&vm.Module{Name: "events", Path: "./events.js", Main: "index"}) + vmp.OnRuntimeInit(HandleRuntimeInit(vmp)) + if err = vmp.Start(); err != nil { + t.Fatalf("unexpected error starting VM: %s", err) + } + res, err := vmp.RunString(`require('regenerator-runtime'); + +(async () => { + let output = null; + + setTimeout(() => { + output = "HELLO!"; + }, 200); + + const sleep = async (d) => { + return new Promise(resolve => { + setTimeout(() => resolve(), d); + }); + }; + + await sleep(500); + + return output; +})(); +`).Await() + if err != nil { + t.Fatalf("error requiring module: %s", err) + } + expected := "HELLO!" + if res.String() != expected { + t.Fatalf("expected: %s\ngot: %s", expected, res.String()) + } +} diff --git a/plugins/node_compat/child_process.go b/plugins/node_compat/child_process.go index aa04897..d530834 100644 --- a/plugins/node_compat/child_process.go +++ b/plugins/node_compat/child_process.go @@ -2,9 +2,11 @@ package main import "code.dopame.me/veonik/squircy3/vm" -var childProcess = &vm.Module{ +// Module ChildProcess is a polyfill for the child_process node module. +var ChildProcess = &vm.Module{ Name: "child_process", Main: "index", + Path: "child_process", Body: ` import EventEmitter from 'events'; diff --git a/plugins/node_compat/crypto.go b/plugins/node_compat/crypto.go index ed8f125..139bccf 100644 --- a/plugins/node_compat/crypto.go +++ b/plugins/node_compat/crypto.go @@ -2,9 +2,12 @@ package main import "code.dopame.me/veonik/squircy3/vm" -var crypto = &vm.Module{ +// Module Crypto is a polyfill providing some functionality from the node +// crypto module. +var Crypto = &vm.Module{ Name: "crypto", Main: "index", + Path: "crypto", Body: ` import {Buffer} from 'buffer'; @@ -36,4 +39,4 @@ export const createHash = (kind) => { throw new Error('unsupported hash algo: '+kind); } };`, -} \ No newline at end of file +} diff --git a/plugins/node_compat/event_emitter.go b/plugins/node_compat/event_emitter.go index 459d77e..9477a57 100644 --- a/plugins/node_compat/event_emitter.go +++ b/plugins/node_compat/event_emitter.go @@ -2,14 +2,20 @@ package main import "code.dopame.me/veonik/squircy3/vm" -var eventEmitter = &vm.Module{ +// Module EventEmitter is a polyfill for the node events module. +// See https://gist.github.com/mudge/5830382 +// Modified to add listenerCount instance method. +var EventEmitter = &vm.Module{ Name: "events", Main: "index", + Path: "events", Body: `/* Polyfill EventEmitter. */ var EventEmitter = function () { this.events = {}; }; +EventEmitter.EventEmitter = EventEmitter; + EventEmitter.prototype.on = function (event, listener) { if (typeof this.events[event] !== 'object') { this.events[event] = []; @@ -22,7 +28,7 @@ EventEmitter.prototype.removeListener = function (event, listener) { var idx; if (typeof this.events[event] === 'object') { - idx = indexOf(this.events[event], listener); + idx = this.events[event].indexOf(listener); if (idx > -1) { this.events[event].splice(idx, 1); @@ -50,5 +56,12 @@ EventEmitter.prototype.once = function (event, listener) { }); }; +EventEmitter.prototype.listenerCount = function (event) { + if(!this.events[event]) { + return 0; + } + return this.events[event].length; +}; + module.exports = EventEmitter;`, } diff --git a/plugins/node_compat/exec.go b/plugins/node_compat/exec.go index 9d45172..a1905ae 100644 --- a/plugins/node_compat/exec.go +++ b/plugins/node_compat/exec.go @@ -14,6 +14,7 @@ import ( "github.com/sirupsen/logrus" ) +// A safeBuffer is a buffer that is safe to use in concurrent contexts. type safeBuffer struct { buf bytes.Buffer mu sync.RWMutex @@ -61,7 +62,7 @@ func NewProcess(command string, args ...string) *Process { return p } -// Start starts the process, leaving stdin open for writing. +// start starts the process, leaving stdin open for writing. // // If the started process reads from stdin, it may not exit until // CloseInput is called. diff --git a/plugins/node_compat/http.go b/plugins/node_compat/http.go new file mode 100644 index 0000000..dc29a13 --- /dev/null +++ b/plugins/node_compat/http.go @@ -0,0 +1,120 @@ +package main + +import "code.dopame.me/veonik/squircy3/vm" + +// Module Http is a polyfill for the node http module. +var Http = &vm.Module{ + Name: "http", + Main: "index", + Path: "http", + Body: ` +import EventEmitter from 'events'; +import {Server as NetServer} from 'net'; + +export class Server extends NetServer { + constructor(options, requestListener) {} + + setTimeout(timeout, callback = null) {} + + get maxHeadersCount() {} + get timeout() {} + get headersTimeout() {} + get keepAliveTimeout() {} +} + +export class OutgoingMessage { + get upgrading() {} + get chunkedEncoding() {} + get shouldKeepAlive() {} + get useChunkedEncodingByDefault() {} + get sendDate() {} + get finished() {} + get headersSent() {} + get connection() {} + + constructor() {} + + setTimeout(timeout, callback = null) {} + setHeader(name, value) {} + getHeader(name) {} + getHeaders() {} + getHeaderNames() {} + hasHeader(name) {} + removeHeader(name) {} + addTrailers(headers) {} + flushHeaders() {} +} + +export class ServerResponse extends OutgoingMessage { + get statusCode() {} + get statusMessage() {} + + constructor(req) {} + + assignSocket(socket) {} + detachSocket(socket) {} + writeContinue(callback) {} + writeHead(statusCode, reasonPhrase, headers = null) {} +} + +export class ClientRequest extends OutgoingMessage { + get connection() {} + get socket() {} + get aborted() {} + + constructor(uri, callback = null) {} + + get path() {} + abort() {} + onSocket(socket) {} + setTimeout(timeout, callback = null) {} + setNoDelay(noDelay) {} + setSocketKeepAlive(enable, initialDelay = null) {} +} + +class IncomingMessage { + constructor(socket) {} + + get httpVersion() {} + get httpVersionMajor() {} + get httpVersionMinor() {} + get connection() {} + get headers() {} + get rawHeaders() {} + get trailers() {} + get rawTrailers() {} + setTimeout(timeout, callback = null) {} + get method() {} + get url() {} + get statusCode() {} + get statusMessage() {} + get socket() {} + + destroy() {} +} + +class Agent { + get maxFreeSockets() { + + } + get maxSockets() {} + get sockets() {} + get requests() {} + + constructor(options) {} + + destroy() {} +} + +export const METHODS = []; +export const STATUS_CODES = {}; + +export function createServer(options, requestListener) {} + +export function request(options, callback) {} +export function get(options, callback) {} + +export let globalAgent; +export const maxHeaderSize; +`, +} diff --git a/plugins/node_compat/internal/net.go b/plugins/node_compat/internal/net.go new file mode 100644 index 0000000..238bb35 --- /dev/null +++ b/plugins/node_compat/internal/net.go @@ -0,0 +1,166 @@ +package internal + +import ( + "io" + "net" + "sync" + + "github.com/dop251/goja" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "code.dopame.me/veonik/squircy3/vm" +) + +type NetConn struct { + conn net.Conn + + buf []byte + readable bool +} + +type readResult struct { + ready bool + value string + error error + + mu sync.Mutex +} + +func (r *readResult) Ready() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.ready +} + +func (r *readResult) Value() (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.ready { + return "", errors.New("not ready") + } + return r.value, r.error +} + +func (r *readResult) resolve(val string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.ready { + return + } + r.value = val + r.error = err + r.ready = true +} + +func NewNetConn(conn net.Conn) (*NetConn, error) { + return &NetConn{ + conn: conn, + buf: make([]byte, 1024), + readable: true, + }, nil +} + +func (c *NetConn) Write(s string) (n int, err error) { + return c.conn.Write([]byte(s)) +} + +// Read asynchronously reads from the connection. +// A readResult is returned back to the js vm and once the read completes, +// it can be read from. This allows the js vm to avoid blocking for reads. +func (c *NetConn) Read(_ int) *readResult { + res := &readResult{} + if !c.readable { + logrus.Warnln("reading from unreadable conn") + return res + } + go func() { + n, err := c.conn.Read(c.buf) + if err != nil { + if err != io.EOF { + res.resolve("", err) + return + } else { + // on the next call to read, we'll return nil to signal done. + c.readable = false + } + } + logrus.Warnln("read", n, "bytes") + rb := make([]byte, n) + copy(rb, c.buf) + res.resolve(string(rb), nil) + }() + return res +} + +func (c *NetConn) Close() error { + return c.conn.Close() +} + +func (c *NetConn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *NetConn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func Dial(kind, addr string) (*NetConn, error) { + c, err := net.Dial(kind, addr) + if err != nil { + return nil, err + } + return NewNetConn(c) +} + +type Server struct { + listener net.Listener + + vm *vm.VM + onConnect goja.Callable +} + +func (s *Server) accept() { + for { + conn, err := s.listener.Accept() + if err != nil { + logrus.Warnln("failed to accept new connection", err) + return + } + s.vm.Do(func(gr *goja.Runtime) { + nc, err := NewNetConn(conn) + if err != nil { + logrus.Warnln("failed to get NetConn from net.Conn", err) + return + } + if _, err := s.onConnect(nil, gr.ToValue(nc)); err != nil { + logrus.Warnln("error running on-connect callback", err) + } + }) + } +} + +func (s *Server) Close() error { + defer func() { + s.onConnect = nil + }() + return s.listener.Close() +} + +func (s *Server) Addr() net.Addr { + return s.listener.Addr() +} + +func Listen(vmp *vm.VM, onConnect goja.Callable, kind, addr string) (*Server, error) { + l, err := net.Listen(kind, addr) + if err != nil { + return nil, err + } + s := &Server{ + listener: l, + vm: vmp, + onConnect: onConnect, + } + go s.accept() + return s, nil +} diff --git a/plugins/node_compat/net.go b/plugins/node_compat/net.go new file mode 100644 index 0000000..eece0d5 --- /dev/null +++ b/plugins/node_compat/net.go @@ -0,0 +1,225 @@ +package main + +import "code.dopame.me/veonik/squircy3/vm" + +// Module Net is a polyfill for the node net module. +var Net = &vm.Module{ + Name: "net", + Main: "index", + Path: "net", + Body: ` +import {Duplex} from 'stream'; +import {Buffer} from 'buffer'; +import {EventEmitter} from 'events'; + +const goAddrToNode = addr => { + // todo: support ipv6 addresses, udp, ipc, etc + let parts = addr.String().split(':'); + return { + host: parts[0], + port: parseInt(parts[1]), + family: addr.Network(), + }; +}; + +export class Socket extends Duplex { + constructor(options = {}) { + super(options); + this.options = options || {}; + this._connection = null; + this._local = null; + this._remote = null; + this._connecting = false; + this.on('ready', () => { + this._connecting = false; + this._local = goAddrToNode(this._connection.LocalAddr()); + this._remote = goAddrToNode(this._connection.RemoteAddr()); + }); + } + + _read(size = null) { + if(!this._connection) { + return; + } + let result = this._connection.Read(size); + let wait = 1; + let check = () => { + if(result.Ready()) { + let data = result.Value(); + if(data !== null && data.length) { + this.push(data); + } else { + this.push(null); + } + } else { + if(wait < 64) { + wait *= 2; + } + setTimeout(check, wait); + } + }; + check(); + } + + _write(buffer, encoding, callback) { + if(!this._connection) { + callback(Error('not connected')); + return; + } + let err = null; + try { + this._connection.Write(buffer); + } catch(e) { + err = e; + } finally { + callback(err); + } + } + + async connect(options, listener = null) { + // todo: support ipc + // todo: udp is defined in Node's dgram module + if(listener !== null) { + this.once('connect', listener); + } + let host = options.host || 'localhost'; + let port = options.port; + if(!port) { + throw new Error('ipc connections are unsupported'); + } + console.log('dialing', host + ':' + port); + this._connecting = true; + this._connection = internal.Dial('tcp', host + ':' + port); + this.emit('connect'); + this.emit('ready'); + } + + setEncoding(encoding) { + this.encoding = encoding; + } + + _destroy(callback) { + let err = null; + try { + this._connection.Close(); + } catch(e) { + err = e; + console.log('error destroying', err.toString()); + } finally { + this._connection = null; + if(callback) { + callback(err); + } + } + } + + // setTimeout(timeout, callback = null) {} + // setNoDelay(noDelay) {} + // setKeepAlive(keepAlive) {} + // address() {} + // unref() {} + // ref() {} + // + // get bufferSize() {} + // get bytesRead() {} + // get bytesWritten() {} + get connecting() { + return this._connecting; + } + get localAddress() { + if(!this._connection) { + return null; + } + return this._local.host; + } + get localPort() { + if(!this._connection) { + return null; + } + return this._local.port; + } + get remoteAddress() { + if(!this._connection) { + return null; + } + return this._remote.host; + } + get remoteFamily() { + if(!this._connection) { + return null; + } + return this._remote.family; + } + get remotePort() { + if(!this._connection) { + return null; + } + return this._remote.port; + } +} + +export class Server extends EventEmitter { + constructor(listener = null) { + super(); + if(listener !== null) { + this.on('connection', listener); + } + this._server = null; + } + + listen(port, hostname, listener = null) { + if(listener !== null) { + this.on('connection', listener); + } + let addr = hostname + ':' + port; + let accept = (conn) => { + let socket = new Socket(); + socket._connection = conn; + socket.on('end', () => { + console.log('server ended'); + socket.destroy(); + }); + socket.emit('connect'); + this.emit('connection', socket); + socket.emit('ready'); + }; + this._server = internal.Listen(accept, 'tcp4', addr); + this.emit('listening'); + } + + close(callback = null) { + this._server.Close(); + this.emit('close'); + if(callback !== null) { + callback(); + } + } + + address() { + return goAddrToNode(this._server.Addr()); + } + + getConnections(callback) {} + + ref() {} + + unref() {} + + get maxConnections() {} + get connections() {} + get listening() {} +} +// +// export function createServer(options = null, connectionListener = null) {} +// +// export function connect(options, connectionListener = null) {} +// +// export function createConnection(options, connectionListener = null) {} +// +// export function isIP(input) {} +// +// export function isIPv4(input) {} +// +// export function isIPv6(input) {} +`, +} diff --git a/plugins/node_compat/net_test.go b/plugins/node_compat/net_test.go new file mode 100644 index 0000000..659d9c4 --- /dev/null +++ b/plugins/node_compat/net_test.go @@ -0,0 +1,173 @@ +package main_test + +import ( + "crypto/sha1" + "fmt" + "os" + "testing" + + "github.com/dop251/goja" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + babel "code.dopame.me/veonik/squircy3/plugins/babel/transformer" + node_compat "code.dopame.me/veonik/squircy3/plugins/node_compat" + "code.dopame.me/veonik/squircy3/plugins/node_compat/internal" + "code.dopame.me/veonik/squircy3/vm" +) + +func init() { + if _, err := os.Stat("../../testdata/node_modules"); os.IsNotExist(err) { + panic("tests in this package require node dependencies to be installed in the testdata directory") + } +} + +func HandleRuntimeInit(vmp *vm.VM) func(*goja.Runtime) { + return func(gr *goja.Runtime) { + vmp.SetTransformer(nil) + b, err := babel.New(gr) + if err != nil { + logrus.Warnln("unable to run babel init script:", err) + return + } + vmp.SetTransformer(b.Transform) + + v := gr.NewObject() + if err := v.Set("Sum", func(b []byte) (string, error) { + return fmt.Sprintf("%x", sha1.Sum(b)), nil + }); err != nil { + logrus.Warnf("%s: error initializing runtime: %s", node_compat.PluginName, err) + } + gr.Set("sha1", v) + + v = gr.NewObject() + if err := v.Set("Dial", internal.Dial); err != nil { + logrus.Warnf("%s: error initializing runtime: %s", node_compat.PluginName, err) + } + if err := v.Set("Listen", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) != 3 { + panic(gr.NewGoError(errors.New("expected exactly 3 arguments"))) + } + arg0 := call.Arguments[0] + fn, ok := goja.AssertFunction(arg0) + if !ok { + panic(gr.NewGoError(errors.New("expected argument 0 to be callable"))) + } + kind := call.Arguments[1].String() + addr := call.Arguments[2].String() + srv, err := internal.Listen(vmp, fn, kind, addr) + if err != nil { + panic(gr.NewGoError(err)) + } + return gr.ToValue(srv) + }); err != nil { + logrus.Warnf("%s: error initializing runtime: %s", node_compat.PluginName, err) + } + gr.Set("internal", v) + + _, err = gr.RunString(`this.global = this.global || this; +require('core-js-bundle'); +this.process = this.process || require('process/browser'); +require('regenerator-runtime');`) + if err != nil { + logrus.Warnf("%s: error initializing runtime: %s", node_compat.PluginName, err) + } + } +} + +var registry = vm.NewRegistry("../../testdata") + +func TestNodeCompat_Net(t *testing.T) { + vmp, err := vm.New(registry) + if err != nil { + t.Fatalf("unexpected error creating VM: %s", err) + } + vmp.SetModule(node_compat.EventEmitter) + vmp.SetModule(node_compat.ChildProcess) + vmp.SetModule(node_compat.Crypto) + vmp.SetModule(node_compat.Stream) + vmp.SetModule(node_compat.Net) + vmp.SetModule(node_compat.Http) + vmp.OnRuntimeInit(HandleRuntimeInit(vmp)) + if err = vmp.Start(); err != nil { + t.Fatalf("unexpected error starting VM: %s", err) + } + res, err := vmp.RunString(` + +import {Socket, Server} from 'net'; + +const sleep = async (d) => { + return new Promise(resolve => { + setTimeout(() => resolve(), d); + }); +}; + +let resolve; +let output = ''; +let result = new Promise(_resolve => { + resolve = _resolve; +}); + +// let originalLog = console.log; +// console.log = function log() { +// let args = Array.from(arguments).map(arg => arg.toString()); +// originalLog(args.join(' ')); +// }; + +(async () => { + var srv = new Server(); + srv.listen(3333, 'localhost', async conn => { + console.log('connected'); + conn.on('data', data => { + console.log('server received', data.toString()); + }); + conn.on('close', () => console.log('server side disconnected')); + conn.on('end', () => { + console.log('ending server connection from user code!'); + srv.close(); + }); + conn.on('ready', () => { + console.log('server: ' + conn.localAddress + ':' + conn.localPort); + console.log('client: ' + conn.remoteAddress + ':' + conn.remotePort); + }); + conn.write('hi'); + await sleep(500); + conn.write('exit\n'); + }); + srv.on('close', () => { + resolve(output); + }); + console.log('listening on', srv.address()); +})(); + +(async () => { + let sock = new Socket(); + console.log('wot'); + sock.on('data', d => { + let data = d.toString(); + console.log('received', data); + if(data.replace(/\n$/, '') === 'exit') { + sock.end('peace!'); + sock.destroy(); + return; + } else { + output += d; + } + }); + sock.on('close', () => console.log('client side disconnected')); + await sock.connect({host: 'localhost', port: 3333}); + sock.write('hello there!\r\n'); + console.log('wot2'); +})(); + +result; + +`).Await() + if err != nil { + t.Fatalf("error requiring module: %s", err) + } + expected := "hi" + if res.String() != expected { + t.Fatalf("expected: %s\ngot: %s", expected, res.String()) + } +} diff --git a/plugins/node_compat/plugin.go b/plugins/node_compat/plugin.go index a137db9..5aedd3f 100644 --- a/plugins/node_compat/plugin.go +++ b/plugins/node_compat/plugin.go @@ -10,23 +10,27 @@ import ( "code.dopame.me/veonik/squircy3/config" "code.dopame.me/veonik/squircy3/plugin" + "code.dopame.me/veonik/squircy3/plugins/node_compat/internal" "code.dopame.me/veonik/squircy3/vm" ) -const pluginName = "node_compat" +const PluginName = "node_compat" func main() { - fmt.Println(pluginName, "- a plugin for squircy3") + plugin.Main(PluginName) } func Initialize(m *plugin.Manager) (plugin.Plugin, error) { vmp, err := vm.FromPlugins(m) if err != nil { - return nil, errors.Wrapf(err, "%s: required dependency missing (vm)", pluginName) + return nil, errors.Wrapf(err, "%s: required dependency missing (vm)", PluginName) } - vmp.SetModule(eventEmitter) - vmp.SetModule(childProcess) - vmp.SetModule(crypto) + vmp.SetModule(EventEmitter) + vmp.SetModule(ChildProcess) + vmp.SetModule(Crypto) + vmp.SetModule(Stream) + vmp.SetModule(Net) + vmp.SetModule(Http) return &nodeCompatPlugin{}, nil } @@ -40,7 +44,7 @@ func (p *nodeCompatPlugin) HandleRuntimeInit(r *goja.Runtime) { } v := r.NewObject() if err := v.Set("Command", NewProcess); err != nil { - logrus.Warnf("%s: error initializing runtime: %s", pluginName, err) + logrus.Warnf("%s: error initializing runtime: %s", PluginName, err) } r.Set("exec", v) @@ -48,16 +52,22 @@ func (p *nodeCompatPlugin) HandleRuntimeInit(r *goja.Runtime) { if err := v.Set("Sum", func(b []byte) (string, error) { return fmt.Sprintf("%x", sha1.Sum(b)), nil }); err != nil { - logrus.Warnf("%s: error initializing runtime: %s", pluginName, err) + logrus.Warnf("%s: error initializing runtime: %s", PluginName, err) } r.Set("sha1", v) + v = r.NewObject() + if err := v.Set("Dial", internal.Dial); err != nil { + logrus.Warnf("%s: error initializing runtime: %s", PluginName, err) + } + r.Set("internal", v) + _, err := r.RunString(`this.global = this.global || this; require('core-js-bundle'); this.process = this.process || require('process/browser'); require('regenerator-runtime');`) if err != nil { - logrus.Warnf("%s: error initializing runtime: %s", pluginName, err) + logrus.Warnf("%s: error initializing runtime: %s", PluginName, err) } } @@ -71,5 +81,5 @@ func (p *nodeCompatPlugin) Configure(conf config.Config) error { } func (p *nodeCompatPlugin) Name() string { - return pluginName + return PluginName } diff --git a/plugins/node_compat/stream.go b/plugins/node_compat/stream.go new file mode 100644 index 0000000..93574d7 --- /dev/null +++ b/plugins/node_compat/stream.go @@ -0,0 +1,142 @@ +package main + +import ( + "code.dopame.me/veonik/squircy3/vm" +) + +// Module Stream is based on stream-browserify and relies on readable-stream. +// See https://github.com/browserify/stream-browserify/blob/v3.0.0/index.js +var Stream = &vm.Module{ + Name: "stream", + Main: "index.js", + Path: "stream", + Body: `// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +module.exports = Stream; + +var EE = require('events').EventEmitter; +var inherits = require('inherits'); + +inherits(Stream, EE); +Stream.Readable = require('readable-stream/lib/_stream_readable.js'); +Stream.Writable = require('readable-stream/lib/_stream_writable.js'); +Stream.Duplex = require('readable-stream/lib/_stream_duplex.js'); +Stream.Transform = require('readable-stream/lib/_stream_transform.js'); +Stream.PassThrough = require('readable-stream/lib/_stream_passthrough.js'); +Stream.finished = require('readable-stream/lib/internal/streams/end-of-Stream.js') +Stream.pipeline = require('readable-stream/lib/internal/streams/pipeline.js') + +// Backwards-compat with node 0.4.x +Stream.Stream = Stream; + + + +// old-style streams. Note that the pipe method (the only relevant +// part of this class) is overridden in the Readable class. + +function Stream() { + EE.call(this); +} + +Stream.prototype.pipe = function(dest, options) { + var source = this; + + function ondata(chunk) { + if (dest.writable) { + if (false === dest.write(chunk) && source.pause) { + source.pause(); + } + } + } + + source.on('data', ondata); + + function ondrain() { + if (source.readable && source.resume) { + source.resume(); + } + } + + dest.on('drain', ondrain); + + // If the 'end' option is not supplied, dest.end() will be called when + // source gets the 'end' or 'close' events. Only dest.end() once. + if (!dest._isStdio && (!options || options.end !== false)) { + source.on('end', onend); + source.on('close', onclose); + } + + var didOnEnd = false; + function onend() { + if (didOnEnd) return; + didOnEnd = true; + + dest.end(); + } + + + function onclose() { + if (didOnEnd) return; + didOnEnd = true; + + if (typeof dest.destroy === 'function') dest.destroy(); + } + + // don't leave dangling pipes when there are errors. + function onerror(er) { + cleanup(); + if (EE.listenerCount(this, 'error') === 0) { + throw er; // Unhandled Stream error in pipe. + } + } + + source.on('error', onerror); + dest.on('error', onerror); + + // remove all the event listeners that were added. + function cleanup() { + source.removeListener('data', ondata); + dest.removeListener('drain', ondrain); + + source.removeListener('end', onend); + source.removeListener('close', onclose); + + source.removeListener('error', onerror); + dest.removeListener('error', onerror); + + source.removeListener('end', cleanup); + source.removeListener('close', cleanup); + + dest.removeListener('close', cleanup); + } + + source.on('end', cleanup); + source.on('close', cleanup); + + dest.on('close', cleanup); + + dest.emit('pipe', source); + + // Allow for unix-like usage: A.pipe(B).pipe(C) + return dest; +};`, +} diff --git a/plugins/script/plugin.go b/plugins/script/plugin.go index 68a1927..dc70d5e 100644 --- a/plugins/script/plugin.go +++ b/plugins/script/plugin.go @@ -1,8 +1,6 @@ package main // import "code.dopame.me/veonik/squircy3/plugins/script" import ( - "fmt" - "code.dopame.me/veonik/squircy3/config" "code.dopame.me/veonik/squircy3/plugin" "code.dopame.me/veonik/squircy3/vm" @@ -15,7 +13,7 @@ import ( const pluginName = "script" func main() { - fmt.Println(pluginName, "- a plugin for squircy3") + plugin.Main(pluginName) } // Initialize is a valid plugin.Initializer diff --git a/plugins/squircy2_compat/plugin.go b/plugins/squircy2_compat/plugin.go index d63fdfc..120d432 100644 --- a/plugins/squircy2_compat/plugin.go +++ b/plugins/squircy2_compat/plugin.go @@ -1,8 +1,6 @@ package main // import "code.dopame.me/veonik/squircy3/plugins/squircy2_compat" import ( - "fmt" - "code.dopame.me/veonik/squircy3/config" "code.dopame.me/veonik/squircy3/event" "code.dopame.me/veonik/squircy3/irc" @@ -16,7 +14,7 @@ import ( const pluginName = "squircy2_compat" func main() { - fmt.Println(pluginName, "- a plugin for squircy3") + plugin.Main(pluginName) } func Initialize(m *plugin.Manager) (plugin.Plugin, error) { diff --git a/plugins/squircy2_compat/runtime.go b/plugins/squircy2_compat/runtime.go index 5a670d8..5ce30e3 100644 --- a/plugins/squircy2_compat/runtime.go +++ b/plugins/squircy2_compat/runtime.go @@ -65,10 +65,6 @@ func (cb *callback) Handle(ev *event.Event) { } func (p *HelperSet) setDispatcher(gr *goja.Runtime) { - //getFnName := func(fn goja.Value) (name string) { - // s := sha256.Sum256([]byte(fmt.Sprintf("%p", fn))) - // return fmt.Sprintf("__Handler%x", s) - //} if p.funcs != nil { for _, f := range p.funcs { p.events.Unbind(f.eventType, f) diff --git a/testdata/es6-promise.js b/testdata/es6-promise.js new file mode 100644 index 0000000..780e6a1 --- /dev/null +++ b/testdata/es6-promise.js @@ -0,0 +1,1174 @@ +/*! + * @overview es6-promise - a tiny implementation of Promises/A+. + * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald) + * @license Licensed under MIT license + * See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE + * @version v4.2.8+1e68dce6 + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.ES6Promise = factory()); +}(this, (function () { 'use strict'; + +function objectOrFunction(x) { + var type = typeof x; + return x !== null && (type === 'object' || type === 'function'); +} + +function isFunction(x) { + return typeof x === 'function'; +} + + + +var _isArray = void 0; +if (Array.isArray) { + _isArray = Array.isArray; +} else { + _isArray = function (x) { + return Object.prototype.toString.call(x) === '[object Array]'; + }; +} + +var isArray = _isArray; + +var len = 0; +var vertxNext = void 0; +var customSchedulerFn = void 0; + +var asap = function asap(callback, arg) { + queue[len] = callback; + queue[len + 1] = arg; + len += 2; + if (len === 2) { + // If len is 2, that means that we need to schedule an async flush. + // If additional callbacks are queued before the queue is flushed, they + // will be processed by this flush that we are scheduling. + if (customSchedulerFn) { + customSchedulerFn(flush); + } else { + scheduleFlush(); + } + } +}; + +function setScheduler(scheduleFn) { + customSchedulerFn = scheduleFn; +} + +function setAsap(asapFn) { + asap = asapFn; +} + +var browserWindow = typeof window !== 'undefined' ? window : undefined; +var browserGlobal = browserWindow || {}; +var BrowserMutationObserver = browserGlobal.MutationObserver || browserGlobal.WebKitMutationObserver; +var isNode = typeof self === 'undefined' && typeof process !== 'undefined' && {}.toString.call(process) === '[object process]'; + +// test for web worker but not in IE10 +var isWorker = typeof Uint8ClampedArray !== 'undefined' && typeof importScripts !== 'undefined' && typeof MessageChannel !== 'undefined'; + +// node +function useNextTick() { + // node version 0.10.x displays a deprecation warning when nextTick is used recursively + // see https://github.com/cujojs/when/issues/410 for details + return function () { + return process.nextTick(flush); + }; +} + +// vertx +function useVertxTimer() { + if (typeof vertxNext !== 'undefined') { + return function () { + vertxNext(flush); + }; + } + + return useSetTimeout(); +} + +function useMutationObserver() { + var iterations = 0; + var observer = new BrowserMutationObserver(flush); + var node = document.createTextNode(''); + observer.observe(node, { characterData: true }); + + return function () { + node.data = iterations = ++iterations % 2; + }; +} + +// web worker +function useMessageChannel() { + var channel = new MessageChannel(); + channel.port1.onmessage = flush; + return function () { + return channel.port2.postMessage(0); + }; +} + +function useSetTimeout() { + // Store setTimeout reference so es6-promise will be unaffected by + // other code modifying setTimeout (like sinon.useFakeTimers()) + var globalSetTimeout = setTimeout; + return function () { + return globalSetTimeout(flush, 1); + }; +} + +var queue = new Array(1000); +function flush() { + for (var i = 0; i < len; i += 2) { + var callback = queue[i]; + var arg = queue[i + 1]; + + callback(arg); + + queue[i] = undefined; + queue[i + 1] = undefined; + } + + len = 0; +} + +function attemptVertx() { + try { + var vertx = Function('return this')().require('vertx'); + vertxNext = vertx.runOnLoop || vertx.runOnContext; + return useVertxTimer(); + } catch (e) { + return useSetTimeout(); + } +} + +var scheduleFlush = void 0; +// Decide what async method to use to triggering processing of queued callbacks: +if (isNode) { + scheduleFlush = useNextTick(); +} else if (BrowserMutationObserver) { + scheduleFlush = useMutationObserver(); +} else if (isWorker) { + scheduleFlush = useMessageChannel(); +} else if (browserWindow === undefined && typeof require === 'function') { + scheduleFlush = attemptVertx(); +} else { + scheduleFlush = useSetTimeout(); +} + +function then(onFulfillment, onRejection) { + var parent = this; + + var child = new this.constructor(noop); + + if (child[PROMISE_ID] === undefined) { + makePromise(child); + } + + var _state = parent._state; + + + if (_state) { + var callback = arguments[_state - 1]; + asap(function () { + return invokeCallback(_state, child, callback, parent._result); + }); + } else { + subscribe(parent, child, onFulfillment, onRejection); + } + + return child; +} + +/** + `Promise.resolve` returns a promise that will become resolved with the + passed `value`. It is shorthand for the following: + + ```javascript + let promise = new Promise(function(resolve, reject){ + resolve(1); + }); + + promise.then(function(value){ + // value === 1 + }); + ``` + + Instead of writing the above, your code now simply becomes the following: + + ```javascript + let promise = Promise.resolve(1); + + promise.then(function(value){ + // value === 1 + }); + ``` + + @method resolve + @static + @param {Any} value value that the returned promise will be resolved with + Useful for tooling. + @return {Promise} a promise that will become fulfilled with the given + `value` +*/ +function resolve$1(object) { + /*jshint validthis:true */ + var Constructor = this; + + if (object && typeof object === 'object' && object.constructor === Constructor) { + return object; + } + + var promise = new Constructor(noop); + resolve(promise, object); + return promise; +} + +var PROMISE_ID = Math.random().toString(36).substring(2); + +function noop() {} + +var PENDING = void 0; +var FULFILLED = 1; +var REJECTED = 2; + +function selfFulfillment() { + return new TypeError("You cannot resolve a promise with itself"); +} + +function cannotReturnOwn() { + return new TypeError('A promises callback cannot return that same promise.'); +} + +function tryThen(then$$1, value, fulfillmentHandler, rejectionHandler) { + try { + then$$1.call(value, fulfillmentHandler, rejectionHandler); + } catch (e) { + return e; + } +} + +function handleForeignThenable(promise, thenable, then$$1) { + asap(function (promise) { + var sealed = false; + var error = tryThen(then$$1, thenable, function (value) { + if (sealed) { + return; + } + sealed = true; + if (thenable !== value) { + resolve(promise, value); + } else { + fulfill(promise, value); + } + }, function (reason) { + if (sealed) { + return; + } + sealed = true; + + reject(promise, reason); + }, 'Settle: ' + (promise._label || ' unknown promise')); + + if (!sealed && error) { + sealed = true; + reject(promise, error); + } + }, promise); +} + +function handleOwnThenable(promise, thenable) { + if (thenable._state === FULFILLED) { + fulfill(promise, thenable._result); + } else if (thenable._state === REJECTED) { + reject(promise, thenable._result); + } else { + subscribe(thenable, undefined, function (value) { + return resolve(promise, value); + }, function (reason) { + return reject(promise, reason); + }); + } +} + +function handleMaybeThenable(promise, maybeThenable, then$$1) { + if (maybeThenable.constructor === promise.constructor && then$$1 === then && maybeThenable.constructor.resolve === resolve$1) { + handleOwnThenable(promise, maybeThenable); + } else { + if (then$$1 === undefined) { + fulfill(promise, maybeThenable); + } else if (isFunction(then$$1)) { + handleForeignThenable(promise, maybeThenable, then$$1); + } else { + fulfill(promise, maybeThenable); + } + } +} + +function resolve(promise, value) { + if (promise === value) { + reject(promise, selfFulfillment()); + } else if (objectOrFunction(value)) { + var then$$1 = void 0; + try { + then$$1 = value.then; + } catch (error) { + reject(promise, error); + return; + } + handleMaybeThenable(promise, value, then$$1); + } else { + fulfill(promise, value); + } +} + +function publishRejection(promise) { + if (promise._onerror) { + promise._onerror(promise._result); + } + + publish(promise); +} + +function fulfill(promise, value) { + if (promise._state !== PENDING) { + return; + } + + promise._result = value; + promise._state = FULFILLED; + + if (promise._subscribers.length !== 0) { + asap(publish, promise); + } +} + +function reject(promise, reason) { + if (promise._state !== PENDING) { + return; + } + promise._state = REJECTED; + promise._result = reason; + + asap(publishRejection, promise); +} + +function subscribe(parent, child, onFulfillment, onRejection) { + var _subscribers = parent._subscribers; + var length = _subscribers.length; + + + parent._onerror = null; + + _subscribers[length] = child; + _subscribers[length + FULFILLED] = onFulfillment; + _subscribers[length + REJECTED] = onRejection; + + if (length === 0 && parent._state) { + asap(publish, parent); + } +} + +function publish(promise) { + var subscribers = promise._subscribers; + var settled = promise._state; + + if (subscribers.length === 0) { + return; + } + + var child = void 0, + callback = void 0, + detail = promise._result; + + for (var i = 0; i < subscribers.length; i += 3) { + child = subscribers[i]; + callback = subscribers[i + settled]; + + if (child) { + invokeCallback(settled, child, callback, detail); + } else { + callback(detail); + } + } + + promise._subscribers.length = 0; +} + +function invokeCallback(settled, promise, callback, detail) { + var hasCallback = isFunction(callback), + value = void 0, + error = void 0, + succeeded = true; + + if (hasCallback) { + try { + value = callback(detail); + } catch (e) { + succeeded = false; + error = e; + } + + if (promise === value) { + reject(promise, cannotReturnOwn()); + return; + } + } else { + value = detail; + } + + if (promise._state !== PENDING) { + // noop + } else if (hasCallback && succeeded) { + resolve(promise, value); + } else if (succeeded === false) { + reject(promise, error); + } else if (settled === FULFILLED) { + fulfill(promise, value); + } else if (settled === REJECTED) { + reject(promise, value); + } +} + +function initializePromise(promise, resolver) { + try { + resolver(function resolvePromise(value) { + resolve(promise, value); + }, function rejectPromise(reason) { + reject(promise, reason); + }); + } catch (e) { + reject(promise, e); + } +} + +var id = 0; +function nextId() { + return id++; +} + +function makePromise(promise) { + promise[PROMISE_ID] = id++; + promise._state = undefined; + promise._result = undefined; + promise._subscribers = []; +} + +function validationError() { + return new Error('Array Methods must be provided an Array'); +} + +var Enumerator = function () { + function Enumerator(Constructor, input) { + this._instanceConstructor = Constructor; + this.promise = new Constructor(noop); + + if (!this.promise[PROMISE_ID]) { + makePromise(this.promise); + } + + if (isArray(input)) { + this.length = input.length; + this._remaining = input.length; + + this._result = new Array(this.length); + + if (this.length === 0) { + fulfill(this.promise, this._result); + } else { + this.length = this.length || 0; + this._enumerate(input); + if (this._remaining === 0) { + fulfill(this.promise, this._result); + } + } + } else { + reject(this.promise, validationError()); + } + } + + Enumerator.prototype._enumerate = function _enumerate(input) { + for (var i = 0; this._state === PENDING && i < input.length; i++) { + this._eachEntry(input[i], i); + } + }; + + Enumerator.prototype._eachEntry = function _eachEntry(entry, i) { + var c = this._instanceConstructor; + var resolve$$1 = c.resolve; + + + if (resolve$$1 === resolve$1) { + var _then = void 0; + var error = void 0; + var didError = false; + try { + _then = entry.then; + } catch (e) { + didError = true; + error = e; + } + + if (_then === then && entry._state !== PENDING) { + this._settledAt(entry._state, i, entry._result); + } else if (typeof _then !== 'function') { + this._remaining--; + this._result[i] = entry; + } else if (c === Promise$1) { + var promise = new c(noop); + if (didError) { + reject(promise, error); + } else { + handleMaybeThenable(promise, entry, _then); + } + this._willSettleAt(promise, i); + } else { + this._willSettleAt(new c(function (resolve$$1) { + return resolve$$1(entry); + }), i); + } + } else { + this._willSettleAt(resolve$$1(entry), i); + } + }; + + Enumerator.prototype._settledAt = function _settledAt(state, i, value) { + var promise = this.promise; + + + if (promise._state === PENDING) { + this._remaining--; + + if (state === REJECTED) { + reject(promise, value); + } else { + this._result[i] = value; + } + } + + if (this._remaining === 0) { + fulfill(promise, this._result); + } + }; + + Enumerator.prototype._willSettleAt = function _willSettleAt(promise, i) { + var enumerator = this; + + subscribe(promise, undefined, function (value) { + return enumerator._settledAt(FULFILLED, i, value); + }, function (reason) { + return enumerator._settledAt(REJECTED, i, reason); + }); + }; + + return Enumerator; +}(); + +/** + `Promise.all` accepts an array of promises, and returns a new promise which + is fulfilled with an array of fulfillment values for the passed promises, or + rejected with the reason of the first passed promise to be rejected. It casts all + elements of the passed iterable to promises as it runs this algorithm. + + Example: + + ```javascript + let promise1 = resolve(1); + let promise2 = resolve(2); + let promise3 = resolve(3); + let promises = [ promise1, promise2, promise3 ]; + + Promise.all(promises).then(function(array){ + // The array here would be [ 1, 2, 3 ]; + }); + ``` + + If any of the `promises` given to `all` are rejected, the first promise + that is rejected will be given as an argument to the returned promises's + rejection handler. For example: + + Example: + + ```javascript + let promise1 = resolve(1); + let promise2 = reject(new Error("2")); + let promise3 = reject(new Error("3")); + let promises = [ promise1, promise2, promise3 ]; + + Promise.all(promises).then(function(array){ + // Code here never runs because there are rejected promises! + }, function(error) { + // error.message === "2" + }); + ``` + + @method all + @static + @param {Array} entries array of promises + @param {String} label optional string for labeling the promise. + Useful for tooling. + @return {Promise} promise that is fulfilled when all `promises` have been + fulfilled, or rejected if any of them become rejected. + @static +*/ +function all(entries) { + return new Enumerator(this, entries).promise; +} + +/** + `Promise.race` returns a new promise which is settled in the same way as the + first passed promise to settle. + + Example: + + ```javascript + let promise1 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 1'); + }, 200); + }); + + let promise2 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 2'); + }, 100); + }); + + Promise.race([promise1, promise2]).then(function(result){ + // result === 'promise 2' because it was resolved before promise1 + // was resolved. + }); + ``` + + `Promise.race` is deterministic in that only the state of the first + settled promise matters. For example, even if other promises given to the + `promises` array argument are resolved, but the first settled promise has + become rejected before the other promises became fulfilled, the returned + promise will become rejected: + + ```javascript + let promise1 = new Promise(function(resolve, reject){ + setTimeout(function(){ + resolve('promise 1'); + }, 200); + }); + + let promise2 = new Promise(function(resolve, reject){ + setTimeout(function(){ + reject(new Error('promise 2')); + }, 100); + }); + + Promise.race([promise1, promise2]).then(function(result){ + // Code here never runs + }, function(reason){ + // reason.message === 'promise 2' because promise 2 became rejected before + // promise 1 became fulfilled + }); + ``` + + An example real-world use case is implementing timeouts: + + ```javascript + Promise.race([ajax('foo.json'), timeout(5000)]) + ``` + + @method race + @static + @param {Array} promises array of promises to observe + Useful for tooling. + @return {Promise} a promise which settles in the same way as the first passed + promise to settle. +*/ +function race(entries) { + /*jshint validthis:true */ + var Constructor = this; + + if (!isArray(entries)) { + return new Constructor(function (_, reject) { + return reject(new TypeError('You must pass an array to race.')); + }); + } else { + return new Constructor(function (resolve, reject) { + var length = entries.length; + for (var i = 0; i < length; i++) { + Constructor.resolve(entries[i]).then(resolve, reject); + } + }); + } +} + +/** + `Promise.reject` returns a promise rejected with the passed `reason`. + It is shorthand for the following: + + ```javascript + let promise = new Promise(function(resolve, reject){ + reject(new Error('WHOOPS')); + }); + + promise.then(function(value){ + // Code here doesn't run because the promise is rejected! + }, function(reason){ + // reason.message === 'WHOOPS' + }); + ``` + + Instead of writing the above, your code now simply becomes the following: + + ```javascript + let promise = Promise.reject(new Error('WHOOPS')); + + promise.then(function(value){ + // Code here doesn't run because the promise is rejected! + }, function(reason){ + // reason.message === 'WHOOPS' + }); + ``` + + @method reject + @static + @param {Any} reason value that the returned promise will be rejected with. + Useful for tooling. + @return {Promise} a promise rejected with the given `reason`. +*/ +function reject$1(reason) { + /*jshint validthis:true */ + var Constructor = this; + var promise = new Constructor(noop); + reject(promise, reason); + return promise; +} + +function needsResolver() { + throw new TypeError('You must pass a resolver function as the first argument to the promise constructor'); +} + +function needsNew() { + throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function."); +} + +/** + Promise objects represent the eventual result of an asynchronous operation. The + primary way of interacting with a promise is through its `then` method, which + registers callbacks to receive either a promise's eventual value or the reason + why the promise cannot be fulfilled. + + Terminology + ----------- + + - `promise` is an object or function with a `then` method whose behavior conforms to this specification. + - `thenable` is an object or function that defines a `then` method. + - `value` is any legal JavaScript value (including undefined, a thenable, or a promise). + - `exception` is a value that is thrown using the throw statement. + - `reason` is a value that indicates why a promise was rejected. + - `settled` the final resting state of a promise, fulfilled or rejected. + + A promise can be in one of three states: pending, fulfilled, or rejected. + + Promises that are fulfilled have a fulfillment value and are in the fulfilled + state. Promises that are rejected have a rejection reason and are in the + rejected state. A fulfillment value is never a thenable. + + Promises can also be said to *resolve* a value. If this value is also a + promise, then the original promise's settled state will match the value's + settled state. So a promise that *resolves* a promise that rejects will + itself reject, and a promise that *resolves* a promise that fulfills will + itself fulfill. + + + Basic Usage: + ------------ + + ```js + let promise = new Promise(function(resolve, reject) { + // on success + resolve(value); + + // on failure + reject(reason); + }); + + promise.then(function(value) { + // on fulfillment + }, function(reason) { + // on rejection + }); + ``` + + Advanced Usage: + --------------- + + Promises shine when abstracting away asynchronous interactions such as + `XMLHttpRequest`s. + + ```js + function getJSON(url) { + return new Promise(function(resolve, reject){ + let xhr = new XMLHttpRequest(); + + xhr.open('GET', url); + xhr.onreadystatechange = handler; + xhr.responseType = 'json'; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.send(); + + function handler() { + if (this.readyState === this.DONE) { + if (this.status === 200) { + resolve(this.response); + } else { + reject(new Error('getJSON: `' + url + '` failed with status: [' + this.status + ']')); + } + } + }; + }); + } + + getJSON('/posts.json').then(function(json) { + // on fulfillment + }, function(reason) { + // on rejection + }); + ``` + + Unlike callbacks, promises are great composable primitives. + + ```js + Promise.all([ + getJSON('/posts'), + getJSON('/comments') + ]).then(function(values){ + values[0] // => postsJSON + values[1] // => commentsJSON + + return values; + }); + ``` + + @class Promise + @param {Function} resolver + Useful for tooling. + @constructor +*/ + +var Promise$1 = function () { + function Promise(resolver) { + this[PROMISE_ID] = nextId(); + this._result = this._state = undefined; + this._subscribers = []; + + if (noop !== resolver) { + typeof resolver !== 'function' && needsResolver(); + this instanceof Promise ? initializePromise(this, resolver) : needsNew(); + } + } + + /** + The primary way of interacting with a promise is through its `then` method, + which registers callbacks to receive either a promise's eventual value or the + reason why the promise cannot be fulfilled. + ```js + findUser().then(function(user){ + // user is available + }, function(reason){ + // user is unavailable, and you are given the reason why + }); + ``` + Chaining + -------- + The return value of `then` is itself a promise. This second, 'downstream' + promise is resolved with the return value of the first promise's fulfillment + or rejection handler, or rejected if the handler throws an exception. + ```js + findUser().then(function (user) { + return user.name; + }, function (reason) { + return 'default name'; + }).then(function (userName) { + // If `findUser` fulfilled, `userName` will be the user's name, otherwise it + // will be `'default name'` + }); + findUser().then(function (user) { + throw new Error('Found user, but still unhappy'); + }, function (reason) { + throw new Error('`findUser` rejected and we're unhappy'); + }).then(function (value) { + // never reached + }, function (reason) { + // if `findUser` fulfilled, `reason` will be 'Found user, but still unhappy'. + // If `findUser` rejected, `reason` will be '`findUser` rejected and we're unhappy'. + }); + ``` + If the downstream promise does not specify a rejection handler, rejection reasons will be propagated further downstream. + ```js + findUser().then(function (user) { + throw new PedagogicalException('Upstream error'); + }).then(function (value) { + // never reached + }).then(function (value) { + // never reached + }, function (reason) { + // The `PedgagocialException` is propagated all the way down to here + }); + ``` + Assimilation + ------------ + Sometimes the value you want to propagate to a downstream promise can only be + retrieved asynchronously. This can be achieved by returning a promise in the + fulfillment or rejection handler. The downstream promise will then be pending + until the returned promise is settled. This is called *assimilation*. + ```js + findUser().then(function (user) { + return findCommentsByAuthor(user); + }).then(function (comments) { + // The user's comments are now available + }); + ``` + If the assimliated promise rejects, then the downstream promise will also reject. + ```js + findUser().then(function (user) { + return findCommentsByAuthor(user); + }).then(function (comments) { + // If `findCommentsByAuthor` fulfills, we'll have the value here + }, function (reason) { + // If `findCommentsByAuthor` rejects, we'll have the reason here + }); + ``` + Simple Example + -------------- + Synchronous Example + ```javascript + let result; + try { + result = findResult(); + // success + } catch(reason) { + // failure + } + ``` + Errback Example + ```js + findResult(function(result, err){ + if (err) { + // failure + } else { + // success + } + }); + ``` + Promise Example; + ```javascript + findResult().then(function(result){ + // success + }, function(reason){ + // failure + }); + ``` + Advanced Example + -------------- + Synchronous Example + ```javascript + let author, books; + try { + author = findAuthor(); + books = findBooksByAuthor(author); + // success + } catch(reason) { + // failure + } + ``` + Errback Example + ```js + function foundBooks(books) { + } + function failure(reason) { + } + findAuthor(function(author, err){ + if (err) { + failure(err); + // failure + } else { + try { + findBoooksByAuthor(author, function(books, err) { + if (err) { + failure(err); + } else { + try { + foundBooks(books); + } catch(reason) { + failure(reason); + } + } + }); + } catch(error) { + failure(err); + } + // success + } + }); + ``` + Promise Example; + ```javascript + findAuthor(). + then(findBooksByAuthor). + then(function(books){ + // found books + }).catch(function(reason){ + // something went wrong + }); + ``` + @method then + @param {Function} onFulfilled + @param {Function} onRejected + Useful for tooling. + @return {Promise} + */ + + /** + `catch` is simply sugar for `then(undefined, onRejection)` which makes it the same + as the catch block of a try/catch statement. + ```js + function findAuthor(){ + throw new Error('couldn't find that author'); + } + // synchronous + try { + findAuthor(); + } catch(reason) { + // something went wrong + } + // async with promises + findAuthor().catch(function(reason){ + // something went wrong + }); + ``` + @method catch + @param {Function} onRejection + Useful for tooling. + @return {Promise} + */ + + + Promise.prototype.catch = function _catch(onRejection) { + return this.then(null, onRejection); + }; + + /** + `finally` will be invoked regardless of the promise's fate just as native + try/catch/finally behaves + + Synchronous example: + + ```js + findAuthor() { + if (Math.random() > 0.5) { + throw new Error(); + } + return new Author(); + } + + try { + return findAuthor(); // succeed or fail + } catch(error) { + return findOtherAuther(); + } finally { + // always runs + // doesn't affect the return value + } + ``` + + Asynchronous example: + + ```js + findAuthor().catch(function(reason){ + return findOtherAuther(); + }).finally(function(){ + // author was either found, or not + }); + ``` + + @method finally + @param {Function} callback + @return {Promise} + */ + + + Promise.prototype.finally = function _finally(callback) { + var promise = this; + var constructor = promise.constructor; + + if (isFunction(callback)) { + return promise.then(function (value) { + return constructor.resolve(callback()).then(function () { + return value; + }); + }, function (reason) { + return constructor.resolve(callback()).then(function () { + throw reason; + }); + }); + } + + return promise.then(callback, callback); + }; + + return Promise; +}(); + +Promise$1.prototype.then = then; +Promise$1.all = all; +Promise$1.race = race; +Promise$1.resolve = resolve$1; +Promise$1.reject = reject$1; +Promise$1._setScheduler = setScheduler; +Promise$1._setAsap = setAsap; +Promise$1._asap = asap; + +/*global self*/ +function polyfill() { + var local = void 0; + + if (typeof global !== 'undefined') { + local = global; + } else if (typeof self !== 'undefined') { + local = self; + } else { + try { + local = Function('return this')(); + } catch (e) { + throw new Error('polyfill failed because global object is unavailable in this environment'); + } + } + + var P = local.Promise; + + if (P) { + var promiseToString = null; + try { + promiseToString = Object.prototype.toString.call(P.resolve()); + } catch (e) { + // silently ignored + } + + if (promiseToString === '[object Promise]' && !P.cast) { + return; + } + } + + local.Promise = Promise$1; +} + +// Strange compat.. +Promise$1.polyfill = polyfill; +Promise$1.Promise = Promise$1; + +return Promise$1; + +}))); + + + +//# sourceMappingURL=es6-promise.map diff --git a/testdata/package.json b/testdata/package.json new file mode 100644 index 0000000..d0b7ae7 --- /dev/null +++ b/testdata/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "dependencies": { + "@babel/standalone": "^7.5.5", + "assert": "^2.0.0", + "assert-polyfill": "^0.0.0", + "buffer": "^5.2.1", + "core-js-bundle": "^3.1.4", + "error-polyfill": "^0.1.2", + "process": "^0.11.10", + "readable-stream": "^3.6.0", + "regenerator-runtime": "^0.13.3", + "regenerator-transform": "^0.14.1" + } +} diff --git a/vm/require.go b/vm/require.go index 07812ac..df770a9 100644 --- a/vm/require.go +++ b/vm/require.go @@ -146,18 +146,21 @@ func require(runtime *goja.Runtime, parent *Module, stack []string) func(goja.Fu } } +// A Module is a javascript module identified by a name and full path. type Module struct { Name string Path string Main string Body string + // etag is the sha256 hash of the original file. etag [sha256.Size]byte prog *goja.Program root *Module registry *Registry + // value is the evaluated value in the currently running VM. value *goja.Object } @@ -224,7 +227,7 @@ func (m *Module) requireRelative(name string) (*Module, error) { } func (m *Module) FullPath() string { - return filepath.Clean(filepath.Join(m.Path, m.Name)) + return filepath.Clean(filepath.Join(m.Path, m.Main)) } func (m *Module) String() string { diff --git a/vm/result.go b/vm/result.go new file mode 100644 index 0000000..a80521d --- /dev/null +++ b/vm/result.go @@ -0,0 +1,354 @@ +package vm + +import ( + "fmt" + "math/rand" + "regexp" + "time" + + "github.com/dop251/goja" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +var ErrExecutionCancelled = errors.New("execution cancelled") + +// A Result is the output from executing synchronous code on a VM. +type Result struct { + // Closed when the result is ready. Read from this channel to detect when + // the result has been populated and is safe to inspect. + Ready chan struct{} + // Error associated with the result, if any. Only read from this after + // the result is ready. + Error error + // Value associated with the result if there is no error. Only read from + // this after the result is ready. + Value goja.Value + + // vmdone is a copy of the VM's done channel at the time Run* is called. + // This removes the need to synchronize when reading from the channel + // since the copy is made while the VM is locked. + vmdone chan struct{} + // cancel is closed to signal that the result is no longer needed. + cancel chan struct{} +} + +// resolve populates the result with the given value or error and signals ready. +func newResult(vmdone chan struct{}) *Result { + r := &Result{Ready: make(chan struct{}), cancel: make(chan struct{}), vmdone: vmdone} + go func() { + for { + select { + case <-r.Ready: + // close the cancel channel if we need to + r.Cancel() + return + + case <-r.cancel: + // signal to cancel received, resolve with an error + r.resolve(nil, ErrExecutionCancelled) + + case <-r.vmdone: + // VM shutdown without resolving, cancel execution + r.Cancel() + } + } + }() + return r +} + +// resolve populates the result with the given value or error and signals ready. +func (r *Result) resolve(v goja.Value, err error) { + select { + case <-r.Ready: + fmt.Println("resolve called on already finished Result") + + default: + r.Error = err + r.Value = v + close(r.Ready) + } +} + +// Await blocks until the result is ready and returns the result or error. +func (r *Result) Await() (goja.Value, error) { + <-r.Ready + return r.Value, r.Error +} + +// Cancel the result to halt execution. +func (r *Result) Cancel() { + select { + case <-r.cancel: + // already cancelled, don't bother + + default: + close(r.cancel) + } +} + +// runFunc is a proxy for the Do method on a VM. +type runFunc func(func(*goja.Runtime)) + +// AsyncResult handles invocations of asynchronous code that returns promises. +// An AsyncResult accepts any goja.Value; non-promises are supported so this +// is safe (if maybe a bit inefficient) to wrap all results produced by using +// one of the Run* methods on a VM. +type AsyncResult struct { + // Closed when the result is ready. Read from this channel to detect when + // the result has been populated and is safe to inspect. + Ready chan struct{} + // Error associated with the result, if any. Only read from this after + // the result is ready. + Error error + // Value associated with the result if there is no error. Only read from + // this after the result is ready. + Value goja.Value + + // syncResult contains the original synchronous result. + // Its value may contain a Promise although other types are also handled. + syncResult *Result + + // Unique javascript variable containing the result. + stateVar string + + // vmdone is a copy of the VM's done channel at the time Run* is called. + // This removes the need to synchronize when reading from the channel + // since the copy is made while the VM is locked. + vmdone chan struct{} + // vmdo will be a pointer to the run method on a scheduler. + // This isn't strictly necessary (a pointer to VM would be fine) but + // this forced indirection reduces the chance of a Result trying to do + // something it shouldn't. + vmdo runFunc + + // cancel is closed to signal that the result is no longer needed. + cancel chan struct{} + // waiting is initialized in the run method and used to synchronize + // the result-ready check. + waiting chan struct{} + // done is closed when the run method returns. + // The goroutine spawned by newAsyncResult waits to return until this + // channel is closed. + done chan struct{} +} + +func uniqueResultIdentifier() string { + const chars = "abcdefghijklmnopqrstuvwxyzABZDEFGHIJKLMNOPQRSTUVWXYZ12345678890" + b := make([]byte, 20) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return "____squircy3_await_" + string(b) +} + +func newAsyncResult(sr *Result, vmdone chan struct{}, vmdo runFunc) *AsyncResult { + r := &AsyncResult{ + Ready: make(chan struct{}), + syncResult: sr, + stateVar: uniqueResultIdentifier(), + vmdo: vmdo, + vmdone: vmdone, + cancel: make(chan struct{}), + done: make(chan struct{}), + } + go func() { + // wait until the original Result is ready + <-sr.Ready + if sr.Error != nil { + // already have an error, resolve immediately + r.resolve(sr.Value, sr.Error) + return + } + // schedule the job to resolve the value + r.vmdo(r.run) + + // note that the scheduler may have been stopped since the + // original Result was ready. + + // block until the result is cancelled, the VM is shut down, + // or the result is ready. + select { + case <-r.cancel: + r.resolve(nil, ErrExecutionCancelled) + return + case <-r.vmdone: + r.resolve(nil, ErrExecutionCancelled) + return + case <-r.done: + // carry on + } + }() + return r +} + +// resolve populates the result with the given value or error and signals ready. +func (r *AsyncResult) resolve(v goja.Value, err error) { + select { + case <-r.Ready: + logrus.Warnln("resolve called on already finished Result") + + default: + r.Error = err + r.Value = v + close(r.Ready) + r.vmdo(r.cleanup) + } +} + +// Await blocks until the result is ready and returns the result or error. +func (r *AsyncResult) Await() (goja.Value, error) { + <-r.Ready + return r.Value, r.Error +} + +// Cancel the result to halt execution. +func (r *AsyncResult) Cancel() { + select { + case <-r.cancel: + // already cancelled, don't bother + + default: + close(r.cancel) + } +} + +func getOrCreateResultHandler(gr *goja.Runtime) goja.Callable { + if v := gr.Get("_squircy_handle_result"); v != nil { + fn, _ := goja.AssertFunction(v) + return fn + } + // this script handles the resolution of the Promise if the value is a + // Promise, sets the error if the value is an Error, or sets the result + // as the value for anything else. + v, err := gr.RunString(` +var handler = function(state) { + if(typeof Promise !== 'undefined' && state.value instanceof Promise) { + state.value + .then(function(result) { state.result = result; }) + .catch(function(error) { state.error = error; }) + .finally(function() { state.done = true; }); + } else if(state.error instanceof Error) { + state.error = state.value; + state.done = true; + } else { + state.result = state.value; + state.done = true; + } +} +handler;`) + if err != nil { + logrus.Warnln("unable to set async result handler:", err.Error()) + return nil + } + gr.Set("____squircy3_handle_result", v) + fn, _ := goja.AssertFunction(v) + return fn +} + +func (r *AsyncResult) run(gr *goja.Runtime) { + defer func() { + select { + case <-r.done: + // already closed + default: + close(r.done) + } + }() + o := gr.NewObject() + _ = o.Set("value", r.syncResult.Value) + _ = o.Set("result", goja.Undefined()) + _ = o.Set("error", goja.Undefined()) + _ = o.Set("done", false) + gr.Set(r.stateVar, o) + hr := getOrCreateResultHandler(gr) + if hr == nil { + r.resolve(nil, errors.New("unable to get result handler")) + return + } + _, err := hr(nil, gr.Get(r.stateVar)) + if err != nil { + r.resolve(nil, err) + return + } + go r.loop() +} + +func (r *AsyncResult) cleanup(gr *goja.Runtime) { + gr.Set(r.stateVar, goja.Undefined()) +} + +func (r *AsyncResult) loop() { + defer func() { + select { + case <-r.Ready: + // already closed, no need to close it again + return + default: + close(r.Ready) + } + }() + // delay is how long to wait until we check for a result + delay := 10 * time.Microsecond + for { + if delay < 100*time.Millisecond { + // backoff sharply at first but stop at 100ms between checks + delay = delay * 10 + } + select { + case <-r.cancel: + r.Error = errors.New("cancelled") + return + case <-r.vmdone: + // VM shutdown without resolving, cancel execution + r.Cancel() + continue + case <-time.After(delay): + r.waiting = make(chan struct{}) + r.vmdo(r.check) + <-r.waiting + } + select { + case <-r.Ready: + return + default: + } + } +} + +func (r *AsyncResult) check(gr *goja.Runtime) { + defer close(r.waiting) + o := gr.Get(r.stateVar).ToObject(gr) + if !o.Get("done").ToBoolean() { + // result is not yet ready + return + } + // result is ready, get it out of the vm + var res goja.Value + var err error + v := o.Get("error") + if goja.IsUndefined(v) { + res = o.Get("result") + } else { + if cv, ok := v.Export().(error); ok { + err = cv + } else { + // seems like many errors will not actually get exported to error interface, + // so this matches the string representation of the value if it looks like + // an error message. + // this will match strings like: + // Error: some message + // TypeError: some message + // Exception: hello, world + // SomeException: hi there + vs := v.String() + if ok, err2 := regexp.MatchString("^([a-zA-Z0-9]+?)?(Error|Exception):", vs); err2 == nil && ok { + err = errors.New(vs) + } + } + if err == nil { + err = errors.Errorf("received non-Error from rejected Promise: %s %s", v.String(), v.ExportType()) + } + } + r.resolve(res, err) +} diff --git a/vm/result_test.go b/vm/result_test.go new file mode 100644 index 0000000..36e96ac --- /dev/null +++ b/vm/result_test.go @@ -0,0 +1,85 @@ +package vm_test + +import ( + "testing" + + "code.dopame.me/veonik/squircy3/vm" +) + +var registry = vm.NewRegistry("../testdata") + +func TestAsyncResult_withNonPromise(t *testing.T) { + v, err := vm.New(registry) + if err != nil { + t.Fatalf("unexpected error creating VM: %s", err) + } + if err := v.Start(); err != nil { + t.Errorf("failed to start v: %s", err) + return + } + res, err := v.RunString(`"hello, world!"`).Await() + if err != nil { + t.Errorf("failed to run script: %s", err) + return + } + if res.String() != "hello, world!" { + t.Errorf("expected: hello, world!\ngot: %s", res.String()) + return + } +} + +func TestAsyncResult_withPromise(t *testing.T) { + v, err := vm.New(registry) + if err != nil { + t.Fatalf("unexpected error creating VM: %s", err) + } + if err := v.Start(); err != nil { + t.Errorf("failed to start v: %s", err) + return + } + res, err := v.RunString(` +this.Promise = require('./es6-promise').Promise; +new Promise(function(resolve) { resolve("hello, world!"); });`).Await() + if err != nil { + t.Errorf("failed to run script: %s", err) + return + } + if res.String() != "hello, world!" { + t.Errorf("expected: hello, world!\ngot: %s", res.String()) + return + } +} + +func TestAsyncResult_StopVM(t *testing.T) { + v, err := vm.New(registry) + if err != nil { + t.Fatalf("unexpected error creating VM: %s", err) + } + if err := v.Start(); err != nil { + t.Errorf("failed to start v: %s", err) + return + } + r := v.RunString(` +this.Promise = require('./es6-promise').Promise; +new Promise(function(resolve) { + setTimeout(function() { + console.log('wot'); + resolve("hello, world!"); + }, 100); +});`) + // <-time.After(10 * time.Millisecond) + if err := v.Shutdown(); err != nil { + t.Errorf("failed to shutdown VM: %s", err) + return + } + res, err := r.Await() + if err == nil { + t.Errorf("expected error, got nil") + t.Errorf("value: %T %T", res, res.Export()) + return + } + if err.Error() != "execution cancelled" { + t.Errorf("expected: cancelled\ngot: %s", err.Error()) + return + } +} diff --git a/vm/scheduler.go b/vm/scheduler.go index 36c9408..ff92a18 100644 --- a/vm/scheduler.go +++ b/vm/scheduler.go @@ -1,7 +1,6 @@ package vm import ( - "fmt" "sync" "time" @@ -10,14 +9,14 @@ import ( "github.com/sirupsen/logrus" ) -// Runtime is a wrapper for goja that intends to increase concurrency safety. -type Runtime struct { +// runtime is a wrapper for goja that intends to increase concurrency safety. +type runtime struct { inner *goja.Runtime mu sync.Mutex } -func (r *Runtime) Do(fn func(*goja.Runtime)) { +func (r *runtime) do(fn func(*goja.Runtime)) { r.mu.Lock() defer r.mu.Unlock() fn(r.inner) @@ -36,7 +35,7 @@ type deferredJob struct { // scheduler handles the javascript event loop and evaluating javascript code. type scheduler struct { - runtime *Runtime + runtime *runtime registry *Registry jobs chan job @@ -119,7 +118,7 @@ func (s *scheduler) worker() { case <-done: return case j := <-s.jobs: - s.runtime.Do(j) + s.runtime.do(j) } } } @@ -128,6 +127,15 @@ func (s *scheduler) run(j job) { s.jobs <- j } +func (s *scheduler) interrupt(v interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running { + return + } + s.runtime.inner.Interrupt(v) +} + func (s *scheduler) start() error { s.mu.Lock() defer s.mu.Unlock() @@ -135,7 +143,8 @@ func (s *scheduler) start() error { return errors.New("already started") } s.done = make(chan struct{}) - s.runtime = &Runtime{inner: goja.New()} + s.runtime = &runtime{inner: goja.New()} + // s.runtime.inner.SetFieldNameMapper(goja.UncapField()) s.running = true s.run(func(r *goja.Runtime) { err := s.initRuntime() @@ -151,21 +160,44 @@ func (s *scheduler) stop() error { if !s.running { return errors.New("not started") } - s.run(func(*goja.Runtime) { + stop := func(gr *goja.Runtime) { + // actually change the state inside this job + // after this is executed, no further jobs will run s.mu.Lock() defer s.mu.Unlock() s.running = false close(s.done) - }) + } + s.run(stop) + select { + case <-time.After(500 * time.Millisecond): + // soft timeout, try emptying the jobs queue and interrupting execution + logrus.Warnln("vm soft time out expired, flushing remaining jobs without done them") + s.drain() + s.runtime.inner.Interrupt("vm is shutting down") + // requeue the stop job since we just flushed it down the drain + s.run(stop) + + case <-s.done: + return nil + } select { case <-time.After(time.Second): + // hard time out, give up return errors.New("timed out waiting to stop") - case <-s.done: return nil } } +// drain empties the jobs channel. +func (s *scheduler) drain() { + for len(s.jobs) > 0 { + <-s.jobs + } +} + +// deferred defers a function invocation. func (s *scheduler) deferred(call goja.FunctionCall, repeating bool) goja.Value { if fn, ok := goja.AssertFunction(call.Argument(0)); ok { delay := call.Argument(1).ToInteger() @@ -191,7 +223,7 @@ func newDeferred(s *scheduler, fn goja.Callable, delay time.Duration, repeat boo case <-time.After(delay): s.run(func(*goja.Runtime) { if _, err := t.fn(nil, t.args...); err != nil { - fmt.Println(err) + logrus.Errorln("error handling deferred job:", err) } }) diff --git a/vm/vm.go b/vm/vm.go index 98bdd06..dbfd8b5 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -1,7 +1,6 @@ package vm // import "code.dopame.me/veonik/squircy3/vm" import ( - "fmt" "sync" "time" @@ -14,8 +13,7 @@ type VM struct { registry *Registry scheduler *scheduler - babel bool - + // done is initialized when the VM is started and closed when it is stopped. done chan struct{} mu sync.Mutex } @@ -61,7 +59,7 @@ func (vm *VM) Start() error { if vm.done != nil { select { case <-vm.done: - // closed; not running, nothing to do + // closed; not done, nothing to do default: return nil } @@ -87,102 +85,25 @@ func (vm *VM) Shutdown() error { select { case <-done: // all done, nothing to do - case <-time.After(time.Second): + case <-time.After(2 * time.Second): return errors.New("timed out waiting for vm to shutdown") } return err } -// Result is the output from executing some code in the VM. -type Result struct { - // Closed when the result is ready. Read from this channel to detect when - // the result has been populated and is safe to inspect. - Ready chan struct{} - // Error associated with the result, if any. Only read from this after - // the result is ready. - Error error - // Value associated with the result if there is no error. Only read from - // this after the result is ready. - Value goja.Value - - vmdone chan struct{} - cancel chan struct{} -} - -func newResult(vm *VM) *Result { - r := &Result{Ready: make(chan struct{}), cancel: make(chan struct{}), vmdone: vm.done} - go func() { - for { - select { - case <-r.Ready: - // close the cancel channel if we need to - select { - case <-r.cancel: - // do nothing - - default: - close(r.cancel) - } - return - - case <-r.cancel: - // signal to cancel received, resolve with an error - r.resolve(nil, errors.New("execution cancelled")) - - case <-r.vmdone: - // VM shutdown without resolving, cancel execution - close(r.cancel) - } - } - }() - return r -} - -// resolve populates the result with the given value or error. -func (r *Result) resolve(v goja.Value, err error) { - select { - case <-r.Ready: - fmt.Println("resolve called on already finished Result") - - default: - r.Error = err - r.Value = v - close(r.Ready) - } -} - -// Await blocks until the result is ready and returns the result or error. -func (r *Result) Await() (goja.Value, error) { - <-r.Ready - return r.Value, r.Error -} - -// Cancel the result to halt execution. -func (r *Result) Cancel() { - select { - case <-r.cancel: - // already cancelled, don't bother - - default: - close(r.cancel) - } +func (vm *VM) doneChan() chan struct{} { + vm.mu.Lock() + defer vm.mu.Unlock() + return vm.done } -func (vm *VM) RunString(in string) *Result { - res := newResult(vm) - vm.scheduler.run(func(r *goja.Runtime) { - p, err := vm.Compile("", in) - if err != nil { - res.resolve(nil, err) - } else { - res.resolve(r.RunProgram(p)) - } - }) - return res +func (vm *VM) RunString(in string) *AsyncResult { + return vm.RunScript("", in) } -func (vm *VM) RunScript(name, in string) *Result { - res := newResult(vm) +func (vm *VM) RunScript(name, in string) *AsyncResult { + vmdone := vm.doneChan() + res := newResult(vmdone) vm.scheduler.run(func(r *goja.Runtime) { p, err := vm.Compile(name, in) if err != nil { @@ -191,15 +112,16 @@ func (vm *VM) RunScript(name, in string) *Result { res.resolve(r.RunProgram(p)) } }) - return res + return newAsyncResult(res, vmdone, vm.Do) } -func (vm *VM) RunProgram(p *goja.Program) *Result { - res := newResult(vm) +func (vm *VM) RunProgram(p *goja.Program) *AsyncResult { + vmdone := vm.doneChan() + res := newResult(vmdone) vm.scheduler.run(func(r *goja.Runtime) { res.resolve(r.RunProgram(p)) }) - return res + return newAsyncResult(res, vmdone, vm.Do) } func (vm *VM) Do(fn func(*goja.Runtime)) { diff --git a/vm/vm_test.go b/vm/vm_test.go index a948833..5b9c131 100644 --- a/vm/vm_test.go +++ b/vm/vm_test.go @@ -1,6 +1,7 @@ package vm_test import ( + "strings" "testing" "time" @@ -51,3 +52,30 @@ func TestVM_Restart(t *testing.T) { return } } + +func TestVM_Shutdown_interrupts(t *testing.T) { + v, err := vm.New(vm.NewRegistry(".")) + if err != nil { + t.Errorf("failed to create v: %s", err) + return + } + if err := v.Start(); err != nil { + t.Errorf("failed to start v: %s", err) + return + } + res := v.RunString("for (;;) {}") + err = v.Shutdown() + if err != nil { + t.Errorf("error shutting down: %s", err) + return + } + _, err = res.Await() + if err == nil { + t.Errorf("expected error to exist, got nil") + return + } + expect := "vm is shutting down" + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain '" + expect + "'\ngot: " + err.Error()) + } +}