merge: lane/proto
This commit is contained in:
+13
-9
@@ -3,13 +3,16 @@
|
|||||||
**This directory is the interface between every lane.** Owner: agent **PROTO**.
|
**This directory is the interface between every lane.** Owner: agent **PROTO**.
|
||||||
Nobody else commits here. Everybody else *generates from* here.
|
Nobody else commits here. Everybody else *generates from* here.
|
||||||
|
|
||||||
> ## Status: **v1.0.0 — FROZEN** (2026-09-09)
|
> ## Status: **v1.3.0** (frozen at v1.0.0 on 2026-09-09; minor bumps since)
|
||||||
>
|
>
|
||||||
> **v1.0.0** froze 38 methods, 9 events, 26 named types. **v1.1.0** (current) is a minor
|
> **v1.0.0** froze 38 methods, 9 events, 26 named types. **v1.1.0** widened `bufferBytes`
|
||||||
> bump on top of it: `bufferBytes` bounds widened to 64 KiB - 16 MiB across all four
|
> bounds and added the segment-budget settings (`docs/adr/0012-...`). **v1.2.0** added
|
||||||
> locations, two new settings keys (`connection.maxTotalBufferBytes`,
|
> `download.provideAuth`, F2's credential return path. **v1.3.0** (current) widens when
|
||||||
> `connection.maxActiveSegments`), and `TaskDetail.effectiveBufferBytes` — see
|
> `event.task.state.error` / `TaskSummary.error` are populated to also cover a `paused`
|
||||||
> `docs/adr/0012-buffer-and-segment-budget.md`. See also `docs/adr/0005-...` for the
|
> the daemon entered unilaterally (`auth_required`, `server_file_changed`, disk full),
|
||||||
|
> not just `failed`/`retry_wait` — the wire shape is unchanged (`error` was already
|
||||||
|
> `TaskError | null`), only the description of when it's set. Landed for DAEMON's
|
||||||
|
> `docs/adr/0013-task-state-machine-ownership.md`. See also `docs/adr/0005-...` for the
|
||||||
> versioning rule and `docs/adr/0010-...` for the failure taxonomy and segment ranges.
|
> 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
|
> Lane requests are answered in writing: `contracts/proto-answers-m1.md` responds to
|
||||||
@@ -30,7 +33,7 @@ Nobody else commits here. Everybody else *generates from* here.
|
|||||||
|
|
||||||
```
|
```
|
||||||
contracts/
|
contracts/
|
||||||
├── VERSION # protocol semver — frozen at 1.0.0
|
├── VERSION # protocol semver — v1.2.0, minor-bumped from the v1.0.0 freeze
|
||||||
├── openrpc.json # human-readable API doc (generated from schema/)
|
├── openrpc.json # human-readable API doc (generated from schema/)
|
||||||
├── schema/
|
├── schema/
|
||||||
│ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes
|
│ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes
|
||||||
@@ -76,7 +79,7 @@ on rather than as prose a reader has to honour:
|
|||||||
| `x-errors` | the error codes this method is documented to return |
|
| `x-errors` | the error codes this method is documented to return |
|
||||||
| `x-wsRestrictions` | extra limits when the call arrives from the extension |
|
| `x-wsRestrictions` | extra limits when the call arrives from the extension |
|
||||||
|
|
||||||
19 of the 38 methods are privileged: everything that reconfigures the daemon, destroys user
|
20 of the 39 methods are privileged: everything that reconfigures the daemon, destroys user
|
||||||
data, or names an arbitrary destination path. The extension may *request* a download; it
|
data, or names an arbitrary destination path. The extension may *request* a download; it
|
||||||
may not choose where the bytes land.
|
may not choose where the bytes land.
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ All four carry **the same JSON-RPC 2.0 payloads**. The framing differences stop
|
|||||||
transport layer; no method behaves differently depending on how it arrived — except that
|
transport layer; no method behaves differently depending on how it arrived — except that
|
||||||
methods marked `"privileged": true` in the schema are refused over the WebSocket transport.
|
methods marked `"privileged": true` in the schema are refused over the WebSocket transport.
|
||||||
|
|
||||||
## Method surface (v1.0.0 target — expand only via PR)
|
## Method surface (v1.2.0 — expand only via PR)
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
| Method | Params → Result |
|
| Method | Params → Result |
|
||||||
@@ -114,6 +117,7 @@ methods marked `"privileged": true` in the schema are refused over the WebSocket
|
|||||||
| `download.remove` | `{taskIds[], deleteFile:bool}` → `{removed[]}` |
|
| `download.remove` | `{taskIds[], deleteFile:bool}` → `{removed[]}` |
|
||||||
| `download.update` | `{taskId, patch:{filename?, saveDir?, categoryId?, queueId?, description?, segments?, bufferBytes?}}` → `TaskSummary` |
|
| `download.update` | `{taskId, patch:{filename?, saveDir?, categoryId?, queueId?, description?, segments?, bufferBytes?}}` → `TaskSummary` |
|
||||||
| `download.refreshUrl` | `{taskId, url, headers?}` → `{ok}` *(IDM's "Refresh Download Address")* |
|
| `download.refreshUrl` | `{taskId, url, headers?}` → `{ok}` *(IDM's "Refresh Download Address")* |
|
||||||
|
| `download.provideAuth` | `{taskId, username, password, save?}` → `{ok}` — answers `event.auth.required`. UDS only; privileged. Credentials go to the Secret Service, never SQLite, never logs |
|
||||||
|
|
||||||
### Organisation
|
### Organisation
|
||||||
`category.list` · `category.upsert` · `category.remove` · `queue.list` · `queue.upsert` ·
|
`category.list` · `category.upsert` · `category.remove` · `queue.list` · `queue.upsert` ·
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.0
|
1.3.0
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "download.provideAuth \u2014 answer a 401 challenge and remember it",
|
||||||
|
"description": "The task was sitting in retry_wait after event.auth.required. This does not restart the transfer itself: the daemon retries with the credential attached and the task's state.transition to connecting/downloading happens on its own, reported the normal way through event.task.state.",
|
||||||
|
"transport": "uds",
|
||||||
|
"request": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 80,
|
||||||
|
"method": "download.provideAuth",
|
||||||
|
"params": {
|
||||||
|
"taskId": "$taskId",
|
||||||
|
"username": "svc-releases",
|
||||||
|
"password": "hunter2-not-a-real-password",
|
||||||
|
"save": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 80,
|
||||||
|
"result": {
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assertions": [
|
||||||
|
"the password never appears in a log line, ever, on either side of this call",
|
||||||
|
"save true stores the credential in the Secret Service keyed by host and realm, not in SQLite",
|
||||||
|
"the task itself is not touched synchronously by this call \u2014 it moves out of retry_wait when the daemon's own retry succeeds, reported via event.task.state",
|
||||||
|
"this method is refused with -32003 over the WebSocket transport"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "download.provideAuth \u2014 the task no longer exists",
|
||||||
|
"description": "The ordinary stale-client case: the user typed credentials into a dialog for a task that was removed in the meantime.",
|
||||||
|
"transport": "uds",
|
||||||
|
"request": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 81,
|
||||||
|
"method": "download.provideAuth",
|
||||||
|
"params": {
|
||||||
|
"taskId": "00000000-0000-4000-8000-000000000000",
|
||||||
|
"username": "x",
|
||||||
|
"password": "y"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 81,
|
||||||
|
"error": {
|
||||||
|
"code": -32010,
|
||||||
|
"message": "no such task",
|
||||||
|
"data": {
|
||||||
|
"taskId": "00000000-0000-4000-8000-000000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assertions": [
|
||||||
|
"credentials submitted for a task that no longer exists are discarded, never persisted anywhere"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "download.provideAuth \u2014 refused over the WebSocket transport",
|
||||||
|
"description": "The other half of event.auth.required's own promise: a credential-bearing method must never be reachable from the browser.",
|
||||||
|
"transport": "ws",
|
||||||
|
"request": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 82,
|
||||||
|
"method": "download.provideAuth",
|
||||||
|
"params": {
|
||||||
|
"taskId": "$taskId",
|
||||||
|
"username": "x",
|
||||||
|
"password": "y"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 82,
|
||||||
|
"error": {
|
||||||
|
"code": -32003,
|
||||||
|
"message": "method is not permitted on this transport"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assertions": [
|
||||||
|
"the extension has no path to this method under any circumstance"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "event.task.state \u2014 the daemon auto-pauses on a 401",
|
||||||
|
"description": "The CORE-auto-pause case ADR 0013 (docs/adr/) needs a wire signal for: the daemon paused this task on its own initiative -- not because the user clicked pause, a schedule window closed, or admission control reconciled a lowered cap -- and error explains why. Compare download.pause.json, where the same target state (paused) carries error: null because that pause was requested.",
|
||||||
|
"notification": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "event.task.state",
|
||||||
|
"params": {
|
||||||
|
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
|
||||||
|
"state": "paused",
|
||||||
|
"previousState": "connecting",
|
||||||
|
"summary": {
|
||||||
|
"taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
|
||||||
|
"filename": "film.mkv",
|
||||||
|
"saveDir": "/home/sami/Downloads/Video",
|
||||||
|
"url": "https://example.org/film.mkv",
|
||||||
|
"effectiveUrl": "https://example.org/film.mkv",
|
||||||
|
"sizeBytes": 1503238553,
|
||||||
|
"downloadedBytes": 0,
|
||||||
|
"state": "paused",
|
||||||
|
"speedBps": 0,
|
||||||
|
"etaSeconds": null,
|
||||||
|
"resumable": true,
|
||||||
|
"segments": 4,
|
||||||
|
"categoryId": "video",
|
||||||
|
"queueId": null,
|
||||||
|
"queuePosition": null,
|
||||||
|
"description": null,
|
||||||
|
"createdAt": "$isoDate",
|
||||||
|
"lastTryAt": "$isoDate",
|
||||||
|
"completedAt": null,
|
||||||
|
"error": {
|
||||||
|
"code": "auth_required",
|
||||||
|
"message": "the server asked for credentials (401)",
|
||||||
|
"httpStatus": 401,
|
||||||
|
"retryable": false,
|
||||||
|
"cause": null,
|
||||||
|
"attempt": 1,
|
||||||
|
"nextRetryAt": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"code": "auth_required",
|
||||||
|
"message": "the server asked for credentials (401)",
|
||||||
|
"httpStatus": 401,
|
||||||
|
"retryable": false,
|
||||||
|
"cause": null,
|
||||||
|
"attempt": 1,
|
||||||
|
"nextRetryAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assertions": [
|
||||||
|
"error is set here specifically because the daemon paused this task itself, not the user -- the trigger is event.auth.required on the same task shortly before",
|
||||||
|
"the scheduler must not resume this task on a schedule window or queue restart: only download.provideAuth (or the user explicitly resuming) may clear it -- resuming blindly re-fails immediately and looks like a flapping bug",
|
||||||
|
"a client distinguishes an auto-pause from a deliberate one by this field being non-null, not by inspecting previousState or any other heuristic"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assertions": [
|
"assertions": [
|
||||||
"error is present exactly when state is failed or retry_wait",
|
"error is present on every failed or retry_wait transition, and also on a paused transition the daemon entered unilaterally -- never on a paused transition the user or scheduler requested",
|
||||||
"error.code is a TaskErrorCode, never a JSON-RPC ErrorCode \u2014 the two are different spaces",
|
"error.code is a TaskErrorCode, never a JSON-RPC ErrorCode \u2014 the two are different spaces",
|
||||||
"retryable false means the scheduler will not pick this up again on its own"
|
"retryable false means the scheduler will not pick this up again on its own"
|
||||||
]
|
]
|
||||||
|
|||||||
+84
-9
@@ -2,7 +2,7 @@
|
|||||||
"openrpc": "1.2.6",
|
"openrpc": "1.2.6",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Velox Download Manager",
|
"title": "Velox Download Manager",
|
||||||
"version": "1.1.0",
|
"version": "1.3.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.",
|
"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": {
|
"license": {
|
||||||
"name": "See repository LICENSE"
|
"name": "See repository LICENSE"
|
||||||
@@ -487,9 +487,9 @@
|
|||||||
],
|
],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 32,
|
"maximum": 32,
|
||||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the 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."
|
"description": "The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to the 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."
|
||||||
},
|
},
|
||||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the 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."
|
"description": "The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to the 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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bufferBytes",
|
"name": "bufferBytes",
|
||||||
@@ -1031,6 +1031,79 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "download.provideAuth",
|
||||||
|
"summary": "Answer an event.",
|
||||||
|
"description": "Answer an event.auth.required challenge. The task sits in retry_wait until this arrives; on success the daemon retries with the credentials attached and the task resumes on its own \u2014 this method does not itself start the transfer. Privileged and Unix-socket-only: a credential-bearing method must never be reachable from the browser, which is exactly the boundary event.auth.required's own description draws ('never back through this event, never into a log') \u2014 this is the other half of that promise. Credentials are handed to the Secret Service, never to SQLite and never logged; save only tells the daemon whether to persist them there for next time, or use them for this attempt alone.",
|
||||||
|
"paramStructure": "by-name",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": "taskId",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1024
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "save",
|
||||||
|
"schema": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "true persists the credential in the Secret Service, keyed by host and realm, for future downloads from the same site. false or null uses it for this task's retry only. Never affects SQLite or the daemon's logs either way."
|
||||||
|
},
|
||||||
|
"description": "true persists the credential in the Secret Service, keyed by host and realm, for future downloads from the same site. false or null uses it for this task's retry only. Never affects SQLite or the daemon's logs either way."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"name": "download.provideAuthResult",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"ok"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-privileged": true,
|
||||||
|
"x-transports": [
|
||||||
|
"uds"
|
||||||
|
],
|
||||||
|
"x-deadlineMs": 5000,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": -32003,
|
||||||
|
"message": "Method is privileged and was called over a transport that may not use it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": -32010,
|
||||||
|
"message": "No task with that id."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "download.refreshUrl",
|
"name": "download.refreshUrl",
|
||||||
"summary": "IDM's 'Refresh Download Address'.",
|
"summary": "IDM's 'Refresh Download Address'.",
|
||||||
@@ -3134,7 +3207,7 @@
|
|||||||
],
|
],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 32,
|
"maximum": 32,
|
||||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the 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."
|
"description": "The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to the 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."
|
||||||
},
|
},
|
||||||
"bufferBytes": {
|
"bufferBytes": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -4070,7 +4143,7 @@
|
|||||||
],
|
],
|
||||||
"minimum": 65536,
|
"minimum": 65536,
|
||||||
"maximum": 16777216,
|
"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."
|
"description": "The write buffer actually in use per live segment, right now. May be well below bufferBytes: the engine 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": {
|
"partPath": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -4111,7 +4184,7 @@
|
|||||||
"title": "TaskDetail"
|
"title": "TaskDetail"
|
||||||
},
|
},
|
||||||
"TaskError": {
|
"TaskError": {
|
||||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download \u2014 the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.",
|
"description": "Why a task is in the failed, retry_wait, or (when the daemon paused it on its own initiative rather than the user) paused state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download \u2014 the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer. A pause the user or the scheduler requested carries no error: this field only explains a paused state the daemon entered unilaterally (auth_required, server_file_changed, disk_full and the like), never a deliberate one.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4496,7 +4569,8 @@
|
|||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"description": "Set when state is failed or retry_wait, and also when state is paused and the daemon entered that state on its own initiative rather than at a user's or scheduler's request. null on every other state, including a deliberate pause."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "TaskSummary"
|
"title": "TaskSummary"
|
||||||
@@ -4811,7 +4885,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "event.task.state",
|
"name": "event.task.state",
|
||||||
"description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the new state is failed or retry_wait.",
|
"description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the daemon has something to say about why: on every failed or retry_wait transition, and on a paused transition the daemon entered unilaterally rather than at a user's or scheduler's request.",
|
||||||
"params": {
|
"params": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -4855,7 +4929,8 @@
|
|||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"description": "Set when the new state is failed or retry_wait, and also when it is paused and the daemon entered that state on its own initiative \u2014 auth_required, server_file_changed, disk_full and the like \u2014 rather than because of a user action, a schedule window closing, or an admission-control decision. null on every other transition, including every deliberately-requested pause. A client must not assume a paused task has no error just because it usually doesn't; check this field rather than the state name alone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Agreed with your ranking: these are minor under rule 4 and land as small PRs to
|
|||||||
| # | Item | Verdict | Shape |
|
| # | Item | Verdict | Shape |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **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. |
|
| **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. |
|
| **F2** | credential return path for 401/407 | **landed in 1.2.0** | `download.provideAuth {taskId, username, password, save?}` → `{ok}`, exactly as proposed: Unix socket only, privileged. It answers the challenge; it does not itself resume the task — the daemon retries with the credential attached and the usual `event.task.state` reports the task leaving `retry_wait`. |
|
||||||
| **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. |
|
| **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. |
|
| **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. |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://velox.dev/schema/events/event.task.state.schema.json",
|
"$id": "https://velox.dev/schema/events/event.task.state.schema.json",
|
||||||
"title": "event.task.state",
|
"title": "event.task.state",
|
||||||
"description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the new state is failed or retry_wait.",
|
"description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the daemon has something to say about why: on every failed or retry_wait transition, and on a paused transition the daemon entered unilaterally rather than at a user's or scheduler's request.",
|
||||||
"x-direction": "server-to-client",
|
"x-direction": "server-to-client",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -49,7 +49,8 @@
|
|||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"description": "Set when the new state is failed or retry_wait, and also when it is paused and the daemon entered that state on its own initiative \u2014 auth_required, server_file_changed, disk_full and the like \u2014 rather than because of a user action, a schedule window closing, or an admission-control decision. null on every other transition, including every deliberately-requested pause. A client must not assume a paused task has no error just because it usually doesn't; check this field rather than the state name alone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://velox.dev/schema/methods/download.provideAuth.schema.json",
|
||||||
|
"title": "download.provideAuth",
|
||||||
|
"description": "Answer an event.auth.required challenge. The task sits in retry_wait until this arrives; on success the daemon retries with the credentials attached and the task resumes on its own — this method does not itself start the transfer. Privileged and Unix-socket-only: a credential-bearing method must never be reachable from the browser, which is exactly the boundary event.auth.required's own description draws ('never back through this event, never into a log') — this is the other half of that promise. Credentials are handed to the Secret Service, never to SQLite and never logged; save only tells the daemon whether to persist them there for next time, or use them for this attempt alone.",
|
||||||
|
"x-privileged": true,
|
||||||
|
"x-transports": ["uds"],
|
||||||
|
"x-deadlineMs": 5000,
|
||||||
|
"x-errors": [-32003, -32010],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"params": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["taskId", "username", "password"],
|
||||||
|
"properties": {
|
||||||
|
"taskId": { "type": "string", "format": "uuid" },
|
||||||
|
"username": { "type": "string", "maxLength": 256 },
|
||||||
|
"password": { "type": "string", "maxLength": 1024 },
|
||||||
|
"save": {
|
||||||
|
"type": ["boolean", "null"],
|
||||||
|
"description": "true persists the credential in the Secret Service, keyed by host and realm, for future downloads from the same site. false or null uses it for this task's retry only. Never affects SQLite or the daemon's logs either way."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["ok"],
|
||||||
|
"properties": {
|
||||||
|
"ok": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
],
|
],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 32,
|
"maximum": 32,
|
||||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the 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."
|
"description": "The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to the 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."
|
||||||
},
|
},
|
||||||
"bufferBytes": {
|
"bufferBytes": {
|
||||||
"type": [
|
"type": [
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
],
|
],
|
||||||
"minimum": 65536,
|
"minimum": 65536,
|
||||||
"maximum": 16777216,
|
"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."
|
"description": "The write buffer actually in use per live segment, right now. May be well below bufferBytes: the engine 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": {
|
"partPath": {
|
||||||
"type": [
|
"type": [
|
||||||
|
|||||||
@@ -2,17 +2,60 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://velox.dev/schema/types/TaskError.schema.json",
|
"$id": "https://velox.dev/schema/types/TaskError.schema.json",
|
||||||
"title": "TaskError",
|
"title": "TaskError",
|
||||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download — the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.",
|
"description": "Why a task is in the failed, retry_wait, or (when the daemon paused it on its own initiative rather than the user) paused state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download \u2014 the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer. A pause the user or the scheduler requested carries no error: this field only explains a paused state the daemon entered unilaterally (auth_required, server_file_changed, disk_full and the like), never a deliberate one.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["code", "message", "retryable"],
|
"required": [
|
||||||
|
"code",
|
||||||
|
"message",
|
||||||
|
"retryable"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": { "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" },
|
"code": {
|
||||||
"message": { "type": "string", "description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots." },
|
"$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json"
|
||||||
"httpStatus": { "type": ["integer", "null"], "minimum": 100, "maximum": 599, "description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise." },
|
},
|
||||||
"retryable": { "type": "boolean", "description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit." },
|
"message": {
|
||||||
"cause": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" }, { "type": "null" }], "description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up." },
|
"type": "string",
|
||||||
"attempt": { "type": ["integer", "null"], "minimum": 0, "description": "How many attempts have been made so far." },
|
"description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots."
|
||||||
"nextRetryAt":{ "type": ["string", "null"], "format": "date-time" }
|
},
|
||||||
|
"httpStatus": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 599,
|
||||||
|
"description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise."
|
||||||
|
},
|
||||||
|
"retryable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit."
|
||||||
|
},
|
||||||
|
"cause": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up."
|
||||||
|
},
|
||||||
|
"attempt": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "How many attempts have been made so far."
|
||||||
|
},
|
||||||
|
"nextRetryAt": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,8 @@
|
|||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"description": "Set when state is failed or retry_wait, and also when state is paused and the daemon entered that state on its own initiative rather than at a user's or scheduler's request. null on every other state, including a deliberate pause."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_cpp.py
|
// Generator: contracts/codegen/gen_cpp.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_cpp.py
|
// python3 contracts/codegen/gen_cpp.py
|
||||||
@@ -4369,6 +4369,78 @@ template <> Result<DownloadProbeResult> parse<DownloadProbeResult>(const nlohman
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void to_json(nlohmann::json& j, const DownloadProvideAuthParams& v) {
|
||||||
|
j = nlohmann::json::object();
|
||||||
|
j["taskId"] = v.taskId;
|
||||||
|
j["username"] = v.username;
|
||||||
|
j["password"] = v.password;
|
||||||
|
if (v.save.has_value()) j["save"] = *v.save;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <> Result<DownloadProvideAuthParams> parse<DownloadProvideAuthParams>(const nlohmann::json& j, std::string_view path) {
|
||||||
|
if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});
|
||||||
|
DownloadProvideAuthParams out;
|
||||||
|
{
|
||||||
|
const std::string fp = join(path, "taskId");
|
||||||
|
const auto it = j.find("taskId");
|
||||||
|
if (it == j.end() || it->is_null())
|
||||||
|
return std::unexpected(ParseError{fp, "required field is missing"});
|
||||||
|
if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"});
|
||||||
|
auto val = (*it).get<std::string>();
|
||||||
|
out.taskId = std::move(val);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const std::string fp = join(path, "username");
|
||||||
|
const auto it = j.find("username");
|
||||||
|
if (it == j.end() || it->is_null())
|
||||||
|
return std::unexpected(ParseError{fp, "required field is missing"});
|
||||||
|
if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"});
|
||||||
|
auto val = (*it).get<std::string>();
|
||||||
|
if (val.size() > 256u) return std::unexpected(ParseError{std::string(fp), "value is longer than 256 characters"});
|
||||||
|
out.username = std::move(val);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const std::string fp = join(path, "password");
|
||||||
|
const auto it = j.find("password");
|
||||||
|
if (it == j.end() || it->is_null())
|
||||||
|
return std::unexpected(ParseError{fp, "required field is missing"});
|
||||||
|
if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"});
|
||||||
|
auto val = (*it).get<std::string>();
|
||||||
|
if (val.size() > 1024u) return std::unexpected(ParseError{std::string(fp), "value is longer than 1024 characters"});
|
||||||
|
out.password = std::move(val);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const std::string fp = join(path, "save");
|
||||||
|
const auto it = j.find("save");
|
||||||
|
if (it != j.end() && !it->is_null()) {
|
||||||
|
if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"});
|
||||||
|
auto val = (*it).get<bool>();
|
||||||
|
out.save = std::move(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void to_json(nlohmann::json& j, const DownloadProvideAuthResult& v) {
|
||||||
|
j = nlohmann::json::object();
|
||||||
|
j["ok"] = v.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <> Result<DownloadProvideAuthResult> parse<DownloadProvideAuthResult>(const nlohmann::json& j, std::string_view path) {
|
||||||
|
if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});
|
||||||
|
DownloadProvideAuthResult out;
|
||||||
|
{
|
||||||
|
const std::string fp = join(path, "ok");
|
||||||
|
const auto it = j.find("ok");
|
||||||
|
if (it == j.end() || it->is_null())
|
||||||
|
return std::unexpected(ParseError{fp, "required field is missing"});
|
||||||
|
if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"});
|
||||||
|
auto val = (*it).get<bool>();
|
||||||
|
out.ok = std::move(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v) {
|
void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v) {
|
||||||
j = nlohmann::json::object();
|
j = nlohmann::json::object();
|
||||||
j["taskId"] = v.taskId;
|
j["taskId"] = v.taskId;
|
||||||
@@ -6895,6 +6967,7 @@ std::string_view to_string(Method m) noexcept {
|
|||||||
case Method::DownloadList: return "download.list";
|
case Method::DownloadList: return "download.list";
|
||||||
case Method::DownloadPause: return "download.pause";
|
case Method::DownloadPause: return "download.pause";
|
||||||
case Method::DownloadProbe: return "download.probe";
|
case Method::DownloadProbe: return "download.probe";
|
||||||
|
case Method::DownloadProvideAuth: return "download.provideAuth";
|
||||||
case Method::DownloadRefreshUrl: return "download.refreshUrl";
|
case Method::DownloadRefreshUrl: return "download.refreshUrl";
|
||||||
case Method::DownloadRemove: return "download.remove";
|
case Method::DownloadRemove: return "download.remove";
|
||||||
case Method::DownloadResume: return "download.resume";
|
case Method::DownloadResume: return "download.resume";
|
||||||
@@ -6938,6 +7011,7 @@ std::optional<Method> method_from_string(std::string_view s) noexcept {
|
|||||||
if (s == "download.list") return Method::DownloadList;
|
if (s == "download.list") return Method::DownloadList;
|
||||||
if (s == "download.pause") return Method::DownloadPause;
|
if (s == "download.pause") return Method::DownloadPause;
|
||||||
if (s == "download.probe") return Method::DownloadProbe;
|
if (s == "download.probe") return Method::DownloadProbe;
|
||||||
|
if (s == "download.provideAuth") return Method::DownloadProvideAuth;
|
||||||
if (s == "download.refreshUrl") return Method::DownloadRefreshUrl;
|
if (s == "download.refreshUrl") return Method::DownloadRefreshUrl;
|
||||||
if (s == "download.remove") return Method::DownloadRemove;
|
if (s == "download.remove") return Method::DownloadRemove;
|
||||||
if (s == "download.resume") return Method::DownloadResume;
|
if (s == "download.resume") return Method::DownloadResume;
|
||||||
@@ -6981,6 +7055,7 @@ bool is_privileged(Method m) noexcept {
|
|||||||
case Method::DownloadList: return false;
|
case Method::DownloadList: return false;
|
||||||
case Method::DownloadPause: return false;
|
case Method::DownloadPause: return false;
|
||||||
case Method::DownloadProbe: return false;
|
case Method::DownloadProbe: return false;
|
||||||
|
case Method::DownloadProvideAuth: return true;
|
||||||
case Method::DownloadRefreshUrl: return false;
|
case Method::DownloadRefreshUrl: return false;
|
||||||
case Method::DownloadRemove: return true;
|
case Method::DownloadRemove: return true;
|
||||||
case Method::DownloadResume: return false;
|
case Method::DownloadResume: return false;
|
||||||
@@ -7025,6 +7100,7 @@ bool is_allowed_on(Method m, Transport t) noexcept {
|
|||||||
case Method::DownloadList: return t == Transport::Uds ? true : true;
|
case Method::DownloadList: return t == Transport::Uds ? true : true;
|
||||||
case Method::DownloadPause: return t == Transport::Uds ? true : true;
|
case Method::DownloadPause: return t == Transport::Uds ? true : true;
|
||||||
case Method::DownloadProbe: return t == Transport::Uds ? true : true;
|
case Method::DownloadProbe: return t == Transport::Uds ? true : true;
|
||||||
|
case Method::DownloadProvideAuth: return t == Transport::Uds ? true : false;
|
||||||
case Method::DownloadRefreshUrl: return t == Transport::Uds ? true : true;
|
case Method::DownloadRefreshUrl: return t == Transport::Uds ? true : true;
|
||||||
case Method::DownloadRemove: return t == Transport::Uds ? true : false;
|
case Method::DownloadRemove: return t == Transport::Uds ? true : false;
|
||||||
case Method::DownloadResume: return t == Transport::Uds ? true : true;
|
case Method::DownloadResume: return t == Transport::Uds ? true : true;
|
||||||
@@ -7069,6 +7145,7 @@ std::int32_t deadline_ms(Method m) noexcept {
|
|||||||
case Method::DownloadList: return 5000;
|
case Method::DownloadList: return 5000;
|
||||||
case Method::DownloadPause: return 5000;
|
case Method::DownloadPause: return 5000;
|
||||||
case Method::DownloadProbe: return 30000;
|
case Method::DownloadProbe: return 30000;
|
||||||
|
case Method::DownloadProvideAuth: return 5000;
|
||||||
case Method::DownloadRefreshUrl: return 30000;
|
case Method::DownloadRefreshUrl: return 30000;
|
||||||
case Method::DownloadRemove: return 10000;
|
case Method::DownloadRemove: return 10000;
|
||||||
case Method::DownloadResume: return 5000;
|
case Method::DownloadResume: return 5000;
|
||||||
@@ -7305,6 +7382,18 @@ nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann
|
|||||||
nlohmann::json out = *r;
|
nlohmann::json out = *r;
|
||||||
return make_result(id, std::move(out));
|
return make_result(id, std::move(out));
|
||||||
}
|
}
|
||||||
|
case Method::DownloadProvideAuth: {
|
||||||
|
auto p = parse<DownloadProvideAuthParams>(params, "params");
|
||||||
|
if (!p)
|
||||||
|
return make_error(id, ErrorCode::InvalidParams, p.error().message,
|
||||||
|
nlohmann::json{{"path", p.error().path}});
|
||||||
|
auto r = handler.on_download_provideAuth(*p);
|
||||||
|
if (!r)
|
||||||
|
return make_error(id, ErrorCode::InternalError, r.error().message,
|
||||||
|
nlohmann::json{{"path", r.error().path}});
|
||||||
|
nlohmann::json out = *r;
|
||||||
|
return make_result(id, std::move(out));
|
||||||
|
}
|
||||||
case Method::DownloadRefreshUrl: {
|
case Method::DownloadRefreshUrl: {
|
||||||
auto p = parse<DownloadRefreshUrlParams>(params, "params");
|
auto p = parse<DownloadRefreshUrlParams>(params, "params");
|
||||||
if (!p)
|
if (!p)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_cpp.py
|
// Generator: contracts/codegen/gen_cpp.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_cpp.py
|
// python3 contracts/codegen/gen_cpp.py
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
// docs/adr/0009-generated-protocol-library.md.
|
// docs/adr/0009-generated-protocol-library.md.
|
||||||
namespace velox::proto {
|
namespace velox::proto {
|
||||||
|
|
||||||
inline constexpr std::string_view kProtocolVersion = "1.1.0";
|
inline constexpr std::string_view kProtocolVersion = "1.3.0";
|
||||||
|
|
||||||
/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer
|
/// 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.
|
/// into the offending document, so a conformance failure names the exact field.
|
||||||
@@ -533,7 +533,7 @@ struct DownloadSpec {
|
|||||||
std::optional<std::string> categoryId{};
|
std::optional<std::string> categoryId{};
|
||||||
/// Required when startMode is 'queue'.
|
/// Required when startMode is 'queue'.
|
||||||
std::optional<std::string> queueId{};
|
std::optional<std::string> queueId{};
|
||||||
/// The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the
|
/// The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to the
|
||||||
/// per-host cap, and to 1 when the source turns out not to be resumable. What is actually in
|
/// 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.
|
/// use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload.
|
||||||
std::optional<std::int64_t> segments{};
|
std::optional<std::int64_t> segments{};
|
||||||
@@ -750,9 +750,13 @@ struct Settings {
|
|||||||
std::optional<std::int64_t> connection_maxActiveSegments{};
|
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
|
/// Why a task is in the failed, retry_wait, or (when the daemon paused it on its own initiative
|
||||||
/// describes a failed call rather than a failed download — the two live in different code
|
/// rather than the user) paused state. Distinct from the JSON-RPC Error, which describes a
|
||||||
/// spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.
|
/// failed call rather than a failed download — the two live in different code spaces on
|
||||||
|
/// purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer. A pause the
|
||||||
|
/// user or the scheduler requested carries no error: this field only explains a paused state
|
||||||
|
/// the daemon entered unilaterally (auth_required, server_file_changed, disk_full and the
|
||||||
|
/// like), never a deliberate one.
|
||||||
struct TaskError {
|
struct TaskError {
|
||||||
TaskErrorCode code{};
|
TaskErrorCode code{};
|
||||||
/// Human-readable, safe to show a user. Never carries a credential, a token or a full local
|
/// Human-readable, safe to show a user. Never carries a credential, a token or a full local
|
||||||
@@ -808,6 +812,9 @@ struct TaskSummary {
|
|||||||
std::string createdAt{};
|
std::string createdAt{};
|
||||||
std::optional<std::string> lastTryAt{};
|
std::optional<std::string> lastTryAt{};
|
||||||
std::optional<std::string> completedAt{};
|
std::optional<std::string> completedAt{};
|
||||||
|
/// Set when state is failed or retry_wait, and also when state is paused and the daemon entered
|
||||||
|
/// that state on its own initiative rather than at a user's or scheduler's request. null on
|
||||||
|
/// every other state, including a deliberate pause.
|
||||||
std::optional<TaskError> error{};
|
std::optional<TaskError> error{};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -828,7 +835,7 @@ struct TaskDetail {
|
|||||||
/// use.
|
/// use.
|
||||||
std::optional<std::int64_t> bufferBytes{};
|
std::optional<std::int64_t> bufferBytes{};
|
||||||
/// The write buffer actually in use per live segment, right now. May be well below 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
|
/// the engine reduces every live segment's buffer to fit connection.maxTotalBufferBytes across
|
||||||
/// connection.maxActiveSegments concurrently-transferring segments, and reports the reduced
|
/// 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
|
/// value here so the GUI can show '16 MiB (using 4 MiB)'. null before the task has started its
|
||||||
/// first segment.
|
/// first segment.
|
||||||
@@ -1004,6 +1011,20 @@ struct DownloadProbeResult {
|
|||||||
std::optional<bool> requiresAuth{};
|
std::optional<bool> requiresAuth{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DownloadProvideAuthParams {
|
||||||
|
std::string taskId{};
|
||||||
|
std::string username{};
|
||||||
|
std::string password{};
|
||||||
|
/// true persists the credential in the Secret Service, keyed by host and realm, for future
|
||||||
|
/// downloads from the same site. false or null uses it for this task's retry only. Never
|
||||||
|
/// affects SQLite or the daemon's logs either way.
|
||||||
|
std::optional<bool> save{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DownloadProvideAuthResult {
|
||||||
|
bool ok{};
|
||||||
|
};
|
||||||
|
|
||||||
struct DownloadRefreshUrlParams {
|
struct DownloadRefreshUrlParams {
|
||||||
std::string taskId{};
|
std::string taskId{};
|
||||||
std::string url{};
|
std::string url{};
|
||||||
@@ -1380,6 +1401,12 @@ struct TaskStateEvent {
|
|||||||
TaskState state{};
|
TaskState state{};
|
||||||
std::optional<TaskState> previousState{};
|
std::optional<TaskState> previousState{};
|
||||||
std::optional<TaskSummary> summary{};
|
std::optional<TaskSummary> summary{};
|
||||||
|
/// Set when the new state is failed or retry_wait, and also when it is paused and the daemon
|
||||||
|
/// entered that state on its own initiative — auth_required, server_file_changed, disk_full and
|
||||||
|
/// the like — rather than because of a user action, a schedule window closing, or an
|
||||||
|
/// admission-control decision. null on every other transition, including every
|
||||||
|
/// deliberately-requested pause. A client must not assume a paused task has no error just
|
||||||
|
/// because it usually doesn't; check this field rather than the state name alone.
|
||||||
std::optional<TaskError> error{};
|
std::optional<TaskError> error{};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1454,6 +1481,8 @@ void to_json(nlohmann::json& j, const DownloadListResult& v);
|
|||||||
void to_json(nlohmann::json& j, const DownloadPauseParams& v);
|
void to_json(nlohmann::json& j, const DownloadPauseParams& v);
|
||||||
void to_json(nlohmann::json& j, const DownloadProbeParams& v);
|
void to_json(nlohmann::json& j, const DownloadProbeParams& v);
|
||||||
void to_json(nlohmann::json& j, const DownloadProbeResult& v);
|
void to_json(nlohmann::json& j, const DownloadProbeResult& v);
|
||||||
|
void to_json(nlohmann::json& j, const DownloadProvideAuthParams& v);
|
||||||
|
void to_json(nlohmann::json& j, const DownloadProvideAuthResult& v);
|
||||||
void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v);
|
void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v);
|
||||||
void to_json(nlohmann::json& j, const DownloadRefreshUrlResult& v);
|
void to_json(nlohmann::json& j, const DownloadRefreshUrlResult& v);
|
||||||
void to_json(nlohmann::json& j, const DownloadRemoveParams& v);
|
void to_json(nlohmann::json& j, const DownloadRemoveParams& v);
|
||||||
@@ -1599,6 +1628,8 @@ template <> Result<DownloadListResult> parse<DownloadListResult>(const nlohmann:
|
|||||||
template <> Result<DownloadPauseParams> parse<DownloadPauseParams>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadPauseParams> parse<DownloadPauseParams>(const nlohmann::json& j, std::string_view path);
|
||||||
template <> Result<DownloadProbeParams> parse<DownloadProbeParams>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadProbeParams> parse<DownloadProbeParams>(const nlohmann::json& j, std::string_view path);
|
||||||
template <> Result<DownloadProbeResult> parse<DownloadProbeResult>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadProbeResult> parse<DownloadProbeResult>(const nlohmann::json& j, std::string_view path);
|
||||||
|
template <> Result<DownloadProvideAuthParams> parse<DownloadProvideAuthParams>(const nlohmann::json& j, std::string_view path);
|
||||||
|
template <> Result<DownloadProvideAuthResult> parse<DownloadProvideAuthResult>(const nlohmann::json& j, std::string_view path);
|
||||||
template <> Result<DownloadRefreshUrlParams> parse<DownloadRefreshUrlParams>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadRefreshUrlParams> parse<DownloadRefreshUrlParams>(const nlohmann::json& j, std::string_view path);
|
||||||
template <> Result<DownloadRefreshUrlResult> parse<DownloadRefreshUrlResult>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadRefreshUrlResult> parse<DownloadRefreshUrlResult>(const nlohmann::json& j, std::string_view path);
|
||||||
template <> Result<DownloadRemoveParams> parse<DownloadRemoveParams>(const nlohmann::json& j, std::string_view path);
|
template <> Result<DownloadRemoveParams> parse<DownloadRemoveParams>(const nlohmann::json& j, std::string_view path);
|
||||||
@@ -1686,6 +1717,7 @@ enum class Method {
|
|||||||
DownloadList, // download.list
|
DownloadList, // download.list
|
||||||
DownloadPause, // download.pause
|
DownloadPause, // download.pause
|
||||||
DownloadProbe, // download.probe
|
DownloadProbe, // download.probe
|
||||||
|
DownloadProvideAuth, // download.provideAuth
|
||||||
DownloadRefreshUrl, // download.refreshUrl
|
DownloadRefreshUrl, // download.refreshUrl
|
||||||
DownloadRemove, // download.remove
|
DownloadRemove, // download.remove
|
||||||
DownloadResume, // download.resume
|
DownloadResume, // download.resume
|
||||||
@@ -1714,7 +1746,7 @@ enum class Method {
|
|||||||
SettingsSet, // settings.set
|
SettingsSet, // settings.set
|
||||||
};
|
};
|
||||||
|
|
||||||
inline constexpr std::size_t kMethodCount = 38;
|
inline constexpr std::size_t kMethodCount = 39;
|
||||||
|
|
||||||
std::string_view to_string(Method m) noexcept;
|
std::string_view to_string(Method m) noexcept;
|
||||||
std::optional<Method> method_from_string(std::string_view s) noexcept;
|
std::optional<Method> method_from_string(std::string_view s) noexcept;
|
||||||
@@ -1819,6 +1851,16 @@ public:
|
|||||||
/// lands.
|
/// lands.
|
||||||
virtual Result<DownloadProbeResult> on_download_probe(const DownloadProbeParams& params) = 0;
|
virtual Result<DownloadProbeResult> on_download_probe(const DownloadProbeParams& params) = 0;
|
||||||
|
|
||||||
|
/// Answer an event.auth.required challenge. The task sits in retry_wait until this arrives; on
|
||||||
|
/// success the daemon retries with the credentials attached and the task resumes on its own —
|
||||||
|
/// this method does not itself start the transfer. Privileged and Unix-socket-only: a
|
||||||
|
/// credential-bearing method must never be reachable from the browser, which is exactly the
|
||||||
|
/// boundary event.auth.required's own description draws ('never back through this event, never
|
||||||
|
/// into a log') — this is the other half of that promise. Credentials are handed to the Secret
|
||||||
|
/// Service, never to SQLite and never logged; save only tells the daemon whether to persist
|
||||||
|
/// them there for next time, or use them for this attempt alone.
|
||||||
|
virtual Result<DownloadProvideAuthResult> on_download_provideAuth(const DownloadProvideAuthParams& params) = 0;
|
||||||
|
|
||||||
/// IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a
|
/// IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a
|
||||||
/// signed link has expired, keeping every byte already on disk. The daemon re-probes and
|
/// signed link has expired, keeping every byte already on disk. The daemon re-probes and
|
||||||
/// compares size and validator: if they still match, the transfer resumes from where it
|
/// compares size and validator: if they still match, the transfer resumes from where it
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ CI noticing the same day.
|
|||||||
6. **`tools/mockd`** — Node/TS. Serves the fixtures over **both** transports (Unix socket
|
6. **`tools/mockd`** — Node/TS. Serves the fixtures over **both** transports (Unix socket
|
||||||
NDJSON and loopback WS), fakes plausible progress events at 4 Hz, and has flags for
|
NDJSON and loopback WS), fakes plausible progress events at 4 Hz, and has flags for
|
||||||
`--slow`, `--flaky`, `--drop-connection`, `--refuse-pairing` so GUI and EXT can test
|
`--slow`, `--flaky`, `--drop-connection`, `--refuse-pairing` so GUI and EXT can test
|
||||||
their unhappy paths before `veloxd` exists.
|
their unhappy paths before `veloxd` exists. **`--tasks <n>`** seeds a large plausible
|
||||||
|
population (varied states/sizes/categories, a rotating active pool) instead of the
|
||||||
|
fixture's two rows — GUI's M1 DoD needs `--tasks 10000` for its scroll-performance test,
|
||||||
|
so this one lands with M0, not as an afterthought once GUI is already blocked on it.
|
||||||
7. **`tests/conformance/`** — one suite, two runners: replays each fixture against a live
|
7. **`tests/conformance/`** — one suite, two runners: replays each fixture against a live
|
||||||
`veloxd` (C++ side) and through the generated TS client. Wired into CI as a **required
|
`veloxd` (C++ side) and through the generated TS client. Wired into CI as a **required
|
||||||
check on every lane's PR**.
|
check on every lane's PR**.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_ts.py
|
// Generator: contracts/codegen/gen_ts.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_ts.py
|
// python3 contracts/codegen/gen_ts.py
|
||||||
@@ -70,7 +70,9 @@ export interface EventMap {
|
|||||||
"event.task.removed": TaskRemovedEvent;
|
"event.task.removed": TaskRemovedEvent;
|
||||||
/**
|
/**
|
||||||
* A task changed lifecycle state. Carries the summary so the row can be repainted in full
|
* A task changed lifecycle state. Carries the summary so the row can be repainted in full
|
||||||
* without a round trip, and error whenever the new state is failed or retry_wait.
|
* without a round trip, and error whenever the daemon has something to say about why: on
|
||||||
|
* every failed or retry_wait transition, and on a paused transition the daemon entered
|
||||||
|
* unilaterally rather than at a user's or scheduler's request.
|
||||||
*/
|
*/
|
||||||
"event.task.state": TaskStateEvent;
|
"event.task.state": TaskStateEvent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_ts.py
|
// Generator: contracts/codegen/gen_ts.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_ts.py
|
// python3 contracts/codegen/gen_ts.py
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_ts.py
|
// Generator: contracts/codegen/gen_ts.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_ts.py
|
// python3 contracts/codegen/gen_ts.py
|
||||||
@@ -33,6 +33,8 @@ import type {
|
|||||||
DownloadPauseParams,
|
DownloadPauseParams,
|
||||||
DownloadProbeParams,
|
DownloadProbeParams,
|
||||||
DownloadProbeResult,
|
DownloadProbeResult,
|
||||||
|
DownloadProvideAuthParams,
|
||||||
|
DownloadProvideAuthResult,
|
||||||
DownloadRefreshUrlParams,
|
DownloadRefreshUrlParams,
|
||||||
DownloadRefreshUrlResult,
|
DownloadRefreshUrlResult,
|
||||||
DownloadRemoveParams,
|
DownloadRemoveParams,
|
||||||
@@ -159,6 +161,17 @@ export interface MethodMap {
|
|||||||
* when this lands.
|
* when this lands.
|
||||||
*/
|
*/
|
||||||
"download.probe": { params: DownloadProbeParams; result: DownloadProbeResult };
|
"download.probe": { params: DownloadProbeParams; result: DownloadProbeResult };
|
||||||
|
/**
|
||||||
|
* Answer an event.auth.required challenge. The task sits in retry_wait until this arrives;
|
||||||
|
* on success the daemon retries with the credentials attached and the task resumes on its
|
||||||
|
* own — this method does not itself start the transfer. Privileged and Unix-socket-only: a
|
||||||
|
* credential-bearing method must never be reachable from the browser, which is exactly the
|
||||||
|
* boundary event.auth.required's own description draws ('never back through this event,
|
||||||
|
* never into a log') — this is the other half of that promise. Credentials are handed to
|
||||||
|
* the Secret Service, never to SQLite and never logged; save only tells the daemon whether
|
||||||
|
* to persist them there for next time, or use them for this attempt alone.
|
||||||
|
*/
|
||||||
|
"download.provideAuth": { params: DownloadProvideAuthParams; result: DownloadProvideAuthResult };
|
||||||
/**
|
/**
|
||||||
* IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a
|
* IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a
|
||||||
* signed link has expired, keeping every byte already on disk. The daemon re-probes and
|
* signed link has expired, keeping every byte already on disk. The daemon re-probes and
|
||||||
@@ -346,6 +359,7 @@ export const METHODS: { readonly [M in MethodName]: MethodMeta } = {
|
|||||||
"download.list": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [] },
|
"download.list": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [] },
|
||||||
"download.pause": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] },
|
"download.pause": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] },
|
||||||
"download.probe": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32013] },
|
"download.probe": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32013] },
|
||||||
|
"download.provideAuth": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32010] },
|
||||||
"download.refreshUrl": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32010, -32013] },
|
"download.refreshUrl": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32010, -32013] },
|
||||||
"download.remove": { privileged: true, transports: ['uds'], deadlineMs: 10000, errors: [-32003, -32010] },
|
"download.remove": { privileged: true, transports: ['uds'], deadlineMs: 10000, errors: [-32003, -32010] },
|
||||||
"download.resume": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] },
|
"download.resume": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_ts.py
|
// Generator: contracts/codegen/gen_ts.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_ts.py
|
// python3 contracts/codegen/gen_ts.py
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = "1.1.0";
|
export const PROTOCOL_VERSION = "1.3.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every error code the daemon may return. Adding one is a minor bump; changing the meaning
|
* Every error code the daemon may return. Adding one is a minor bump; changing the meaning
|
||||||
@@ -208,7 +208,7 @@ export interface DownloadSpec {
|
|||||||
/** Required when startMode is 'queue'. */
|
/** Required when startMode is 'queue'. */
|
||||||
queueId?: string | null;
|
queueId?: string | null;
|
||||||
/**
|
/**
|
||||||
* The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to
|
* The REQUESTED connection count. An upper bound, not a promise: the engine lowers it to
|
||||||
* the per-host cap, and to 1 when the source turns out not to be resumable. What is
|
* the 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
|
* actually in use comes back as TaskSummary.segments. null means use
|
||||||
* connection.maxSegmentsPerDownload.
|
* connection.maxSegmentsPerDownload.
|
||||||
@@ -646,9 +646,13 @@ export const TASK_ERROR_CODE_VALUES = [
|
|||||||
] as const satisfies readonly TaskErrorCode[];
|
] as const satisfies readonly TaskErrorCode[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which
|
* Why a task is in the failed, retry_wait, or (when the daemon paused it on its own
|
||||||
|
* initiative rather than the user) paused state. Distinct from the JSON-RPC Error, which
|
||||||
* describes a failed call rather than a failed download — the two live in different code
|
* describes a failed call rather than a failed download — the two live in different code
|
||||||
* spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.
|
* spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.
|
||||||
|
* A pause the user or the scheduler requested carries no error: this field only explains a
|
||||||
|
* paused state the daemon entered unilaterally (auth_required, server_file_changed,
|
||||||
|
* disk_full and the like), never a deliberate one.
|
||||||
*/
|
*/
|
||||||
export interface TaskError {
|
export interface TaskError {
|
||||||
code: TaskErrorCode;
|
code: TaskErrorCode;
|
||||||
@@ -716,6 +720,11 @@ export interface TaskSummary {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastTryAt?: string | null;
|
lastTryAt?: string | null;
|
||||||
completedAt?: string | null;
|
completedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Set when state is failed or retry_wait, and also when state is paused and the daemon
|
||||||
|
* entered that state on its own initiative rather than at a user's or scheduler's request.
|
||||||
|
* null on every other state, including a deliberate pause.
|
||||||
|
*/
|
||||||
error?: TaskError | null;
|
error?: TaskError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +752,7 @@ export interface TaskDetail {
|
|||||||
bufferBytes?: number | null;
|
bufferBytes?: number | null;
|
||||||
/**
|
/**
|
||||||
* The write buffer actually in use per live segment, right now. May be well below
|
* 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
|
* bufferBytes: the engine reduces every live segment's buffer to fit
|
||||||
* connection.maxTotalBufferBytes across connection.maxActiveSegments
|
* connection.maxTotalBufferBytes across connection.maxActiveSegments
|
||||||
* concurrently-transferring segments, and reports the reduced value here so the GUI can
|
* 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.
|
* show '16 MiB (using 4 MiB)'. null before the task has started its first segment.
|
||||||
@@ -978,6 +987,22 @@ export interface DownloadProbeResult {
|
|||||||
requiresAuth?: boolean;
|
requiresAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadProvideAuthParams {
|
||||||
|
taskId: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
/**
|
||||||
|
* true persists the credential in the Secret Service, keyed by host and realm, for future
|
||||||
|
* downloads from the same site. false or null uses it for this task's retry only. Never
|
||||||
|
* affects SQLite or the daemon's logs either way.
|
||||||
|
*/
|
||||||
|
save?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProvideAuthResult {
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadRefreshUrlParams {
|
export interface DownloadRefreshUrlParams {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -1447,6 +1472,14 @@ export interface TaskStateEvent {
|
|||||||
state: TaskState;
|
state: TaskState;
|
||||||
previousState?: TaskState | null;
|
previousState?: TaskState | null;
|
||||||
summary?: TaskSummary | null;
|
summary?: TaskSummary | null;
|
||||||
|
/**
|
||||||
|
* Set when the new state is failed or retry_wait, and also when it is paused and the
|
||||||
|
* daemon entered that state on its own initiative — auth_required, server_file_changed,
|
||||||
|
* disk_full and the like — rather than because of a user action, a schedule window
|
||||||
|
* closing, or an admission-control decision. null on every other transition, including
|
||||||
|
* every deliberately-requested pause. A client must not assume a paused task has no error
|
||||||
|
* just because it usually doesn't; check this field rather than the state name alone.
|
||||||
|
*/
|
||||||
error?: TaskError | null;
|
error?: TaskError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_ts.py
|
// Generator: contracts/codegen/gen_ts.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_ts.py
|
// python3 contracts/codegen/gen_ts.py
|
||||||
@@ -46,6 +46,8 @@ import type {
|
|||||||
DownloadPauseParams,
|
DownloadPauseParams,
|
||||||
DownloadProbeParams,
|
DownloadProbeParams,
|
||||||
DownloadProbeResult,
|
DownloadProbeResult,
|
||||||
|
DownloadProvideAuthParams,
|
||||||
|
DownloadProvideAuthResult,
|
||||||
DownloadRefreshUrlParams,
|
DownloadRefreshUrlParams,
|
||||||
DownloadRefreshUrlResult,
|
DownloadRefreshUrlResult,
|
||||||
DownloadRemoveParams,
|
DownloadRemoveParams,
|
||||||
@@ -1233,6 +1235,32 @@ export function validateDownloadProbeResult(v: unknown, path = ''): Validated<Do
|
|||||||
return { ok: true, value: out as unknown as DownloadProbeResult };
|
return { ok: true, value: out as unknown as DownloadProbeResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate an untrusted value as DownloadProvideAuthParams. */
|
||||||
|
export function validateDownloadProvideAuthParams(v: unknown, path = ''): Validated<DownloadProvideAuthParams> {
|
||||||
|
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
let r: Validated<null>;
|
||||||
|
r = req(v, "taskId", path, vString, out);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
r = req(v, "username", path, vLimited(vString, { maxLength: 256 }), out);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
r = req(v, "password", path, vLimited(vString, { maxLength: 1024 }), out);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
r = opt(v, "save", path, vBoolean, out);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
return { ok: true, value: out as unknown as DownloadProvideAuthParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate an untrusted value as DownloadProvideAuthResult. */
|
||||||
|
export function validateDownloadProvideAuthResult(v: unknown, path = ''): Validated<DownloadProvideAuthResult> {
|
||||||
|
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
let r: Validated<null>;
|
||||||
|
r = req(v, "ok", path, vBoolean, out);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
return { ok: true, value: out as unknown as DownloadProvideAuthResult };
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate an untrusted value as DownloadRefreshUrlParams. */
|
/** Validate an untrusted value as DownloadRefreshUrlParams. */
|
||||||
export function validateDownloadRefreshUrlParams(v: unknown, path = ''): Validated<DownloadRefreshUrlParams> {
|
export function validateDownloadRefreshUrlParams(v: unknown, path = ''): Validated<DownloadRefreshUrlParams> {
|
||||||
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
||||||
@@ -2058,6 +2086,7 @@ const PARAMS_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['params']>
|
|||||||
"download.list": validateDownloadListParams,
|
"download.list": validateDownloadListParams,
|
||||||
"download.pause": validateDownloadPauseParams,
|
"download.pause": validateDownloadPauseParams,
|
||||||
"download.probe": validateDownloadProbeParams,
|
"download.probe": validateDownloadProbeParams,
|
||||||
|
"download.provideAuth": validateDownloadProvideAuthParams,
|
||||||
"download.refreshUrl": validateDownloadRefreshUrlParams,
|
"download.refreshUrl": validateDownloadRefreshUrlParams,
|
||||||
"download.remove": validateDownloadRemoveParams,
|
"download.remove": validateDownloadRemoveParams,
|
||||||
"download.resume": validateDownloadResumeParams,
|
"download.resume": validateDownloadResumeParams,
|
||||||
@@ -2099,6 +2128,7 @@ const RESULT_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['result']>
|
|||||||
"download.list": validateDownloadListResult,
|
"download.list": validateDownloadListResult,
|
||||||
"download.pause": validateBulkTaskResult,
|
"download.pause": validateBulkTaskResult,
|
||||||
"download.probe": validateDownloadProbeResult,
|
"download.probe": validateDownloadProbeResult,
|
||||||
|
"download.provideAuth": validateDownloadProvideAuthResult,
|
||||||
"download.refreshUrl": validateDownloadRefreshUrlResult,
|
"download.refreshUrl": validateDownloadRefreshUrlResult,
|
||||||
"download.remove": validateDownloadRemoveResult,
|
"download.remove": validateDownloadRemoveResult,
|
||||||
"download.resume": validateBulkTaskResult,
|
"download.resume": validateBulkTaskResult,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Wires the conformance suite into ctest, so `ctest -L conformance` — the check
|
||||||
|
# .github/workflows/ci.yml's `conformance` job runs, and the one BRANCH_PROTECTION.md
|
||||||
|
# names as required "when tests/conformance/ lands" — actually runs it. Without this file
|
||||||
|
# that job's presence-check finds neither a CMakeLists.txt nor a package.json here and
|
||||||
|
# silently no-ops to a green "skipped", forever: the M0 exit gate would never actually
|
||||||
|
# gate anything. Owned by lane PROTO.
|
||||||
|
#
|
||||||
|
# One ctest entry shells out to run.sh — the exact command tests/conformance/README.md
|
||||||
|
# tells a human to run locally. That is deliberate: there is exactly one definition of
|
||||||
|
# "the suite passed", not a CMake-flavoured near-duplicate of it that can drift from the
|
||||||
|
# real one. The native (C++) half also gets its own finer-grained test via add_subdirectory
|
||||||
|
# below, for a lane iterating on core/generated/ who doesn't want npm's cost every run.
|
||||||
|
|
||||||
|
add_subdirectory(cpp)
|
||||||
|
|
||||||
|
add_test(
|
||||||
|
NAME conformance
|
||||||
|
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/run.sh
|
||||||
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||||
|
)
|
||||||
|
set_tests_properties(conformance PROPERTIES
|
||||||
|
LABELS "conformance"
|
||||||
|
# Installs two npm trees and runs a ten-minute-capable suite end to end; generous but
|
||||||
|
# not unbounded, so a genuinely hung mockd fails the job instead of the runner.
|
||||||
|
TIMEOUT 600
|
||||||
|
)
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
# this exercises the wire types, which is a separate concern from the engine. See
|
# this exercises the wire types, which is a separate concern from the engine. See
|
||||||
# docs/adr/0009-generated-protocol-library.md.
|
# docs/adr/0009-generated-protocol-library.md.
|
||||||
|
|
||||||
|
# The root CMakeLists.txt only find_package(nlohmann_json)'s when daemon/CMakeLists.txt
|
||||||
|
# exists (daemon is its real consumer), so this target may not exist yet when this
|
||||||
|
# directory configures on its own — this suite must not depend on daemon having landed.
|
||||||
|
# Self-sufficient rather than reaching into the root file to widen that guard.
|
||||||
|
if(NOT TARGET nlohmann_json::nlohmann_json)
|
||||||
|
find_package(nlohmann_json 3.11 REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
add_executable(velox_conformance_cpp
|
add_executable(velox_conformance_cpp
|
||||||
conformance_main.cpp
|
conformance_main.cpp
|
||||||
${CMAKE_SOURCE_DIR}/core/generated/velox_proto.cpp)
|
${CMAKE_SOURCE_DIR}/core/generated/velox_proto.cpp)
|
||||||
@@ -18,3 +26,4 @@ target_link_libraries(velox_conformance_cpp PRIVATE nlohmann_json::nlohmann_json
|
|||||||
# The runner needs the repository root so it can find contracts/fixtures.
|
# The runner needs the repository root so it can find contracts/fixtures.
|
||||||
add_test(NAME conformance_cpp
|
add_test(NAME conformance_cpp
|
||||||
COMMAND velox_conformance_cpp ${CMAKE_SOURCE_DIR})
|
COMMAND velox_conformance_cpp ${CMAKE_SOURCE_DIR})
|
||||||
|
set_tests_properties(conformance_cpp PROPERTIES LABELS "conformance")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Source: contracts/schema/**
|
// Source: contracts/schema/**
|
||||||
// Generator: contracts/codegen/gen_cpp.py
|
// Generator: contracts/codegen/gen_cpp.py
|
||||||
// Contract: v1.1.0
|
// Contract: v1.3.0
|
||||||
//
|
//
|
||||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||||
// python3 contracts/codegen/gen_cpp.py
|
// python3 contracts/codegen/gen_cpp.py
|
||||||
@@ -87,6 +87,11 @@ public:
|
|||||||
return golden<proto::DownloadProbeResult>("download.probe");
|
return golden<proto::DownloadProbeResult>("download.probe");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proto::Result<proto::DownloadProvideAuthResult> on_download_provideAuth(const proto::DownloadProvideAuthParams& params) override {
|
||||||
|
(void)params;
|
||||||
|
return golden<proto::DownloadProvideAuthResult>("download.provideAuth");
|
||||||
|
}
|
||||||
|
|
||||||
proto::Result<proto::DownloadRefreshUrlResult> on_download_refreshUrl(const proto::DownloadRefreshUrlParams& params) override {
|
proto::Result<proto::DownloadRefreshUrlResult> on_download_refreshUrl(const proto::DownloadRefreshUrlParams& params) override {
|
||||||
(void)params;
|
(void)params;
|
||||||
return golden<proto::DownloadRefreshUrlResult>("download.refreshUrl");
|
return golden<proto::DownloadRefreshUrlResult>("download.refreshUrl");
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ step() { printf '\n=== %s ===\n' "$1"; }
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- 1. static
|
# ---------------------------------------------------------------- 1. static
|
||||||
step "static conformance (schemas, fixtures, generated code)"
|
step "static conformance (schemas, fixtures, generated code)"
|
||||||
|
# jsonschema/referencing aren't part of tools/bootstrap.sh's apt list (that's PKG's
|
||||||
|
# script; these are this suite's own Python deps), so this suite installs them itself
|
||||||
|
# rather than assuming a CI image happens to have them. Cheap and idempotent when
|
||||||
|
# they're already present, which is every local dev run after the first.
|
||||||
|
python3 -c "import jsonschema, referencing" 2>/dev/null \
|
||||||
|
|| python3 -m pip install --quiet --disable-pip-version-check --user jsonschema referencing
|
||||||
python3 "$HERE/check_contract.py"
|
python3 "$HERE/check_contract.py"
|
||||||
|
|
||||||
# ------------------------------------------------------------------- 2. C++
|
# ------------------------------------------------------------------- 2. C++
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Defaults: `$XDG_RUNTIME_DIR/velox/velox.sock` and `ws://127.0.0.1:52000`.
|
|||||||
| `--ws-port <n>` / `--no-ws` | loopback WebSocket port, or don't listen |
|
| `--ws-port <n>` / `--no-ws` | loopback WebSocket port, or don't listen |
|
||||||
| `--progress-hz <n>` | progress event rate (default 4, the contract's ceiling) |
|
| `--progress-hz <n>` | progress event rate (default 4, the contract's ceiling) |
|
||||||
| `--speed <bytes>` | synthetic per-task speed |
|
| `--speed <bytes>` | synthetic per-task speed |
|
||||||
|
| `--tasks <n>` | seed `n` plausible synthetic tasks instead of the fixture's two — see below |
|
||||||
|
| `--active-cap <n>` | ceiling on concurrently-"downloading" synthetic tasks (default 24) |
|
||||||
|
| `--seed <n>` | PRNG seed for `--tasks`, so a run is exactly reproducible (default 1337) |
|
||||||
| `--slow <ms>` | delay every reply. **Past 750 ms `capture.offer` must fail open.** |
|
| `--slow <ms>` | delay every reply. **Past 750 ms `capture.offer` must fail open.** |
|
||||||
| `--flaky <0..1>` | answer this fraction of calls with `-32603` |
|
| `--flaky <0..1>` | answer this fraction of calls with `-32603` |
|
||||||
| `--drop-connection <s>` | terminate every connection every N seconds |
|
| `--drop-connection <s>` | terminate every connection every N seconds |
|
||||||
@@ -33,6 +36,37 @@ Defaults: `$XDG_RUNTIME_DIR/velox/velox.sock` and `ws://127.0.0.1:52000`.
|
|||||||
| `--allow-any-origin` | skip the `moz-extension://` Origin check (debugging only) |
|
| `--allow-any-origin` | skip the `moz-extension://` Origin check (debugging only) |
|
||||||
| `--no-validate` | stop validating params (to see what a client actually sends) |
|
| `--no-validate` | stop validating params (to see what a client actually sends) |
|
||||||
|
|
||||||
|
## `--tasks` — load testing the GUI's table
|
||||||
|
|
||||||
|
GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with flat memory
|
||||||
|
over 10 minutes (`mockd --tasks 10000`)". That takes more than 10 000 identical rows:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm start -- --tasks 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeds a plausible population — varied `state`, size, category, queue position and
|
||||||
|
description, drawn from the same `category.list` / `queue.list` fixtures the rest of
|
||||||
|
mockd serves, so nothing here can name a category or queue those methods don't also
|
||||||
|
return. Roughly 55% land `complete`, the rest split across `failed`, `cancelled`,
|
||||||
|
`paused`, `retry_wait` and `queued`, plus a bounded pool (`--active-cap`, default 24)
|
||||||
|
seeded straight into `downloading`.
|
||||||
|
|
||||||
|
That pool is **rotating**, not fixed: as an active task finishes, the next one is
|
||||||
|
promoted from its queue's FIFO — with the rest of that queue's `queuePosition` renumbered,
|
||||||
|
as a real scheduler would — and a small fraction of "finishing" active tasks fail instead
|
||||||
|
and cycle through `retry_wait` before rejoining. Over a ten-minute run this means hundreds
|
||||||
|
of distinct rows have shown live progress by the time it ends, not the same handful
|
||||||
|
forever, while at any instant the active count stays realistic. A manual `download.add`
|
||||||
|
is always admitted immediately regardless of `--active-cap` — a human driving the GUI by
|
||||||
|
hand is never made to wait behind synthetic load.
|
||||||
|
|
||||||
|
`tick()` only ever walks the active pool plus whatever retry-wait entries just came due,
|
||||||
|
never the full task list, so the per-tick cost stays flat regardless of `--tasks`.
|
||||||
|
|
||||||
|
`--seed` makes a run reproducible: the same seed always produces the same table, which
|
||||||
|
matters when a GUI bug only shows up at a particular row.
|
||||||
|
|
||||||
## What is real and what is faked
|
## What is real and what is faked
|
||||||
|
|
||||||
**Real**, because a client's correctness depends on it:
|
**Real**, because a client's correctness depends on it:
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ export class Dispatcher {
|
|||||||
return { updated, failed };
|
return { updated, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'download.provideAuth': {
|
||||||
|
const task = state.tasks.get(String(params['taskId']));
|
||||||
|
if (!task) return notFound(String(params['taskId']));
|
||||||
|
// A real transition, not just a canned {ok:true}: the task was parked in
|
||||||
|
// retry_wait by an auth challenge, and answering it is what lets it resume.
|
||||||
|
state.transition(task.taskId, 'connecting');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
case 'download.remove': {
|
case 'download.remove': {
|
||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
const failed: Json[] = [];
|
const failed: Json[] = [];
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import { rmSync } from 'node:fs';
|
|||||||
import { Dispatcher, type Session } from './dispatch.js';
|
import { Dispatcher, type Session } from './dispatch.js';
|
||||||
import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js';
|
import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js';
|
||||||
import { MockState } from './state.js';
|
import { MockState } from './state.js';
|
||||||
|
import { DEFAULT_ACTIVE_CAP, generateSyntheticTasks } from './synth.js';
|
||||||
import { startUds, type Connection } from './transport/uds.js';
|
import { startUds, type Connection } from './transport/uds.js';
|
||||||
import { startWs } from './transport/ws.js';
|
import { startWs } from './transport/ws.js';
|
||||||
import type { TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
import type { Category, Queue, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||||
import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js';
|
import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js';
|
||||||
|
|
||||||
const HERE = resolve(fileURLToPath(import.meta.url), '..');
|
const HERE = resolve(fileURLToPath(import.meta.url), '..');
|
||||||
@@ -37,6 +38,20 @@ const USAGE = `mockd — mock veloxd, serving contracts/fixtures over both trans
|
|||||||
--progress-hz <n> progress event rate (default: 4, the contract's maximum)
|
--progress-hz <n> progress event rate (default: 4, the contract's maximum)
|
||||||
--speed <bytes> synthetic per-task speed in bytes/sec (default: 8388608)
|
--speed <bytes> synthetic per-task speed in bytes/sec (default: 8388608)
|
||||||
|
|
||||||
|
Load testing, for the GUI lane's "N rows at 60 fps" definition of done:
|
||||||
|
--tasks <n> seed n plausible synthetic tasks — varied states, sizes,
|
||||||
|
speeds, categories and queue positions — instead of the two
|
||||||
|
from contracts/fixtures/download.list.json. A bounded, rotating
|
||||||
|
pool of active downloads (see --active-cap) keeps progress
|
||||||
|
moving across a broad slice of the table for as long as mockd
|
||||||
|
runs, not just the first few rows.
|
||||||
|
e.g. mockd --tasks 10000
|
||||||
|
--active-cap <n> ceiling on concurrently-"downloading" synthetic tasks
|
||||||
|
(default: 24). Has no effect on a manual download.add, which is
|
||||||
|
always admitted immediately.
|
||||||
|
--seed <n> PRNG seed for --tasks, so a given seed reproduces the exact
|
||||||
|
same table (default: 1337)
|
||||||
|
|
||||||
Unhappy paths, for the GUI and EXT lanes:
|
Unhappy paths, for the GUI and EXT lanes:
|
||||||
--slow <ms> delay every reply by <ms>. Past 750 ms, capture.offer must
|
--slow <ms> delay every reply by <ms>. Past 750 ms, capture.offer must
|
||||||
fail open and let Firefox take the download.
|
fail open and let Firefox take the download.
|
||||||
@@ -56,6 +71,9 @@ interface Args {
|
|||||||
wsPort: number | null;
|
wsPort: number | null;
|
||||||
progressHz: number;
|
progressHz: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
|
tasks: number;
|
||||||
|
activeCap: number;
|
||||||
|
seed: number;
|
||||||
slow: number;
|
slow: number;
|
||||||
flaky: number;
|
flaky: number;
|
||||||
dropEverySec: number;
|
dropEverySec: number;
|
||||||
@@ -73,6 +91,9 @@ function parseArgs(argv: readonly string[]): Args {
|
|||||||
wsPort: 52000,
|
wsPort: 52000,
|
||||||
progressHz: 4,
|
progressHz: 4,
|
||||||
speed: 8 * 1024 * 1024,
|
speed: 8 * 1024 * 1024,
|
||||||
|
tasks: 0,
|
||||||
|
activeCap: DEFAULT_ACTIVE_CAP,
|
||||||
|
seed: 1337,
|
||||||
slow: 0,
|
slow: 0,
|
||||||
flaky: 0,
|
flaky: 0,
|
||||||
dropEverySec: 0,
|
dropEverySec: 0,
|
||||||
@@ -99,6 +120,9 @@ function parseArgs(argv: readonly string[]): Args {
|
|||||||
case '--no-ws': args.wsPort = null; break;
|
case '--no-ws': args.wsPort = null; break;
|
||||||
case '--progress-hz': args.progressHz = Number(next()); break;
|
case '--progress-hz': args.progressHz = Number(next()); break;
|
||||||
case '--speed': args.speed = Number(next()); break;
|
case '--speed': args.speed = Number(next()); break;
|
||||||
|
case '--tasks': args.tasks = Number(next()); break;
|
||||||
|
case '--active-cap': args.activeCap = Number(next()); break;
|
||||||
|
case '--seed': args.seed = Number(next()); break;
|
||||||
case '--slow': args.slow = Number(next()); break;
|
case '--slow': args.slow = Number(next()); break;
|
||||||
case '--flaky': args.flaky = Number(next()); break;
|
case '--flaky': args.flaky = Number(next()); break;
|
||||||
case '--drop-connection': args.dropEverySec = Number(next()); break;
|
case '--drop-connection': args.dropEverySec = Number(next()); break;
|
||||||
@@ -126,13 +150,39 @@ function main(): void {
|
|||||||
log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`);
|
log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`);
|
||||||
|
|
||||||
// Seed the task list from the download.list fixture, so a client that connects before
|
// Seed the task list from the download.list fixture, so a client that connects before
|
||||||
// adding anything still has rows to draw.
|
// adding anything still has rows to draw. --tasks replaces this with a much larger
|
||||||
|
// synthetic population instead — see below.
|
||||||
const listFixture = byMethod.get('download.list');
|
const listFixture = byMethod.get('download.list');
|
||||||
const seed = (resolvePlaceholders(
|
const seed = args.tasks > 0 ? [] : (resolvePlaceholders(
|
||||||
(listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [],
|
(listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [],
|
||||||
) as TaskSummary[]);
|
) as TaskSummary[]);
|
||||||
|
|
||||||
const state = new MockState(seed, { progressHz: args.progressHz, speedBps: args.speed });
|
const state = new MockState(seed, {
|
||||||
|
progressHz: args.progressHz, speedBps: args.speed, activeCap: args.activeCap,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.tasks > 0) {
|
||||||
|
// Categories and queues come from their own golden fixtures rather than being
|
||||||
|
// reinvented here, so a --tasks run can never drift from what category.list and
|
||||||
|
// queue.list actually serve — the extension's default-category picker and the GUI's
|
||||||
|
// category tree see the same ids these synthetic tasks are filed under.
|
||||||
|
const categories = (resolvePlaceholders(
|
||||||
|
(byMethod.get('category.list')?.response as { result?: { items?: unknown[] } } | undefined)
|
||||||
|
?.result?.items ?? [],
|
||||||
|
) as Category[]);
|
||||||
|
const queues = (resolvePlaceholders(
|
||||||
|
(byMethod.get('queue.list')?.response as { result?: { items?: unknown[] } } | undefined)
|
||||||
|
?.result?.items ?? [],
|
||||||
|
) as Queue[]);
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const synthetic = generateSyntheticTasks({
|
||||||
|
count: args.tasks, seed: args.seed, categories, queues, baseSpeedBps: args.speed,
|
||||||
|
});
|
||||||
|
state.seedSynthetic(synthetic);
|
||||||
|
log(`--tasks ${args.tasks}: generated in ${Date.now() - started} ms `
|
||||||
|
+ `(${synthetic.initiallyActive.length} active now, active-cap ${args.activeCap}, seed ${args.seed})`);
|
||||||
|
}
|
||||||
const dispatcher = new Dispatcher(state, byMethod, {
|
const dispatcher = new Dispatcher(state, byMethod, {
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
daemonVersion: '0.0.0-mockd',
|
daemonVersion: '0.0.0-mockd',
|
||||||
@@ -169,10 +219,14 @@ function main(): void {
|
|||||||
// Progress at the contract's 4 Hz ceiling, as one batched array — never one
|
// Progress at the contract's 4 Hz ceiling, as one batched array — never one
|
||||||
// notification per task. The GUI lane needs this shape to build its coalescing against.
|
// notification per task. The GUI lane needs this shape to build its coalescing against.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const { moved, completed } = state.tick();
|
const { moved, completed, failed } = state.tick();
|
||||||
if (moved.length > 0) {
|
// A progress payload only makes sense for a still-active task; a task that just
|
||||||
|
// finished (either direction) gets its own event.task.state below instead, so the two
|
||||||
|
// events never race on the same tick with contradictory numbers.
|
||||||
|
const stillProgressing = moved.filter((t) => t.state === 'downloading');
|
||||||
|
if (stillProgressing.length > 0) {
|
||||||
broadcast('event.task.progress', {
|
broadcast('event.task.progress', {
|
||||||
tasks: moved.map((t) => ({
|
tasks: stillProgressing.map((t) => ({
|
||||||
taskId: t.taskId,
|
taskId: t.taskId,
|
||||||
downloadedBytes: t.downloadedBytes,
|
downloadedBytes: t.downloadedBytes,
|
||||||
speedBps: t.speedBps,
|
speedBps: t.speedBps,
|
||||||
@@ -196,6 +250,19 @@ function main(): void {
|
|||||||
body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete',
|
body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const task of failed) {
|
||||||
|
broadcast('event.task.state', {
|
||||||
|
taskId: task.taskId, state: task.state, previousState: 'downloading',
|
||||||
|
summary: task, error: task.error,
|
||||||
|
});
|
||||||
|
if (task.state === 'failed') {
|
||||||
|
broadcast('event.notify', {
|
||||||
|
level: 'error', title: 'Download failed',
|
||||||
|
body: `${task.filename}: ${task.error?.message ?? 'unknown error'}`,
|
||||||
|
taskId: task.taskId, sound: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}, Math.max(1, Math.round(1000 / args.progressHz)));
|
}, Math.max(1, Math.round(1000 / args.progressHz)));
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
+162
-14
@@ -7,17 +7,39 @@
|
|||||||
* answered straight from a golden file.
|
* answered straight from a golden file.
|
||||||
*
|
*
|
||||||
* This is deliberately not a download engine. Bytes advance on a clock, not from a socket.
|
* This is deliberately not a download engine. Bytes advance on a clock, not from a socket.
|
||||||
|
*
|
||||||
|
* At scale (`--tasks 10000`) two things matter that don't at a handful of tasks:
|
||||||
|
*
|
||||||
|
* 1. `tick()` must not become O(total tasks) every 250 ms. It only ever touches the
|
||||||
|
* bounded *active* set (`activeCap`, default 24) plus whatever retry-wait entries just
|
||||||
|
* came due — never the thousands of tasks sitting in a terminal or queued state.
|
||||||
|
* 2. Progress has to reach a broad slice of the table over the life of a run, not the same
|
||||||
|
* couple of rows forever. As an active task finishes, the next one is promoted from its
|
||||||
|
* queue's FIFO (with the rest of that queue's `queuePosition` renumbered, as a real
|
||||||
|
* scheduler would), and a slice of "finished" active tasks fail instead and go through
|
||||||
|
* `retry_wait` before being promoted again — so the active pool keeps rotating through
|
||||||
|
* new rows for the whole run rather than draining once and going static.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||||
|
import { randomTaskErrorCode, taskErrorFor, type SynthResult } from './synth.js';
|
||||||
|
|
||||||
export interface MockOptions {
|
export interface MockOptions {
|
||||||
readonly progressHz: number;
|
readonly progressHz: number;
|
||||||
readonly speedBps: number;
|
readonly speedBps: number;
|
||||||
|
/** Ceiling on concurrently-"downloading" tasks. Bulk-seeded tasks rotate through this;
|
||||||
|
* a manual download.add is admitted immediately regardless (see add()) — a human
|
||||||
|
* driving the GUI by hand should never be told to wait behind synthetic load. */
|
||||||
|
readonly activeCap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE: readonly TaskState[] = ['connecting', 'downloading'];
|
const ACTIVE: readonly TaskState[] = ['connecting', 'downloading'];
|
||||||
|
// Chance per active task per tick that a healthy transfer hits a transient failure instead
|
||||||
|
// of completing normally — keeps the active pool cycling through retry_wait for the whole
|
||||||
|
// life of a long run rather than just draining the initial 'queued' population once.
|
||||||
|
const TRANSIENT_FAILURE_CHANCE = 0.0015;
|
||||||
|
const RETRY_DELAY_MS: readonly [number, number] = [5_000, 45_000];
|
||||||
|
|
||||||
export class MockState {
|
export class MockState {
|
||||||
readonly tasks = new Map<string, TaskSummary>();
|
readonly tasks = new Map<string, TaskSummary>();
|
||||||
@@ -25,15 +47,47 @@ export class MockState {
|
|||||||
settings: Record<string, unknown> = {};
|
settings: Record<string, unknown> = {};
|
||||||
private readonly opts: MockOptions;
|
private readonly opts: MockOptions;
|
||||||
|
|
||||||
|
/** taskIds currently connecting/downloading. Only this set is walked every tick. */
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
/** Per-queue FIFO of taskIds waiting to be promoted, front = next admitted. */
|
||||||
|
private readonly pendingByQueue = new Map<string, string[]>();
|
||||||
|
/** taskIds with no queueId, or whose queue is unknown to this process (e.g. added via
|
||||||
|
* download.add with startMode 'queue' and a queueId mockd has no fixture-derived queue
|
||||||
|
* for) — a fallback FIFO so nothing is silently unpromotable. */
|
||||||
|
private readonly pendingUnqueued: string[] = [];
|
||||||
|
private readonly retryWaiting: Array<{ taskId: string; dueAt: number }> = [];
|
||||||
|
private readonly speedFactor = new Map<string, number>();
|
||||||
|
|
||||||
constructor(seed: readonly TaskSummary[], opts: MockOptions) {
|
constructor(seed: readonly TaskSummary[], opts: MockOptions) {
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
for (const t of seed) this.tasks.set(t.taskId, { ...t });
|
for (const t of seed) this.registerNew({ ...t });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-seed a large synthetic population without re-deriving the pool bookkeeping the
|
||||||
|
* generator already computed — the one place a --tasks 10000 startup does real O(n)
|
||||||
|
* work, and it happens once, not per tick. */
|
||||||
|
seedSynthetic(result: SynthResult): void {
|
||||||
|
for (const task of result.tasks) this.tasks.set(task.taskId, task);
|
||||||
|
for (const taskId of result.initiallyActive) this.active.add(taskId);
|
||||||
|
for (const [queueId, ids] of result.queuedOrder) this.pendingByQueue.set(queueId, [...ids]);
|
||||||
|
for (const [taskId, factor] of result.speedFactor) this.speedFactor.set(taskId, factor);
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): TaskSummary[] {
|
list(): TaskSummary[] {
|
||||||
return [...this.tasks.values()];
|
return [...this.tasks.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerNew(task: TaskSummary): void {
|
||||||
|
this.tasks.set(task.taskId, task);
|
||||||
|
if (ACTIVE.includes(task.state)) this.active.add(task.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A manual add (download.add, capture.offer) is admitted immediately regardless of the
|
||||||
|
* active pool — a human testing the GUI by hand must never be told to wait behind
|
||||||
|
* synthetic --tasks load. It participates in tick()'s active set from the moment it is
|
||||||
|
* created, same as any other active task.
|
||||||
|
*/
|
||||||
add(partial: Partial<TaskSummary> & { url: string }): TaskSummary {
|
add(partial: Partial<TaskSummary> & { url: string }): TaskSummary {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const task: TaskSummary = {
|
const task: TaskSummary = {
|
||||||
@@ -58,7 +112,7 @@ export class MockState {
|
|||||||
completedAt: null,
|
completedAt: null,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
this.tasks.set(task.taskId, task);
|
this.registerNew(task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +120,7 @@ export class MockState {
|
|||||||
const task = this.tasks.get(taskId);
|
const task = this.tasks.get(taskId);
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
const changed = task.state !== state;
|
const changed = task.state !== state;
|
||||||
|
const wasActive = ACTIVE.includes(task.state);
|
||||||
task.state = state;
|
task.state = state;
|
||||||
task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0;
|
task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0;
|
||||||
if (!ACTIVE.includes(state)) task.etaSeconds = null;
|
if (!ACTIVE.includes(state)) task.etaSeconds = null;
|
||||||
@@ -73,29 +128,116 @@ export class MockState {
|
|||||||
task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes;
|
task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes;
|
||||||
task.completedAt = new Date().toISOString();
|
task.completedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
const isActive = ACTIVE.includes(state);
|
||||||
|
if (isActive && !wasActive) this.active.add(taskId);
|
||||||
|
else if (!isActive && wasActive) this.active.delete(taskId);
|
||||||
return { changed, task };
|
return { changed, task };
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(taskId: string): boolean {
|
remove(taskId: string): boolean {
|
||||||
|
this.active.delete(taskId);
|
||||||
return this.tasks.delete(taskId);
|
return this.tasks.delete(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private popNextQueued(): string | undefined {
|
||||||
|
for (const [, ids] of this.pendingByQueue) {
|
||||||
|
const taskId = ids.shift();
|
||||||
|
if (taskId !== undefined) {
|
||||||
|
for (let i = 0; i < ids.length; i += 1) {
|
||||||
|
const t = this.tasks.get(ids[i]!);
|
||||||
|
if (t) t.queuePosition = i + 1;
|
||||||
|
}
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.pendingUnqueued.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
private admit(taskId: string): void {
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
if (!task) return; // removed while queued — nothing to admit
|
||||||
|
task.state = 'connecting';
|
||||||
|
task.speedBps = 0;
|
||||||
|
task.queuePosition = null;
|
||||||
|
task.lastTryAt = new Date().toISOString();
|
||||||
|
this.active.add(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillActiveFromQueues(): void {
|
||||||
|
while (this.active.size < this.opts.activeCap) {
|
||||||
|
const taskId = this.popNextQueued();
|
||||||
|
if (taskId === undefined) return;
|
||||||
|
this.admit(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance one progress tick. Returns the tasks that moved and any that just finished,
|
* Advance one progress tick. Returns the tasks that moved and any that just finished
|
||||||
* so the caller can emit event.task.progress and event.task.state from one place.
|
* (completed, or newly failed), so the caller can emit event.task.progress and
|
||||||
|
* event.task.state from one place.
|
||||||
*/
|
*/
|
||||||
tick(): { moved: TaskSummary[]; completed: TaskSummary[] } {
|
tick(): { moved: TaskSummary[]; completed: TaskSummary[]; failed: TaskSummary[] } {
|
||||||
const moved: TaskSummary[] = [];
|
const moved: TaskSummary[] = [];
|
||||||
const completed: TaskSummary[] = [];
|
const completed: TaskSummary[] = [];
|
||||||
const perTick = Math.floor(this.opts.speedBps / this.opts.progressHz);
|
const failed: TaskSummary[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
for (const task of this.tasks.values()) {
|
// Retry-wait entries whose backoff elapsed rejoin the active pool directly (they have
|
||||||
|
// already waited their turn once; they don't go back through a queue's FIFO).
|
||||||
|
while (this.retryWaiting.length > 0 && this.retryWaiting[0]!.dueAt <= now) {
|
||||||
|
const { taskId } = this.retryWaiting.shift()!;
|
||||||
|
if (this.active.size < this.opts.activeCap) {
|
||||||
|
this.admit(taskId);
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
// Don't push to `moved` here: admit() leaves the task 'connecting', and the main
|
||||||
|
// loop below visits every active taskId (this one included, since admit() just
|
||||||
|
// added it) and pushes exactly once when it flips to 'downloading'. Pushing here
|
||||||
|
// too would duplicate this task in the same tick's event.task.progress batch.
|
||||||
|
if (task) task.error = null;
|
||||||
|
} else {
|
||||||
|
// Pool is full: park it at the front of the fallback FIFO rather than dropping it.
|
||||||
|
this.pendingUnqueued.unshift(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.fillActiveFromQueues();
|
||||||
|
|
||||||
|
const perTickBase = Math.floor(this.opts.speedBps / this.opts.progressHz);
|
||||||
|
|
||||||
|
for (const taskId of [...this.active]) {
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
if (!task) {
|
||||||
|
this.active.delete(taskId); // stale — removed mid-transfer
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (task.state === 'connecting') {
|
if (task.state === 'connecting') {
|
||||||
|
// One settle tick before bytes start moving — also fixes a latent double-push:
|
||||||
|
// falling through to the unconditional moved.push() below would otherwise queue
|
||||||
|
// this task twice in the same event.task.progress batch (once here, once there).
|
||||||
task.state = 'downloading';
|
task.state = 'downloading';
|
||||||
moved.push(task);
|
moved.push(task);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (task.state !== 'downloading') continue;
|
if (task.state !== 'downloading') continue;
|
||||||
|
|
||||||
|
if (task.downloadedBytes > 0 && Math.random() < TRANSIENT_FAILURE_CHANCE) {
|
||||||
|
const retryable = Math.random() < 0.7;
|
||||||
|
const code = randomTaskErrorCode(Math.random, retryable);
|
||||||
|
task.error = taskErrorFor(Math.random, code, retryable);
|
||||||
|
task.state = retryable ? 'retry_wait' : 'failed';
|
||||||
|
task.speedBps = 0;
|
||||||
|
task.etaSeconds = null;
|
||||||
|
this.active.delete(taskId);
|
||||||
|
failed.push(task);
|
||||||
|
moved.push(task);
|
||||||
|
if (retryable) {
|
||||||
|
const [lo, hi] = RETRY_DELAY_MS;
|
||||||
|
this.retryWaiting.push({ taskId, dueAt: now + lo + Math.random() * (hi - lo) });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = this.speedFactor.get(taskId) ?? 1;
|
||||||
|
const perTick = Math.max(1, Math.round(perTickBase * factor));
|
||||||
task.speedBps = jitter(perTick * this.opts.progressHz);
|
task.speedBps = jitter(perTick * this.opts.progressHz);
|
||||||
task.downloadedBytes += perTick;
|
task.downloadedBytes += perTick;
|
||||||
const size = task.sizeBytes;
|
const size = task.sizeBytes;
|
||||||
@@ -105,27 +247,33 @@ export class MockState {
|
|||||||
task.speedBps = 0;
|
task.speedBps = 0;
|
||||||
task.etaSeconds = null;
|
task.etaSeconds = null;
|
||||||
task.completedAt = new Date().toISOString();
|
task.completedAt = new Date().toISOString();
|
||||||
|
this.active.delete(taskId);
|
||||||
completed.push(task);
|
completed.push(task);
|
||||||
} else if (size !== null && size !== undefined && task.speedBps > 0) {
|
} else if (size !== null && size !== undefined && task.speedBps > 0) {
|
||||||
task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps);
|
task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps);
|
||||||
}
|
}
|
||||||
moved.push(task);
|
moved.push(task);
|
||||||
}
|
}
|
||||||
return { moved, completed };
|
|
||||||
|
// Backfill whatever just finished, so the pool is at capacity again for the next tick
|
||||||
|
// rather than idling until someone happens to call fillActiveFromQueues().
|
||||||
|
this.fillActiveFromQueues();
|
||||||
|
|
||||||
|
return { moved, completed, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } {
|
globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } {
|
||||||
let downBps = 0;
|
let downBps = 0;
|
||||||
let activeCount = 0;
|
let activeCount = 0;
|
||||||
let queuedCount = 0;
|
for (const taskId of this.active) {
|
||||||
for (const t of this.tasks.values()) {
|
const task = this.tasks.get(taskId);
|
||||||
if (ACTIVE.includes(t.state)) {
|
if (task) {
|
||||||
downBps += t.speedBps;
|
downBps += task.speedBps;
|
||||||
activeCount += 1;
|
activeCount += 1;
|
||||||
} else if (t.state === 'queued') {
|
|
||||||
queuedCount += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let queuedCount = this.pendingUnqueued.length + this.retryWaiting.length;
|
||||||
|
for (const [, ids] of this.pendingByQueue) queuedCount += ids.length;
|
||||||
return { downBps, activeCount, queuedCount };
|
return { downBps, activeCount, queuedCount };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* `--tasks N`: a plausible, large synthetic task list.
|
||||||
|
*
|
||||||
|
* GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with flat memory
|
||||||
|
* over 10 minutes (`mockd --tasks 10000`)". That is a claim about a table full of rows that
|
||||||
|
* look like a real download manager's history, not 10 000 copies of one row: varied
|
||||||
|
* states, sizes, speeds, categories and queue positions, drawn from the same category and
|
||||||
|
* queue fixtures the rest of mockd already serves so nothing here can drift from them.
|
||||||
|
*
|
||||||
|
* "Progress advances across the whole set, not a handful of live rows" rules out the
|
||||||
|
* obvious cheap answer — seed a handful of tasks as 'downloading' forever and leave
|
||||||
|
* thousands static. Instead a bounded, rotating pool of concurrently-active downloads
|
||||||
|
* (state.ts's `activeCap`) is fed continuously from the queued population and from
|
||||||
|
* transient retries, so over a ten-minute run hundreds of distinct rows have shown live
|
||||||
|
* progress by the time it ends — while at any single instant the active count stays
|
||||||
|
* realistic, exactly as a real daemon would run it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Category, Queue, TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||||
|
import { TASK_ERROR_CODE_VALUES, type TaskErrorCode } from '../../../extension/src/shared/protocol/types.js';
|
||||||
|
|
||||||
|
/** Deterministic PRNG (mulberry32) so a given --seed reproduces the same table — useful
|
||||||
|
* when a GUI bug only shows up at a particular row and needs to be reproduced exactly. */
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rng = () => number;
|
||||||
|
|
||||||
|
function pick<T>(rng: Rng, items: readonly T[]): T {
|
||||||
|
return items[Math.floor(rng() * items.length)] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(rng: Rng, min: number, max: number): number {
|
||||||
|
return Math.floor(min + rng() * (max - min + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log-uniform: real download sizes span 50 KB README files to 20 GB disk images, and a
|
||||||
|
* plain uniform draw would make everything look mid-sized. */
|
||||||
|
function logUniform(rng: Rng, min: number, max: number): number {
|
||||||
|
const lo = Math.log(min);
|
||||||
|
const hi = Math.log(max);
|
||||||
|
return Math.round(Math.exp(lo + rng() * (hi - lo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADJECTIVES = ['annual', 'final', 'draft', 'archived', 'backup', 'shared', 'personal',
|
||||||
|
'weekly', 'monthly', 'legacy', 'updated', 'source', 'compiled', 'signed', 'raw', 'edited'];
|
||||||
|
const NOUNS = ['report', 'photos', 'session', 'build', 'release', 'dataset', 'presentation',
|
||||||
|
'recording', 'mixdown', 'clip', 'manual', 'invoice', 'archive', 'snapshot', 'export', 'project'];
|
||||||
|
|
||||||
|
function synthFilename(rng: Rng, ext: string): string {
|
||||||
|
const stem = `${pick(rng, ADJECTIVES)}-${pick(rng, NOUNS)}-${int(rng, 1, 9999)}`;
|
||||||
|
return `${stem}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function synthHost(rng: Rng): string {
|
||||||
|
const hosts = ['cdn.example.org', 'files.example.net', 'mirror.example.com',
|
||||||
|
'downloads.example.io', 'assets.example.dev', 'releases.example.org'];
|
||||||
|
return pick(rng, hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retryable per B1's taxonomy (`docs/adr/0010`) — matches `x-carriesHttpStatus` roughly:
|
||||||
|
* network hiccups and 5xx are the plausible transient ones a synthetic run should cycle
|
||||||
|
* through; auth/4xx/local-fs errors are terminal and go straight to 'failed'. */
|
||||||
|
const RETRYABLE_CODES: readonly TaskErrorCode[] =
|
||||||
|
['resolve_failed', 'connect_failed', 'connection_reset', 'timeout', 'http_server_error'];
|
||||||
|
const TERMINAL_CODES: readonly TaskErrorCode[] =
|
||||||
|
TASK_ERROR_CODE_VALUES.filter((c) => !RETRYABLE_CODES.includes(c) && c !== 'canceled');
|
||||||
|
|
||||||
|
export function randomTaskErrorCode(rng: Rng, retryable: boolean): TaskErrorCode {
|
||||||
|
return pick(rng, retryable ? RETRYABLE_CODES : TERMINAL_CODES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskErrorFor(rng: Rng, code: TaskErrorCode, retryable: boolean): TaskSummary['error'] {
|
||||||
|
const httpCoded: readonly TaskErrorCode[] =
|
||||||
|
['http_client_error', 'http_server_error', 'auth_required', 'forbidden', 'not_found',
|
||||||
|
'range_not_satisfiable', 'gone'];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: `synthetic ${code} for load testing`,
|
||||||
|
httpStatus: httpCoded.includes(code) ? pick(rng, [403, 404, 410, 429, 500, 502, 503]) : null,
|
||||||
|
retryable,
|
||||||
|
cause: null,
|
||||||
|
attempt: int(rng, 1, 4),
|
||||||
|
nextRetryAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthOptions {
|
||||||
|
readonly count: number;
|
||||||
|
readonly seed: number;
|
||||||
|
readonly categories: readonly Category[];
|
||||||
|
readonly queues: readonly Queue[];
|
||||||
|
readonly baseSpeedBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthResult {
|
||||||
|
readonly tasks: TaskSummary[];
|
||||||
|
/** taskIds seeded directly into 'downloading', in creation order. */
|
||||||
|
readonly initiallyActive: string[];
|
||||||
|
/** taskId -> the ordered position it holds in its queue (1-based), for tasks seeded
|
||||||
|
* 'queued'. Bulk-seeding needs this to rebuild state.ts's per-queue FIFOs without
|
||||||
|
* re-deriving order from creation time. */
|
||||||
|
readonly queuedOrder: Map<string, string[]>; // queueId -> taskIds, front to back
|
||||||
|
/** taskId -> a per-task speed multiplier, so active downloads don't all move in lockstep. */
|
||||||
|
readonly speedFactor: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weights need not sum to any particular total; they are normalised below. Kept as a flat
|
||||||
|
// list (not a Record) so the generator can walk it in one pass while assigning position
|
||||||
|
// within each state's own counter (queuePosition, retry delay, etc).
|
||||||
|
const STATE_WEIGHTS: ReadonlyArray<readonly [TaskState, number]> = [
|
||||||
|
['complete', 55],
|
||||||
|
['failed', 8],
|
||||||
|
['cancelled', 4],
|
||||||
|
['paused', 6],
|
||||||
|
['retry_wait', 2],
|
||||||
|
['queued', 25],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateSyntheticTasks(opts: SynthOptions): SynthResult {
|
||||||
|
const rng = mulberry32(opts.seed);
|
||||||
|
const tasks: TaskSummary[] = [];
|
||||||
|
const initiallyActive: string[] = [];
|
||||||
|
const queuedOrder = new Map<string, string[]>();
|
||||||
|
const speedFactor = new Map<string, number>();
|
||||||
|
|
||||||
|
const categories = opts.categories.length > 0 ? opts.categories : FALLBACK_CATEGORIES;
|
||||||
|
const queues = opts.queues.length > 0 ? opts.queues : FALLBACK_QUEUES;
|
||||||
|
for (const q of queues) queuedOrder.set(q.queueId, []);
|
||||||
|
|
||||||
|
// Reserve a slice of the population to start already 'downloading', mid-transfer, so a
|
||||||
|
// client that connects the instant mockd starts sees live rows immediately rather than
|
||||||
|
// waiting for the first promotion from the queue.
|
||||||
|
const activeSeed = Math.min(opts.count, DEFAULT_ACTIVE_CAP);
|
||||||
|
|
||||||
|
const totalWeight = STATE_WEIGHTS.reduce((sum, [, w]) => sum + w, 0);
|
||||||
|
const remaining = Math.max(0, opts.count - activeSeed);
|
||||||
|
|
||||||
|
// How many of the `remaining` tasks get each state, largest remainder method so the
|
||||||
|
// rounding doesn't silently drop or duplicate a task at small N.
|
||||||
|
const quotas = new Map<TaskState, number>();
|
||||||
|
let assigned = 0;
|
||||||
|
const remainders: Array<[TaskState, number]> = [];
|
||||||
|
for (const [state, weight] of STATE_WEIGHTS) {
|
||||||
|
const exact = (remaining * weight) / totalWeight;
|
||||||
|
const floor = Math.floor(exact);
|
||||||
|
quotas.set(state, floor);
|
||||||
|
assigned += floor;
|
||||||
|
remainders.push([state, exact - floor]);
|
||||||
|
}
|
||||||
|
remainders.sort((a, b) => b[1] - a[1]);
|
||||||
|
for (let i = 0; i < remaining - assigned; i += 1) {
|
||||||
|
const state = remainders[i % remainders.length]![0];
|
||||||
|
quotas.set(state, (quotas.get(state) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const spawn = (state: TaskState): TaskSummary => {
|
||||||
|
const category = pick(rng, categories);
|
||||||
|
const ext = pick(rng, category.extensions.length > 0 ? category.extensions : ['bin']);
|
||||||
|
const filename = synthFilename(rng, ext);
|
||||||
|
const sizeBytes = logUniform(rng, 50_000, 20_000_000_000);
|
||||||
|
const resumable = rng() > 0.08;
|
||||||
|
const segments = resumable ? pick(rng, [1, 2, 4, 4, 8, 8, 8, 16]) : 1;
|
||||||
|
// Spread creation times over the past 30 days so the table doesn't show one instant.
|
||||||
|
const createdAt = new Date(now - int(rng, 0, 30 * 24 * 3600) * 1000).toISOString();
|
||||||
|
|
||||||
|
const task: TaskSummary = {
|
||||||
|
taskId: randomUUID(),
|
||||||
|
filename,
|
||||||
|
saveDir: category.saveDir,
|
||||||
|
url: `https://${synthHost(rng)}/${category.categoryId}/${filename}`,
|
||||||
|
effectiveUrl: null,
|
||||||
|
sizeBytes,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
state,
|
||||||
|
speedBps: 0,
|
||||||
|
etaSeconds: null,
|
||||||
|
resumable,
|
||||||
|
segments,
|
||||||
|
categoryId: category.categoryId,
|
||||||
|
queueId: null,
|
||||||
|
queuePosition: null,
|
||||||
|
description: rng() > 0.85 ? pick(rng, NOUNS) : null,
|
||||||
|
createdAt,
|
||||||
|
lastTryAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
speedFactor.set(task.taskId, 0.3 + rng() * 1.7);
|
||||||
|
return task;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [state, quota] of quotas) {
|
||||||
|
for (let i = 0; i < quota; i += 1) {
|
||||||
|
const task = spawn(state);
|
||||||
|
switch (state) {
|
||||||
|
case 'complete': {
|
||||||
|
task.downloadedBytes = task.sizeBytes ?? 0;
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
task.completedAt = new Date(
|
||||||
|
new Date(task.createdAt).getTime() + int(rng, 5, 3600) * 1000,
|
||||||
|
).toISOString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'failed': {
|
||||||
|
const code = randomTaskErrorCode(rng, false);
|
||||||
|
task.error = taskErrorFor(rng, code, false);
|
||||||
|
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.6);
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'cancelled': {
|
||||||
|
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.4);
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'paused': {
|
||||||
|
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng());
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'retry_wait': {
|
||||||
|
const code = randomTaskErrorCode(rng, true);
|
||||||
|
task.error = taskErrorFor(rng, code, true);
|
||||||
|
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.5);
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'queued': {
|
||||||
|
// ~80/20 split across the first two queues, matching Main/Sync in the fixtures;
|
||||||
|
// falls back to the single available queue if fewer than two exist.
|
||||||
|
const queue = queues.length > 1 && rng() < 0.8 ? queues[0]! : pick(rng, queues);
|
||||||
|
task.queueId = queue.queueId;
|
||||||
|
const order = queuedOrder.get(queue.queueId)!;
|
||||||
|
order.push(task.taskId);
|
||||||
|
task.queuePosition = order.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reserved active slice: mid-transfer, non-zero progress, so it reads as "already
|
||||||
|
// running" rather than "just started" the moment mockd comes up.
|
||||||
|
for (let i = 0; i < activeSeed; i += 1) {
|
||||||
|
const task = spawn('downloading');
|
||||||
|
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.7);
|
||||||
|
task.lastTryAt = task.createdAt;
|
||||||
|
tasks.push(task);
|
||||||
|
initiallyActive.push(task.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tasks, initiallyActive, queuedOrder, speedFactor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ACTIVE_CAP = 24;
|
||||||
|
|
||||||
|
const FALLBACK_CATEGORIES: readonly Category[] = [
|
||||||
|
{ categoryId: 'compressed', name: 'Compressed', saveDir: '/home/sami/Downloads/Compressed',
|
||||||
|
extensions: ['zip', 'tar', 'gz'], mimeTypes: [], builtin: true, sortOrder: 0 },
|
||||||
|
];
|
||||||
|
const FALLBACK_QUEUES: readonly Queue[] = [
|
||||||
|
{ queueId: 'main', name: 'Main Queue', state: 'running', maxConcurrent: 3,
|
||||||
|
taskIds: [], schedule: null, onComplete: 'nothing' },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user