fix: run Claude Code where Android refuses its syscall - #378
Merged
Conversation
The Claude Code CLI asks for epoll_pwait2. Android refuses system calls an app is not on the list for, and a refusal there is a kill rather than the ENOSYS a runtime could fall back from, so the program dies the moment its event loop starts. The sign-in panel reports "Claude Code process terminated by signal SIGSYS" and a terminal prints "Bad system call", neither of which tells the user what happened or that nothing here can change it. Measured with a ptrace tracer against both emulators, same VSCodroid build (byte-identical APK) and same extension build on each: the refused call is 441, epoll_pwait2. It fails on Android 13 and runs on Android 17. An extension release four months older fails identically on Android 13, so this is the platform and not the extension version, and targetSdk has not moved since 1.1.0. The wrapper now reads the 159 the shell reports for a SIGSYS kill and says what was refused; the user guide records which versions were measured and that the filter is Android's to set, not ours.
…e call bionic's SYSCALLS.TXT gains epoll_pwait2 in android15 and does not carry it on the android13 or android14 branches, so the boundary is Android 15 rather than the open range the first version of this message described. The guide and the wrapper now say that instead of naming two measured points and leaving the middle unstated.
An app may make only the system calls bionic exposes in SYSCALLS.TXT, and epoll_pwait2 (441) is there from android15: absent on the android13 and android14 branches. The Claude Code CLI's runtime calls it as soon as its event loop starts, so on Android 13 and 14 the process was stopped there. The sign-in panel reported "Claude Code process terminated by signal SIGSYS" and a terminal printed "Bad system call", neither of which says what happened or that nothing on the device could change it. Android refuses with SECCOMP_RET_TRAP rather than a kill, so the refusal arrives as a catchable SIGSYS and can be answered. - libseccomp-shim.so is preloaded into the CLI and emulates that one call with epoll_pwait, rounding the timespec up so a sub-millisecond wait does not become a spin. Anything else refused is left to die as the platform intended. - It also interposes sigaction, because the CLI installs a SIGSYS handler of its own that re-raises through kill(): without this the emulation answers three calls and the fourth is fatal. - libclaude-launch.so is what claudeCode.claudeProcessWrapper now names. It puts the shim into LD_PRELOAD and execs musl's loader, rather than setting LD_PRELOAD globally, where a Bionic child would read the shim's musl-shaped sigaction translation and corrupt its own. - The shim links with --pack-dyn-relocs=none. Targeting API 30 or newer, lld emits DT_ANDROID_RELR, which musl 1.2.5 ignores, leaving its constructor pointer unrelocated and the CLI dead with SIGSEGV before it runs a line. Both that and any NEEDED entry fail the build. Verified on an API 33 emulator against an API 37 control, through the shipped launcher: claude --version exits 0 and a stream-json session reaches its own auth prompt, where both died with signal 31, and sign-in completes. No SIGSYS is left in the log.
Both are compiled from this repository's own source, so they carry no third-party obligation, but the attribution check knows only what it is told and refuses anything it cannot classify. The seccomp shim links against no library at all, which is what leaves it nothing to attribute beyond the root LICENSE.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Claude Code sign-in panel reported
Claude Code process terminated by signal SIGSYSon Android 13 and 14, and a terminal printedBad system call. Neithersays what happened, and both read as if VSCodroid broke something.
Root cause
An Android app may make only the system calls bionic exposes in
SYSCALLS.TXT,and
epoll_pwait2(441 on aarch64) appears there in android15: it is absentfrom
android13-releaseandandroid14-release. The CLI's runtime calls it assoon as its event loop starts, so the process was stopped there. That is why
claude --versionprinted and exited happily while anything that ran the loopdied.
Established with a ptrace tracer run inside the app (it has to live in
nativeLibraryDir, since SELinux refusesexecveunderfilesDir):Each alternative was measured and ruled out, not argued away: the extension
build from four months earlier fails identically; both emulators run a
byte-identical APK; DNS and HTTPS resolve on the failing device;
targetSdkhasbeen 36 since 1.1.0; and the failure reproduces with the CLI sandbox both on and
off.
The fix
Android refuses with
SECCOMP_RET_TRAPrather than a kill, so the refusalarrives as a catchable SIGSYS: the syscall does not run, and a handler that
returns resumes the thread with whatever it left in
x0. That is enough toanswer the call.
scripts/seccomp-shim.cbuildslibseccomp-shim.so, preloaded into the CLI.It emulates
epoll_pwait2withepoll_pwait, rounding the timespec up to thenext millisecond so a sub-millisecond wait does not become a spin. Any other
refused call is left to die as the platform intended: the handler restores
SIG_DFLand returns.sigaction. The CLI installs a SIGSYS handler of its ownthat re-raises through
kill(); without the interposition the emulationanswers three calls and the fourth is fatal. Every other signal is passed
through with the layout translated in both directions, since musl's
struct sigactionis not the kernel's.scripts/claude-launch.cbuildslibclaude-launch.so, which is whatclaudeCode.claudeProcessWrappernow names. It appends the shim toLD_PRELOADand execs musl's loader. SettingLD_PRELOADglobally would putthe shim in front of Bionic children too, which would read its musl-shaped
sigactiontranslation and corrupt their own.--pack-dyn-relocs=none. Targeting API 30 or newer, lldemits relative relocations in Android's packed form (
DT_ANDROID_RELR), whichmusl 1.2.5 does not implement: the constructor pointer in
.init_arraykeepsits link-time address and the CLI dies with SIGSEGV before running a line. The
build fails on either that or a
NEEDEDentry, which would put a second libcin a musl process.
requiredJniLibs, so an APK cannot ship without them, andbuild-all.sh,build.ymlandrelease.ymlbuild them alongside the othernative pieces.
docs/USER_GUIDE.mdnow says the platform limit is handled and what to report ifa future CLI reaches for a call the shim does not cover. The
claudewrapperkeeps its 159 check for the same case.
Verification
Measured on an API 33 emulator with an API 37 control, through the shipped
launcher rather than a hand-built one:
claude --version2.1.240 (Claude Code), status 0initJSON, then its own auth promptterminated by signal SIGSYSAPI 37 is unchanged: the syscall is allowed there, so the handler never fires.
2227 JVM tests, 0 failures; lint clean; the punctuation, document-date,
build-step, workflow-step, translatable-string, bundled-extension, binary-list,
checklist and instrumented-inventory gates and the nine JavaScript self-checks
all exit 0.
Sources: bionic
SYSCALLS.TXTon the android13, android14 and android15branches;
asm-generic/unistd.hfor the syscall number.