diff --git a/CHANGELOG.md b/CHANGELOG.md index f820b39..1ff98af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ This file records user-visible changes to the generator. Documentation on the default branch may be newer than the latest published package; released users should also check their installed version with `supernote-module --version`. +## Unreleased + +- Generate one KSP compiler option per feature so plugins with multiple V2 + features compile instead of passing an invalid newline-bearing option. +- Allow native-only, JVM-only, and mixed feature sets by ignoring absent + optional implementation roots when declaring Gradle task inputs. +- Include generator templates explicitly in source distributions so every + permitted build backend produces an installable, usable wheel. + ## 2.0.2 - 2026-08-15 - Make Add and Remove rollback restore Android application integration exactly, diff --git a/MANIFEST.in b/MANIFEST.in index 98d36bb..5ab9ce5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,4 +6,5 @@ recursive-include docs *.md recursive-include maintainers *.md recursive-include architecture *.md recursive-include tests *.py +recursive-include src/supernote_module_generator/templates * global-exclude __pycache__ *.py[cod] diff --git a/src/supernote_module_generator/cli.py b/src/supernote_module_generator/cli.py index f1b77cb..31fdbe7 100644 --- a/src/supernote_module_generator/cli.py +++ b/src/supernote_module_generator/cli.py @@ -35,6 +35,7 @@ from .operation_lock import plugin_operation_lock from .project import managed_modules, resolve_plugin_root from .rendering import Renderer, TerminalCapabilities +from .subprocesses import run_process from .transaction import recover_pending from .workflows import ReturnToMenu @@ -296,14 +297,7 @@ def _exception_result(command: str, exc: Exception, debug: bool) -> CommandResul def _startup_reconcile(root: Path, command: List[str]) -> bool: try: - result = subprocess.run( - command, - cwd=root, - capture_output=True, - text=True, - timeout=600, - check=False, - ) + result = run_process(command, cwd=root, timeout=600) except (OSError, subprocess.TimeoutExpired): return False return result.returncode == 0 diff --git a/src/supernote_module_generator/doctor.py b/src/supernote_module_generator/doctor.py index 24207ad..1f565a8 100644 --- a/src/supernote_module_generator/doctor.py +++ b/src/supernote_module_generator/doctor.py @@ -10,6 +10,11 @@ from typing import Callable, ContextManager, List, Optional, Sequence, Tuple from .models import CommandResult, DoctorCheckResult, DoctorResult, ErrorInfo +from .platform_tools import ( + gradle_wrapper_command, + gradle_wrapper_path, + ndk_compiler_path, +) from .project import manager_evidence, resolve_plugin_root from .rendering import ProgressReporter, Renderer from .subprocesses import run_process @@ -27,6 +32,31 @@ def _version_tuple(value: Optional[str]) -> Tuple[int, ...]: return parts +def _gradle_version(output: str) -> Optional[str]: + match = re.search(r"^Gradle\s+([^\s]+)", output, flags=re.MULTILINE) + return match.group(1) if match else None + + +def _gradle_jvm_lines(output: str) -> Tuple[Optional[str], Optional[str]]: + """Return an effective version and daemon Java home when Gradle reports them.""" + legacy = re.search(r"^JVM:\s*([^\s]+)", output, flags=re.MULTILINE) + launcher = re.search(r"^Launcher JVM:\s*([^\s]+)", output, flags=re.MULTILINE) + daemon = re.search(r"^Daemon JVM:\s*(.+)$", output, flags=re.MULTILINE) + daemon_home = None + if daemon: + value = daemon.group(1).strip() + value = re.sub(r"\s+\((?:from|no JDK specified).*$", "", value) + if value and ( + value.startswith(("/", "~", ".")) + or re.match(r"^[A-Za-z]:[\\/]", value) + ): + daemon_home = value + return ( + legacy.group(1) if legacy else launcher.group(1) if launcher else None, + daemon_home, + ) + + class DoctorService: def __init__( self, @@ -34,11 +64,13 @@ def __init__( renderer: Renderer, *, run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + platform_name: Optional[str] = None, ) -> None: self.cwd = cwd.expanduser().resolve() self.renderer = renderer self.progress = ProgressReporter(renderer) self.run = run + self.platform_name = os.name if platform_name is None else platform_name def _phase(self, active: str, completed: str) -> ContextManager[object]: # Plain output is commonly redirected or read linearly. The final Doctor @@ -133,18 +165,30 @@ def execute(self, scope: str) -> CommandResult: ) return CommandResult("doctor", doctor=doctor) - @staticmethod def _required_issue_next_action( + self, failed: Sequence[DoctorCheckResult], ) -> str: - if len(failed) == 1 and failed[0].id == "gradle_wrapper": - if failed[0].path is None: + failed_ids = {check.id for check in failed} + if failed_ids in ({"gradle_wrapper"}, {"gradle_wrapper", "gradle_jvm"}): + wrapper = next(check for check in failed if check.id == "gradle_wrapper") + relative = ( + "android/gradlew.bat" + if self.platform_name == "nt" + else "android/gradlew" + ) + if wrapper.path is None: + if self.platform_name == "nt": + return ( + f"Restore `{relative}`, then rerun " + "`supernote-module doctor`." + ) return ( - "Restore `android/gradlew`, make it executable, then rerun " + f"Restore `{relative}`, make it executable, then rerun " "`supernote-module doctor`." ) return ( - "Fix `android/gradlew` so it executes successfully, then rerun " + f"Fix `{relative}` so it executes successfully, then rerun " "`supernote-module doctor`." ) return ( @@ -154,12 +198,16 @@ def _required_issue_next_action( def _probe(self, command: Sequence[str], timeout: int = 10) -> Tuple[bool, Optional[str], str]: try: - if self.renderer.mode == "verbose" and self.run is subprocess.run: + if self.run is subprocess.run: result = run_process( command, cwd=self.cwd, timeout=timeout, - stream=self._verbose_stream, + stream=( + self._verbose_stream + if self.renderer.mode == "verbose" + else None + ), ) else: result = self.run( @@ -287,12 +335,17 @@ def _android_checks(self, root: Path, valid_root: bool) -> List[DoctorCheckResul else "ANDROID_HOME or ANDROID_SDK_ROOT does not identify an SDK with platform 35.", ) if valid_root: - gradle = root / "android" / ("gradlew.bat" if os.name == "nt" else "gradlew") + gradle = gradle_wrapper_path(root, platform_name=self.platform_name) if gradle.is_file(): - command = [str(gradle), "--version"] if os.access(gradle, os.X_OK) else ["sh", str(gradle), "--version"] - passed, version, _ = self._probe(command, timeout=120) + command = gradle_wrapper_command( + gradle, + ["--version"], + platform_name=self.platform_name, + ) + passed, _, gradle_output = self._probe(command, timeout=120) + version = _gradle_version(gradle_output) else: - passed, version = False, None + passed, version, gradle_output = False, None, "" gradle_exists = gradle.is_file() gradle_check = DoctorCheckResult( "gradle_wrapper", @@ -309,6 +362,11 @@ def _android_checks(self, root: Path, valid_root: bool) -> List[DoctorCheckResul else "The project Gradle wrapper is missing." ), ) + gradle_jvm_check = self._gradle_jvm_check( + gradle_output, + wrapper_passed=passed, + shell_java=java_check, + ) else: gradle_check = DoctorCheckResult( "gradle_wrapper", @@ -319,7 +377,85 @@ def _android_checks(self, root: Path, valid_root: bool) -> List[DoctorCheckResul None, "The project Gradle wrapper is unavailable outside a plugin root.", ) - return [java_check, sdk_check, gradle_check] + gradle_jvm_check = DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "failed", + None, + None, + "The Gradle JVM is unavailable outside a plugin root.", + ) + return [java_check, sdk_check, gradle_check, gradle_jvm_check] + + def _gradle_jvm_check( + self, + output: str, + *, + wrapper_passed: bool, + shell_java: DoctorCheckResult, + ) -> DoctorCheckResult: + if not wrapper_passed: + return DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "failed", + None, + None, + "The Gradle JVM could not be inspected because the wrapper failed.", + ) + reported_version, daemon_home = _gradle_jvm_lines(output) + detected = reported_version + path = daemon_home + if daemon_home: + executable = Path(daemon_home).expanduser() / "bin" / ( + "java.exe" if self.platform_name == "nt" else "java" + ) + passed, detected, _ = self._probe([str(executable), "--version"]) + if not passed: + return DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "failed", + detected, + str(executable), + "Gradle reported a daemon JVM that could not be executed.", + ) + path = str(executable) + if not detected: + return DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "failed", + None, + shell_java.path, + "Gradle did not report the JVM that will run the Android build.", + ) + gradle_java = _version_tuple(detected) + if gradle_java < (17,) or gradle_java >= (24,): + return DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "failed", + detected, + path, + "The effective Gradle JVM is outside the Java 17 through 23 " + "range supported by the generated Gradle 8.13 build; check " + "JAVA_HOME and org.gradle.java.home. Java 17 is recommended.", + ) + return DoctorCheckResult( + "gradle_jvm", + "Gradle JVM", + "required", + "passed", + detected, + path, + "The effective Gradle JVM is supported (Java 17 through 23).", + ) def _native_checks(self) -> List[DoctorCheckResult]: cmake = self._tool_check("cmake", "CMake", "cmake") @@ -367,9 +503,17 @@ def _native_checks(self) -> List[DoctorCheckResult]: ) detected_version = match.group(1).strip() if match else ndk.name prebuilt = ndk / "toolchains/llvm/prebuilt" - clang = next(iter(sorted(prebuilt.glob("*/bin/clang"))), None) if prebuilt.is_dir() else None + clang = ndk_compiler_path( + prebuilt, + "clang", + platform_name=self.platform_name, + ) if clang is not None: - clangxx = clang.with_name("clang++") + clangxx = ndk_compiler_path( + prebuilt, + "clang++", + platform_name=self.platform_name, + ) clang_ok, _, _ = self._probe([str(clang), "--version"]) c23_ok, _, _ = self._probe( [ @@ -383,7 +527,7 @@ def _native_checks(self) -> List[DoctorCheckResult]: ] ) cpp23_ok = False - if clangxx.is_file(): + if clangxx is not None and clangxx.is_file(): cpp23_ok, _, _ = self._probe( [ str(clangxx), diff --git a/src/supernote_module_generator/feature_cli_operations.py b/src/supernote_module_generator/feature_cli_operations.py index 12ef23d..fa51c03 100644 --- a/src/supernote_module_generator/feature_cli_operations.py +++ b/src/supernote_module_generator/feature_cli_operations.py @@ -28,6 +28,7 @@ SubprocessError, ValidationResult, ) +from .platform_tools import gradle_wrapper_path from .naming import ( normalize_description, validate_android_namespace, @@ -332,6 +333,10 @@ def _validate_add(self, decisions: FeatureAddDecisions) -> None: def _snapshot_operation( self, transaction: Transaction, feature_paths: Iterable[Path] ) -> None: + transaction.track_created_directory(self.root / "local_modules") + transaction.track_created_directory( + (self.root / RUNTIME_RELATIVE_ROOT).parent + ) paths = [ *parent_mutation_targets(self.root), *integration_mutation_files(self.root), @@ -414,9 +419,12 @@ def _dependency_result( def _run(self, command: list[str], *, phase: str) -> None: try: - if self.renderer.mode == "verbose" and self.run is subprocess.run: + if self.run is subprocess.run: result = run_process( - command, cwd=self.root, timeout=600, stream=self._stream + command, + cwd=self.root, + timeout=600, + stream=self._stream if self.renderer.mode == "verbose" else None, ) else: result = self.run( @@ -459,7 +467,7 @@ def _health_check_manager(self, manager: Optional[str]) -> None: raise ConfigurationError(f"{manager} is not available") def _health_check_build(self) -> None: - gradle = self.root / "android/gradlew" + gradle = gradle_wrapper_path(self.root) if not gradle.is_file(): raise ConfigurationError("Android Gradle wrapper is not available") diff --git a/src/supernote_module_generator/feature_generator.py b/src/supernote_module_generator/feature_generator.py index a299787..3978362 100644 --- a/src/supernote_module_generator/feature_generator.py +++ b/src/supernote_module_generator/feature_generator.py @@ -13,6 +13,13 @@ from .feature_model import FeatureManifest, StarterFamily +def _javascript_string(value: str) -> str: + """Render a deterministic single-quoted JavaScript string literal.""" + + body = json.dumps(value, ensure_ascii=False)[1:-1] + return "'" + body.replace(r'\"', '"').replace("'", r"\'") + "'" + + @dataclass(frozen=True) class FeatureConfig: output: Path @@ -113,15 +120,16 @@ def stage_feature( _write( temporary, "index.js", + "/* global globalThis */\n" "export class SupernoteError extends Error {\n" " constructor(code, message) {\n" " super(message);\n" " this.name = 'SupernoteError';\n" - " Object.defineProperty(this, 'code', { value: code, enumerable: true });\n" + " Object.defineProperty(this, 'code', {value: code, enumerable: true});\n" " }\n" "}\n\n" "const ERROR_CONSTRUCTOR_PROPERTY = '__supernoteErrorConstructor';\n" - f"const INSTALL_ERROR = {json.dumps(config.public_name + ' is not installed in the Supernote V2 runtime')};\n\n" + f"const INSTALL_ERROR = {_javascript_string(config.public_name + ' is not installed in the Supernote V2 runtime')};\n\n" "function requireFeature() {\n" " const runtime = globalThis." + global_name @@ -129,7 +137,7 @@ def stage_feature( " if (!runtime || typeof runtime.feature !== 'function') {\n" " throw new Error(INSTALL_ERROR);\n" " }\n" - f" const value = runtime.feature({json.dumps(feature.feature_id)});\n" + f" const value = runtime.feature({_javascript_string(feature.feature_id)});\n" " if (value[ERROR_CONSTRUCTOR_PROPERTY] !== SupernoteError) {\n" " Object.defineProperty(value, ERROR_CONSTRUCTOR_PROPERTY, {\n" " configurable: true,\n" @@ -144,11 +152,15 @@ def stage_feature( " {},\n" " {\n" " get(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) return undefined;\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " return undefined;\n" + " }\n" " return requireFeature()[property];\n" " },\n" " has(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) return false;\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " return false;\n" + " }\n" " return property in requireFeature();\n" " },\n" " ownKeys() {\n" @@ -157,12 +169,14 @@ def stage_feature( " );\n" " },\n" " getOwnPropertyDescriptor(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) return undefined;\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " return undefined;\n" + " }\n" " const descriptor = Object.getOwnPropertyDescriptor(\n" " requireFeature(),\n" " property,\n" " );\n" - " return descriptor ? { ...descriptor, configurable: true } : undefined;\n" + " return descriptor ? {...descriptor, configurable: true} : undefined;\n" " },\n" " },\n" ");\n\n" diff --git a/src/supernote_module_generator/integration.py b/src/supernote_module_generator/integration.py index 5932083..4a66971 100644 --- a/src/supernote_module_generator/integration.py +++ b/src/supernote_module_generator/integration.py @@ -13,6 +13,8 @@ from .config import METADATA_FILES, gradle_project_name, normalize_backend from .errors import ConfigurationError, FilesystemError, GeneratorError +from .platform_tools import host_command +from .subprocesses import run_process LOCAL_MODULES_DIR = "local_modules" LEGACY_MODULES_DIRS = ("local-modules", "modules") @@ -188,11 +190,12 @@ def choose_package_manager(root: Path, requested: str | None, *, interactive: bo def _run_package_manager(command: list[str], root: Path, *, verbose: bool) -> subprocess.CompletedProcess[str]: + resolved_command = [host_command(command[0]), *command[1:]] if verbose: - print("Running: " + shlex.join(command)) + print("Running: " + shlex.join(resolved_command)) try: - result = subprocess.run(command, cwd=root, text=True, capture_output=True, check=False, timeout=600) - except OSError as exc: + result = run_process(resolved_command, cwd=root, timeout=600) + except (OSError, subprocess.TimeoutExpired) as exc: raise GeneratorError(f"Could not run {command[0]}: {exc}") from exc if verbose: if result.stdout: diff --git a/src/supernote_module_generator/operation_lock.py b/src/supernote_module_generator/operation_lock.py index 0d64517..e76206b 100644 --- a/src/supernote_module_generator/operation_lock.py +++ b/src/supernote_module_generator/operation_lock.py @@ -2,9 +2,10 @@ from __future__ import annotations from contextlib import contextmanager -import fcntl +import hashlib import os from pathlib import Path +import threading from typing import Iterator from .errors import ConfigurationError, FilesystemError @@ -17,11 +18,41 @@ class PluginBusyError(ConfigurationError): phase = "preflight" +_PROCESS_LOCKS: set[str] = set() +_PROCESS_LOCKS_GUARD = threading.Lock() + + +def _lock_identity(root: Path) -> str: + value = str(root) + if os.name == "nt": + value = os.path.normcase(value) + return value + + +def _busy() -> PluginBusyError: + return PluginBusyError( + "Another supernote-module command is already running for this plugin. " + "Wait for it to finish and try again." + ) + + @contextmanager -def plugin_operation_lock(plugin_root: Path) -> Iterator[None]: - """Lock the plugin directory without adding a user-visible lock file.""" +def _process_claim(identity: str) -> Iterator[None]: + with _PROCESS_LOCKS_GUARD: + if identity in _PROCESS_LOCKS: + raise _busy() + _PROCESS_LOCKS.add(identity) + try: + yield + finally: + with _PROCESS_LOCKS_GUARD: + _PROCESS_LOCKS.discard(identity) + + +@contextmanager +def _posix_directory_lock(root: Path) -> Iterator[None]: + import fcntl - root = plugin_root.resolve() try: descriptor = os.open(root, os.O_RDONLY) except OSError as exc: @@ -32,10 +63,7 @@ def plugin_operation_lock(plugin_root: Path) -> Iterator[None]: fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) acquired = True except BlockingIOError as exc: - raise PluginBusyError( - "Another supernote-module command is already running for this plugin. " - "Wait for it to finish and try again." - ) from exc + raise _busy() from exc yield finally: try: @@ -43,3 +71,81 @@ def plugin_operation_lock(plugin_root: Path) -> Iterator[None]: fcntl.flock(descriptor, fcntl.LOCK_UN) finally: os.close(descriptor) + + +def _windows_mutex_name(identity: str) -> str: + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() + return f"Local\\SupernoteModuleGenerator-{digest}" + + +@contextmanager +def _windows_named_mutex(identity: str) -> Iterator[None]: + # Imports stay inside the Windows-only path so this module remains + # importable on every supported host. + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_mutex = kernel32.CreateMutexW + create_mutex.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR] + create_mutex.restype = wintypes.HANDLE + wait_for_single_object = kernel32.WaitForSingleObject + wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD] + wait_for_single_object.restype = wintypes.DWORD + release_mutex = kernel32.ReleaseMutex + release_mutex.argtypes = [wintypes.HANDLE] + release_mutex.restype = wintypes.BOOL + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + + handle = create_mutex(None, False, _windows_mutex_name(identity)) + if not handle: + error = ctypes.get_last_error() + raise FilesystemError( + f"Could not create the Windows plugin-operation mutex (error {error})." + ) + + wait_object_0 = 0x00000000 + wait_abandoned = 0x00000080 + wait_timeout = 0x00000102 + wait_failed = 0xFFFFFFFF + acquired = False + try: + result = wait_for_single_object(handle, 0) + if result in (wait_object_0, wait_abandoned): + acquired = True + elif result == wait_timeout: + raise _busy() + elif result == wait_failed: + error = ctypes.get_last_error() + raise FilesystemError( + f"Could not wait for the Windows plugin-operation mutex (error {error})." + ) + else: + raise FilesystemError( + f"Windows returned an unexpected plugin-operation mutex status: {result}." + ) + yield + finally: + if acquired: + release_mutex(handle) + close_handle(handle) + + +@contextmanager +def plugin_operation_lock(plugin_root: Path) -> Iterator[None]: + """Lock the plugin directory without adding a user-visible lock file.""" + + root = plugin_root.resolve() + identity = _lock_identity(root) + with _process_claim(identity): + if os.name == "nt": + with _windows_named_mutex(identity): + yield + return + if os.name == "posix": + with _posix_directory_lock(root): + yield + return + raise FilesystemError(f"Unsupported host platform for plugin locking: {os.name}") diff --git a/src/supernote_module_generator/operations.py b/src/supernote_module_generator/operations.py index 6ddc489..f4d0229 100644 --- a/src/supernote_module_generator/operations.py +++ b/src/supernote_module_generator/operations.py @@ -39,6 +39,7 @@ ValidationResult, WarningInfo, ) +from .platform_tools import gradle_wrapper_command, gradle_wrapper_path from .naming import ( normalize_description, validate_android_namespace, @@ -147,16 +148,20 @@ def _run( ) -> Tuple[subprocess.CompletedProcess[str], int]: started = time.monotonic() try: - if self.renderer.mode == "verbose" and self.run is subprocess.run: + if self.run is subprocess.run: result = run_process( command, cwd=cwd or self.root, timeout=timeout, - stream=lambda destination, content: _stream( - self.renderer, - destination, - content, - ), + stream=( + lambda destination, content: _stream( + self.renderer, + destination, + content, + ) + ) + if self.renderer.mode == "verbose" + else None, ) else: result = self.run( @@ -206,10 +211,10 @@ def _health_check_manager(self, manager: str) -> None: self._run([manager, "--version"], timeout=10, phase="preflight") def _health_check_build(self) -> None: - gradle = self.root / "android" / "gradlew" + gradle = gradle_wrapper_path(self.root) if not gradle.is_file(): raise ConfigurationError("Android Gradle wrapper is not available") - command = [str(gradle), "--version"] if os.access(gradle, os.X_OK) else ["sh", str(gradle), "--version"] + command = gradle_wrapper_command(gradle, ["--version"]) self._run(command, cwd=self.root / "android", timeout=120, phase="preflight") def _reconcile(self, command: List[str]) -> bool: diff --git a/src/supernote_module_generator/platform_tools.py b/src/supernote_module_generator/platform_tools.py new file mode 100644 index 0000000..dfb6b53 --- /dev/null +++ b/src/supernote_module_generator/platform_tools.py @@ -0,0 +1,58 @@ +"""Host-platform paths and commands used by the Android toolchain.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +from typing import List, Optional, Sequence + + +def gradle_wrapper_path(root: Path, *, platform_name: Optional[str] = None) -> Path: + """Return the checked-in Gradle wrapper for the current host.""" + + host = os.name if platform_name is None else platform_name + name = "gradlew.bat" if host == "nt" else "gradlew" + return root / "android" / name + + +def gradle_wrapper_command( + wrapper: Path, + arguments: Sequence[str], + *, + platform_name: Optional[str] = None, +) -> List[str]: + """Build a command that can execute the host's Gradle wrapper.""" + + host = os.name if platform_name is None else platform_name + executable = bool(wrapper.stat().st_mode & 0o111) if host == "posix" else True + if host == "nt" or executable: + return [str(wrapper), *arguments] + return ["sh", str(wrapper), *arguments] + + +def host_command( + command: str, + *, + platform_name: Optional[str] = None, +) -> str: + """Return the directly executable host spelling for a command.""" + + host = os.name if platform_name is None else platform_name + if host != "nt": + return command + return shutil.which(command) or command + + +def ndk_compiler_path( + prebuilt_root: Path, + compiler: str, + *, + platform_name: Optional[str] = None, +) -> Optional[Path]: + """Find an NDK host compiler, including Windows' required .exe suffix.""" + + host = os.name if platform_name is None else platform_name + executable = f"{compiler}.exe" if host == "nt" else compiler + if not prebuilt_root.is_dir(): + return None + return next(iter(sorted(prebuilt_root.glob(f"*/bin/{executable}"))), None) diff --git a/src/supernote_module_generator/plugin_runtime_codegen.py b/src/supernote_module_generator/plugin_runtime_codegen.py index 8adcf16..220eb5a 100644 --- a/src/supernote_module_generator/plugin_runtime_codegen.py +++ b/src/supernote_module_generator/plugin_runtime_codegen.py @@ -51,10 +51,16 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: f' "${{CMAKE_CURRENT_LIST_DIR}}/../../../{path}"' for path in native_roots ) - encoded_roots = "\n".join( - f"{entry.feature.feature_id}\tlocal_modules/" - f"{entry.feature.npm_name}/{entry.feature.roots.jvm}" - for entry in registry.features + ksp_root_args = "\n".join( + " arg(" + + repr(f"supernoteFeatureRoot_{index:08d}") + + ", " + + json.dumps( + f"{entry.feature.feature_id}\tlocal_modules/" + f"{entry.feature.npm_name}/{entry.feature.roots.jvm}" + ) + + ")" + for index, entry in enumerate(registry.features) ) cmake = f"""# Generated by supernote_module_generator. Do not edit. cmake_minimum_required(VERSION 3.22.1) @@ -76,8 +82,13 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: "${{SUPERNOTE_NATIVE_ROOT}}/*.cxx") list(APPEND SUPERNOTE_USER_SOURCES ${{SUPERNOTE_FEATURE_SOURCES}}) endforeach() +if(NOT DEFINED SUPERNOTE_GENERATED_ROOT) + set(SUPERNOTE_GENERATED_ROOT + "${{CMAKE_CURRENT_LIST_DIR}}/build/generated/supernote") +endif() +file(TO_CMAKE_PATH "${{SUPERNOTE_GENERATED_ROOT}}" SUPERNOTE_GENERATED_ROOT) file(GLOB SUPERNOTE_GENERATED_BINDINGS CONFIGURE_DEPENDS - "${{CMAKE_CURRENT_LIST_DIR}}/build/generated/supernote/${{SUPERNOTE_VARIANT}}/jni/*.cpp") + "${{SUPERNOTE_GENERATED_ROOT}}/${{SUPERNOTE_VARIANT}}/jni/*.cpp") if(NOT SUPERNOTE_GENERATED_BINDINGS) message(FATAL_ERROR "Supernote generated JSI bindings are missing") endif() @@ -133,6 +144,14 @@ def supernoteJvmRoots = [ def supernoteNativeRoots = [ {native_root_rows} ] +def supernoteIsWindows = System.getProperty('os.name').toLowerCase().contains('windows') +def supernoteWindowsBuildRoot = new File( + System.getProperty('java.io.tmpdir'), + 'supernote-v2/{component}', +) +if (supernoteIsWindows) {{ + layout.buildDirectory.set(new File(supernoteWindowsBuildRoot, 'gradle')) +}} android {{ namespace 'supernote.generated.{component}' @@ -146,7 +165,10 @@ def supernoteNativeRoots = [ }} externalNativeBuild {{ cmake {{ - arguments '-DANDROID_STL=c++_shared' + arguments( + '-DANDROID_STL=c++_shared', + "-DSUPERNOTE_GENERATED_ROOT=${{layout.buildDirectory.dir('generated/supernote').get().asFile.absolutePath}}", + ) }} }} }} @@ -155,6 +177,11 @@ def supernoteNativeRoots = [ cmake {{ path file('CMakeLists.txt') version '3.22.1' + buildStagingDirectory( + supernoteIsWindows + ? new File(supernoteWindowsBuildRoot, 'cxx') + : file("${{rootProject.projectDir}}/.cxx/snv2") + ) }} }} buildFeatures {{ @@ -195,14 +222,19 @@ def supernoteNativeRoots = [ ksp {{ arg('supernotePluginRoot', supernotePluginRoot.absolutePath) - arg('supernoteFeatureRoots', {json.dumps(encoded_roots)}) +{ksp_root_args} }} kotlin {{ jvmToolchain(17) }} -def supernotePython = System.getenv('SUPERNOTE_PYTHON') ?: 'python3' +def supernotePythonOverride = System.getenv('SUPERNOTE_PYTHON') +def supernotePythonCommand = supernotePythonOverride + ? [supernotePythonOverride] + : (supernoteIsWindows + ? ['py', '-3'] + : ['python3']) def supernoteCommonScript = file('common_codegen.py') def supernoteTypescriptOutputs = {json.dumps([ f"${{supernotePluginRoot}}/local_modules/{entry.feature.npm_name}/index.d.ts" @@ -214,19 +246,23 @@ def variantName = buildVariant.toLowerCase() def commonTask = tasks.register("generateSupernote${{buildVariant}}Semantics", Exec) {{ inputs.file(file('feature-registry.json')) inputs.dir(file('inputs')) - supernoteNativeRoots.each {{ nativeRoot -> inputs.dir(nativeRoot) }} + supernoteNativeRoots.findAll {{ it.isDirectory() }}.each {{ nativeRoot -> + inputs.dir(nativeRoot) + }} inputs.files(fileTree(layout.buildDirectory.dir( "generated/ksp/${{variantName}}/resources" ))) outputs.dir(layout.buildDirectory.dir("generated/supernote/${{variantName}}")) outputs.files(supernoteTypescriptOutputs) commandLine( - supernotePython, + *supernotePythonCommand, supernoteCommonScript.absolutePath, '--plugin-root', supernotePluginRoot.absolutePath, '--runtime-root', projectDir.absolutePath, + '--build-root', + layout.buildDirectory.get().asFile.absolutePath, '--variant', variantName, ) @@ -390,6 +426,15 @@ class DeferredDestruction { DeferredDestruction &operator=(const DeferredDestruction &) = delete; bool submit(std::function cleanup) noexcept; + template + bool submit(Cleanup &&cleanup) noexcept { + try { + return submit(std::function( + std::forward(cleanup))); + } catch (...) { + return false; + } + } void drain_and_shutdown() noexcept; private: @@ -432,13 +477,11 @@ class ManagedRef { auto cleanup = cleanup_; cleanup_.reset(); if (!value) return; - if (!cleanup || !cleanup->submit( - [value = std::move(value)]() mutable { value.reset(); })) { - // Component shutdown is the only rejected-admission case. At that - // boundary all generated work has already been drained, so inline - // release is safe and cannot run on a live JavaScript callback. - value.reset(); - } + if (cleanup && cleanup->submit( + [value = std::move(value)]() mutable { value.reset(); })) return; + // Queue admission can fail during component shutdown or memory pressure. + // Inline release is the bounded fallback and cannot touch JSI. + value.reset(); } private: @@ -627,10 +670,9 @@ class FeatureSession : public std::enable_shared_from_this { void defer_release(std::shared_ptr value) noexcept { if (!value) return; auto cleanup = cleanup_; - if (!cleanup || !cleanup->submit( - [value = std::move(value)]() mutable { value.reset(); })) { - value.reset(); - } + if (cleanup && cleanup->submit( + [value = std::move(value)]() mutable { value.reset(); })) return; + value.reset(); } SessionId id_; @@ -1081,19 +1123,23 @@ class Result final { void RuntimeSession::invalidate() noexcept { if (!active_.exchange(false, std::memory_order_acq_rel)) return; - std::vector> features; { std::lock_guard lock(mutex_); - for (auto &[id, feature] : features_) { - (void)id; - features.push_back(std::move(feature)); - } - features_.clear(); plugin_class_loader_.reset(); platform_context_.reset(); scheduler_ = {}; } - for (auto &feature : features) feature->close_runtime(); + for (;;) { + std::shared_ptr feature; + { + std::lock_guard lock(mutex_); + if (features_.empty()) break; + auto current = features_.begin(); + feature = std::move(current->second); + features_.erase(current); + } + feature->close_runtime(); + } } std::shared_ptr RuntimeSession::plugin_class_loader() const noexcept { @@ -1285,28 +1331,23 @@ class Result final { void FeatureSession::close_runtime() noexcept { close(true); } void FeatureSession::close(bool runtime_teardown) noexcept { - std::vector> pending; - std::vector> services; { std::lock_guard lock(mutex_); if (state() != FeatureState::ACTIVE) return; state_.store(FeatureState::CLOSING, std::memory_order_release); - for (auto &[id, operation] : pending_) { - (void)id; - pending.push_back(std::move(operation)); - } - pending_.clear(); - for (auto &[id, slot] : services_) { - (void)id; - std::lock_guard slot_lock(slot->mutex); - if (slot->value) services.push_back(std::move(slot->value)); - } - services_.clear(); } auto runtime = runtime_.lock(); if (!runtime_teardown && runtime) runtime->remove_feature(id_); - for (auto &operation : pending) { + for (;;) { + std::shared_ptr operation; + { + std::lock_guard lock(mutex_); + if (pending_.empty()) break; + auto current = pending_.begin(); + operation = std::move(current->second); + pending_.erase(current); + } const auto outcome = runtime_teardown ? OperationWinner::CANCELLED_BY_RUNTIME : OperationWinner::CANCELLED_BY_FEATURE; @@ -1320,7 +1361,22 @@ class Result final { runtime->schedule(std::move(rejection)); } } - for (auto &service : services) defer_release(std::move(service)); + for (;;) { + std::shared_ptr slot; + { + std::lock_guard lock(mutex_); + if (services_.empty()) break; + auto current = services_.begin(); + slot = std::move(current->second); + services_.erase(current); + } + std::shared_ptr service; + { + std::lock_guard slot_lock(slot->mutex); + service = std::move(slot->value); + } + defer_release(std::move(service)); + } runtime_.reset(); state_.store(FeatureState::INACTIVE, std::memory_order_release); } diff --git a/src/supernote_module_generator/project.py b/src/supernote_module_generator/project.py index 51febea..68a3fa3 100644 --- a/src/supernote_module_generator/project.py +++ b/src/supernote_module_generator/project.py @@ -220,6 +220,8 @@ def git_status(root: Path) -> str: cwd=root, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=2, check=False, ) diff --git a/src/supernote_module_generator/subprocesses.py b/src/supernote_module_generator/subprocesses.py index 64588ff..71b1af5 100644 --- a/src/supernote_module_generator/subprocesses.py +++ b/src/supernote_module_generator/subprocesses.py @@ -1,10 +1,96 @@ -"""Subprocess execution with optional real-time, stream-preserving output.""" +"""Subprocess execution with bounded, process-tree-aware cleanup.""" from __future__ import annotations +from contextlib import contextmanager +import os +import signal import subprocess import threading from pathlib import Path -from typing import Callable, List, Optional, Sequence +from typing import Callable, Iterator, List, Optional, Sequence + +from .platform_tools import host_command + + +def _popen_options() -> dict[str, object]: + if os.name == "posix": + return {"start_new_session": True} + if os.name == "nt": + return { + "creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + } + return {} + + +def _signal_tree(process: subprocess.Popen[str], *, force: bool) -> None: + """Signal the complete child tree without ever targeting our own group.""" + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + pass + return + if os.name == "nt": + # taskkill is part of Windows and is the only generally available way + # to include grandchildren that do not share a Python Popen handle. + try: + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + capture_output=True, + check=False, + ) + except OSError: + if process.poll() is None: + process.kill() + return + if process.poll() is None: + (process.kill if force else process.terminate)() + + +def _stop_tree(process: subprocess.Popen[str], *, graceful: bool) -> None: + _signal_tree(process, force=not graceful) + if graceful: + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + # The direct child may have exited while a descendant ignored SIGTERM. + # Sending SIGKILL to the now-empty process group is harmless. + _signal_tree(process, force=True) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + _signal_tree(process, force=True) + process.wait() + + +@contextmanager +def _forward_sigterm(process: subprocess.Popen[str]) -> Iterator[None]: + """Make an externally terminated CLI stop its active dependency tree.""" + if ( + os.name != "posix" + or threading.current_thread() is not threading.main_thread() + ): + yield + return + previous = signal.getsignal(signal.SIGTERM) + + def handle(signum: int, frame: object) -> None: + _signal_tree(process, force=True) + signal.signal(signal.SIGTERM, previous) + if previous == signal.SIG_IGN: + return + if callable(previous): + previous(signum, frame) # type: ignore[arg-type] + return + os.kill(os.getpid(), signal.SIGTERM) + + signal.signal(signal.SIGTERM, handle) + try: + yield + finally: + if signal.getsignal(signal.SIGTERM) is handle: + signal.signal(signal.SIGTERM, previous) def run_process( @@ -14,24 +100,37 @@ def run_process( timeout: int, stream: Optional[Callable[[str, str], None]] = None, ) -> subprocess.CompletedProcess[str]: - if stream is None: - return subprocess.run( - list(command), - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - + resolved_command = list(command) + if resolved_command: + resolved_command[0] = host_command(resolved_command[0]) process = subprocess.Popen( - list(command), + resolved_command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", bufsize=1, + **_popen_options(), ) + if stream is None: + try: + with _forward_sigterm(process): + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired as exc: + _stop_tree(process, graceful=False) + stdout, stderr = process.communicate() + exc.stdout = stdout + exc.stderr = stderr + raise + except KeyboardInterrupt: + _stop_tree(process, graceful=True) + raise + return subprocess.CompletedProcess( + resolved_command, process.returncode, stdout, stderr + ) + stdout_parts: List[str] = [] stderr_parts: List[str] = [] @@ -60,24 +159,19 @@ def pump(name: str, pipe: object, parts: List[str]) -> None: for thread in threads: thread.start() try: - return_code = process.wait(timeout=timeout) + with _forward_sigterm(process): + return_code = process.wait(timeout=timeout) except subprocess.TimeoutExpired: - process.kill() - process.wait() + _stop_tree(process, graceful=False) raise except KeyboardInterrupt: - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + _stop_tree(process, graceful=True) raise finally: for thread in threads: thread.join(timeout=2) return subprocess.CompletedProcess( - list(command), + resolved_command, return_code, "".join(stdout_parts), "".join(stderr_parts), diff --git a/src/supernote_module_generator/templates/cpp.build.gradle.kts.tmpl b/src/supernote_module_generator/templates/cpp.build.gradle.kts.tmpl index 465e320..f4e2d0f 100644 --- a/src/supernote_module_generator/templates/cpp.build.gradle.kts.tmpl +++ b/src/supernote_module_generator/templates/cpp.build.gradle.kts.tmpl @@ -101,8 +101,16 @@ val generateSupernoteBindings = tasks.register("generateSupernoteBindings" outputs.dir(layout.buildDirectory.dir("generated/supernote")) outputs.file(file("../index.d.ts")) workingDir(projectDir.parentFile) + val pythonOverride = System.getenv("SUPERNOTE_PYTHON") + val pythonCommand = if (pythonOverride != null) { + listOf(pythonOverride) + } else if (System.getProperty("os.name").lowercase().contains("windows")) { + listOf("py", "-3") + } else { + listOf("python3") + } commandLine( - System.getenv("SUPERNOTE_PYTHON") ?: "python3", + *pythonCommand.toTypedArray(), "-B", file(".supernote-module/codegen.py").absolutePath, "--module-root", diff --git a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl index 75651b8..7091d65 100644 --- a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl @@ -330,8 +330,13 @@ class SupernoteV2Processor( private fun featureRoots(): List { val pluginRoot = environment.options["supernotePluginRoot"] ?: error("Missing supernotePluginRoot") - val encoded = environment.options["supernoteFeatureRoots"] ?: error("Missing supernoteFeatureRoots") - return encoded.lines().filter { it.isNotBlank() }.map { line -> + val optionPrefix = "supernoteFeatureRoot_" + val encodedRoots = environment.options + .filterKeys { it.startsWith(optionPrefix) } + .toSortedMap() + return encodedRoots.entries.mapIndexed { index, (optionName, line) -> + val expectedName = optionPrefix + index.toString().padStart(8, '0') + require(optionName == expectedName) { "Invalid Supernote feature-root option sequence" } val parts = line.split('\t') require(parts.size == 2) { "Invalid supernoteFeatureRoots entry" } FeatureRoot(parts[0], parts[1], java.nio.file.Paths.get(pluginRoot).resolve(parts[1]).toAbsolutePath().normalize()) diff --git a/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl b/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl index 6d89112..ee49e47 100644 --- a/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl +++ b/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl @@ -34,17 +34,28 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--plugin-root", type=Path, required=True) parser.add_argument("--runtime-root", type=Path, required=True) + parser.add_argument("--build-root", type=Path) parser.add_argument("--variant", required=True) arguments = parser.parse_args() generate( arguments.plugin_root.resolve(), arguments.runtime_root.resolve(), + ( + arguments.build_root.resolve() + if arguments.build_root is not None + else (arguments.runtime_root.resolve() / "build") + ), arguments.variant, ) return 0 -def generate(plugin_root: Path, runtime_root: Path, variant: str) -> None: +def generate( + plugin_root: Path, + runtime_root: Path, + build_root: Path, + variant: str, +) -> None: registry = json.loads( (runtime_root / "feature-registry.json").read_text(encoding="utf-8") ) @@ -54,8 +65,8 @@ def generate(plugin_root: Path, runtime_root: Path, variant: str) -> None: if not isinstance(features, list): raise RuntimeError("feature registry has no feature list") manifest_root = ( - runtime_root - / "build/generated/ksp" + build_root + / "generated/ksp" / variant / "resources/supernote/generated/manifests" ) @@ -73,7 +84,7 @@ def generate(plugin_root: Path, runtime_root: Path, variant: str) -> None: if unknown: raise RuntimeError("KSP emitted manifests for unknown features: " + ", ".join(unknown)) - output_root = runtime_root / "build/generated/supernote" / variant + output_root = build_root / "generated/supernote" / variant semantic_registry = [] generated_jni: dict[str, str] = {} generated_internal_headers: dict[str, str] = {} diff --git a/src/supernote_module_generator/transaction.py b/src/supernote_module_generator/transaction.py index 64ff663..d546256 100644 --- a/src/supernote_module_generator/transaction.py +++ b/src/supernote_module_generator/transaction.py @@ -175,6 +175,34 @@ def track_created(self, path: Path) -> None: ) self._persist() + def track_created_directory(self, path: Path) -> None: + """Remove a generator-created parent on rollback only when it is empty.""" + canonical = path.resolve(strict=False) + if not _inside(self.root, canonical): + raise FilesystemError( + f"Generated directory escapes the plugin root: {canonical}" + ) + if canonical.exists(): + return + if any( + entry.get("kind") == "created_directory" + and entry.get("path") == str(canonical) + for entry in self._entries() + ): + return + self._entries().append( + { + "path": str(canonical), + "restore": str( + self.state_dir / "unused" / str(len(self._entries())) + ), + "existed": False, + "kind": "created_directory", + "hash": None, + } + ) + self._persist() + def detach(self, destination: Path) -> None: destination = destination.resolve() if not _inside(self.root, destination): @@ -306,6 +334,16 @@ def _rollback_data( failures.append(str(path)) continue try: + if raw.get("kind") == "created_directory": + if path.is_dir() and not path.is_symlink(): + try: + path.rmdir() + except OSError: + # Never remove user content that appeared concurrently. + pass + raw["restored"] = True + restored.append(str(path)) + continue existed = bool(raw.get("existed")) expected = raw.get("hash") if existed and not restore.exists(): diff --git a/src/supernote_module_generator/typescript_codegen.py b/src/supernote_module_generator/typescript_codegen.py index b17baf0..183d271 100644 --- a/src/supernote_module_generator/typescript_codegen.py +++ b/src/supernote_module_generator/typescript_codegen.py @@ -54,11 +54,11 @@ def render_typescript(feature_name: str, api: SemanticApi) -> str: return ( "/* Generated by supernote_module_generator. Do not edit. */\n" "export type SupernoteErrorCode =\n" - ' | "RESOURCE_EXHAUSTED"\n' - ' | "CANCELLED"\n' - ' | "FEATURE_CLOSED"\n' - ' | "IMPLEMENTATION_ERROR"\n' - ' | "INTERNAL";\n\n' + " | 'RESOURCE_EXHAUSTED'\n" + " | 'CANCELLED'\n" + " | 'FEATURE_CLOSED'\n" + " | 'IMPLEMENTATION_ERROR'\n" + " | 'INTERNAL';\n\n" "export class SupernoteError extends Error {\n" " readonly code: SupernoteErrorCode;\n" "}\n\n" diff --git a/src/supernote_module_generator/verification.py b/src/supernote_module_generator/verification.py index c1b731e..2213827 100644 --- a/src/supernote_module_generator/verification.py +++ b/src/supernote_module_generator/verification.py @@ -11,6 +11,7 @@ from .config import METADATA_FILE, native_class_prefix from .integration import marker from .models import SubprocessError, ValidationResult +from .platform_tools import gradle_wrapper_command, gradle_wrapper_path from .project import ( ManagedModule, android_settings, @@ -235,8 +236,8 @@ def build_android( verbose: bool, stream: Optional[Callable[[str, str], None]] = None, ) -> Tuple[bool, Optional[SubprocessError], int]: - gradle = root / "android" / "gradlew" - command = [str(gradle), ":app:assembleDebug"] if gradle.stat().st_mode & 0o111 else ["sh", str(gradle), ":app:assembleDebug"] + gradle = gradle_wrapper_path(root) + command = gradle_wrapper_command(gradle, [":app:assembleDebug"]) try: result = run_process( command, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c9ea38e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def make_directory_symlink(): + """Create a directory symlink or skip when the host forbids test symlinks.""" + + def make(link: Path, target: Path) -> None: + try: + link.symlink_to(target, target_is_directory=True) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"directory symlinks are unavailable on this host: {exc}") + + return make diff --git a/tests/test_binding_codegen.py b/tests/test_binding_codegen.py index 4723abd..16c8c20 100644 --- a/tests/test_binding_codegen.py +++ b/tests/test_binding_codegen.py @@ -1433,8 +1433,9 @@ class CounterFactory { public: CounterFactory(); }; self.assertIn("generated TypeScript name 'CounterFactory'", message) self.assertIn("object export 'Counter'", message) self.assertIn("export 'CounterFactory'", message) - self.assertIn("model/Counter.hpp:1", message) - self.assertIn("model/Counter.hpp:3", message) + relative_header = str(Path("model/Counter.hpp")) + self.assertIn(f"{relative_header}:1", message) + self.assertIn(f"{relative_header}:3", message) def test_v1_object_marker_and_alias_syntax_are_rejected(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_doctor_spec.py b/tests/test_doctor_spec.py index 9b587e7..85d400c 100644 --- a/tests/test_doctor_spec.py +++ b/tests/test_doctor_spec.py @@ -2,6 +2,7 @@ import io import json +import os import subprocess from pathlib import Path @@ -9,6 +10,11 @@ from supernote_module_generator.rendering import Renderer, TerminalCapabilities +HOST_GRADLE = "gradlew.bat" if os.name == "nt" else "gradlew" +HOST_CLANG = "clang.exe" if os.name == "nt" else "clang" +HOST_CLANGXX = "clang++.exe" if os.name == "nt" else "clang++" + + def plugin(tmp_path: Path, *, both_locks: bool = False) -> Path: (tmp_path / "android").mkdir() (tmp_path / "PluginConfig.json").write_text("{}\n", encoding="utf-8") @@ -19,7 +25,10 @@ def plugin(tmp_path: Path, *, both_locks: bool = False) -> Path: (tmp_path / "android/settings.gradle").write_text( "include ':app'\n", encoding="utf-8" ) - (tmp_path / "android/gradlew").write_text("#!/bin/sh\n", encoding="utf-8") + (tmp_path / "android" / HOST_GRADLE).write_text( + "@echo off\r\n" if os.name == "nt" else "#!/bin/sh\n", + encoding="utf-8", + ) if both_locks: (tmp_path / "package-lock.json").write_text("{}\n", encoding="utf-8") (tmp_path / "yarn.lock").write_text("", encoding="utf-8") @@ -43,26 +52,34 @@ def install_fake_sdk(tmp_path: Path, monkeypatch) -> Path: ndk = sdk / "ndk/27.1.0" compiler = ndk / "toolchains/llvm/prebuilt/test/bin" compiler.mkdir(parents=True) - (compiler / "clang").write_text("", encoding="utf-8") - (compiler / "clang++").write_text("", encoding="utf-8") + (compiler / HOST_CLANG).write_text("", encoding="utf-8") + (compiler / HOST_CLANGXX).write_text("", encoding="utf-8") (ndk / "source.properties").write_text( "Pkg.Revision = 27.1.0\n", encoding="utf-8" ) monkeypatch.setenv("ANDROID_HOME", str(sdk)) + monkeypatch.setenv("ANDROID_SDK_ROOT", str(sdk)) + monkeypatch.setenv("ANDROID_NDK_HOME", str(ndk)) + monkeypatch.setenv("ANDROID_NDK_ROOT", str(ndk)) return sdk def successful_run(command, **kwargs): executable = Path(command[0]).name + if executable == "sh" and len(command) > 1: + executable = Path(command[1]).name output = { "node": "v20.0.0\n", "npm": "10.0.0\n", "yarn": "1.22.0\n", "java": "openjdk 17.0.12\n", - "gradlew": "Gradle 8.0\n", + "gradlew": "Gradle 8.13\nJVM: 17.0.12\n", + "gradlew.bat": "Gradle 8.13\nJVM: 17.0.12\n", "cmake": "cmake version 3.22.1\n", "clang": "clang version 18.0.0\n", "clang++": "clang version 18.0.0\n", + "clang.exe": "clang version 18.0.0\n", + "clang++.exe": "clang version 18.0.0\n", }.get(executable, "") return subprocess.CompletedProcess(command, 0, output, "") @@ -87,6 +104,43 @@ def test_doctor_executes_required_probes_and_keeps_selinux_advisory( assert result.doctor.advisory_count >= 1 +def test_windows_doctor_uses_batch_wrapper_and_exe_ndk_compilers( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + for wrapper in (root / "android/gradlew", root / "android/gradlew.bat"): + wrapper.unlink(missing_ok=True) + (root / "android/gradlew.bat").write_text("@echo off\r\n", encoding="utf-8") + sdk = install_fake_sdk(tmp_path, monkeypatch) + compiler = sdk / "ndk/27.1.0/toolchains/llvm/prebuilt/test/bin" + (compiler / "clang").unlink(missing_ok=True) + (compiler / "clang++").unlink(missing_ok=True) + (compiler / "clang.exe").write_bytes(b"") + (compiler / "clang++.exe").write_bytes(b"") + monkeypatch.setattr( + "supernote_module_generator.doctor.shutil.which", + lambda name: f"C:/tools/{name}", + ) + commands = [] + + def run(command, **kwargs): + commands.append(list(command)) + return successful_run(command, **kwargs) + + result = DoctorService( + root, + renderer(), + run=run, + platform_name="nt", + ).execute("plugin") + + assert result.exit_code == 0 + assert any(Path(command[0]).name == "gradlew.bat" for command in commands) + assert not any(command[0] == "sh" for command in commands) + assert any(Path(command[0]).name == "clang.exe" for command in commands) + assert any(Path(command[0]).name == "clang++.exe" for command in commands) + + def test_plugin_doctor_reports_jsi_policy_without_probing_deployment( tmp_path: Path, monkeypatch ): @@ -148,6 +202,118 @@ def run(command, **kwargs): assert cmake.status == "failed" +def test_doctor_fails_when_gradle_uses_java_older_than_path_java( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + install_fake_sdk(tmp_path, monkeypatch) + monkeypatch.setattr( + "supernote_module_generator.doctor.shutil.which", + lambda name: f"/tools/{name}", + ) + + def run(command, **kwargs): + if any(Path(part).name in {"gradlew", "gradlew.bat"} for part in command): + return subprocess.CompletedProcess( + command, + 0, + "Gradle 8.13\nJVM: 11.0.31\n", + "", + ) + return successful_run(command, **kwargs) + + result = DoctorService(root, renderer(), run=run).execute("plugin") + + assert result.exit_code == 1 + assert result.doctor is not None + shell_java = next(check for check in result.doctor.checks if check.id == "java") + gradle_java = next( + check for check in result.doctor.checks if check.id == "gradle_jvm" + ) + assert shell_java.status == "passed" + assert shell_java.detected_version == "openjdk 17.0.12" + assert gradle_java.status == "failed" + assert gradle_java.detected_version == "11.0.31" + assert "JAVA_HOME" in gradle_java.message + + +def test_doctor_rejects_gradle_jvm_newer_than_generated_gradle_support( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + install_fake_sdk(tmp_path, monkeypatch) + monkeypatch.setattr( + "supernote_module_generator.doctor.shutil.which", + lambda name: f"/tools/{name}", + ) + + def run(command, **kwargs): + if any(Path(part).name in {"gradlew", "gradlew.bat"} for part in command): + return subprocess.CompletedProcess( + command, + 0, + "Gradle 8.13\nJVM: 25.0.3\n", + "", + ) + return successful_run(command, **kwargs) + + result = DoctorService(root, renderer(), run=run).execute("plugin") + + assert result.exit_code == 1 + assert result.doctor is not None + gradle_java = next( + check for check in result.doctor.checks if check.id == "gradle_jvm" + ) + assert gradle_java.status == "failed" + assert gradle_java.detected_version == "25.0.3" + assert "Java 17 through 23" in gradle_java.message + assert "Java 17 is recommended" in gradle_java.message + + +def test_doctor_probes_the_daemon_java_home_reported_by_new_gradle( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + install_fake_sdk(tmp_path, monkeypatch) + daemon_home = tmp_path / "jdk-11" + daemon_java = daemon_home / "bin" / ( + "java.exe" if os.name == "nt" else "java" + ) + daemon_java.parent.mkdir(parents=True) + daemon_java.write_text("", encoding="utf-8") + monkeypatch.setattr( + "supernote_module_generator.doctor.shutil.which", + lambda name: f"/tools/{name}", + ) + + def run(command, **kwargs): + if any(Path(part).name in {"gradlew", "gradlew.bat"} for part in command): + return subprocess.CompletedProcess( + command, + 0, + "Gradle 8.13\n" + "Launcher JVM: 25.0.3\n" + f"Daemon JVM: {daemon_home} (from org.gradle.java.home)\n", + "", + ) + if Path(command[0]) == daemon_java: + return subprocess.CompletedProcess( + command, 0, "openjdk 11.0.31\n", "" + ) + return successful_run(command, **kwargs) + + result = DoctorService(root, renderer(), run=run).execute("plugin") + + assert result.exit_code == 1 + assert result.doctor is not None + gradle_java = next( + check for check in result.doctor.checks if check.id == "gradle_jvm" + ) + assert gradle_java.detected_version == "openjdk 11.0.31" + assert gradle_java.path == str(daemon_java) + assert gradle_java.status == "failed" + + def test_plain_doctor_emits_one_final_report_without_progress_noise( tmp_path: Path, monkeypatch ): @@ -179,7 +345,7 @@ def test_missing_gradle_wrapper_has_specific_diagnosis_and_recovery( tmp_path: Path, monkeypatch ): root = plugin(tmp_path) - (root / "android/gradlew").unlink() + (root / "android" / HOST_GRADLE).unlink() install_fake_sdk(tmp_path, monkeypatch) monkeypatch.setattr( "supernote_module_generator.doctor.shutil.which", @@ -194,7 +360,10 @@ def test_missing_gradle_wrapper_has_specific_diagnosis_and_recovery( check for check in result.doctor.checks if check.id == "gradle_wrapper" ) assert gradle.message == "The project Gradle wrapper is missing." - assert result.metadata["next_action"] == ( - "Restore `android/gradlew`, make it executable, then rerun " + expected = ( + "Restore `android/gradlew.bat`, then rerun `supernote-module doctor`." + if os.name == "nt" + else "Restore `android/gradlew`, make it executable, then rerun " "`supernote-module doctor`." ) + assert result.metadata["next_action"] == expected diff --git a/tests/test_feature_generator.py b/tests/test_feature_generator.py index 5af0477..1c612c6 100644 --- a/tests/test_feature_generator.py +++ b/tests/test_feature_generator.py @@ -91,6 +91,10 @@ def test_feature_package_uses_shared_runtime_proxy_and_no_native_package(tmp_pat package = json.loads((feature / "package.json").read_text()) assert "globalThis.__supernoteV2" in index + assert index.startswith("/* global globalThis */\n") + assert "if (property === ERROR_CONSTRUCTOR_PROPERTY) return" not in index + assert "{...descriptor, configurable: true}" in index + assert '"supernote:feature:' not in index assert "runtime.feature(" in index assert "new Proxy(" in index assert package["main"] == "index.js" diff --git a/tests/test_feature_metadata_diagnostics.py b/tests/test_feature_metadata_diagnostics.py index bebab85..de586c0 100644 --- a/tests/test_feature_metadata_diagnostics.py +++ b/tests/test_feature_metadata_diagnostics.py @@ -104,7 +104,7 @@ def test_wrong_kind_json_result_is_not_silently_treated_as_no_features(tmp_path: def test_escaping_managed_feature_symlink_is_rejected_without_following_it( - tmp_path: Path, + tmp_path: Path, make_directory_symlink ): plugin_root = tmp_path / "plugin" plugin_root.mkdir() @@ -112,7 +112,7 @@ def test_escaping_managed_feature_symlink_is_rejected_without_following_it( feature = _feature(root) outside = tmp_path / "outside-feature" feature.rename(outside) - feature.symlink_to(outside, target_is_directory=True) + make_directory_symlink(feature, outside) sentinel = outside / "sentinel.txt" sentinel.write_text("outside stays untouched\n", encoding="utf-8") @@ -148,5 +148,6 @@ def test_marked_cpp_boundary_error_has_source_preflight_classification(tmp_path: assert code == 1 assert result["error"]["kind"] == "invalid_source" assert result["error"]["phase"] == "preflight" - assert "android/src/main/cpp/Safe.cpp:2" in result["error"]["message"] + source_location = str(Path("android/src/main/cpp/Safe.cpp")) + ":2" + assert source_location in result["error"]["message"] assert "raw pointers are not supported" in result["error"]["message"] diff --git a/tests/test_guided_spec.py b/tests/test_guided_spec.py index 518f6c9..2560162 100644 --- a/tests/test_guided_spec.py +++ b/tests/test_guided_spec.py @@ -271,7 +271,9 @@ def test_guided_remove_offers_build_cleanup_with_a_safe_no_default(tmp_path: Pat assert (build / "proof.txt").read_text(encoding="utf-8") == "keep" -def test_guided_validate_offers_android_build_with_a_safe_no_default(tmp_path: Path): +def test_guided_validate_offers_android_build_with_a_safe_no_default( + tmp_path: Path, make_directory_symlink +): root = plugin(tmp_path) assert main( ["add", "local-safe", "--starter", "cpp", "--skip-install", "--yes"], @@ -283,7 +285,7 @@ def test_guided_validate_offers_android_build_with_a_safe_no_default(tmp_path: P feature = root / "local_modules/local-safe" link = root / "node_modules/local-safe" link.parent.mkdir() - link.symlink_to(feature, target_is_directory=True) + make_directory_symlink(link, feature) stdout = TtyStringIO() stderr = TtyStringIO() diff --git a/tests/test_interaction_spec.py b/tests/test_interaction_spec.py index 5e031b3..a0fb650 100644 --- a/tests/test_interaction_spec.py +++ b/tests/test_interaction_spec.py @@ -141,6 +141,7 @@ def test_plain_default_stays_dimless_and_inline_when_enter_accepts_it(): assert stderr.getvalue() == "JavaScript name [Math]: \n" +@pytest.mark.skipif(os.name == "nt", reason="exercises the POSIX byte reader") def test_utf8_keyboard_input_is_read_as_one_unicode_scalar(): read_descriptor, write_descriptor = os.pipe() try: diff --git a/tests/test_operation_lock.py b/tests/test_operation_lock.py index ea9fce5..a160cd1 100644 --- a/tests/test_operation_lock.py +++ b/tests/test_operation_lock.py @@ -2,7 +2,10 @@ import io import json +import os from pathlib import Path +import subprocess +import sys import threading import pytest @@ -11,6 +14,7 @@ from supernote_module_generator.feature_cli_operations import FeatureCliOperationService from supernote_module_generator.operation_lock import ( PluginBusyError, + _windows_mutex_name, plugin_operation_lock, ) from supernote_module_generator.transaction import JOURNAL_NAME, Transaction @@ -50,6 +54,45 @@ def test_plugin_directory_lock_is_nonblocking_and_leaves_no_artifact(tmp_path: P assert not list(root.glob("*lock*")) +def test_windows_mutex_identity_is_stable_and_contains_no_plugin_path(tmp_path: Path): + identity = str(tmp_path.resolve()) + + first = _windows_mutex_name(identity) + second = _windows_mutex_name(identity) + + assert first == second + assert first.startswith("Local\\SupernoteModuleGenerator-") + assert identity not in first + + +def test_operation_lock_module_import_does_not_require_fcntl(tmp_path: Path): + source = Path(__file__).parents[1] / "src" + script = ( + "import builtins\n" + "original = builtins.__import__\n" + "def guarded(name, *args, **kwargs):\n" + " if name == 'fcntl':\n" + " raise ModuleNotFoundError('simulated Windows host')\n" + " return original(name, *args, **kwargs)\n" + "builtins.__import__ = guarded\n" + "import supernote_module_generator.operation_lock\n" + ) + environment = os.environ.copy() + environment["PYTHONPATH"] = str(source) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_overlapping_cli_command_fails_cleanly_before_mutation(tmp_path: Path): root = plugin(tmp_path) before = (root / "package.json").read_bytes() diff --git a/tests/test_operations_spec.py b/tests/test_operations_spec.py index 2326500..915b296 100644 --- a/tests/test_operations_spec.py +++ b/tests/test_operations_spec.py @@ -2,6 +2,7 @@ import io import json +import os from pathlib import Path import pytest @@ -11,6 +12,7 @@ from supernote_module_generator.errors import SubprocessFailure from supernote_module_generator.feature_cli_operations import FeatureCliOperationService from supernote_module_generator.feature_workflows import FeatureDecisionCollector +from supernote_module_generator.platform_tools import gradle_wrapper_path from supernote_module_generator.plugin_build_integration import set_runtime_wiring from supernote_module_generator.transaction import Transaction, recover_pending @@ -75,7 +77,7 @@ def test_add_scaffolds_selected_families_without_backend_metadata( ) assert code == 0, stderr - assert stdout.startswith('✓ Added feature "document"\n') + assert stdout.splitlines()[0].endswith('Added feature "document"') feature = root / "local_modules/document" metadata = json.loads((feature / ".supernote-module.json").read_text()) assert "type" not in metadata @@ -173,7 +175,7 @@ def test_update_preserves_both_source_roots_and_deleted_starter(tmp_path: Path): @pytest.mark.parametrize("option", ["--skip-install", "--package-manager=npm"]) def test_update_rejects_dependency_options_when_refresh_is_not_required( - tmp_path: Path, option: str + tmp_path: Path, option: str, make_directory_symlink ): root = plugin(tmp_path) assert invoke( @@ -183,7 +185,7 @@ def test_update_rejects_dependency_options_when_refresh_is_not_required( feature = root / "local_modules/current" link = root / "node_modules/current" link.parent.mkdir() - link.symlink_to(feature, target_is_directory=True) + make_directory_symlink(link, feature) code, _, stderr = invoke(root, ["update", "current", option, "--yes"]) @@ -217,10 +219,62 @@ def test_add_postcondition_failure_rolls_back_feature_runtime_and_parent( assert "structural postconditions" in stderr assert not (root / "local_modules/broken").exists() assert not (root / "android/.supernote-module/v2-runtime").exists() + assert not (root / "local_modules").exists() + assert not (root / "android/.supernote-module").exists() for path, content in originals.items(): assert path.read_bytes() == content +@pytest.mark.skipif( + os.name == "nt", + reason="POSIX fake npm; byte-decoding behavior has a platform-neutral subprocess test", +) +def test_non_utf8_dependency_failure_is_structured_and_restores_exact_parents( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path, npm_lock=True) + tools = tmp_path / "tools" + tools.mkdir() + node = tools / "node" + npm = tools / "npm" + npm_state = tools / "npm-state" + node.write_text("#!/bin/sh\necho v20.0.0\n", encoding="utf-8") + npm.write_text( + f"#!/bin/sh\nif [ -f {str(npm_state)!r} ]; then exit 0; fi\n" + f"touch {str(npm_state)!r}\n" + "printf 'valid diagnostic\\n\\377invalid diagnostic\\n' >&2\n" + "exit 1\n", + encoding="utf-8", + ) + node.chmod(0o755) + npm.chmod(0o755) + monkeypatch.setenv("PATH", str(tools) + os.pathsep + os.environ["PATH"]) + + code, stdout, stderr = invoke( + root, + [ + "--json", + "add", + "broken", + "--starter", + "cpp", + "--package-manager", + "npm", + "--yes", + ], + ) + + payload = json.loads(stdout) + assert code == 1 + assert stderr == "" + assert payload["error"]["kind"] == "install_dependency_failed" + assert payload["error"]["phase"] == "install_dependency" + assert payload["error"]["subprocess"]["exit_code"] == 1 + assert "valid diagnostic" in payload["error"]["subprocess"]["relevant_lines"] + assert not (root / "local_modules").exists() + assert not (root / "android/.supernote-module").exists() + + def test_remove_dependency_failure_restores_feature_runtime_and_parent( tmp_path: Path, monkeypatch ): @@ -353,7 +407,7 @@ def test_empty_validation_rejects_leftover_package_registration_alone( def test_feature_validation_rejects_missing_main_application_registration( - tmp_path: Path, + tmp_path: Path, make_directory_symlink ): root = plugin(tmp_path) application = main_application(root) @@ -368,7 +422,7 @@ def test_feature_validation_rejects_missing_main_application_registration( (root / "android/app/build.gradle").write_bytes(app_build) link = root / "node_modules/safe" link.parent.mkdir() - link.symlink_to(root / "local_modules/safe", target_is_directory=True) + make_directory_symlink(link, root / "local_modules/safe") code, _, stderr = invoke(root, ["validate", "safe"]) @@ -490,11 +544,21 @@ def test_build_flag_routes_to_parent_assemble_task_and_changes_success_copy( tmp_path: Path, ): root = plugin(tmp_path) - gradle = root / "android/gradlew" - gradle.write_text( - '#!/bin/sh\ncase "$1" in\n --version|:app:assembleDebug) exit 0 ;;\n *) exit 1 ;;\nesac\n' - ) - gradle.chmod(0o755) + gradle = gradle_wrapper_path(root) + if os.name == "nt": + gradle.write_text( + "@echo off\r\n" + 'if "%1"=="--version" exit /b 0\r\n' + 'if "%1"==":app:assembleDebug" exit /b 0\r\n' + "exit /b 1\r\n", + encoding="utf-8", + ) + else: + gradle.write_text( + '#!/bin/sh\ncase "$1" in\n --version|:app:assembleDebug) exit 0 ;;\n *) exit 1 ;;\nesac\n', + encoding="utf-8", + ) + gradle.chmod(0o755) code, stdout, stderr = invoke( root, @@ -505,13 +569,15 @@ def test_build_flag_routes_to_parent_assemble_task_and_changes_success_copy( assert 'Added and built feature "built"' in stdout -def test_add_rejects_local_modules_symlink_that_escapes_plugin_root(tmp_path: Path): +def test_add_rejects_local_modules_symlink_that_escapes_plugin_root( + tmp_path: Path, make_directory_symlink +): plugin_root = tmp_path / "plugin" plugin_root.mkdir() root = plugin(plugin_root) outside = tmp_path / "outside" outside.mkdir() - (root / "local_modules").symlink_to(outside, target_is_directory=True) + make_directory_symlink(root / "local_modules", outside) code, _, stderr = invoke( root, diff --git a/tests/test_packaging.py b/tests/test_packaging.py index ea99f8f..92beccf 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -42,6 +42,7 @@ def test_release_license_and_manifest_are_present(): assert "recursive-include maintainers *.md" in manifest assert "recursive-include architecture *.md" in manifest assert "recursive-include tests" in manifest + assert "recursive-include src/supernote_module_generator/templates *" in manifest def test_root_readme_is_the_self_contained_pypi_description(): diff --git a/tests/test_platform_tools.py b/tests/test_platform_tools.py new file mode 100644 index 0000000..7fa2cbb --- /dev/null +++ b/tests/test_platform_tools.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +from supernote_module_generator.platform_tools import ( + gradle_wrapper_command, + gradle_wrapper_path, + host_command, + ndk_compiler_path, +) + + +def test_gradle_wrapper_uses_windows_batch_file_without_posix_shell(tmp_path: Path): + wrapper = gradle_wrapper_path(tmp_path, platform_name="nt") + + assert wrapper == tmp_path / "android/gradlew.bat" + assert gradle_wrapper_command( + wrapper, + ["--version"], + platform_name="nt", + ) == [str(wrapper), "--version"] + + +def test_gradle_wrapper_uses_sh_for_non_executable_posix_script(tmp_path: Path): + wrapper = gradle_wrapper_path(tmp_path, platform_name="posix") + wrapper.parent.mkdir(parents=True) + wrapper.write_text("#!/bin/sh\n", encoding="utf-8") + + assert gradle_wrapper_command( + wrapper, + [":app:assembleDebug"], + platform_name="posix", + ) == ["sh", str(wrapper), ":app:assembleDebug"] + + +def test_ndk_compiler_resolution_uses_windows_executable_suffix(tmp_path: Path): + prebuilt = tmp_path / "prebuilt" + compiler = prebuilt / "windows-x86_64/bin" + compiler.mkdir(parents=True) + clang = compiler / "clang.exe" + clangxx = compiler / "clang++.exe" + clang.write_bytes(b"") + clangxx.write_bytes(b"") + + assert ndk_compiler_path( + prebuilt, + "clang", + platform_name="nt", + ) == clang + assert ndk_compiler_path( + prebuilt, + "clang++", + platform_name="nt", + ) == clangxx + assert ndk_compiler_path( + prebuilt, + "clang", + platform_name="posix", + ) is None + + +def test_windows_host_command_uses_discovered_command_shim(monkeypatch): + monkeypatch.setattr( + "supernote_module_generator.platform_tools.shutil.which", + lambda command: rf"C:\Program Files\nodejs\{command}.CMD", + ) + + assert host_command("npm", platform_name="nt") == ( + r"C:\Program Files\nodejs\npm.CMD" + ) + assert host_command("npm", platform_name="posix") == "npm" diff --git a/tests/test_plugin_runtime_codegen.py b/tests/test_plugin_runtime_codegen.py index 2e5c52f..01618ae 100644 --- a/tests/test_plugin_runtime_codegen.py +++ b/tests/test_plugin_runtime_codegen.py @@ -3,8 +3,11 @@ from pathlib import Path import shutil import subprocess +import sys import textwrap +import pytest + from supernote_module_generator.feature_model import ( FeatureManifest, FeatureRegistryEntry, @@ -38,6 +41,48 @@ def registry(*names: str) -> PluginRuntimeRegistry: ) +def host_cxx_compiler(): + candidates = [ + os.environ.get("CXX"), + shutil.which("c++"), + shutil.which("clang++"), + ] + if os.name == "nt": + program_files = os.environ.get("ProgramFiles") + if program_files: + candidates.append(str(Path(program_files) / "LLVM/bin/clang++.exe")) + return next( + ( + str(Path(candidate)) + for candidate in candidates + if candidate and Path(candidate).is_file() + ), + None, + ) + + +def test_ksp_feature_roots_use_one_compiler_option_per_feature(tmp_path: Path): + for feature_count in (0, 1, 2, 32): + generated = generate_plugin_runtime( + tmp_path / str(feature_count), + registry(*(f"feature{index}" for index in range(feature_count))), + ) + gradle = (generated / "build.gradle").read_text() + root_options = [ + line.strip() + for line in gradle.splitlines() + if line.strip().startswith("arg('supernoteFeatureRoot_") + ] + + assert len(root_options) == feature_count + assert "supernoteFeatureRoots" not in gradle + for index, option in enumerate(root_options): + assert option.startswith( + f"arg('supernoteFeatureRoot_{index:08d}', " + ) + assert "\\tlocal_modules/" in option + + def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Path): runtime_registry = registry("alpha", "beta") generated = generate_plugin_runtime(tmp_path, runtime_registry) @@ -97,7 +142,28 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "**/libreactnative.so" in gradle assert "local_modules/@local/alpha/android/src/main/java" in gradle assert "local_modules/@local/beta/android/src/main/java" in gradle - assert "supernoteFeatureRoots" in gradle + assert "supernoteFeatureRoots" not in gradle + assert "arg('supernoteFeatureRoot_00000000'" in gradle + assert "arg('supernoteFeatureRoot_00000001'" in gradle + assert "supernote:feature:" in gradle + assert "\\tlocal_modules/@local/alpha/android/src/main/java" in gradle + assert "\\tlocal_modules/@local/beta/android/src/main/java" in gradle + assert "supernoteNativeRoots.findAll { it.isDirectory() }" in gradle + assert "def supernoteIsWindows" in gradle + assert "'supernote-v2/sn_supernote_runtime_" in gradle + assert "layout.buildDirectory.set(new File(supernoteWindowsBuildRoot, 'gradle'))" in gradle + assert "new File(supernoteWindowsBuildRoot, 'cxx')" in gradle + assert 'file("${rootProject.projectDir}/.cxx/snv2")' in gradle + assert "-DSUPERNOTE_GENERATED_ROOT=${layout.buildDirectory.dir('generated/supernote').get().asFile.absolutePath}" in gradle + assert "'--build-root'" in gradle + assert "layout.buildDirectory.get().asFile.absolutePath" in gradle + assert 'file(TO_CMAKE_PATH "${SUPERNOTE_GENERATED_ROOT}"' in cmake + assert '"${SUPERNOTE_GENERATED_ROOT}/${SUPERNOTE_VARIANT}/jni/*.cpp"' in cmake + assert "? ['py', '-3']" in gradle + assert ": ['python3']" in gradle + assert "*supernotePythonCommand" in gradle + assert 'optionPrefix = "supernoteFeatureRoot_"' in processor + assert "toSortedMap()" in processor assert "catch (_: SupernoteSourceDiagnostic)" in processor assert "throw SupernoteSourceDiagnostic()" in processor assert "throw IllegalArgumentException(message)" not in processor @@ -230,8 +296,9 @@ def test_activation_can_restore_previous_shared_component(tmp_path: Path): def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( tmp_path: Path, ): - compiler = shutil.which("c++") - assert compiler is not None + compiler = host_cxx_compiler() + if compiler is None: + pytest.skip("a host C++ compiler is required for the runtime contract") generated = generate_plugin_runtime(tmp_path, registry("alpha")) harness = tmp_path / "runtime_contract.cpp" harness.write_text( @@ -239,6 +306,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( r""" #include "runtime_services.hpp" + #include #include #include #include @@ -461,12 +529,15 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( ), encoding="utf-8", ) - executable = tmp_path / "runtime_contract" + executable = tmp_path / ( + "runtime_contract.exe" if os.name == "nt" else "runtime_contract" + ) + thread_flags = [] if os.name == "nt" else ["-pthread"] compiled = subprocess.run( [ compiler, "-std=c++23", - "-pthread", + *thread_flags, str(generated / "src/runtime_services.cpp"), str(harness), "-I", @@ -485,6 +556,128 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( assert executed.returncode == 0, executed.stderr +def test_generated_runtime_teardown_survives_allocation_failure(tmp_path: Path): + compiler = host_cxx_compiler() + if compiler is None: + pytest.skip("a host C++ compiler is required for the allocation harness") + generated = generate_plugin_runtime(tmp_path, registry("alpha")) + harness = tmp_path / "runtime_allocation_failure.cpp" + harness.write_text( + textwrap.dedent( + r""" + #include "runtime_services.hpp" + + #include + #include + #include + #include + #include + #include + #include + + namespace { + std::atomic fail_next{false}; + struct LargeCleanup { + std::array storage{}; + void operator()() const noexcept {} + }; + } + + void* operator new(std::size_t size) { + if (fail_next.exchange(false, std::memory_order_acq_rel)) { + throw std::bad_alloc(); + } + if (void* value = std::malloc(size)) return value; + throw std::bad_alloc(); + } + + void operator delete(void* value) noexcept { std::free(value); } + void operator delete(void* value, std::size_t) noexcept { + std::free(value); + } + + int main(int argc, char** argv) { + if (argc != 2) return 2; + std::set_terminate([] { std::_Exit(86); }); + using namespace supernote::runtime; + std::vector queue; + auto runtime = RuntimeSession::create( + [&](RuntimeSession::JsTask task) { + queue.push_back(std::move(task)); + }); + auto cleanup = std::make_shared(); + auto feature = FeatureSession::create(runtime, cleanup); + const std::string_view mode(argv[1]); + + if (mode == "schedule") { + fail_next = true; + if (runtime->schedule([](void*) {})) return 10; + } else if (mode == "submit-large") { + fail_next = true; + if (cleanup->submit(LargeCleanup{})) return 12; + } else if (mode == "invalidate") { + fail_next = true; + runtime->invalidate(); + } else if (mode == "close-feature") { + if (!feature->accept([](void*) {})) return 11; + fail_next = true; + feature->close_feature(); + } else if (mode == "close-service") { + auto service = feature->service( + "cpp:Service", [] { return std::make_shared(7); }); + fail_next = true; + feature->close_feature(); + } else { + return 3; + } + runtime->invalidate(); + cleanup->drain_and_shutdown(); + return 0; + } + """ + ), + encoding="utf-8", + ) + executable = tmp_path / ( + "runtime_allocation_failure.exe" + if os.name == "nt" + else "runtime_allocation_failure" + ) + thread_flags = [] if os.name == "nt" else ["-pthread"] + compiled = subprocess.run( + [ + compiler, + "-std=c++23", + *thread_flags, + str(generated / "src/runtime_services.cpp"), + str(harness), + "-I", + str(generated / "src"), + "-o", + str(executable), + ], + capture_output=True, + text=True, + check=False, + ) + assert compiled.returncode == 0, compiled.stderr + for mode in ( + "schedule", + "submit-large", + "invalidate", + "close-feature", + "close-service", + ): + executed = subprocess.run( + [str(executable), mode], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + assert executed.returncode == 0, f"{mode}: {executed.stderr}" + + def test_standalone_common_codegen_runs_without_repository_pythonpath(tmp_path: Path): generated = generate_plugin_runtime(tmp_path, registry("alpha")) feature = tmp_path / "local_modules/@local/alpha" @@ -493,7 +686,7 @@ def test_standalone_common_codegen_runs_without_repository_pythonpath(tmp_path: environment.pop("PYTHONPATH", None) result = subprocess.run( [ - "python3", + sys.executable, str(generated / "common_codegen.py"), "--plugin-root", str(tmp_path), @@ -555,7 +748,7 @@ def test_common_codegen_emits_real_cpp_jsi_route(tmp_path: Path): environment.pop("PYTHONPATH", None) result = subprocess.run( [ - "python3", + sys.executable, str(generated / "common_codegen.py"), "--plugin-root", str(tmp_path), @@ -626,7 +819,7 @@ class IndexService { environment.pop("PYTHONPATH", None) result = subprocess.run( [ - "python3", + sys.executable, str(generated / "common_codegen.py"), "--plugin-root", str(tmp_path), diff --git a/tests/test_spec_smoke.py b/tests/test_spec_smoke.py index 055a1ff..9fa232f 100644 --- a/tests/test_spec_smoke.py +++ b/tests/test_spec_smoke.py @@ -27,14 +27,14 @@ def invoke(root: Path, arguments: list[str]): return code, stdout.getvalue(), stderr.getvalue() -def test_add_validate_remove_smoke(tmp_path: Path): +def test_add_validate_remove_smoke(tmp_path: Path, make_directory_symlink): root = plugin(tmp_path) code, stdout, stderr = invoke( root, ["add", "local-math", "--starter", "cpp", "--skip-install", "--yes"], ) assert code == 0, stderr - assert stdout.startswith('✓ Added feature "local-math"\n') + assert stdout.splitlines()[0].endswith('Added feature "local-math"') module = root / "local_modules/local-math" assert (module / ".supernote-module.json").is_file() assert json.loads((module / "package.json").read_text())["name"] == "local-math" @@ -42,16 +42,16 @@ def test_add_validate_remove_smoke(tmp_path: Path): link = root / "node_modules/local-math" link.parent.mkdir() - link.symlink_to(module, target_is_directory=True) + make_directory_symlink(link, module) code, stdout, stderr = invoke(root, ["validate", "local-math"]) assert code == 0, stderr - assert stdout == '✓ Feature "local-math" is valid\n' + assert stdout.splitlines()[0].endswith('Feature "local-math" is valid') code, stdout, stderr = invoke( root, ["remove", "local-math", "--skip-install", "--yes"] ) assert code == 0, stderr - assert stdout.startswith('✓ Removed feature "local-math"\n') + assert stdout.splitlines()[0].endswith('Removed feature "local-math"') assert not module.exists() @@ -72,7 +72,9 @@ def test_validate_missing_dependency_link_gives_install_action_without_rollback( assert "Rollback:" not in stderr -def test_validate_all_uses_singular_copy_for_one_feature(tmp_path: Path): +def test_validate_all_uses_singular_copy_for_one_feature( + tmp_path: Path, make_directory_symlink +): root = plugin(tmp_path) assert invoke( root, @@ -81,12 +83,12 @@ def test_validate_all_uses_singular_copy_for_one_feature(tmp_path: Path): feature = root / "local_modules/local-math" link = root / "node_modules/local-math" link.parent.mkdir() - link.symlink_to(feature, target_is_directory=True) + make_directory_symlink(link, feature) code, stdout, stderr = invoke(root, ["validate", "--all"]) assert code == 0, stderr - assert stdout == "✓ 1 feature is valid\n" + assert stdout.splitlines()[0].endswith("1 feature is valid") def test_json_add_has_stable_envelope_and_empty_stderr(tmp_path: Path): diff --git a/tests/test_subprocesses.py b/tests/test_subprocesses.py new file mode 100644 index 0000000..f399455 --- /dev/null +++ b/tests/test_subprocesses.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +import pytest + +from supernote_module_generator.subprocesses import run_process + + +def test_run_process_uses_the_resolved_host_command(tmp_path: Path, monkeypatch): + monkeypatch.setattr( + "supernote_module_generator.subprocesses.host_command", + lambda command: sys.executable if command == "python-shim" else command, + ) + + result = run_process( + ["python-shim", "-c", "print('resolved')"], + cwd=tmp_path, + timeout=5, + ) + + assert result.returncode == 0 + assert result.stdout == "resolved\n" + assert result.args[0] == sys.executable + + +def _is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + if os.name == "posix": + state = subprocess.run( + ["ps", "-o", "stat=", "-p", str(pid)], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + return bool(state) and not state.startswith("Z") + return True + + +def _wait_until_stopped(pid: int) -> bool: + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if not _is_running(pid): + return True + time.sleep(0.05) + return not _is_running(pid) + + +def _write_process_tree(tmp_path: Path) -> tuple[Path, Path]: + grandchild_pid = tmp_path / "grandchild.pid" + grandchild = tmp_path / "grandchild.py" + grandchild.write_text( + "import os, signal, time\n" + f"open({str(grandchild_pid)!r}, 'w').write(str(os.getpid()))\n" + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + "time.sleep(60)\n", + encoding="utf-8", + ) + child = tmp_path / "child.py" + child.write_text( + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, {str(grandchild)!r}], " + "stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n" + "time.sleep(60)\n", + encoding="utf-8", + ) + return child, grandchild_pid + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +def test_timeout_stops_the_complete_child_process_group(tmp_path: Path): + child, pid_path = _write_process_tree(tmp_path) + + with pytest.raises(subprocess.TimeoutExpired): + run_process([sys.executable, str(child)], cwd=tmp_path, timeout=1) + + grandchild_pid = int(pid_path.read_text(encoding="utf-8")) + assert _wait_until_stopped(grandchild_pid) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX signal-forwarding regression") +def test_sigterm_stops_dependency_descendants(tmp_path: Path): + child, pid_path = _write_process_tree(tmp_path) + source_root = Path(__file__).parents[1] / "src" + wrapper = tmp_path / "wrapper.py" + wrapper.write_text( + "from pathlib import Path\n" + "import sys\n" + "from supernote_module_generator.subprocesses import run_process\n" + f"run_process([sys.executable, {str(child)!r}], " + f"cwd=Path({str(tmp_path)!r}), timeout=60)\n", + encoding="utf-8", + ) + environment = os.environ.copy() + environment["PYTHONPATH"] = str(source_root) + wrapper_process = subprocess.Popen( + [sys.executable, str(wrapper)], + cwd=tmp_path, + env=environment, + ) + deadline = time.monotonic() + 3 + while not pid_path.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert pid_path.is_file() + grandchild_pid = int(pid_path.read_text(encoding="utf-8")) + + wrapper_process.send_signal(signal.SIGTERM) + assert wrapper_process.wait(timeout=3) == -signal.SIGTERM + assert _wait_until_stopped(grandchild_pid) + + +def test_non_utf8_output_is_replaced_without_losing_the_exit_status(tmp_path: Path): + result = run_process( + [ + sys.executable, + "-c", + "import os; os.write(1, b'valid\\n\\xff\\n'); " + "os.write(2, b'failure\\n\\xfe\\n'); raise SystemExit(7)", + ], + cwd=tmp_path, + timeout=5, + ) + + assert result.returncode == 7 + assert "valid" in result.stdout + assert "failure" in result.stderr + assert "\ufffd" in result.stdout + assert "\ufffd" in result.stderr diff --git a/tests/test_transaction_spec.py b/tests/test_transaction_spec.py index be44fe1..ad0ac2a 100644 --- a/tests/test_transaction_spec.py +++ b/tests/test_transaction_spec.py @@ -28,6 +28,25 @@ def test_rollback_restores_files_and_removes_created_tree(tmp_path: Path): assert not (tmp_path / JOURNAL_NAME).exists() +def test_rollback_removes_only_empty_generator_created_parent_directories( + tmp_path: Path, +): + empty_parent = tmp_path / "local_modules" + preserved_parent = tmp_path / "android/.supernote-module" + transaction = Transaction(tmp_path, "add", ["local-math"]) + transaction.track_created_directory(empty_parent) + transaction.track_created_directory(preserved_parent) + empty_parent.mkdir() + preserved_parent.mkdir(parents=True) + (preserved_parent / "user-file").write_text("keep", encoding="utf-8") + + rollback = transaction.rollback() + + assert rollback.status == "completed" + assert not empty_parent.exists() + assert (preserved_parent / "user-file").read_text(encoding="utf-8") == "keep" + + def test_startup_recovery_uses_persistent_journal(tmp_path: Path): path = tmp_path / "package.json" path.write_text("before", encoding="utf-8") diff --git a/tests/test_typescript_codegen.py b/tests/test_typescript_codegen.py index a14f2cc..2ce5d05 100644 --- a/tests/test_typescript_codegen.py +++ b/tests/test_typescript_codegen.py @@ -59,6 +59,8 @@ def test_typescript_uses_only_public_common_semantics_and_exact_value_mappings() assert "hidden" not in text assert "export class SupernoteError extends Error" in text assert 'readonly code: SupernoteErrorCode;' in text + assert "| 'RESOURCE_EXHAUSTED'" in text + assert '| "RESOURCE_EXHAUSTED"' not in text def test_typescript_generates_public_object_factory_and_explicit_members_only():