@bli-cockpit/cli 0.2.254 → 0.2.255

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.
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.254");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.255");
25
25
  return 0;
26
26
  }
27
27
 
@@ -8,6 +8,28 @@
8
8
  * the timer is ref'd, and the race rejects even when an injected or broken
9
9
  * fetch ignores the abort, so the caller always gets a named failure.
10
10
  */
11
+ /**
12
+ * One rule for how long a request carrying a body may take (BLI-22801).
13
+ *
14
+ * A fixed deadline is wrong for any body whose send time scales with its size:
15
+ * from Vietnam the path to our US hosts moves about 40 KB/s, so a 1.5 MB gzip
16
+ * chunk needs ~40 s and a 2 MB one over a minute, and a flat 60 s deadline
17
+ * timed out every attempt. The deadline is a floor budget (connect, TLS, the
18
+ * server's own work) plus the time the body needs at a floor throughput of
19
+ * 8 KiB/s, five times slower than the measured link, capped at 10 minutes.
20
+ * A truly stalled request still aborts, at that deadline at the latest.
21
+ *
22
+ * Chosen over a stalled-progress abort: Node's fetch exposes no upload byte
23
+ * progress without a streamed duplex body, which an injected fetch and the
24
+ * signed-URL PUT do not reliably support. A size-scaled total is deterministic.
25
+ */
26
+ export const UPLOAD_DEADLINE_BASE_MS = 30_000;
27
+ export const UPLOAD_DEADLINE_FLOOR_BYTES_PER_S = 8_192;
28
+ export const UPLOAD_DEADLINE_MAX_MS = 600_000;
29
+ export function uploadDeadlineMs(bodyBytes) {
30
+ const sendMs = Math.ceil(Math.max(0, bodyBytes) / UPLOAD_DEADLINE_FLOOR_BYTES_PER_S) * 1000;
31
+ return Math.min(UPLOAD_DEADLINE_MAX_MS, UPLOAD_DEADLINE_BASE_MS + sendMs);
32
+ }
11
33
  export const UPLOAD_DEADLINE_ERROR_NAME = "EvidenceUploadDeadlineError";
12
34
  export function isUploadDeadlineError(error) {
13
35
  return error instanceof Error && error.name === UPLOAD_DEADLINE_ERROR_NAME;
@@ -1,8 +1,6 @@
1
1
  import { encodeEvidenceBytes } from "@bli-cockpit/telemetry-core/evidence-storage-encoding";
2
- import { fetchWithDeadline, isUploadDeadlineError } from "./evidence-upload-deadline.js";
2
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
3
3
  import { noteSyncProgress } from "./sync-lock.js";
4
- /** The signed capability is the only credential sent to Storage. Never log it,
5
- * its query string, provider bodies, or thrown errors that might contain it. */
6
4
  export async function putSignedChunk(options, signedUrl, bytes, uploadId, index) {
7
5
  // Identical manifest + gzip framing to the server chunk route staging write.
8
6
  const encoded = encodeEvidenceBytes(bytes);
@@ -10,36 +8,49 @@ export async function putSignedChunk(options, signedUrl, bytes, uploadId, index)
10
8
  upload_id: uploadId, chunk_index: index, chunk_encoding: "gzip",
11
9
  bytes_before: bytes.length, bytes_after: encoded.length,
12
10
  }));
11
+ const deadlineMs = uploadDeadlineMs(encoded.length);
12
+ let deadlineHit = false;
13
13
  const attempts = options.maxAttemptsPerRequest ?? 3;
14
14
  for (let attempt = 1; attempt <= attempts; attempt++) {
15
15
  let status = 0;
16
+ const startedAt = Date.now();
16
17
  try {
17
18
  const response = await fetchWithDeadline(options.fetchImpl, signedUrl, {
18
19
  method: "PUT", headers: { "Content-Type": "application/octet-stream", "x-upsert": "false" },
19
20
  body: new Uint8Array(encoded), redirect: "error",
20
- }, 60_000);
21
+ }, deadlineMs);
21
22
  status = response.status;
