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
13 changes: 7 additions & 6 deletions .github/workflows/build-wheels-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ permissions:

jobs:
setup:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
has_jobs: ${{ steps.set-matrix.outputs.has_jobs }}
Expand Down Expand Up @@ -180,7 +180,7 @@ jobs:
for pkg in $build_packages; do
pkg_name="${pkg%%:*}"
if [[ "$arch" == "android" ]]; then
runner="ubuntu-latest"
runner="ubuntu-24.04"
platform="android"
rust_targets="aarch64-linux-android,arm-linux-androideabi,x86_64-linux-android"
else
Expand Down Expand Up @@ -495,10 +495,11 @@ jobs:
for w in "$GITHUB_WORKSPACE"/dist/*.whl; do
[[ -e "$w" ]] || continue
base=$(basename "$w")
# name-version[-buildtag]-cpXY-cpXY-plat.whl
# → name-version-9999-cpXY-cpXY-plat.whl
# name-version[-buildtag]-{cpXY-cpXY|cpXY-abi3|py3-none}-plat.whl
# → name-version-9999-...-plat.whl. py3-none covers the build.sh
# flet-lib* wheels a prebuild_recipes chain puts here.
new=$(printf '%s\n' "$base" \
| sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-cp[0-9]+)-/\1-9999-\3-/')
| sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-((cp[0-9]+|py3)-(cp[0-9]+|abi[0-9]+|none))-/\1-9999-\3-/')
cp "$w" "$GITHUB_WORKSPACE/dist-test/$new"
done

Expand Down Expand Up @@ -562,7 +563,7 @@ jobs:
[[ -e "$w" ]] || continue
base=$(basename "$w")
new=$(printf '%s\n' "$base" \
| sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/')
| sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-((cp[0-9]+|py3)-(cp[0-9]+|abi[0-9]+|none))-/\1-9999-\3-/')
cp "$w" "$GITHUB_WORKSPACE/dist-test/$new"
done

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ permissions:

jobs:
detect:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
pythons_json: ${{ steps.detect-pythons.outputs.pythons_json }}
canonical_python: ${{ steps.detect-pythons.outputs.canonical_python }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
security-events: write
contents: read
Expand Down
5 changes: 3 additions & 2 deletions recipes/cffi/meta.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package:
name: cffi
version: "2.0.0"
version: "2.1.1"

build:
number: 1
Expand All @@ -10,4 +10,5 @@ requirements:
- libffi

patches:
- mobile.patch
- mobile.patch
- ios-ffi-closure-alloc.patch
54 changes: 54 additions & 0 deletions recipes/cffi/patches/ios-ffi-closure-alloc.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
Make ffi.callback() and variadic calls work on iOS (python-cffi/cffi#265).

cffi uses libffi's ffi_closure_alloc()/ffi_prep_closure_loc()/ffi_prep_cif_var()
on Apple only when ffi.h defines FFI_AVAILABLE_APPLE, which only the macOS SDK's
ffi.h does. iOS links the support tree's upstream libffi, so every
ffi.callback() fell back to malloc_closure.h's mmap(PROT_WRITE|PROT_EXEC),
which iOS refuses ("MemoryError: Cannot allocate write+execute memory"), and
variadic calls went through ffi_prep_cif(), which passes garbage on arm64,
where Apple puts varargs on the stack. The fallback's ffi_prep_closure() is
unusable on arm64 even with W+X allowed: that libffi uses trampoline tables.

Same defines as upstream #265 (90e8cc434a, merged 2026-07-26, in no release
as of 2.1.1), narrowed to TARGET_OS_IPHONE rather than upstream's hunk
verbatim, because forge applies patches with a non-interactive `patch -p1`:
on a release that already carries #265 the verbatim hunk is taken as
"previously applied", answered "Assume -R? [y]", and silently REVERTED with
exit 0 (measured on the 2.1.1 tree with #265 applied). This variant lands
there as a redundant, dead branch instead. Android is not __APPLE__ and forge
builds no macOS, so behaviour on every forge target equals upstream's. Drop
this patch on a cffi release that contains #265.

--- a/src/c/_cffi_backend.c
+++ b/src/c/_cffi_backend.c
@@ -99,6 +99,10 @@
* This is also used on macOS, provided we are executing on macOS 10.15 or
* above. It's a mess because it needs runtime checks in that case.
*/
+#ifdef __APPLE__
+# include <TargetConditionals.h>
+#endif
+
#ifdef __NetBSD__

