OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 8effda2

0.1.234: release artifacts answer HEAD, carry Content-Length, and support Range
A 148 MB APK would not download to a phone from https://7rnx.net/git. Measured from the handset: HEAD hung 25s and returned 0 bytes, so Android's DownloadManager — which preflights with HEAD — never started the transfer. The network was fine (100ms RTT, 0% loss) and GET streamed at full speed. Three defects in the release-artifact route, all here: 1. Every route was gated on `req.method === 'GET'`, so HEAD matched nothing and fell through to the 404 handler, which advertises a Content-Length for a body it then correctly omits — leaving the client waiting on bytes never sent. 2. Content-Length was omitted deliberately, so a piped body went out Transfer-Encoding: chunked. No size, no progress, no completeness check. 3. No Range support. A Range request returned 200 and the whole stream, so an interrupted mobile download restarted from zero every time. Headers now go through setHeader, never writeHead's object form. The old comment blamed a duplicate Content-Length, but the source is bare-http1 itself: server-response.js `_write()` sets its own Content-Length whenever a body fits in one finishing write, and writeHead preserves header case while setHeader lowercases — so the two land as separate keys and the header is emitted twice. That only bites SMALL artifacts, which is exactly what favela-android's OTA fetcher reads (tree.json). Routing through setHeader makes bare-http1's internal call overwrite the same key instead of appending. HEAD calls flushHeaders() before end(), because `_final()` sets Content-Length: 0 when it finds the headers unsent — which would tell the client the artifact is empty. Flushing also means HEAD never opens the file. Two traps found by probing bare-http1 rather than assuming: - HEAD plus a raw .pipe() kills the daemon with the same PREMATURE_CLOSE that pipe-file.js documents from celltron. The route uses guarded pipeFile. - bare-http1's CLIENT has no HEAD awareness on the response side, so http.request({method:'HEAD'}) blocks forever on a correct response. The test therefore speaks HTTP over a raw socket, which also gives an exact duplicate-header count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8effda2cb5e8dea9d953a6e81b49d9dd9e3c9cb6
by plankatron <fleeky@gmail.com> · 2026-08-25T22:52:42.000Z

Changed files (3) +211 -13

lib/web/router.js +66 -10
……@@ -531,8 +531,14 @@ export function registerRoutes (svc) {
531531 // has the serverKey can fetch any release via P2P anyway, so gating
532532 // HTTP reads wouldn't add security, just friction. Path-traversal
533533 // guarded by the repo / version / artifact regex components.
534 // HEAD as well as GET: Android's DownloadManager preflights a download
535 // with HEAD, and every route here used to be gated on GET alone, so HEAD
536 // fell through to the 404 handler — which advertises a Content-Length for
537 // a body it then (correctly) omits, leaving the client waiting. Measured
538 // from a handset against the production forge: 25 s, zero bytes, no
539 // download. See test/release-download.test.js.
534540 const releaseDl = url.match(/^\/releases\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)\/(.+)$/i)
535 if (req.method === 'GET' && releaseDl) {
541 if ((req.method === 'GET' || req.method === 'HEAD') && releaseDl) {
536542 if (!svc._releases) return notFound(res, 'release manager not available')
537543 const repoName = decodeURIComponent(releaseDl[1])
538544 const version = decodeURIComponent(releaseDl[2])
……@@ -581,18 +587,68 @@ export function registerRoutes (svc) {
581587 let st = null
582588 try { st = fs.statSync(resolved) } catch { st = null }
583589 if (!st || !st.isFile()) return notFound(res, 'release artifact not found')
584 // Content-Length omitted on a piped body — see the /files/raw route
585 // below: writeHead preserves header case, setHeader lowercases, and
586 // a duplicate Content-Length is what bare-http1's own parser
587 // rejects. The response derives the length itself.
588 res.writeHead(200, {
589 'Content-Type': mimeFor(artifactPath),
590 'Content-Disposition': 'attachment; filename="' + encodeURIComponent(path.basename(artifactPath)) + '"'
591 })
590 // Headers go through setHeader, NEVER writeHead's object form. The
591 // duplicate-Content-Length hazard is real but it comes from
592 // bare-http1 itself: server-response.js `_write()` sets its own
593 // Content-Length whenever a body fits in one finishing write, and
594 // `writeHead` preserves header case while `setHeader` lowercases —
595 // so the two land as SEPARATE keys and the header is emitted twice.
596 // That only bites SMALL artifacts, which is exactly what the
597 // favela-android OTA fetcher reads (tree.json). Routing everything
598 // through setHeader means bare-http1's internal call overwrites the
599 // same key instead of appending a second one.
600 const total = st.size
601 res.setHeader('Content-Type', mimeFor(artifactPath))
602 res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(path.basename(artifactPath)) + '"')
603 // Advertised so clients resume instead of restarting. A 148 MB APK
604 // over mobile that drops at 90% used to begin again from zero.
605 res.setHeader('Accept-Ranges', 'bytes')
606
607 let start = 0
608 let end = total - 1
609 let partial = false
610 const rangeHeader = req.headers.range
611 if (rangeHeader) {
612 // Single ranges only. A multi-range request needs a multipart body;
613 // ignoring it and sending the whole entity is a legal response, and
614 // no client of ours asks for one.
615 const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim())
616 if (m && !(m[1] === '' && m[2] === '')) {
617 if (m[1] === '') {
618 start = Math.max(0, total - Number(m[2])) // suffix: last N bytes
619 } else {
620 start = Number(m[1])
621 if (m[2] !== '') end = Number(m[2])
622 }
623 if (end > total - 1) end = total - 1
624 if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
625 res.setHeader('Content-Range', 'bytes */' + total)
626 res.writeHead(416)
627 res.flushHeaders()
628 return res.end()
629 }
630 partial = true
631 res.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + total)
632 }
633 }
634 res.setHeader('Content-Length', String(end - start + 1))
635 res.writeHead(partial ? 206 : 200)
636
637 // HEAD: flushHeaders() FIRST, because `_final()` sets
638 // Content-Length: 0 when it finds the headers unsent — which would
639 // tell the client the artifact is empty. Flushing also means HEAD
640 // never opens the file at all.
641 if (req.method === 'HEAD') {
642 res.flushHeaders()
643 return res.end()
644 }
592645 // pipeFile, not a bare .pipe(): the catch below only covers the
593646 // synchronous setup, so a stream error on a later tick would escape
594647 // and kill the daemon (see lib/web/pipe-file.js).
595 return pipeFile(fs.createReadStream(resolved), res, 'release artifact ' + artifactPath)
648 const body = partial
649 ? fs.createReadStream(resolved, { start, end })
650 : fs.createReadStream(resolved)
651 return pipeFile(body, res, 'release artifact ' + artifactPath)
596652 } catch (err) {
597653 if (err.code === 'ENOENT') return notFound(res, 'release artifact not found')
598654 throw err
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.233",
3 "version": "0.1.234",
44 "description": "P2P git forge \u2014 multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/release-download.test.js +144 -2
……@@ -23,6 +23,7 @@ import { mkdtempSync, mkdirSync } from '#node:fs'
2323import { join } from '#node:path'
2424import { tmpdir } from '#node:os'
2525import http from '#node:http'
26import net from '#node:net'
2627import b4a from 'b4a'
2728import crypto from 'hypercore-crypto'
2829
……@@ -31,10 +32,16 @@ import { startPearGit } from '../index.js'
3132function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-rel-')) }
3233function tmpSock () { return join(mkdtempSync(join(tmpdir(), 'pgv3-rel-sock-')), 'svc.sock') }
3334
34function fetchBytes (url) {
35function fetchBytes (url, opts = {}) {
3536 return new Promise((resolve, reject) => {
3637 const u = new URL(url)
37 const req = http.request({ method: 'GET', hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => {
38 const req = http.request({
39 method: opts.method || 'GET',
40 hostname: u.hostname,
41 port: u.port,
42 path: u.pathname + u.search,
43 headers: opts.headers || {}
44 }, (res) => {
3845 const parts = []
3946 res.on('data', c => parts.push(c))
4047 res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts), headers: res.headers }))
……@@ -45,6 +52,51 @@ function fetchBytes (url) {
4552 })
4653}
4754
55// HEAD has to be asserted at the wire level. bare-http1's CLIENT has no HEAD
56// awareness on the response side — lib/client-request.js special-cases HEAD only
57// for the REQUEST body — so it blocks forever waiting for a body that a correct
58// HEAD response never sends. curl and Android's DownloadManager handle it fine;
59// our own client cannot, so `http.request({method:'HEAD'})` would hang the test
60// rather than measure the server. Speak HTTP directly instead.
61//
62// Returning the raw header keys in order also gives an exact duplicate check,
63// which matters because the bug being guarded is a header emitted TWICE.
64function rawHead (port, reqPath) {
65 return new Promise((resolve, reject) => {
66 // createConnection, not connect: bare-net exposes only the former.
67 const socket = net.createConnection(port, '127.0.0.1')
68 let buf = ''
69 let settled = false
70 socket.on('error', reject)
71 socket.on('connect', () => {
72 socket.write(b4a.from('HEAD ' + reqPath + ' HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'))
73 })
74 socket.on('data', (chunk) => {
75 buf += b4a.toString(b4a.from(chunk))
76 const i = buf.indexOf('\r\n\r\n')
77 if (i === -1 || settled) return
78 settled = true
79 const lines = buf.slice(0, i).split('\r\n')
80 const status = Number(lines[0].split(' ')[1])
81 const headers = {}
82 const rawKeys = []
83 for (const line of lines.slice(1)) {
84 const j = line.indexOf(':')
85 if (j === -1) continue
86 const k = line.slice(0, j).trim().toLowerCase()
87 const v = line.slice(j + 1).trim()
88 rawKeys.push(k)
89 headers[k] = k in headers ? headers[k] + ', ' + v : v
90 }
91 socket.destroy()
92 resolve({ status, headers, rawKeys, body: buf.slice(i + 4) })
93 })
94 socket.on('close', () => {
95 if (!settled) reject(new Error('connection closed before headers'))
96 })
97 })
98}
99
48100test('a published release artifact downloads over HTTP', async (t) => {
49101 const storage = tmpStorage()
50102 mkdirSync(storage, { recursive: true })
……@@ -193,3 +245,93 @@ test('release artifacts carry a useful Content-Type', async (t) => {
193245 t.is(bin.headers['content-type'], 'application/octet-stream',
194246 'unknown extensions still fall through to a binary download')
195247})
248
249// Mobile downloads of a large artifact failed against the production forge
250// (2026-08-25, a 148 MB APK over https://7rnx.net/git). Three defects, all here:
251//
252// 1. Every route was gated on `req.method === 'GET'`, so HEAD matched nothing
253// and fell through to the 404 handler. Android's DownloadManager preflights
254// with HEAD, so the download died before a byte moved. Measured from the
255// handset: HEAD hung for 25 s and returned 0 bytes.
256// 2. Content-Length was omitted deliberately, so the body went out
257// Transfer-Encoding: chunked — no size, no progress, no completeness check.
258// 3. No Range support: a Range request returned 200 and the whole stream, so
259// an interrupted mobile download restarted from zero every time.
260//
261// The duplicate-Content-Length hazard the old comment warned about is real, but
262// it comes from bare-http1's own `_write()`, which sets Content-Length itself
263// when a body fits in a single finishing write. writeHead preserves header case
264// and setHeader lowercases, so writeHead('Content-Length') plus that internal
265// call emits the header TWICE — for SMALL artifacts only, which is exactly what
266// the favela-android OTA fetcher reads (tree.json). Hence setHeader throughout.
267test('release artifacts support HEAD, Content-Length and Range', async (t) => {
268 const storage = tmpStorage()
269 mkdirSync(storage, { recursive: true })
270
271 const node = await startPearGit({
272 storage, uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
273 })
274 t.teardown(async () => { try { await node.close() } catch {} })
275
276 // Big enough to span multiple stream chunks, plus a tiny one that lands in a
277 // single finishing write — the two sides of the duplicate-header trap.
278 const big = crypto.randomBytes(512 * 1024)
279 const small = b4a.from('{"entries":[]}')
280
281 await node.svc._releases.publish({
282 repo: 'demo-repo',
283 version: 'v2',
284 artifacts: [
285 { path: 'big.apk', data: big },
286 { path: 'tree.json', data: small }
287 ],
288 meta: {}
289 })
290
291 const port = node.svc.http.server.address().port
292 const base = 'http://127.0.0.1:' + port + '/releases/demo-repo/v2/'
293
294 // (2) GET advertises an exact length and range support.
295 const got = await fetchBytes(base + 'big.apk')
296 t.is(got.status, 200, 'GET still 200')
297 t.is(got.headers['content-length'], String(big.length), 'GET carries an exact Content-Length')
298 t.is(got.headers['accept-ranges'], 'bytes', 'GET advertises range support')
299 t.ok(got.body.equals(big), 'GET body still byte-identical')
300
301 // Duplicate headers arrive joined by ', ' — a comma means we emitted it twice.
302 t.absent(String(got.headers['content-length']).includes(','),
303 'exactly one Content-Length on a multi-chunk body')
304 const smallGot = await fetchBytes(base + 'tree.json')
305 t.is(smallGot.headers['content-length'], String(small.length), 'small artifact length correct')
306 t.absent(String(smallGot.headers['content-length']).includes(','),
307 'exactly one Content-Length on a single-write body — bare-http1 sets its own')
308 t.ok(smallGot.body.equals(small), 'small artifact bytes intact (the OTA fetcher path)')
309
310 // (1) HEAD answers with the size and no body, instead of 404.
311 const head = await rawHead(port, '/releases/demo-repo/v2/big.apk')
312 t.is(head.status, 200, 'HEAD is answered, not 404 — Android DownloadManager preflights with it')
313 t.is(head.headers['content-length'], String(big.length), 'HEAD reports the real size, not 0')
314 t.is(head.headers['accept-ranges'], 'bytes', 'HEAD advertises range support too')
315 t.is(head.body.length, 0, 'HEAD carries no body')
316 t.is(head.rawKeys.filter(k => k === 'content-length').length, 1,
317 'exactly one Content-Length on HEAD — _final() must not append its own 0')
318
319 const headMiss = await rawHead(port, '/releases/demo-repo/v2/nope.apk')
320 t.is(headMiss.status, 404, 'HEAD on a missing artifact still 404s')
321
322 // (3) Range yields 206 and the exact slice, so a dropped mobile download resumes.
323 const part = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=100-199' } })
324 t.is(part.status, 206, 'Range request → 206 Partial Content')
325 t.is(part.headers['content-range'], 'bytes 100-199/' + big.length, 'Content-Range describes the slice')
326 t.is(part.headers['content-length'], '100', 'Content-Length is the slice length')
327 t.ok(part.body.equals(big.subarray(100, 200)), 'the slice is the right bytes')
328
329 // Open-ended form is what a resuming client actually sends.
330 const tail = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length - 10) + '-' } })
331 t.is(tail.status, 206, 'open-ended range → 206')
332 t.ok(tail.body.equals(big.subarray(big.length - 10)), 'open-ended range returns the tail')
333
334 const unsat = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length + 5) + '-' } })
335 t.is(unsat.status, 416, 'unsatisfiable range → 416, not a silent full body')
336 t.is(unsat.headers['content-range'], 'bytes */' + big.length, '416 reports the real size')
337})