Skip to content

Commit fd059ba

Browse files
authored
Merge pull request #85 from thisisthepy/work/jar-libpython
Fix: Real libpython in the desktop jar, staged prefix and packaged home; extract into a cache
2 parents 26485a0 + a0ccd22 commit fd059ba

8 files changed

Lines changed: 494 additions & 65 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ fail, then implement.
8989
from the prefix a property names, or from a Compose resources directory). The
9090
`createDistributable` run itself is a manual check —
9191
`docs/platforms/desktop-packaged-app.md`.
92+
- **L-9a** (issue #74) Desktop packaging and loading of `libpython`: every `lib/**` entry of the desktop jar is a real
93+
file (never 0 bytes -- Gradle extracts the python-build-standalone symlink `libpython3.14.so` as an empty file, so
94+
symlinks are resolved and the real library is packed under the `System.mapLibraryName` name, no `.so.1.0`); linux
95+
libraries are stripped of debug info when an ELF-capable `strip` exists on the build host (best-effort). The
96+
classpath library is extracted to `<user cache>/python-multiplatform/<python version>/<platform>/` (macOS
97+
`~/Library/Caches`, Windows `%LOCALAPPDATA%`, else `$XDG_CACHE_HOME` or `~/.cache`; fallback
98+
`java.io.tmpdir/python-multiplatform-<user>`; override `-Dpython.multiplatform.cache`), never the working
99+
directory, and reused when its size matches. The same holds for the plugin's staged prefix and the packaged
100+
`python-multiplatform-home`: `stagePythonHome` extracts the archive itself and materialises every symlink/hard
101+
link as a copy of its in-archive target (Gradle's `tarTree` yields 0-byte files), the staging stamp changed so
102+
old prefixes are re-extracted, and the packaged copy fails the build on any 0-byte `libpython*`.
103+
`Status: implemented` -- `PM/desktopTest/.../ffi/DesktopJarLibrariesTest.kt`, `ExtractLibraryCacheTest.kt`,
104+
`GP/StagedPrefixLinksTest.kt`.
92105
- **L-10** Desktop: the library uses no JDK module beyond `java.base`, so it runs on a `jlink`ed
93106
runtime such as the one Compose Desktop's `createDistributable` bundles (issue #77). In particular
94107
native memory is reached through `java.lang.foreign`, never `sun.misc.Unsafe` (module

‎python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,9 @@ class PythonBindingsPlugin : Plugin<Project> {
678678
exclude(PACKAGED_PREFIX_EXCLUDES)
679679
into(PACKAGED_HOME_DIRECTORY)
680680
}
681+
doLast {
682+
requireNoEmptyLibraries(destinationDir.resolve(PACKAGED_HOME_DIRECTORY))
683+
}
681684
}
682685
}
683686

‎python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt‎

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,149 @@ internal fun stagingStamp(
202202
platform: String,
203203
freeThreaded: Boolean,
204204
): String = "python-multiplatform $pythonVersion+$pbsRelease $platform " +
205-
(if (freeThreaded) "freethreaded" else "default")
205+
(if (freeThreaded) "freethreaded" else "default") +
206+
// Bumped when the extraction itself changes. "links-materialised" (issue #74): Gradle's
207+
// `tarTree` turned the archive's `libpython3.14.so -> libpython3.14.so.1.0` into a 0-byte file,
208+
// so every prefix staged before this is wrong and must be extracted again.
209+
" links-materialised"
210+
211+
/**
212+
* Extracts a python-build-standalone `.tar.gz` into [root], turning every symbolic and hard link
213+
* into a copy of its in-archive target (issue #74).
214+
*
215+
* Gradle's `tarTree` extracts a symlink as an empty regular file, so `lib/libpython3.14.so` -- a
216+
* link to `libpython3.14.so.1.0` -- came out 0 bytes and `System.load` failed with "file too
217+
* short". A copy rather than a link also survives being copied into a jar or an app image, which
218+
* flatten links. Dependency-free: `GZIPInputStream` plus a minimal ustar/GNU/pax header reader.
219+
* A link whose target is not inside the archive, or that escapes [root], is skipped.
220+
*/
221+
internal fun extractTarGzMaterialisingLinks(archive: File, root: File) {
222+
val rootPath = root.canonicalFile.toPath()
223+
fun inside(rel: String): java.nio.file.Path? {
224+
val p = rootPath.resolve(rel).normalize()
225+
return if (p.startsWith(rootPath) && p != rootPath) p else null
226+
}
227+
class Link(val path: java.nio.file.Path, val target: String, val hard: Boolean)
228+
val links = mutableListOf<Link>()
229+
230+
java.util.zip.GZIPInputStream(archive.inputStream().buffered(1 shl 16)).use { input ->
231+
val header = ByteArray(512)
232+
var longName: String? = null
233+
var longLink: String? = null
234+
var paxPath: String? = null
235+
var paxLink: String? = null
236+
237+
fun readFully(buf: ByteArray, len: Int): Boolean {
238+
var n = 0
239+
while (n < len) { val r = input.read(buf, n, len - n); if (r < 0) return false; n += r }
240+
return true
241+
}
242+
fun str(off: Int, len: Int): String {
243+
var end = off
244+
while (end < off + len && header[end] != 0.toByte()) end++
245+
return String(header, off, end - off, Charsets.UTF_8)
246+
}
247+
fun octal(off: Int, len: Int): Long = str(off, len).trim().ifEmpty { "0" }.toLong(8)
248+
fun readData(size: Long): ByteArray {
249+
val padded = (size + 511) / 512 * 512
250+
val buf = ByteArray(padded.toInt())
251+
check(readFully(buf, buf.size)) { "truncated tar entry in $archive" }
252+
return buf.copyOf(size.toInt())
253+
}
254+
255+
while (true) {
256+
if (!readFully(header, 512)) break
257+
if (header.all { it == 0.toByte() }) break
258+
var name = str(0, 100)
259+
val prefix = str(345, 155)
260+
if (prefix.isNotEmpty() && str(257, 5) == "ustar") name = "$prefix/$name"
261+
val mode = octal(100, 8)
262+
val size = octal(124, 12)
263+
val type = header[156].toInt().toChar()
264+
var linkName = str(157, 100)
265+
when (type) {
266+
'L' -> { longName = String(readData(size), Charsets.UTF_8).trimEnd('\u0000'); continue }
267+
'K' -> { longLink = String(readData(size), Charsets.UTF_8).trimEnd('\u0000'); continue }
268+
'x' -> {
269+
String(readData(size), Charsets.UTF_8).lineSequence().forEach { rec ->
270+
val kv = rec.substringAfter(' ', "")
271+
when {
272+
kv.startsWith("path=") -> paxPath = kv.removePrefix("path=")
273+
kv.startsWith("linkpath=") -> paxLink = kv.removePrefix("linkpath=")
274+
}
275+
}
276+
continue
277+
}
278+
'g' -> { readData(size); continue }
279+
}
280+
(paxPath ?: longName)?.let { name = it }
281+
(paxLink ?: longLink)?.let { linkName = it }
282+
longName = null; longLink = null; paxPath = null; paxLink = null
283+
284+
val dest = inside(name.trimEnd('/')) ?: run {
285+
if (size > 0) readData(size)
286+
null
287+
} ?: continue
288+
when (type) {
289+
'5' -> java.nio.file.Files.createDirectories(dest)
290+
'2' -> links += Link(dest, linkName, hard = false)
291+
'1' -> links += Link(dest, linkName, hard = true)
292+
'0', '\u0000', '7' -> {
293+
java.nio.file.Files.createDirectories(dest.parent)
294+
dest.toFile().outputStream().use { out ->
295+
var remaining = size
296+
val buf = ByteArray(1 shl 16)
297+
while (remaining > 0) {
298+
val r = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
299+
check(r >= 0) { "truncated tar entry $name in $archive" }
300+
out.write(buf, 0, r); remaining -= r
301+
}
302+
}
303+
var pad = (512 - size % 512) % 512
304+
while (pad > 0) { val r = input.skip(pad); if (r <= 0) { input.read(); pad-- } else pad -= r }
305+
if (mode and 0b001_001_001L != 0L) dest.toFile().setExecutable(true, false)
306+
}
307+
else -> if (size > 0) readData(size)
308+
}
309+
}
310+
}
311+
312+
// Links last, repeated until stable, so a link to a link (or to a later entry) resolves.
313+
var pending: List<Link> = links
314+
while (pending.isNotEmpty()) {
315+
val unresolved = mutableListOf<Link>()
316+
for (link in pending) {
317+
val source = if (link.hard) inside(link.target)
318+
else link.path.parent.resolve(link.target).normalize().takeIf { it.startsWith(rootPath) }
319+
val file = source?.toFile()
320+
if (file == null || !file.exists() || (file.isFile && pending.any { it.path == source })) {
321+
unresolved += link; continue
322+
}
323+
if (file.isDirectory) file.copyRecursively(link.path.toFile(), overwrite = true)
324+
else {
325+
java.nio.file.Files.createDirectories(link.path.parent)
326+
file.copyTo(link.path.toFile(), overwrite = true)
327+
link.path.toFile().setExecutable(file.canExecute(), false)
328+
}
329+
}
330+
if (unresolved.size == pending.size) break // remaining targets are outside the archive
331+
pending = unresolved
332+
}
333+
}
334+
335+
/**
336+
* Fails when a packaged prefix carries a zero-length shared library -- a symlink stub is never a
337+
* library, and shipping one fails at the user's first launch with "file too short" (issue #74).
338+
*/
339+
internal fun requireNoEmptyLibraries(dir: File) {
340+
if (!dir.isDirectory) return
341+
val empty = dir.walkTopDown()
342+
.filter { it.isFile && it.length() == 0L && it.name.startsWith("libpython") }
343+
.map { it.relativeTo(dir).path }.toList()
344+
if (empty.isNotEmpty()) {
345+
throw GradleException("packaged CPython prefix contains zero-length libraries: $empty (symlink stubs?)")
346+
}
347+
}
206348

207349
/**
208350
* The directory a packaged desktop application carries its CPython prefix in, inside Compose
@@ -334,10 +476,8 @@ abstract class StagePythonHomeTask : DefaultTask() {
334476
}
335477

336478
logger.lifecycle("Staging CPython $version ($target) into $prefix")
337-
fs.copy {
338-
from(archives.tarTree(archives.gzip(archive)))
339-
into(root)
340-
}
479+
// Not `tarTree`: it extracts the archive's symlinks as empty files (issue #74).
480+
extractTarGzMaterialisingLinks(archive, root)
341481

342482
if (!marker.isFile) {
343483
throw GradleException(
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package python.multiplatform.gradle
2+
3+
import org.gradle.api.GradleException
4+
import java.io.ByteArrayOutputStream
5+
import java.io.File
6+
import java.nio.file.Files
7+
import java.util.zip.GZIPOutputStream
8+
import kotlin.test.Test
9+
import kotlin.test.assertContentEquals
10+
import kotlin.test.assertFailsWith
11+
import kotlin.test.assertTrue
12+
13+
/**
14+
* Issue #74. Gradle's `tarTree` extracts the archive's `lib/libpython3.14.so -> libpython3.14.so.1.0`
15+
* as a 0-byte file, which the staged prefix, the packaged `python-multiplatform-home` and the
16+
* desktop jar then all carried. Expected red before the fix: the extractor did not exist; with
17+
* `tarTree` the staged link is empty.
18+
*/
19+
class StagedPrefixLinksTest {
20+
21+
private fun entry(out: ByteArrayOutputStream, name: String, type: Char, data: ByteArray = ByteArray(0), link: String = "") {
22+
val h = ByteArray(512)
23+
fun put(off: Int, s: String) = s.toByteArray().copyInto(h, off)
24+
put(0, name)
25+
put(100, "0000755\u0000")
26+
put(124, String.format("%011o\u0000", data.size))
27+
h[156] = type.code.toByte()
28+
put(157, link)
29+
put(257, "ustar\u000000")
30+
for (i in 148 until 156) h[i] = ' '.code.toByte()
31+
put(148, String.format("%06o\u0000 ", h.sumOf { it.toInt() and 0xff }))
32+
out.write(h)
33+
out.write(data)
34+
out.write(ByteArray((512 - data.size % 512) % 512))
35+
}
36+
37+
private fun fakeArchive(dir: File, payload: ByteArray): File {
38+
val tar = ByteArrayOutputStream()
39+
entry(tar, "python/", '5')
40+
entry(tar, "python/lib/", '5')
41+
entry(tar, "python/lib/libpython3.14.so.1.0", '0', payload)
42+
entry(tar, "python/lib/libpython3.14.so", '2', link = "libpython3.14.so.1.0")
43+
entry(tar, "python/lib/libpython3.so", '2', link = "libpython3.14.so") // link to a link
44+
tar.write(ByteArray(1024))
45+
val f = File(dir, "fake.tar.gz")
46+
GZIPOutputStream(f.outputStream()).use { it.write(tar.toByteArray()) }
47+
return f
48+
}
49+
50+
@Test
51+
fun symlinksBecomeCopiesOfTheirTargets() {
52+
val work = Files.createTempDirectory(File("../.tmp").apply { mkdirs() }.toPath(), "links").toFile()
53+
try {
54+
val payload = ByteArray(5000) { (it % 251).toByte() }
55+
val root = File(work, "out")
56+
extractTarGzMaterialisingLinks(fakeArchive(work, payload), root)
57+
58+
val real = File(root, "python/lib/libpython3.14.so.1.0")
59+
val link = File(root, "python/lib/libpython3.14.so")
60+
val chained = File(root, "python/lib/libpython3.so")
61+
assertContentEquals(payload, real.readBytes())
62+
assertTrue(link.length() > 0, "staged symlink is empty")
63+
assertContentEquals(real.readBytes(), link.readBytes())
64+
assertContentEquals(real.readBytes(), chained.readBytes())
65+
assertTrue(!Files.isSymbolicLink(link.toPath()))
66+
} finally {
67+
work.deleteRecursively()
68+
}
69+
}
70+
71+
@Test
72+
fun theStampChangedSoOldPrefixesAreReExtracted() {
73+
assertTrue("links-materialised" in stagingStamp("3.14.7", "20260807", "linux-x86_64", false))
74+
}
75+
76+
@Test
77+
fun aPackagedHomeWithAnEmptyLibraryFailsTheBuild() {
78+
val work = Files.createTempDirectory(File("../.tmp").apply { mkdirs() }.toPath(), "packaged").toFile()
79+
try {
80+
val lib = File(work, "lib").apply { mkdirs() }
81+
File(lib, "libpython3.14.so.1.0").writeBytes(ByteArray(10))
82+
requireNoEmptyLibraries(work) // clean tree passes
83+
File(lib, "libpython3.14.so").writeBytes(ByteArray(0))
84+
assertFailsWith<GradleException> { requireNoEmptyLibraries(work) }
85+
} finally {
86+
work.deleteRecursively()
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)