# define CFFI_CHECK_FFI_CLOSURE_ALLOC 1
@@ -117,6 +121,19 @@
# define CFFI_CHECK_FFI_PREP_CIF_VAR __builtin_available(macos 10.15, ios 13, watchos 6, tvos 13, *)
# define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 1

+#elif defined(__APPLE__) && TARGET_OS_IPHONE
+
+/* iOS links upstream libffi, whose ffi.h lacks FFI_AVAILABLE_APPLE (only the
+ * macOS SDK's has it). Falling to malloc_closure.h means a W+X mmap iOS
+ * refuses, and ffi_prep_cif() for varargs, which arm64 Apple passes on the
+ * stack. The static libffi always has all three. python-cffi/cffi#265. */
+# define CFFI_CHECK_FFI_CLOSURE_ALLOC 1
+# define CFFI_CHECK_FFI_CLOSURE_ALLOC_MAYBE 1
+# define CFFI_CHECK_FFI_PREP_CLOSURE_LOC 1
+# define CFFI_CHECK_FFI_PREP_CLOSURE_LOC_MAYBE 1
+# define CFFI_CHECK_FFI_PREP_CIF_VAR 1
+# define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 1
+
#elif defined(__EMSCRIPTEN__)

# define CFFI_CHECK_FFI_CLOSURE_ALLOC 1
28 changes: 19 additions & 9 deletions recipes/cffi/patches/mobile.patch
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
diff --git a/setup.py b/setup.py
index f9cabaa..c592203 100644
Cross-compile cffi against the target's libffi, not the build machine's.

setup.py: drop the /usr/include/ffi and /usr/include/libffi defaults and the
pkg-config probe, which would point a cross build at the host's libffi
headers; forge's cross environment supplies the target paths.

malloc_closure.h: guard the Linux-only PaX getline() scan by
__ANDROID_API__ >= 18, as Chaquopy does (bionic gained getline() at API 18).
A no-op at forge's API 24.

Regenerated against 2.1.1, where the setup.py hunk moved by 6 lines.

diff -ruN a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -9,8 +9,7 @@ import setuptools
@@ -9,8 +9,7 @@

sources = ['src/c/_cffi_backend.c']
libraries = ['ffi']
Expand All @@ -12,7 +23,7 @@ index f9cabaa..c592203 100644
define_macros = [('FFI_BUILDING', '1')] # for linking with libffi static library
library_dirs = []
extra_compile_args = []
@@ -144,7 +143,7 @@ if sys.platform == "win32" and uses_msvc():
@@ -150,7 +149,7 @@
sources.extend(os.path.join(COMPILE_LIBFFI, filename)
for filename in _filenames)
else:
Expand All @@ -21,11 +32,10 @@ index f9cabaa..c592203 100644
ask_supports_thread()
ask_supports_sync_synchronize()

diff --git a/src/c/malloc_closure.h b/src/c/malloc_closure.h
index bebb93d..f82dbe4 100644
diff -ruN a/src/c/malloc_closure.h b/src/c/malloc_closure.h
--- a/src/c/malloc_closure.h
+++ b/src/c/malloc_closure.h
@@ -42,6 +42,8 @@ emutramp_enabled_check (void)
@@ -42,6 +42,8 @@
return 0;
ret = 0;

Expand All @@ -34,12 +44,12 @@ index bebb93d..f82dbe4 100644
while (getline (&buf, &len, f) != -1)
if (!strncmp (buf, "PaX:", 4))
{
@@ -51,6 +53,8 @@ emutramp_enabled_check (void)
@@ -51,6 +53,8 @@
break;
}
free (buf);
+ #endif
+
fclose (f);
return ret;
}
}
102 changes: 98 additions & 4 deletions recipes/cffi/tests/test_cffi.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,101 @@
def test_basic():
from cffi import FFI
import gc
import sys
import threading
from typing import Any

from cffi import FFI

CDEF = """
size_t strlen(char *str);
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
typedef unsigned long pthread_t;
int pthread_create(pthread_t *thread, void *attr,
void *(*start)(void *), void *arg);
int pthread_join(pthread_t thread, void **retval);
int snprintf(char *str, size_t size, const char *format, ...);
"""


def _libc() -> tuple[FFI, Any]:
"""ABI-mode handle on the C library: bionic's public libc.so on Android, the
process's own symbols (libSystem) on iOS."""
ffi = FFI()
ffi.cdef("size_t strlen(char *str);")
lib = ffi.dlopen(None)
ffi.cdef(CDEF)
# Not sys.platform: Android's Python 3.12 still reports "linux".
android = hasattr(sys, "getandroidapilevel")
return ffi, ffi.dlopen("libc.so" if android else None)


def test_basic() -> None:
"""ABI-mode call into libc through a dlopen'd handle."""
ffi, lib = _libc()
assert lib.strlen(ffi.new("char[]", b"hello world")) == 11


def test_callback_called_from_c() -> None:
"""C calls a Python ffi.callback() (qsort's comparator). On iOS this needs
libffi's ffi_closure_alloc(): cffi's own allocator asks for W+X pages."""
ffi, lib = _libc()

@ffi.callback("int(const void *, const void *)")
def cmp(a: Any, b: Any) -> int:
x, y = ffi.cast("int *", a)[0], ffi.cast("int *", b)[0]
return (x > y) - (x < y)

arr = ffi.new("int[]", [5, 3, 9, 1, 7, 2])
lib.qsort(arr, 6, ffi.sizeof("int"), cmp)
assert list(arr) == [1, 2, 3, 5, 7, 9]


def test_callback_from_native_thread() -> None:
"""A callback entered on a thread Python did not create, as PortAudio and
libsndfile's virtual I/O do."""
ffi, lib = _libc()
seen: list[tuple[int, int]] = []

@ffi.callback("void *(void *)")
def start(arg: Any) -> Any:
seen.append((threading.get_ident(), int(ffi.cast("intptr_t", arg))))
return ffi.NULL

tid = ffi.new("pthread_t *")
assert lib.pthread_create(tid, ffi.NULL, start, ffi.cast("void *", 1234)) == 0
assert lib.pthread_join(tid[0], ffi.NULL) == 0
assert len(seen) == 1
assert seen[0][1] == 1234
assert seen[0][0] != threading.get_ident()


def test_many_callbacks_freed_and_reallocated() -> None:
"""3000 live closures, freed, then 3000 more. On arm64 iOS a libffi trampoline
table holds 1024, so this grows past one table, releases them and starts again."""
ffi, _ = _libc()
cbs = [
ffi.callback("int(int)", (lambda k: lambda x: x + k)(k)) for k in range(3000)
]
assert [cbs[k](1) for k in (0, 1023, 1024, 2999)] == [1, 1024, 1025, 3000]
del cbs
gc.collect()
again = [ffi.callback("int(int)", lambda x: -x) for _ in range(3000)]
assert again[0](5) == -5
assert again[-1](7) == -7


# Last on purpose: a backend without ffi_prep_cif_var() can crash here, not just fail.
def test_variadic_call() -> None:
"""snprintf with a string, an int and a double in the variadic part. arm64
Apple passes varargs on the stack, so this is garbage or a crash unless cffi
prepares the call with ffi_prep_cif_var()."""
ffi, lib = _libc()
buf = ffi.new("char[64]")
n = lib.snprintf(
buf,
64,
b"[%s] [%d] [%.2f]",
ffi.new("char[]", b"hi"),
ffi.cast("int", 42),
ffi.cast("double", 3.5),
)
assert ffi.string(buf) == b"[hi] [42] [3.50]"
assert n == 16
49 changes: 25 additions & 24 deletions recipes/soundfile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,26 @@ file, so it is how you check a samplerate or duration before deciding to decode.
### Reading audio the user picked

