Monday, June 27, 2011

Post Response SPDY Server Push

‹prev | My Chain | next›

I figured out a minor issue with SPDY server push and HTML pages last night. It occurs to me that pushing other HTML pages into the browser's cache before the original server request is fulfilled is a tad silly.

I makes far more sense to push out only resources needed for the page itself (e.g. images, Javascript, CSS) at the same time as the requested page. Only once all of those resources have been sent should secondary web pages get sent.

Currently, my express-spdy app is sending out three pages and a CSS stylesheet thusly:
var app = module.exports = express.createServer({
//...
push: function(pusher) {
// Only push in response to the first request
if (pusher.streamID > 1) return;

pusher.push_file("public/one.html", "https://localhost:3000/one.html");
pusher.push_file("public/two.html", "https://localhost:3000/two.html");
pusher.push_file("public/three.html", "https://localhost:3000/three.html");
pusher.push_file("public/stylesheets/style.css", "https://localhost:3000/stylesheets/style.css");

}
});
I am unsure how I would like the post-response push API to work, but the simplest thing that I can think of for experimentation is:
var app = module.exports = express.createServer({
// ...
push: function(pusher) {
// Only push in response to the first request
if (pusher.streamID > 1) return;

pusher.push_file("public/stylesheets/style.css", "https://localhost:3000/stylesheets/style.css");
},
push_after: function(pusher) {
// Only push in response to the first request
if (pusher.streamID > 1) return;

pusher.push_file("public/one.html", "https://localhost:3000/one.html");
pusher.push_file("public/two.html", "https://localhost:3000/two.html");
pusher.push_file("public/three.html", "https://localhost:3000/three.html");

}
});
The express-spdy package already supports the push callback (by way of node-spdy), so I would only need to add support for push_after. Let's see if that's easy...

In the Response class, I use a bit of indirection to invoke that push callback. It ultimately get called as part of the write() method, just after sending out headers:

Response.prototype._write = function(data, encoding, fin) {
if (!this._written) {
this._flushHead();
this._push_stream();
}
//
};
To get the desired effect, I could push out the post-response resources after the data frame has been written:
Response.prototype._write = function(data, encoding, fin) {
// ...

this.c.write(dframe);

this._push_after_response_stream();
};
But... SPDY data frames can include a FIN flag. If set, a FIN flag indicates that the stream is closed. This means that no more data can be sent on the stream and no more pushed data associated with that stream can be initiated. If the headers for a push stream had been initiated via SYN_STREAM frame prior to sending a data FIN (or just plain FIN), then the data could still be pushed. But, as that code is written, it is possible to write() a FIN data frame and then attempt to initiate a server push which is a SPDY no-no.

A quick and not-too-dirty solution would be to write the post-response push data after write(), but not in an end():
Response.prototype.write = function(data, encoding) {
var stream = this._write(data, encoding, false);

this._push_after_response_stream();

return stream;
};

Response.prototype.end = function(data, encoding) {
this.writable = false;
return this._write(data, encoding, true);
};
An end() sets the FIN flag whereas a write() does not. Unfortunately, express.js seems to favor calls to end().

So instead, I opt for an ugly hack. In the "private" _write() method, I send a normal data frame and a separate FIN data frame. In between those, I push any post-response push streams:
Response.prototype._write = function(data, encoding, fin) {
//...

// Write the data frame
var dframe = createDataFrame(this.getStreamCompressor(), {
streamID: this.streamID,
flags: 0
}, Buffer.isBuffer(data) ? data : new Buffer(data, encoding));

this.c.write(dframe);

// Push any post-response push streams
this._push_after_response_stream();

// Write the data FIN if this if fin
if (fin) {
var dfin = createDataFrame(this.getStreamCompressor(), {
streamID: this.streamID,
flags: enums.DATA_FLAG_FIN
}, new Buffer(0));
this.c.write(dfin);
}
};
That ain't pretty, but it ought to get the job done for today. Firing up the browser, I find that it does, indeed, work:
#####
# Response headers
t=1309198692649 [st= 80] SPDY_SESSION_SYN_REPLY
--> flags = 0
--> connection: keep-alive
content-length: 303
content-type: text/html
status: 200 OK
version: HTTP/1.1
x-powered-by: Express
--> id = 1

#####
# Pre-response push (CSS)
t=1309198692657 [st= 88] SPDY_SESSION_PUSHED_SYN_STREAM
--> associated_stream = 1
--> flags = 2
--> status: 200
url: https://localhost:3000/stylesheets/style.css
version: http/1.1
--> id = 2

#####
# Response data (the requested web page)
t=1309198692658 [st= 89] SPDY_SESSION_RECV_DATA
--> flags = 0
--> size = 303
--> stream_id = 1

######
# Post response push

# Post response push #1
t=1309198692658 [st= 89] SPDY_SESSION_PUSHED_SYN_STREAM
--> associated_stream = 1
--> flags = 2
--> content-type: text/html
status: 200
url: https://localhost:3000/one.html
version: http/1.1
--> id = 4

# Post response push #2
t=1309198692658 [st= 89] SPDY_SESSION_PUSHED_SYN_STREAM
--> associated_stream = 1
--> flags = 2
--> content-type: text/html
status: 200
url: https://localhost:3000/two.html
version: http/1.1
--> id = 6


# Post response push #3
t=1309198692658 [st= 89] SPDY_SESSION_PUSHED_SYN_STREAM
--> associated_stream = 1
--> flags = 2
--> content-type: text/html
status: 200
url: https://localhost:3000/three.html
version: http/1.1
--> id = 8

#####
# Finally, the data FIN for the response:
t=1309198692658 [st= 89] SPDY_SESSION_RECV_DATA
--> flags = 0
--> size = 0
--> stream_id = 1

t=1309198692666 [st= 97] SPDY_STREAM_ADOPTED_PUSH_STREAM
Checking out the failure case, I move the post-response push after the data FIN has been sent:
Response.prototype._write = function(data, encoding, fin) {
// ...

// Write the data FIN if this if fin
if (fin) {
var dfin = createDataFrame(this.getStreamCompressor(), {
streamID: this.streamID,
flags: enums.DATA_FLAG_FIN
}, new Buffer(0));
this.c.write(dfin);
}

// Push any post-response push streams
this._push_after_response_stream();
};
After trying to load this in the browser, I find that it does fail as expected:
t=1309199284990 [st= 61]     SPDY_SESSION_RECV_DATA  
--> flags = 0
--> size = 303
--> stream_id = 1
t=1309199284990 [st= 61] SPDY_SESSION_RECV_DATA
--> flags = 0
--> size = 0
--> stream_id = 1
t=1309199284990 [st= 61] SPDY_SESSION_PUSHED_SYN_STREAM
--> associated_stream = 1
--> flags = 2
--> content-type: text/html
status: 200
url: https://localhost:3000/one.html
version: http/1.1
--> id = 4
t=1309199284990 [st= 61] SPDY_SESSION_SEND_RST_STREAM
--> status = 8
--> stream_id = 4
After attempting to push on a stream that was just closed (#1), Chrome correctly sends a RST_STREAM packet informing the server that it behaving badly. Nice.

That is a good stopping point for today. I will pick back up tomorrow attempting to come up with a nice API for post-response push.

Day #59

No comments:

Post a Comment