amv-diffusion 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. termux_diffusion/workflow.py +1345 -0
@@ -0,0 +1,499 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """Automated C++ core engine provisioning, binary locator, build healer, and doctor diagnostics."""
4
+
5
+ import logging
6
+ import io
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ if hasattr(sys.stdout, "reconfigure"):
16
+ try:
17
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
18
+ except (AttributeError, io.UnsupportedOperation) as _out_err:
19
+ _ = _out_err
20
+ if hasattr(sys.stderr, "reconfigure"):
21
+ try:
22
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
23
+ except (AttributeError, io.UnsupportedOperation) as _err_err:
24
+ _ = _err_err
25
+
26
+
27
+
28
+ from .exceptions import ProvisioningError
29
+ from .platform import (
30
+ TERMUX_PREFIX,
31
+ check_memory_safety,
32
+ get_default_cache_dir,
33
+ get_memory_info,
34
+ is_android_termux,
35
+ is_arm64,
36
+ )
37
+
38
+ from .engine import (
39
+ get_engine_bin_dir,
40
+ get_engine_lib_dir,
41
+ get_binary_path,
42
+ locate_sd_cli,
43
+ )
44
+
45
+ from ._version import __version__
46
+
47
+ logger = logging.getLogger("termux_diffusion.installer")
48
+
49
+ SD_CPP_REPO = "https://github.com/leejet/stable-diffusion.cpp"
50
+
51
+ TERMUX_DIFFUSION_RELEASE_LATEST = "https://github.com/uno-km/termux-diffusion/releases/latest/download"
52
+
53
+
54
+ def get_prebuilt_base_url() -> str:
55
+ """Resolve dynamic base URL for prebuilt binary assets."""
56
+ if custom := os.environ.get("TERMUX_DIFFUSION_RELEASE_BASE") or os.environ.get("AMEVA_RELEASE_BASE"):
57
+ return custom.rstrip("/")
58
+ if custom_tag := os.environ.get("TERMUX_DIFFUSION_RELEASE_TAG") or os.environ.get("AMEVA_RELEASE_TAG"):
59
+ tag = custom_tag if custom_tag.startswith("v") else f"v{custom_tag}"
60
+ return f"https://github.com/uno-km/termux-diffusion/releases/download/{tag}"
61
+ return f"https://github.com/uno-km/termux-diffusion/releases/download/v{__version__}"
62
+
63
+
64
+ PREBUILT_BASE_URL = get_prebuilt_base_url()
65
+
66
+
67
+ def get_candidate_prebuilt_urls(filename: str = "sd-cli-cpu-android-arm64.tar.gz") -> List[str]:
68
+ """Resolve prioritized candidate URLs for downloading prebuilt engine packages."""
69
+ urls: List[str] = []
70
+ if custom_base := os.environ.get("TERMUX_DIFFUSION_RELEASE_BASE") or os.environ.get("AMEVA_RELEASE_BASE"):
71
+ urls.append(f"{custom_base.rstrip('/')}/{filename}")
72
+ # Tier 2: GitHub Releases latest canonical endpoint (Zero-Hardcoding SSOT)
73
+ urls.append(f"{TERMUX_DIFFUSION_RELEASE_LATEST}/{filename}")
74
+
75
+ # Tier 3: Installed package dynamic version matching
76
+ urls.append(f"https://github.com/uno-km/termux-diffusion/releases/download/v{__version__}/{filename}")
77
+ return urls
78
+
79
+
80
+ import uuid
81
+ from .locking import InstallLock
82
+ from .downloader import atomic_download_file
83
+ from .selftest import run_binary_self_test
84
+
85
+
86
+ def activate_binary(bin_dir: Path, target_name: str) -> Path:
87
+ """Atomically activate target_name symlink to sd-cli using os.replace in same directory."""
88
+ allowed = {
89
+ "sd-cli-cpu",
90
+ "sd-cli-source-cpu",
91
+ "sd-cli",
92
+ "sd-cli-vulkan",
93
+ }
94
+ if target_name not in allowed:
95
+ raise ValueError(f"Unauthorized activation target: {target_name}")
96
+
97
+ target = bin_dir / target_name
98
+ active = bin_dir / "sd-cli"
99
+ temporary = bin_dir / f".sd-cli.{uuid.uuid4().hex}.tmp"
100
+
101
+ if not target.is_file() or target.is_symlink():
102
+ raise RuntimeError(f"Activation target {target_name} is not a valid regular file")
103
+
104
+ try:
105
+ try:
106
+ os.symlink(target_name, temporary)
107
+ os.replace(temporary, active)
108
+ except (NotImplementedError, OSError):
109
+ # symlink 미지원 OS(일부 Windows, FAT32 등) — copy2 fallback.
110
+ logger.warning(
111
+ "[installer] symlink not supported; using copy2 fallback for %s -> %s",
112
+ target_name, active,
113
+ )
114
+ shutil.copy2(target, active)
115
+ return active
116
+ finally:
117
+ if temporary.exists() or temporary.is_symlink():
118
+ try:
119
+ temporary.unlink()
120
+ except (PermissionError, OSError) as _tmp_err:
121
+ logger.warning(
122
+ "[installer] Failed to clean up temporary symlink %s: %s. "
123
+ "Manual cleanup may be required.",
124
+ temporary, _tmp_err,
125
+ )
126
+
127
+
128
+
129
+
130
+ def is_valid_elf(path: Path) -> bool:
131
+ """Verifies that the target path is a valid ELF executable/library via magic bytes."""
132
+ try:
133
+ p = path.resolve() if path.is_symlink() else path
134
+ if not p.is_file():
135
+ return False
136
+ with open(p, "rb") as f:
137
+ return f.read(4) == b"\x7fELF"
138
+ except (OSError, PermissionError):
139
+ return False
140
+
141
+
142
+ def fetch_prebuilt_binary(
143
+ install_mode: str = "prebuilt-first",
144
+ force: bool = False,
145
+ dedicate: bool = False,
146
+ ) -> Optional[Path]:
147
+ """Try acquiring prebuilt Bionic ARM64 CPU baseline binary with integrity verification and self-test."""
148
+ if not (is_android_termux() and is_arm64()):
149
+ return None
150
+
151
+ bin_dir = get_engine_bin_dir()
152
+ lock_file = get_default_cache_dir() / "install.lock"
153
+
154
+ with InstallLock(lock_file, timeout_sec=5.0):
155
+ if install_mode == "source-only":
156
+ return None
157
+
158
+ cpu_bin = bin_dir / "sd-cli-cpu"
159
+ lib_dir = get_engine_lib_dir()
160
+ omp_so = lib_dir / "libomp.so"
161
+
162
+ # Dedicated triage mode
163
+ if dedicate:
164
+ active_link = bin_dir / "sd-cli"
165
+ if active_link.is_symlink():
166
+ target = str(active_link.resolve())
167
+ if ".local/share/ameva" in target:
168
+ print("[termux-diffusion] [DEDICATE] AMEVA Runtime managed engine detected. Preserving co-existence (<0.002s).")
169
+ return active_link
170
+
171
+ # If force, clear existing binaries to guarantee fresh acquisition
172
+ if force:
173
+ if cpu_bin.is_file():
174
+ cpu_bin.unlink(missing_ok=True)
175
+ if omp_so.is_file():
176
+ omp_so.unlink(missing_ok=True)
177
+
178
+ # "있어? 넘어가" - Skip if verified ELF binary already exists
179
+ if not force and cpu_bin.is_file() and is_valid_elf(cpu_bin):
180
+ if omp_so.is_file() and is_valid_elf(omp_so):
181
+ active = activate_binary(bin_dir, "sd-cli-cpu")
182
+ print("[termux-diffusion] [OK] Verified native CPU engine already present. Skipping download (<0.002s).")
183
+ return active
184
+
185
+ print("[termux-diffusion] Attempting Prebuilt CPU Baseline Engine installation...")
186
+ try:
187
+ # 1. Ensure OpenMP companion library (libomp.so)
188
+ if force or not omp_so.is_file() or not is_valid_elf(omp_so):
189
+ omp_urls = get_candidate_prebuilt_urls("libomp-android-arm64.so")
190
+ print("[termux-diffusion] Provisioning OpenMP parallel runtime (libomp.so)...")
191
+ for o_url in omp_urls:
192
+ try:
193
+ atomic_download_file(o_url, omp_so)
194
+ if omp_so.is_file() and is_valid_elf(omp_so):
195
+ break
196
+ except Exception as o_err:
197
+ logger.debug("OpenMP download candidate failed from %s: %s", o_url, o_err)
198
+
199
+ if omp_so.is_file():
200
+ try:
201
+ omp_so.chmod(0o755)
202
+ except OSError:
203
+ pass
204
+
205
+ # 2. Ensure sd-cli-cpu binary
206
+ if force or not cpu_bin.is_file() or not is_valid_elf(cpu_bin):
207
+ candidate_urls = get_candidate_prebuilt_urls("sd-cli-cpu-android-arm64.tar.gz")
208
+ staging_dir = get_default_cache_dir() / ".staging-diffusion-cpu"
209
+ staging_dir.mkdir(parents=True, exist_ok=True)
210
+ tar_dest = staging_dir / "cpu-prebuilt.tar.gz"
211
+ downloaded = False
212
+ for pkg_url in candidate_urls:
213
+ try:
214
+ print(f"[termux-diffusion] Downloading CPU baseline binary from: {pkg_url} ...")
215
+ atomic_download_file(pkg_url, tar_dest)
216
+ downloaded = True
217
+ break
218
+ except Exception as dl_err:
219
+ logger.debug("CPU prebuilt download candidate failed from %s: %s", pkg_url, dl_err)
220
+
221
+ if downloaded and tar_dest.is_file():
222
+ import tarfile
223
+ with tarfile.open(tar_dest, "r:gz") as tar:
224
+ for member in tar.getmembers():
225
+ member_path = os.path.realpath(os.path.join(staging_dir, member.name))
226
+ if not member_path.startswith(os.path.realpath(staging_dir)):
227
+ raise ProvisioningError(
228
+ f"[termux-diffusion] E_TAR_PATH_ESCAPE: tarball 내 경로 탈출 시도가 감지되어 추출을 중단했습니다: {member.name}"
229
+ )
230
+ tar.extractall(path=staging_dir)
231
+ tar_dest.unlink(missing_ok=True)
232
+
233
+ for cand in staging_dir.rglob("sd-cli*"):
234
+ if cand.is_file() and is_valid_elf(cand):
235
+ shutil.copy2(cand, cpu_bin)
236
+ cpu_bin.chmod(0o755)
237
+ break
238
+
239
+ shutil.rmtree(staging_dir, ignore_errors=True)
240
+
241
+ if cpu_bin.is_file() and is_valid_elf(cpu_bin) and run_binary_self_test(cpu_bin, expected_backend="cpu").stage1_load_passed:
242
+ active = activate_binary(bin_dir, "sd-cli-cpu")
243
+ print("[termux-diffusion] Fast-Track: Prebuilt CPU Baseline binary validated and activated.")
244
+ return active
245
+ except ProvisioningError:
246
+ raise
247
+ except Exception as exc:
248
+ logger.debug("CPU prebuilt attempt failed: %s", exc)
249
+
250
+ return None
251
+
252
+
253
+ def provision_engine(
254
+ force: bool = False,
255
+ install_mode: str = "prebuilt-first",
256
+ jobs: Optional[int] = None,
257
+ make_jobs: Optional[int] = None,
258
+ backend: str = "cpu",
259
+ dedicate: bool = False,
260
+ ) -> Path:
261
+ """Download, verify, or compile stable-diffusion.cpp into ~/.cache/termux-diffusion/bin/sd-cli."""
262
+ bin_dir = get_engine_bin_dir()
263
+
264
+ # Prebuilt-First Pipeline (Pure CPU Baseline Engine)
265
+ if install_mode in ("prebuilt-first", "prebuilt-only"):
266
+ prebuilt = fetch_prebuilt_binary(install_mode=install_mode, force=force, dedicate=dedicate)
267
+ if prebuilt:
268
+ return prebuilt
269
+
270
+ # COMPILATION GATE: Never compile automatically without explicit user opt-in (Zero Silent Fallback)
271
+ allow_source = os.environ.get("TERMUX_DIFFUSION_ALLOW_SOURCE_BUILD") == "1"
272
+ if not allow_source or install_mode == "prebuilt-only":
273
+ raise ProvisioningError(
274
+ "E_PREBUILT_UNAVAILABLE: Precompiled native CPU engine (sd-cli-cpu) is not installed.\n"
275
+ "No precompiled binary was found locally and automated acquisition from GitHub Releases failed.\n"
276
+ "On-device compilation is disabled by default to prevent thermal throttling, excessive battery drain, and memory exhaustion.\n\n"
277
+ "--> ACTION REQUIRED:\n"
278
+ "1. Ensure internet access to GitHub and run: 'termux-diffusion install'\n"
279
+ "2. Or manually download 'sd-cli-cpu-android-arm64.tar.gz' and 'libomp-android-arm64.so' from:\n"
280
+ " https://github.com/uno-km/termux-diffusion/releases/latest\n"
281
+ "3. If you explicitly want to compile from C++ source on this device, you MUST specify:\n"
282
+ " export TERMUX_DIFFUSION_ALLOW_SOURCE_BUILD=1\n"
283
+ " and optionally set compilation cores:\n"
284
+ " export TERMUX_DIFFUSION_JOBS=<cores>\n",
285
+ code="E_PREBUILT_UNAVAILABLE"
286
+ )
287
+
288
+ print("[termux-diffusion] Initializing native ARM64 Bionic CPU engine source compilation...")
289
+
290
+ # Step 1: Ensure required system packages
291
+ if is_android_termux() and shutil.which("pkg"):
292
+ print("[termux-diffusion] Checking build toolchains...")
293
+ try:
294
+ subprocess.run(
295
+ [
296
+ "pkg", "install", "-y",
297
+ "git", "cmake", "clang", "make", "termux-api", "wget"
298
+ ],
299
+ capture_output=False,
300
+ check=False,
301
+ timeout=180.0
302
+ )
303
+ except Exception as exc:
304
+ logger.warning("pkg install invocation warning: %s", exc)
305
+
306
+ # Verify toolchain existence
307
+ has_compiler = bool(shutil.which("clang") or shutil.which("gcc") or shutil.which("clang++"))
308
+ has_cmake = bool(shutil.which("cmake"))
309
+ has_git = bool(shutil.which("git"))
310
+
311
+ if not (has_compiler and has_cmake and has_git):
312
+ missing = []
313
+ if not has_compiler:
314
+ missing.append("clang / gcc")
315
+ if not has_cmake:
316
+ missing.append("cmake")
317
+ if not has_git:
318
+ missing.append("git")
319
+ raise ProvisioningError(
320
+ f"Missing required build tools: {', '.join(missing)}. "
321
+ f"Please run 'pkg install -y clang cmake git termux-api' before provisioning."
322
+ )
323
+
324
+ # Step 2: Set up build directory
325
+ build_root = get_default_cache_dir() / "build_src"
326
+ build_root.mkdir(parents=True, exist_ok=True)
327
+ repo_dir = build_root / "stable-diffusion.cpp"
328
+
329
+ if not repo_dir.exists():
330
+ print(f"[termux-diffusion] Cloning {SD_CPP_REPO} (depth=1, recursive)...")
331
+ try:
332
+ res = subprocess.run(
333
+ ["git", "clone", "--depth", "1", "--recursive", SD_CPP_REPO, str(repo_dir)],
334
+ capture_output=True,
335
+ text=True,
336
+ timeout=180.0
337
+ )
338
+ if res.returncode != 0:
339
+ raise ProvisioningError(f"Failed cloning stable-diffusion.cpp repository: {res.stderr.strip()}")
340
+ except subprocess.TimeoutExpired as exc:
341
+ raise ProvisioningError(
342
+ "Network timeout (180s) while cloning stable-diffusion.cpp. "
343
+ "Please check your internet connection or install sd-cli manually."
344
+ ) from exc
345
+
346
+ # Step 3: Crucial Submodule Update (Ensures ggml is present)
347
+ print("[termux-diffusion] Synchronizing tensor submodules (ggml)...")
348
+ try:
349
+ sub_res = subprocess.run(
350
+ ["git", "submodule", "update", "--init", "--recursive", "--depth", "1"],
351
+ cwd=str(repo_dir),
352
+ capture_output=True,
353
+ text=True,
354
+ timeout=180.0,
355
+ check=False
356
+ )
357
+ if sub_res.returncode != 0:
358
+ logger.warning("Git submodule sync note: %s", sub_res.stderr)
359
+ except subprocess.TimeoutExpired:
360
+ logger.warning("Submodule sync timed out after 45s. Proceeding with existing source files.")
361
+
362
+ # Bionic Healer: Fix Termux libwebp NDK cpu-features dependency
363
+ webp_cmake = repo_dir / "thirdparty" / "libwebp" / "CMakeLists.txt"
364
+ if webp_cmake.is_file():
365
+ try:
366
+ content = webp_cmake.read_text(encoding="utf-8")
367
+ content = content.replace("if(ANDROID)", "if(FALSE)").replace("if (ANDROID)", "if(FALSE)")
368
+ webp_cmake.write_text(content, encoding="utf-8")
369
+ logger.info("Patched libwebp CMakeLists.txt for Termux Bionic compatibility.")
370
+ except Exception as e:
371
+ logger.warning("libwebp patch note: %s", e)
372
+
373
+
374
+
375
+ # Step 4: CMake & Compilation - Use hardware-detected optimal flags
376
+ build_dir = repo_dir / "build"
377
+ if build_dir.exists():
378
+ shutil.rmtree(build_dir, ignore_errors=True)
379
+ build_dir.mkdir(parents=True, exist_ok=True)
380
+
381
+ from .hardware import detect_hardware_profile, format_hardware_report, _build_cmake_flags
382
+ hw_profile = detect_hardware_profile()
383
+ print(f"[termux-diffusion] Detected SoC: {hw_profile.soc_name}, GPU: {hw_profile.gpu_name}")
384
+ print(f"[termux-diffusion] Vulkan: {'Available' if hw_profile.vulkan_available else 'Not Found'}, "
385
+ f"OpenCL: {'Available' if hw_profile.opencl_available else 'Not Found'}")
386
+ print(f"[termux-diffusion] CPU Extensions: DotProd={'Y' if hw_profile.has_dotprod else 'N'} "
387
+ f"FP16={'Y' if hw_profile.has_fp16 else 'N'} I8MM={'Y' if hw_profile.has_i8mm else 'N'}")
388
+ print("[termux-diffusion] Target build backend: cpu")
389
+
390
+ print("[termux-diffusion] Configuring CMake build with device-optimized flags...")
391
+ use_ninja = bool(shutil.which("ninja"))
392
+ cmake_cmd = [
393
+ "cmake", "..",
394
+ "-DCMAKE_BUILD_TYPE=Release",
395
+ "-DCMAKE_SYSTEM_NAME=Linux",
396
+ "-DANDROID=OFF",
397
+ "-DSD_BUILD_EXAMPLES=ON",
398
+ "-DGGML_OPENMP=OFF",
399
+ "-DWEBP_ENABLE_SIMD=OFF",
400
+ ]
401
+ if use_ninja:
402
+ cmake_cmd.extend(["-G", "Ninja"])
403
+
404
+ # Append hardware-specific flags (DotProd, FP16, etc. with pure CPU backend)
405
+ flags = _build_cmake_flags(hw_profile, backend="cpu")
406
+ cmake_cmd.extend(flags)
407
+ cmake_res = subprocess.run(
408
+ cmake_cmd,
409
+ cwd=str(build_dir),
410
+ capture_output=True,
411
+ text=True
412
+ )
413
+ if cmake_res.returncode != 0:
414
+ raise ProvisioningError(f"CMake configuration failed: {cmake_res.stderr.strip()}")
415
+
416
+ # Determine parallel compilation jobs
417
+ env_jobs = os.environ.get("TERMUX_DIFFUSION_MAKE_JOBS") or os.environ.get("TERMUX_DIFFUSION_JOBS")
418
+ if jobs is not None:
419
+ actual_jobs = max(1, int(jobs))
420
+ elif make_jobs is not None:
421
+ actual_jobs = max(1, int(make_jobs))
422
+ elif env_jobs and env_jobs.strip().isdigit():
423
+ actual_jobs = max(1, int(env_jobs.strip()))
424
+ else:
425
+ mem_info = get_memory_info()
426
+ cpu_cores = os.cpu_count() or 2
427
+ total_ram = mem_info.get("effective_total_mb") or mem_info.get("mem_total_mb", 4096)
428
+ if total_ram < 4096:
429
+ actual_jobs = 1 # RAM < 4GB: Single job to prevent Clang compiler OOM/LMK
430
+ elif total_ram < 8192:
431
+ actual_jobs = min(2, cpu_cores) # RAM 4-8GB: 2 parallel jobs
432
+ else:
433
+ actual_jobs = min(4, cpu_cores) # RAM 8GB+: Up to 4 parallel jobs
434
+
435
+ builder_name = "ninja" if use_ninja else "make"
436
+ print(f"[termux-diffusion] Compiling native Bionic binary with clang ({builder_name} sd-cli -j{actual_jobs})...")
437
+ build_run_cmd = ["ninja", "sd-cli", f"-j{actual_jobs}"] if use_ninja else ["make", "sd-cli", f"-j{actual_jobs}"]
438
+ make_res = subprocess.run(
439
+ build_run_cmd,
440
+ cwd=str(build_dir),
441
+ capture_output=True,
442
+ text=True
443
+ )
444
+ if make_res.returncode != 0:
445
+ full_err = (make_res.stdout or "") + "\n" + (make_res.stderr or "")
446
+ err_tail = "\n".join(full_err.strip().splitlines()[-15:]) if full_err.strip() else "No compiler error output"
447
+ raise ProvisioningError(
448
+ f"Compilation failed with exit code {make_res.returncode}.\n"
449
+ f"Compiler Error:\n{err_tail}\n"
450
+ "Please run 'termux-diffusion doctor' to diagnose missing headers."
451
+ )
452
+
453
+ # Locate compiled binary
454
+ compiled_bin = None
455
+ for candidate in (build_dir / "bin" / "sd-cli", build_dir / "bin" / "sd", build_dir / "sd-cli", build_dir / "sd"):
456
+ if candidate.is_file():
457
+ compiled_bin = candidate
458
+ break
459
+
460
+ if not compiled_bin:
461
+ raise ProvisioningError("Could not locate compiled binary in build directory.")
462
+
463
+ # Install into cache bin directory using atomic rename
464
+ target_bin = get_engine_bin_dir() / "sd-cli"
465
+ temp_bin = target_bin.with_name(f"sd-cli.{os.getpid()}.part")
466
+ shutil.copy2(compiled_bin, temp_bin)
467
+ temp_bin.chmod(0o755)
468
+ os.replace(temp_bin, target_bin)
469
+
470
+ print(f"[termux-diffusion] Engine provisioned successfully at: {target_bin}")
471
+ install_auxiliary_assets()
472
+ return target_bin.resolve()
473
+
474
+
475
+ def install_auxiliary_assets() -> None:
476
+ """Assetize and deploy pre-cached prompt embeddings and auxiliary models into user cache."""
477
+ cache_root = get_default_cache_dir()
478
+ prompt_cache_target = cache_root / "prompt_cache"
479
+ prompt_cache_target.mkdir(parents=True, exist_ok=True)
480
+
481
+ bundled_prompts = Path(__file__).parent / "data" / "prompt_cache"
482
+ if bundled_prompts.is_dir():
483
+ copied_count = 0
484
+ for asset in bundled_prompts.iterdir():
485
+ if asset.is_file():
486
+ dest = prompt_cache_target / asset.name
487
+ if not dest.exists():
488
+ shutil.copy2(asset, dest)
489
+ copied_count += 1
490
+ logger.info("[termux-diffusion] Assetized prompt cache entry: %s", asset.name)
491
+ if copied_count > 0:
492
+ print(f"[termux-diffusion] Deployed {copied_count} bundled prompt cache asset(s).")
493
+
494
+ print(f"[termux-diffusion] Prompt cache assets initialized at: {prompt_cache_target}")
495
+
496
+
497
+ # Re-export diagnostic utilities from .doctor for backward compatibility
498
+ from .doctor import run_doctor
499
+
@@ -0,0 +1,134 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ import logging
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from .exceptions import InstallLockError
11
+
12
+ logger = logging.getLogger("termux_diffusion.locking")
13
+
14
+
15
+ class InstallLock:
16
+ """File lock manager controlling full installer state machine execution with stale PID auto-recovery."""
17
+ def __init__(self, lock_file: Path, timeout_sec: float = 5.0):
18
+ self.lock_file = lock_file
19
+ self.timeout_sec = timeout_sec
20
+ self._acquired = False
21
+
22
+ def _is_pid_alive(self, pid: int) -> bool | None:
23
+ """Check if PID is alive.
24
+
25
+ 반환:
26
+ True → 프로세스 확인됨
27
+ False → 프로세스 종료됨
28
+ None → 측정 불가 (PermissionError 등)
29
+ → 호출자는 None을 alive=True로 변환하지 말 것
30
+ """
31
+ if pid <= 0:
32
+ return False
33
+ if sys.platform == "win32":
34
+ try:
35
+ import ctypes
36
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
37
+ h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
38
+ if h:
39
+ ctypes.windll.kernel32.CloseHandle(h)
40
+ return True
41
+ # OpenProcess가 NULL 반환 — 프로세스 없음 또는 접근 거부
42
+ # GetLastError()로 구분 가능하나 여기서는 False(종료)로 처리
43
+ return False
44
+ except PermissionError:
45
+ # 측정 불가 — 스테일 판정을 False로 하지 않음
46
+ logger.warning("[locking] _is_pid_alive PermissionError for pid=%d (unmeasurable)", pid)
47
+ return None
48
+ except (AttributeError, OSError) as _win_err:
49
+ # ctypes.windll 미지원 또는 Win32 API 오류
50
+ logger.warning("[locking] _is_pid_alive Win32 error for pid=%d: %s (unmeasurable)", pid, _win_err)
51
+ return None
52
+ # 예상 밖 예외는 재발생
53
+ else:
54
+ try:
55
+ os.kill(pid, 0)
56
+ return True
57
+ except ProcessLookupError:
58
+ return False # Allowed: process gone -> correctly identified as not alive.
59
+ except PermissionError:
60
+ # 권한 없음 — 측정 불가
61
+ logger.warning("[locking] _is_pid_alive PermissionError for pid=%d (unmeasurable)", pid)
62
+ return None
63
+ except OSError as _os_err:
64
+ logger.warning("[locking] _is_pid_alive OSError for pid=%d: %s", pid, _os_err)
65
+ return None
66
+
67
+
68
+
69
+ def _check_and_clear_stale_lock(self) -> bool:
70
+ """Check if existing lock file belongs to a deceased process, removing it if stale."""
71
+ if not self.lock_file.exists():
72
+ return True
73
+ try:
74
+ content = self.lock_file.read_text(encoding="utf-8")
75
+ for line in content.splitlines():
76
+ if line.startswith("pid="):
77
+ pid_str = line.split("=", 1)[1].strip()
78
+ if pid_str.isdigit():
79
+ pid = int(pid_str)
80
+ alive = self._is_pid_alive(pid)
81
+ if alive is None:
82
+ # 측정 불가 — 스테일 판정 금지. 잠금을 보존 (fail-closed).
83
+ logger.warning(
84
+ "[locking] Cannot determine if PID %d is alive (unmeasurable). "
85
+ "Preserving lock to avoid data corruption.",
86
+ pid,
87
+ )
88
+ return False
89
+ if not alive:
90
+ logger.warning(
91
+ "[termux-diffusion] Stale installation lock detected (PID: %d is dead). Auto-reclaiming lock.",
92
+ pid,
93
+ )
94
+ self.lock_file.unlink(missing_ok=True)
95
+ return True
96
+
97
+ except Exception as e:
98
+ logger.debug("Stale lock inspection note: %s", e)
99
+ return False
100
+
101
+ def acquire(self):
102
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
103
+ self._check_and_clear_stale_lock()
104
+ start = time.time()
105
+ while time.time() - start < self.timeout_sec:
106
+ try:
107
+ fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
108
+ os.write(fd, f"pid={os.getpid()}\nstarted_at={time.time()}\n".encode("utf-8"))
109
+ os.close(fd)
110
+ self._acquired = True
111
+ return
112
+ except FileExistsError:
113
+ if self._check_and_clear_stale_lock():
114
+ continue
115
+ time.sleep(0.2)
116
+ raise InstallLockError(
117
+ f"E_INSTALL_LOCKED: Installation process locked by another running process ({self.lock_file})."
118
+ )
119
+
120
+ def release(self):
121
+ if self._acquired and self.lock_file.exists():
122
+ try:
123
+ self.lock_file.unlink(missing_ok=True)
124
+ except Exception as e:
125
+ logger.warning("Failed removing install.lock: %s", e)
126
+ self._acquired = False
127
+
128
+ def __enter__(self):
129
+ self.acquire()
130
+ return self
131
+
132
+ def __exit__(self, exc_type, exc_val, exc_tb):
133
+ self.release()
134
+