Skip to content

Commit 3bf3ca9

Browse files
akoclaude
andcommitted
fix(docker): auto-preload system libfreetype to fix libSkiaSharp crash
mx/mxbuild abort with "libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property" on some releases (e.g. 11.10.0) because they run under the Temurin JVM, whose bundled libfreetype is stripped and lacks that symbol — Skia loads the JVM's FreeType instead of the system one and crashes. Add docker.PrepareMxCommand, which globs the system libfreetype across the common multiarch locations and sets LD_PRELOAD on the mx child (no-op on non-Linux or when none is found; preserves an existing LD_PRELOAD). Wire it into every mx/mxbuild invocation: docker check/build (check, update-widgets, mxbuild), new (create-project), and marketplace install (module-import). This fixes build/run AND check with one mechanism while keeping Skia working, so it supersedes the libSkiaSharp move-aside hack: rewrite scripts/mx-check.sh to use LD_PRELOAD and correct the root-cause wording in CLAUDE.md. Avoids hardcoding an arch path so amd64 and arm64 both work without config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c73c79b commit 3bf3ca9

8 files changed

Lines changed: 218 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,16 @@ mxcli setup mxbuild -p app.mpr
6666
mxcli docker check -p app.mpr
6767
```
6868

69-
**Devcontainer gotcha — libSkiaSharp crash on some mxbuild releases.** Certain bundled `mx` binaries (observed on 11.10.0) abort with `symbol lookup error: .../libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property` against the system libfreetype. `mx check` doesn't need Skia, so use the guarded wrapper which moves the library aside, runs the check, and always restores it (even on Ctrl-C):
69+
**Devcontainer gotcha — libSkiaSharp/FreeType crash on some mxbuild releases.** Certain bundled `mx` binaries (observed on 11.10.0) abort with `symbol lookup error: .../libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property`. Root cause: `mx`/mxbuild run under the Temurin JVM, whose bundled libfreetype is stripped and lacks `FT_Get_BDF_Property`, so Skia loads the *JVM's* FreeType instead of the system one (which has the symbol). Preloading the system libfreetype makes it load first and fixes `mx check`/`build`/`run` while keeping Skia working.
70+
71+
`mxcli docker check`/`build`/`new` apply this automatically (`docker.PrepareMxCommand`, which globs the system libfreetype and sets `LD_PRELOAD` on the `mx` child — no-op on non-Linux or when none is found). To invoke a bundled `mx` directly, use the wrapper (same fix) or export `LD_PRELOAD` yourself:
7072

7173
```bash
7274
scripts/mx-check.sh -p /path/to/app.mpr --version 11.10.0
75+
# or, for any mx command:
76+
export LD_PRELOAD=/usr/lib/$(uname -m)-linux-gnu/libfreetype.so.6
7377
```
7478

75-
Never leave `libSkiaSharp.so` moved aside manually — other `mx` commands (build, etc.) need it.
76-
7779
## Project Architecture
7880

7981
```

cmd/mxcli/cmd_marketplace_install.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ func installModule(ctx context.Context, client *marketplace.Client, v *marketpla
159159
}
160160

