@mmmbuto/nexuscrew 0.9.51 → 0.9.53

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.
@@ -9,6 +9,7 @@ const {
9
9
  PACKAGE_NAME, parseVersion, scrubError, adoptUpdateLock, releaseUpdateLock, readState, writeState,
10
10
  stableRuntimeDir,
11
11
  } = require('./core.js');
12
+ const npmEnv = require('./npm-env.js');
12
13
 
13
14
  // da revisione — rigenerazione delle definizioni di boot dopo l'installazione.
14
15
  //
@@ -233,6 +234,21 @@ function args(argv) {
233
234
  return out;
234
235
  }
235
236
 
237
+ // Versione del processo che ascolta su `port`, dal suo /api/config. null = non leggibile (non si presume).
238
+ async function readRuntimeVersion(port, token, opts = {}) {
239
+ if (typeof opts.runtimeVersionImpl === 'function') return opts.runtimeVersionImpl(port, token);
240
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
241
+ if (typeof fetchImpl !== 'function') return null;
242
+ try {
243
+ const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
244
+ headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout?.(5000),
245
+ });
246
+ if (r.status !== 200) return null;
247
+ const v = (await r.json()).version;
248
+ return typeof v === 'string' && v ? v : null;
249
+ } catch (_) { return null; }
250
+ }
251
+
236
252
  async function restartRuntime(opts = {}) {
237
253
  const home = opts.home || os.homedir();
238
254
  const commands = opts.commands || require('../cli/commands.js');
@@ -298,7 +314,15 @@ async function restartRuntime(opts = {}) {
298
314
  await wait(100);
299
315
  }
300
316
  if (!released) throw new Error(`restart portatile fallito: porta ${port} non liberata`);
301
- commands.startPortable({ platform, home });
317
+ const launched = commands.startPortable({ platform, home });
318
+ if (launched && launched.started === false) {
319
+ // «già in esecuzione»/non avviato NON e' un successo: lo e' solo se il processo che risponde e' gia' alla
320
+ // versione attesa (verificato), altrimenti l'update ha installato codice che nessuno esegue.
321
+ const running = opts.expectedVersion ? await readRuntimeVersion(port, token, opts) : null;
322
+ if (!opts.expectedVersion || running !== opts.expectedVersion) {
323
+ throw new Error(`avvio portatile non riuscito (${launched.reason || 'started:false'}); versione in esecuzione ${running || 'non leggibile'}${opts.expectedVersion ? `, attesa ${opts.expectedVersion}` : ''}`);
324
+ }
325
+ }
302
326
  mode = 'portable';
303
327
  }
304
328
  }
@@ -309,6 +333,12 @@ async function restartRuntime(opts = {}) {
309
333
  ...(opts.healthProbeImpl ? { probeImpl: opts.healthProbeImpl } : {}),
310
334
  });
311
335
  if (!healthy) throw new Error(`NexusCrew ${mode} non healthy su 127.0.0.1:${port} dopo il restart`);
336
+ if (opts.expectedVersion) {
337
+ const running = await readRuntimeVersion(port, token, opts);
338
+ if (running !== opts.expectedVersion) {
339
+ throw new Error(`NexusCrew ${mode} risponde ma esegue la versione ${running || 'non leggibile'}, attesa ${opts.expectedVersion}`);
340
+ }
341
+ }
312
342
  }
313
343
  return mode;
314
344
  }
@@ -320,10 +350,20 @@ async function runUpdate(opts = {}) {
320
350
  const statusPath = opts.statusPath || path.join(home, '.nexuscrew', 'npm-update.json');
321
351
  const workDir = opts.cwd || stableRuntimeDir(home);
322
352
  const execImpl = opts.execImpl || execFileSync;
323
- const readInstalledVersion = opts.readInstalledVersion || (() => {
324
- const p = path.resolve(__dirname, '..', '..', 'package.json');
325
- return JSON.parse(fs.readFileSync(p, 'utf8')).version;
353
+ const packageRoot = opts.packageRoot || path.resolve(__dirname, '..', '..');
354
+ const log = opts.log || console.log;
355
+ // La versione si legge dal package.json del percorso ESEGUITO (realpath), non con `npm ls`: e' il codice che il
356
+ // riavvio caricherebbe davvero.
357
+ const readInstalledVersion = opts.readInstalledVersion || (() => npmEnv.readInstalledFrom(packageRoot).version);
358
+ const installedPath = () => { try { return npmEnv.redact((opts.readInstalledFromImpl || (() => npmEnv.readInstalledFrom(packageRoot)))().path, home); } catch (_) { return ''; } };
359
+ // npm = STESSO node del demone; la preflight prova, prima di installare, che `npm -g` scriverebbe dove il codice vive.
360
+ const invocation = (opts.npmInvocationImpl || npmEnv.resolveNpmInvocation)({ execPath: opts.execPath || process.execPath });
361
+ const runNpm = (npmArgs) => execImpl(invocation.bin, [...invocation.argvPrefix, ...npmArgs], {
362
+ stdio: 'inherit', timeout: 5 * 60 * 1000, cwd: workDir, ...(invocation.env ? { env: invocation.env } : {}),
326
363
  });
364
+ const logEnv = (phase) => log(`env-check[${phase}] ${npmEnv.describeEnv({
365
+ execPath: opts.execPath || process.execPath, envPath: opts.envPath !== undefined ? opts.envPath : process.env.PATH, home,
366
+ })} npm=${invocation.kind}${invocation.npmCli ? `:${npmEnv.redact(invocation.npmCli, home)}` : ''} pkg=${npmEnv.redact(packageRoot, home)}`);
327
367
  const preflightImpl = opts.preflightImpl || (({ version: expectedVersion = version } = {}) => {
328
368
  const bin = path.resolve(__dirname, '..', '..', 'bin', 'nexuscrew.js');
329
369
  const output = execFileSync(process.execPath, [bin, 'version'], {
@@ -357,11 +397,11 @@ async function runUpdate(opts = {}) {
357
397
  : undefined;
358
398
  try {
359
399
  writeState(statusPath, { ...previous, phase: 'installing', targetVersion: version, updaterPid: process.pid, lastError: '' });
360
- execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${version}`, '--no-audit', '--no-fund'], {
361
- stdio: 'inherit', timeout: 5 * 60 * 1000, cwd: workDir,
362
- });
400
+ logEnv('pre-install');
401
+ await (opts.npmPreflightImpl || npmEnv.npmPreflight)({ invocation, packageRoot, home });
402
+ runNpm(['install', '--global', `${PACKAGE_NAME}@${version}`, '--no-audit', '--no-fund']);
363
403
  const installed = String(readInstalledVersion() || '');
364
- if (installed !== version) throw new Error(`verifica installazione fallita: attesa ${version}, trovata ${installed || 'sconosciuta'}`);
404
+ if (installed !== version) throw new Error(`verifica installazione fallita: attesa ${version}, trovata ${installed || 'sconosciuta'}${installedPath() ? ` in ${installedPath()}` : ''}`);
365
405
  installedNew = true;
366
406
  await preflightImpl({ version, home });
367
407
  writeState(statusPath, { ...readState(statusPath), phase: 'restarting', updaterPid: process.pid, lastError: '' });
@@ -376,7 +416,8 @@ async function runUpdate(opts = {}) {
376
416
  home, running: wasRunning, execImpl, ...(opts.regenSeams || {}),
377
417
  ...(opts.regenBootImpl ? { regenImpl: opts.regenBootImpl } : {}),
378
418
  });
379
- const restartMode = await restartImpl({ home, ...(opts.runtimeSeams || {}) });
419
+ const restartMode = await restartImpl({ home, expectedVersion: version, ...(opts.runtimeSeams || {}) });
420
+ logEnv('post-restart');
380
421
  writeState(statusPath, {
381
422
  ...readState(statusPath), phase: 'installed', current: version, latest: version,
382
423
  available: false, blockedVersion: '', lastUpdatedAt: new Date().toISOString(), lastError: '',
@@ -386,9 +427,8 @@ async function runUpdate(opts = {}) {
386
427
  let rollbackError = null; let rolledBack = false;
387
428
  if (installedNew && parseVersion(previousVersion) && previousVersion !== version) {
388
429
  try {
389
- execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${previousVersion}`, '--no-audit', '--no-fund'], {
390
- stdio: 'inherit', timeout: 5 * 60 * 1000, cwd: workDir,
391
- });
430
+ logEnv('pre-rollback');
431
+ runNpm(['install', '--global', `${PACKAGE_NAME}@${previousVersion}`, '--no-audit', '--no-fund']);
392
432
  if (String(readInstalledVersion() || '') !== previousVersion) throw new Error(`rollback verify: attesa ${previousVersion}`);
393
433
  await preflightImpl({ version: previousVersion, home, rollback: true });
394
434
  // da revisione: il rollback riparte dalle stesse definizioni sane — il
@@ -399,7 +439,7 @@ async function runUpdate(opts = {}) {
399
439
  home, running: wasRunning, execImpl, ...(opts.regenSeams || {}),
400
440
  ...(opts.regenBootImpl ? { regenImpl: opts.regenBootImpl } : {}),
401
441
  });
402
- await restartImpl({ home, ...(opts.runtimeSeams || {}) });
442
+ await restartImpl({ home, expectedVersion: previousVersion, ...(opts.runtimeSeams || {}) });
403
443
  rolledBack = true;
404
444
  } catch (rollbackFailure) { rollbackError = rollbackFailure; }
405
445
  }
@@ -425,4 +465,4 @@ if (require.main === module) {
425
465
  .catch(() => { process.exitCode = 1; });
426
466
  }
427
467
 
428
- module.exports = { args, restartRuntime, runUpdate, regenBootDefinitions, healBootDefinitions };
468
+ module.exports = { args, readRuntimeVersion, restartRuntime, runUpdate, regenBootDefinitions, healBootDefinitions };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ // Lock di avvio condiviso: serializza chi EMETTE un avvio/riavvio del nodo (boot via start, `show`, `start`,
3
+ // `restart`, runner dell'update). Due avviatori simultanei creavano un nodo che ne uccideva un altro appena partito.
4
+ //
5
+ // Contro il deadlock:
6
+ // - sezione critica breve: si tiene solo mentre si EMETTE il comando (spawn/systemctl/launchctl), mai mentre si
7
+ // attende la salute del nodo, quindi il processo appena avviato non ha nulla da attendere;
8
+ // - rientrante nello STESSO processo (smartUp -> restart -> startPortable non si blocca da solo);
9
+ // - proprietario VERIFICATO: pid vivo e, se noto, stessa nascita del processo (pid riassegnato = lock orfano);
10
+ // - SCADENZA: oltre ttlMs il lock si considera stantio anche con il proprietario vivo, e si riprende;
11
+ // - attesa limitata: allo scadere errore chiaro `start-lock-timeout` (con chi lo tiene), mai attesa infinita;
12
+ // - si rilascia/toglie solo un lock col PROPRIO token.
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const crypto = require('node:crypto');
17
+
18
+ const DEFAULT_TTL_MS = 60 * 1000;
19
+ const DEFAULT_WAIT_MS = 20 * 1000;
20
+ const POLL_MS = 100;
21
+ const held = new Map(); // file -> { depth, token }
22
+
23
+ const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch (_) { /* best effort */ } };
24
+
25
+ function lockPath(home) { return path.join(home || os.homedir(), '.nexuscrew', 'start.lock'); }
26
+
27
+ function pidAlive(pid) {
28
+ if (!Number.isInteger(pid) || pid < 1) return false;
29
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
30
+ }
31
+
32
+ function processStartOf(pid, impl) {
33
+ try { return (impl || require('../cli/pidfile.js').readProcessStart)(pid) || ''; } catch (_) { return ''; }
34
+ }
35
+
36
+ function readLock(file) {
37
+ try {
38
+ const st = fs.lstatSync(file);
39
+ if (!st.isFile() || st.isSymbolicLink()) return { corrupt: true };
40
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
41
+ return v && Number.isInteger(v.pid) && typeof v.token === 'string' ? v : { corrupt: true };
42
+ } catch (e) { return e.code === 'ENOENT' ? null : { corrupt: true }; }
43
+ }
44
+
45
+ function isStale(meta, now, ttlMs, impl = {}) {
46
+ if (!meta || meta.corrupt) return true;
47
+ if (!pidAlive(meta.pid)) return true;
48
+ if (meta.processStart) {
49
+ const live = processStartOf(meta.pid, impl.readProcessStartImpl);
50
+ if (live && live !== meta.processStart) return true; // pid riassegnato a un altro processo
51
+ }
52
+ return now - Number(meta.createdAt || 0) > ttlMs;
53
+ }
54
+
55
+ function unlinkIfToken(file, token) {
56
+ const cur = readLock(file);
57
+ if (cur && !cur.corrupt && cur.token !== token) return false;
58
+ try { fs.unlinkSync(file); return true; } catch (e) { return e.code === 'ENOENT'; }
59
+ }
60
+
61
+ // Ritorna un handle {release()}. Sincrono: chi lo chiama (spawn, execFileSync) lo e' gia'.
62
+ function acquireStartLock({ home, ttlMs = DEFAULT_TTL_MS, waitMs = DEFAULT_WAIT_MS, label = '', impl = {} } = {}) {
63
+ const file = lockPath(home);
64
+ const mine = held.get(file);
65
+ if (mine) { mine.depth += 1; return { release: () => releaseHeld(file) }; }
66
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
67
+ const deadline = Date.now() + waitMs;
68
+ for (;;) {
69
+ const token = crypto.randomBytes(12).toString('hex');
70
+ try {
71
+ fs.writeFileSync(file, `${JSON.stringify({ pid: process.pid, token, createdAt: Date.now(), label, processStart: processStartOf(process.pid, impl.readProcessStartImpl) })}\n`, { flag: 'wx', mode: 0o600 });
72
+ held.set(file, { depth: 1, token });
73
+ return { release: () => releaseHeld(file) };
74
+ } catch (e) {
75
+ if (e.code !== 'EEXIST') throw e;
76
+ }
77
+ const meta = readLock(file);
78
+ if (isStale(meta, Date.now(), ttlMs, impl)) {
79
+ // ripresa: solo se il file e' ancora quello che abbiamo giudicato stantio
80
+ const again = readLock(file);
81
+ if (!again || again.corrupt || again.token === (meta && meta.token)) { unlinkIfToken(file, meta && !meta.corrupt ? meta.token : undefined); continue; }
82
+ }
83
+ if (Date.now() >= deadline) {
84
+ const error = new Error(`avvio gia' in corso (${meta && meta.label ? meta.label : 'altro avviatore'}, pid ${meta && meta.pid}): riprova fra poco`);
85
+ error.code = 'start-lock-timeout'; error.holder = meta && !meta.corrupt ? { pid: meta.pid, label: meta.label || '', ageMs: Date.now() - Number(meta.createdAt || 0) } : null;
86
+ throw error;
87
+ }
88
+ sleepSync(POLL_MS);
89
+ }
90
+ }
91
+
92
+ function releaseHeld(file) {
93
+ const mine = held.get(file);
94
+ if (!mine) return;
95
+ mine.depth -= 1;
96
+ if (mine.depth > 0) return;
97
+ held.delete(file);
98
+ unlinkIfToken(file, mine.token);
99
+ }
100
+
101
+ function withStartLock(fn, opts = {}) {
102
+ const handle = acquireStartLock(opts);
103
+ try { return fn(); } finally { handle.release(); }
104
+ }
105
+
106
+ module.exports = { DEFAULT_TTL_MS, DEFAULT_WAIT_MS, lockPath, acquireStartLock, withStartLock, isStale, readLock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
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": {