@andrian.yablonskyy/thub-coordinator 1.0.41 → 1.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.41",
3
+ "version": "1.0.42",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
package/src/api/sse.js CHANGED
@@ -28,6 +28,12 @@ function attachJobStream(req, res, { jobId, services }){
28
28
  res.status(404).json({ error: 'Unknown job' });
29
29
  return;
30
30
  }
31
+ // HEAD (a monitoring check, a proxy): no stream — one opened for it would
32
+ // never end, and block every later request on the same connection.
33
+ if (req.method === 'HEAD'){
34
+ res.status(204).end();
35
+ return;
36
+ }
31
37
 
32
38
  res.writeHead(200, {
33
39
  'Content-Type': 'text/event-stream',
package/src/web/routes.js CHANGED
@@ -395,6 +395,14 @@ function createWebRouter({ services, config }){
395
395
  const LIVE_PING_MS = 25_000,
396
396
  LIVE_SESSION_CHECK_MS = 15_000;
397
397
  router.get('/live', (req, res) => {
398
+ // HEAD (live.js asks it whether the session is still alive): answer and
399
+ // finish. Express sends HEAD to this GET handler, and a stream opened
400
+ // for it would never end: the browser — or a proxy — reuses the
401
+ // connection once the headers are in, and every later request on it
402
+ // would queue behind that response forever (stuck page loads).
403
+ if (req.method === 'HEAD'){
404
+ return res.status(204).end();
405
+ }
398
406
  res.writeHead(200, {
399
407
  'Content-Type': 'text/event-stream',
400
408
  'Cache-Control': 'no-cache, no-transform',
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @file packages/coordinator/test/stream-head.test.js
3
+ * @description Tests: a HEAD request to a stream endpoint (/live, job log streams) finishes, and doesn't block its keep-alive connection
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ http = require('node:http'),
20
+ os = require('node:os'),
21
+ path = require('node:path'),
22
+ { loadConfig } = require('../src/config'),
23
+ { buildServices, createApp } = require('../src/server');
24
+
25
+ // What a browser does: one keep-alive connection, reused as soon as a
26
+ // response is complete — and a HEAD response is complete with its headers.
27
+ function oneConnection(port){
28
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }),
29
+ request = (method, url, headers = {}) => new Promise((resolve, reject) => {
30
+ const req = http.request({ host: '127.0.0.1', port, method, path: url, headers, agent }, (res) => {
31
+ res.resume();
32
+ res.on('end', () => resolve(res.statusCode));
33
+ });
34
+ req.setTimeout(3000, () => req.destroy(new Error(`${method} ${url}: no answer within 3 s — the connection is blocked`)));
35
+ req.on('error', reject);
36
+ req.end();
37
+ });
38
+ return { request, close: () => agent.destroy() };
39
+ }
40
+
41
+ test('HEAD on a stream endpoint answers at once and leaves the connection usable (no stuck page loads)', async (t) => {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-head-')),
43
+ file = path.join(dir, 'coordinator.json');
44
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
45
+ const config = loadConfig(file),
46
+ services = buildServices(config),
47
+ server = createApp(config, services).listen(0, '127.0.0.1');
48
+ t.after(() => new Promise((r) => {
49
+ server.closeAllConnections?.();
50
+ server.close(r);
51
+ }));
52
+ await new Promise((r) => server.on('listening', r));
53
+ const port = server.address().port;
54
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
55
+ const cookie = (await fetch(`http://127.0.0.1:${port}/login`, {
56
+ method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
57
+ })).headers.get('set-cookie').split(';')[0],
58
+ { agent, token } = services.agents.create({ name: 'ci', kind: 'ci' }),
59
+ // A Client the job can wait for (a job nothing could ever run is refused).
60
+ registered = services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] }),
61
+ // An active job: its streams stay open for a GET.
62
+ job = registered && services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
63
+
64
+ for (const [url, headers]of [
65
+ ['/live', { cookie, 'X-Thub-Live': '1' }], // what live.js probes
66
+ [`/jobs/${job.id}/stream`, { cookie }],
67
+ [`/api/v1/jobs/${job.id}/logs/stream`, { authorization: `Bearer ${token}` }]
68
+ ]){
69
+ const conn = oneConnection(port);
70
+ t.after(conn.close);
71
+ assert.equal(await conn.request('HEAD', url, headers), 204, url);
72
+ // The next request on the very same connection: answered, not queued forever.
73
+ assert.equal(await conn.request('GET', '/js/copy-to-clipboard.js'), 200, `after HEAD ${url}`);
74
+ }
75
+ });