diff --git a/contracts/README.md b/contracts/README.md index 0d6e3c6..ea1261a 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -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. diff --git a/contracts/VERSION b/contracts/VERSION index 3eefcb9..9084fa2 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/contracts/fixtures/download.get.json b/contracts/fixtures/download.get.json index f381414..2c47d05 100644 --- a/contracts/fixtures/download.get.json +++ b/contracts/fixtures/download.get.json @@ -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." ] } diff --git a/contracts/fixtures/settings.get.json b/contracts/fixtures/settings.get.json index 5cd3dac..7ad5075 100644 --- a/contracts/fixtures/settings.get.json +++ b/contracts/fixtures/settings.get.json @@ -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" ] } diff --git a/contracts/fixtures/settings.set.json b/contracts/fixtures/settings.set.json index eade7b0..22ab914 100644 --- a/contracts/fixtures/settings.set.json +++ b/contracts/fixtures/settings.set.json @@ -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" ] } diff --git a/contracts/openrpc.json b/contracts/openrpc.json index 296c742..8443421 100644 --- a/contracts/openrpc.json +++ b/contracts/openrpc.json @@ -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": [ diff --git a/contracts/proto-answers-m1.md b/contracts/proto-answers-m1.md index 92de964..4217a55 100644 --- a/contracts/proto-answers-m1.md +++ b/contracts/proto-answers-m1.md @@ -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: ""}`, with `value` patterned `^[0-9a-fA-F]{32,128}$`. Parse your `":"` 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. | diff --git a/contracts/schema/methods/download.update.schema.json b/contracts/schema/methods/download.update.schema.json index 8c6f18b..bf1653c 100644 --- a/contracts/schema/methods/download.update.schema.json +++ b/contracts/schema/methods/download.update.schema.json @@ -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": [ diff --git a/contracts/schema/types/DownloadSpec.schema.json b/contracts/schema/types/DownloadSpec.schema.json index a4bf9b6..cfe4271 100644 --- a/contracts/schema/types/DownloadSpec.schema.json +++ b/contracts/schema/types/DownloadSpec.schema.json @@ -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" diff --git a/contracts/schema/types/SettingKey.schema.json b/contracts/schema/types/SettingKey.schema.json index 1d7abf0..2bf556a 100644 --- a/contracts/schema/types/SettingKey.schema.json +++ b/contracts/schema/types/SettingKey.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" ] } diff --git a/contracts/schema/types/Settings.schema.json b/contracts/schema/types/Settings.schema.json index 355ebf3..6ca8033 100644 --- a/contracts/schema/types/Settings.schema.json +++ b/contracts/schema/types/Settings.schema.json @@ -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." } } } diff --git a/contracts/schema/types/TaskDetail.schema.json b/contracts/schema/types/TaskDetail.schema.json index c213f21..82a4a71 100644 --- a/contracts/schema/types/TaskDetail.schema.json +++ b/contracts/schema/types/TaskDetail.schema.json @@ -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": [ diff --git a/core/generated/velox_proto.cpp b/core/generated/velox_proto.cpp index b74500c..63dcdf0 100644 --- a/core/generated/velox_proto.cpp +++ b/core/generated/velox_proto.cpp @@ -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 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 parse(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(); - 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 parse(const nlohmann::json& j, std::string_view path) { @@ -2660,8 +2666,8 @@ template <> Result parse(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(); - 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 parse(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(); + 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(); + 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 parse(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(); - 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(); + 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 parse(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(); - 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); } } diff --git a/core/generated/velox_proto.hpp b/core/generated/velox_proto.hpp index 5f9d44a..169d878 100644 --- a/core/generated/velox_proto.hpp +++ b/core/generated/velox_proto.hpp @@ -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 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 bufferBytes{}; std::optional startMode{}; std::optional description{}; @@ -708,6 +713,9 @@ struct Settings { std::optional saveTo_createSubfolderPerSite{}; std::optional connection_preset{}; std::optional 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 connection_bufferBytes{}; std::optional connection_maxConcurrentDownloads{}; std::optional connection_timeoutSec{}; @@ -729,6 +737,17 @@ struct Settings { std::optional sounds_onComplete{}; std::optional sounds_onQueueComplete{}; std::optional 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 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 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 referrer{}; std::optional userAgent{}; std::optional mime{}; + /// The REQUESTED write buffer per segment. See effectiveBufferBytes for what is actually in + /// use. std::optional 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 effectiveBufferBytes{}; /// Absolute path of the .veloxpart file while the task is unfinished. std::optional partPath{}; std::optional 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 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 bufferBytes{}; std::optional checksum{}; }; diff --git a/docs/04-engine-design.md b/docs/04-engine-design.md index 629531f..7626cfe 100644 --- a/docs/04-engine-design.md +++ b/docs/04-engine-design.md @@ -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). diff --git a/docs/adr/0012-buffer-and-segment-budget.md b/docs/adr/0012-buffer-and-segment-budget.md new file mode 100644 index 0000000..c2ddd8d --- /dev/null +++ b/docs/adr/0012-buffer-and-segment-budget.md @@ -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. diff --git a/extension/src/shared/protocol/events.ts b/extension/src/shared/protocol/events.ts index 832a88d..a1c5717 100644 --- a/extension/src/shared/protocol/events.ts +++ b/extension/src/shared/protocol/events.ts @@ -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 diff --git a/extension/src/shared/protocol/index.ts b/extension/src/shared/protocol/index.ts index ee4957a..a93229b 100644 --- a/extension/src/shared/protocol/index.ts +++ b/extension/src/shared/protocol/index.ts @@ -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 diff --git a/extension/src/shared/protocol/methods.ts b/extension/src/shared/protocol/methods.ts index 0ac60fd..8fd353e 100644 --- a/extension/src/shared/protocol/methods.ts +++ b/extension/src/shared/protocol/methods.ts @@ -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 diff --git a/extension/src/shared/protocol/types.ts b/extension/src/shared/protocol/types.ts index 1530f9f..0b6c6af 100644 --- a/extension/src/shared/protocol/types.ts +++ b/extension/src/shared/protocol/types.ts @@ -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; } diff --git a/extension/src/shared/protocol/validate.ts b/extension/src/shared/protocol/validate.ts index bb4f6a0..0aef2af 100644 --- a/extension/src/shared/protocol/validate.ts +++ b/extension/src/shared/protocol/validate.ts @@ -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 { 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 { 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 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; diff --git a/tests/conformance/cpp/fixture_dispatcher.hpp b/tests/conformance/cpp/fixture_dispatcher.hpp index c017d3a..c900c28 100644 --- a/tests/conformance/cpp/fixture_dispatcher.hpp +++ b/tests/conformance/cpp/fixture_dispatcher.hpp @@ -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