nukebase 2.5.1 → 2.5.2

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.
@@ -177,34 +177,83 @@ function splitBySize(files, sizeOf, n) {
177
177
  }
178
178
 
179
179
  // One progress line shared by every stream of a transfer. counter() gives each
180
- // stream its own pass-through that adds to the shared total.
180
+ // stream its own pass-through that adds to the shared total; add(n) counts
181
+ // bytes moved without a stream (storage uploads; negative takes back a failed
182
+ // attempt).
183
+ //
184
+ // The speed is the rate over the last few seconds, not the average since the
185
+ // start, and it is redrawn on a timer rather than when bytes arrive:
186
+ // - An upload's bytes are counted as they enter the request. The first
187
+ // several MB land in Node's and the OS's send buffers almost at once
188
+ // (measured ~10 MB on Windows), so a whole-run average opened far above
189
+ // the line speed and took a long time to fall. The window starts only once
190
+ // the buffers have filled (SETTLE_MS after the first byte); from then on
191
+ // the counted bytes move at the real pace.
192
+ // - Redrawing only on data froze the line during a stall, then jumped.
193
+ // Live redraws go to a terminal only (in a log or an AI agent they pile up);
194
+ // the final line, with the whole-run average, is always printed.
181
195
  function progressMeter(totalBytes) {
182
- let bytes = 0;
183
- let last = 0;
196
+ const TICK_MS = 250; // redraw interval
197
+ const SETTLE_MS = 1000; // after the first byte: buffers filling, not measured
198
+ const WINDOW_MS = 5000; // speed = bytes over (at most) this long
199
+ // Shortest span worth showing a speed for. Bytes leave in steps of a few MB
200
+ // (the OS frees send buffer in bursts), so a shorter span can catch a gap
201
+ // between steps and read 0.
202
+ const MIN_SPAN_MS = 2000;
203
+ const SMOOTH = 0.3; // per-tick weight of the newest reading
204
+ const tty = !!process.stdout.isTTY;
184
205
  const start = Date.now();
206
+ let bytes = 0;
207
+ let firstAt = 0;
208
+ let speed = null; // bytes per second, smoothed; null until measured
209
+ const samples = []; // [time, bytes], from SETTLE_MS after the first byte
185
210
  const mb = (n) => (n / 1024 / 1024).toFixed(2);
186
- const draw = (force) => {
211
+
212
+ const measure = () => {
187
213
  const now = Date.now();
188
- if (!force && now - last < 100) return;
189
- last = now;
190
- const secs = (now - start) / 1000;
191
- const speed = secs > 0 ? (bytes / 1024 / 1024) / secs : 0;
192
- const remaining = Math.max(0, totalBytes - bytes);
193
- const eta = speed > 0 ? formatEta((remaining / 1024 / 1024) / speed) : '--:--';
214
+ if (bytes > 0 && !firstAt) firstAt = now;
215
+ if (!firstAt || now - firstAt < SETTLE_MS) return;
216
+ // All of an upload counted: the tail is still in the send buffers, going
217
+ // out at the same pace. Hold the speed rather than let it decay to 0.
218
+ if (totalBytes > 0 && bytes >= totalBytes) return;
219
+ samples.push([now, bytes]);
220
+ while (samples.length > 2 && now - samples[1][0] >= WINDOW_MS) samples.shift();
221
+ const [t0, b0] = samples[0];
222
+ if (now - t0 < MIN_SPAN_MS) return;
223
+ const rate = Math.max(0, (bytes - b0) / ((now - t0) / 1000));
224
+ speed = speed === null ? rate : speed + SMOOTH * (rate - speed);
225
+ };
226
+ const line = () => {
227
+ const shown = totalBytes > 0 ? Math.min(bytes, totalBytes) : bytes;
194
228
  const of = totalBytes > 0 ? ` of ${mb(totalBytes).padStart(7)}` : '';
195
- process.stdout.write(`\r ${mb(bytes).padStart(7)}${of} MB || ${speed.toFixed(2).padStart(6)} MB/s || ETA ${String(eta).padStart(5)} `);
229
+ const rate = speed === null ? ' -- MB/s' : `${(speed / 1024 / 1024).toFixed(2).padStart(6)} MB/s (${Math.round(speed * 8 / 1e6)} Mbps)`;
230
+ const remaining = Math.max(0, totalBytes - bytes);
231
+ const eta = totalBytes > 0 && remaining === 0 ? 'finishing'
232
+ : speed > 0 && totalBytes > 0 ? formatEta(remaining / speed) : '--:--';
233
+ return `\r ${mb(shown).padStart(7)}${of} MB || ${rate} || ETA ${String(eta).padStart(5)} `;
196
234
  };
235
+ const tick = () => { measure(); if (tty) process.stdout.write(line()); };
236
+ const timer = setInterval(tick, TICK_MS);
237
+ if (timer.unref) timer.unref();
238
+
197
239
  return {
198
240
  counter() {
199
- return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
241
+ return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; cb(null, chunk); } });
200
242
  },
201
- // Bytes moved without a stream to count them (storage uploads).
202
- add(n) { bytes += n; draw(false); },
243
+ add(n) { bytes = Math.max(0, bytes + n); },
203
244
  bytes: () => bytes,
245
+ // For a transfer that failed: stop redrawing and end the line, so the
246
+ // error printed next is not drawn over. done() is for one that finished.
247
+ stop() {
248
+ clearInterval(timer);
249
+ if (tty && firstAt) process.stdout.write('\n');
250
+ },
204
251
  done() {
205
- draw(true);
252
+ clearInterval(timer);
253
+ if (tty) process.stdout.write(line());
206
254
  const secs = Math.max((Date.now() - start) / 1000, 0.001);
207
- console.log(`\n ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(bytes / 1024 / 1024 / secs).toFixed(2)} MB/s)`);
255
+ const avg = bytes / secs;
256
+ console.log(`${tty ? '\n' : ''} ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(avg / 1024 / 1024).toFixed(2)} MB/s, ${Math.round(avg * 8 / 1e6)} Mbps)`);
208
257
  }
209
258
  };
210
259
  }
@@ -283,6 +332,9 @@ async function downloadStreams(dest, files, sizeOf, totalBytes) {
283
332
  if (!stopRequested) throw err;
284
333
  }
285
334
  }));
335
+ } catch (err) {
336
+ meter.stop();
337
+ throw err;
286
338
  } finally {
287
339
  process.off('SIGINT', onSigint);
288
340
  }
@@ -1670,7 +1722,9 @@ async function push() {
1670
1722
  if (storageUp.length) {
1671
1723
  console.log(`\n File storage (${storageUp.length} file(s)):`);
1672
1724
  const meter = progressMeter(storageBytes);
1673
- storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
1725
+ try {
1726
+ storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
1727
+ } catch (e) { meter.stop(); throw e; }
1674
1728
  meter.done();
1675
1729
  if (storageFailed.length) {
1676
1730
  console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
@@ -2436,7 +2490,9 @@ async function backup() {
2436
2490
  if (storageChanged.length) {
2437
2491
  console.log(`\n File storage (${storageChanged.length} file(s)):`);
2438
2492
  const meter = progressMeter(storageBytes);
2439
- storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
2493
+ try {
2494
+ storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
2495
+ } catch (e) { meter.stop(); throw e; }
2440
2496
  meter.done();
2441
2497
  }
2442
2498
 
package/lib/storage.js CHANGED
@@ -50,8 +50,12 @@ async function manifest(API, authHeaders) {
50
50
  }
51
51
 
52
52
  // One request to the bucket, IPv4, failing on IDLE_MS without progress.
53
- // Resolves { status, headers, stream } once headers arrive.
54
- function request(method, url, body) {
53
+ // Resolves { status, headers, stream } once headers arrive. With onSent, the
54
+ // body goes out in SLICE pieces and onSent(n) is called as each one is handed
55
+ // to the network, so the progress line moves while a part uploads instead of
56
+ // jumping by a whole part (up to 16 MB) when it finishes.
57
+ const SLICE = 256 * 1024;
58
+ function request(method, url, body, onSent) {
55
59
  return new Promise((resolve, reject) => {
56
60
  const u = new URL(url);
57
61
  const mod = u.protocol === 'http:' ? http : https;
@@ -64,10 +68,37 @@ function request(method, url, body) {
64
68
  });
65
69
  req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
66
70
  req.on('error', reject);
67
- req.end(body || undefined);
71
+ if (!body || !onSent) { req.end(body || undefined); return; }
72
+ let off = 0;
73
+ const writeMore = () => {
74
+ while (off < body.length) {
75
+ const end = Math.min(off + SLICE, body.length);
76
+ const n = end - off;
77
+ const more = req.write(body.subarray(off, end), (err) => { if (!err) onSent(n); });
78
+ off = end;
79
+ if (!more) { req.once('drain', writeMore); return; }
80
+ }
81
+ req.end();
82
+ };
83
+ writeMore();
68
84
  });
69
85
  }
70
86
 
87
+ // One upload attempt whose bytes are reported as they go out. If the attempt
88
+ // fails, what it reported is taken back, so a retried part is not counted
89
+ // twice and the line cannot pass the total.
90
+ async function counted(onBytes, attempt) {
91
+ let n = 0;
92
+ let over = false; // a write callback landing after the failure adds nothing
93
+ try {
94
+ return await attempt((k) => { if (over) return; n += k; onBytes(k); });
95
+ } catch (e) {
96
+ over = true;
97
+ if (n) onBytes(-n);
98
+ throw e;
99
+ }
100
+ }
101
+
71
102
  async function retry(what, fn) {
72
103
  let last;
73
104
  for (let i = 0; i < TRIES; i++) {
@@ -214,12 +245,11 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
214
245
  const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
215
246
  if (plan.url) {
216
247
  const body = fs.readFileSync(file);
217
- await retry(key, async () => {
218
- const r = await request('PUT', plan.url, body);
248
+ await retry(key, () => counted(onBytes, async (onSent) => {
249
+ const r = await request('PUT', plan.url, body, onSent);
219
250
  r.stream.resume();
220
251
  if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
221
- });
222
- onBytes(size);
252
+ }));
223
253
  sent.push(key);
224
254
  return;
225
255
  }
@@ -230,13 +260,12 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
230
260
  const len = Math.min(plan.partSize, size - i * plan.partSize);
231
261
  const buf = Buffer.alloc(len);
232
262
  fs.readSync(fd, buf, 0, len, i * plan.partSize);
233
- await retry(`${key} part ${i + 1}`, async () => {
234
- const r = await request('PUT', url, buf);
263
+ await retry(`${key} part ${i + 1}`, () => counted(onBytes, async (onSent) => {
264
+ const r = await request('PUT', url, buf, onSent);
235
265
  r.stream.resume();
236
266
  if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
237
267
  etags[i] = r.headers.etag;
238
- });
239
- onBytes(len);
268
+ }));
240
269
  });
241
270
  await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
242
271
  sent.push(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"