[`FilePicker`](https://flet.dev/docs/controls/filepicker/) hands back a path on both
platforms, and `sf.read()` takes it directly. **A real path is the only form that works
everywhere** — see the iOS note below.
platforms, and `sf.read()` takes it directly.

Where you hold bytes instead (a download, a file read out of `assets/`), `soundfile` does
accept any file object, and on Android that works:
Where you hold bytes instead (a download, a file read out of `assets/`), pass any binary
file object:

```python
import io

data, sr = sf.read(io.BytesIO(raw_bytes)) # Android and desktop only
data, sr = sf.read(io.BytesIO(raw_bytes))
```

**On iOS this raises `MemoryError`,** and no version of the package will fix it. That path
uses libsndfile's virtual I/O, which means handing the C library a `ffi.callback()`; cffi
writes that trampoline into memory at runtime, and iOS refuses write+execute pages to an
app without the JIT entitlement. The message names the cause:
A file object goes through libsndfile's virtual I/O, which calls back into Python through
`ffi.callback()`. That works on both platforms with **cffi 2.1.1-1 or later from
pypi.flet.dev**, which pip picks over PyPI's own iOS wheel of the same version. Apps built
against an older cffi raised this on iOS for every file object:

MemoryError: Cannot allocate write+execute memory for ffi.callback().

Write the bytes to a file and read that instead — it costs one write, needs no
per-platform branch, and is the recommended form on both platforms:
A real path is still the simplest form, with no callbacks at all. If the bytes are going to
disk anyway, read them back by path:

```python
import os
Expand All @@ -92,9 +91,6 @@ with open(path, "wb") as f:
data, sr = sf.read(path)
```

Only the *file-object* form is affected. Paths, and integer file descriptors (which go
through `sf_open_fd`), use no callbacks and work normally on iOS.

### Storage

`soundfile` reads and writes nothing of its own — no config, no cache, no env vars. Files you
Expand Down Expand Up @@ -142,9 +138,10 @@ device, which is not something you can assume for every package.
being killed by the OS. Pass `dtype="float32"` unless you specifically need the precision,
and prefer `blocks()` over reading whole files.

- **iOS cannot read audio from a file object.** Repeating it here because it is the one
thing that behaves differently between the two platforms, and it fails at the point of
use rather than at import. `io.BytesIO` in, `MemoryError` out; write a file first.
- **`MemoryError: Cannot allocate write+execute memory` on iOS means an old cffi.** File
objects need cffi 2.1.1-1 or later; with PyPI's 2.1.1 or our 2.0.0-1 the app imports
fine and fails at the first file object. Check which cffi the build resolved rather
than looking at soundfile.

- **Opus only encodes at 48 kHz.** libsndfile will not resample for you; `sf.write(...,
format="OGG", subtype="OPUS")` at any other rate raises. Resample first — [`soxr`](../soxr)
Expand Down Expand Up @@ -218,6 +215,10 @@ download six wheels whose contents already live inside `libsndfile.so`.
`py2.py3-none-any` for an unknown `sys.platform`. forge's `fix_wheel` overwrites the tag,
which is what makes our wheel outrank PyPI's `any` wheel at the same version. Do not
"fix" the tag in a patch.
- **File objects on iOS depend on the cffi recipe's `ios-ffi-closure-alloc.patch`**
(python-cffi/cffi#265, in no cffi release as of 2.1.1). If PyPI ships a newer cffi with
iOS wheels before pypi.flet.dev does, apps resolve it, and `test_read_from_file_object`
goes red on iOS unless that release carries #265.
- **FLAC 1.5.0 needs `-DCMAKE_SYSTEM_VERSION` on Android** and `-DENABLE_MULTITHREADING=OFF`;
both have comments in `recipes/flet-libflac/build.sh` saying what breaks without them.

Expand All @@ -241,10 +242,10 @@ download six wheels whose contents already live inside `libsndfile.so`.
### Coverage gaps

The device tests cover loading the library, WAV round trips in three subtypes, every
container and codec, virtual I/O from `BytesIO` (Android) and its `MemoryError` on iOS,
the write-a-file workaround, block reads with seeking, and the format registry. They do not
cover: writing to `FLET_APP_STORAGE_*` (a path question, not a
libsndfile one), any real recorded audio file, `sf.info()`, the `RAW` format's manual
`samplerate`/`channels`/`subtype` arguments, or multi-threaded decoding. The security
backports are compile-verified only — there are no regression tests for the five fixes, since
reproducing them needs the malformed inputs from upstream's fuzz corpus.
container and codec, virtual I/O from `BytesIO` on both platforms, bytes spilled to a path,
block reads with seeking, and the format registry. They do not cover: writing to
`FLET_APP_STORAGE_*` (a path question, not a libsndfile one), any real recorded audio
file, `sf.info()`, the `RAW` format's manual `samplerate`/`channels`/`subtype` arguments,
or multi-threaded decoding. The security backports are compile-verified only — there are
no regression tests for the five fixes, since reproducing them needs the malformed inputs
from upstream's fuzz corpus.
Loading
Loading