feat: make libp2p a state machine (#257)

* docs: add events to readme
This commit is contained in:
Jacob Heun 2018-10-19 17:37:34 +02:00 committed by GitHub
parent 686379efb0
commit 0b75f99d75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 519 additions and 109 deletions

View File

@ -49,6 +49,7 @@ We've come a long way, but this project is still in Alpha, lots of development i
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [Events](#events)
- [Development](#development)
- [Tests](#tests)
- [Packages](#packages)
@ -206,22 +207,20 @@ Required keys in the `options` object:
> Start the libp2p Node.
`callback` is a function with the following `function (err) {}` signature, where `err` is an Error in case starting the node fails.
`callback` following signature `function (err) {}`, where `err` is an Error in case starting the node fails.
#### `libp2p.stop(callback)`
> Stop the libp2p Node.
`callback` is a function with the following `function (err) {}` signature, where `err` is an Error in case stopping the node fails.
`callback` following signature `function (err) {}`, where `err` is an Error in case stopping the node fails.
#### `libp2p.dial(peer, callback)`
> Dials to another peer in the network, establishes the connection.
- `peer`: can be an instance of [PeerInfo][], [PeerId][], [multiaddr][], or a multiaddr string
- `callback`: Function with signature `function (err, conn) {}` where `conn` is a [Connection](https://github.com/libp2p/interface-connection) object
`callback` is a function with the following `function (err, conn) {}` signature, where `err` is an Error in of failure to dial the connection and `conn` is a [Connection][] instance in case of a protocol selected, if not it is undefined.
- `callback` following signature `function (err, conn) {}`, where `err` is an Error in of failure to dial the connection and `conn` is a [Connection][] instance in case of a protocol selected, if not it is undefined.
#### `libp2p.dialProtocol(peer, protocol, callback)`
@ -229,9 +228,17 @@ Required keys in the `options` object:
- `peer`: can be an instance of [PeerInfo][], [PeerId][], [multiaddr][], or a multiaddr string
- `protocol`: String that defines the protocol (e.g '/ipfs/bitswap/1.1.0')
- `callback`: Function with signature `function (err, conn) {}` where `conn` is a [Connection](https://github.com/libp2p/interface-connection) object
- `callback`: Function with signature `function (err, conn) {}`, where `conn` is a [Connection](https://github.com/libp2p/interface-connection) object
`callback` is a function with the following `function (err, conn) {}` signature, where `err` is an Error in of failure to dial the connection and `conn` is a [Connection][] instance in case of a protocol selected, if not it is undefined.
`callback` following signature `function (err, conn) {}`, where `err` is an Error in of failure to dial the connection and `conn` is a [Connection][] instance in case of a protocol selected, if not it is undefined.
#### `libp2p.dialFSM(peer, protocol, callback)`
> Behaves like `.dial` and `.dialProtocol` but calls back with a Connection State Machine
- `peer`: can be an instance of [PeerInfo][], [PeerId][], [multiaddr][], or a multiaddr string
- `protocol`: an optional String that defines the protocol (e.g '/ipfs/bitswap/1.1.0')
- `callback`: following signature `function (err, connFSM) {}`, where `connFSM` is a [Connection State Machine](https://github.com/libp2p/js-libp2p-switch#connection-state-machine)
#### `libp2p.hangUp(peer, callback)`
@ -239,7 +246,7 @@ Required keys in the `options` object:
- `peer`: can be an instance of [PeerInfo][], [PeerId][] or [multiaddr][]
`callback` is a function with the following `function (err) {}` signature, where `err` is an Error in case stopping the node fails.
`callback` following signature `function (err) {}`, where `err` is an Error in case stopping the node fails.
#### `libp2p.peerRouting.findPeer(id, options, callback)`
@ -265,7 +272,7 @@ Required keys in the `options` object:
> Handle new protocol
- `protocol`: String that defines the protocol (e.g '/ipfs/bitswap/1.1.0')
- `handlerFunc`: Function with signature `function (protocol, conn) {}` where `conn` is a [Connection](https://github.com/libp2p/interface-connection) object
- `handlerFunc`: following signature `function (protocol, conn) {}`, where `conn` is a [Connection](https://github.com/libp2p/interface-connection) object
- `matchFunc`: Function for matching on protocol (exact matching, semver, etc). Default to exact match.
#### `libp2p.unhandle(protocol)`
@ -274,19 +281,35 @@ Required keys in the `options` object:
- `protocol`: String that defines the protocol (e.g '/ipfs/bitswap/1.1.0')
#### `libp2p.on('peer:discovery', (peer) => {})`
#### Events
##### `libp2p.on('start', () => {})`
> Libp2p has started, along with all its services.
##### `libp2p.on('stop', () => {})`
> Libp2p has stopped, along with all its services.
##### `libp2p.on('error', (err) => {})`
> An error has occurred
- `err`: instance of `Error`
##### `libp2p.on('peer:discovery', (peer) => {})`
> Peer has been discovered.
- `peer`: instance of [PeerInfo][]
#### `libp2p.on('peer:connect', (peer) => {})`
##### `libp2p.on('peer:connect', (peer) => {})`
> We connected to a new peer
- `peer`: instance of [PeerInfo][]
#### `libp2p.on('peer:disconnect', (peer) => {})`
##### `libp2p.on('peer:disconnect', (peer) => {})`
> We disconnected from Peer
@ -574,4 +597,4 @@ The libp2p implementation in JavaScript is a work in progress. As such, there ar
## License
[MIT](LICENSE) © David Dias
[MIT](LICENSE) © Protocol Labs

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const domReady = require('detect-dom-ready')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
'use strict'
const libp2p = require('../../')

View File

@ -38,13 +38,15 @@
},
"dependencies": {
"async": "^2.6.1",
"debug": "^4.0.1",
"err-code": "^1.1.2",
"fsm-event": "^2.1.0",
"joi": "^13.6.0",
"joi-browser": "^13.4.0",
"libp2p-connection-manager": "~0.0.2",
"libp2p-floodsub": "~0.15.0",
"libp2p-ping": "~0.8.0",
"libp2p-switch": "~0.40.8",
"libp2p-switch": "~0.41.0",
"libp2p-websockets": "~0.12.0",
"mafmt": "^6.0.2",
"multiaddr": "^5.0.0",
@ -57,16 +59,17 @@
"@nodeutils/defaults-deep": "^1.1.0",
"aegir": "^15.2.0",
"chai": "^4.1.2",
"chai-checkmark": "^1.0.1",
"cids": "~0.5.3",
"dirty-chai": "^2.0.1",
"electron-webrtc": "~0.3.0",
"libp2p-bootstrap": "~0.9.3",
"libp2p-circuit": "~0.2.1",
"libp2p-delegated-content-routing": "~0.2.2",
"libp2p-delegated-peer-routing": "~0.2.2",
"libp2p-kad-dht": "~0.10.5",
"libp2p-mdns": "~0.12.0",
"libp2p-mplex": "~0.8.2",
"libp2p-bootstrap": "~0.9.3",
"libp2p-secio": "~0.10.0",
"libp2p-spdy": "~0.12.1",
"libp2p-tcp": "~0.13.0",

View File

@ -1,7 +1,11 @@
'use strict'
const FSM = require('fsm-event')
const EventEmitter = require('events').EventEmitter
const assert = require('assert')
const debug = require('debug')
const log = debug('libp2p')
log.error = debug('libp2p:error')
const each = require('async/each')
const series = require('async/series')
@ -22,6 +26,14 @@ const validateConfig = require('./config').validate
const NOT_STARTED_ERROR_MESSAGE = 'The libp2p node is not started yet'
/**
* @fires Node#error Emitted when an error occurs
* @fires Node#peer:connect Emitted when a peer is connected to this node
* @fires Node#peer:disconnect Emitted when a peer disconnects from this node
* @fires Node#peer:discovery Emitted when a peer is discovered
* @fires Node#start Emitted when the node and its services has started
* @fires Node#stop Emitted when the node and its services has stopped
*/
class Node extends EventEmitter {
constructor (_options) {
super()
@ -109,15 +121,181 @@ class Node extends EventEmitter {
// Mount default protocols
Ping.mount(this._switch)
this.state = new FSM('STOPPED', {
STOPPED: {
start: 'STARTING',
stop: 'STOPPED'
},
STARTING: {
done: 'STARTED',
abort: 'STOPPED',
stop: 'STOPPING'
},
STARTED: {
stop: 'STOPPING',
start: 'STARTED'
},
STOPPING: {
stop: 'STOPPING',
done: 'STOPPED'
}
})
this.state.on('STARTING', () => {
log('libp2p is starting')
this._onStarting()
})
this.state.on('STOPPING', () => {
log('libp2p is stopping')
this._onStopping()
})
this.state.on('STARTED', () => {
log('libp2p has started')
this.emit('start')
})
this.state.on('STOPPED', () => {
log('libp2p has stopped')
this.emit('stop')
})
this.state.on('error', (err) => {
log.error(err)
this.emit('error', err)
})
}
/*
* Start the libp2p node
* - create listeners on the multiaddrs the Peer wants to listen
/**
* Starts the libp2p node and all sub services
*
* @param {function(Error)} callback
* @returns {void}
*/
start (callback) {
start (callback = () => {}) {
this.once('start', callback)
this.state('start')
}
/**
* Stop the libp2p node by closing its listeners and open connections
*
* @param {function(Error)} callback
* @returns {void}
*/
stop (callback = () => {}) {
this.once('stop', callback)
this.state('stop')
}
isStarted () {
return this.state ? this.state._state === 'STARTED' : false
}
/**
* Dials to the provided peer. If successful, the `PeerInfo` of the
* peer will be added to the nodes `PeerBook`
*
* @param {PeerInfo|PeerId|Multiaddr|string} peer The peer to dial
* @param {function(Error)} callback
* @returns {void}
*/
dial (peer, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
this.dialProtocol(peer, null, callback)
}
/**
* Dials to the provided peer and handshakes with the given protocol.
* If successful, the `PeerInfo` of the peer will be added to the nodes `PeerBook`,
* and the `Connection` will be sent in the callback
*
* @param {PeerInfo|PeerId|Multiaddr|string} peer The peer to dial
* @param {string} protocol
* @param {function(Error, Connection)} callback
* @returns {void}
*/
dialProtocol (peer, protocol, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
if (typeof protocol === 'function') {
callback = protocol
protocol = undefined
}
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
this._switch.dial(peerInfo, protocol, (err, conn) => {
if (err) { return callback(err) }
this.peerBook.put(peerInfo)
callback(null, conn)
})
})
}
/**
* Similar to `dial` and `dialProtocol`, but the callback will contain a
* Connection State Machine.
*
* @param {PeerInfo|PeerId|Multiaddr|string} peer The peer to dial
* @param {string} protocol
* @param {function(Error, ConnectionFSM)} callback
* @returns {void}
*/
dialFSM (peer, protocol, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
if (typeof protocol === 'function') {
callback = protocol
protocol = undefined
}
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
const connFSM = this._switch.dialFSM(peerInfo, protocol, (err) => {
if (!err) {
this.peerBook.put(peerInfo)
}
})
callback(null, connFSM)
})
}
hangUp (peer, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
this._switch.hangUp(peerInfo, callback)
})
}
ping (peer, callback) {
if (!this.isStarted()) {
return callback(new Error(NOT_STARTED_ERROR_MESSAGE))
}
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
callback(null, new Ping(this._switch, peerInfo))
})
}
handle (protocol, handlerFunc, matchFunc) {
this._switch.handle(protocol, handlerFunc, matchFunc)
}
unhandle (protocol) {
this._switch.unhandle(protocol)
}
_onStarting () {
if (!this._modules.transport) {
return callback(new Error('no transports were present'))
this.emit('error', new Error('no transports were present'))
return this.state('abort')
}
let ws
@ -214,12 +392,10 @@ class Node extends EventEmitter {
// TODO: chicken-and-egg problem #2:
// have to set started here because FloodSub requires libp2p is already started
if (this._floodSub) {
this._floodSub.start(cb)
} else {
cb()
return this._floodSub.start(cb)
}
cb()
},
(cb) => {
// detect which multiaddrs we don't have a transport for and remove them
const multiaddrs = this.peerInfo.multiaddrs.toArray()
@ -231,18 +407,18 @@ class Node extends EventEmitter {
}
})
cb()
},
(cb) => {
this.emit('start')
cb()
}
], callback)
], (err) => {
if (err) {
log.error(err)
this.emit('error', err)
return this.state('stop')
}
this.state('done')
})
}
/*
* Stop the libp2p node by closing its listeners and open connections
*/
stop (callback) {
_onStopping () {
series([
(cb) => {
if (this._modules.peerDiscovery) {
@ -269,86 +445,21 @@ class Node extends EventEmitter {
cb()
},
(cb) => {
this.connectionManager.stop()
this._switch.stop(cb)
// Ensures idempotency for restarts
this._switch.transport.removeAll(cb)
},
(cb) => {
this.emit('stop')
cb()
this.connectionManager.stop()
this._switch.stop(cb)
}
], (err) => {
this._isStarted = false
callback(err)
if (err) {
log.error(err)
this.emit('error', err)
}
this.state('done')
})
}
isStarted () {
return this._isStarted
}
dial (peer, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
this._switch.dial(peerInfo, (err) => {
if (err) { return callback(err) }
this.peerBook.put(peerInfo)
callback()
})
})
}
dialProtocol (peer, protocol, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
if (typeof protocol === 'function') {
callback = protocol
protocol = undefined
}
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
this._switch.dial(peerInfo, protocol, (err, conn) => {
if (err) { return callback(err) }
this.peerBook.put(peerInfo)
callback(null, conn)
})
})
}
hangUp (peer, callback) {
assert(this.isStarted(), NOT_STARTED_ERROR_MESSAGE)
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
this._switch.hangUp(peerInfo, callback)
})
}
ping (peer, callback) {
if (!this.isStarted()) {
return callback(new Error(NOT_STARTED_ERROR_MESSAGE))
}
this._getPeerInfo(peer, (err, peerInfo) => {
if (err) { return callback(err) }
callback(null, new Ping(this._switch, peerInfo))
})
}
handle (protocol, handlerFunc, matchFunc) {
this._switch.handle(protocol, handlerFunc, matchFunc)
}
unhandle (protocol) {
this._switch.unhandle(protocol)
}
}
module.exports = Node