22
23
  await response.body?.cancel();
23
24
  if (response.ok) {
24
25
  noteSyncProgress();
25
- log("signed_chunk_uploaded", uploadId, index, attempt, status);
26
- return true;
26
+ const elapsedMs = Date.now() - startedAt;
27
+ log("signed_chunk_uploaded", uploadId, index, attempt, status, {
28
+ bytes: encoded.length, elapsed_ms: elapsedMs,
29
+ bytes_per_s: Math.round(encoded.length / Math.max(elapsedMs, 1) * 1000),
30
+ });
31
+ return "uploaded";
27
32
  }
28
33
  log("signed_upload_refused", uploadId, index, attempt, status);
29
34
  if (status < 500 && status !== 429)
30
- return false;
35
+ return "refused";
31
36
  }
32
37
  catch (error) {
33
- log(isUploadDeadlineError(error) ? "signed_upload_deadline_exceeded" : "signed_upload_network_failure", uploadId, index, attempt, status);
38
+ if (isUploadDeadlineError(error)) {
39
+ deadlineHit = true;
40
+ log("signed_upload_deadline_exceeded", uploadId, index, attempt, status, { bytes: encoded.length, deadline_ms: deadlineMs });
41
+ }
42
+ else {
43
+ log("signed_upload_network_failure", uploadId, index, attempt, status);
44
+ }
34
45
  }
35
46
  if (attempt < attempts)
36
47
  await (options.sleep ?? sleep)(250 * attempt);
37
48
  }
38
- return false;
49
+ return deadlineHit ? "deadline" : "refused";
39
50
  }
40
- function log(reason, uploadId, index, attempt, status) {
51
+ function log(reason, uploadId, index, attempt, status, extra = {}) {
41
52
  console.error("[evidence-upload] direct chunk", JSON.stringify({
42
- reason, upload_id: uploadId, chunk_index: index, attempt, http_status: status,
53
+ reason, upload_id: uploadId, chunk_index: index, attempt, http_status: status, ...extra,
43
54
  }));
44
55
  }
45
56
  function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
@@ -50,7 +50,17 @@ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, o
50
50
  continue;
51
51
  const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
52
52
  const offer = disposition.signed_uploads?.find(upload => upload.chunk_index === index);
53
- if (offer && await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index)) {
53
+ const direct = offer
54
+ ? await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index)
55
+ : "refused";
56
+ if (direct === "deadline") {
57
+ // BLI-22801: the chunk route would push the same bytes over the same slow link.
58
+ console.error("[evidence-upload] chunk route skipped", JSON.stringify({
59
+ reason: "signed_upload_deadline_no_fallback", upload_id: disposition.upload_id, chunk_index: index, chunk_bytes: chunk.length,
60
+ }));
61
+ return { ok: false, outcome: failedOutcome(entry.file, `chunk_${index}_failed_upload_deadline`, uploadedChunks) };
62
+ }
63
+ if (direct === "uploaded") {
54
64
  directChunks.push({ chunk_index: index, byte_size: chunk.length,
55
65
  chunk_hash_sha256: sha256(chunk) });
56
66
  uploadedChunks += 1;
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { describeError } from "./health-detail.js";
3
- import { fetchWithDeadline, isUploadDeadlineError } from "./evidence-upload-deadline.js";
3
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
4
4
  import { noteSyncProgress } from "./sync-lock.js";
5
5
  /**
6
6
  * The `send` stage: one retrying POST to an evidence-upload route, plus the
@@ -11,12 +11,13 @@ import { noteSyncProgress } from "./sync-lock.js";
11
11
  */
12
12
  const DEFAULT_MAX_ATTEMPTS = 3;
13
13
  const RETRY_DELAY_MS = 250;
14
- const REQUEST_DEADLINE_MS = 120_000;
15
14
  export async function requestJson(options, routePath, body) {
16
15
  const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_MAX_ATTEMPTS;
17
16
  const sleep = options.sleep ?? defaultSleep;
18
17
  let lastStatus = 0;
19
18
  let lastBody = null;
19
+ const serialized = JSON.stringify(body);
20
+ const deadlineMs = uploadDeadlineMs(Buffer.byteLength(serialized));
20
21
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
21
22
  try {
22
23
  const response = await fetchWithDeadline(options.fetchImpl, `${options.dashboardUrl}${routePath}`, {
@@ -27,8 +28,8 @@ export async function requestJson(options, routePath, body) {
27
28
  ...(routePath.endsWith("/evidence/upload/begin")
28
29
  ? { "x-tower-evidence-direct": "staged-gzip-v1" } : {}),
29
30
  },
30
- body: JSON.stringify(body),
31
- }, REQUEST_DEADLINE_MS);
31
+ body: serialized,
32
+ }, deadlineMs);
32
33
  lastStatus = response.status;
33
34
  lastBody = await readResponseJson(response);
34
35
  if (response.ok || (response.status < 500 && response.status !== 429)) {
@@ -50,6 +51,8 @@ export async function requestJson(options, routePath, body) {
50
51
  route: routePath,
51
52
  attempt,
52
53
  max_attempts: maxAttempts,
54
+ body_bytes: Buffer.byteLength(serialized),
55
+ deadline_ms: deadlineMs,
53
56
  ...describeError(error),
54
57
  }));
