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
12 changes: 7 additions & 5 deletions packages/stim-cli/shim/android-optimizations.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
68 changes: 48 additions & 20 deletions packages/stim-cli/src/__tests__/android-path-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,62 @@
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 - 2 - 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 24-character root measured on Windows and refuses the 25-character one that failed', () => {
const object = GESTURE_HANDLER_OBJECT;
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:\\src\\trailhead-abcdefgh';
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: 239,
maxRootLength: 24,
});
});

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();
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', () => {
Expand All @@ -34,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();
});

Expand All @@ -53,21 +84,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.`,
`(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 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\\<name>`);
expect(remedy).not.toContain('subst X:');
});

test('doctor reports the room as an Android cost finding on win32 only, for the emulator ABI', () => {
Expand All @@ -82,7 +110,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', () => {
Expand Down
18 changes: 11 additions & 7 deletions packages/stim-cli/src/__tests__/doctor-cxx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);

Expand Down
2 changes: 1 addition & 1 deletion packages/stim-cli/src/__tests__/engine-gradle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/stim-cli/src/diagnostics/doctor-cxx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 59 additions & 15 deletions packages/stim-cli/src/engine/android-path-limit.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,20 +14,50 @@
export const ANDROID_OBJECT_PATH_MAX = 240;

/**
* 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>\<abi>\safeareacontext_autolinked_build/CMakeFiles/react_codegen_safeareacontext.dir/<md5>/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.
* CMake's single-config Ninja generator measures an object against CMAKE_OBJECT_PATH_MAX from
* `<target>.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
*/
export const LONGEST_ANDROID_OBJECT_PATH_TAIL = 198;
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. */
tail: number;
}

/**
* `\android\app\.cxx\<4 hex>\Debug\<8 chars>\<abi>\safeareacontext_autolinked_build/CMakeFiles/react_codegen_safeareacontext.dir/<md5>/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 };

/**
* 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>\<abi>\rngesturehandler_codegen_autolinked_build/CMakeFiles/react_codegen_rngesturehandler_codegen.dir/<md5>/RNGestureHandlerDetectorShadowNode.cpp.o`,
* measured on trailhead. https://github.com/appandflow/stim/issues/3641
*/
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';

export interface AndroidPathRoom {
root: string;
abi: string;
target: string;
longest: number;
maxRootLength: number;
}
Expand All @@ -34,25 +68,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 };
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, 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} ` +
`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.`
);
}

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\\<name>, ` +
'and run Stim there. A subst drive does not help: Node and Expo autolinking resolve it back to the long path.'
);
}
17 changes: 11 additions & 6 deletions packages/stim-cli/src/guide/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <root>\`, 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 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
short real path such as C:\\src\\<name> 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
Expand Down
3 changes: 2 additions & 1 deletion packages/stim-cli/src/guide/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<profile> (or custom staging root). Switching modes in Stim selects
.cxx/stim-<profile> (.cxx/<first 4 profile characters> 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.

Expand Down
4 changes: 3 additions & 1 deletion packages/stim-cli/src/guide/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
<module>/.cxx/stim-<profile>, or under the project's custom staging root. Switching
<module>/.cxx/stim-<profile> (<module>/.cxx/<first 4 profile characters> 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
Expand Down
2 changes: 1 addition & 1 deletion scripts/type-assertion-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
},
"test": {
"as": 543,
"nonNull": 793
"nonNull": 792
}
}
}
Loading
Loading