@mmmbuto/nexuscrew 0.9.15 → 0.9.16

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/lib/server.js CHANGED
@@ -71,6 +71,7 @@ const { createNotifier } = require('./notify/notifier.js');
71
71
  const { notifyRoutes } = require('./notify/routes.js');
72
72
  const { createNpmUpdater } = require('./update/manager.js');
73
73
  const { createDiagnostics } = require('./diagnostics/store.js');
74
+ const { createDropCounter } = require('./ws/drop-counter.js');
74
75
  const { diagnosticsRoutes } = require('./diagnostics/routes.js');
75
76
  const vlNodeStore = require('./vl-nodes/store.js');
76
77
  const { createBroker: createVlNodeBroker } = require('./vl-nodes/broker.js');
@@ -146,6 +147,8 @@ function createServer(opts = {}) {
146
147
  const asksStore = createAsksStore({ dir: notifyDir });
147
148
  const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
148
149
  const diagnostics = opts.diagnostics || createDiagnostics();
150
+ // Contatore cadute WS (rolling 10 min): alimenta i meta dei log di bridge.
151
+ const wsDropCounter = createDropCounter();
149
152
  const updater = opts.updateManager || createNpmUpdater({
150
153
  currentVersion: VERSION,
151
154
  home: cfg.home || os.homedir(),
@@ -942,6 +945,7 @@ function createServer(opts = {}) {
942
945
  }));
943
946
  api.use('/cells', cellsRoutes({
944
947
  fleetP,
948
+ diagnostics,
945
949
  instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
946
950
  submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
947
951
  engine: meta && meta.engine,
@@ -1139,7 +1143,13 @@ function createServer(opts = {}) {
1139
1143
  // lato browser, che il client riconnette senza richiedere refresh pagina.
1140
1144
  const heartbeat = setInterval(() => {
1141
1145
  for (const client of wss.clients) {
1142
- if (client.isAlive === false) { try { client.terminate(); } catch (_) {} continue; }
1146
+ if (client.isAlive === false) {
1147
+ // Marca il motivo PRIMA del terminate: il close event (1006) nel bridge
1148
+ // lo classifichera' come heartbeat-timeout invece di «drop TCP generico».
1149
+ try { client.__ncCloseReason = 'heartbeat-timeout'; } catch (_) {}
1150
+ try { client.terminate(); } catch (_) {}
1151
+ continue;
1152
+ }
1143
1153
  client.isAlive = false;
1144
1154
  try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
1145
1155
  }
@@ -1234,6 +1244,8 @@ function createServer(opts = {}) {
1234
1244
  defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
1235
1245
  ptyGrace,
1236
1246
  onAttach: (sess) => attachedWs.set(ws, sess),
1247
+ diagnostics,
1248
+ dropCounter: wsDropCounter,
1237
1249
  });
1238
1250
  ws.on('close', () => attachedWs.delete(ws));
1239
1251
  });
package/lib/ws/bridge.js CHANGED
@@ -118,10 +118,24 @@ function sendJson(ws, value) {
118
118
  }
119
119
 
120
120
  function bindWs(ws, deps) {
121
- const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null } = deps;
121
+ const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null } = deps;
122
122
  let record = null;
123
123
  let attached = false;
124
124
  let session = null;
125
+ let closeLogged = false;
126
+ // UNA sola riga diagnostica per socket: la prima chiusura vince. Le
127
+ // successive (close + error sullo stesso socket) non generano rumore.
128
+ const logClose = (level, event, message, meta = {}) => {
129
+ if (closeLogged) return;
130
+ closeLogged = true;
131
+ if (!diagnostics) return;
132
+ try { diagnostics.record(level, 'ws', event, message, { cell: session || undefined, ...meta }); } catch (_) {}
133
+ };
134
+ const countDrop = () => {
135
+ if (!dropCounter) return {};
136
+ const snap = dropCounter.recordDrop(session);
137
+ return { drops: snap.drops };
138
+ };
125
139
 
126
140
  const detach = () => {
127
141
  if (!record || record.ws !== ws) return;
@@ -144,6 +158,11 @@ function bindWs(ws, deps) {
144
158
  // fino al close event tiene in piedi un handle senza scopo.
145
159
  clearAttachTimer();
146
160
  sendJson(ws, { type: 'error', reason });
161
+ // Chiusura INITIATA DAL SERVER: il motivo e' qui, non nel close event.
162
+ // Level warn = sempre visibile (non richiede verbose).
163
+ logClose('warn', 'WS_SERVER_CLOSE', `Server closed socket: ${reason}`, {
164
+ reason: String(reason).slice(0, 48), closeCode: Number(code) || undefined, ...countDrop(),
165
+ });
147
166
  try { ws.close(code, reason); } catch (_) {}
148
167
  }
149
168
 
@@ -197,12 +216,14 @@ function bindWs(ws, deps) {
197
216
  attached = true;
198
217
  session = record.session;
199
218
  onAttach(session, ws);
219
+ logReattach();
200
220
  sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
201
221
  return;
202
222
  }
203
223
  attached = true;
204
224
  clearAttachTimer();
205
225
  session = msg.session;
226
+ logReattach();
206
227
  // Resize default: when nobody else is attached, drive the session size so a
207
228
  // small phone gets a usable (non-clipped) view and clean line editing. When a
208
229
  // real terminal is already attached, default to ignore-size so we don't shrink
@@ -250,6 +271,9 @@ function bindWs(ws, deps) {
250
271
  record.pty.onExit((info) => {
251
272
  record.ended = true;
252
273
  record.exitCode = info && info.exitCode;
274
+ logClose('notice', 'PTY_EXIT', 'Sessione terminata dal PTY', {
275
+ exitCode: typeof record.exitCode === 'number' ? record.exitCode : undefined,
276
+ });
253
277
  if (!record.ws) return;
254
278
  sendJson(record.ws, { type: 'exit', code: record.exitCode });
255
279
  try { record.ws.close(1000, 'exit'); } catch (_) {}
@@ -272,8 +296,37 @@ function bindWs(ws, deps) {
272
296
  else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
273
297
  }
274
298
 
299
+ // Alla riconnessione la DURATA della caduta e' il gap close->reopen della
300
+ // stessa sessione. Ritorni con gap = notice (sempre visibili); un primo
301
+ // attach senza storia e' debug (solo verbose).
302
+ function logReattach() {
303
+ if (!diagnostics) return;
304
+ if (!dropCounter) return;
305
+ try {
306
+ const reopen = dropCounter.recordReopen(session);
307
+ if (reopen.gapMs != null) {
308
+ diagnostics.record('notice', 'ws', 'WS_REATTACHED', 'Riconnesso dopo una caduta', {
309
+ cell: session, gapMs: reopen.gapMs, drops: reopen.drops,
310
+ });
311
+ } else {
312
+ diagnostics.record('debug', 'ws', 'WS_ATTACHED', 'Attach completato', { cell: session });
313
+ }
314
+ } catch (_) {}
315
+ }
316
+
275
317
  ws.on('message', onMessage);
276
- ws.on('close', () => { clearAttachTimer(); detach(); });
318
+ ws.on('close', (code) => {
319
+ clearAttachTimer(); detach();
320
+ if (closeLogged) return;
321
+ const closeCode = Number(code) || undefined;
322
+ if (ws.__ncCloseReason === 'heartbeat-timeout') {
323
+ logClose('warn', 'WS_HEARTBEAT_DROPPED', 'Heartbeat scaduto: connessione mezzo-aperta terminata', { closeCode, ...countDrop() });
324
+ } else if (closeCode === 1006) {
325
+ logClose('warn', 'WS_ABNORMAL_CLOSE', 'Chiusura senza handshake (drop TCP o terminate)', { closeCode, ...countDrop() });
326
+ } else {
327
+ logClose('notice', 'WS_CLIENT_CLOSE', 'Il client ha chiuso la connessione', { closeCode, ...countDrop() });
328
+ }
329
+ });
277
330
  ws.on('error', () => { clearAttachTimer(); detach(); });
278
331
  }
279
332
  module.exports = { bindWs, clamp, createPtyGraceStore, PTY_GRACE_MS };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ // Contatore di cadute WebSocket: trasforma «celle che vanno e vengono»
3
+ // in numeri — quante cadute nella finestra (default 10 minuti) e, alla
4
+ // riconnessione, quanto è durata la caduta (gap tra close e reopen).
5
+ // In memoria, zero I/O: i valori viaggiano nei meta dei log di bridge.js.
6
+ function createDropCounter({ windowMs = 10 * 60 * 1000, now = Date.now } = {}) {
7
+ const drops = []; // { ts, sessionId }
8
+ const lastClose = new Map(); // sessionId -> ts
9
+ const key = (s) => String(s == null ? '' : s);
10
+ function prune(ts) {
11
+ const cutoff = ts - windowMs;
12
+ while (drops.length && drops[0].ts < cutoff) drops.shift();
13
+ }
14
+ return {
15
+ recordDrop(session, ts = now()) {
16
+ const k = key(session);
17
+ drops.push({ ts, sessionId: k });
18
+ if (k) lastClose.set(k, ts);
19
+ prune(ts);
20
+ return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
21
+ },
22
+ // Alla riconnessione: la durata della caduta e' il gap close->reopen.
23
+ // Consuma il last close: una connessione lunga e sana non e' «ritorno».
24
+ recordReopen(session, ts = now()) {
25
+ const k = key(session);
26
+ const last = lastClose.get(k);
27
+ lastClose.delete(k);
28
+ prune(ts);
29
+ return { gapMs: last == null ? null : Math.max(0, ts - last), drops: drops.length };
30
+ },
31
+ snapshot(ts = now()) {
32
+ prune(ts);
33
+ return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
34
+ },
35
+ };
36
+ }
37
+ module.exports = { createDropCounter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -23,7 +23,7 @@ Five nouns carry almost everything:
23
23
  - **Node** — one installation on one machine. Its identity is an opaque
24
24
  `instanceId`; the human-readable name is a **label**, and two nodes may
25
25
  legitimately carry the same one. **Address things by id, never by name.**
26
- - **Cell** — one stable working identity (`Dev`, `Research`, …) bound to one
26
+ - **Cell** — one stable working identity (`Alpha`, `Beta`, …) bound to one
27
27
  tmux session and one engine. A cell is not a process: it survives restarts of
28
28
  the service, and stopping it does not end the work it was doing.
29
29
  - **Engine** — what a cell runs: an AI CLI, a plain shell, a command in a