Skip to content

Commit cf7f540

Browse files
committed
Address comments
1 parent 4e0078e commit cf7f540

3 files changed

Lines changed: 186 additions & 49 deletions

File tree

lib/runtime/transports/http.js

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const debug = require('debug')('rclnodejs:runtime:http');
1313
const { Connection } = require('../connection.js');
1414
const { TransportAdapter } = require('../transport_adapter.js');
1515

16-
// Map dispatcher error codes to HTTP status codes. Anything not listed
17-
// here falls back to 500 — see the README for the full table.
16+
// Map dispatcher error codes to HTTP status codes. Codes not listed
17+
// here fall back to 500.
1818
const _STATUS_BY_CODE = Object.freeze({
1919
not_exposed: 404,
2020
not_implemented: 501,
@@ -64,9 +64,23 @@ class HttpRequestConnection extends Connection {
6464
this._payload = payload;
6565
this._replied = false;
6666
this._closed = false;
67-
// Synthesised frame id so the dispatcher can correlate even though
68-
// the HTTP path doesn't carry one on the wire.
67+
// Placeholder frame id. The dispatcher reads `frame.id` from incoming
68+
// frames and echoes it on the reply; over HTTP we discard the reply id
69+
// and write the body directly, so the value just needs to be present.
6970
this._id = '__http__';
71+
72+
// NOTE: we deliberately do *not* wire `req.on('close')` /
73+
// `res.on('close')` to `_emitCloseOnce()` here. Doing so would
74+
// catch client-side disconnects, but the close event also fires
75+
// on normal end-of-response, racing the still-in-flight rcl
76+
// reply callback and tearing down the dispatcher's lazy Client
77+
// mid-flight (rmw throws "client will not receive response" and
78+
// crashes the process). For now, dispatcher cleanup runs from
79+
// `send()` only — a long-running call that the client gave up
80+
// on holds onto its Publisher/Client until `send()` completes.
81+
// The leak is bounded (one entry per capability per request)
82+
// and acceptable until we have a way to drive cleanup that
83+
// doesn't race the rcl reply path.
7084
}
7185

7286
/**
@@ -160,15 +174,16 @@ class HttpRequestConnection extends Connection {
160174
}
161175

162176
/**
163-
* Layer-2 HTTP adapter for the Web Runtime.
177+
* HTTP adapter for the Web Runtime.
164178
*
165179
* Exposes `call` and `publish` capabilities over plain HTTP:
166180
*
167181
* POST /capability/call/<name>
168182
* POST /capability/publish/<name>
169183
*
170-
* Subscriptions stay on the WebSocket transport (see WEB_RUNTIME_ROADMAP.md
171-
* "Why no SSE?"). Anything that isn't `POST /capability/call/<name>` or
184+
* Subscriptions stay on the WebSocket transport (one HTTP connection
185+
* per inbound message would burn the browser's per-origin budget).
186+
* Anything that isn't `POST /capability/call/<name>` or
172187
* `POST /capability/publish/<name>` returns a 404 / 405 — the adapter
173188
* does not serve unrelated routes.
174189
*
@@ -195,7 +210,7 @@ class HttpTransport extends TransportAdapter {
195210
super();
196211
this.port = options.port != null ? options.port : 9001;
197212
this.host = options.host != null ? options.host : '::';
198-
this.basePath = options.basePath || '/capability';
213+
this.basePath = _normaliseBasePath(options.basePath);
199214
this.verifyRequest = options.verifyRequest;
200215
this._server = null;
201216
this._onConnection = null;
@@ -230,7 +245,15 @@ class HttpTransport extends TransportAdapter {
230245
if (!this._server) return;
231246
const server = this._server;
232247
this._server = null;
233-
return new Promise((resolve) => server.close(() => resolve()));
248+
return new Promise((resolve) => {
249+
// Force-close keep-alive sockets so server.close() doesn't hang
250+
// waiting for clients (notably Node's fetch/undici, which keeps
251+
// sockets warm by default). Available since Node 18.2.
252+
if (typeof server.closeAllConnections === 'function') {
253+
server.closeAllConnections();
254+
}
255+
server.close(() => resolve());
256+
});
234257
}
235258

236259
// ---------- internals ----------
@@ -276,7 +299,7 @@ class HttpTransport extends TransportAdapter {
276299
});
277300
}
278301

279-
const tail = pathname.slice(this.basePath.length + 1); // strip "/capability/"
302+
const tail = pathname.slice(this.basePath.length + 1); // strip basePath + '/'
280303
// tail = "<kind>/<rest...>"; first segment is the kind, rest is the
281304
// ROS name (which itself can contain slashes).
282305
const slash = tail.indexOf('/');
@@ -355,21 +378,24 @@ function _readJsonBody(req, maxBytes, cb) {
355378
};
356379

357380
const ctype = (req.headers['content-type'] || '').toLowerCase();
381+
// Only the media-type segment matters; ignore parameters like
382+
// `; charset=utf-8` and reject sneaky values such as
383+
// `text/plain;application/json` that include the right substring
384+
// but mean something else.
385+
const mediaType = ctype.split(';')[0].trim();
358386
if (
359387
req.method === 'POST' &&
360-
!ctype.includes('application/json') &&
361-
!ctype.startsWith('application/json')
388+
ctype !== '' &&
389+
mediaType !== 'application/json'
362390
) {
363391
// Be lenient: empty bodies + missing content-type are treated as
364392
// "publish with no fields", same as `{}`. Only reject explicit
365393
// non-JSON content types.
366-
if (ctype && ctype !== '') {
367-
const e = new Error(
368-
`unsupported content-type: ${ctype} (expected application/json)`
369-
);
370-
e.code = 'invalid_content_type';
371-
return finish(e);
372-
}
394+
const e = new Error(
395+
`unsupported content-type: ${ctype} (expected application/json)`
396+
);
397+
e.code = 'invalid_content_type';
398+
return finish(e);
373399
}
374400
let total = 0;
375401
const chunks = [];
@@ -403,4 +429,22 @@ function _readJsonBody(req, maxBytes, cb) {
403429
});
404430
}
405431

432+
function _normaliseBasePath(value) {
433+
if (value === undefined || value === null || value === '') {
434+
return '/capability';
435+
}
436+
if (typeof value !== 'string') {
437+
throw new TypeError(
438+
`HttpTransport: basePath must be a string, got ${typeof value}`
439+
);
440+
}
441+
// Ensure single leading slash, no trailing slash.
442+
let p = value.replace(/\/+$/, '');
443+
if (!p.startsWith('/')) p = '/' + p;
444+
if (p === '') {
445+
throw new TypeError('HttpTransport: basePath must not be empty');
446+
}
447+
return p;
448+
}
449+
406450
module.exports = { HttpTransport, HttpRequestConnection };

test/test-web-http.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,85 @@ describe('rclnodejs/web — HTTP transport (call + publish)', function () {
326326
/ws must start with ws:\/\/ or wss:\/\//
327327
);
328328
});
329+
330+
it('call() before connect() rejects asynchronously, never throws sync', async function () {
331+
// Regression: previously `(this._http || this._ws).call(...)` could
332+
// throw a synchronous TypeError when constructed with ws:// and
333+
// connect() hadn't run yet. Now call() is async and rejects.
334+
const r = new RosClient('ws://127.0.0.1:1');
335+
const p = r.call('/never_used', {});
336+
assert.ok(typeof p.then === 'function', 'call() must return a Promise');
337+
await assert.rejects(p);
338+
await r.close();
339+
});
340+
341+
it('stop() does not hang when keep-alive sockets are open', async function () {
342+
// Open a keep-alive request and don't read the body, then stop().
343+
// closeAllConnections() must let server.close() resolve promptly.
344+
const t0 = Date.now();
345+
const stopPromise = (async () => {
346+
await fetch(httpBase + '/capability/call/http_add', {
347+
method: 'POST',
348+
headers: { 'content-type': 'application/json' },
349+
body: '{}',
350+
}).catch(() => undefined);
351+
})();
352+
await stopPromise;
353+
// Build a one-off runtime so we can stop it cleanly here.
354+
const tmp = createRuntime({
355+
node,
356+
transport: new HttpTransport({ port: 0, host: '127.0.0.1' }),
357+
});
358+
tmp.expose({ call: { '/x': 'std_srvs/srv/Empty' } });
359+
await tmp.start();
360+
const port = tmp.transports[0].port;
361+
// Fire-and-forget a request; ignore its outcome. The keep-alive
362+
// socket would normally hold server.close() open for ~5s.
363+
fetch(`http://127.0.0.1:${port}/capability/call/x`, {
364+
method: 'POST',
365+
headers: { 'content-type': 'application/json' },
366+
body: '{}',
367+
}).catch(() => undefined);
368+
await new Promise((r) => setTimeout(r, 50));
369+
const t1 = Date.now();
370+
await tmp.stop();
371+
assert.ok(
372+
Date.now() - t1 < 2000,
373+
`stop() took ${Date.now() - t1}ms; expected fast shutdown`
374+
);
375+
assert.ok(Date.now() - t0 < 5000, 'whole test should finish under 5s');
376+
});
377+
378+
it('normalises basePath: leading/trailing slash, missing leading', async function () {
379+
const cases = [
380+
{ input: 'cap', expected: '/cap' },
381+
{ input: '/cap/', expected: '/cap' },
382+
{ input: '/cap///', expected: '/cap' },
383+
];
384+
for (const { input, expected } of cases) {
385+
const t = new HttpTransport({ basePath: input });
386+
assert.strictEqual(
387+
t.basePath,
388+
expected,
389+
`for ${JSON.stringify(input)}`
390+
);
391+
}
392+
assert.throws(
393+
() => new HttpTransport({ basePath: 42 }),
394+
/basePath must be a string/
395+
);
396+
});
397+
398+
it('rejects sneaky content-type that includes application/json', async function () {
399+
const res = await fetch(httpBase + '/capability/call/http_add', {
400+
method: 'POST',
401+
headers: { 'content-type': 'text/plain;application/json' },
402+
body: '{}',
403+
});
404+
assert.strictEqual(res.status, 400);
405+
const body = await res.json();
406+
assert.strictEqual(body.code, 'invalid_content_type');
407+
});
329408
});
330409
});
331410

web/client.js

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,13 @@ export class RosClient {
369369
this.url = httpUrl || wsUrl;
370370
this._http = httpUrl ? new _HttpLink(httpUrl) : null;
371371
this._wsUrl = wsUrl;
372-
this._ws = null; // built lazily by _ensureWs() unless eager
373-
// Eagerly open WS only when the user *explicitly* asked for it
374-
// (passed `ws://...` or `{ws}`). When we *derived* a sibling WS
375-
// URL from an HTTP base, it's lazy — most HTTP-only callers
376-
// never subscribe.
372+
// Eagerly construct (but don't yet open) the WS link when the user
373+
// explicitly asked for it. When the WS URL was *derived* from an
374+
// HTTP base, leave construction lazy — most HTTP-only callers
375+
// never subscribe and never need the WS sibling at all.
376+
this._ws = wsExplicit && wsUrl ? new _WsLink(wsUrl) : null;
377377
this._wsEager = !!wsExplicit;
378-
this._wsConnect = null; // memoised connect promise
378+
this._wsConnect = null; // memoised connect promise (in-flight or settled)
379379
}
380380

381381
/** Open the link(s). */
@@ -392,8 +392,20 @@ export class RosClient {
392392
/** Close the underlying link(s). */
393393
async close() {
394394
const tasks = [];
395-
if (this._ws) tasks.push(this._ws.close());
396395
if (this._http) tasks.push(this._http.close());
396+
if (this._wsConnect) {
397+
// WS is connecting or connected — wait for the open to settle,
398+
// then close. Swallow the open error: nothing to close in that case.
399+
tasks.push(
400+
this._wsConnect.then(
401+
(link) => link.close(),
402+
() => undefined
403+
)
404+
);
405+
} else if (this._ws) {
406+
// Constructed eagerly but connect() never ran — no-op close.
407+
tasks.push(this._ws.close());
408+
}
397409
await Promise.all(tasks);
398410
}
399411

@@ -410,36 +422,38 @@ export class RosClient {
410422
{ code: 'transport_unavailable' }
411423
);
412424
}
413-
if (this._ws) return this._ws;
414-
if (!this._wsConnect) {
415-
const link = new _WsLink(this._wsUrl);
416-
this._wsConnect = link.connect().then(
417-
() => {
418-
this._ws = link;
419-
return link;
420-
},
421-
(err) => {
422-
this._wsConnect = null; // allow a retry on next subscribe()
423-
throw Object.assign(
424-
new Error(
425-
`failed to open WebSocket sibling at ${this._wsUrl}: ${err && err.message ? err.message : String(err)}`
426-
),
427-
{ code: 'transport_unavailable', cause: err }
428-
);
429-
}
430-
);
431-
}
425+
if (this._wsConnect) return this._wsConnect; // open is in-flight or done.
426+
// Reuse the eagerly-constructed link if present, otherwise build it
427+
// now (HTTP-derived sibling case).
428+
if (!this._ws) this._ws = new _WsLink(this._wsUrl);
429+
const link = this._ws;
430+
this._wsConnect = link.connect().then(
431+
() => link,
432+
(err) => {
433+
this._wsConnect = null; // allow a retry on next subscribe()
434+
throw Object.assign(
435+
new Error(
436+
`failed to open WebSocket sibling at ${this._wsUrl}: ${err && err.message ? err.message : String(err)}`
437+
),
438+
{ code: 'transport_unavailable', cause: err }
439+
);
440+
}
441+
);
432442
return this._wsConnect;
433443
}
434444

435445
// -- verb API --
436446

437-
call(capability, payload) {
438-
return (this._http || this._ws).call(capability, payload);
447+
async call(capability, payload) {
448+
if (this._http) return this._http.call(capability, payload);
449+
const ws = await this._ensureWs();
450+
return ws.call(capability, payload);
439451
}
440452

441-
publish(capability, payload) {
442-
return (this._http || this._ws).publish(capability, payload);
453+
async publish(capability, payload) {
454+
if (this._http) return this._http.publish(capability, payload);
455+
const ws = await this._ensureWs();
456+
return ws.publish(capability, payload);
443457
}
444458

445459
async subscribe(capability, callback) {

0 commit comments

Comments
 (0)