From 2872af81ec1f8899ab98a90d8f7083c6be7574d3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 04:10:31 -0400 Subject: [PATCH 1/3] fix: fit Windows Android native paths for RNGH 3 projects Shorten the Windows CMake profile directory to 4 hex characters, size the STIM_PATH_TOO_LONG preflight for react-native-gesture-handler 3 shared shadow nodes, and replace the subst remedy, which Expo autolinking defeats, with moving the project under a short real path. Fixes #3641 --- .../shim/android-optimizations.gradle | 12 ++-- .../src/__tests__/android-path-limit.test.ts | 60 +++++++++++++----- .../stim-cli/src/__tests__/doctor-cxx.test.ts | 18 +++--- .../stim-cli/src/diagnostics/doctor-cxx.ts | 2 +- .../stim-cli/src/engine/android-path-limit.ts | 62 +++++++++++++++---- packages/stim-cli/src/guide/errors.ts | 17 +++-- packages/stim-cli/src/guide/lifecycle.ts | 3 +- packages/stim-cli/src/guide/settings.ts | 4 +- scripts/type-assertion-baseline.json | 2 +- website/docs/android-cas.md | 1 + website/docs/build-optimizations.md | 4 +- website/docs/requirements.md | 18 +++--- 12 files changed, 143 insertions(+), 60 deletions(-) diff --git a/packages/stim-cli/shim/android-optimizations.gradle b/packages/stim-cli/shim/android-optimizations.gradle index edbc6b1a9..3ee667431 100644 --- a/packages/stim-cli/shim/android-optimizations.gradle +++ b/packages/stim-cli/shim/android-optimizations.gradle @@ -5,12 +5,14 @@ gradle.beforeProject { project -> def reactApplication = project.plugins.hasPlugin("com.android.application") && project.plugins.hasPlugin("com.facebook.react") if (!android.externalNativeBuild.cmake.path && !reactApplication) return def profile = System.getenv("STIM_ANDROID_NATIVE_PROFILE") + def windows = System.getProperty("os.name").toLowerCase().startsWith("windows") def staging = android.externalNativeBuild.cmake.buildStagingDirectory ?: new File(project.projectDir, ".cxx") - android.externalNativeBuild.cmake.buildStagingDirectory = new File(staging, "stim-" + profile) - // Windows caps a path at 260 characters and the SDK's ninja is not long-path aware, so the - // staging directory added above has to buy its room back: CMake replaces the mangled absolute - // source directory in an over-long object path with a hash of it. - if (System.getProperty("os.name").toLowerCase().startsWith("windows")) { + // Windows caps a path at 260 characters and the SDK's ninja is not long-path aware, so every + // character of the staging directory comes out of the project root's budget: there the profile + // directory keeps 4 hex characters, and CMake replaces the mangled absolute source directory in an + // over-long object path with a hash of it. + android.externalNativeBuild.cmake.buildStagingDirectory = new File(staging, windows ? profile.take(4) : "stim-" + profile) + if (windows) { android.defaultConfig.externalNativeBuild.cmake.arguments.add("-DCMAKE_OBJECT_PATH_MAX=240") } def configs = [android.defaultConfig] + android.buildTypes.toList() + android.productFlavors.toList() diff --git a/packages/stim-cli/src/__tests__/android-path-limit.test.ts b/packages/stim-cli/src/__tests__/android-path-limit.test.ts index 0e18723f6..de665273b 100644 --- a/packages/stim-cli/src/__tests__/android-path-limit.test.ts +++ b/packages/stim-cli/src/__tests__/android-path-limit.test.ts @@ -1,27 +1,56 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { expect, test } from 'vitest'; import { ANDROID_OBJECT_PATH_MAX, - LONGEST_ANDROID_OBJECT_PATH_TAIL, + GESTURE_HANDLER_OBJECT, + SAFE_AREA_CONTEXT_OBJECT, androidPathRoom, androidPathRoomMessage, androidPathRoomRemedy, + longestAndroidObject, } from '../engine/android-path-limit.ts'; import { checkAndroidPathRoom } from '../diagnostics/doctor.ts'; const rootOf = (length: number) => `D:\\${'a'.repeat(length - 3)}`; -const maxRoot = (abi: string) => ANDROID_OBJECT_PATH_MAX - 1 - LONGEST_ANDROID_OBJECT_PATH_TAIL - abi.length; +const maxRoot = (abi: string) => ANDROID_OBJECT_PATH_MAX - 1 - SAFE_AREA_CONTEXT_OBJECT.tail - abi.length; -test('the e2e fixture builds at its 33-character CI root and fails at the 36-character root measured on the debug host', () => { +test('the e2e fixture builds at its 33-character CI root', () => { expect(androidPathRoom('D:\\e\\sn-36AFO0\\e2e-1-1c17ed8f9e83', { abi: 'x86_64', platform: 'win32' })).toBeNull(); - const room = androidPathRoom('D:\\a\\stim\\stim\\e2e-tmp\\sn-nBa2Bi\\app', { abi: 'x86_64', platform: 'win32' }); - expect(room).toEqual({ - root: 'D:\\a\\stim\\stim\\e2e-tmp\\sn-nBa2Bi\\app', +}); + +test('react-native-gesture-handler 3 builds at the C:\\src roots measured on Windows and refuses one that cannot', () => { + const object = GESTURE_HANDLER_OBJECT; + expect(androidPathRoom('C:\\src\\trailhead', { abi: 'x86_64', platform: 'win32', object })).toBeNull(); + expect(androidPathRoom('C:\\stimw\\954971c6', { abi: 'arm64-v8a', platform: 'win32', object })).toBeNull(); + const root = 'C:\\Users\\runneradmin\\src\\trailhead'; + expect(androidPathRoom(root, { abi: 'x86_64', platform: 'win32', object })).toEqual({ + root, abi: 'x86_64', - longest: ANDROID_OBJECT_PATH_MAX, - maxRootLength: 35, + target: 'react_codegen_rngesturehandler_codegen', + longest: root.length + 214, + maxRootLength: 25, }); }); +test('the gesture handler object applies only where its shared shadow nodes resolve from the project', () => { + const dir = mkdtempSync(join(tmpdir(), 'stim-path-room-')); + try { + const app = join(dir, 'apps', 'mobile'); + mkdirSync(app, { recursive: true }); + expect(longestAndroidObject(app)).toBe(SAFE_AREA_CONTEXT_OBJECT); + const gestureHandler = join(dir, 'node_modules', 'react-native-gesture-handler'); + mkdirSync(join(gestureHandler, 'src'), { recursive: true }); + writeFileSync(join(gestureHandler, 'package.json'), '{}'); + expect(longestAndroidObject(app)).toBe(SAFE_AREA_CONTEXT_OBJECT); + mkdirSync(join(gestureHandler, 'shared', 'shadowNodes'), { recursive: true }); + expect(longestAndroidObject(app)).toBe(GESTURE_HANDLER_OBJECT); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('the last root that fits is one character shorter than the one that reaches the limit', () => { const abi = 'x86_64'; expect(androidPathRoom(rootOf(maxRoot(abi)), { abi, platform: 'win32' })).toBeNull(); @@ -53,21 +82,18 @@ test('only win32 has the limit', () => { expect(androidPathRoom(long, { platform: 'win32' })).not.toBeNull(); }); -test('the message names the root, its length, the ABI and the root cap; the remedy names subst', () => { +test('the message names the root, its length, the object, the ABI and the root cap; the remedy names a short real path', () => { const root = rootOf(maxRoot('x86_64') + 12); const room = androidPathRoom(root, { abi: 'x86_64', platform: 'win32' }); expect(room).not.toBeNull(); expect(androidPathRoomMessage(room!)).toBe( `The project root ${root} is ${root.length} characters, so the longest known Android native object path ` + `(react_codegen_safeareacontext for x86_64) reaches ${ANDROID_OBJECT_PATH_MAX + 11} characters, past the ` + - `${ANDROID_OBJECT_PATH_MAX - 1} the NDK's ninja can be given on Windows; the root can be at most 35 characters.`, - ); - expect(androidPathRoomRemedy(room!)).toBe( - `Map the project to a drive letter (\`subst X: "${root}"\`) and run Stim from X:\\, or move it under a shorter root.`, + `${ANDROID_OBJECT_PATH_MAX - 1} the NDK's ninja can be given on Windows; the root can be at most ${maxRoot('x86_64')} characters.`, ); - const spacedRoot = `${root} folder`; - const spacedRoom = androidPathRoom(spacedRoot, { abi: 'x86_64', platform: 'win32' }); - expect(androidPathRoomRemedy(spacedRoom!)).toContain(`subst X: "${spacedRoot}"`); + const remedy = androidPathRoomRemedy(room!); + expect(remedy).toContain(`at most ${maxRoot('x86_64')} characters, such as C:\\src\\`); + expect(remedy).not.toContain('subst X:'); }); test('doctor reports the room as an Android cost finding on win32 only, for the emulator ABI', () => { @@ -82,7 +108,7 @@ test('doctor reports the room as an Android cost finding on win32 only, for the expect(finding?.detail).toContain(`${root} is ${root.length} characters`); expect(finding?.detail).toContain('for x86_64'); expect(finding?.detail).toContain('STIM_PATH_TOO_LONG'); - expect(finding?.fix).toContain(`subst X: "${root}"`); + expect(finding?.fix).toContain(`at most ${maxRoot('x86_64')} characters`); }); test('doctor uses the Windows ARM64 emulator ABI when checking path room', () => { diff --git a/packages/stim-cli/src/__tests__/doctor-cxx.test.ts b/packages/stim-cli/src/__tests__/doctor-cxx.test.ts index 788c73db3..89f3abb0b 100644 --- a/packages/stim-cli/src/__tests__/doctor-cxx.test.ts +++ b/packages/stim-cli/src/__tests__/doctor-cxx.test.ts @@ -233,18 +233,22 @@ test('doctor preserves native configurations while a CAS toolchain Stim can use }); test.skipIf(process.platform === 'win32')( - 'legacy launcher repair preserves managed compiler profiles alongside stale legacy configurations (ccache PATH lookup is POSIX-only; skipped on win32)', + 'legacy launcher repair preserves managed compiler profiles, including the short Windows form, alongside stale legacy configurations (ccache PATH lookup is POSIX-only; skipped on win32)', () => { const legacy = cache('android/app', null); - const managed = join(root, 'android/app/.cxx/stim-0123456789abcdef/Debug/abc123/arm64-v8a'); - mkdirSync(managed, { recursive: true }); - writeFileSync( - join(managed, 'CMakeCache.txt'), - 'CMAKE_BUILD_TYPE:STRING=Debug\nCMAKE_CXX_COMPILER_LAUNCHER:STRING=\n', + const managed = ['stim-0123456789abcdef', '0123'].map((profile) => + join(root, `android/app/.cxx/${profile}/Debug/abc123/arm64-v8a`), ); + for (const dir of managed) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'CMakeCache.txt'), + 'CMAKE_BUILD_TYPE:STRING=Debug\nCMAKE_CXX_COMPILER_LAUNCHER:STRING=\n', + ); + } expect(repairCxxLauncherState(root, projectSettings()).removed).toHaveLength(1); expect(existsSync(legacy)).toBe(false); - expect(existsSync(managed)).toBe(true); + for (const dir of managed) expect(existsSync(dir)).toBe(true); }, ); diff --git a/packages/stim-cli/src/diagnostics/doctor-cxx.ts b/packages/stim-cli/src/diagnostics/doctor-cxx.ts index 6ed40d45f..c94e25fa5 100644 --- a/packages/stim-cli/src/diagnostics/doctor-cxx.ts +++ b/packages/stim-cli/src/diagnostics/doctor-cxx.ts @@ -43,7 +43,7 @@ function moduleRoots(root: string, layout: AndroidLayout): string[] { function cacheFiles(base: string, depth = 0): string[] { const files: string[] = []; for (const name of children(base)) { - if (depth === 0 && /^stim-[a-f0-9]{16}$/.test(name)) continue; + if (depth === 0 && /^(?:stim-[a-f0-9]{16}|[a-f0-9]{4})$/.test(name)) continue; const path = join(base, name); const entry = lstatSync(path); if (entry.isSymbolicLink()) continue; diff --git a/packages/stim-cli/src/engine/android-path-limit.ts b/packages/stim-cli/src/engine/android-path-limit.ts index e1d60e9d5..048e1a04d 100644 --- a/packages/stim-cli/src/engine/android-path-limit.ts +++ b/packages/stim-cli/src/engine/android-path-limit.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { resolvePackageJson } from '../workspace/project-files.ts'; + /** * The NDK's ninja is not long-path aware, so every object path it opens has to stay under Windows' * MAX_PATH. CMake names the object for a source outside its source directory after that source's @@ -9,14 +13,35 @@ */ export const ANDROID_OBJECT_PATH_MAX = 240; +export interface AndroidLongestObject { + target: string; + /** Characters the shortened object path adds to the project root and the ABI name. */ + tail: number; +} + +/** + * `\android\app\.cxx\<4 hex>\Debug\<8 chars>\\safeareacontext_autolinked_build/CMakeFiles/react_codegen_safeareacontext.dir//RNCSafeAreaViewShadowNode.cpp.o`, + * measured on the bare e2e fixture and on trailhead (Expo SDK 58, RN 0.88). + */ +export const SAFE_AREA_CONTEXT_OBJECT: AndroidLongestObject = { target: 'react_codegen_safeareacontext', tail: 181 }; + /** - * Characters the longest known shortened object path adds to the project root and the ABI name, - * measured on the bare e2e fixture at a 36-character root: - * `\android\app\.cxx\stim-<16 hex>\Debug\<8 chars>\\safeareacontext_autolinked_build/CMakeFiles/react_codegen_safeareacontext.dir//RNCSafeAreaViewShadowNode.cpp.o`. - * At that root the path reaches ANDROID_OBJECT_PATH_MAX and CMake leaves it unshortened (341 - * characters); the same fixture builds at a 33-character root. + * react-native-gesture-handler 3 compiles shadow nodes from its `shared/shadowNodes` directory into + * its codegen library: + * `\android\app\.cxx\<4 hex>\Debug\<8 chars>\\rngesturehandler_codegen_autolinked_build/CMakeFiles/react_codegen_rngesturehandler_codegen.dir//RNGestureHandlerDetectorShadowNode.cpp.o`, + * measured on trailhead. https://github.com/appandflow/stim/issues/3641 */ -export const LONGEST_ANDROID_OBJECT_PATH_TAIL = 198; +export const GESTURE_HANDLER_OBJECT: AndroidLongestObject = { + target: 'react_codegen_rngesturehandler_codegen', + tail: 208, +}; + +export function longestAndroidObject(root: string): AndroidLongestObject { + const gestureHandler = resolvePackageJson(root, 'react-native-gesture-handler'); + return gestureHandler && existsSync(join(dirname(gestureHandler), 'shared', 'shadowNodes')) + ? GESTURE_HANDLER_OBJECT + : SAFE_AREA_CONTEXT_OBJECT; +} /** The ABI name a build for every ABI has to fit; `stim android` narrows a build to the device's. */ const LONGEST_ANDROID_ABI = 'armeabi-v7a'; @@ -24,6 +49,7 @@ const LONGEST_ANDROID_ABI = 'armeabi-v7a'; export interface AndroidPathRoom { root: string; abi: string; + target: string; longest: number; maxRootLength: number; } @@ -34,25 +60,35 @@ export function androidPathRoom( abi = null, variant = null, platform = process.platform, - }: { abi?: string | null; variant?: string | null; platform?: NodeJS.Platform } = {}, + object = null, + }: { + abi?: string | null; + variant?: string | null; + platform?: NodeJS.Platform; + object?: AndroidLongestObject | null; + } = {}, ): AndroidPathRoom | null { if (platform !== 'win32') return null; + const { target, tail: objectTail } = object ?? longestAndroidObject(root); const abiName = abi ?? LONGEST_ANDROID_ABI; const configurationLength = /release$/i.test(variant ?? '') ? 'RelWithDebInfo'.length : 'Debug'.length; - const tail = LONGEST_ANDROID_OBJECT_PATH_TAIL + configurationLength - 'Debug'.length + abiName.length; + const tail = objectTail + configurationLength - 'Debug'.length + abiName.length; const longest = root.length + tail; if (longest < ANDROID_OBJECT_PATH_MAX) return null; - return { root, abi: abiName, longest, maxRootLength: ANDROID_OBJECT_PATH_MAX - 1 - tail }; + return { root, abi: abiName, target, longest, maxRootLength: ANDROID_OBJECT_PATH_MAX - 1 - tail }; } -export function androidPathRoomMessage({ root, abi, longest, maxRootLength }: AndroidPathRoom): string { +export function androidPathRoomMessage({ root, abi, target, longest, maxRootLength }: AndroidPathRoom): string { return ( `The project root ${root} is ${root.length} characters, so the longest known Android native object path ` + - `(react_codegen_safeareacontext for ${abi}) reaches ${longest} characters, past the ${ANDROID_OBJECT_PATH_MAX - 1} ` + + `(${target} for ${abi}) reaches ${longest} characters, past the ${ANDROID_OBJECT_PATH_MAX - 1} ` + `the NDK's ninja can be given on Windows; the root can be at most ${maxRootLength} characters.` ); } -export function androidPathRoomRemedy({ root }: AndroidPathRoom): string { - return `Map the project to a drive letter (\`subst X: "${root}"\`) and run Stim from X:\\, or move it under a shorter root.`; +export function androidPathRoomRemedy({ maxRootLength }: AndroidPathRoom): string { + return ( + `Move or clone the project to a real path of at most ${maxRootLength} characters, such as C:\\src\\, ` + + 'and run Stim there. A subst drive does not help: Node and Expo autolinking resolve it back to the long path.' + ); } diff --git a/packages/stim-cli/src/guide/errors.ts b/packages/stim-cli/src/guide/errors.ts index ab47bd3f5..d47dbc857 100644 --- a/packages/stim-cli/src/guide/errors.ts +++ b/packages/stim-cli/src/guide/errors.ts @@ -184,12 +184,17 @@ Branch on the code, never on the message.`, after their mangled absolute source path, so each carries the project root twice unless CMake shortens it, and CMake only shortens a name that then fits the object path limit Stim's Gradle shim sets. The message names the - longest known object (measured on the e2e fixture, for the ABI being - built) and the longest root it leaves room for. Refused BEFORE Gradle runs - -- a cache hit still installs -- because the build would otherwise fail - deep inside ninja ("mkdir ... No such file or directory" or "Filename - longer than 260 characters"). Map the project to a drive letter (\`subst - X: \`, then run Stim from X:\\) or move it under a shorter root. + longest known object for the ABI being built and the longest root it + leaves room for. A project with react-native-gesture-handler 3, whose + shared shadow nodes have the longest known object path, fits a root of at + most 25 characters for an x86_64 Debug build and 22 for arm64-v8a; other + projects fit 52 and 49. Refused BEFORE Gradle + runs -- a cache hit still installs -- because the build would otherwise + fail deep inside ninja ("mkdir ... No such file or directory" or + "Filename longer than 260 characters"). Move or clone the project under a + short real path such as C:\\src\\ and run Stim there. A subst drive + does not help: Node's realpath and Expo autolinking resolve it back to the + long path, and Gradle then sees two roots. A custom Gradle buildStagingDirectory can be shorter than the default; this preflight does not inspect that configured path and may refuse it too. \`doctor --platform android\` reports the same root as a finding for the diff --git a/packages/stim-cli/src/guide/lifecycle.ts b/packages/stim-cli/src/guide/lifecycle.ts index bb34e880f..564a2f11a 100644 --- a/packages/stim-cli/src/guide/lifecycle.ts +++ b/packages/stim-cli/src/guide/lifecycle.ts @@ -1597,7 +1597,8 @@ then compiles through the cache the selection leaves -- ccache, or none when compilerCache is none. It never refuses. See https://stim.appandflow.com/docs/android-cas for setup, evidence, and limits. Compiler/PCH modes have separate generated directories under each module's -.cxx/stim- (or custom staging root). Switching modes in Stim selects +.cxx/stim- (.cxx/ on Windows, or custom +staging root). Switching modes in Stim selects the matching directory; direct Gradle builds keep their own configuration. Generated CAS directories still depend on Stim's environment and adapter paths. diff --git a/packages/stim-cli/src/guide/settings.ts b/packages/stim-cli/src/guide/settings.ts index 7e30d5e7b..a6da2791e 100644 --- a/packages/stim-cli/src/guide/settings.ts +++ b/packages/stim-cli/src/guide/settings.ts @@ -1194,7 +1194,9 @@ PCH mode matches; disable artifact caching too to force native compilation. Lega cannot key these compiler profiles, so Stim skips that tier for custom profiles and Android CAS. Providers implementing the Stim key contract remain usable. Android compiler/PCH profiles also get separate CMake staging directories under -/.cxx/stim-, or under the project's custom staging root. Switching +/.cxx/stim- (/.cxx/ on +Windows, where every character counts against MAX_PATH), or under the project's +custom staging root. Switching backends in Stim selects the matching directory without deleting previous builds. These directories accumulate across profile changes and shim upgrades. Stim does not prune them; remove an obsolete generated profile only with all native diff --git a/scripts/type-assertion-baseline.json b/scripts/type-assertion-baseline.json index 46e9289ce..1e46187e7 100644 --- a/scripts/type-assertion-baseline.json +++ b/scripts/type-assertion-baseline.json @@ -106,7 +106,7 @@ }, "test": { "as": 543, - "nonNull": 793 + "nonNull": 792 } } } diff --git a/website/docs/android-cas.md b/website/docs/android-cas.md index fd0ccb008..0220f225f 100644 --- a/website/docs/android-cas.md +++ b/website/docs/android-cas.md @@ -222,6 +222,7 @@ probe is the independently runnable correctness evidence. and different build-system layouts need coverage. - Generated CAS configurations retain adapter paths and depend on the CAS environment. Stim separates compiler/PCH modes under `.cxx/stim-` + (`.cxx/` on Windows) so switching back to `compilerCache: "ccache"` or `"none"` uses a different configuration without deleting evidence. Direct Gradle uses its own staging directory. Legacy unprofiled configurations need their previous cleanup flow. diff --git a/website/docs/build-optimizations.md b/website/docs/build-optimizations.md index c2726213f..5e3121c11 100644 --- a/website/docs/build-optimizations.md +++ b/website/docs/build-optimizations.md @@ -161,7 +161,9 @@ Legacy Expo cache providers cannot distinguish these custom compiler profiles and are skipped for them; providers using Stim's cache-key contract remain usable. Android compiler and PCH profiles also use separate generated CMake directories -under each module's `.cxx/stim-` or its custom staging root. Switching +under each module's `.cxx/stim-` (`.cxx/` +on Windows, where every character counts against `MAX_PATH`) or its custom +staging root. Switching profiles keeps previous output for reuse. These directories can accumulate; there is no profile-pruning command. Remove obsolete generated profiles only after all native builds have stopped, or let worktree removal reclaim them. diff --git a/website/docs/requirements.md b/website/docs/requirements.md index 89920b152..e9e2e759d 100644 --- a/website/docs/requirements.md +++ b/website/docs/requirements.md @@ -70,11 +70,16 @@ android` reports it and `stim android` refuses with `STIM_BUILD_FAILED` - A working Java and Gradle setup for the project. - On Windows, a project root short enough for the NDK's object paths: its ninja cannot open a path of 260 characters or more, and a React Native - codegen object path only fits when CMake can shorten it. `stim doctor ---platform android` reports a root that leaves no room and `stim android` - refuses it with `STIM_PATH_TOO_LONG` before Gradle runs; `subst X: ""` - and working from `X:\` is the usual fix. The check assumes the default native - staging path and may also refuse a shorter custom `buildStagingDirectory`. + codegen object path only fits when CMake can shorten it. A project with + react-native-gesture-handler 3 fits a root of at most 25 characters for an + x86_64 emulator build and 22 for arm64-v8a, such as `C:\src\trailhead`; + other projects fit about twice that. `stim doctor --platform android` + reports a root that leaves no room and `stim android` refuses it with + `STIM_PATH_TOO_LONG` before Gradle runs. Move or clone the project under a + short real path such as `C:\src\`. A `subst` drive does not work: Node + and Expo autolinking resolve it back to the long path. The check + assumes the default native staging path and may also refuse a shorter + custom `buildStagingDirectory`. ## Windows @@ -90,8 +95,7 @@ is no local Xcode path. Requirements on top of the Android list: - Emulator acceleration: WHPX (Windows Hypervisor Platform) or the Android Emulator hypervisor driver. `emulator -accel-check` reports which is active. - JDK 17 on `JAVA_HOME` or `PATH`. -- A short project path or a `subst` drive, as described in the Android path - requirement above. +- A short project path, as described in the Android path requirement above. On Windows, `stim doctor --platform android` checks path room, and `stim doctor --platform ios` points at `--remote eas` for iOS. From e4d6f644c93717d1d9727ee1c484e9229c2094ce Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 05:04:33 -0400 Subject: [PATCH 2/3] fix: account for the ./ CMake adds to the Ninja object directory CMake's single-config Ninja generator measures objects from .dir/./, so a shortened object path fits only up to 238 characters. A trailhead build at a 25-character root failed at 239. --- .../src/__tests__/android-path-limit.test.ts | 22 ++++++++++--------- .../stim-cli/src/engine/android-path-limit.ts | 16 ++++++++++---- packages/stim-cli/src/guide/errors.ts | 4 ++-- website/docs/requirements.md | 4 ++-- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/stim-cli/src/__tests__/android-path-limit.test.ts b/packages/stim-cli/src/__tests__/android-path-limit.test.ts index de665273b..76ea4d886 100644 --- a/packages/stim-cli/src/__tests__/android-path-limit.test.ts +++ b/packages/stim-cli/src/__tests__/android-path-limit.test.ts @@ -14,23 +14,23 @@ import { import { checkAndroidPathRoom } from '../diagnostics/doctor.ts'; const rootOf = (length: number) => `D:\\${'a'.repeat(length - 3)}`; -const maxRoot = (abi: string) => ANDROID_OBJECT_PATH_MAX - 1 - SAFE_AREA_CONTEXT_OBJECT.tail - abi.length; +const maxRoot = (abi: string) => ANDROID_OBJECT_PATH_MAX - 2 - SAFE_AREA_CONTEXT_OBJECT.tail - abi.length; test('the e2e fixture builds at its 33-character CI root', () => { expect(androidPathRoom('D:\\e\\sn-36AFO0\\e2e-1-1c17ed8f9e83', { abi: 'x86_64', platform: 'win32' })).toBeNull(); }); -test('react-native-gesture-handler 3 builds at the C:\\src roots measured on Windows and refuses one that cannot', () => { +test('react-native-gesture-handler 3 builds at the 24-character root measured on Windows and refuses the 25-character one that failed', () => { const object = GESTURE_HANDLER_OBJECT; - expect(androidPathRoom('C:\\src\\trailhead', { abi: 'x86_64', platform: 'win32', object })).toBeNull(); + expect(androidPathRoom('C:\\src\\trailhead-abcdefg', { abi: 'x86_64', platform: 'win32', object })).toBeNull(); expect(androidPathRoom('C:\\stimw\\954971c6', { abi: 'arm64-v8a', platform: 'win32', object })).toBeNull(); - const root = 'C:\\Users\\runneradmin\\src\\trailhead'; + const root = 'C:\\src\\trailhead-abcdefgh'; expect(androidPathRoom(root, { abi: 'x86_64', platform: 'win32', object })).toEqual({ root, abi: 'x86_64', target: 'react_codegen_rngesturehandler_codegen', - longest: root.length + 214, - maxRootLength: 25, + longest: 239, + maxRootLength: 24, }); }); @@ -54,7 +54,9 @@ test('the gesture handler object applies only where its shared shadow nodes reso test('the last root that fits is one character shorter than the one that reaches the limit', () => { const abi = 'x86_64'; expect(androidPathRoom(rootOf(maxRoot(abi)), { abi, platform: 'win32' })).toBeNull(); - expect(androidPathRoom(rootOf(maxRoot(abi) + 1), { abi, platform: 'win32' })?.longest).toBe(ANDROID_OBJECT_PATH_MAX); + expect(androidPathRoom(rootOf(maxRoot(abi) + 1), { abi, platform: 'win32' })?.longest).toBe( + ANDROID_OBJECT_PATH_MAX - 1, + ); }); test('release variants account for the longer RelWithDebInfo directory', () => { @@ -63,7 +65,7 @@ test('release variants account for the longer RelWithDebInfo directory', () => { expect(androidPathRoom(rootOf(releaseRoot), { abi, variant: 'productionRelease', platform: 'win32' })).toBeNull(); expect( androidPathRoom(rootOf(releaseRoot + 1), { abi, variant: 'productionRelease', platform: 'win32' })?.longest, - ).toBe(ANDROID_OBJECT_PATH_MAX); + ).toBe(ANDROID_OBJECT_PATH_MAX - 1); expect(androidPathRoom(rootOf(maxRoot(abi)), { abi, variant: 'productionDebug', platform: 'win32' })).toBeNull(); }); @@ -88,8 +90,8 @@ test('the message names the root, its length, the object, the ABI and the root c expect(room).not.toBeNull(); expect(androidPathRoomMessage(room!)).toBe( `The project root ${root} is ${root.length} characters, so the longest known Android native object path ` + - `(react_codegen_safeareacontext for x86_64) reaches ${ANDROID_OBJECT_PATH_MAX + 11} characters, past the ` + - `${ANDROID_OBJECT_PATH_MAX - 1} the NDK's ninja can be given on Windows; the root can be at most ${maxRoot('x86_64')} characters.`, + `(react_codegen_safeareacontext for x86_64) reaches ${ANDROID_OBJECT_PATH_MAX + 10} characters, past the ` + + `${ANDROID_OBJECT_PATH_MAX - 2} CMake can shorten it to for the NDK's ninja on Windows; the root can be at most ${maxRoot('x86_64')} characters.`, ); const remedy = androidPathRoomRemedy(room!); expect(remedy).toContain(`at most ${maxRoot('x86_64')} characters, such as C:\\src\\`); diff --git a/packages/stim-cli/src/engine/android-path-limit.ts b/packages/stim-cli/src/engine/android-path-limit.ts index 048e1a04d..105966666 100644 --- a/packages/stim-cli/src/engine/android-path-limit.ts +++ b/packages/stim-cli/src/engine/android-path-limit.ts @@ -13,6 +13,14 @@ import { resolvePackageJson } from '../workspace/project-files.ts'; */ export const ANDROID_OBJECT_PATH_MAX = 240; +/** + * CMake's single-config Ninja generator measures an object against CMAKE_OBJECT_PATH_MAX from + * `.dir/./`, two characters longer than the directory the object is written to, so a + * shortened object path fits only up to this length. + * https://github.com/Kitware/CMake/blob/v3.22.1/Source/cmGlobalNinjaGenerator.cxx#L1005-L1013 + */ +const LONGEST_SHORTENED_OBJECT_PATH = ANDROID_OBJECT_PATH_MAX - './'.length; + export interface AndroidLongestObject { target: string; /** Characters the shortened object path adds to the project root and the ABI name. */ @@ -74,15 +82,15 @@ export function androidPathRoom( const configurationLength = /release$/i.test(variant ?? '') ? 'RelWithDebInfo'.length : 'Debug'.length; const tail = objectTail + configurationLength - 'Debug'.length + abiName.length; const longest = root.length + tail; - if (longest < ANDROID_OBJECT_PATH_MAX) return null; - return { root, abi: abiName, target, longest, maxRootLength: ANDROID_OBJECT_PATH_MAX - 1 - tail }; + if (longest <= LONGEST_SHORTENED_OBJECT_PATH) return null; + return { root, abi: abiName, target, longest, maxRootLength: LONGEST_SHORTENED_OBJECT_PATH - tail }; } export function androidPathRoomMessage({ root, abi, target, longest, maxRootLength }: AndroidPathRoom): string { return ( `The project root ${root} is ${root.length} characters, so the longest known Android native object path ` + - `(${target} for ${abi}) reaches ${longest} characters, past the ${ANDROID_OBJECT_PATH_MAX - 1} ` + - `the NDK's ninja can be given on Windows; the root can be at most ${maxRootLength} characters.` + `(${target} for ${abi}) reaches ${longest} characters, past the ${LONGEST_SHORTENED_OBJECT_PATH} ` + + `CMake can shorten it to for the NDK's ninja on Windows; the root can be at most ${maxRootLength} characters.` ); } diff --git a/packages/stim-cli/src/guide/errors.ts b/packages/stim-cli/src/guide/errors.ts index d47dbc857..35e4c595f 100644 --- a/packages/stim-cli/src/guide/errors.ts +++ b/packages/stim-cli/src/guide/errors.ts @@ -187,8 +187,8 @@ Branch on the code, never on the message.`, longest known object for the ABI being built and the longest root it leaves room for. A project with react-native-gesture-handler 3, whose shared shadow nodes have the longest known object path, fits a root of at - most 25 characters for an x86_64 Debug build and 22 for arm64-v8a; other - projects fit 52 and 49. Refused BEFORE Gradle + most 24 characters for an x86_64 Debug build and 21 for arm64-v8a; other + projects fit 51 and 48. Refused BEFORE Gradle runs -- a cache hit still installs -- because the build would otherwise fail deep inside ninja ("mkdir ... No such file or directory" or "Filename longer than 260 characters"). Move or clone the project under a diff --git a/website/docs/requirements.md b/website/docs/requirements.md index e9e2e759d..83118ac2f 100644 --- a/website/docs/requirements.md +++ b/website/docs/requirements.md @@ -71,8 +71,8 @@ android` reports it and `stim android` refuses with `STIM_BUILD_FAILED` - On Windows, a project root short enough for the NDK's object paths: its ninja cannot open a path of 260 characters or more, and a React Native codegen object path only fits when CMake can shorten it. A project with - react-native-gesture-handler 3 fits a root of at most 25 characters for an - x86_64 emulator build and 22 for arm64-v8a, such as `C:\src\trailhead`; + react-native-gesture-handler 3 fits a root of at most 24 characters for an + x86_64 emulator build and 21 for arm64-v8a, such as `C:\src\trailhead`; other projects fit about twice that. `stim doctor --platform android` reports a root that leaves no room and `stim android` refuses it with `STIM_PATH_TOO_LONG` before Gradle runs. Move or clone the project under a From 795a982c73debfe0899fdec80306f573ee49a37b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 05:35:50 -0400 Subject: [PATCH 3/3] test: keep the Windows path refusal root longer than the new limit On Linux CI the temp root is short enough to fit a project without RNGH 3, so the refusal case needs a root longer than 51 characters everywhere. --- packages/stim-cli/src/__tests__/engine-gradle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stim-cli/src/__tests__/engine-gradle.test.ts b/packages/stim-cli/src/__tests__/engine-gradle.test.ts index a7ce74808..6d42a6974 100644 --- a/packages/stim-cli/src/__tests__/engine-gradle.test.ts +++ b/packages/stim-cli/src/__tests__/engine-gradle.test.ts @@ -403,7 +403,7 @@ const nativeScript = join(import.meta.dirname, '../../shim/android-optimizations describe('buildAndroid', () => { test('refuses a Windows native build with an object path that cannot fit before invoking Gradle', async () => { - const longRoot = join(root, 'windows-object-path-limit'); + const longRoot = join(root, 'windows-object-path-limit'.padEnd(60, '-')); mkdirSync(join(longRoot, 'android'), { recursive: true }); writeFileSync(gradlewPath(defaultAndroidLayout(longRoot)), ''); let spawned = false;