eventloop

package
v0.1.0-alpha.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package eventloop runs a moejs Runtime on an event loop: the Node.js timers (setTimeout, setInterval, setImmediate and their clear functions) and host work whose result comes back from another goroutine.

A bare Runtime has no timers, and the settle functions of Runtime.NewPromise may only be called from the goroutine that uses the runtime. A Loop owns its runtime while it runs, and is the one goroutine that uses it then:

rt := moejs.NewRuntime(moejs.Options{})
loop, err := eventloop.New(rt, eventloop.Options{})
err = loop.Run(func(rt *moejs.Runtime) error {
	_, err := rt.RunScript(script) // may call setTimeout
	return err
})

Run returns once nothing keeps the loop alive: no timer or immediate that is ref'd, no Hold not yet done, no promise of NewPromise not yet settled and no task not yet run. Other goroutines hand work to the loop with RunOnLoop, and a host function whose result comes from another goroutine returns a promise of NewPromise. Between runs the runtime is the host's again.

Phases

One iteration of the loop has three phases, as Node's timers, poll and check phases: the timers due when the phase starts, in order of expiry, then of scheduling; the tasks posted before the phase started (RunOnLoop, the done function of Hold, the settle function of NewPromise); the immediates set before the phase started. The promise jobs and queueMicrotask callbacks a callback or task queues run when it returns, before the next one. With nothing to run, the loop sleeps until the next timer is due or a task is posted.

Timers

The timer functions follow Node.js:

  • setTimeout and setInterval return a Timeout object with the methods ref, unref, hasRef, refresh, close and Symbol.toPrimitive, which returns the timer's numeric id; setImmediate returns an Immediate with ref, unref and hasRef.
  • The callback gets the extra arguments, and the Timeout or Immediate as this. A callback that is not a function is a TypeError with the code ERR_INVALID_ARG_TYPE; a string is not evaluated.
  • The delay goes through ToNumber and is truncated to whole milliseconds. NaN, a delay below 1 and one above 2147483647 are 1 ms, so a timer set by a callback runs in a later iteration.
  • An interval is rescheduled for its delay after the time its callback started. refresh restarts a timer's delay from now, and runs a timeout that already fired again; a cleared timer stays cleared.
  • clearTimeout and clearInterval take a Timeout, or the id its Symbol.toPrimitive returned, as a number or a string, once that was read; clearImmediate takes an Immediate. They ignore anything else, and work inside the timer's own callback.
  • An unref'd timer or immediate does not keep Run alive, and runs only while something else does.

Differences from Node: the Timeout and Immediate objects have no own properties (JSON.stringify gives {}, and constructor.name is "Object"), their methods throw a TypeError when called on another object, a Proxy of one included, and a Timeout has no Symbol.dispose method. Timers expire in the order of their exact due times, where Node compares whole milliseconds. timers/promises, the AbortSignal options, util.promisify and Node's warning for a delay above 2147483647 are not provided.

Errors and interrupts

A callback's exception stops the loop, as an uncaught exception ends a Node.js process, unless Options.OnError takes it. An interrupt always stops it, before it takes the next timer, task or immediate off its queues, which keep them. A loop is interrupted through Loop.Interrupt: Runtime.Interrupt alone stops the JavaScript running but does not wake a loop that sleeps. A rejection no handler catches is the host's business, through Runtime.SetPromiseRejectionTracker.

Memory limit

Options.MemoryLimit of the runtime bounds each macrotask: the loop resets the runtime's allocation count (Runtime.ResetAllocation) before each timer or immediate callback and each task, so the promise jobs a callback queues count with it. A callback that passes the limit stops the loop with its *moejs.InterruptedError, as any interrupt does. The limit bounds what one macrotask allocates, not what the loop's callbacks keep alive between them.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrTerminated is Run's and Start's error once Terminate ended the
	// loop, and the value Terminate interrupts the runtime with.
	ErrTerminated = errors.New("eventloop: loop terminated")
	// ErrRunning is Run's and Start's error while the loop already runs,
	// started by Run or Start.
	ErrRunning = errors.New("eventloop: loop is already running")
)

Functions

This section is empty.

Types

type Loop

type Loop struct {
	// contains filtered or unexported fields
}

Loop is an event loop that owns one Runtime while it runs. Its methods may be called from any goroutine, except NewPromise, which a host function calls on the loop, and Stop and Terminate, which must not be called on the loop: they wait for it. On the loop means from Run's fn, a callback, a task, a host function JavaScript calls there, or OnError. A runtime has at most one Loop.

func New

func New(rt *moejs.Runtime, opts Options) (*Loop, error)

New creates a loop for rt and installs setTimeout, clearTimeout, setInterval, clearInterval, setImmediate and clearImmediate as globals of rt, as the package documentation describes. The error is SetGlobal's.

func (*Loop) Hold

func (l *Loop) Hold() (done func(fn func(*moejs.Runtime)))

Hold keeps the loop alive for host work in progress, until done is called. done may be called from any goroutine: it posts fn to the loop as RunOnLoop does (fn may be nil) and releases the hold. The first call counts and later calls do nothing; after Terminate, done drops fn. Hold may be called from any goroutine, but only a hold taken on the loop (in a host function or a callback) keeps a running Run from returning: one taken on another goroutine races with a Run that has nothing else left, and almost always loses it, so that it only keeps the next Run alive.

func (*Loop) Interrupt

func (l *Loop) Interrupt(v any)

Interrupt interrupts the runtime (Runtime.Interrupt with v) and wakes the loop, which Runtime.Interrupt alone leaves asleep until its next timer or task. The JavaScript running stops, and the loop stops before it takes another timer, task or immediate off its queues, which keep them: Run returns the *moejs.InterruptedError, and Stop for a loop started with Start. As after Runtime.Interrupt, the interrupt stays pending, so the host calls Runtime.ClearInterrupt before it runs the loop or uses the runtime again. Interrupt may be called from any goroutine, the loop included.

func (*Loop) NewPromise

func (l *Loop) NewPromise() (p moejs.Value, settle func(f func(*moejs.Runtime) (moejs.Value, error)))

NewPromise creates a pending promise for a host function to return, and keeps the loop alive until settle is called. It is called on the loop. settle may be called from any goroutine: the first call posts f to the loop, which settles the promise with what f returns there, and later calls do nothing. A nil error fulfills it with the value, or adopts the value when it is a thenable; any other error rejects it with the value JavaScript would catch from a host function that returned the error (Runtime.ThrownValue). An *moejs.InterruptedError leaves it pending and stops the loop. A nil f fulfills it with undefined. The jobs settling queues run before the loop goes on; what they throw, and what settling fails with (moejs.ErrForeign), goes to Options.OnError.

Example

ExampleLoop_NewPromise gives JavaScript an asynchronous host function: it returns a promise of NewPromise and does its work on another goroutine, which settles the promise on the loop. The Go error of a failed lookup rejects it with an Error.

package main

import (
	"errors"
	"fmt"

	"github.com/Calcium-Ion/moejs"
	"github.com/Calcium-Ion/moejs/eventloop"
)

func main() {
	rt := moejs.NewRuntime(moejs.Options{})
	loop, err := eventloop.New(rt, eventloop.Options{})
	if err != nil {
		panic(err)
	}
	err = rt.SetGlobal("lookup", moejs.NativeFunc(func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
		name := moejs.Arg(args, 0).String()
		p, settle := loop.NewPromise()
		go func() {
			// Blocking work, such as I/O, runs off the loop.
			var err error
			if name == "" {
				err = errors.New("empty name")
			}
			settle(func(rt *moejs.Runtime) (moejs.Value, error) {
				if err != nil {
					return moejs.Undefined(), err
				}
				return rt.FromGo(map[string]any{"name": name, "length": len(name)})
			})
		}()
		return p, nil
	}))
	if err != nil {
		panic(err)
	}
	mod, err := moejs.Compile("plugin.js", `export async function describe(name) {
	try {
		const r = await lookup(name);
		return r.name + " has " + r.length + " letters";
	} catch (e) {
		return "failed: " + e.message;
	}
}`)
	if err != nil {
		panic(err)
	}
	describe, err := mod.Hook("describe")
	if err != nil {
		panic(err)
	}
	var results []moejs.Value
	err = loop.Run(func(rt *moejs.Runtime) error {
		if err := rt.Load(mod); err != nil {
			return err
		}
		for _, name := range []string{"moejs", ""} {
			p, err := rt.Call(describe, moejs.String(name))
			if err != nil {
				return err
			}
			results = append(results, p)
		}
		return nil
	})
	if err != nil {
		panic(err)
	}
	// Run returned: every promise of NewPromise is settled.
	for _, p := range results {
		_, v, _ := moejs.PromiseResult(p)
		fmt.Println(v.String())
	}
}
Output:
moejs has 5 letters
failed: empty name

func (*Loop) Run

func (l *Loop) Run(fn func(*moejs.Runtime) error) (err error)

Run calls fn with the runtime on the calling goroutine and then runs the loop there until nothing keeps it alive, Stop or StopNoWait ends it, or an error or an interrupt stops it (Options.OnError, Interrupt). It returns fn's error at once when fn fails, the error that stopped the loop (an *moejs.InterruptedError for an interrupt), ErrTerminated once Terminate ended it, or nil. fn may be nil. fn runs on the loop: it must not call Stop or Terminate, which would wait for it.