118
test/fsm.spec.js Normal file
View File

@ -0,0 +1,118 @@
/* eslint-env mocha */
'use strict'
const chai = require('chai')
chai.use(require('dirty-chai'))
chai.use(require('chai-checkmark'))
const expect = chai.expect
const sinon = require('sinon')
const series = require('async/series')
const createNode = require('./utils/create-node')
describe('libp2p state machine (fsm)', () => {
describe('starting and stopping', () => {
let node
beforeEach((done) => {
createNode([], (err, _node) => {
node = _node
done(err)
})
})
afterEach(() => {
node.removeAllListeners()
})
after((done) => {
node.stop(done)
node = null
})
it('should be able to start and stop several times', (done) => {
node.on('start', (err) => {
expect(err).to.not.exist().mark()
})
node.on('stop', (err) => {
expect(err).to.not.exist().mark()
})
expect(4).checks(done)
series([
(cb) => node.start(cb),
(cb) => node.stop(cb),
(cb) => node.start(cb),
(cb) => node.stop(cb)
], () => {})
})
it('should noop when stopping a stopped node', (done) => {
node.once('start', node.stop)
node.once('stop', () => {
node.state.on('STOPPING', () => {
throw new Error('should not stop a stopped node')
})
node.once('stop', done)
// stop the stopped node
node.stop()
})
node.start()
})
it('should noop when starting a started node', (done) => {
node.once('start', () => {
node.state.on('STARTING', () => {
throw new Error('should not start a started node')
})
node.once('start', () => {
node.once('stop', done)
node.stop()
})
// start the started node
node.start()
})
node.start()
})
it('should error on start with no transports', (done) => {
let transports = node._modules.transport
node._modules.transport = null
node.on('stop', () => {
node._modules.transport = transports
expect(node._modules.transport).to.exist().mark()
})
node.on('error', (err) => {
expect(err).to.exist().mark()
})
node.on('start', () => {
throw new Error('should not start')
})
expect(2).checks(done)
node.start()
})
it('should not start if the switch fails to start', (done) => {
const error = new Error('switch didnt start')
const stub = sinon.stub(node._switch, 'start')
.callsArgWith(0, error)
node.on('stop', () => {
expect(stub.calledOnce).to.eql(true).mark()
stub.restore()
})
node.on('error', (err) => {
expect(err).to.eql(error).mark()
})
node.on('start', () => {
throw new Error('should not start')
})
expect(2).checks(done)
node.start()
})
})
})