161161
c := exec.CommandContext(ctx, mxPath, "module-import", mpkPath, mprPath)
162+
docker.PrepareMxCommand(c)
162163
combined, runErr := c.CombinedOutput()
163164
if runErr != nil {
164165
return fmt.Errorf("mx module-import failed: %w\n%s", runErr, strings.TrimSpace(string(combined)))

cmd/mxcli/cmd_new.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Examples:
8282
mxCmd.Dir = absDir
8383
mxCmd.Stdout = os.Stdout
8484
mxCmd.Stderr = os.Stderr
85+
docker.PrepareMxCommand(mxCmd)
8586
if err := mxCmd.Run(); err != nil {
8687
fmt.Fprintf(os.Stderr, "Error creating project: %v\n", err)
8788
os.Exit(1)

cmd/mxcli/docker/build.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func Build(opts BuildOptions) error {
106106
uwCmd := exec.Command(mxPath, "update-widgets", opts.ProjectPath)
107107
uwCmd.Stdout = w
108108
uwCmd.Stderr = os.Stderr
109+
PrepareMxCommand(uwCmd)
109110
if err := uwCmd.Run(); err != nil {
110111
fmt.Fprintf(w, " Warning: update-widgets failed (continuing): %v\n", err)
111112
}
@@ -114,6 +115,7 @@ func Build(opts BuildOptions) error {
114115
cmd := exec.Command(mxPath, "check", opts.ProjectPath)
115116
cmd.Stdout = w
116117
cmd.Stderr = os.Stderr
118+
PrepareMxCommand(cmd)
117119
if err := cmd.Run(); err != nil {
118120
return fmt.Errorf("project has errors (fix them or use --skip-check to bypass): %w", err)
119121
}
@@ -171,6 +173,7 @@ func Build(opts BuildOptions) error {
171173
)
172174
cmd.Stdout = w
173175
cmd.Stderr = os.Stderr
176+
PrepareMxCommand(cmd)
174177

175178
if err := cmd.Run(); err != nil {
176179
return fmt.Errorf("mxbuild failed: %w", err)

cmd/mxcli/docker/check.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func Check(opts CheckOptions) error {
6868
uwCmd := exec.Command(mxPath, "update-widgets", opts.ProjectPath)
6969
uwCmd.Stdout = w
7070
uwCmd.Stderr = stderr
71+
PrepareMxCommand(uwCmd)
7172
if err := uwCmd.Run(); err != nil {
7273
// Non-fatal: warn and continue with check
7374
fmt.Fprintf(w, "Warning: update-widgets failed (continuing with check): %v\n", err)
@@ -81,6 +82,7 @@ func Check(opts CheckOptions) error {
8182
cmd := exec.Command(mxPath, "check", opts.ProjectPath)
8283
cmd.Stdout = w
8384
cmd.Stderr = stderr
85+
PrepareMxCommand(cmd)
8486

8587
if err := cmd.Run(); err != nil {
8688
return fmt.Errorf("project check failed: %w", err)

cmd/mxcli/docker/mxenv.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package docker
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
)
12+
13+
// systemFreeTypePath returns the path to the system libfreetype.so.6, or "" if
14+
// none is found. It globs the common (multiarch) library locations so it works
15+
// on amd64, arm64, and other Linux distros without a hardcoded arch path.
16+
func systemFreeTypePath() string {
17+
patterns := []string{
18+
"/usr/lib/*/libfreetype.so.6", // Debian/Ubuntu multiarch (x86_64, aarch64, …)
19+
"/usr/lib/libfreetype.so.6",
20+
"/lib/*/libfreetype.so.6",
21+
"/usr/local/lib/libfreetype.so.6",
22+
}
23+
for _, pat := range patterns {
24+
matches, _ := filepath.Glob(pat)
25+
for _, m := range matches {
26+
if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
27+
return m
28+
}
29+
}
30+
}
31+
return ""
32+
}
33+
34+
// skiaFreeTypeLib returns the system libfreetype to preload to work around the
35+
// libSkiaSharp "undefined symbol: FT_Get_BDF_Property" crash, or "" when the
36+
// workaround does not apply.
37+
//
38+
// Root cause: mx/mxbuild run under the Temurin JVM, whose bundled libfreetype
39+
// is stripped and lacks FT_Get_BDF_Property. Skia then loads the JVM's FreeType
40+
// instead of the system one (which has the symbol) and aborts. Preloading the
41+
// system libfreetype makes it load first, fixing mx build/run/check while
42+
// keeping Skia working (unlike moving libSkiaSharp.so aside, which disables it).
43+
//
44+
// Returns "" on non-Linux hosts or when no system libfreetype is found, so
45+
// callers leave the environment untouched everywhere the bug can't occur.
46+
func skiaFreeTypeLib() string {
47+
if runtime.GOOS != "linux" {
48+
return ""
49+
}
50+
return systemFreeTypePath()
51+
}
52+
53+
// PrepareMxCommand injects the FreeType LD_PRELOAD workaround into cmd's
54+
// environment before it runs an mx/mxbuild binary. It is a no-op when the
55+
// workaround does not apply (non-Linux, no system libfreetype) or when an
56+
// LD_PRELOAD already references libfreetype (e.g. the user exported it). When a
57+
// non-libfreetype LD_PRELOAD is already present, the system libfreetype is
58+
// prepended so it loads first.
59+
func PrepareMxCommand(cmd *exec.Cmd) {
60+
lib := skiaFreeTypeLib()
61+
if lib == "" {
62+
return
63+
}
64+
env := cmd.Env
65+
if env == nil {
66+
env = os.Environ()
67+
}
68+
if merged, changed := injectLDPreload(env, lib); changed {
69+
cmd.Env = merged
70+
}
71+
}
72+
73+
// injectLDPreload returns env with lib added to LD_PRELOAD, and whether it
74+
// changed anything. If an LD_PRELOAD already references libfreetype it is left
75+
// untouched (changed=false); a non-libfreetype LD_PRELOAD gets lib prepended so
76+
// it loads first. env is not mutated; a copy is returned when changed.
77+
func injectLDPreload(env []string, lib string) (result []string, changed bool) {
78+
existing := ""
79+
idx := -1
80+
for i, e := range env {
81+
if v, ok := strings.CutPrefix(e, "LD_PRELOAD="); ok {
82+
existing = v
83+
idx = i
84+
}
85+
}
86+
if strings.Contains(existing, "libfreetype.so") {
87+
return env, false
88+
}
89+
value := lib
90+
if existing != "" {
91+
value = lib + ":" + existing
92+
}
93+
entry := "LD_PRELOAD=" + value
94+
out := append([]string(nil), env...)
95+
if idx >= 0 {
96+
out[idx] = entry
97+
} else {
98+
out = append(out, entry)
99+
}
100+
return out, true
101+
}

cmd/mxcli/docker/mxenv_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package docker
4+
5+
import (
6+
"slices"
7+
"testing"
8+
)
9+
10+
func TestInjectLDPreload(t *testing.T) {
11+
const lib = "/usr/lib/aarch64-linux-gnu/libfreetype.so.6"
12+
13+
tests := []struct {
14+
name string
15+
env []string
16+
wantChanged bool
17+
wantEntry string // expected LD_PRELOAD value; "" means assert no LD_PRELOAD added beyond input
18+
}{
19+
{
20+
name: "no existing LD_PRELOAD appends",
21+
env: []string{"PATH=/bin", "HOME=/root"},
22+
wantChanged: true,
23+
wantEntry: "LD_PRELOAD=" + lib,
24+
},
25+
{
26+
name: "freetype already preloaded is left alone",
27+
env: []string{"LD_PRELOAD=/some/other/libfreetype.so.6"},
28+
wantChanged: false,
29+
wantEntry: "LD_PRELOAD=/some/other/libfreetype.so.6",
30+
},
31+
{
32+
name: "non-freetype LD_PRELOAD gets lib prepended",
33+
env: []string{"LD_PRELOAD=/opt/libfoo.so"},
34+
wantChanged: true,
35+
wantEntry: "LD_PRELOAD=" + lib + ":/opt/libfoo.so",
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
input := slices.Clone(tt.env)
42+
got, changed := injectLDPreload(input, lib)
43+
if changed != tt.wantChanged {
44+
t.Errorf("changed = %v, want %v", changed, tt.wantChanged)
45+
}
46+
// Input must never be mutated.
47+
if !slices.Equal(input, tt.env) {
48+
t.Errorf("input env mutated: %v", input)
49+
}
50+
var found string
51+
for _, e := range got {
52+
if after, ok := stringsCutPreload(e); ok {
53+
found = "LD_PRELOAD=" + after
54+
}
55+
}
56+
if found != tt.wantEntry {
57+
t.Errorf("LD_PRELOAD = %q, want %q", found, tt.wantEntry)
58+
}
59+
// Exactly one LD_PRELOAD entry in the result.
60+
count := 0
61+
for _, e := range got {
62+
if _, ok := stringsCutPreload(e); ok {
63+
count++
64+
}
65+
}
66+
if count > 1 {
67+
t.Errorf("expected at most one LD_PRELOAD entry, got %d in %v", count, got)
68+
}
69+
})
70+
}
71+
}
72+
73+
func stringsCutPreload(e string) (string, bool) {
74+
const p = "LD_PRELOAD="
75+
if len(e) >= len(p) && e[:len(p)] == p {
76+
return e[len(p):], true
77+
}
78+
return "", false
79+
}

scripts/mx-check.sh

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
#!/bin/bash
22

3-
# mx-check.sh — run `mx check` with the libSkiaSharp.so move-aside workaround.
3+
# mx-check.sh — run `mx check` with the libSkiaSharp/FreeType LD_PRELOAD fix.
44
#
5-
# Some bundled mxbuild releases (observed on 11.10.0) ship a libSkiaSharp.so that
6-
# fails to load in this devcontainer:
5+
# Some bundled mxbuild releases (observed on 11.10.0) abort in this devcontainer
6+
# with:
77
# symbol lookup error: .../libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property
8-
# `mx check` does not need Skia, so we move the library aside, run the check, and
9-
# ALWAYS restore it afterwards — including on error or Ctrl-C — so that other
10-
# `mx` commands (which DO need Skia) keep working. Never leave the lib moved.
8+
# Root cause: mx runs under the Temurin JVM, whose bundled libfreetype is
9+
# stripped and lacks FT_Get_BDF_Property, so Skia loads the JVM's FreeType
10+
# instead of the system one (which has the symbol). Preloading the system
11+
# libfreetype makes it load first — fixing mx check/build/run while keeping Skia
12+
# working (unlike the old move-aside hack, which disabled Skia).
13+
#
14+
# Note: `mxcli docker check`/`build`/`new` now apply this fix automatically; this
15+
# wrapper is for invoking a bundled `mx` binary directly.
1116
#
1217
# Usage:
1318
# scripts/mx-check.sh -p <project.mpr> [--version X.Y.Z] [--mx /path/to/modeler/mx] [-- <extra mx check args>]
@@ -64,25 +69,27 @@ fi
6469

6570
[[ -x "$MX_BIN" ]] || { echo "error: mx binary not found or not executable: $MX_BIN" >&2; exit 1; }
6671

67-
LIB="$(dirname "$MX_BIN")/libSkiaSharp.so"
68-
BAK="$LIB.mxcli-bak"
72+
# Find the system libfreetype across common multiarch locations (works on
73+
# amd64, aarch64, …) and prepend it to LD_PRELOAD for the mx invocation only.
74+
FREETYPE=""
75+
for pat in /usr/lib/*/libfreetype.so.6 /usr/lib/libfreetype.so.6 /lib/*/libfreetype.so.6 /usr/local/lib/libfreetype.so.6; do
76+
for cand in $pat; do
77+
if [[ -f "$cand" ]]; then FREETYPE="$cand"; break 2; fi
78+
done
79+
done
6980

70-
# Always put the library back, whatever happens.
71-
restore() {
72-
if [[ -e "$BAK" ]]; then
73-
mv -f "$BAK" "$LIB"
81+
PRELOAD="${LD_PRELOAD:-}"
82+
if [[ -n "$FREETYPE" && "$PRELOAD" != *libfreetype.so* ]]; then
83+
if [[ -n "$PRELOAD" ]]; then
84+
PRELOAD="$FREETYPE:$PRELOAD"
85+
else
86+
PRELOAD="$FREETYPE"
7487
fi
75-
}
76-
trap restore EXIT INT TERM
77-
78-
# Move aside only if present (older/working builds may not need the workaround).
79-
if [[ -e "$LIB" ]]; then
80-
mv "$LIB" "$BAK"
8188
fi
8289

8390
# Run the check, preserving mx's exit code for callers / CI.
8491
set +e
85-
"$MX_BIN" check "$PROJECT" "${EXTRA[@]}"
92+
LD_PRELOAD="$PRELOAD" "$MX_BIN" check "$PROJECT" "${EXTRA[@]}"
8693
rc=$?
8794
set -e
8895
exit "$rc"

0 commit comments

Comments
 (0)