dsh-dl-server 0.2.0 → 0.3.0

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.
package/README.md CHANGED
@@ -78,6 +78,15 @@ A download started in one session is visible to every other session — progress
78
78
  | **Cumulative disk budget** | Enqueue counts what already-queued jobs still need, so a batch cannot quietly fill the disk. |
79
79
  | **Failures are no longer silent** | Checkpoint write errors are counted and surfaced in `download_status` under `storage`; an unwritable destination is refused before the transfer starts. |
80
80
 
81
+ ## New in 0.3.0
82
+
83
+ | | |
84
+ | --- | --- |
85
+ | **Queue de-duplication** | One destination is never downloaded twice. A finished job is reused while its file is still on disk; a different source claiming the same path fails with `dest-in-use`. |
86
+ | **Mirror self-verification** | Before a mirror is used, it is HEADed once: the length must equal the origin's, and when the origin advertises an ETag the mirror's must match too. |
87
+
88
+ > A mirror is a third party and serves whatever it likes. Length plus ETag catches a cached older build or an HTML error page; it does **not** catch a poisoned copy. Only `sha256` proves content, which is why mirrors are a fallback and not a guarantee.
89
+
81
90
  ## Example
82
91
 
83
92
  ```
@@ -123,12 +132,19 @@ The last row is the point: **on a per-connection-throttled host, segmentation be
123
132
 
124
133
  ## Roadmap
125
134
 
126
- ```
127
- v1 queue + persistence + seven tools + aria2 transport ← released
128
- v1.1 disk headroom / silent failures / session fairness / mirrors
129
- v2 shell interception / torrents / own segmented engine / content addressing
130
- v3 user panel + callbacks into the agent
131
- ```
135
+ The roadmap names product stages (`v1`, `v1.1`, `v1.2`, `v2`); npm versions follow semver. They do not map one-to-one — use this table:
136
+
137
+ | npm | Roadmap | Content | Status |
138
+ | --- | --- | --- | --- |
139
+ | `0.1.x` | v1 | queue + persistence + seven tools + aria2 transport | released |
140
+ | `0.2.x` | v1.1 | mirrors / mirror cooldown / size-aware concurrency / session fairness / disk budget / loud failures | released |
141
+ | `0.3.x` | v1.2 | queue de-duplication / mirror self-verification | planned |
142
+ | `1.0.0` | v2 | shell interception / torrents / own segmented engine / content addressing | planned |
143
+ | `1.1.x` | v3 | user panel + callbacks into the agent | planned |
144
+
145
+ **Why 0.x lasts until v2:** `1.0.0` means "the tool names and config keys will not change incompatibly". Only v2 touches the transport and the tool set, so that is where 1.0 belongs.
146
+
147
+ **You never have to do the conversion yourself** — install the latest and you get the newest milestone.
132
148
 
133
149
  ## License
134
150
 
package/README.zh.md CHANGED
@@ -78,6 +78,15 @@ DLS 做的是**队列层**,不是传输层。
78
78
  | **磁盘余量累加** | 入队时把「已在队列里还没下完的」一起算进去,批量入队写不满盘 |
79
79
  | **失败不再静默** | 断点写失败会计数并通过 `download_status` 的 `storage` 字段暴露;目标目录不可写在传输开始前就拒绝 |
80
80
 
81
+ ## 0.3.0 新增
82
+
83
+ | 能力 | 说明 |
84
+ | --- | --- |
85
+ | **队列去重** | 同一个落地路径不会下两份。同源且文件已在盘上时直接复用已完成的任务;不同源抢同一路径会明确报 `dest-in-use` |
86
+ | **镜像自证同源** | 用镜像前先 HEAD 一次,长度必须与原始一致;原始给了 ETag 时镜像的 ETag 也必须相等,否则不用 |
87
+
88
+ > 镜像是第三方,它给什么我们不知道。长度 + ETag 能挡住「缓存了旧版本」「返回了错误页」这类问题,**挡不住投毒** —— 那只有 `sha256` 做得到。所以镜像只是兜底,真要保证内容就对 `sha256`。
89
+
81
90
  ## 用它
82
91
 
83
92
  ```