View File

@ -4,8 +4,9 @@ require('./pnet.node')
require('./transports.node')
require('./stream-muxing.node')
require('./peer-discovery.node')
require('./pubsub.node')
require('./peer-routing.node')
require('./ping.node')
require('./pubsub.node')
require('./content-routing.node')
require('./circuit-relay.node')
require('./multiaddr-trim.node')

61
test/ping.node.js Normal file
View File

@ -0,0 +1,61 @@
/* eslint-env mocha */
'use strict'
const chai = require('chai')
chai.use(require('dirty-chai'))
const expect = chai.expect
const parallel = require('async/parallel')
const createNode = require('./utils/create-node.js')
const echo = require('./utils/echo')
describe('ping', () => {
let nodeA
let nodeB
before((done) => {
parallel([
(cb) => createNode('/ip4/0.0.0.0/tcp/0', (err, node) => {
expect(err).to.not.exist()
nodeA = node
node.handle('/echo/1.0.0', echo)
node.start(cb)
}),
(cb) => createNode('/ip4/0.0.0.0/tcp/0', (err, node) => {
expect(err).to.not.exist()
nodeB = node
node.handle('/echo/1.0.0', echo)
node.start(cb)
})
], done)
})
after((done) => {
parallel([
(cb) => nodeA.stop(cb),
(cb) => nodeB.stop(cb)
], done)
})
it('should be able to ping another node', (done) => {
nodeA.ping(nodeB.peerInfo, (err, ping) => {
expect(err).to.not.exist()
ping.once('ping', (time) => {
expect(time).to.exist()
ping.stop()
done()
})
ping.start()
})
})
it('should be not be able to ping when stopped', (done) => {
nodeA.stop(() => {
nodeA.ping(nodeB.peerInfo, (err) => {
expect(err).to.exist()
done()
})
})
})
})

View File

@ -148,6 +148,42 @@ describe('transports', () => {
})
})
it('.dialFSM check conn and close', (done) => {
nodeA.dialFSM(peerB, (err, connFSM) => {
expect(err).to.not.exist()
connFSM.once('muxed', () => {
expect(nodeA._switch.muxedConns).to.have.any.keys(
peerB.id.toB58String()
)
connFSM.once('error', done)
connFSM.once('close', () => {
// ensure the connection is closed
expect(nodeA._switch.muxedConns).to.not.have.any.keys([
peerB.id.toB58String()
])
done()
})
connFSM.close()
})
})
})
it('.dialFSM with a protocol, do an echo and close', (done) => {
nodeA.dialFSM(peerB, '/echo/1.0.0', (err, connFSM) => {
expect(err).to.not.exist()
connFSM.once('connection', (conn) => {
tryEcho(conn, () => {
connFSM.close()
})
})
connFSM.once('error', done)
connFSM.once('close', done)
})
})
describe('stress', () => {
it('one big write', (done) => {
nodeA.dialProtocol(peerB, '/echo/1.0.0', (err, conn) => {
@ -194,6 +230,7 @@ describe('transports', () => {
})
describe('webrtc-star', () => {
/* eslint-disable-next-line no-console */
if (!w.support) { return console.log('NO WEBRTC SUPPORT') }
let peer1

View File

@ -185,7 +185,7 @@ describe('transports', () => {
})
it('nodeA.hangUp nodeB using PeerId (third)', (done) => {
nodeA.hangUp(nodeB.peerInfo.multiaddrs.toArray()[0], (err) => {
nodeA.hangUp(nodeB.peerInfo.id, (err) => {
expect(err).to.not.exist()
setTimeout(check, 500)
@ -207,6 +207,51 @@ describe('transports', () => {
}
})
})
it('.dialFSM check conn and close', (done) => {
nodeA.dialFSM(nodeB.peerInfo, (err, connFSM) => {
expect(err).to.not.exist()
connFSM.once('muxed', () => {
expect(nodeA._switch.muxedConns).to.have.any.keys(
nodeB.peerInfo.id.toB58String()
)
connFSM.once('error', done)
connFSM.once('close', () => {
// ensure the connection is closed
expect(nodeA._switch.muxedConns).to.not.have.any.keys([
nodeB.peerInfo.id.toB58String()
])
done()
})
connFSM.close()
})
})
})
it('.dialFSM with a protocol, do an echo and close', (done) => {
nodeA.dialFSM(nodeB.peerInfo, '/echo/1.0.0', (err, connFSM) => {
expect(err).to.not.exist()
connFSM.once('connection', (conn) => {
expect(nodeA._switch.muxedConns).to.have.all.keys([
nodeB.peerInfo.id.toB58String()
])
tryEcho(conn, () => {
connFSM.close()
})
})
connFSM.once('error', done)
connFSM.once('close', () => {
// ensure the connection is closed
expect(nodeA._switch.muxedConns).to.not.have.any.keys([
nodeB.peerInfo.id.toB58String()
])
done()
})
})
})
})
describe('TCP + WebSockets', () => {

View File

@ -42,6 +42,7 @@ describe('Turbolence tests', () => {
}
})
/* eslint-disable-next-line no-console */
nodeSpawn.stderr.on('data', (data) => console.log(data.toString()))
})