Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/design/torrent-control-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Torrent control protocol, version 1

This contract implements the API boundary decision in [roadmap #162][roadmap]. The types live in
`library:api`, so WebAssembly and remote clients can use them without linking a torrent engine.
This PR defines inspection, capability negotiation, ordering, and command preconditions. Runtime
wiring and the typed mutation methods ship with their capabilities, with SDK/HTTP/SSE parity checked
in roadmap step 27. A contract declaration is not an implemented runtime capability.

## Availability and negotiation

`KetchApi.torrents` is nullable and defaults to null for existing backend implementations. Null means
no typed control protocol, not no torrent downloader. Local and remote adapters expose a controller
only when they implement inspection. They advertise only executable features. The backend's
platform determines capabilities: a browser connected to a daemon can have capabilities unavailable
in a local browser. Protocol major versions other than 1 disable all known features in this SDK.
Unknown capability strings survive decoding. New mandatory semantics require a new major version;
optional fields may be added without changing the interpretation of existing fields.

Page sizes and subscription limits are explicit bounded ceilings. Oversized requests fail rather
than being silently truncated. Cursors bind to task, authenticated principal, filters, sort, and
revision. Mutations that invalidate a page return a stale-cursor error; no mixed-revision file list.
Peer addresses and other sensitive details are requested separately, not broadcast in every event.

## State and completion

`TorrentSnapshot` is a bounded aggregate. Metadata-unavailable counters are null, not invented zeroes.
Selected verified bytes, wanted bytes, payload received, uploaded payload, discarded payload, and
protocol overhead are separate. Virtual padding never counts toward user payload. A recheck can
reduce verified progress. Counters cannot exceed their content bounds or become negative.

A selection has a monotonically increasing generation. Completion belongs to that generation and
can coexist with seeding. Expanding a completed selection requires an explicit restart; existing
`awaitCompletion` callers retain their original generation. Empty selections can complete once
metadata is known. They do not imply possession of the torrent's payload. Download queue slots and
seed slots remain separate. Legacy upload remains disabled unless explicitly enabled; the named
production profile can enable uploads while downloading. Post-completion seeding remains opt-in.

## Revision and reconnect ordering

Every published task state has an opaque catalog epoch and a nonnegative sequence. Sequences never
wrap. A backend changes the epoch if it loses monotonic state, including restoration of an older
catalog. Epoch strings are compared for equality, never sorted. Full snapshots can jump forward;
deltas require their exact predecessor. Older or duplicate same-epoch messages are ignored. Gaps
in delta history and epoch changes require an authoritative resync.

`compareIncoming` implements this merge decision. It assumes messages belong to the active
connection generation. Adapters separately reject late responses from old connections. Reconnect
cancels the old subscription, fetches authoritative state, and installs the new epoch under a new
connection generation. An old HTTP response cannot overwrite a newer event in the same epoch.
An HTTP response from an obsolete connection cannot reset the new epoch.

Inspection streams emit full snapshots and conflate slow consumers to the latest snapshot. Null
means a tombstone and completes the task stream. A tombstone is terminal for that task ID, which is
never reused. The client rejects all subsequent responses for that removed task in the current
connection generation. Transport failures terminate observation; reconnect is explicit. Persisted
idempotency outcomes for removed tasks remain available during the supported retry window.

## Mutations and operations

Each existing-task mutation carries `TorrentCommandContext`: an idempotency key and the expected
revision. Scope the key to principal and task. Check the persisted retry ledger before revision
validation: an exact retry returns its original result, even if state has since advanced. Reusing
a key for a different canonical request fails. New requests with stale revisions return a conflict
and current revision, without partial mutation. Concurrent duplicates execute only once.

Long-running operations return an operation ID with queued/running/succeeded/failed/canceled state,
progress, and cancellation support. Acceptance never implies completion. A mutation's committed
outcome and idempotency record are persisted atomically. Ledger capacity is bounded; reject new
admissions rather than evicting entries still inside the advertised retry window. After expiration,
a retry fails explicitly instead of unexpectedly executing an old destructive command again.

The typed command families and required semantics are:

| Command family | Contract |
| --- | --- |
| Selection | Stable file IDs; priorities; sequential mode; verified-range deadlines; explicit restart |
| Transfer | Download/upload limits; pause/resume; runtime admission applies to both directions |
| Seeding | Ratio and duration goals; start/stop; separate seed queue; no implicit upload opt-in |
| Integrity | Recheck/import as cancelable operations; publish only verified state |
| Trackers | Edit ordered tiers and reannounce; retain private/proxy policy and credential context |
| Storage | Move/rename as recoverable operations; reject unsafe paths and ownership conflicts |
| Removal | Explicit keep-data or remove-owned-data policy; never delete unrelated files |
| Export | Export metainfo/magnets without publication, tracker requests, or starting seeding |
| Creation | Stable source snapshot, format and piece policy; cancelable; no implicit publication |
| Streaming | Authenticated task/file/range reads; only verified ranges; bounded lifetime and buffers |

Creation has no existing task revision; its request is scoped to the principal's creation ledger.
Operation cancellation has its own idempotency key and operation revision. Errors distinguish
unsupported capability, invalid input, conflict, policy denial, resource exhaustion, storage failure,
integrity failure, not found, and cancellation. They must not expose tracker credentials or raw
untrusted paths. Unsupported requests fail before any side effect or network access.

## Migration gates

Existing `KetchApi` implementations compile with the default null controller. New clients connected
to old daemons keep legacy downloads working and hide unsupported controls. Adding this API does not
change the existing resume format. Checkpoint v2 migration retains old state until verified commit,
rehashes legacy v1 data, and preserves unsupported native blobs for explicit recovery. No optimistic
conversion may claim unverified bytes. Format identity and checkpoint implementation remain in
roadmap steps 04–09. These gates are pending until exercised by their implementation tests.

[roadmap]: https://github.com/linroid/Ketch/issues/162
14 changes: 13 additions & 1 deletion docs/plans/pure-kotlin-torrent-v2-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ span multiple PRs; it is complete only when all of its acceptance gates have evi
| Slice | Branch | Scope |
| --- | --- | --- |
| 01a | `torrent-v2-01-foundation` | Pinned reference clients, executed-scenario evidence, CI gate |
| 01b | `torrent-v2-01-contracts` (planned) | Control/state/capability and compatibility contracts |
| 01b | `torrent-v2-01-contracts` | Control/state/capability and compatibility contracts |
| 01c | Planned | Resource admission profiles, deterministic harness, performance baseline |

Slice 01a covers two existing v1 interoperability scenarios. Missing Transmission fails required
Expand All @@ -34,3 +34,15 @@ Steps 02–30 remain pending. Runtime behavior and the legacy upload default are

CI artifacts provide revision-specific results. Local fixture timings are correctness test durations,
not throughput or memory benchmarks; the production performance baseline is still pending.

## Slice 01b

The [control protocol contract](../design/torrent-control-contract.md) specifies capabilities,
revision/reconnect ordering, completion generations, mutation semantics, and compatibility gates.
The API module supplies bounded inspection models, capability negotiation, command preconditions,
and revision merge decisions. `KetchApi.torrents` defaults to null; existing local/remote backends
continue their legacy behavior until the runtime adapters are implemented.

Validation: API tests pass on JVM and JavaScript; core and remote JVM implementations compile.
Mutation implementations, paginated detail endpoints, operation ledgers, and runtime adapters
remain pending; their declarations and tests ship with the respective implementation slices.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.linroid.ketch.api

import com.linroid.ketch.api.torrent.TorrentController
import kotlinx.coroutines.flow.StateFlow

/**
Expand All @@ -11,6 +12,12 @@ interface KetchApi {
/** Human-readable label: "Core" or "Remote · host:port". */
val backendLabel: String

/**
* Optional typed torrent controls exposed by this backend. Null preserves compatibility with
* backends that support legacy torrent downloads but have not implemented the control protocol.
*/
val torrents: TorrentController? get() = null

/** Reactive task list updated on any state change. */
val tasks: StateFlow<List<DownloadTask>>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.linroid.ketch.api.torrent

import kotlinx.serialization.Serializable

/** Stable capability names; unknown advertised names are retained for forward compatibility. */
enum class TorrentCapability(val wireName: String) {
INSPECT("inspect"),
FILE_SELECTION("file-selection"),
STREAMING("verified-streaming"),
TRANSFER_LIMITS("transfer-limits"),
SEEDING("seeding"),
RECHECK("recheck"),
TRACKERS("trackers"),
RELOCATE("relocate"),
RENAME("rename"),
IMPORT("import"),
EXPORT("export"),
CREATE("create"),
REMOVE_OWNED_DATA("remove-owned-data"),
V1("v1"),
V2("v2"),
HYBRID("hybrid"),
UTP("utp"),
ENCRYPTION("mse-pe"),
PROXY("proxy"),
}

/**
* Capabilities of the connected backend, not the frontend platform.
*
* Names are strings so a newer backend can advertise features an older SDK does not know.
* An incompatible major version must never be interpreted as supporting a known command.
* Limits are negotiated ceilings, not a promise that admission will succeed under current load.
* [backgroundTransfers] describes unattended execution; foreground transfers may still work.
*/
@Serializable
data class TorrentCapabilities(
val protocolMajor: Int = 1,
val names: Set<String> = emptySet(),
val maxPageSize: Int = 100,
val maxSubscriptions: Int = 16,
val backgroundTransfers: Boolean = false,
) {
init {
require(protocolMajor > 0)
require(names.size <= 128 && names.all { it.length in 1..64 })
require(maxPageSize in 1..1000)
require(maxSubscriptions in 1..1024)
}

/** Whether this SDK can safely use the advertised feature. Unknown major versions fail closed. */
fun supports(capability: TorrentCapability): Boolean =
protocolMajor == 1 && capability.wireName in names
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.linroid.ketch.api.torrent

import kotlinx.coroutines.flow.Flow

/**
* Backend-owned torrent inspection and capability negotiation.
*
* Obtain this optional controller from KetchApi. A null controller means the backend does not
* expose this protocol; it does not imply that legacy torrent downloads are unavailable.
* Runtime implementations and mutation commands are introduced with their roadmap capabilities.
* Implementations advertise only features they can execute, never planned features.
*/
interface TorrentController {
/** Negotiate before subscribing or issuing feature-specific requests. */
suspend fun capabilities(): TorrentCapabilities

/**
* Get an authoritative summary, or null if the task no longer exists or is not a torrent.
* Callers must merge concurrent HTTP/event results using [TorrentRevision.compareIncoming].
*/
suspend fun snapshot(taskId: String): TorrentSnapshot?

/**
* Observe full snapshots for one task, starting with its current value. Null is a tombstone and
* completes the stream. Unknown tasks emit null and complete. The backend bounds subscriptions;
* each slow subscriber receives the latest full snapshot, not an unbounded queue of updates.
*
* The adapter cancels old subscriptions before reconnecting, obtains a new authoritative
* snapshot,
* and discards all late responses from the old connection generation. Revisions within an epoch
* never decrease. Transport errors terminate the stream and require explicit reconnect.
*/
fun observe(taskId: String): Flow<TorrentSnapshot?>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.linroid.ketch.api.torrent

import kotlinx.serialization.Serializable

/**
* Revision within one backend state incarnation. The opaque [epoch] changes whenever the backend
* cannot preserve monotonic revisions (including restoring an older catalog). Counters never wrap.
* Epochs cannot be ordered; a different epoch requires an authoritative reconnect snapshot.
*/
@Serializable
data class TorrentRevision(val epoch: String, val sequence: Long) {
init {
require(epoch.isNotBlank() && epoch.length <= 128)
require(sequence >= 0)
}
}

/** Result of comparing an incoming snapshot/event against the current task view. */
enum class TorrentRevisionDecision {
APPLY,
IGNORE,
RESYNC,
}

/**
* Decide whether to apply a revision from the current connection generation.
*
* A delta requires an exact predecessor; full snapshots may jump forward. Duplicate/older data
* never replaces newer state. A changed epoch requires reconnect initialization, not implicit
* replacement. The caller must separately discard responses from obsolete connection generations.
*/
fun TorrentRevision.compareIncoming(
incoming: TorrentRevision,
predecessor: TorrentRevision? = null,
): TorrentRevisionDecision = when {
incoming.epoch != epoch -> TorrentRevisionDecision.RESYNC
incoming.sequence <= sequence -> TorrentRevisionDecision.IGNORE
predecessor != null && predecessor != this -> TorrentRevisionDecision.RESYNC
else -> TorrentRevisionDecision.APPLY
}

/**
* Optimistic concurrency and retry identity for one mutation of an existing task.
*
* The backend scopes [idempotencyKey] to the authenticated principal and task. An exact retry
* returns
* the original outcome; reuse with a different command fails. A new command requires the current
* [expectedRevision]. Check the retry ledger before checking the revision. Long-running operations
* return their operation ID; accepting a command does not imply completion.
*/
@Serializable
data class TorrentCommandContext(
val idempotencyKey: String,
val expectedRevision: TorrentRevision,
) {
init {
require(idempotencyKey.isNotBlank() && idempotencyKey.length <= 128)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.linroid.ketch.api.torrent

import kotlinx.serialization.Serializable

/** Transfer lifecycle is independent of the download task's selected-data completion. */
@Serializable
enum class TorrentActivity {
RESOLVING,
CHECKING,
QUEUED,
DOWNLOADING,
SEEDING,
PAUSED,
STOPPED,
FAILED,
}

/**
* Aggregate counters. Payload excludes padding and protocol overhead; retries/discards are
* separate.
* [selectedVerifiedBytes] can decrease after selection changes or a failed recheck. Completion is
* scoped to the snapshot's selection generation. Ratio is undefined when downloaded payload is
* zero.
*/
@Serializable
data class TorrentCounters(
val totalPayloadBytes: Long,
val wantedBytes: Long,
val selectedVerifiedBytes: Long,
val receivedPayloadBytes: Long,
val uploadedPayloadBytes: Long,
val discardedPayloadBytes: Long,
val protocolBytes: Long,
val downloadBytesPerSecond: Long,
val uploadBytesPerSecond: Long,
val seedSeconds: Long,
) {
init {
require(totalPayloadBytes >= 0 && wantedBytes in 0..totalPayloadBytes)
require(selectedVerifiedBytes in 0..wantedBytes)
require(receivedPayloadBytes >= 0 && uploadedPayloadBytes >= 0)
require(discardedPayloadBytes >= 0 && protocolBytes >= 0)
require(downloadBytesPerSecond >= 0 && uploadBytesPerSecond >= 0 && seedSeconds >= 0)
}
}

/**
* Bounded task summary. Peer/file lists are paginated separately at the same revision.
* Metadata may be absent while resolving; [counters] then remains null rather than implying zero.
* A completed selection does not imply full-content availability or stopped seeding.
*/
@Serializable
data class TorrentSnapshot(
val taskId: String,
val revision: TorrentRevision,
val activity: TorrentActivity,
val selectionGeneration: Long,
val selectionComplete: Boolean,
val counters: TorrentCounters? = null,
) {
init {
require(taskId.isNotBlank() && taskId.length <= 128)
require(selectionGeneration >= 0)
require(!selectionComplete || counters != null &&
counters.selectedVerifiedBytes == counters.wantedBytes)
}
}

/** Bounded page request with opaque cursors, invalidated by relevant mutations. */
@Serializable
data class TorrentPageRequest(val limit: Int = 100, val cursor: String? = null) {
init {
require(limit in 1..1000)
require(cursor == null || cursor.isNotBlank() && cursor.length <= 4096)
}
}
Loading
Loading