Skip to content

Commit 03cd59a

Browse files
authored
Fix stale native library cache keeping AVX build on old CPUs (#406)
NativeLibraryLoader validated its persistent cache by file size only. The AVX and SSE2 builds of WinTray.dll are both 23552 bytes, so after upgrading from a build that crashed on pre-2011 CPUs (no AVX) the loader kept serving the stale cached library and the crash survived the fix. Validate the cache by SHA-256 content hash instead, and add a CI guard that disassembles the built x64 DLL and fails if any AVX instruction is present (issue #401).
1 parent eac08a1 commit 03cd59a

6 files changed

Lines changed: 111 additions & 5 deletions

File tree

.github/workflows/build-natives.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,31 @@ jobs:
111111
ls -la build/nativeLibs/win32-x86-64/
112112
ls -la build/nativeLibs/win32-arm64/
113113
114+
# Regression guard for issue #401: WinTray.dll must stay AVX-free so it does
115+
# not crash with EXCEPTION_ILLEGAL_INSTRUCTION on pre-2011 CPUs (no AVX).
116+
# AVX/AVX2 are VEX-encoded; their mnemonics start with 'v' (vpxor, vmovdqu,
117+
# vzeroupper, vxorps, ...). No legacy x86-64 instruction MSVC emits for C code
118+
# starts with 'v', so any such mnemonic in .text means AVX leaked in.
119+
- name: Verify x64 WinTray.dll is AVX-free (issue #401)
120+
shell: pwsh
121+
run: |
122+
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
123+
$vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
124+
if (-not $vsPath) { throw "Visual Studio installation not found" }
125+
$dumpbin = Get-ChildItem -Path "$vsPath\VC\Tools\MSVC" -Recurse -Filter dumpbin.exe |
126+
Where-Object { $_.FullName -match '\\Hostx64\\x64\\' } | Select-Object -First 1
127+
if (-not $dumpbin) { throw "dumpbin.exe not found" }
128+
Write-Host "Using $($dumpbin.FullName)"
129+
$disasm = & $dumpbin.FullName /DISASM:NOBYTES "build/nativeLibs/win32-x86-64/WinTray.dll"
130+
$avx = $disasm | Select-String -Pattern '^\s+[0-9A-Fa-f]+:\s+v[a-z]' |
131+
Where-Object { $_.Line -notmatch '\bv(err|erw)\b' }
132+
if ($avx) {
133+
Write-Host "AVX instructions found in WinTray.dll (x64):"
134+
$avx | Select-Object -First 20 | ForEach-Object { Write-Host $_.Line.Trim() }
135+
throw "WinTray.dll (x64) contains AVX instructions; it would crash on CPUs without AVX (issue #401). Do not add /arch:AVX* to CMakeLists."
136+
}
137+
Write-Host "OK: no AVX instructions found in x64 WinTray.dll"
138+
114139
- name: Upload Windows x64 library
115140
uses: actions/upload-artifact@v4
116141
with:

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ kotlin {
5252
implementation(libs.nucleus.core.runtime)
5353
implementation(libs.nucleus.darkmode.detector)
5454
}
55+
jvmTest.dependencies {
56+
implementation(kotlin("test"))
57+
}
5558
}
5659
}
5760

src/jvmMain/kotlin/com/kdroid/composetray/utils/JarResourceExtractor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ internal fun extractToTempIfDifferent(jarPath: String): File? {
8888
}
8989

9090
// Extension to calculate SHA-256 of a file
91-
private fun File.sha256(): String = inputStream().use { it.sha256() }
91+
internal fun File.sha256(): String = inputStream().use { it.sha256() }
9292

9393
// Extension to calculate SHA-256 of an InputStream
94-
private fun InputStream.sha256(): String {
94+
internal fun InputStream.sha256(): String {
9595
val digest = MessageDigest.getInstance("SHA-256")
9696
val buffer = ByteArray(1024)
9797
var bytesRead: Int

src/jvmMain/kotlin/com/kdroid/composetray/utils/NativeLibraryLoader.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,13 @@ internal object NativeLibraryLoader {
5959
cacheDir.mkdirs()
6060
val cachedFile = File(cacheDir, fileName)
6161

62-
// Validate cache: re-extract if size differs or file is missing
63-
val resourceSize = resourceUrl.openConnection().contentLengthLong
64-
if (cachedFile.exists() && cachedFile.length() == resourceSize) {
62+
// Validate the cache by content hash, not by size. Two builds of the same
63+
// source can produce libraries of identical byte size but different code
64+
// (e.g. the AVX vs SSE2 build of WinTray.dll — both 23552 bytes — see #401).
65+
// A size-only check would keep serving a stale cached library after the user
66+
// upgrades, so the original crash survives the fix. Compare hashes instead.
67+
val resourceHash = resourceUrl.openStream().use { it.sha256() }
68+
if (isCacheUpToDate(cachedFile, resourceHash)) {
6569
return cachedFile
6670
}
6771

@@ -80,6 +84,16 @@ internal object NativeLibraryLoader {
8084
return cachedFile
8185
}
8286

87+
/**
88+
* A cached library is valid only if it exists and its content hash matches the
89+
* resource currently on the classpath. Content-based (not size-based) so that an
90+
* upgraded library replaces a same-sized stale one (see #401).
91+
*/
92+
internal fun isCacheUpToDate(
93+
cachedFile: File,
94+
expectedSha256: String,
95+
): Boolean = cachedFile.exists() && cachedFile.sha256() == expectedSha256
96+
8397
private fun resolveCacheDir(platform: String): File {
8498
val os = System.getProperty("os.name")?.lowercase() ?: ""
8599
val base =
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.kdroid.composetray.utils
2+
3+
import java.io.File
4+
import kotlin.test.Test
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertTrue
7+
8+
/**
9+
* Regression test for issue #401.
10+
*
11+
* The AVX/SSE2 builds of WinTray.dll are byte-for-byte the same size (23552 bytes)
12+
* but differ in content. The old loader validated its persistent cache by size only,
13+
* so after upgrading the user kept running the stale AVX library and the crash
14+
* survived the fix. The cache must be validated by content hash instead.
15+
*/
16+
class NativeLibraryLoaderCacheTest {
17+
private fun tempFileWith(bytes: ByteArray): File =
18+
File.createTempFile("composetray-cache-test", ".bin").apply {
19+
deleteOnExit()
20+
writeBytes(bytes)
21+
}
22+
23+
@Test
24+
fun `stale cache of identical size but different content is rejected`() {
25+
// Two payloads with the SAME size but DIFFERENT content — the exact
26+
// shape of the AVX vs SSE2 WinTray.dll pair.
27+
val cachedBytes = ByteArray(23552) { 0x01 }
28+
val resourceBytes = ByteArray(23552) { 0x02 }
29+
require(cachedBytes.size == resourceBytes.size)
30+
31+
val cachedFile = tempFileWith(cachedBytes)
32+
val resourceHash = resourceBytes.inputStream().use { it.sha256() }
33+
34+
// A size-only check would have accepted this stale file; the hash check rejects it.
35+
assertFalse(
36+
NativeLibraryLoader.isCacheUpToDate(cachedFile, resourceHash),
37+
"Same-size but different-content cache must be treated as stale and re-extracted",
38+
)
39+
}
40+
41+
@Test
42+
fun `matching content is served from cache`() {
43+
val bytes = ByteArray(23552) { (it % 256).toByte() }
44+
val cachedFile = tempFileWith(bytes)
45+
val resourceHash = bytes.inputStream().use { it.sha256() }
46+
47+
assertTrue(
48+
NativeLibraryLoader.isCacheUpToDate(cachedFile, resourceHash),
49+
"Identical content must be reused from cache",
50+
)
51+
}
52+
53+
@Test
54+
fun `missing cache file is not up to date`() {
55+
val missing = File.createTempFile("composetray-cache-test", ".bin").apply { delete() }
56+
assertFalse(NativeLibraryLoader.isCacheUpToDate(missing, "anyhash"))
57+
}
58+
}

src/native/windows/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ endif()
1717
if(CMAKE_GENERATOR_PLATFORM STREQUAL "x64" OR CMAKE_GENERATOR_PLATFORM STREQUAL "")
1818
set(TARGET_ARCH "x64")
1919
set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-x86-64")
20+
# Do NOT add /arch:AVX, /arch:AVX2 or higher here. The default x64 baseline is
21+
# SSE2, which every x86-64 CPU supports. Enabling AVX makes MSVC emit
22+
# VEX-encoded instructions (e.g. VPXOR) that raise EXCEPTION_ILLEGAL_INSTRUCTION
23+
# on pre-2011 CPUs without AVX (Nehalem Xeon, Core 2 Duo, ...). See issue #401.
24+
# The CI build disassembles the produced DLL and fails if any AVX instruction
25+
# is found, so a reintroduced /arch flag will be caught.
2026
elseif(CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64")
2127
set(TARGET_ARCH "ARM64")
2228
set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-arm64")

0 commit comments

Comments
 (0)