What is still pending when Run returns, an unref'd timer or immediate, or anything after a stop, an error or an interrupt, stays pending: the next Run or Start continues it, and JavaScript the host runs on the runtime between them (Call) can set more. The runtime is the host's again between them.

A panic in fn or in a host task (the functions of RunOnLoop, of done and of settle, and OnError) is not recovered: it unwinds through Run, or ends the program for a loop started with Start. The loop is marked stopped first, and the tasks and immediates its phase had not run yet stay queued; the task that panicked is gone, and a promise whose settle function panicked stays pending.

Example

ExampleLoop_Run runs a script that sets timers: Run returns once the last timer ran. The promise jobs of the script run before the loop starts.

package main

import (
	"fmt"

	"github.com/Calcium-Ion/moejs"
	"github.com/Calcium-Ion/moejs/eventloop"
)

func main() {
	rt := moejs.NewRuntime(moejs.Options{})
	loop, err := eventloop.New(rt, eventloop.Options{})
	if err != nil {
		panic(err)
	}
	err = rt.SetGlobal("print", moejs.NativeFunc(func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
		fmt.Println(moejs.Arg(args, 0).String())
		return moejs.Undefined(), nil
	}))
	if err != nil {
		panic(err)
	}
	script, err := moejs.CompileScript("main.js", `
let n = 0;
const tick = setInterval(() => {
	print("tick " + ++n);
	if (n === 3) clearInterval(tick);
}, 10);
setTimeout(() => print("timeout"), 5);
Promise.resolve().then(() => print("job"));
print("script");`)
	if err != nil {
		panic(err)
	}
	err = loop.Run(func(rt *moejs.Runtime) error {
		_, err := rt.RunScript(script)
		return err
	})
	if err != nil {
		panic(err)
	}
}
Output:
script
job
timeout
tick 1
tick 2
tick 3

func (*Loop) RunOnLoop

func (l *Loop) RunOnLoop(fn func(*moejs.Runtime)) bool

RunOnLoop posts fn to the loop, which calls it with the runtime in its next tasks phase, and reports true. It may be called from any goroutine, the loop included. When the loop does not run, fn waits for the next Run or Start; a posted task keeps Run alive until it ran. RunOnLoop returns false once Terminate ended the loop, and fn is dropped.

func (*Loop) Runtime

func (l *Loop) Runtime() *moejs.Runtime

Runtime returns the loop's runtime.

func (*Loop) Start

func (l *Loop) Start() error

Start runs the loop on a new goroutine until Stop, StopNoWait or Terminate ends it, or an error stops it (Options.OnError): idle, it waits for tasks (RunOnLoop) and timers. While it runs, the host uses the runtime only through RunOnLoop. It returns ErrRunning when the loop already runs and ErrTerminated once Terminate ended it.

func (*Loop) Stop

func (l *Loop) Stop() error

Stop ends the running loop, started by Run or Start, after the callback or task it is running returns (or Run's fn), and waits until it has ended. Timers, immediates, holds and posted tasks stay pending for the next Run or Start. Stop returns what ended the run it waited for, or the last run when none was running, as Run returns it: nil, the error that stopped it (Options.OnError), the *moejs.InterruptedError of an interrupt or ErrTerminated; for a loop started with Start it is how the host learns of that error. It must not be called on the loop, where it would wait for itself; StopNoWait may be.

func (*Loop) StopNoWait

func (l *Loop) StopNoWait()

StopNoWait is Stop without the wait: the loop ends after the callback or task it is running returns. It may be called on the loop.

func (*Loop) Terminate

func (l *Loop) Terminate()

Terminate ends the loop for good: it interrupts the runtime (Runtime.Interrupt with ErrTerminated), which stops the JavaScript the loop runs, drops every pending timer, immediate and task, and waits until a running loop has ended. Run and Start then return ErrTerminated, RunOnLoop returns false, the done function of Hold and the settle function of NewPromise do nothing, and setTimeout, setInterval and setImmediate throw. The interrupt is left pending also when no JavaScript was running, so the host calls Runtime.ClearInterrupt before it uses the runtime again; a new Loop may be created for it then. It must not be called on the loop, where it would wait for itself.

type Options

type Options struct {
	// OnError receives the errors of the callbacks the loop runs: what a
	// timer or immediate callback throws, the first exception the jobs it
	// queued throw (a queueMicrotask callback's), a Go panic as an
	// *moejs.InternalError, and what the settle function of NewPromise
	// fails with. It runs on the loop, which keeps running afterwards.
	// When OnError is nil, such an error stops the loop: Run returns it,
	// or Stop for a loop started with Start, as an uncaught exception ends
	// a Node.js process. An interrupt always stops the loop and is never
	// passed to OnError.
	OnError func(err error)
}

Options configures New.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL