Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changes/unreleased/windows-cloud-files-streamed-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: none
pull: 519
platforms: windows
user-facing: yes

Opening a cloud-only file in File Explorer now downloads it with one streamed request instead of one server request per 4 MiB, so large photos open about as fast as a normal download.
4 changes: 2 additions & 2 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|876
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6161
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6115
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1023
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2319
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2275
ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt|2479
ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt|2888
ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt|2149
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,33 @@ class NextcloudFileRangeSession(
private val readBlock: suspend (offset: Long, length: Int) -> ByteArray,
private val closeBlock: () -> Unit = {},
private val beginUseBlock: () -> (() -> Unit)? = { {} },
private val streamBlock: (
suspend (offset: Long, length: Long, chunkBytes: Int, onChunk: (Long, ByteArray) -> Unit) -> Unit
)? = null,
) : AutoCloseable {
init {
require(size > 0L) { "A file range session must have a positive size." }
}

suspend fun read(offset: Long, length: Int): ByteArray = readBlock(offset, length)

/**
* Delivers [length] bytes from [offset] in order, as chunks of [chunkBytes] except a shorter
* final chunk. A platform with streaming support serves the whole range with one request;
* otherwise this falls back to one bounded read per chunk.
*/
suspend fun stream(offset: Long, length: Long, chunkBytes: Int, onChunk: (offset: Long, bytes: ByteArray) -> Unit) {
require(offset >= 0L && length > 0L && chunkBytes > 0) { "The file range stream is invalid." }
require(offset <= size && length <= size - offset) { "The requested file range exceeds the file size." }
streamBlock?.let { stream -> return stream(offset, length, chunkBytes, onChunk) }
var position = 0L
while (position < length) {
val chunk = minOf(chunkBytes.toLong(), length - position).toInt()
onChunk(offset + position, read(offset + position, chunk))
position += chunk
}
}

fun beginUse(): (() -> Unit)? = beginUseBlock()

override fun close() = closeBlock()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package dev.obiente.nextcloudnative.app

import java.io.InputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.ResponseBody

/**
* Generation-pinned Files WebDAV range requests for one [NextcloudFileRangeSession].
*
* Every request sends If-Match with the pinned ETag, requires HTTP 206 with exactly the requested
* Content-Range, and fails when the body is shorter or longer than that range. [stream] serves a
* whole range with one response and hands it over chunk by chunk as bytes arrive, so a large
* hydration costs one server round trip instead of one per chunk.
*/
internal class DesktopFileRangeTransport(
private val client: OkHttpClient,
private val url: String,
private val authorization: String,
private val userAgent: String,
private val etag: String,
private val size: Long,
private val closed: AtomicBoolean,
private val recordFailure: (startedNanos: Long, attempt: JvmNetworkRequestAttempt, failure: Throwable) -> Unit,
) {
private val activeCall = AtomicReference<Call?>(null)

fun cancel() {
activeCall.get()?.cancel()
}

fun read(offset: Long, length: Int): ByteArray = execute(offset, length.toLong()) { body ->
body.byteStream().use { input -> input.readExactly(length).also { input.requireEnd() } }
}

fun stream(offset: Long, length: Long, chunkBytes: Int, onChunk: (offset: Long, bytes: ByteArray) -> Unit) {
require(chunkBytes > 0) { "The file range chunk size must be greater than zero." }
execute(offset, length) { body ->
body.byteStream().use { input ->
var position = 0L
while (position < length) {
val chunk = input.readExactly(minOf(chunkBytes.toLong(), length - position).toInt())
onChunk(offset + position, chunk)
Comment thread
veryCrunchy marked this conversation as resolved.
position += chunk.size
}
input.requireEnd()
}
}
}

private fun <T> execute(offset: Long, length: Long, consume: (ResponseBody) -> T): T {
check(!closed.get()) { "The file range session is closed." }
require(offset >= 0L) { "The file range offset must not be negative." }
require(length > 0L) { "The file range length must be greater than zero." }
require(offset <= size && length <= size - offset) { "The requested file range exceeds the file size." }
val endInclusive = Math.addExact(offset, length - 1L)
val started = System.nanoTime()
val networkAttempt = JvmNetworkRequestAttempt()
val request = Request.Builder()
.url(url)
.get()
.header("Accept", "application/octet-stream")
.header("Range", "bytes=$offset-$endInclusive")
.header("If-Match", etag)
.header("User-Agent", userAgent)
.header("Authorization", authorization)
.tag(JvmNetworkRequestAttempt::class.java, networkAttempt)
.build()
val call = client.newCall(request)
check(activeCall.compareAndSet(null, call)) { "Only one file range read can run at a time." }
if (closed.get()) call.cancel()
try {
call.execute().use { response ->
if (response.code != 206) throw NextcloudFileRangeHttpException(response.code)
check(isExactHttpByteContentRange(response.header("Content-Range"), offset, endInclusive)) {
"The server returned a different file range than requested."
}
val contentLength = response.body.contentLength()
if (contentLength in 0 until length) throw JvmNetworkResponseTruncatedIOException()
check(contentLength == -1L || contentLength == length) { "The server returned an incomplete file range." }
return consume(response.body)
}
} catch (failure: Throwable) {
recordFailure(started, networkAttempt, failure)
throw failure
} finally {
activeCall.compareAndSet(call, null)
}
}

private fun InputStream.readExactly(length: Int): ByteArray {
val bytes = ByteArray(length)
var filled = 0
while (filled < length) {
val read = read(bytes, filled, length - filled)
if (read < 0) throw JvmNetworkResponseTruncatedIOException()
filled += read
}
return bytes
}

private fun InputStream.requireEnd() {
check(read() == -1) { "The server returned more data than the requested file range." }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.prefs.Preferences
import javax.swing.JFileChooser
import javax.xml.parsers.DocumentBuilderFactory
Expand All @@ -53,7 +52,6 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -4445,73 +4443,29 @@ class DesktopNextcloudServices(
synchronized(fileRangeSessionLock) {
check(!sessionClearing) { "The account session is closing." }
}
val safeEtag = requireSafeFileRangeEtag(expectedEtag)
val closed = AtomicBoolean(false)
val activeCall = AtomicReference<Call?>(null)
val transport = DesktopFileRangeTransport(
client = noRedirectHttpClient,
url = buildNextcloudFileUrl(session.serverUrl, userId, path),
authorization = "Basic " + Base64.getEncoder()
.encodeToString("${session.loginName}:${session.appPassword}".toByteArray()),
userAgent = USER_AGENT,
etag = requireSafeFileRangeEtag(expectedEtag),
size = size,
closed = closed,
) { started, attempt, failure ->
recordDesktopStreamingFailure(session, "file_range", started, attempt, failure)
}
lateinit var rangeSession: NextcloudFileRangeSession
rangeSession = NextcloudFileRangeSession(
size = size,
readBlock = { offset, length ->
withContext(Dispatchers.IO) {
check(!closed.get()) { "The file range session is closed." }
require(offset >= 0L) { "The file range offset must not be negative." }
require(length > 0) { "The file range length must be greater than zero." }
require(offset <= size && length.toLong() <= size - offset) {
"The requested file range exceeds the file size."
}
val endInclusive = Math.addExact(offset, length.toLong() - 1L)
val builder = Request.Builder()
.url(buildNextcloudFileUrl(session.serverUrl, userId, path))
.get()
.header("Accept", "application/octet-stream")
.header("Range", "bytes=$offset-$endInclusive")
.header("If-Match", safeEtag)
.header("User-Agent", USER_AGENT)
val started = System.nanoTime()
val networkAttempt = JvmNetworkRequestAttempt()
builder.tag(JvmNetworkRequestAttempt::class.java, networkAttempt)
val encoded = Base64.getEncoder()
.encodeToString("${session.loginName}:${session.appPassword}".toByteArray())
builder.header("Authorization", "Basic $encoded")
val call = noRedirectHttpClient.newCall(builder.build())
check(activeCall.compareAndSet(null, call)) { "Only one file range read can run at a time." }
if (closed.get()) call.cancel()
try {
call.execute().use { response ->
if (response.code != 206) {
throw NextcloudFileRangeHttpException(response.code)
}
check(isExactHttpByteContentRange(response.header("Content-Range"), offset, endInclusive)) {
"The server returned a different file range than requested."
}
val responseBody = response.body
val contentLength = responseBody.contentLength()
if (contentLength in 0 until length.toLong()) {
throw JvmNetworkResponseTruncatedIOException()
}
check(contentLength == -1L || contentLength == length.toLong()) {
"The server returned an incomplete file range."
}
responseBody.byteStream().readBounded(length.toLong())
.requireExactJvmNetworkResponseBytes(length)
}
} catch (failure: Throwable) {
recordDesktopStreamingFailure(
session = session,
streamKind = "file_range",
startedNanos = started,
attempt = networkAttempt,
failure = failure,
)
throw failure
} finally {
activeCall.compareAndSet(call, null)
}
}
readBlock = { offset, length -> withContext(Dispatchers.IO) { transport.read(offset, length) } },
streamBlock = { offset, length, chunkBytes, onChunk ->
withContext(Dispatchers.IO) { transport.stream(offset, length, chunkBytes, onChunk) }
},
closeBlock = {
if (closed.compareAndSet(false, true)) {
activeCall.get()?.cancel()
transport.cancel()
synchronized(fileRangeSessionLock) {
activeFileRangeSessions.remove(rangeSession)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package dev.obiente.nextcloudnative.app

import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

internal const val WINDOWS_CLOUD_ALIGNMENT = 4 * 1024L

/** Bytes handed to CfExecute at a time; small enough that an opening app sees data early. */
internal const val WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES = 1024 * 1024

/**
* Cancellation for one Cloud Files callback request. [cancel] also closes the read handle that
* the request has open, so a hydration blocked on a slow or stalled response stops at once
* instead of waiting for the next chunk or a network timeout.
*/
internal class WindowsCloudRequestCancellation {
private val cancelled = AtomicBoolean(false)
private val activeHandle = AtomicReference<AutoCloseable?>(null)

fun get(): Boolean = cancelled.get()

fun cancel() {
cancelled.set(true)
activeHandle.getAndSet(null)?.close()
}

/** Runs [block] with [handle] closable by [cancel]; a request cancelled first never starts. */
fun <T> whileOpen(handle: AutoCloseable, block: () -> T): T {
activeHandle.set(handle)
try {
if (cancelled.get()) throw CancellationException("Windows cancelled the request.")
return block()
} finally {
activeHandle.compareAndSet(handle, null)
}
}
}

/** Read access to one pinned remote file generation for hydration. */
internal interface WindowsCloudFileReadHandle : AutoCloseable {
val size: Long
fun read(offset: Long, length: Int): ByteArray

/**
* Delivers a range in order as [chunkBytes] chunks. Backends that can stream serve it with one
* request; this fallback costs one request per chunk.
*/
fun stream(offset: Long, length: Long, chunkBytes: Int, onChunk: (offset: Long, bytes: ByteArray) -> Unit) {
var position = 0L
while (position < length) {
val chunk = minOf(chunkBytes.toLong(), length - position).toInt()
onChunk(offset + position, read(offset + position, chunk))
position += chunk
}
}
}

internal data class WindowsCloudHydrationRange(val offset: Long, val length: Int) {
init {
require(offset >= 0L && offset % WINDOWS_CLOUD_ALIGNMENT == 0L)
require(length > 0)
}
}

/** Produces CfExecute-compatible chunks; only the final range may end unaligned at EOF. */
internal fun planWindowsCloudHydration(
requiredOffset: Long,
requiredLength: Long,
fileSize: Long,
maximumChunkBytes: Int = WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES,
): List<WindowsCloudHydrationRange> {
require(requiredOffset >= 0L && requiredLength > 0L && fileSize > 0L)
require(
maximumChunkBytes.toLong() >= WINDOWS_CLOUD_ALIGNMENT &&
maximumChunkBytes.toLong() % WINDOWS_CLOUD_ALIGNMENT == 0L,
)
val requiredEnd = minOf(fileSize, Math.addExact(requiredOffset, requiredLength))
require(requiredOffset < requiredEnd)
val transferEnd = if (requiredEnd == fileSize) {
fileSize
} else {
minOf(fileSize, Math.addExact(requiredEnd, WINDOWS_CLOUD_ALIGNMENT - 1L) / WINDOWS_CLOUD_ALIGNMENT * WINDOWS_CLOUD_ALIGNMENT)
}
var cursor = requiredOffset - requiredOffset % WINDOWS_CLOUD_ALIGNMENT
return buildList {
while (cursor < transferEnd) {
val remaining = transferEnd - cursor
val length = minOf(maximumChunkBytes.toLong(), remaining).toInt()
add(WindowsCloudHydrationRange(cursor, length))
cursor += length
}
}
}

/**
* Serves one FETCH_DATA request. Windows asks for a whole file when an application opens it, so
* the aligned span is fetched as one streamed range rather than one server request per chunk,
* and each chunk is transferred as soon as it arrives. Cancelling closes [source], which stops a
* blocked read; a chunk that arrives after cancellation is never transferred. Any other mismatch
* fails the request.
*/
internal fun hydrateWindowsCloudRange(
source: WindowsCloudFileReadHandle,
requiredOffset: Long,
requiredLength: Long,
cancellation: WindowsCloudRequestCancellation,
transfer: (offset: Long, bytes: ByteArray) -> Unit,
) {
val plan = planWindowsCloudHydration(requiredOffset, requiredLength, source.size)
val start = plan.first().offset
val end = plan.last().let { it.offset + it.length }
var next = 0
cancellation.whileOpen(source) {
source.stream(start, end - start, WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES) { offset, bytes ->
if (cancellation.get()) throw CancellationException("Windows cancelled the hydration request.")
val expected = plan.getOrNull(next++)
check(expected != null && offset == expected.offset && bytes.size == expected.length) {
"The remote file returned an incomplete range."
}
transfer(offset, bytes)
}
}
check(next == plan.size) { "The remote file returned an incomplete range." }
}
Loading
Loading