478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
/*
|
|
* Pure ECMAScript eventloop example.
|
|
*
|
|
* Timer state handling is inefficient in this trivial example. Timers are
|
|
* kept in an array sorted by their expiry time which works well for expiring
|
|
* timers, but has O(n) insertion performance. A better implementation would
|
|
* use a heap or some other efficient structure for managing timers so that
|
|
* all operations (insert, remove, get nearest timer) have good performance.
|
|
*
|
|
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Timers
|
|
*/
|
|
|
|
/*
|
|
* Event loop
|
|
*
|
|
* Timers are sorted by 'target' property which indicates expiry time of
|
|
* the timer. The timer expiring next is last in the array, so that
|
|
* removals happen at the end, and inserts for timers expiring in the
|
|
* near future displace as few elements in the array as possible.
|
|
*/
|
|
|
|
EventLoop = {
|
|
// timers
|
|
timers: [], // active timers, sorted (nearest expiry last)
|
|
expiring: null, // set to timer being expired (needs special handling in clearTimeout/clearInterval)
|
|
nextTimerId: 1,
|
|
minimumDelay: 1,
|
|
minimumWait: 1,
|
|
maximumWait: 60000,
|
|
maxExpirys: 10,
|
|
|
|
// sockets
|
|
socketListening: {}, // fd -> callback
|
|
socketReading: {}, // fd -> callback
|
|
socketConnecting: {}, // fd -> callback
|
|
|
|
// misc
|
|
exitRequested: false
|
|
};
|
|
|
|
EventLoop.dumpState = function() {
|
|
print('TIMER STATE:');
|
|
this.timers.forEach(function(t) {
|
|
print(' ' + Duktape.enc('jx', t));
|
|
});
|
|
if (this.expiring) {
|
|
print(' EXPIRING: ' + Duktape.enc('jx', this.expiring));
|
|
}
|
|
}
|
|
|
|
// Get timer with lowest expiry time. Since the active timers list is
|
|
// sorted, it's always the last timer.
|
|
EventLoop.getEarliestTimer = function() {
|
|
var timers = this.timers;
|
|
n = timers.length;
|
|
return (n > 0 ? timers[n - 1] : null);
|
|
}
|
|
|
|
EventLoop.getEarliestWait = function() {
|
|
var t = this.getEarliestTimer();
|
|
return (t ? t.target - Date.now() : null);
|
|
}
|
|
|
|
EventLoop.insertTimer = function(timer) {
|
|
var timers = this.timers;
|
|
var i, n, t;
|
|
|
|
/*
|
|
* Find 'i' such that we want to insert *after* timers[i] at index i+1.
|
|
* If no such timer, for-loop terminates with i-1, and we insert at -1+1=0.
|
|
*/
|
|
|
|
n = timers.length;
|
|
for (i = n - 1; i >= 0; i--) {
|
|
t = timers[i];
|
|
if (timer.target <= t.target) {
|
|
// insert after 't', to index i+1
|
|
break;
|
|
}
|
|
}
|
|
|
|
timers.splice(i + 1 /*start*/, 0 /*deleteCount*/, timer);
|
|
}
|
|
|
|
// Remove timer/interval with a timer ID. The timer/interval can reside
|
|
// either on the active list or it may be an expired timer (this.expiring)
|
|
// whose user callback we're running when this function gets called.
|
|
EventLoop.removeTimerById = function(timer_id) {
|
|
var timers = this.timers;
|
|
var i, n, t;
|
|
|
|
t = this.expiring;
|
|
if (t) {
|
|
if (t.id === timer_id) {
|
|
// Timer has expired and we're processing its callback. User
|
|
// callback has requested timer deletion. Mark removed, so
|
|
// that the timer is not reinserted back into the active list.
|
|
// This is actually a common case because an interval may very
|
|
// well cancel itself.
|
|
t.removed = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
n = timers.length;
|
|
for (i = 0; i < n; i++) {
|
|
t = timers[i];
|
|
if (t.id === timer_id) {
|
|
// Timer on active list: mark removed (not really necessary, but
|
|
// nice for dumping), and remove from active list.
|
|
t.removed = true;
|
|
this.timers.splice(i /*start*/, 1 /*deleteCount*/);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// no such ID, ignore
|
|
}
|
|
|
|
EventLoop.processTimers = function() {
|
|
var now = Date.now();
|
|
var timers = this.timers;
|
|
var sanity = this.maxExpirys;
|
|
var n, t;
|
|
|
|
/*
|
|
* Here we must be careful with mutations: user callback may add and
|
|
* delete an arbitrary number of timers.
|
|
*
|
|
* Current solution is simple: check whether the timer at the end of
|
|
* the list has expired. If not, we're done. If it has expired,
|
|
* remove it from the active list, record it in this.expiring, and call
|
|
* the user callback. If user code deletes the this.expiring timer,
|
|
* there is special handling which just marks the timer deleted so
|
|
* it won't get inserted back into the active list.
|
|
*
|
|
* This process is repeated at most maxExpirys times to ensure we don't
|
|
* get stuck forever; user code could in principle add more and more
|
|
* already expired timers.
|
|
*/
|
|
|
|
while (sanity-- > 0) {
|
|
// If exit requested, don't call any more callbacks. This allows
|
|
// a callback to do cleanups and request exit, and can be sure that
|
|
// no more callbacks are processed.
|
|
|
|
if (this.exitRequested) {
|
|
//print('exit requested, exit');
|
|
break;
|
|
}
|
|
|
|
// Timers to expire?
|
|
|
|
n = timers.length;
|
|
if (n <= 0) {
|
|
break;
|
|
}
|
|
t = timers[n - 1];
|
|
if (now <= t.target) {
|
|
// Timer has not expired, and no other timer could have expired
|
|
// either because the list is sorted.
|
|
break;
|
|
}
|
|
timers.pop();
|
|
|
|
// Remove the timer from the active list and process it. The user
|
|
// callback may add new timers which is not a problem. The callback
|
|
// may also delete timers which is not a problem unless the timer
|
|
// being deleted is the timer whose callback we're running; this is
|
|
// why the timer is recorded in this.expiring so that clearTimeout()
|
|
// and clearInterval() can detect this situation.
|
|
|
|
if (t.oneshot) {
|
|
t.removed = true; // flag for removal
|
|
} else {
|
|
t.target = now + t.delay;
|
|
}
|
|
this.expiring = t;
|
|
try {
|
|
t.cb();
|
|
} catch (e) {
|
|
print('timer callback failed, ignored: ' + e);
|
|
}
|
|
this.expiring = null;
|
|
|
|
// If the timer was one-shot, it's marked 'removed'. If the user callback
|
|
// requested deletion for the timer, it's also marked 'removed'. If the
|
|
// timer is an interval (and is not marked removed), insert it back into
|
|
// the timer list.
|
|
|
|
if (!t.removed) {
|
|
// Reinsert interval timer to correct sorted position. The timer
|
|
// must be an interval timer because one-shot timers are marked
|
|
// 'removed' above.
|
|
this.insertTimer(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
EventLoop.run = function() {
|
|
var wait;
|
|
var POLLIN = Poll.POLLIN;
|
|
var POLLOUT = Poll.POLLOUT;
|
|
var poll_set;
|
|
var poll_count;
|
|
var fd;
|
|
var t, rev;
|
|
var rc;
|
|
var acc_res;
|
|
|
|
for (;;) {
|
|
/*
|
|
* Process expired timers.
|
|
*/
|
|
|
|
this.processTimers();
|
|
//this.dumpState();
|
|
|
|
/*
|
|
* Exit check (may be requested by a user callback)
|
|
*/
|
|
|
|
if (this.exitRequested) {
|
|
//print('exit requested, exit');
|
|
break;
|
|
}
|
|
|
|
/*
|
|
* Create poll socket list. This is a very naive approach.
|
|
* On Linux, one could use e.g. epoll() and manage socket lists
|
|
* incrementally.
|
|
*/
|
|
|
|
poll_set = {};
|
|
poll_count = 0;
|
|
for (fd in this.socketListening) {
|
|
poll_set[fd] = { events: POLLIN, revents: 0 };
|
|
poll_count++;
|
|
}
|
|
for (fd in this.socketReading) {
|
|
poll_set[fd] = { events: POLLIN, revents: 0 };
|
|
poll_count++;
|
|
}
|
|
for (fd in this.socketConnecting) {
|
|
poll_set[fd] = { events: POLLOUT, revents: 0 };
|
|
poll_count++;
|
|
}
|
|
//print(new Date(), 'poll_set IN:', Duktape.enc('jx', poll_set));
|
|
|
|
/*
|
|
* Wait timeout for timer closest to expiry. Since the poll
|
|
* timeout is relative, get this as close to poll() as possible.
|
|
*/
|
|
|
|
wait = this.getEarliestWait();
|
|
if (wait === null) {
|
|
if (poll_count === 0) {
|
|
print('no active timers and no sockets to poll, exit');
|
|
break;
|
|
} else {
|
|
wait = this.maximumWait;
|
|
}
|
|
} else {
|
|
wait = Math.min(this.maximumWait, Math.max(this.minimumWait, wait));
|
|
}
|
|
|
|
/*
|
|
* Do the actual poll.
|
|
*/
|
|
|
|
try {
|
|
Poll.poll(poll_set, wait);
|
|
} catch (e) {
|
|
// Eat errors silently.
|
|
}
|
|
|
|
/*
|
|
* Process all sockets so that nothing is left unhandled for the
|
|
* next round.
|
|
*/
|
|
|
|
//print(new Date(), 'poll_set OUT:', Duktape.enc('jx', poll_set));
|
|
for (fd in poll_set) {
|
|
t = poll_set[fd];
|
|
rev = t.revents;
|
|
|
|
if (rev & POLLIN) {
|
|
cb = this.socketReading[fd];
|
|
if (cb) {
|
|
data = Socket.read(fd); // no size control now
|
|
//print('READ', Duktape.enc('jx', data));
|
|
if (data.length === 0) {
|
|
//print('zero read for fd ' + fd + ', closing forcibly');
|
|
rc = Socket.close(fd); // ignore result
|
|
delete this.socketListening[fd];
|
|
delete this.socketReading[fd];
|
|
} else {
|
|
cb(fd, data);
|
|
}
|
|
} else {
|
|
cb = this.socketListening[fd];
|
|
if (cb) {
|
|
acc_res = Socket.accept(fd);
|
|
//print('ACCEPT:', Duktape.enc('jx', acc_res));
|
|
cb(acc_res.fd, acc_res.addr, acc_res.port);
|
|
} else {
|
|
//print('UNKNOWN');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rev & POLLOUT) {
|
|
cb = this.socketConnecting[fd];
|
|
if (cb) {
|
|
delete this.socketConnecting[fd];
|
|
cb(fd);
|
|
} else {
|
|
//print('UNKNOWN POLLOUT');
|
|
}
|
|
}
|
|
|
|
if ((rev & ~(POLLIN | POLLOUT)) !== 0) {
|
|
//print('revents ' + t.revents + ' for fd ' + fd + ', closing forcibly');
|
|
rc = Socket.close(fd); // ignore result
|
|
delete this.socketListening[fd];
|
|
delete this.socketReading[fd];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
EventLoop.requestExit = function() {
|
|
this.exitRequested = true;
|
|
}
|
|
|
|
EventLoop.server = function(address, port, cb_accepted) {
|
|
var fd = Socket.createServerSocket(address, port);
|
|
this.socketListening[fd] = cb_accepted;
|
|
}
|
|
|
|
EventLoop.connect = function(address, port, cb_connected) {
|
|
var fd = Socket.connect(address, port);
|
|
this.socketConnecting[fd] = cb_connected;
|
|
}
|
|
|
|
EventLoop.close = function(fd) {
|
|
delete this.socketReading[fd];
|
|
delete this.socketListening[fd];
|
|
}
|
|
|
|
EventLoop.setReader = function(fd, cb_read) {
|
|
this.socketReading[fd] = cb_read;
|
|
}
|
|
|
|
EventLoop.write = function(fd, data) {
|
|
// This simple example doesn't have support for write blocking / draining
|
|
if (typeof data === 'string') {
|
|
data = new TextEncoder().encode(data);
|
|
}
|
|
var rc = Socket.write(fd, data);
|
|
}
|
|
|
|
/*
|
|
* Timer API
|
|
*
|
|
* These interface with the singleton EventLoop.
|
|
*/
|
|
|
|
function setTimeout(func, delay) {
|
|
var cb_func;
|
|
var bind_args;
|
|
var timer_id;
|
|
var evloop = EventLoop;
|
|
|
|
// Delay can be optional at least in some contexts, so tolerate that.
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout
|
|
if (typeof delay !== 'number') {
|
|
if (typeof delay === 'undefined') {
|
|
delay = 0;
|
|
} else {
|
|
throw new TypeError('invalid delay');
|
|
}
|
|
}
|
|
delay = Math.max(evloop.minimumDelay, delay);
|
|
|
|
if (typeof func === 'string') {
|
|
// Legacy case: callback is a string.
|
|
cb_func = eval.bind(this, func);
|
|
} else if (typeof func !== 'function') {
|
|
throw new TypeError('callback is not a function/string');
|
|
} else if (arguments.length > 2) {
|
|
// Special case: callback arguments are provided.
|
|
bind_args = Array.prototype.slice.call(arguments, 2); // [ arg1, arg2, ... ]
|
|
bind_args.unshift(this); // [ global(this), arg1, arg2, ... ]
|
|
cb_func = func.bind.apply(func, bind_args);
|
|
} else {
|
|
// Normal case: callback given as a function without arguments.
|
|
cb_func = func;
|
|
}
|
|
|
|
timer_id = evloop.nextTimerId++;
|
|
|
|
evloop.insertTimer({
|
|
id: timer_id,
|
|
oneshot: true,
|
|
cb: cb_func,
|
|
delay: delay,
|
|
target: Date.now() + delay
|
|
});
|
|
|
|
return timer_id;
|
|
}
|
|
|
|
function clearTimeout(timer_id) {
|
|
var evloop = EventLoop;
|
|
|
|
if (typeof timer_id !== 'number') {
|
|
throw new TypeError('timer ID is not a number');
|
|
}
|
|
evloop.removeTimerById(timer_id);
|
|
}
|
|
|
|
function setInterval(func, delay) {
|
|
var cb_func;
|
|
var bind_args;
|
|
var timer_id;
|
|
var evloop = EventLoop;
|
|
|
|
if (typeof delay !== 'number') {
|
|
if (typeof delay === 'undefined') {
|
|
delay = 0;
|
|
} else {
|
|
throw new TypeError('invalid delay');
|
|
}
|
|
}
|
|
delay = Math.max(evloop.minimumDelay, delay);
|
|
|
|
if (typeof func === 'string') {
|
|
// Legacy case: callback is a string.
|
|
cb_func = eval.bind(this, func);
|
|
} else if (typeof func !== 'function') {
|
|
throw new TypeError('callback is not a function/string');
|
|
} else if (arguments.length > 2) {
|
|
// Special case: callback arguments are provided.
|
|
bind_args = Array.prototype.slice.call(arguments, 2); // [ arg1, arg2, ... ]
|
|
bind_args.unshift(this); // [ global(this), arg1, arg2, ... ]
|
|
cb_func = func.bind.apply(func, bind_args);
|
|
} else {
|
|
// Normal case: callback given as a function without arguments.
|
|
cb_func = func;
|
|
}
|
|
|
|
timer_id = evloop.nextTimerId++;
|
|
|
|
evloop.insertTimer({
|
|
id: timer_id,
|
|
oneshot: false,
|
|
cb: cb_func,
|
|
delay: delay,
|
|
target: Date.now() + delay
|
|
});
|
|
|
|
return timer_id;
|
|
}
|
|
|
|
function clearInterval(timer_id) {
|
|
var evloop = EventLoop;
|
|
|
|
if (typeof timer_id !== 'number') {
|
|
throw new TypeError('timer ID is not a number');
|
|
}
|
|
evloop.removeTimerById(timer_id);
|
|
}
|
|
|
|
/* custom call */
|
|
function requestEventLoopExit() {
|
|
EventLoop.requestExit();
|
|
}
|