merge: protocol 1.1.0 — buffer bounds, segment budget settings
This commit is contained in:
+6
-3
@@ -5,9 +5,12 @@ Nobody else commits here. Everybody else *generates from* here.
|
||||
|
||||
> ## Status: **v1.0.0 — FROZEN** (2026-09-09)
|
||||
>
|
||||
> The surface below is complete and generated from: 38 methods, 9 events, 26 named types,
|
||||
> 59 fixtures. See `docs/adr/0005-protocol-1.0.0-freeze.md` for the versioning rule and
|
||||
> `docs/adr/0010-...` for the failure taxonomy and the segment range convention.
|
||||
> **v1.0.0** froze 38 methods, 9 events, 26 named types. **v1.1.0** (current) is a minor
|
||||
> bump on top of it: `bufferBytes` bounds widened to 64 KiB - 16 MiB across all four
|
||||
> locations, two new settings keys (`connection.maxTotalBufferBytes`,
|
||||
> `connection.maxActiveSegments`), and `TaskDetail.effectiveBufferBytes` — see
|
||||
> `docs/adr/0012-buffer-and-segment-budget.md`. See also `docs/adr/0005-...` for the
|
||||
> versioning rule and `docs/adr/0010-...` for the failure taxonomy and segment ranges.
|
||||
>
|
||||
> Lane requests are answered in writing: `contracts/proto-answers-m1.md` responds to
|
||||
> `core/docs/proto-requests-m1.md` point by point.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.0
|
||||
|
||||
@@ -116,7 +116,8 @@
|
||||
"referrer": "https://releases.ubuntu.com/26.04/",
|
||||
"userAgent": "Velox/0.1",
|
||||
"mime": "application/octet-stream",
|
||||
"bufferBytes": 4194304,
|
||||
"bufferBytes": 16777216,
|
||||
"effectiveBufferBytes": 4194304,
|
||||
"partPath": "/home/sami/Downloads/Programs/ubuntu-26.04-desktop-amd64.iso.veloxpart",
|
||||
"checksum": null,
|
||||
"checksumVerified": null,
|
||||
@@ -128,6 +129,7 @@
|
||||
"segment ranges are contiguous and cover exactly [0, sizeBytes) with no gaps or overlaps",
|
||||
"startByte and endByte are both INCLUSIVE: segment 0 here covers 778567680 bytes, 0 through 778567679, and is copied verbatim into 'Range: bytes=0-778567679'",
|
||||
"segmentDetail has exactly summary.segments entries",
|
||||
"the GUI draws one bar per entry and is never told what a segment steal is"
|
||||
"the GUI draws one bar per entry and is never told what a segment steal is",
|
||||
"bufferBytes is what was requested (16 MiB); effectiveBufferBytes (4 MiB) is what this segment is actually using right now, after connection.maxTotalBufferBytes (128 MiB default) is divided across every live segment in the daemon -- not just this task's -- up to connection.maxActiveSegments (32 default). The clamp is global: a task can be reduced even when its own segment count alone would not force it."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"keys": [
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"connection.bufferBytes",
|
||||
"connection.maxTotalBufferBytes",
|
||||
"connection.maxActiveSegments",
|
||||
"connection.maxConcurrentDownloads",
|
||||
"connection.timeoutSec"
|
||||
]
|
||||
@@ -20,7 +22,9 @@
|
||||
"result": {
|
||||
"values": {
|
||||
"connection.maxSegmentsPerDownload": 8,
|
||||
"connection.bufferBytes": 4194304,
|
||||
"connection.bufferBytes": 1048576,
|
||||
"connection.maxTotalBufferBytes": 134217728,
|
||||
"connection.maxActiveSegments": 32,
|
||||
"connection.maxConcurrentDownloads": 5,
|
||||
"connection.timeoutSec": 30
|
||||
}
|
||||
@@ -29,6 +33,8 @@
|
||||
"assertions": [
|
||||
"only the requested keys come back",
|
||||
"keys null returns everything",
|
||||
"no password is ever present: credentials live in the Secret Service"
|
||||
"no password is ever present: credentials live in the Secret Service",
|
||||
"connection.bufferBytes defaults to 1 MiB (1048576), not the old 4 MiB",
|
||||
"connection.maxTotalBufferBytes and connection.maxActiveSegments are the two knobs behind TaskDetail.effectiveBufferBytes; Options cannot show or set the clamp without them"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"params": {
|
||||
"values": {
|
||||
"connection.maxSegmentsPerDownload": 16,
|
||||
"downloads.verifyChecksums": true
|
||||
"downloads.verifyChecksums": true,
|
||||
"connection.bufferBytes": 2097152
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18,17 +19,20 @@
|
||||
"result": {
|
||||
"values": {
|
||||
"connection.maxSegmentsPerDownload": 16,
|
||||
"downloads.verifyChecksums": true
|
||||
"downloads.verifyChecksums": true,
|
||||
"connection.bufferBytes": 2097152
|
||||
},
|
||||
"changed": [
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"downloads.verifyChecksums"
|
||||
"downloads.verifyChecksums",
|
||||
"connection.bufferBytes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"event.settings.changed is emitted carrying exactly the keys in changed[]",
|
||||
"an unknown key is -32602 and nothing at all is written",
|
||||
"a directory key naming an unwritable path is -32011"
|
||||
"a directory key naming an unwritable path is -32011",
|
||||
"connection.bufferBytes accepts 64 KiB - 16 MiB; a value outside that range is -32602"
|
||||
]
|
||||
}
|
||||
|
||||
+41
-12
@@ -2,7 +2,7 @@
|
||||
"openrpc": "1.2.6",
|
||||
"info": {
|
||||
"title": "Velox Download Manager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "The wire contract between veloxd and every client: the Qt GUI, the CLI, the native-messaging host and the Firefox extension. One JSON-RPC 2.0 payload set over four framings; only the framing differs.\n\nGENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. Do not edit by hand.",
|
||||
"license": {
|
||||
"name": "See repository LICENSE"
|
||||
@@ -498,9 +498,11 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
}
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "Requested write buffer per segment, in bytes. null means use connection.bufferBytes. Default 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit connection.maxTotalBufferBytes across all live segments; the effective value is reported back as TaskDetail.effectiveBufferBytes."
|
||||
},
|
||||
"description": "Requested write buffer per segment, in bytes. null means use connection.bufferBytes. Default 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit connection.maxTotalBufferBytes across all live segments; the effective value is reported back as TaskDetail.effectiveBufferBytes."
|
||||
},
|
||||
{
|
||||
"name": "startMode",
|
||||
@@ -1366,8 +1368,9 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The REQUESTED write buffer per segment. Subject to the same maxTotalBufferBytes reduction as DownloadSpec.bufferBytes; the effective value comes back on the next download.get."
|
||||
},
|
||||
"checksum": {
|
||||
"oneOf": [
|
||||
@@ -3138,8 +3141,9 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "Requested write buffer per segment, in bytes. null means use connection.bufferBytes. Default 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit connection.maxTotalBufferBytes across all live segments; the effective value is reported back as TaskDetail.effectiveBufferBytes."
|
||||
},
|
||||
"startMode": {
|
||||
"$ref": "#/components/schemas/StartMode"
|
||||
@@ -3760,6 +3764,8 @@
|
||||
"connection.preset",
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"connection.bufferBytes",
|
||||
"connection.maxTotalBufferBytes",
|
||||
"connection.maxActiveSegments",
|
||||
"connection.maxConcurrentDownloads",
|
||||
"connection.timeoutSec",
|
||||
"connection.maxRetries",
|
||||
@@ -3883,8 +3889,9 @@
|
||||
},
|
||||
"connection.bufferBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "Default per-segment write buffer, in bytes, when a task does not request its own. Default 1 MiB (1048576); range 64 KiB - 16 MiB. This is the single biggest throughput knob and is exposed in Options -> Downloads -> 'Write buffer per connection'."
|
||||
},
|
||||
"connection.maxConcurrentDownloads": {
|
||||
"type": "integer",
|
||||
@@ -3973,6 +3980,18 @@
|
||||
},
|
||||
"sounds.onError": {
|
||||
"type": "string"
|
||||
},
|
||||
"connection.maxTotalBufferBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 16777216,
|
||||
"maximum": 2147483648,
|
||||
"description": "Global cap on write-buffer memory across every live segment, in bytes. Default 128 MiB (134217728). Every live segment's buffer is reduced to fit maxTotalBufferBytes / (live segment count, capped at maxActiveSegments); the reduced value is reported per task as TaskDetail.effectiveBufferBytes. Exists so a burst of large downloads with a large per-segment buffer cannot exhaust memory."
|
||||
},
|
||||
"connection.maxActiveSegments": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 256,
|
||||
"description": "Global ceiling on segments actually transferring at once, across every task. Default 32. This is the real bound behind '20 active downloads': the rest of each download's segments queue rather than all dialling out simultaneously. DAEMON's scheduler needs this value to decide what to admit; CORE enforces it."
|
||||
}
|
||||
},
|
||||
"title": "Settings"
|
||||
@@ -4040,8 +4059,18 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The REQUESTED write buffer per segment. See effectiveBufferBytes for what is actually in use."
|
||||
},
|
||||
"effectiveBufferBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The write buffer actually in use per live segment, right now. May be well below bufferBytes: the daemon reduces every live segment's buffer to fit connection.maxTotalBufferBytes across connection.maxActiveSegments concurrently-transferring segments, and reports the reduced value here so the GUI can show '16 MiB (using 4 MiB)'. null before the task has started its first segment."
|
||||
},
|
||||
"partPath": {
|
||||
"type": [
|
||||
|
||||
@@ -99,7 +99,7 @@ Agreed with your ranking: these are minor under rule 4 and land as small PRs to
|
||||
|
||||
| # | Item | Verdict | Shape |
|
||||
|---|---|---|---|
|
||||
| **B2a** | readable effective buffer size | **accepted** | `effectiveBufferBytes` on `TaskSummary`, next to the effective segment count, so the requested/effective split reads the same way for both. You are right about the `additionalProperties: false` trap — no daemon can tack it on, so it needs a schema PR either way. |
|
||||
| **B2a** | readable effective buffer size | **landed in 1.1.0** | `TaskDetail.effectiveBufferBytes` (placed on `TaskDetail`, not `TaskSummary` — `bufferBytes` itself was already `TaskDetail`-only, so the pair stays together). See `docs/adr/0012-buffer-and-segment-budget.md`, which also lands B4's bounds and the two new settings keys in the same PR. |
|
||||
| **F2** | credential return path for 401/407 | **accepted as proposed** | `download.provideAuth {taskId, username, password, save?}` → `{ok}`. Unix socket only, privileged: a credential-bearing method must never be reachable from the browser. Secrets go to the Secret Service; `save` only tells DAEMON whether to persist. |
|
||||
| **F1** | "needs user decision" carrier | **the simple option** | `state: paused` + `event.notify` is the intended carrier for M1: CORE reports `server_file_changed`, DAEMON pauses and notifies, GUI offers restart. A dedicated `event.task.decision` + `download.decide` is a real design with a state machine attached, and it should not be invented in a hurry — raise it again in M3 if the notify path proves too thin. A string comparison on `error.code` covers the engine side either way, which is now a `TaskErrorCode` comparison rather than a magic number. |
|
||||
| **F3** | `checksum` string format | **already frozen, differently** | `download.add {checksum}` is **not** a string. It is a `Checksum` object: `{algorithm: "md5"\|"sha1"\|"sha256"\|"sha512", value: "<hex>"}`, with `value` patterned `^[0-9a-fA-F]{32,128}$`. Parse your `"<algo>:<hex>"` form at the CLI or GUI edge, not on the wire. Note `sha512` is accepted by the contract even though the appendix lists MD5/SHA-256 — reject it in the engine if you do not implement it, rather than the contract forbidding it. |
|
||||
|
||||
@@ -78,8 +78,9 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The REQUESTED write buffer per segment. Subject to the same maxTotalBufferBytes reduction as DownloadSpec.bufferBytes; the effective value comes back on the next download.get."
|
||||
},
|
||||
"checksum": {
|
||||
"oneOf": [
|
||||
|
||||
@@ -87,8 +87,9 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "Requested write buffer per segment, in bytes. null means use connection.bufferBytes. Default 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit connection.maxTotalBufferBytes across all live segments; the effective value is reported back as TaskDetail.effectiveBufferBytes."
|
||||
},
|
||||
"startMode": {
|
||||
"$ref": "https://velox.dev/schema/types/StartMode.schema.json"
|
||||
|
||||
@@ -5,25 +5,48 @@
|
||||
"description": "Every settings key that exists. The Options dialog maps 1:1 onto this list and the GUI must not invent a key that is not here. Kept in lockstep with Settings.schema.json by a conformance check.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"general.launchOnLogin", "general.minimizeToTray", "general.showDropTarget",
|
||||
"general.confirmOnExit", "general.language", "general.checkForUpdates",
|
||||
|
||||
"capture.enabled", "capture.monitoredExtensions", "capture.monitoredMimeTypes",
|
||||
"capture.minSizeBytes", "capture.excludedHosts", "capture.bypassModifier",
|
||||
"general.launchOnLogin",
|
||||
"general.minimizeToTray",
|
||||
"general.showDropTarget",
|
||||
"general.confirmOnExit",
|
||||
"general.language",
|
||||
"general.checkForUpdates",
|
||||
"capture.enabled",
|
||||
"capture.monitoredExtensions",
|
||||
"capture.monitoredMimeTypes",
|
||||
"capture.minSizeBytes",
|
||||
"capture.excludedHosts",
|
||||
"capture.bypassModifier",
|
||||
"capture.autoStartTypes",
|
||||
|
||||
"saveTo.defaultDir", "saveTo.tempDir", "saveTo.allowedRoots",
|
||||
"saveTo.fileExistsPolicy", "saveTo.createSubfolderPerSite",
|
||||
|
||||
"connection.preset", "connection.maxSegmentsPerDownload", "connection.bufferBytes",
|
||||
"connection.maxConcurrentDownloads", "connection.timeoutSec", "connection.maxRetries",
|
||||
"saveTo.defaultDir",
|
||||
"saveTo.tempDir",
|
||||
"saveTo.allowedRoots",
|
||||
"saveTo.fileExistsPolicy",
|
||||
"saveTo.createSubfolderPerSite",
|
||||
"connection.preset",
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"connection.bufferBytes",
|
||||
"connection.maxTotalBufferBytes",
|
||||
"connection.maxActiveSegments",
|
||||
"connection.maxConcurrentDownloads",
|
||||
"connection.timeoutSec",
|
||||
"connection.maxRetries",
|
||||
"connection.retryBackoffSec",
|
||||
|
||||
"downloads.speedLimitBps", "downloads.speedLimitEnabled", "downloads.virusScanCommand",
|
||||
"downloads.postDownloadCommand", "downloads.duplicatePolicy", "downloads.verifyChecksums",
|
||||
|
||||
"proxy.mode", "proxy.host", "proxy.port", "proxy.username", "proxy.bypassHosts", "proxy.pacUrl",
|
||||
|
||||
"sounds.enabled", "sounds.onComplete", "sounds.onQueueComplete", "sounds.onError"
|
||||
"downloads.speedLimitBps",
|
||||
"downloads.speedLimitEnabled",
|
||||
"downloads.virusScanCommand",
|
||||
"downloads.postDownloadCommand",
|
||||
"downloads.duplicatePolicy",
|
||||
"downloads.verifyChecksums",
|
||||
"proxy.mode",
|
||||
"proxy.host",
|
||||
"proxy.port",
|
||||
"proxy.username",
|
||||
"proxy.bypassHosts",
|
||||
"proxy.pacUrl",
|
||||
"sounds.enabled",
|
||||
"sounds.onComplete",
|
||||
"sounds.onQueueComplete",
|
||||
"sounds.onError"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
},
|
||||
"connection.bufferBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "Default per-segment write buffer, in bytes, when a task does not request its own. Default 1 MiB (1048576); range 64 KiB - 16 MiB. This is the single biggest throughput knob and is exposed in Options -> Downloads -> 'Write buffer per connection'."
|
||||
},
|
||||
"connection.maxConcurrentDownloads": {
|
||||
"type": "integer",
|
||||
@@ -191,6 +192,18 @@
|
||||
},
|
||||
"sounds.onError": {
|
||||
"type": "string"
|
||||
},
|
||||
"connection.maxTotalBufferBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 16777216,
|
||||
"maximum": 2147483648,
|
||||
"description": "Global cap on write-buffer memory across every live segment, in bytes. Default 128 MiB (134217728). Every live segment's buffer is reduced to fit maxTotalBufferBytes / (live segment count, capped at maxActiveSegments); the reduced value is reported per task as TaskDetail.effectiveBufferBytes. Exists so a burst of large downloads with a large per-segment buffer cannot exhaust memory."
|
||||
},
|
||||
"connection.maxActiveSegments": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 256,
|
||||
"description": "Global ceiling on segments actually transferring at once, across every task. Default 32. This is the real bound behind '20 active downloads': the rest of each download's segments queue rather than all dialling out simultaneously. DAEMON's scheduler needs this value to decide what to admit; CORE enforces it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,18 @@
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The REQUESTED write buffer per segment. See effectiveBufferBytes for what is actually in use."
|
||||
},
|
||||
"effectiveBufferBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 65536,
|
||||
"maximum": 16777216,
|
||||
"description": "The write buffer actually in use per live segment, right now. May be well below bufferBytes: the daemon reduces every live segment's buffer to fit connection.maxTotalBufferBytes across connection.maxActiveSegments concurrently-transferring segments, and reports the reduced value here so the GUI can show '16 MiB (using 4 MiB)'. null before the task has started its first segment."
|
||||
},
|
||||
"partPath": {
|
||||
"type": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_cpp.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_cpp.py
|
||||
@@ -401,6 +401,8 @@ std::string_view to_string(SettingKey v) noexcept {
|
||||
case SettingKey::ConnectionPreset: return "connection.preset";
|
||||
case SettingKey::ConnectionMaxSegmentsPerDownload: return "connection.maxSegmentsPerDownload";
|
||||
case SettingKey::ConnectionBufferBytes: return "connection.bufferBytes";
|
||||
case SettingKey::ConnectionMaxTotalBufferBytes: return "connection.maxTotalBufferBytes";
|
||||
case SettingKey::ConnectionMaxActiveSegments: return "connection.maxActiveSegments";
|
||||
case SettingKey::ConnectionMaxConcurrentDownloads: return "connection.maxConcurrentDownloads";
|
||||
case SettingKey::ConnectionTimeoutSec: return "connection.timeoutSec";
|
||||
case SettingKey::ConnectionMaxRetries: return "connection.maxRetries";
|
||||
@@ -447,6 +449,8 @@ Result<SettingKey> parse_SettingKey(std::string_view s) {
|
||||
if (s == "connection.preset") return SettingKey::ConnectionPreset;
|
||||
if (s == "connection.maxSegmentsPerDownload") return SettingKey::ConnectionMaxSegmentsPerDownload;
|
||||
if (s == "connection.bufferBytes") return SettingKey::ConnectionBufferBytes;
|
||||
if (s == "connection.maxTotalBufferBytes") return SettingKey::ConnectionMaxTotalBufferBytes;
|
||||
if (s == "connection.maxActiveSegments") return SettingKey::ConnectionMaxActiveSegments;
|
||||
if (s == "connection.maxConcurrentDownloads") return SettingKey::ConnectionMaxConcurrentDownloads;
|
||||
if (s == "connection.timeoutSec") return SettingKey::ConnectionTimeoutSec;
|
||||
if (s == "connection.maxRetries") return SettingKey::ConnectionMaxRetries;
|
||||
@@ -1601,8 +1605,8 @@ template <> Result<DownloadSpec> parse<DownloadSpec>(const nlohmann::json& j, st
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"});
|
||||
if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"});
|
||||
if (val < 65536) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 65536"});
|
||||
if (val > 16777216) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 16777216"});
|
||||
out.bufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
@@ -2428,6 +2432,8 @@ void to_json(nlohmann::json& j, const Settings& v) {
|
||||
if (v.sounds_onComplete.has_value()) j["sounds.onComplete"] = *v.sounds_onComplete;
|
||||
if (v.sounds_onQueueComplete.has_value()) j["sounds.onQueueComplete"] = *v.sounds_onQueueComplete;
|
||||
if (v.sounds_onError.has_value()) j["sounds.onError"] = *v.sounds_onError;
|
||||
if (v.connection_maxTotalBufferBytes.has_value()) j["connection.maxTotalBufferBytes"] = *v.connection_maxTotalBufferBytes;
|
||||
if (v.connection_maxActiveSegments.has_value()) j["connection.maxActiveSegments"] = *v.connection_maxActiveSegments;
|
||||
}
|
||||
|
||||
template <> Result<Settings> parse<Settings>(const nlohmann::json& j, std::string_view path) {
|
||||
@@ -2660,8 +2666,8 @@ template <> Result<Settings> parse<Settings>(const nlohmann::json& j, std::strin
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"});
|
||||
if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"});
|
||||
if (val < 65536) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 65536"});
|
||||
if (val > 16777216) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 16777216"});
|
||||
out.connection_bufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
@@ -2865,6 +2871,28 @@ template <> Result<Settings> parse<Settings>(const nlohmann::json& j, std::strin
|
||||
out.sounds_onError = std::move(val);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "connection.maxTotalBufferBytes");
|
||||
const auto it = j.find("connection.maxTotalBufferBytes");
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 16777216) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 16777216"});
|
||||
if (val > 2147483648) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 2147483648"});
|
||||
out.connection_maxTotalBufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "connection.maxActiveSegments");
|
||||
const auto it = j.find("connection.maxActiveSegments");
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"});
|
||||
if (val > 256) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 256"});
|
||||
out.connection_maxActiveSegments = std::move(val);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -3183,6 +3211,7 @@ void to_json(nlohmann::json& j, const TaskDetail& v) {
|
||||
if (v.userAgent.has_value()) j["userAgent"] = *v.userAgent;
|
||||
if (v.mime.has_value()) j["mime"] = *v.mime;
|
||||
if (v.bufferBytes.has_value()) j["bufferBytes"] = *v.bufferBytes;
|
||||
if (v.effectiveBufferBytes.has_value()) j["effectiveBufferBytes"] = *v.effectiveBufferBytes;
|
||||
if (v.partPath.has_value()) j["partPath"] = *v.partPath;
|
||||
if (v.checksum.has_value()) j["checksum"] = *v.checksum;
|
||||
if (v.checksumVerified.has_value()) j["checksumVerified"] = *v.checksumVerified;
|
||||
@@ -3264,11 +3293,22 @@ template <> Result<TaskDetail> parse<TaskDetail>(const nlohmann::json& j, std::s
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"});
|
||||
if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"});
|
||||
if (val < 65536) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 65536"});
|
||||
if (val > 16777216) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 16777216"});
|
||||
out.bufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "effectiveBufferBytes");
|
||||
const auto it = j.find("effectiveBufferBytes");
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 65536) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 65536"});
|
||||
if (val > 16777216) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 16777216"});
|
||||
out.effectiveBufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "partPath");
|
||||
const auto it = j.find("partPath");
|
||||
@@ -4711,8 +4751,8 @@ template <> Result<DownloadUpdateParamsPatch> parse<DownloadUpdateParamsPatch>(c
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"});
|
||||
if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"});
|
||||
if (val < 65536) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 65536"});
|
||||
if (val > 16777216) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 16777216"});
|
||||
out.bufferBytes = std::move(val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_cpp.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_cpp.py
|
||||
@@ -27,7 +27,7 @@
|
||||
// docs/adr/0009-generated-protocol-library.md.
|
||||
namespace velox::proto {
|
||||
|
||||
inline constexpr std::string_view kProtocolVersion = "1.0.0";
|
||||
inline constexpr std::string_view kProtocolVersion = "1.1.0";
|
||||
|
||||
/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer
|
||||
/// into the offending document, so a conformance failure names the exact field.
|
||||
@@ -219,6 +219,8 @@ enum class SettingKey {
|
||||
ConnectionPreset, // "connection.preset"
|
||||
ConnectionMaxSegmentsPerDownload, // "connection.maxSegmentsPerDownload"
|
||||
ConnectionBufferBytes, // "connection.bufferBytes"
|
||||
ConnectionMaxTotalBufferBytes, // "connection.maxTotalBufferBytes"
|
||||
ConnectionMaxActiveSegments, // "connection.maxActiveSegments"
|
||||
ConnectionMaxConcurrentDownloads, // "connection.maxConcurrentDownloads"
|
||||
ConnectionTimeoutSec, // "connection.timeoutSec"
|
||||
ConnectionMaxRetries, // "connection.maxRetries"
|
||||
@@ -535,6 +537,9 @@ struct DownloadSpec {
|
||||
/// per-host cap, and to 1 when the source turns out not to be resumable. What is actually in
|
||||
/// use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload.
|
||||
std::optional<std::int64_t> segments{};
|
||||
/// Requested write buffer per segment, in bytes. null means use connection.bufferBytes. Default
|
||||
/// 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit connection.maxTotalBufferBytes across
|
||||
/// all live segments; the effective value is reported back as TaskDetail.effectiveBufferBytes.
|
||||
std::optional<std::int64_t> bufferBytes{};
|
||||
std::optional<StartMode> startMode{};
|
||||
std::optional<std::string> description{};
|
||||
@@ -708,6 +713,9 @@ struct Settings {
|
||||
std::optional<bool> saveTo_createSubfolderPerSite{};
|
||||
std::optional<SettingsConnectionPreset> connection_preset{};
|
||||
std::optional<std::int64_t> connection_maxSegmentsPerDownload{};
|
||||
/// Default per-segment write buffer, in bytes, when a task does not request its own. Default 1
|
||||
/// MiB (1048576); range 64 KiB - 16 MiB. This is the single biggest throughput knob and is
|
||||
/// exposed in Options -> Downloads -> 'Write buffer per connection'.
|
||||
std::optional<std::int64_t> connection_bufferBytes{};
|
||||
std::optional<std::int64_t> connection_maxConcurrentDownloads{};
|
||||
std::optional<std::int64_t> connection_timeoutSec{};
|
||||
@@ -729,6 +737,17 @@ struct Settings {
|
||||
std::optional<std::string> sounds_onComplete{};
|
||||
std::optional<std::string> sounds_onQueueComplete{};
|
||||
std::optional<std::string> sounds_onError{};
|
||||
/// Global cap on write-buffer memory across every live segment, in bytes. Default 128 MiB
|
||||
/// (134217728). Every live segment's buffer is reduced to fit maxTotalBufferBytes / (live
|
||||
/// segment count, capped at maxActiveSegments); the reduced value is reported per task as
|
||||
/// TaskDetail.effectiveBufferBytes. Exists so a burst of large downloads with a large
|
||||
/// per-segment buffer cannot exhaust memory.
|
||||
std::optional<std::int64_t> connection_maxTotalBufferBytes{};
|
||||
/// Global ceiling on segments actually transferring at once, across every task. Default 32.
|
||||
/// This is the real bound behind '20 active downloads': the rest of each download's segments
|
||||
/// queue rather than all dialling out simultaneously. DAEMON's scheduler needs this value to
|
||||
/// decide what to admit; CORE enforces it.
|
||||
std::optional<std::int64_t> connection_maxActiveSegments{};
|
||||
};
|
||||
|
||||
/// Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which
|
||||
@@ -805,7 +824,15 @@ struct TaskDetail {
|
||||
std::optional<std::string> referrer{};
|
||||
std::optional<std::string> userAgent{};
|
||||
std::optional<std::string> mime{};
|
||||
/// The REQUESTED write buffer per segment. See effectiveBufferBytes for what is actually in
|
||||
/// use.
|
||||
std::optional<std::int64_t> bufferBytes{};
|
||||
/// The write buffer actually in use per live segment, right now. May be well below bufferBytes:
|
||||
/// the daemon reduces every live segment's buffer to fit connection.maxTotalBufferBytes across
|
||||
/// connection.maxActiveSegments concurrently-transferring segments, and reports the reduced
|
||||
/// value here so the GUI can show '16 MiB (using 4 MiB)'. null before the task has started its
|
||||
/// first segment.
|
||||
std::optional<std::int64_t> effectiveBufferBytes{};
|
||||
/// Absolute path of the .veloxpart file while the task is unfinished.
|
||||
std::optional<std::string> partPath{};
|
||||
std::optional<Checksum> checksum{};
|
||||
@@ -1030,6 +1057,8 @@ struct DownloadUpdateParamsPatch {
|
||||
/// as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented
|
||||
/// underneath the user.
|
||||
std::optional<std::int64_t> segments{};
|
||||
/// The REQUESTED write buffer per segment. Subject to the same maxTotalBufferBytes reduction as
|
||||
/// DownloadSpec.bufferBytes; the effective value comes back on the next download.get.
|
||||
std::optional<std::int64_t> bufferBytes{};
|
||||
std::optional<Checksum> checksum{};
|
||||
};
|
||||
|
||||
@@ -67,13 +67,23 @@ One file, opened once, `O_WRONLY`. Each segment `pwrite()`s at its own absolute
|
||||
- `posix_fallocate()` the full size up front → contiguous extents, no ENOSPC surprise at
|
||||
99 %, no fragmentation.
|
||||
- Per-segment ring buffer, size = **`buffer_bytes`** (the user-visible "Buffer size"
|
||||
setting). Default 4 MiB, range 64 KiB – 64 MiB. Curl's write callback appends; the
|
||||
buffer is flushed with a single `pwrite` when full or when the segment ends.
|
||||
setting, `connection.bufferBytes` on the wire). Default **1 MiB**, range **64 KiB –
|
||||
16 MiB**. Curl's write callback appends; the buffer is flushed with a single `pwrite`
|
||||
when full or when the segment ends.
|
||||
*This is the single biggest throughput knob and it is exposed in the UI: Options →
|
||||
Downloads → "Write buffer per connection".*
|
||||
- Global cap `max_total_buffer_bytes` (default 256 MiB) so 32 segments × 64 MiB can't OOM
|
||||
the box. The per-segment value is silently reduced to fit and the effective value is
|
||||
reported back to the UI.
|
||||
- Global cap `max_total_buffer_bytes` (`connection.maxTotalBufferBytes`, default
|
||||
**128 MiB**) so a burst of large downloads with a large per-segment buffer can't OOM the
|
||||
box. Combined with `max_active_segments` (`connection.maxActiveSegments`, default
|
||||
**32**) — the ceiling on segments actually transferring at once, across every task, not
|
||||
per download — every live segment's buffer is reduced to fit
|
||||
`max_total_buffer_bytes / live_segment_count` (capped by `max_active_segments`), never
|
||||
below the 64 KiB floor. The requested and effective values are both reported back to
|
||||
the UI (`TaskDetail.bufferBytes` / `.effectiveBufferBytes`) so it can show, for example,
|
||||
"16 MiB (using 4 MiB)". See `docs/adr/0012-buffer-and-segment-budget.md` for the
|
||||
reasoning behind these numbers, including why 4 MiB / 64 MiB / 256 MiB (this section's
|
||||
earlier draft) does not hold ≤ 60 MB RSS once buffers are counted per segment rather
|
||||
than per download.
|
||||
- `posix_fadvise(POSIX_FADV_DONTNEED)` on written ranges — do not let a 40 GB ISO evict
|
||||
the user's entire page cache.
|
||||
- `fdatasync()` on a timer (default 5 s) and on pause, **not** per write.
|
||||
@@ -122,6 +132,6 @@ toggles between Full speed / a saved limit, exactly as IDM does.
|
||||
## 8. Performance targets (M7 gate, `tools/bench/`)
|
||||
|
||||
- Saturate a 1 Gbit link with ≤ 8 % of one core.
|
||||
- ≤ 60 MB RSS with 20 active downloads at default buffers.
|
||||
- ≤ 60 MB RSS with 20 active downloads at default buffers, **given `max_active_segments = 32`** — without that cap, 20 downloads × 8 segments each is 160 live buffers even at the 1 MiB default, and the number does not hold. See `docs/adr/0012-buffer-and-segment-budget.md`.
|
||||
- 10 000-row task list: RPC `download.list` under 50 ms, GUI scroll at 60 fps.
|
||||
- No allocation in the curl write callback hot path (ring buffer is preallocated).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# ADR 0012 — Buffer bounds, the total-buffer cap, and `maxActiveSegments`
|
||||
|
||||
**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO
|
||||
**Answers:** `core/docs/buffer-sizing.md` (CORE's B4)
|
||||
|
||||
## Context
|
||||
|
||||
`docs/04` §4 (line 70) said 64 KiB – 64 MiB, default 4 MiB, global cap 256 MiB. The frozen
|
||||
contract said 4 KiB – 8 MiB in four places. Neither matched the other, and CORE's
|
||||
reconciliation found the deeper problem: `docs/04`'s own performance target — "≤ 60 MB RSS
|
||||
with 20 active downloads at default buffers" (§8, line 125) — assumed one buffer per
|
||||
*download*. The design is one buffer per *segment* ("Per-segment ring buffer", §4 line 68).
|
||||
Twenty downloads at the 8-segment default is 160 buffers, not 20. At any of the candidate
|
||||
defaults (4 MiB, 2 MiB, even 1 MiB) that arithmetic busts the 60 MB target by 3–4×, and the
|
||||
256 MiB global cap does too on its own.
|
||||
|
||||
CORE also found a second wire gap: the clamp the engine has to implement — reduce every
|
||||
live segment's buffer to fit the global cap — has no field to report the reduced value on,
|
||||
so the GUI cannot show what a download is actually using, only what was requested.
|
||||
|
||||
## Decision
|
||||
|
||||
**Bounds**, in all four schema locations (`DownloadSpec.bufferBytes`,
|
||||
`TaskDetail.bufferBytes`, `download.update`'s patch, `Settings.connection.bufferBytes`):
|
||||
|
||||
| | Old (frozen 1.0.0) | New (1.1.0) |
|
||||
|---|---|---|
|
||||
| minimum | 4096 (4 KiB) | **65536 (64 KiB)** |
|
||||
| maximum | 8388608 (8 MiB) | **16777216 (16 MiB)** |
|
||||
| default | *(unstated on the wire)* | **1048576 (1 MiB)** |
|
||||
|
||||
64 KiB because 4 KiB is smaller than one libcurl HTTP/2 write-callback delivery — a buffer
|
||||
that small does not coalesce anything, it just adds a layer between curl and `pwrite` that
|
||||
does nothing. 16 MiB because throughput from write size is flat past ~1–4 MiB on NVMe; the
|
||||
only thing 8–16 MiB buys past that is absorbing a disk stall without stalling the socket,
|
||||
and past 16 MiB there is stall-cover left to buy but not memory left to spend it on. 1 MiB
|
||||
default because it is the only default of the three considered (4 MiB, 2 MiB, 1 MiB) for
|
||||
which the RSS target below actually holds.
|
||||
|
||||
**Two new settings keys**, `connection.maxTotalBufferBytes` (default 134217728, 128 MiB)
|
||||
and `connection.maxActiveSegments` (default 32). Both are new, both minor. Without the
|
||||
first, CORE's clamp has no configuration surface. Without the second, "20 active downloads"
|
||||
has no wire meaning distinct from "160 live TLS connections", which is the actual RSS
|
||||
driver — `docs/01` §2 already assumes a cap ("~8 active segments" per transfer thread) that
|
||||
`docs/04` never stated as a number.
|
||||
|
||||
**`TaskDetail.effectiveBufferBytes`** (new, per B2a, already accepted in
|
||||
`contracts/proto-answers-m1.md`): what a live segment is actually using, after the daemon
|
||||
divides `maxTotalBufferBytes` across live segments (capped at `maxActiveSegments`) and
|
||||
clamps down to fit. `bufferBytes` stays the requested value on every type; effective and
|
||||
requested sit side by side wherever both apply, the same pattern `TaskSummary.segments` /
|
||||
`DownloadSpec.segments` already established in ADR 0010.
|
||||
|
||||
**The RSS target: 60 MB stands, and is now conditional in writing.** CORE offered a choice
|
||||
— keep 60 MB and rely on `maxActiveSegments = 32`, or raise it to 120 MB and use a looser
|
||||
cap. This ADR keeps 60 MB, because "lean daemon" is already a stated goal (`docs/01`) and
|
||||
CORE's own arithmetic gets to 45–50 MB at the chosen defaults — comfortable margin, not a
|
||||
number that only barely holds. `docs/04` §8 now states the RSS target is conditional on
|
||||
`maxActiveSegments = 32` and default buffers, so it stops being a claim nobody can check.
|
||||
|
||||
## Why this is a minor bump, not major
|
||||
|
||||
Every change either widens a range (4 KiB–8 MiB → 64 KiB–16 MiB, which is not a superset in
|
||||
both directions — see below), adds a field, or adds a settings key. Rule 4 in
|
||||
`contracts/README.md`: optional field or new method is minor; only rename/remove/retype is
|
||||
major.
|
||||
|
||||
**The one place this needs a caller's attention despite being "minor":** the new minimum
|
||||
(64 KiB) is *higher* than the old one (4 KiB), and the new maximum (16 MiB) is *lower* than
|
||||
old `docs/04`'s stated 64 MiB (though *higher* than the old frozen 8 MiB). A value that
|
||||
validated against the pre-1.1.0 schema — say, `bufferBytes: 2048` — no longer validates.
|
||||
Nothing shipped against 1.0.0 yet (this repository is the only consumer), so there is no
|
||||
live client to break, but the general rule for a future minor bump that narrows one bound
|
||||
while widening another is: **check it against every existing fixture before shipping**,
|
||||
which `tests/conformance/check_contract.py` now does mechanically — this bump passed
|
||||
because both prior fixture values (4 MiB) happened to fall inside the new range too.
|
||||
|
||||
## Consequences
|
||||
|
||||
* `TaskDetail` gains a field; `Settings` and `SettingKey` gain two keys; four
|
||||
`bufferBytes` constraint blocks change bounds and gain a stated default.
|
||||
* DAEMON's scheduler needs `maxActiveSegments` to decide what to admit — this is the
|
||||
concrete data DAEMON asked for in its own admission-control proposal (currently under
|
||||
discussion as a separate ADR against a different number; PROTO takes no position on that
|
||||
split here beyond supplying the wire field DAEMON needs to read).
|
||||
* `docs/04` §4 and §8 are updated in the same change, per `CLAUDE.md` rule 5 (docs move
|
||||
with the behaviour they describe) and per CORE's explicit request to land schema, docs
|
||||
and ADR together.
|
||||
* The `download.get` fixture now shows a real clamp — 16 MiB requested, 4 MiB effective —
|
||||
rather than a case where the cap happens not to bind, so the requested/effective split
|
||||
actually gets exercised by conformance.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
**120 MB RSS target, looser cap.** CORE offered this and it is defensible — 32 live TLS
|
||||
connections have an irreducible cost, and IDM budgets more. Rejected because the tighter
|
||||
number is achievable with margin per CORE's own arithmetic and "lean daemon" is already a
|
||||
stated design goal; raising a target because it is easier is the wrong direction to move
|
||||
without a measurement forcing it. If M7's `tools/bench` run shows 60 MB is not actually
|
||||
reachable in practice, that is new information and reopens this ADR — it is not a reason to
|
||||
soften the number pre-emptively.
|
||||
|
||||
**Keeping `docs/04`'s 64 MiB ceiling.** Rejected on CORE's own reachability argument:
|
||||
against a 128 MiB total cap, 64 MiB per segment is unreachable past 2 live segments, which
|
||||
under correct per-segment accounting is a quarter of one default 8-segment download. A
|
||||
ceiling nothing can reach is not a ceiling, it is dead text.
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
@@ -11,7 +11,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const PROTOCOL_VERSION = "1.1.0";
|
||||
|
||||
/**
|
||||
* Every error code the daemon may return. Adding one is a minor bump; changing the meaning
|
||||
@@ -214,6 +214,12 @@ export interface DownloadSpec {
|
||||
* connection.maxSegmentsPerDownload.
|
||||
*/
|
||||
segments?: number | null;
|
||||
/**
|
||||
* Requested write buffer per segment, in bytes. null means use connection.bufferBytes.
|
||||
* Default 1 MiB; range 64 KiB - 16 MiB. Silently reduced to fit
|
||||
* connection.maxTotalBufferBytes across all live segments; the effective value is reported
|
||||
* back as TaskDetail.effectiveBufferBytes.
|
||||
*/
|
||||
bufferBytes?: number | null;
|
||||
startMode?: StartMode;
|
||||
description?: string | null;
|
||||
@@ -440,7 +446,7 @@ export interface Segment {
|
||||
* must not invent a key that is not here. Kept in lockstep with Settings.schema.json by a
|
||||
* conformance check.
|
||||
*/
|
||||
export type SettingKey = "general.launchOnLogin" | "general.minimizeToTray" | "general.showDropTarget" | "general.confirmOnExit" | "general.language" | "general.checkForUpdates" | "capture.enabled" | "capture.monitoredExtensions" | "capture.monitoredMimeTypes" | "capture.minSizeBytes" | "capture.excludedHosts" | "capture.bypassModifier" | "capture.autoStartTypes" | "saveTo.defaultDir" | "saveTo.tempDir" | "saveTo.allowedRoots" | "saveTo.fileExistsPolicy" | "saveTo.createSubfolderPerSite" | "connection.preset" | "connection.maxSegmentsPerDownload" | "connection.bufferBytes" | "connection.maxConcurrentDownloads" | "connection.timeoutSec" | "connection.maxRetries" | "connection.retryBackoffSec" | "downloads.speedLimitBps" | "downloads.speedLimitEnabled" | "downloads.virusScanCommand" | "downloads.postDownloadCommand" | "downloads.duplicatePolicy" | "downloads.verifyChecksums" | "proxy.mode" | "proxy.host" | "proxy.port" | "proxy.username" | "proxy.bypassHosts" | "proxy.pacUrl" | "sounds.enabled" | "sounds.onComplete" | "sounds.onQueueComplete" | "sounds.onError";
|
||||
export type SettingKey = "general.launchOnLogin" | "general.minimizeToTray" | "general.showDropTarget" | "general.confirmOnExit" | "general.language" | "general.checkForUpdates" | "capture.enabled" | "capture.monitoredExtensions" | "capture.monitoredMimeTypes" | "capture.minSizeBytes" | "capture.excludedHosts" | "capture.bypassModifier" | "capture.autoStartTypes" | "saveTo.defaultDir" | "saveTo.tempDir" | "saveTo.allowedRoots" | "saveTo.fileExistsPolicy" | "saveTo.createSubfolderPerSite" | "connection.preset" | "connection.maxSegmentsPerDownload" | "connection.bufferBytes" | "connection.maxTotalBufferBytes" | "connection.maxActiveSegments" | "connection.maxConcurrentDownloads" | "connection.timeoutSec" | "connection.maxRetries" | "connection.retryBackoffSec" | "downloads.speedLimitBps" | "downloads.speedLimitEnabled" | "downloads.virusScanCommand" | "downloads.postDownloadCommand" | "downloads.duplicatePolicy" | "downloads.verifyChecksums" | "proxy.mode" | "proxy.host" | "proxy.port" | "proxy.username" | "proxy.bypassHosts" | "proxy.pacUrl" | "sounds.enabled" | "sounds.onComplete" | "sounds.onQueueComplete" | "sounds.onError";
|
||||
export const SETTING_KEY_VALUES = [
|
||||
"general.launchOnLogin",
|
||||
"general.minimizeToTray",
|
||||
@@ -463,6 +469,8 @@ export const SETTING_KEY_VALUES = [
|
||||
"connection.preset",
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"connection.bufferBytes",
|
||||
"connection.maxTotalBufferBytes",
|
||||
"connection.maxActiveSegments",
|
||||
"connection.maxConcurrentDownloads",
|
||||
"connection.timeoutSec",
|
||||
"connection.maxRetries",
|
||||
@@ -553,6 +561,11 @@ export interface Settings {
|
||||
"saveTo.createSubfolderPerSite"?: boolean;
|
||||
"connection.preset"?: SettingsConnectionPreset;
|
||||
"connection.maxSegmentsPerDownload"?: number;
|
||||
/**
|
||||
* Default per-segment write buffer, in bytes, when a task does not request its own.
|
||||
* Default 1 MiB (1048576); range 64 KiB - 16 MiB. This is the single biggest throughput
|
||||
* knob and is exposed in Options -> Downloads -> 'Write buffer per connection'.
|
||||
*/
|
||||
"connection.bufferBytes"?: number;
|
||||
"connection.maxConcurrentDownloads"?: number;
|
||||
"connection.timeoutSec"?: number;
|
||||
@@ -574,6 +587,21 @@ export interface Settings {
|
||||
"sounds.onComplete"?: string;
|
||||
"sounds.onQueueComplete"?: string;
|
||||
"sounds.onError"?: string;
|
||||
/**
|
||||
* Global cap on write-buffer memory across every live segment, in bytes. Default 128 MiB
|
||||
* (134217728). Every live segment's buffer is reduced to fit maxTotalBufferBytes / (live
|
||||
* segment count, capped at maxActiveSegments); the reduced value is reported per task as
|
||||
* TaskDetail.effectiveBufferBytes. Exists so a burst of large downloads with a large
|
||||
* per-segment buffer cannot exhaust memory.
|
||||
*/
|
||||
"connection.maxTotalBufferBytes"?: number;
|
||||
/**
|
||||
* Global ceiling on segments actually transferring at once, across every task. Default 32.
|
||||
* This is the real bound behind '20 active downloads': the rest of each download's
|
||||
* segments queue rather than all dialling out simultaneously. DAEMON's scheduler needs
|
||||
* this value to decide what to admit; CORE enforces it.
|
||||
*/
|
||||
"connection.maxActiveSegments"?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -708,7 +736,19 @@ export interface TaskDetail {
|
||||
referrer?: string | null;
|
||||
userAgent?: string | null;
|
||||
mime?: string | null;
|
||||
/**
|
||||
* The REQUESTED write buffer per segment. See effectiveBufferBytes for what is actually in
|
||||
* use.
|
||||
*/
|
||||
bufferBytes?: number | null;
|
||||
/**
|
||||
* The write buffer actually in use per live segment, right now. May be well below
|
||||
* bufferBytes: the daemon reduces every live segment's buffer to fit
|
||||
* connection.maxTotalBufferBytes across connection.maxActiveSegments
|
||||
* concurrently-transferring segments, and reports the reduced value here so the GUI can
|
||||
* show '16 MiB (using 4 MiB)'. null before the task has started its first segment.
|
||||
*/
|
||||
effectiveBufferBytes?: number | null;
|
||||
/** Absolute path of the .veloxpart file while the task is unfinished. */
|
||||
partPath?: string | null;
|
||||
checksum?: Checksum | null;
|
||||
@@ -995,6 +1035,11 @@ export interface DownloadUpdateParamsPatch {
|
||||
* re-segmented underneath the user.
|
||||
*/
|
||||
segments?: number | null;
|
||||
/**
|
||||
* The REQUESTED write buffer per segment. Subject to the same maxTotalBufferBytes
|
||||
* reduction as DownloadSpec.bufferBytes; the effective value comes back on the next
|
||||
* download.get.
|
||||
*/
|
||||
bufferBytes?: number | null;
|
||||
checksum?: Checksum | null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
@@ -490,7 +490,7 @@ export function validateDownloadSpec(v: unknown, path = ''): Validated<DownloadS
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out);
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 65536, maximum: 16777216 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "startMode", path, validateStartMode, out);
|
||||
if (!r.ok) return r;
|
||||
@@ -754,7 +754,7 @@ export function validateSettings(v: unknown, path = ''): Validated<Settings> {
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "connection.maxSegmentsPerDownload", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "connection.bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out);
|
||||
r = opt(v, "connection.bufferBytes", path, vLimited(vInteger, { minimum: 65536, maximum: 16777216 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "connection.maxConcurrentDownloads", path, vLimited(vInteger, { minimum: 1, maximum: 64 }), out);
|
||||
if (!r.ok) return r;
|
||||
@@ -796,6 +796,10 @@ export function validateSettings(v: unknown, path = ''): Validated<Settings> {
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "sounds.onError", path, vString, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "connection.maxTotalBufferBytes", path, vLimited(vInteger, { minimum: 16777216, maximum: 2147483648 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "connection.maxActiveSegments", path, vLimited(vInteger, { minimum: 1, maximum: 256 }), out);
|
||||
if (!r.ok) return r;
|
||||
return { ok: true, value: out as unknown as Settings };
|
||||
}
|
||||
|
||||
@@ -824,7 +828,9 @@ export function validateTaskDetail(v: unknown, path = ''): Validated<TaskDetail>
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "mime", path, vString, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out);
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 65536, maximum: 16777216 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "effectiveBufferBytes", path, vLimited(vInteger, { minimum: 65536, maximum: 16777216 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "partPath", path, vString, out);
|
||||
if (!r.ok) return r;
|
||||
@@ -1336,7 +1342,7 @@ export function validateDownloadUpdateParamsPatch(v: unknown, path = ''): Valida
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out);
|
||||
r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 65536, maximum: 16777216 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "checksum", path, validateChecksum, out);
|
||||
if (!r.ok) return r;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_cpp.py
|
||||
// Contract: v1.0.0
|
||||
// Contract: v1.1.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_cpp.py
|
||||
|
||||
Reference in New Issue
Block a user