Skip to content

fix(miner-logs): declare the real payload format on the log file leg - #203

Merged
tekwani merged 2 commits into
tetherto:developfrom
arif-dewi:fix/miner-log-download-content-type
Aug 27, 2026
Merged

fix(miner-logs): declare the real payload format on the log file leg#203
tekwani merged 2 commits into
tetherto:developfrom
arif-dewi:fix/miner-log-download-content-type

Conversation

@arif-dewi

@arif-dewi arif-dewi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Asana

Description

Companion to tetherto/moria-app-ui#2926. The miner log file leg declared a name and type it could not know were true:

`miner-log-${meta.minerId}-${id}.log`      // always .log
reply.header('Content-Type', 'application/octet-stream')

The bytes are whatever the miner produced. Some models stream a single plain-text log; Whatsminers stream a gzipped tar of their log directory (<ip>.logs/board_stat0..3.log, power.log, miner-state.log, temp0..3.xls, …). A production export was reported as "corrupted and illegible": the file was a complete, valid tar.gz (gzip -t clean, tar -xzf extracts 20 entries / 17 MB) saved as .log, so the OS opened gzip bytes in a text viewer.

A fixed extension can only ever be right for one fleet, whichever value it holds — which is why the client-side name has had to change direction before.

What changed

The action result carries no format field (coreKey, byteLength, expiresAt, minerId), so the file leg reads the leading bytes:

  • workers/lib/server/lib/payloadFormat.js (new)
    • detectPayloadFormat(head){ extension, contentType } for gzip / zip / text. For gzip it inflates only the first tar header (zlib.gunzipSync with Z_SYNC_FLUSH, tolerating the truncated deflate block) and checks ustar at offset 257, so a gzipped tar becomes .tar.gz and a bare gzip stays .gz — it never claims a format it has not seen.
    • peekFirstChunk(stream) reads the first chunk and keeps an error listener attached for the life of the stream (an error with no listener is thrown by EventEmitter, and the source can fail in the gap before the pipe).
    • prependChunk(source, head) re-emits the peeked bytes ahead of the rest, honouring consumer backpressure. stream.unshift() cannot be used here: once the peek has consumed the source's last chunk it silently drops the head, which truncated single-chunk logs — caught by the new test.
  • downloadLogFile now sets Content-Type and the Content-Disposition extension from the detected format, and returns 500 with a JSON body if the source fails before the first byte, rather than a truncated stream under a confident content-type.

Content-Length, Cache-Control: no-store, the ownership check and the expiry check are unchanged, and the response body stays byte-identical to what the miner sent.

Tests

  • tests/unit/lib/payloadFormat.test.js (new) — 13 tests: gzipped tar (including from a truncated 512-byte stream prefix), gzip-without-tar, zip, text, empty and 1-byte payloads; peek semantics; prependChunk byte-integrity for 1/2/3-chunk payloads and a 4 MB payload through the high-water mark; post-peek source failure propagation.
  • tests/unit/handlers/minerLogs.handlers.test.js — file-leg coverage: .tar.gz + application/gzip for an archive, .log + text/plain for text, byte-for-byte stream integrity (peek included), preserved Content-Length/Cache-Control, and 500 on an early stream error. makeMockReply() gained header capture.
  • npm run lint (standard) clean. Full unit suite: my files pass; tests/unit/handlers/finance.handlers.test.js and the two admin_external cases in auth/users routes already fail on develop (verified by stashing this branch) — untouched here.

Type

  • fix

Follow-up

The rack worker should report fileName / contentType in the downloadLogs action result data, and getMinerLogDownloadStatus should surface it — after which neither side needs to sniff bytes. Worth a ticket; the worker repo is outside this change.

The file leg hardcoded `.log` and `application/octet-stream` for every
download. The bytes are whatever the miner produced: some models stream a
plain-text log, Whatsminers stream a gzipped tar of their log directory
(<ip>.logs/board_stat0..3.log, power.log, miner-state.log, …). Clients that
trusted the declared name saved an archive as .log and opened it as garbage;
the frontend had already been flipped between .tar.gz and .log once each way
because the constant can only ever be right for one fleet.

The action result carries no format field (coreKey, byteLength, expiresAt,
minerId), so read the leading bytes instead:

- lib/payloadFormat.js detects gzip/zip/text and, for gzip, inflates only the
  first tar header to tell a gzipped tar from a bare .gz — never claiming a
  format it has not seen. Content-Type now follows the same detection.
- The peeked chunk is re-emitted ahead of the rest via prependChunk(), which
  honours consumer backpressure. stream.unshift() cannot be used here: once
  the peek has consumed the source's last chunk it silently drops the head,
  truncating small logs.
- A source failure before the first chunk returns 500 with a JSON body rather
  than a truncated stream under a confident content-type.

Content-Length, Cache-Control and the ownership/expiry checks are unchanged,
and the body is byte-identical to what the miner sent.

Note: tests/unit/handlers/finance.handlers.test.js and the two admin_external
cases in auth/users routes already fail on develop, unrelated to this change.
tekwani
tekwani previously approved these changes Aug 27, 2026

@paragmore paragmore left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice fix — the detection logic is solid. left a few notes on the plumbing around it.

Comment thread workers/lib/server/lib/payloadFormat.js Outdated

const write = async (chunk) => {
if (out.write(chunk) === false) {
await new Promise((resolve) => out.once('drain', resolve))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the client aborts mid-download, fastify destroys out but nothing destroys source — the hyperswarm stream to the miner stays open, and the pump parks here forever on a drain that never fires (checked against streamx locally). The old reply.send(stream) let fastify destroy the source directly. out.on('close', () => source.destroy()) plus a destroyed check in write would cover it — or write the head then source.pipe(out), pipe ties the two lifecycles for free.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and the parked pump was the worse half — thanks.

Lifecycles are tied now: out.on('close', () => source.destroy()), plus write() returns false once out is destroyed so the pump returns instead of awaiting a drain that never comes (the backpressure wait settles on close as well as drain).

I didn't go with source.pipe(out) though: the peek can consume the source's last chunk, and piping an already-ended stream never ends the destination — that's exactly what made the first version of this (stream.unshift) deliver empty bodies for single-chunk logs. Left a note in the JSDoc so it doesn't get re-suggested.

Covered by a regression test: with the close handler removed it fails on its 2s timeout.

Comment thread workers/lib/server/lib/payloadFormat.js Outdated
'use strict'

const zlib = require('zlib')
const { PassThrough } = require('streamx')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamx isn't in package.json — it resolves through hoisting from a transitive dep today, so a bump elsewhere in the tree can break this route with no lockfile signal. Same story as the debug dep a while back; worth declaring it in dependencies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and digging into it: the repo's convention is already Node's built-in streams (export/serializers.js, and log-downloader.test.js uses node:stream for the same kind of fixture) — my file was the only streamx require in the tree.

So the output is now require('stream').PassThrough and production code doesn't depend on streamx at all. The tests still use streamx.Readable on purpose, since that's what hypercore.createByteStream() actually returns and its peek/prefetch semantics differ from Node's, so streamx is declared as a pinned devDependency (2.28.0, matching brittle/standard style) with the matching lockfile root entry — no more resolving through hoisting.

Comment thread workers/lib/server/lib/payloadFormat.js Outdated
// block is expected to be truncated. Returns null when it cannot be inflated at all.
function inflateHead (head) {
try {
return zlib.gunzipSync(head, { finishFlush: zlib.constants.Z_SYNC_FLUSH })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

head is a device-supplied chunk and deflate expands up to ~1000x, so this can be a ~66MB sync inflate per request. Detection only needs the first 512 output bytes — inflating a slice like head.subarray(0, 4096) bounds it and keeps tar detection intact. (maxOutputLength doesn't fit here: it throws, which would turn a real .tar.gz into .gz.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the maxOutputLength note is exactly why I'd avoided it.

Bounded to head.subarray(0, 4096) now. Measured on a gzip of 64MB of zeros: 67MB allocated before, 4.2MB and 1.35ms after, still classified correctly — tar detection is unaffected since it only needs 512 output bytes.

inflateHead is exported so the bound is asserted on output size rather than by timing, which would be too noisy to catch a regression.

…robe

Review follow-ups on the payload-format detection:

- A client aborting mid-download left the transfer open: fastify destroys the
  response stream, but nothing destroyed the hypercore source, and the pump
  parked on a drain that would never fire. The two now share a lifecycle —
  destroying the output destroys the source, and a write to a gone consumer
  stops the pump instead of hanging it. source.pipe() would tie them for free
  but cannot be used here: the peek may already have consumed the source's
  last chunk, and piping an ended stream never ends the destination (that is
  what made the earlier unshift version drop single-chunk logs).

- `head` is device-supplied and deflate expands up to ~1000x, so probing a
  whole chunk could inflate ~67MB synchronously per request. Only the first
  4096 bytes are inflated now — 4.2MB and 1.35ms on a 64MB-of-zeros payload,
  with tar detection unchanged since it needs 512 output bytes.
  maxOutputLength is deliberately not used: it throws, which would demote a
  real .tar.gz to .gz.

- The output stream is Node's stream.PassThrough rather than streamx, which
  matches the convention already used in export/serializers.js and drops the
  runtime dependency entirely. The tests keep streamx.Readable, since that is
  what hypercore.createByteStream() returns and its peek semantics differ, so
  streamx is now declared as a devDependency instead of resolving through
  hoisting from a transitive dep.

Both fixes have regression coverage: removing the lifecycle handler fails the
abort test on its timeout, and the inflate bound is asserted on output size
rather than timing.
@tekwani
tekwani merged commit 42691e7 into tetherto:develop Aug 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants