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-dehydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: none
pull: 518
platforms: windows
user-facing: yes

Windows Cloud Files Free up space, automatic cleanup, and the storage summary now count and free downloaded files instead of finding nothing. Edited, pinned, and open files stay on the computer.
29 changes: 29 additions & 0 deletions docs/windows-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,35 @@ Each case must end with the placeholder in sync at the server's revision.
behavior. These tests do not replace the Explorer and published-MSI acceptance
checks above.

### Dehydration and storage accounting

**Free up space**, automatic cleanup, and the Virtual files storage summary read
allocated bytes and dehydrate placeholders through
[`WindowsCloudDehydration`](../ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudDehydration.kt).
A connected file placeholder is a cloud reparse point, which Java NIO does not
report as a regular file, so both operations classify the path from native
find data with the same rule as the startup enumeration. Allocated bytes come
from `GetCompressedFileSizeW`: a dehydrated placeholder keeps its logical size
and allocates no bytes.

Only an unpinned, in-sync file placeholder that allocates bytes is dehydrated,
so an online-only placeholder costs no native call. As
[`CfDehydratePlaceholder`](https://learn.microsoft.com/en-us/previous-versions/mt827480(v=vs.85))
requires, dehydration holds an exclusive `CfOpenFileWithOplock` handle. When
the oplock cannot be taken (`0x80070020`) or breaks first, or when Windows
refuses a file that became dirty (`0x80070179`), pinned (`0x80070188`), or open
in another application (`0x80070187`), the content stays local and counts as
not freed. Automatic cleanup also keeps a planned file whose known revision,
pin, sync state, or allocated size changed after planning. Freed totals count
the bytes each dehydration actually released.

**Last reviewed: 2026-10-17.** On a local Windows 11 sync root,
`WindowsCloudFilesDehydrationTest` hydrates a synthetic file, observes nonzero
allocated bytes, frees them through Free up space and through automatic cleanup,
and reopens the content. It also keeps a dirty edit, a pinned file, and a file
held open by another process local. This evidence does not cover Explorer's own
Free up space command or a published MSI.

## WinGet delivery

`tools/create-winget-manifests.ps1` generates a three-file WinGet manifest from
Expand Down
4 changes: 2 additions & 2 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteT
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6161
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|1030
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2331
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1023
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2319
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
@@ -0,0 +1,85 @@
package dev.obiente.nextcloudnative.app

import com.sun.jna.Native
import com.sun.jna.WString
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.ptr.IntByReference
import java.nio.file.Path

private const val FILE_ATTRIBUTE_PINNED = 0x0008_0000

/**
* Only an unpinned, in-sync file placeholder may release its local content. Dirty content is the
* only copy of a local edit and pinned content is kept on request, so neither is a candidate.
*
* The decision uses native find data: a connected file placeholder is a cloud reparse point that
* Java NIO reports as "other", never as a regular file.
*/
internal fun windowsCloudDehydratable(entry: WindowsCloudLocalEntry, fileAttributes: Int): Boolean =
entry.file &&
fileAttributes and FILE_ATTRIBUTE_PINNED == 0 &&
entry.inspection.state == WindowsCloudPlaceholderEntryState.InSync

/**
* Windows keeps content that became dirty, pinned or opened by another application after
* [windowsCloudDehydratable] accepted it. The content stays local and nothing is freed.
*/
internal fun isWindowsCloudDehydrationRefusedResult(result: Int): Boolean =
result == 0x80070020.toInt() || // HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION): no exclusive oplock
result == 0x80070179.toInt() || // HRESULT_FROM_WIN32(ERROR_CLOUD_FILE_NOT_IN_SYNC)
result == 0x80070187.toInt() || // HRESULT_FROM_WIN32(ERROR_CLOUD_FILE_IN_USE)
result == 0x80070188.toInt() || // HRESULT_FROM_WIN32(ERROR_CLOUD_FILE_PINNED)
result == 0x80070323.toInt() // HRESULT_FROM_WIN32(ERROR_OPLOCK_HANDLE_CLOSED)

/**
* CfDehydratePlaceholder requires an exclusive handle, and Microsoft recommends an exclusive
* oplock so another application's open breaks it instead of failing. Returns false when Windows
* keeps the content, including when the oplock cannot be taken or breaks before dehydration.
*/
internal fun CldApi.dehydrateExclusively(path: Path): Boolean {
val protectedHandle = WinNT.HANDLEByReference()
val flags = windowsCloudOpenFileFlags(write = false, exclusive = true)
val opened = CfOpenFileWithOplock(WString(path.toWindowsWin32Path()), flags, protectedHandle)
if (isWindowsCloudDehydrationRefusedResult(opened)) return false
checkWindowsCloudHResult(opened, "open a Windows Cloud Files placeholder for dehydration")
try {
// A reference fails once another application's open has broken the oplock.
if (CfReferenceProtectedHandle(protectedHandle.value).toInt() == 0) return false
try {
val handle = CfGetWin32HandleFromProtectedHandle(protectedHandle.value)
val result = CfDehydratePlaceholder(handle, 0L, -1L, 0, null)
if (isWindowsCloudDehydrationRefusedResult(result)) return false
checkWindowsCloudHResult(result, "dehydrate a Windows Cloud Files placeholder")
return true
} finally {
CfReleaseProtectedHandle(protectedHandle.value)
}
} finally {
CfCloseHandle(protectedHandle.value)
}
}

/**
* Dehydrates the files an eviction plan selected, rechecking each at execution time. A file that
* another revision replaced, or that was pinned, edited or resized since planning, is kept.
* Returns the bytes actually released.
*/
internal fun WindowsCloudFilesApi.dehydratePlannedEvictions(
evictions: List<VirtualFileEviction>,
currentFile: (relativePath: String) -> Pair<WindowsCloudFileIdentity, Path>?,
): Long = evictions.sumOf { eviction ->
val (identity, path) = currentFile(eviction.key.relativePath) ?: return@sumOf 0L
val unchanged = identity.remoteRevision == eviction.expectedLocalRevision &&
!isPinned(path) &&
placeholderState(path) == WindowsCloudPlaceholderState.InSync &&
allocatedBytes(path) == eviction.sizeBytes
if (unchanged) dehydrate(path) else 0L
}

/** Bytes allocated on disk; a dehydrated placeholder keeps its logical size but allocates none. */
internal fun KernelFileApi.allocatedFileBytes(path: Path): Long {
val high = IntByReference()
val low = GetCompressedFileSizeW(WString(path.toWindowsWin32Path()), high)
if (low == -1 && Native.getLastError() != 0) return 0L
return ((high.value.toLong() and 0xffff_ffffL) shl 32) or (low.toLong() and 0xffff_ffffL)
}
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,8 @@ internal class JnaWindowsCloudFilesApi(

override fun localChildren(directory: Path): List<Path> = localEntries(directory).map { it.path }

override fun allocatedBytes(path: Path): Long {
if (!Files.isRegularFile(path)) return 0L
val high = IntByReference()
val low = kernelFiles.GetCompressedFileSizeW(WString(path.toWindowsWin32Path()), high)
if (low == -1 && Native.getLastError() != 0) return 0L
return ((high.value.toLong() and 0xffff_ffffL) shl 32) or (low.toLong() and 0xffff_ffffL)
}
override fun allocatedBytes(path: Path): Long =
if (withFindData(path, false) { localEntry(path, it).file }) kernelFiles.allocatedFileBytes(path) else 0L

override fun lastAccessedAtEpochMillis(path: Path): Long =
withFindData(path, 0L) { findData -> findData.ftLastAccessTime.toTime().coerceAtLeast(0L) }
Expand Down Expand Up @@ -528,17 +523,15 @@ internal class JnaWindowsCloudFilesApi(
}

override fun dehydrate(path: Path): Long {
if (!Files.isRegularFile(path)) return 0L
val size = Files.size(path)
withWindowsFileHandle(path, write = true) { handle ->
checkWindowsCloudHResult(
cldApi.CfDehydratePlaceholder(handle, 0L, -1L, 0, null),
"dehydrate a Windows Cloud Files placeholder",
)
}
return size
if (!withFindData(path, false) { windowsCloudDehydratable(localEntry(path, it), it.dwFileAttributes) }) return 0L
// An online-only placeholder needs no native call, so a pass does not scale with the remote tree.
val allocated = kernelFiles.allocatedFileBytes(path).takeIf { it > 0L } ?: return 0L
return if (cldApi.dehydrateExclusively(path)) allocated else 0L
}

private fun localEntry(path: Path, findData: WinBase.WIN32_FIND_DATA): WindowsCloudLocalEntry =
findData.windowsCloudLocalEntry(path, cldApi::CfGetPlaceholderStateFromFindData)

override fun close() {
callbacksByConnection.keys.toList().forEach { key -> runCatching { disconnect(key) } }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ internal interface WindowsCloudFilesApi : AutoCloseable {
fun localContentStamp(path: Path): WindowsCloudLocalContentStamp = nioWindowsCloudLocalContentStamp(path)
fun markInSyncIfUnchanged(path: Path, expected: WindowsCloudLocalContentStamp): WindowsCloudInSyncCommit =
markInSyncWhenStampMatches(path, expected)
/** Returns the bytes released, or 0 when the entry is not an unpinned in-sync file or Windows kept it. */
fun dehydrate(path: Path): Long
}

Expand Down Expand Up @@ -1037,9 +1038,7 @@ internal class WindowsCloudFilesProvider(
if (requestedBytes > 0L && freed >= requestedBytes) return@forEach
val path = localPath(identity)
if (path.startsWith(root) && api.placeholderState(path) == WindowsCloudPlaceholderState.InSync) {
val allocated = api.allocatedBytes(path)
api.dehydrate(path)
freed += allocated
freed += api.dehydrate(path)
}
}
return freed
Expand Down Expand Up @@ -1078,20 +1077,9 @@ internal class WindowsCloudFilesProvider(
availableFreeBytes = Files.getFileStore(root).usableSpace,
nowEpochMillis = nowEpochMillis,
)
var freed = 0L
plan.evictions.forEach { eviction ->
val identity = knownIdentities[eviction.key.relativePath] ?: return@forEach
val path = localPath(identity)
if (
!api.isPinned(path) &&
api.placeholderState(path) == WindowsCloudPlaceholderState.InSync &&
api.allocatedBytes(path) == eviction.sizeBytes
) {
api.dehydrate(path)
freed += eviction.sizeBytes
}
return api.dehydratePlannedEvictions(plan.evictions) { relative ->
knownIdentities[relative]?.let { identity -> identity to localPath(identity) }
}
return freed
}

fun summary(): WindowsCloudFilesSummary = synchronized(corruptRootStableAccessLock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ internal fun WinBase.WIN32_FIND_DATA.windowsCloudInspection(
)
}

/** Classifies one native find-data record; see [windowsCloudLocalEntry]. */
internal fun WinBase.WIN32_FIND_DATA.windowsCloudLocalEntry(
path: Path,
placeholderState: (Pointer) -> Int,
): WindowsCloudLocalEntry =
windowsCloudLocalEntry(path, dwFileAttributes, dwReserved0, windowsCloudInspection(placeholderState))

/**
* Enumerates resident children with their find data in one native call, without asking Cloud
* Files to populate remote folders.
Expand All @@ -132,10 +139,7 @@ internal fun windowsCloudLocalEntries(
do {
data.read()
val name = Native.toString(data.cFileName)
if (name != "." && name != "..") {
val inspection = data.windowsCloudInspection(placeholderState)
add(windowsCloudLocalEntry(directory.resolve(name), data.dwFileAttributes, data.dwReserved0, inspection))
}
if (name != "." && name != "..") add(data.windowsCloudLocalEntry(directory.resolve(name), placeholderState))
} while (Kernel32.INSTANCE.FindNextFile(handle, data.pointer))
val error = Native.getLastError()
if (error != 18) throw WindowsCloudLocalScanException(error)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package dev.obiente.nextcloudnative.app

import java.nio.file.Path
import java.util.Base64
import java.util.concurrent.TimeUnit

/**
* Native Cloud Files tests act on a sync root from another process, as Explorer or an editor
* would. Cloud Files sends no close notification for I/O by the connected provider's own process.
*/
internal fun runExternalPowerShell(script: String, environment: Map<String, String>) {
val process = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
.redirectErrorStream(true)
.apply { environment().putAll(environment) }
.start()
val output = process.inputStream.bufferedReader().readText()
check(process.waitFor(30, TimeUnit.SECONDS) && process.exitValue() == 0) { "External process failed: $output" }
}

/** Overwrites from offset zero without truncating, like an editor saving a longer file. */
internal fun editInPlaceFromAnotherProcess(path: Path, bytes: ByteArray) = runExternalPowerShell(
"\$s = [IO.File]::Open(\$env:NATIVE_EDIT_PATH, 'Open', 'Write', 'ReadWrite');" +
"\$b = [Convert]::FromBase64String(\$env:NATIVE_EDIT_BYTES); \$s.Write(\$b, 0, \$b.Length); \$s.Close()",
mapOf(
"NATIVE_EDIT_PATH" to path.toAbsolutePath().toString(),
"NATIVE_EDIT_BYTES" to Base64.getEncoder().encodeToString(bytes),
),
)

/**
* Holds a read handle in another process until closed, as an open document would. [share] is the
* .NET FileShare mode granted to other openers.
*/
internal class ExternalOpenHandle(path: Path, share: String = "ReadWrite") : AutoCloseable {
private val process = ProcessBuilder(
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"\$s = [IO.File]::Open(\$env:NATIVE_HOLD_PATH, 'Open', 'Read', '$share'); " +
"[Console]::Out.WriteLine('open'); [Console]::Out.Flush(); [void][Console]::In.ReadLine(); \$s.Close()",
).redirectErrorStream(true)
.apply { environment()["NATIVE_HOLD_PATH"] = path.toAbsolutePath().toString() }
.start()

init {
val ready = process.inputStream.bufferedReader().readLine()
check(ready == "open") { "The external handle did not open: $ready" }
}

override fun close() {
process.outputStream.bufferedWriter().use { it.write("close"); it.newLine() }
check(process.waitFor(30, TimeUnit.SECONDS) && process.exitValue() == 0) { "The external handle did not close." }
}
}
Loading
Loading