@@ -123,12 +132,19 @@ agent 会调 `download_enqueue` 两次拿到 `J#10` / `J#11`,然后 `download_
123
132
 
124
133
  ## 路线
125
134
 
126
- ```
127
- v1 队列 + 持久化 + 七个工具 + aria2 传输 ← 已发布
128
- v1.1 磁盘余量 / 静默失败 / 会话公平 / 镜像表
129
- v2 拦截钩子 / 种子 / 自研分段 / 内容寻址
130
- v3 用户面板 + 操作回传
131
- ```
135
+ 路线图用 `v1 / v1.1 / v1.2 / v2` 讲产品阶段,npm 版本按语义化版本走。两者对不上一一对应,查这张表:
136
+
137
+ | npm 版本 | 路线图 | 内容 | 状态 |
138
+ | --- | --- | --- | --- |
139
+ | `0.1.x` | v1 | 队列 + 持久化 + 七个工具 + aria2 传输 | 已发布 |
140
+ | `0.2.x` | v1.1 | 镜像表 / 镜像冷却 / 并发分档 / 会话公平 / 磁盘余量 / 失败显式化 | 已发布 |
141
+ | `0.3.x` | v1.2 | 队列去重 / 镜像自证同源 | 计划 |
142
+ | `1.0.0` | v2 | 拦截 shell 下载 / 种子与磁力 / 自研分段引擎 / 内容寻址 | 计划 |
143
+ | `1.1.x` | v3 | 用户面板 + 操作回传 agent | 计划 |
144
+
145
+ **为什么 0.x 一直拖到 v2:** 1.0.0 在语义化版本里意味着「工具名和配置项从此不破坏性变更」。v2 才会动到传输层和工具集合,在那之前不配叫 1.0。
146
+
147
+ **升级时不用换算**:装最新版就行,`dsh plugin add dsh-dl-server` 永远指向当下最新。
132
148
 
133
149
  ## 许可
134
150
 
package/lib/mirrors.js CHANGED
@@ -100,3 +100,43 @@ export function hostOf(url) {
100
100
  return undefined
101
101
  }
102
102
  }
103
+
104
+ /** `W/"abc"` → `abc`; weak validators and quoting differ between servers. */
105
+ export function normalizeEtag(etag) {
106
+ if (typeof etag !== 'string') return ''
107
+ return etag.trim().replace(/^W\//i, '').replace(/^"|"$/g, '').toLowerCase()
108
+ }
109
+
110
+ /**
111
+ * Can `candidate` be accepted as the same bytes the origin would serve?
112
+ *
113
+ * This is a heuristic, not a proof: an ETag is whatever the mirror declares.
114
+ * It catches the common failures — a mirror caching an older build, or answering
115
+ * with an HTML error page — and nothing more. Only `expectSha256` proves content.
116
+ *
117
+ * @param {{ length?: number, etag?: string }} origin
118
+ * @param {{ length?: number, etag?: string }} candidate
119
+ * @returns {{ ok: boolean, reason?: string }}
120
+ */
121
+ export function sameResource(origin, candidate) {
122
+ const originLength = Number(origin?.length ?? -1)
123
+ const candidateLength = Number(candidate?.length ?? -1)
124
+
125
+ // No baseline (the origin probe failed): we cannot prove anything, and the
126
+ // mirror exists precisely for the case where the origin is unreachable.
127
+ if (!(originLength > 0)) return { ok: true, reason: 'origin-unknown' }
128
+
129
+ if (!(candidateLength > 0)) return { ok: false, reason: 'mirror did not report a length' }
130
+ if (candidateLength !== originLength) {
131
+ return { ok: false, reason: `length ${candidateLength} != ${originLength}` }
132
+ }
133
+
134
+ const originEtag = normalizeEtag(origin?.etag)
135
+ if (originEtag) {
136
+ const candidateEtag = normalizeEtag(candidate?.etag)
137
+ if (!candidateEtag) return { ok: false, reason: 'mirror reports no ETag' }
138
+ if (candidateEtag !== originEtag) return { ok: false, reason: 'ETag differs' }
139
+ }
140
+
141
+ return { ok: true }
142
+ }
package/lib/queue.js CHANGED
@@ -38,6 +38,26 @@ function storageKeyOf(id) {
38
38
  return seq === undefined ? id : `j${seq}`
39
39
  }
40
40
 
41
+ /**
42
+ * Comparison key for a destination path. Windows folds case and accepts both
43
+ * separators, so `D:\A\b.bin` and `d:/a/B.bin` are the same file there.
44
+ * @param {string} destPath
45
+ */
46
+ function destKeyOf(destPath) {
47
+ const resolved = path.resolve(destPath).replace(/[\\/]+$/, '')
48
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
49
+ }
50
+
51
+ /** @param {string} file */
52
+ async function fileExists(file) {
53
+ try {
54
+ const info = await fs.stat(file)
55
+ return info.isFile() && info.size > 0
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
41
61
  export class QueueError extends Error {
42
62
  /** @param {string} code @param {string} message */
43
63
  constructor(code, message) {
@@ -99,6 +119,8 @@ export class Queue {
99
119
  this.closed = false
100
120
  this.timer = undefined
101
121
  this.checkpointTimer = undefined
122
+ /** Session served last, so the next pass starts with a different one. */
123
+ this.lastServed = undefined
102
124
 
103
125
  /** @type {{ errors: number, lastError?: string, lastAt?: number }} */
104
126
  this.storage = { errors: 0 }
@@ -206,9 +228,37 @@ export class Queue {
206
228
  }
207
229
 
208
230
  const expanded = expandMirrors(spec.urls)
231
+ const destPath = path.resolve(spec.destPath)
232
+ const key = destKeyOf(destPath)
233
+
234
+ // Two jobs writing the same path is a conflict no matter which URL each one
235
+ // uses: aria2 would fight over the same .aria2 control file. Dedupe on the
236
+ // destination, not on the URL — one file can legitimately have many URLs.
237
+ const sameDest = [...this.jobs.entries()].map(([, record]) => record).filter((record) => destKeyOf(record.destPath) === key)
238
+
239
+ const active = sameDest.find((record) => !isTerminal(record.state))
240
+ if (active) {
241
+ if (active.urls[0] === expanded.urls[0]) {
242
+ this.log('info', `${active.id} reused by ${caller.session.slice(0, 8)} (same destination and source)`)
243
+ return { ...this.#view(active), deduplicated: true }
244
+ }
245
+ throw new QueueError(
246
+ 'dest-in-use',
247
+ `${destPath} is already being downloaded as ${active.id} from ${active.urls[0]}; cancel it first or choose another destination`,
248
+ )
249
+ }
250
+
251
+ // A finished job for the same file is what the caller wants far more often
252
+ // than a second download. Only reuse it while the file is still on disk —
253
+ // a deleted file must be fetched again.
254
+ const finished = sameDest.find((record) => record.state === 'completed' && record.urls[0] === expanded.urls[0])
255
+ if (finished && (await fileExists(finished.destPath))) {
256
+ this.log('info', `${finished.id} reused by ${caller.session.slice(0, 8)} (already downloaded)`)
257
+ return { ...this.#view(finished), deduplicated: true }
258
+ }
209
259
 
210
- await this.#assertDestWritable(spec.destPath)
211
- await this.#assertDiskSpace(spec.destPath, spec.expectedBytes)
260
+ await this.#assertDestWritable(destPath)
261
+ await this.#assertDiskSpace(destPath, spec.expectedBytes)
212
262
 
213
263
  const seq = await this.#nextSeq()
214
264
  const id = formatJobId(seq)
@@ -223,7 +273,7 @@ export class Queue {
223
273
  origin: caller.origin,
224
274
  urls: expanded.urls,
225
275
  mirrors: expanded.applied.length > 0 ? expanded.applied : undefined,
226
- destPath: spec.destPath,
276
+ destPath,
227
277
  state: 'queued',
228
278
  createdAt: stamp,
229
279
  updatedAt: stamp,
@@ -397,7 +447,7 @@ export class Queue {
397
447
  owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
398
448
  origin: record.origin,
399
449
  urls: record.urls,
400
- mirrors: record.mirrors,
450
+ mirrors: this.#mirrorView(record),
401
451
  dest: record.destPath,
402
452
  bytesTotal: total,
403
453
  bytesDone: done,
@@ -419,6 +469,21 @@ export class Queue {
419
469
  })
420
470
  }
421
471
 
472
+ /**
473
+ * Candidates were recorded at enqueue time; the transport only learns which of
474
+ * them actually proved to serve the same bytes once the transfer starts.
475
+ * @param {import('./types.js').JobRecord} record
476
+ */
477
+ #mirrorView(record) {
478
+ if (!record.mirrors) return undefined
479
+ const report = this.transport?.mirrorReports?.get(record.urls[0])
480
+ return {
481
+ candidates: record.mirrors,
482
+ verified: report?.verified ?? [],
483
+ rejected: report?.rejected ?? [],
484
+ }
485
+ }
486
+
422
487
  #schedule() {
423
488
  if (this.closed || this.timer) return
424
489
  this.timer = setTimeout(() => {
@@ -462,12 +527,16 @@ export class Queue {
462
527
  else bySession.set(key, [record])
463
528
  }
464
529
 
465
- // Round-robin across sessions, so a session that queued ten jobs cannot run
466
- // ahead of one that queued a single job.
530
+ // Start each pass after the session that was served last, otherwise the
531
+ // first session in iteration order wins every tie and round-robin is a lie.
532
+ const order = [...bySession.keys()]
533
+ const startAt = this.lastServed && order.includes(this.lastServed) ? (order.indexOf(this.lastServed) + 1) % order.length : 0
534
+ const rotated = [...order.slice(startAt), ...order.slice(0, startAt)]
535
+
467
536
  let progressed = true
468
537
  while (progressed && this.active.size < this.config.maxConcurrentJobs) {
469
538
  progressed = false
470
- for (const key of bySession.keys()) {
539
+ for (const key of rotated) {
471
540
  if (this.active.size >= this.config.maxConcurrentJobs) break
472
541
  const list = bySession.get(key)
473
542
  if (!list || list.length === 0) continue
@@ -475,6 +544,7 @@ export class Queue {
475
544
  const record = list.shift()
476
545
  if (this.active.has(record.id)) continue
477
546
  running.set(key, (running.get(key) ?? 0) + 1)
547
+ this.lastServed = key
478
548
  void this.#run(record)
479
549
  progressed = true
480
550
  }
package/lib/tools.js CHANGED
@@ -90,7 +90,13 @@ export function registerTools({ ctx, queue, config }) {
90
90
  { urls: args.urls, destPath: args.dest, expectSha256: args.sha256 },
91
91
  withCaller(exec),
92
92
  )
93
- return { ok: true, jobId: job.id, state: job.state, dest: job.dest }
93
+ return {
94
+ ok: true,
95
+ jobId: job.id,
96
+ state: job.state,
97
+ dest: job.dest,
98
+ ...(job.deduplicated ? { deduplicated: true } : {}),
99
+ }
94
100
  } catch (error) {
95
101
  return fail(error)
96
102
  }
package/lib/transport.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  import { Readable } from 'node:stream'
7
7
  import { pipeline } from 'node:stream/promises'
8
8
  import { randomBytes } from 'node:crypto'
9
+ import { sameResource } from './mirrors.js'
9
10
 
10
11
  /**
11
12
  * Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
@@ -194,6 +195,8 @@ export class Transport {
194
195
  this.mode = 'direct'
195
196
  this.aria2 = undefined
196
197
  this.rpc = undefined
198
+ /** @type {Map<string, { verified: string[], rejected: { url: string, reason: string }[] }>} */
199
+ this.mirrorReports = new Map()
197
200
  }
198
201
 
199
202
  /** Detect aria2 and start the daemon if possible. Never throws. */
@@ -247,15 +250,16 @@ export class Transport {
247
250
 
248
251
  /** @param {TransferOptions} options @returns {Promise<TransferResult>} */
249
252
  async transfer(options) {
250
- const urls = options.urls
251
- const declared = await this.#probeSize(urls, options.signal)
252
- const plan = planTransfer(declared, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
253
+ const origin = await this.#probeHead(options.urls[0], options.signal)
254
+ const plan = planTransfer(origin.length, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
253
255
  const connections = options.connections ?? plan.connections
256
+
257
+ const urls = await this.#verifiedUrls(options.urls, origin, options.signal)
254
258
  const useAria2 = this.mode === 'aria2' && this.rpc && !options.preferDirect && !plan.direct
255
259
 
256
260
  if (useAria2) {
257
261
  try {
258
- const result = await this.#transferAria2({ ...options, connections })
262
+ const result = await this.#transferAria2({ ...options, urls, connections })
259
263
  options.onUrlResult?.(urls[0], true)
260
264
  return result
261
265
  } catch (error) {
@@ -263,19 +267,53 @@ export class Transport {
263
267
  this.log('warn', `aria2 transfer failed, retrying single-stream: ${error.message}`)
264
268
  }
265
269
  }
266
- return this.#transferDirect(options)
270
+ return this.#transferDirect({ ...options, urls })
267
271
  }
268
272
 
269
273
  /**
270
- * Best-effort Content-Length lookup. A failure is not an error: unknown size
271
- * only means the planner falls back to its middle-of-the-road setting.
274
+ * Keep only the mirrors that can be shown to serve the same bytes as the
275
+ * origin. A mirror is a third party: it may hold an older build or answer with
276
+ * an HTML error page, and `sha256` is the only proof of content. Length plus
277
+ * ETag catches the common mistakes; the reasoning is documented in SPEC §11.2.
272
278
  * @param {string[]} urls
279
+ * @param {{ length: number, etag?: string }} origin
280
+ * @param {AbortSignal} [signal]
281
+ */
282
+ async #verifiedUrls(urls, origin, signal) {
283
+ if (urls.length <= 1) return urls
284
+
285
+ const accepted = [urls[0]]
286
+ /** @type {{ url: string, reason: string }[]} */
287
+ const rejected = []
288
+
289
+ for (const mirror of urls.slice(1)) {
290
+ const head = await this.#probeHead(mirror, signal)
291
+ const verdict = sameResource(origin, head)
292
+ if (verdict.ok) {
293
+ accepted.push(mirror)
294
+ if (verdict.reason === 'origin-unknown') {
295
+ this.log('warn', `mirror ${mirror} kept unverified: the origin did not report a length`)
296
+ }
297
+ } else {
298
+ rejected.push({ url: mirror, reason: verdict.reason ?? 'unverified' })
299
+ this.log('warn', `mirror ${mirror} rejected: ${verdict.reason}`)
300
+ }
301
+ }
302
+
303
+ this.mirrorReports.set(urls[0], { verified: accepted.slice(1), rejected })
304
+ return accepted
305
+ }
306
+
307
+ /**
308
+ * Best-effort HEAD: the length and validator used for sizing and for proving a
309
+ * mirror matches. A failure is not an error — unknown size only means the
310
+ * planner stays conservative.
311
+ * @param {string} url
273
312
  * @param {AbortSignal} [signal]
274
- * @returns {Promise<number>} the size, or -1 when it could not be determined
313
+ * @returns {Promise<{ length: number, etag?: string }>}
275
314
  */
276
- async #probeSize(urls, signal) {
277
- const url = urls[0]
278
- if (!url) return -1
315
+ async #probeHead(url, signal) {
316
+ if (!url) return { length: -1 }
279
317
  const controller = new AbortController()
280
318
  const timer = setTimeout(() => controller.abort(), 5000)
281
319
  timer.unref?.()
@@ -284,11 +322,12 @@ export class Transport {
284
322
 
285
323
  try {
286
324
  const response = await fetch(url, { method: 'HEAD', signal: controller.signal })
287
- if (!response.ok) return -1
325
+ if (!response.ok) return { length: -1 }
288
326
  const length = Number(response.headers.get('content-length') ?? 0)
289
- return Number.isFinite(length) && length > 0 ? length : -1
327
+ const etag = response.headers.get('etag') ?? undefined
328
+ return { length: Number.isFinite(length) && length > 0 ? length : -1, etag }
290
329
  } catch {
291
- return -1
330
+ return { length: -1 }
292
331
  } finally {
293
332
  clearTimeout(timer)
294
333
  signal?.removeEventListener('abort', onAbort)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dl-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DLS — DSH Download Server: a global, session-aware, persistent download queue for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",