55
58
  lastStatus = 0;
@@ -11,22 +11,23 @@
11
11
  */
12
12
  import { ImageDescribeResponseSchema, } from "@bli-cockpit/telemetry-core";
13
13
  import { describeError } from "./health-detail.js";
14
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
14
15
  import { safeFailureDetail } from "./evidence-upload-transport.js";
15
16
  import { noteSyncProgress } from "./sync-lock.js";
16
- const REQUEST_TIMEOUT_MS = 60_000;
17
17
  const MAX_ATTEMPTS = 2;
18
18
  const RETRY_DELAY_MS = 500;
19
19
  export async function postImageToDescribeDoor(options, request) {
20
20
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
21
+ const payload = JSON.stringify(request);
22
+ const deadlineMs = uploadDeadlineMs(Buffer.byteLength(payload));
21
23
  let last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
22
24
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
23
25
  try {
24
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/images`, {
26
+ const response = await fetchWithDeadline(options.fetchImpl, `${options.dashboardUrl}/api/ambient/images`, {
25
27
  method: "POST",
26
28
  headers: { Authorization: `Bearer ${options.deviceToken}`, "Content-Type": "application/json" },
27
- body: JSON.stringify(request),
28
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
29
- });
29
+ body: payload,
30
+ }, deadlineMs);
30
31
  const body = await readJson(response);
31
32
  noteSyncProgress();
32
33
  if (response.ok) {
@@ -41,7 +42,7 @@ export async function postImageToDescribeDoor(options, request) {
41
42
  return last;
42
43
  }
43
44
  catch (error) {
44
- console.error("[image-describe] request failed before a status came back", JSON.stringify({ reason: "transport_error", attempt, max_attempts: MAX_ATTEMPTS, sha256_prefix: request.sha256.slice(0, 16), ...describeError(error) }));
45
+ console.error("[image-describe] request failed before a status came back", JSON.stringify({ reason: isUploadDeadlineError(error) ? "deadline_exceeded" : "transport_error", body_bytes: Buffer.byteLength(payload), deadline_ms: deadlineMs, attempt, max_attempts: MAX_ATTEMPTS, sha256_prefix: request.sha256.slice(0, 16), ...describeError(error) }));
45
46
  last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
46
47
  }
47
48
  if (attempt < MAX_ATTEMPTS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.254",
3
+ "version": "0.2.255",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {