Skip to content

Commit

Permalink
[FIX JENKINS-40326] Add support for reconnect after the backend serve…
Browse files Browse the repository at this point in the history
…r has been restarted (#15)

* onError event handler

* Added sample plugin

* Added error handler in sample

* Added ping endpoint

* install sse from parent dir

* remove .watch_trigger file

* Added **/.watch_trigger to .gitignore

* Server reconnect support

Detect that the server has been restarted and provide a mechanism for hooking into the restart

* Fix lint errors

* Modify sample app to only load EventSource polyfill for MSIE

* Better connection failure detection

* Api tweaks

* fixed typo

* 0.0.16-tfbeta1
  • Loading branch information
tfennelly committed Jan 23, 2017
1 parent 172a93f commit 6c30589
Show file tree
Hide file tree
Showing 16 changed files with 503 additions and 10 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -16,3 +16,5 @@ npm-debug.log
**/.project
**/.classpath
etc/

**/.watch_trigger
42 changes: 42 additions & 0 deletions README.md
Expand Up @@ -75,6 +75,48 @@ var jobSubs = connection.subscribe('job', function (event) {
connection.unsubscribe(jobSubs);
```

## Handling connection errors

As is to be expected, the connection to Jenkins can be lost. To handle this situation, simply register an `onError` handler with the connection instance.

```javascript
var sse = require('@jenkins-cd/sse-gateway');

// Connect to the SSE Gateway.
var connection = sse.connect('myplugin');

// Connection error handling...
connection.onError(function (e) {
// Check the connection...
connection.waitConnectionOk(function(status) {
if (status.connectError) {
// The last attempt to connect was a failure, so
// notify the user in some way....

} else if (status.connectErrorCount > 0) {
// The last attempt to connect was not a failure,
// but we had earlier failures, so undo
// earlier error notifications etc ...

// And perhaps reload the current page, forcing
// a login if needed....
setTimeout(function() {
window.location.reload(true);
}, 2000);
}
});
});

// etc...
```

> Note that only one handler can be registered per `connection` instance.
Note how the supplied `connection.onError` handler makes a call to `connection.waitConnectionOk`.
`connection.waitConnectionOk` takes a connection status callback handler. This handler is called
periodically until the connection is ok again i.e. it can be called more than once, constantly getting
feedback on the connection state.

# Internet Explorer Support

As always with Internet Explorer, there are issues. It doesn't support the SSE `EventSource` so in order to
Expand Down
2 changes: 1 addition & 1 deletion package.json
@@ -1,6 +1,6 @@
{
"name": "@jenkins-cd/sse-gateway",
"version": "0.0.15",
"version": "0.0.16-tfbeta1",
"description": "Client API for the Jenkins SSE Gateway plugin. Browser UI push events from Jenkins.",
"main": "src/main/js/index.js",
"files": [
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/org/jenkinsci/plugins/ssegateway/Endpoint.java
Expand Up @@ -171,6 +171,25 @@ public HttpResponse doConfigure(StaplerRequest request, StaplerResponse response
}
}

@Restricted(DoNotUse.class) // Web only
public HttpResponse doPing(StaplerRequest request) throws IOException {
String dispatcherId = request.getParameter("dispatcherId");

if (dispatcherId != null) {
EventDispatcher dispatcher = EventDispatcherFactory.getDispatcher(dispatcherId, request.getSession());
if (dispatcher != null) {
try {
dispatcher.dispatchEvent("pingback", "ack");
} catch (ServletException e) {
LOGGER.log(Level.FINE, "Failed to send pingback to dispatcher " + dispatcherId + ".", e);
return HttpResponses.errorJSON("Failed to send pingback to dispatcher " + dispatcherId + ".");
}
}
}

return HttpResponses.okJSON();
}

// Using a Servlet Filter for the async channel. We're doing this because we
// do not want these requests making their way to Stapler. This is really
// down to fear of the unknown magic that happens in Stapler and the effect
Expand Down
95 changes: 92 additions & 3 deletions src/main/js/SSEConnection.js
Expand Up @@ -109,7 +109,7 @@ SSEConnection.prototype = {
try {
this.jenkinsUrl = jsModules.getRootURL();
} catch (e) {
console.warn("Jenkins SSE client initialization failed. Unable to connect to " +
LOGGER.warn("Jenkins SSE client initialization failed. Unable to connect to " +
"Jenkins because we are unable to determine the Jenkins Root URL. SSE events " +
"will not be received. Probable cause: no 'data-rooturl' on the page <head> " +
"element e.g. running in a test, or running headless without specifying a " +
Expand All @@ -120,8 +120,22 @@ SSEConnection.prototype = {
this.jenkinsUrl = normalizeUrl(this.jenkinsUrl);
}

this.pingUrl = this.jenkinsUrl + '/sse-gateway/ping';

// Used to keep track of connection errors.
var errorTracking = {
errors: [],
reset: function () {
if (errorTracking.pingbackTimeout) {
clearTimeout(errorTracking.pingbackTimeout);
delete errorTracking.pingbackTimeout;
}
errorTracking.errors = [];
}
};

if (!eventSourceSupported) {
console.warn("This browser does not support EventSource. Where's the polyfill?");
LOGGER.warn("This browser does not support EventSource. Where's the polyfill?");
} else if (this.jenkinsUrl !== undefined) {
var connectUrl = this.jenkinsUrl + '/sse-gateway/connect?clientId='
+ encodeURIComponent(tabClientId);
Expand All @@ -143,13 +157,46 @@ SSEConnection.prototype = {

source.addEventListener('open', function (e) {
LOGGER.debug('SSE channel "open" event.', e);
errorTracking.reset();
if (e.data) {
sseConnection.jenkinsSessionInfo = JSON.parse(e.data);
if (onConnect) {
onConnect(sseConnection.jenkinsSessionInfo);
}
}
}, false);
source.addEventListener('error', function (e) {
LOGGER.debug('SSE channel "error" event.', e);
if (errorTracking.errors.length === 0) {
// Send ping request.
// If the connection is ok, we should get a pingback ack and the above
// errorTracking.pingbackTimeout should get cleared etc.
// See 'pingback' below
errorTracking.pingbackTimeout = setTimeout(function () {
delete errorTracking.pingbackTimeout;
if (typeof sseConnection._onerror === 'function'
&& errorTracking.errors.length > 0) {
var errorToSend = errorTracking.errors[0];
errorTracking.reset();
try {
sseConnection._onerror(errorToSend);
} catch (error) {
LOGGER.error('SSEConnection "onError" event handler ' +
'threw unexpected error.', error);
}
} else {
errorTracking.reset();
}
}, 5000); // TODO: magic num ... what's realistic ?
ajax.get(sseConnection.pingUrl + '?dispatcherId=' +
encodeURIComponent(sseConnection.jenkinsSessionInfo.dispatcherId));
}
errorTracking.errors.push(e);
}, false);
source.addEventListener('pingback', function (e) {
LOGGER.debug('SSE channel "pingback" event received.', e);
errorTracking.reset();
}, false);
source.addEventListener('configure', function (e) {
LOGGER.debug('SSE channel "configure" ACK event (see batchId on event).', e);
if (e.data) {
Expand Down Expand Up @@ -178,6 +225,48 @@ SSEConnection.prototype = {
// We are connected if we have an EventSource object.
return (this.eventSource !== undefined);
},
onError: function (handler) {
this._onerror = handler;
},
waitConnectionOk: function (handler) {
if (!this.eventSource) {
throw new Error('Not connected.');
}
if (typeof handler !== 'function') {
throw new Error('No waitServerRunning callback function provided.');
}

var connection = this;
var connectErrorCount = 0;

function doPingWait() {
ajax.isAlive(connection.pingUrl, function (status) {
var connectError = false;
// - status 0 "typically" means timed out. Anything less than 100
// is meaningless anyway, so lets just go with that.
// - status 500+ errors mean that the server (or intermediary) are
// unable to handle the request, which from a users point of view
// is equivalent to not being able to connect to the server.
if (status < 100 || status >= 500) {
connectError = true;
connectErrorCount++;

// Try again in few seconds
LOGGER.debug('Server connection error %s (%s).', status, connection.jenkinsUrl);
setTimeout(doPingWait, 3000);
} else {
// Ping worked ... we connected.
LOGGER.debug('Server connection ok.');
}
handler({
statusCode: status,
connectError: connectError,
connectErrorCount: connectErrorCount
});
});
}
doPingWait();
},
disconnect: function () {
if (this.eventSource) {
try {
Expand Down Expand Up @@ -385,7 +474,7 @@ SSEConnection.prototype = {
processCount++;
subscription.callback(parsedData);
} catch (e) {
console.trace(e);
LOGGER.debug(e);
}
}
}
Expand Down
51 changes: 45 additions & 6 deletions src/main/js/ajax.js
@@ -1,10 +1,17 @@
var json = require('./json');

exports.get = function (url, onSuccess) {
// See https://github.com/tfennelly/jenkins-js-logging - will move to jenskinsci org
var logging = require('@jenkins-cd/logging');
var LOGGER = logging.logger('org.jenkinsci.sse');

exports.get = function (url, onSuccess, onError) {
var http = new XMLHttpRequest();

http.onreadystatechange = function () {
if (http.readyState === 4) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug('HTTP GET %s', url, http);
}
if (http.status >= 200 && http.status < 300) {
try {
var responseJSON = JSON.parse(http.responseText);
Expand All @@ -22,10 +29,14 @@ exports.get = function (url, onSuccess) {
}
} catch (e) {
// Not a JSON response.
if (onError) {
onError(http);
}
}
} else {
console.error('SSE Gateway error to ' + url + ': ');
console.error(http);
if (onError) {
onError(http);
}
}
}
};
Expand All @@ -36,11 +47,42 @@ exports.get = function (url, onSuccess) {
http.send();
};

exports.isAlive = function (url, callback) {
var http = new XMLHttpRequest();
var callbackCalled = false;

function doCallback(result) {
if (!callbackCalled) {
callback(result);
callbackCalled = true;
}
}

http.onreadystatechange = function () {
if (http.readyState === 4) {
// http.status of 0 can mean timeout. Anything
// else "seems" to be good.
doCallback(http.status);
}
};
http.ontimeout = function () {
doCallback(0);
};

http.open('GET', url, true);
http.timeout = 5000;
http.setRequestHeader('Accept', 'application/json');
http.send();
};

exports.post = function (data, toUrl, jenkinsSessionInfo) {
var http = new XMLHttpRequest();

http.onreadystatechange = function () {
if (http.readyState === 4) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug('HTTP POST %s', toUrl, http);
}
if (http.status >= 200 && http.status < 300) {
try {
var responseJSON = JSON.parse(http.responseText);
Expand All @@ -55,9 +97,6 @@ exports.post = function (data, toUrl, jenkinsSessionInfo) {
} catch (e) {
// Not a JSON response.
}
} else {
console.error('SSE Gateway error to ' + toUrl + ': ');
console.error(http);
}
}
};
Expand Down
9 changes: 9 additions & 0 deletions sse-gateway-sample/README.md
@@ -0,0 +1,9 @@
A simple plugin that demos how to use the [sse-gateway-plugin](https://github.com/tfennelly/sse-gateway-plugin).

The plugin is very simple. It just listens for all "job" events, displaying them in a simple window.

![Video Clip](./img/sse-gateway-sample-plugin.gif)

# Source code

See [src/main/js/sse-gateway-sample.js](src/main/js/sse-gateway-sample.js).
7 changes: 7 additions & 0 deletions sse-gateway-sample/gulpfile.js
@@ -0,0 +1,7 @@
var builder = require('@jenkins-cd/js-builder');

//
// Bundle the modules.
// See https://github.com/jenkinsci/js-builder
//
builder.bundle('src/main/js/sse-gateway-sample.js').less('src/main/js/sse-gateway-sample.less');
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions sse-gateway-sample/package.json
@@ -0,0 +1,20 @@
{
"name": "sse-gateway-sample",
"version": "1.0.0",
"description": "SSE Gateway Sample",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Tom Fennelly <tom.fennelly@gmail.com> (https://github.com/tfennelly)",
"license": "MIT",
"devDependencies": {
"@jenkins-cd/js-builder": "latest",
"gulp": "^3.9.1"
},
"dependencies": {
"@jenkins-cd/sse-gateway": "../",
"@jenkins-cd/js-modules": "latest",
"jquery": "^2.2.3"
}
}

0 comments on commit 6c30589

Please sign in to comment.