openvibers/openvibe.host Commits
A database error while resolving a request's host now answers 500 instead of hanging the request: the async host-dispatch middleware (tenant.resolve/handle read PostgreSQL since ADR-035) had no error path, and Express 4 does not catch a rejected async handler, so the rejection was unhandled and the socket stayed open. The error reaches the app's error handler, counted as an unknown-host request. test/host-dispatch-error.test.js: resolve rejects -> 500 within 3 s, then the site serves 200 again. Found by the estate-wide floating-promise sweep; reviewed independently (VERDICT: SHIP). npm test 42/42 test files passed.
Changes
@@@ -114,12 +114,19 @@ async function createApp(opts = {}) {app.use(httpMetrics.middleware);// ── Host dispatch: tenant sites never reach anything below ──++ // resolve/handle read the database (ADR-035: async), so a rejection here must reach the error++ // handler below: Express 4 does not catch a rejected async handler, which would hang the request.app.use(async (req, res, next) => {-- const target = await tenant.resolve(req.headers.host);-- req.hostTarget = target.kind;-- if (target.kind === 'dashboard') return next();-- if (target.kind === 'site') return await tenant.handle(req, res, target.site);-- return tenant.unknownHost(req, res);++ try {++ const target = await tenant.resolve(req.headers.host);++ req.hostTarget = target.kind;++ if (target.kind === 'dashboard') return next();++ if (target.kind === 'site') return await tenant.handle(req, res, target.site);++ return tenant.unknownHost(req, res);++ } catch (err) {++ req.hostTarget = req.hostTarget || 'unknown';++ return next(err);++ }});app.use(contracts.http.middleware());nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * Regression (ADR-035): the Host dispatch middleware in server/app.js awaits tenant.resolve and++ * tenant.handle, which read the database asynchronously. Express 4 does not catch a rejected async++ * handler: a transient database error used to escape as an unhandled rejection and leave the request++ * hanging with no response. It must reach the app's error handler instead (a logged 500).++ */++const assert = require('assert');++const { boot, check, done } = require('./stageb/boot');++++/** The response, or a failure the moment it is clear no response is coming. */++function withDeadline(promise, what) {++ return Promise.race([++ promise,++ new Promise((_, reject) => {++ const timer = setTimeout(() => reject(new Error(`${what}: no response after 3000ms — the rejection never reached the error handler`)), 3000);++ promise.then(() => clearTimeout(timer), () => clearTimeout(timer));++ }),++ ]);++}++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++++ await check('a failing Host resolve answers 500 instead of hanging the request', async () => {++ const original = t.ctx.tenant.resolve;++ t.ctx.tenant.resolve = async () => { throw new Error('transient database error'); };++ try {++ const r = await withDeadline(t.get('alice.openvibe.host', '/'), 'resolve');++ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);++ assert.match(r.text, /Something went wrong/);++ } finally {++ t.ctx.tenant.resolve = original;++ }++ });++++ await check('a failing tenant handler answers 500 instead of hanging the request', async () => {++ const project = await t.project(alice, 'Dispatch errors');++ const site = await t.site(alice, project.id, 'alice');++ await t.deploy(alice, site.id, { 'index.html': 'hello' });++ const original = t.ctx.tenant.handle;++ t.ctx.tenant.handle = async () => { throw new Error('transient database error'); };++ try {++ const r = await withDeadline(t.get('alice.openvibe.host', '/'), 'handle');++ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);++ } finally {++ t.ctx.tenant.handle = original;++ }++ });++++ await check('the site is served normally again once the failure passes', async () => {++ const r = await t.get('alice.openvibe.host', '/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, 'hello');++ });++++ await t.close();++ done();++})();
@@ -114,12 +114,19 @@ async function createApp(opts = {}) {
@@ -114,12 +114,19 @@ async function createApp(opts = {}) {
app.use(httpMetrics.middleware);
app.use(httpMetrics.middleware);
// ── Host dispatch: tenant sites never reach anything below ──
// ── Host dispatch: tenant sites never reach anything below ──
+ // resolve/handle read the database (ADR-035: async), so a rejection here must reach the error
+ // handler below: Express 4 does not catch a rejected async handler, which would hang the request.
app.use(async (req, res, next) => {
app.use(async (req, res, next) => {
- const target = await tenant.resolve(req.headers.host);
- req.hostTarget = target.kind;
- if (target.kind === 'dashboard') return next();
- if (target.kind === 'site') return await tenant.handle(req, res, target.site);
- return tenant.unknownHost(req, res);
+ try {
+ const target = await tenant.resolve(req.headers.host);
+ req.hostTarget = target.kind;
+ if (target.kind === 'dashboard') return next();
+ if (target.kind === 'site') return await tenant.handle(req, res, target.site);
+ return tenant.unknownHost(req, res);
+ } catch (err) {
+ req.hostTarget = req.hostTarget || 'unknown';
+ return next(err);
+ }
});
});
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * Regression (ADR-035): the Host dispatch middleware in server/app.js awaits tenant.resolve and
+ * tenant.handle, which read the database asynchronously. Express 4 does not catch a rejected async
+ * handler: a transient database error used to escape as an unhandled rejection and leave the request
+ * hanging with no response. It must reach the app's error handler instead (a logged 500).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./stageb/boot');
+
+/** The response, or a failure the moment it is clear no response is coming. */
+function withDeadline(promise, what) {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ const timer = setTimeout(() => reject(new Error(`${what}: no response after 3000ms — the rejection never reached the error handler`)), 3000);
+ promise.then(() => clearTimeout(timer), () => clearTimeout(timer));
+ }),
+ ]);
+}
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+
+ await check('a failing Host resolve answers 500 instead of hanging the request', async () => {
+ const original = t.ctx.tenant.resolve;
+ t.ctx.tenant.resolve = async () => { throw new Error('transient database error'); };
+ try {
+ const r = await withDeadline(t.get('alice.openvibe.host', '/'), 'resolve');
+ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);
+ assert.match(r.text, /Something went wrong/);
+ } finally {
+ t.ctx.tenant.resolve = original;
+ }
+ });
+
+ await check('a failing tenant handler answers 500 instead of hanging the request', async () => {
+ const project = await t.project(alice, 'Dispatch errors');
+ const site = await t.site(alice, project.id, 'alice');
+ await t.deploy(alice, site.id, { 'index.html': 'hello' });
+ const original = t.ctx.tenant.handle;
+ t.ctx.tenant.handle = async () => { throw new Error('transient database error'); };
+ try {
+ const r = await withDeadline(t.get('alice.openvibe.host', '/'), 'handle');
+ assert.strictEqual(r.status, 500, `expected 500, got ${r.status}: ${r.text.slice(0, 120)}`);
+ } finally {
+ t.ctx.tenant.handle = original;
+ }
+ });
+
+ await check('the site is served normally again once the failure passes', async () => {
+ const r = await t.get('alice.openvibe.host', '/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, 'hello');
+ });
+
+ await t.close();
+ done();
+})();