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.
- amv_diffusion-2.1.0.dist-info/METADATA +180 -0
- amv_diffusion-2.1.0.dist-info/RECORD +39 -0
- amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
- amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
- amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
- amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
- termux_diffusion/__init__.py +185 -0
- termux_diffusion/__main__.py +7 -0
- termux_diffusion/_version.py +3 -0
- termux_diffusion/adapter.py +41 -0
- termux_diffusion/canvas.py +364 -0
- termux_diffusion/cli.py +492 -0
- termux_diffusion/cluster.py +340 -0
- termux_diffusion/control/__init__.py +5 -0
- termux_diffusion/control/component.py +309 -0
- termux_diffusion/control/status.py +15 -0
- termux_diffusion/core.py +2936 -0
- termux_diffusion/data/bootstrap-manifest.json +15 -0
- termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
- termux_diffusion/data/release-public-keys.json +10 -0
- termux_diffusion/data/validated-vulkan-profiles.json +298 -0
- termux_diffusion/detailer.py +283 -0
- termux_diffusion/doctor.py +128 -0
- termux_diffusion/downloader.py +111 -0
- termux_diffusion/engine.py +226 -0
- termux_diffusion/exceptions.py +158 -0
- termux_diffusion/hardware.py +985 -0
- termux_diffusion/hub.py +686 -0
- termux_diffusion/installer.py +499 -0
- termux_diffusion/locking.py +134 -0
- termux_diffusion/manifest.py +128 -0
- termux_diffusion/npu.py +267 -0
- termux_diffusion/platform.py +533 -0
- termux_diffusion/purger.py +145 -0
- termux_diffusion/py.typed +1 -0
- termux_diffusion/safety.py +150 -0
- termux_diffusion/selftest.py +178 -0
- termux_diffusion/web.py +3370 -0
- termux_diffusion/workflow.py +1345 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""Platform inspection, Samsung Galaxy hardware detection, memory checks, and Android OS bridges."""
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import glob
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("termux_diffusion.platform")
|
|
18
|
+
|
|
19
|
+
# Android Termux standard paths
|
|
20
|
+
TERMUX_PREFIX = os.environ.get("PREFIX") or "/data/data/com.termux/files/usr"
|
|
21
|
+
TERMUX_HOME = os.environ.get("HOME") or "/data/data/com.termux/files/home"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_clean_execution_env(
|
|
25
|
+
base_env: Optional[Dict[str, str]] = None,
|
|
26
|
+
backend: str = "cpu",
|
|
27
|
+
) -> Dict[str, str]:
|
|
28
|
+
"""Construct clean execution environment for Termux binaries with proper library search paths.
|
|
29
|
+
|
|
30
|
+
Guarantees $PREFIX, $PREFIX/lib, and companion directories are correctly populated
|
|
31
|
+
while preventing Android system library collision (/system/, /vendor/, etc.).
|
|
32
|
+
|
|
33
|
+
Zero-Collision Rule:
|
|
34
|
+
For native Bionic Vulkan binaries (backend in ('vulkan', 'gpu')), Termux $PREFIX/lib is
|
|
35
|
+
strictly excluded from LD_LIBRARY_PATH to eliminate symbol collisions with Android Bionic
|
|
36
|
+
system libraries (/system/lib64/libunwindstack.so, etc.).
|
|
37
|
+
"""
|
|
38
|
+
env = (base_env or os.environ).copy()
|
|
39
|
+
prefix = os.environ.get("PREFIX") or "/data/data/com.termux/files/usr"
|
|
40
|
+
|
|
41
|
+
if "PREFIX" not in env or not env["PREFIX"]:
|
|
42
|
+
env["PREFIX"] = prefix
|
|
43
|
+
|
|
44
|
+
bin_dir = str(Path(prefix) / "bin")
|
|
45
|
+
cur_path = env.get("PATH", "")
|
|
46
|
+
if bin_dir not in cur_path.split(":"):
|
|
47
|
+
env["PATH"] = f"{bin_dir}:{cur_path}" if cur_path else bin_dir
|
|
48
|
+
|
|
49
|
+
req_backend = str(backend or "cpu").strip().lower()
|
|
50
|
+
is_vulkan_route = req_backend in ("vulkan", "gpu")
|
|
51
|
+
is_opencl_route = req_backend in ("opencl", "cl")
|
|
52
|
+
|
|
53
|
+
forbidden_prefixes = ("/system/", "/vendor/", "/apex/", "/system_ext/", "/odm/", "/product/")
|
|
54
|
+
cur_ld = env.get("LD_LIBRARY_PATH", "")
|
|
55
|
+
existing_parts = [p for p in cur_ld.split(":") if p and not any(p.startswith(fp) for fp in forbidden_prefixes)]
|
|
56
|
+
|
|
57
|
+
if is_vulkan_route:
|
|
58
|
+
# Strictly isolate Vulkan binaries from Termux /usr/lib to prevent libc++ ABI crash
|
|
59
|
+
lib_candidates = [
|
|
60
|
+
str(Path.home() / ".local" / "share" / "ameva" / "current" / "diffusion"),
|
|
61
|
+
str(Path.home() / ".cache" / "termux-diffusion" / "lib"),
|
|
62
|
+
]
|
|
63
|
+
# Filter out prefix/lib from existing parts for Vulkan
|
|
64
|
+
existing_parts = [p for p in existing_parts if not p.startswith(str(Path(prefix) / "lib"))]
|
|
65
|
+
elif is_opencl_route:
|
|
66
|
+
# Strictly prioritize OpenCL standalone bin directory and exclude /usr/lib (stub libOpenCL.so)
|
|
67
|
+
lib_candidates = [
|
|
68
|
+
str(Path.home() / "sd-opencl-build" / "build" / "bin"),
|
|
69
|
+
str(Path.home() / ".cache" / "termux-diffusion" / "lib"),
|
|
70
|
+
]
|
|
71
|
+
existing_parts = [p for p in existing_parts if not p.startswith(str(Path(prefix) / "lib"))]
|
|
72
|
+
else:
|
|
73
|
+
lib_candidates = [
|
|
74
|
+
str(Path(prefix) / "lib"),
|
|
75
|
+
str(Path.home() / ".cache" / "termux-diffusion" / "lib"),
|
|
76
|
+
str(Path.home() / ".cache" / "termux-diffusion" / "staging" / "lib"),
|
|
77
|
+
str(Path.home() / ".local" / "lib"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
merged: List[str] = []
|
|
81
|
+
for d in lib_candidates + existing_parts:
|
|
82
|
+
if d not in merged and Path(d).is_dir() and not any(d.startswith(fp) for fp in forbidden_prefixes):
|
|
83
|
+
merged.append(d)
|
|
84
|
+
|
|
85
|
+
if merged:
|
|
86
|
+
env["LD_LIBRARY_PATH"] = ":".join(merged)
|
|
87
|
+
elif is_vulkan_route and "LD_LIBRARY_PATH" in env:
|
|
88
|
+
env.pop("LD_LIBRARY_PATH", None)
|
|
89
|
+
|
|
90
|
+
# Detect libegl_shim.so for Android Mali/Samsung GOS Vulkan HAL compatibility (Vulkan only)
|
|
91
|
+
if is_vulkan_route:
|
|
92
|
+
shim_candidates = [
|
|
93
|
+
Path(prefix) / "lib" / "libegl_shim.so",
|
|
94
|
+
Path.home() / ".cache" / "termux-diffusion" / "lib" / "libegl_shim.so",
|
|
95
|
+
Path.home() / ".local" / "lib" / "libegl_shim.so",
|
|
96
|
+
Path("/data/data/com.termux/files/usr/lib/libegl_shim.so"),
|
|
97
|
+
]
|
|
98
|
+
cur_preload = env.get("LD_PRELOAD", "")
|
|
99
|
+
for shim in shim_candidates:
|
|
100
|
+
if shim.is_file():
|
|
101
|
+
shim_str = str(shim.resolve())
|
|
102
|
+
if shim_str not in cur_preload:
|
|
103
|
+
env["LD_PRELOAD"] = f"{shim_str}:{cur_preload}" if cur_preload else shim_str
|
|
104
|
+
break
|
|
105
|
+
elif is_opencl_route:
|
|
106
|
+
# Strip Mali Vulkan shim from LD_PRELOAD on OpenCL to prevent Adreno linker faults
|
|
107
|
+
cur_preload = env.get("LD_PRELOAD", "")
|
|
108
|
+
clean_preloads = [p for p in cur_preload.split(":") if p and "libegl_shim.so" not in p]
|
|
109
|
+
if clean_preloads:
|
|
110
|
+
env["LD_PRELOAD"] = ":".join(clean_preloads)
|
|
111
|
+
else:
|
|
112
|
+
env.pop("LD_PRELOAD", None)
|
|
113
|
+
|
|
114
|
+
return env
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# [B방안] Platform SSOT: ameva-runtime.platform 에서 공유 구현을 가져옵니다.
|
|
118
|
+
try:
|
|
119
|
+
from ameva_runtime.vulkan.platform import is_termux as _ameva_is_termux
|
|
120
|
+
_AMEVA_PLATFORM_AVAILABLE = True
|
|
121
|
+
except ImportError:
|
|
122
|
+
_AMEVA_PLATFORM_AVAILABLE = False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_android_termux() -> bool:
|
|
126
|
+
"""Check whether the current runtime environment is native Android Termux.
|
|
127
|
+
|
|
128
|
+
[B방안] ameva-runtime.platform.is_termux() 를 SSOT 로 사용하며,
|
|
129
|
+
미설치 환경에서는 인라인 구현으로 안전하게 폴백합니다.
|
|
130
|
+
"""
|
|
131
|
+
if _AMEVA_PLATFORM_AVAILABLE:
|
|
132
|
+
return _ameva_is_termux()
|
|
133
|
+
if os.environ.get("TERMUX_VERSION") or os.environ.get("TERMUX_APP_PID"):
|
|
134
|
+
return True
|
|
135
|
+
if os.path.exists("/data/data/com.termux"):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_arm64() -> bool:
|
|
141
|
+
"""Check whether the host CPU architecture is 64-bit ARM (aarch64 / arm64)."""
|
|
142
|
+
machine = platform.machine().lower()
|
|
143
|
+
return machine in ("aarch64", "arm64", "armv8l", "armv8b")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_default_cache_dir() -> Path:
|
|
147
|
+
"""Return root cache directory for termux-diffusion."""
|
|
148
|
+
env_cache = os.environ.get("TERMUX_DIFFUSION_CACHE")
|
|
149
|
+
if env_cache:
|
|
150
|
+
return Path(env_cache).resolve()
|
|
151
|
+
return Path.home() / ".cache" / "termux-diffusion"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_galaxy_gallery_dir() -> Path:
|
|
155
|
+
"""Return the designated Samsung Galaxy / Android photo gallery save directory.
|
|
156
|
+
|
|
157
|
+
Default hierarchy:
|
|
158
|
+
1. Custom environment variable (TERMUX_DIFFUSION_OUTPUT_DIR or AMEVA_OUTPUT_DIR)
|
|
159
|
+
2. Primary Android internal storage DCIM album (~/storage/dcim/TermuxDiffusion or /sdcard/DCIM/TermuxDiffusion)
|
|
160
|
+
3. Secondary Android Pictures album (~/storage/pictures/TermuxDiffusion or /sdcard/Pictures/TermuxDiffusion)
|
|
161
|
+
4. Fallback to app-private cache (~/.cache/termux-diffusion/outputs)
|
|
162
|
+
"""
|
|
163
|
+
custom_dir = os.environ.get("TERMUX_DIFFUSION_OUTPUT_DIR") or os.environ.get("AMEVA_OUTPUT_DIR")
|
|
164
|
+
if custom_dir:
|
|
165
|
+
p = Path(custom_dir).expanduser().resolve()
|
|
166
|
+
try:
|
|
167
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
return p
|
|
169
|
+
except Exception as _e:
|
|
170
|
+
logger.debug("[platform] custom output dir failed: %s", _e)
|
|
171
|
+
|
|
172
|
+
# 1. Primary: Samsung Galaxy Internal Storage DCIM/TermuxDiffusion
|
|
173
|
+
termux_dcim = Path.home() / "storage" / "dcim" / "TermuxDiffusion"
|
|
174
|
+
if (Path.home() / "storage" / "dcim").is_dir():
|
|
175
|
+
try:
|
|
176
|
+
termux_dcim.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
return termux_dcim
|
|
178
|
+
except (PermissionError, OSError) as _perm_err:
|
|
179
|
+
logger.debug("[platform] termux_dcim mkdir failed: %s", _perm_err)
|
|
180
|
+
|
|
181
|
+
sdcard_dcim = Path("/sdcard/DCIM/TermuxDiffusion")
|
|
182
|
+
if Path("/sdcard/DCIM").is_dir():
|
|
183
|
+
try:
|
|
184
|
+
sdcard_dcim.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
return sdcard_dcim
|
|
186
|
+
except (PermissionError, OSError) as _perm_err:
|
|
187
|
+
logger.debug("[platform] sdcard_dcim mkdir failed: %s", _perm_err)
|
|
188
|
+
|
|
189
|
+
# 2. Secondary: Pictures/TermuxDiffusion
|
|
190
|
+
termux_storage = Path.home() / "storage" / "pictures" / "TermuxDiffusion"
|
|
191
|
+
if (Path.home() / "storage" / "pictures").is_dir():
|
|
192
|
+
try:
|
|
193
|
+
termux_storage.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
return termux_storage
|
|
195
|
+
except (PermissionError, OSError) as _perm_err:
|
|
196
|
+
logger.debug("[platform] termux_storage mkdir failed: %s", _perm_err)
|
|
197
|
+
|
|
198
|
+
sdcard_pictures = Path("/sdcard/Pictures/TermuxDiffusion")
|
|
199
|
+
if Path("/sdcard/Pictures").is_dir():
|
|
200
|
+
try:
|
|
201
|
+
sdcard_pictures.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
return sdcard_pictures
|
|
203
|
+
except (PermissionError, OSError) as _perm_err:
|
|
204
|
+
logger.debug("[platform] sdcard mkdir failed: %s", _perm_err)
|
|
205
|
+
|
|
206
|
+
fallback = get_default_cache_dir() / "outputs"
|
|
207
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
return fallback
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def export_to_android_gallery(image_path: Path) -> Optional[Path]:
|
|
216
|
+
"""Copy generated image to Samsung Gallery and broadcast media scanner intent.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Optional[Path]: Destination path in gallery if successful, None if storage permission denied.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
gallery_dir = get_galaxy_gallery_dir()
|
|
223
|
+
fallback_dir = get_default_cache_dir() / "outputs"
|
|
224
|
+
|
|
225
|
+
# If gallery dir is fallback (app-private sandbox), notify user if permission is missing
|
|
226
|
+
if gallery_dir == fallback_dir and is_android_termux() and not (Path.home() / "storage").exists():
|
|
227
|
+
logger.warning(
|
|
228
|
+
"Android storage permission not granted. Image saved to app cache: %s. "
|
|
229
|
+
"To export to Samsung Gallery automatically, run 'termux-setup-storage'.",
|
|
230
|
+
image_path
|
|
231
|
+
)
|
|
232
|
+
_broadcast_media_scanner(image_path)
|
|
233
|
+
return image_path
|
|
234
|
+
|
|
235
|
+
dest = gallery_dir / image_path.name
|
|
236
|
+
if dest.resolve() != image_path.resolve():
|
|
237
|
+
shutil.copy2(image_path, dest)
|
|
238
|
+
|
|
239
|
+
_broadcast_media_scanner(dest)
|
|
240
|
+
return dest
|
|
241
|
+
except (PermissionError, OSError) as exc:
|
|
242
|
+
logger.warning(
|
|
243
|
+
"Could not export image to Android Gallery due to permission restrictions (%s). "
|
|
244
|
+
"Image is safely preserved at local path: %s. "
|
|
245
|
+
"Please run 'termux-setup-storage' to grant external gallery access.",
|
|
246
|
+
exc, image_path
|
|
247
|
+
)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _broadcast_media_scanner(image_path: Path) -> None:
|
|
252
|
+
"""Broadcast android.intent.action.MEDIA_SCANNER_SCAN_FILE so Galaxy Gallery indexes the file."""
|
|
253
|
+
if not is_android_termux():
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
am_binary = shutil.which("am") or shutil.which("termux-am")
|
|
257
|
+
if not am_binary:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
file_uri = f"file://{urllib.parse.quote(str(image_path.resolve()))}"
|
|
261
|
+
try:
|
|
262
|
+
subprocess.run(
|
|
263
|
+
[am_binary, "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", file_uri],
|
|
264
|
+
capture_output=True,
|
|
265
|
+
timeout=3.0,
|
|
266
|
+
check=False
|
|
267
|
+
)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
logger.debug("Media scanner broadcast note: %s", exc)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_optimal_thread_count() -> int:
|
|
273
|
+
"""Calculate the optimal number of inference threads for mobile and host CPUs.
|
|
274
|
+
|
|
275
|
+
In big.LITTLE mobile architectures (e.g., 4 Performance + 4 Efficiency cores),
|
|
276
|
+
running on all cores causes severe thermal throttling and cache thrashing.
|
|
277
|
+
We inspect sysfs CPU frequency scaling topologies when available, or dynamically
|
|
278
|
+
derive cluster sizes.
|
|
279
|
+
"""
|
|
280
|
+
# 1. Try parsing Linux/Android sysfs cpufreq topology
|
|
281
|
+
try:
|
|
282
|
+
freq_files = sorted(glob.glob("/sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq"))
|
|
283
|
+
if not freq_files:
|
|
284
|
+
freq_files = sorted(glob.glob("/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_max_freq"))
|
|
285
|
+
|
|
286
|
+
if freq_files:
|
|
287
|
+
freqs: List[int] = []
|
|
288
|
+
for ff in freq_files:
|
|
289
|
+
try:
|
|
290
|
+
with open(ff, "r", encoding="utf-8") as f:
|
|
291
|
+
val = f.read().strip()
|
|
292
|
+
if val.isdigit():
|
|
293
|
+
freqs.append(int(val))
|
|
294
|
+
except (OSError, IOError) as err:
|
|
295
|
+
logger.debug("Sysfs freq read note on '%s': %s", ff, err)
|
|
296
|
+
|
|
297
|
+
if freqs:
|
|
298
|
+
max_f = max(freqs)
|
|
299
|
+
# Count cores operating within 85% of peak frequency (Big / Mid cluster)
|
|
300
|
+
threshold = int(max_f * 0.85)
|
|
301
|
+
perf_cores = sum(1 for f in freqs if f >= threshold)
|
|
302
|
+
if perf_cores > 0:
|
|
303
|
+
return perf_cores
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug("Sysfs CPU frequency probing error: %s", e)
|
|
306
|
+
|
|
307
|
+
# 2. General fallback based on logical CPU core topology
|
|
308
|
+
total_cores = os.cpu_count() or 4
|
|
309
|
+
if is_arm64():
|
|
310
|
+
# Typical ARM mobile: 8 cores = 4 Big + 4 Little (or 1+3+4)
|
|
311
|
+
if total_cores >= 8:
|
|
312
|
+
return 4
|
|
313
|
+
elif total_cores >= 6:
|
|
314
|
+
return 4
|
|
315
|
+
elif total_cores >= 4:
|
|
316
|
+
return 4
|
|
317
|
+
return max(1, total_cores)
|
|
318
|
+
else:
|
|
319
|
+
# Desktop x86_64: Use physical cores heuristic (half of hyperthreaded cores or up to 8)
|
|
320
|
+
return max(1, min(8, total_cores // 2 if total_cores > 4 else total_cores))
|
|
321
|
+
|
|
322
|
+
# Standard Unified Interface Alias
|
|
323
|
+
get_optimal_threads = get_optimal_thread_count
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get_windows_memory_info() -> Optional[Dict[str, int]]:
|
|
327
|
+
"""Retrieve accurate Windows physical RAM and commit limit via GlobalMemoryStatusEx."""
|
|
328
|
+
try:
|
|
329
|
+
class MEMORYSTATUSEX(ctypes.Structure):
|
|
330
|
+
_fields_ = [
|
|
331
|
+
("dwLength", ctypes.c_ulong),
|
|
332
|
+
("dwMemoryLoad", ctypes.c_ulong),
|
|
333
|
+
("ullTotalPhys", ctypes.c_ulonglong),
|
|
334
|
+
("ullAvailPhys", ctypes.c_ulonglong),
|
|
335
|
+
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
336
|
+
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
337
|
+
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
338
|
+
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
339
|
+
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
stat = MEMORYSTATUSEX()
|
|
343
|
+
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
|
344
|
+
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
|
345
|
+
mem_total_mb = int(stat.ullTotalPhys // (1024 * 1024))
|
|
346
|
+
mem_available_mb = int(stat.ullAvailPhys // (1024 * 1024))
|
|
347
|
+
pagefile_total_mb = int(stat.ullTotalPageFile // (1024 * 1024))
|
|
348
|
+
pagefile_avail_mb = int(stat.ullAvailPageFile // (1024 * 1024))
|
|
349
|
+
|
|
350
|
+
swap_total_mb = max(0, pagefile_total_mb - mem_total_mb)
|
|
351
|
+
swap_free_mb = max(0, pagefile_avail_mb - mem_available_mb)
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
"mem_total_mb": mem_total_mb,
|
|
355
|
+
"mem_available_mb": mem_available_mb,
|
|
356
|
+
"swap_total_mb": swap_total_mb,
|
|
357
|
+
"swap_free_mb": swap_free_mb,
|
|
358
|
+
"effective_total_mb": mem_total_mb + swap_total_mb,
|
|
359
|
+
"effective_available_mb": mem_available_mb + swap_free_mb,
|
|
360
|
+
}
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.debug("Windows GlobalMemoryStatusEx query failed: %s", e)
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_memory_info() -> Dict[str, int]:
|
|
367
|
+
"""Inspect system memory metrics (RAM and swap/zRAM) in MB from genuine OS interfaces."""
|
|
368
|
+
metrics = {
|
|
369
|
+
"mem_total_mb": 0,
|
|
370
|
+
"mem_available_mb": 0,
|
|
371
|
+
"swap_total_mb": 0,
|
|
372
|
+
"swap_free_mb": 0,
|
|
373
|
+
"effective_total_mb": 0,
|
|
374
|
+
"effective_available_mb": 0,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# 1. Linux & Android /proc/meminfo (Standard on Termux)
|
|
378
|
+
if os.path.exists("/proc/meminfo"):
|
|
379
|
+
try:
|
|
380
|
+
with open("/proc/meminfo", "r", encoding="utf-8") as f:
|
|
381
|
+
for line in f:
|
|
382
|
+
parts = line.split(":")
|
|
383
|
+
if len(parts) == 2:
|
|
384
|
+
key = parts[0].strip()
|
|
385
|
+
val_str = parts[1].strip().split()[0]
|
|
386
|
+
if val_str.isdigit():
|
|
387
|
+
val_kb = int(val_str)
|
|
388
|
+
val_mb = val_kb // 1024
|
|
389
|
+
if key == "MemTotal":
|
|
390
|
+
metrics["mem_total_mb"] = val_mb
|
|
391
|
+
elif key == "MemAvailable":
|
|
392
|
+
metrics["mem_available_mb"] = val_mb
|
|
393
|
+
elif key == "SwapTotal":
|
|
394
|
+
metrics["swap_total_mb"] = val_mb
|
|
395
|
+
elif key == "SwapFree":
|
|
396
|
+
metrics["swap_free_mb"] = val_mb
|
|
397
|
+
metrics["effective_total_mb"] = metrics["mem_total_mb"] + metrics["swap_total_mb"]
|
|
398
|
+
metrics["effective_available_mb"] = metrics["mem_available_mb"] + metrics["swap_free_mb"]
|
|
399
|
+
return metrics
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.debug("Failed reading /proc/meminfo: %s", e)
|
|
402
|
+
|
|
403
|
+
# 2. Windows Native Win32 API
|
|
404
|
+
if sys.platform == "win32":
|
|
405
|
+
win_mem = _get_windows_memory_info()
|
|
406
|
+
if win_mem:
|
|
407
|
+
return win_mem
|
|
408
|
+
|
|
409
|
+
# 3. Optional psutil fallback if installed
|
|
410
|
+
try:
|
|
411
|
+
import psutil # type: ignore
|
|
412
|
+
vmem = psutil.virtual_memory()
|
|
413
|
+
smem = psutil.swap_memory()
|
|
414
|
+
metrics["mem_total_mb"] = int(vmem.total // (1024 * 1024))
|
|
415
|
+
metrics["mem_available_mb"] = int(vmem.available // (1024 * 1024))
|
|
416
|
+
metrics["swap_total_mb"] = int(smem.total // (1024 * 1024))
|
|
417
|
+
metrics["swap_free_mb"] = int(smem.free // (1024 * 1024))
|
|
418
|
+
metrics["effective_total_mb"] = metrics["mem_total_mb"] + metrics["swap_total_mb"]
|
|
419
|
+
metrics["effective_available_mb"] = metrics["mem_available_mb"] + metrics["swap_free_mb"]
|
|
420
|
+
return metrics
|
|
421
|
+
except ImportError as _psutil_err:
|
|
422
|
+
logger.debug("Optional dependency psutil not available for memory info: %s", _psutil_err)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
return metrics
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def check_memory_safety(required_mb: int = 1500) -> Tuple[bool, str]:
|
|
429
|
+
"""Verify if available memory is sufficient for model loading without triggering Android LMK."""
|
|
430
|
+
mem = get_memory_info()
|
|
431
|
+
avail = mem["effective_available_mb"]
|
|
432
|
+
|
|
433
|
+
# If memory info could not be determined at all (0 MB), pass with a note
|
|
434
|
+
if mem["effective_total_mb"] == 0:
|
|
435
|
+
return True, "Memory detection unavailable on host OS; bypassing pre-flight guard."
|
|
436
|
+
|
|
437
|
+
if avail < required_mb:
|
|
438
|
+
return False, (
|
|
439
|
+
f"Available memory ({avail} MB) is below the recommended threshold ({required_mb} MB). "
|
|
440
|
+
f"Please enable Samsung RAM Plus (Settings -> Battery and device care -> Memory -> RAM Plus) "
|
|
441
|
+
f"or close background apps to avoid Low Memory Killer (LMK) termination."
|
|
442
|
+
)
|
|
443
|
+
return True, f"Memory check passed: {avail} MB available (RAM + zRAM)."
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class TermuxWakeLock:
|
|
447
|
+
"""Context manager and controller to hold Android CPU WakeLock during long-running tasks."""
|
|
448
|
+
|
|
449
|
+
def __init__(self, enabled: bool = True, fail_silently: bool = True):
|
|
450
|
+
self.enabled = enabled
|
|
451
|
+
self.fail_silently = fail_silently
|
|
452
|
+
self._acquired = False
|
|
453
|
+
|
|
454
|
+
def acquire(self) -> bool:
|
|
455
|
+
"""Acquire CPU WakeLock via Termux API or direct Android Service Intent."""
|
|
456
|
+
if not self.enabled or not is_android_termux():
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
# Boost process niceness to highest priority
|
|
460
|
+
try:
|
|
461
|
+
os.nice(-20)
|
|
462
|
+
logger.info("[TermuxWakeLock] Process niceness boosted to highest priority (-20)")
|
|
463
|
+
except Exception:
|
|
464
|
+
try:
|
|
465
|
+
os.nice(-10)
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
wake_lock_bin = shutil.which("termux-wake-lock") or "/data/data/com.termux/files/usr/bin/termux-wake-lock"
|
|
470
|
+
if os.path.exists(wake_lock_bin):
|
|
471
|
+
try:
|
|
472
|
+
res = subprocess.run([wake_lock_bin], capture_output=True, timeout=3.0, check=False)
|
|
473
|
+
self._acquired = (res.returncode == 0)
|
|
474
|
+
if self._acquired:
|
|
475
|
+
logger.info("[TermuxWakeLock] Acquired Termux CPU WakeLock via binary.")
|
|
476
|
+
return self._acquired
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.debug("Exception while acquiring WakeLock via binary: %s", e)
|
|
479
|
+
|
|
480
|
+
# Robust fallback: invoke TermuxService wake_lock intent directly
|
|
481
|
+
try:
|
|
482
|
+
res = subprocess.run(
|
|
483
|
+
["am", "startservice", "--user", "0", "-a", "com.termux.service_wake_lock", "com.termux/.app.TermuxService"],
|
|
484
|
+
capture_output=True, timeout=3.0, check=False
|
|
485
|
+
)
|
|
486
|
+
self._acquired = (res.returncode == 0)
|
|
487
|
+
if self._acquired:
|
|
488
|
+
logger.info("[TermuxWakeLock] Acquired Termux CPU WakeLock via TermuxService Intent.")
|
|
489
|
+
return self._acquired
|
|
490
|
+
except Exception as e:
|
|
491
|
+
if not self.fail_silently:
|
|
492
|
+
raise
|
|
493
|
+
logger.debug("Exception while acquiring WakeLock via Intent: %s", e)
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
def release(self) -> bool:
|
|
497
|
+
"""Release CPU WakeLock via Termux API or direct Android Service Intent."""
|
|
498
|
+
if self._acquired:
|
|
499
|
+
wake_unlock_bin = shutil.which("termux-wake-unlock") or "/data/data/com.termux/files/usr/bin/termux-wake-unlock"
|
|
500
|
+
if os.path.exists(wake_unlock_bin):
|
|
501
|
+
try:
|
|
502
|
+
res = subprocess.run([wake_unlock_bin], capture_output=True, timeout=3.0, check=False)
|
|
503
|
+
self._acquired = False
|
|
504
|
+
if res.returncode == 0:
|
|
505
|
+
logger.info("[TermuxWakeLock] Released Termux CPU WakeLock via binary.")
|
|
506
|
+
return (res.returncode == 0)
|
|
507
|
+
except Exception as e:
|
|
508
|
+
logger.debug("Exception while releasing WakeLock via binary: %s", e)
|
|
509
|
+
|
|
510
|
+
# Robust fallback: invoke TermuxService wake_unlock intent directly
|
|
511
|
+
try:
|
|
512
|
+
res = subprocess.run(
|
|
513
|
+
["am", "startservice", "--user", "0", "-a", "com.termux.service_wake_unlock", "com.termux/.app.TermuxService"],
|
|
514
|
+
capture_output=True, timeout=3.0, check=False
|
|
515
|
+
)
|
|
516
|
+
self._acquired = False
|
|
517
|
+
if res.returncode == 0:
|
|
518
|
+
logger.info("[TermuxWakeLock] Released Termux CPU WakeLock via TermuxService Intent.")
|
|
519
|
+
return (res.returncode == 0)
|
|
520
|
+
except Exception as e:
|
|
521
|
+
if not self.fail_silently:
|
|
522
|
+
raise
|
|
523
|
+
logger.debug("Exception while releasing WakeLock via Intent: %s", e)
|
|
524
|
+
self._acquired = False
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
def __enter__(self) -> "TermuxWakeLock":
|
|
528
|
+
self.acquire()
|
|
529
|
+
return self
|
|
530
|
+
|
|
531
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
532
|
+
self.release()
|
|
533
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""Clean-Room Purger module for terminating non-essential memory hogs.
|
|
4
|
+
|
|
5
|
+
Strictly preserves critical system daemons, user shells, SSH sessions, and multiplexers
|
|
6
|
+
while reclaiming physical RAM from zombie or idle inference processes before model loading.
|
|
7
|
+
Operates exclusively as an opt-in CLI flag (--kill [threshold_mb]).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Set, Tuple
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("termux_diffusion.purger")
|
|
19
|
+
|
|
20
|
+
# 절대 불사 화이트리스트: 사용자 원격 세션 및 OS 필수 프로세스 영구 보호
|
|
21
|
+
IMMORTAL_WHITELIST_COMM: Set[str] = {
|
|
22
|
+
"sshd",
|
|
23
|
+
"bash",
|
|
24
|
+
"sh",
|
|
25
|
+
"zsh",
|
|
26
|
+
"fish",
|
|
27
|
+
"tmux",
|
|
28
|
+
"tmux: server",
|
|
29
|
+
"screen",
|
|
30
|
+
"tailscaled",
|
|
31
|
+
"tailscale",
|
|
32
|
+
"init",
|
|
33
|
+
"zygote",
|
|
34
|
+
"zygote64",
|
|
35
|
+
"system_server",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ProcessKillRecord:
|
|
41
|
+
pid: int
|
|
42
|
+
name: str
|
|
43
|
+
rss_mb: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PurgeSummary:
|
|
48
|
+
threshold_mb: float
|
|
49
|
+
scanned_count: int
|
|
50
|
+
killed_processes: List[ProcessKillRecord] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_reclaimed_mb(self) -> float:
|
|
54
|
+
return round(sum(p.rss_mb for p in self.killed_processes), 1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_process_rss_mb(pid: int) -> float:
|
|
58
|
+
"""Read RSS memory deterministically from /proc/[pid]/statm in MB."""
|
|
59
|
+
statm_path = Path(f"/proc/{pid}/statm")
|
|
60
|
+
if not statm_path.exists():
|
|
61
|
+
return 0.0
|
|
62
|
+
try:
|
|
63
|
+
with open(statm_path, "r", encoding="utf-8") as f:
|
|
64
|
+
parts = f.readline().split()
|
|
65
|
+
if len(parts) >= 2:
|
|
66
|
+
# 2nd field is resident pages. 1 page = 4096 bytes on ARM64 Linux.
|
|
67
|
+
resident_pages = int(parts[1])
|
|
68
|
+
return round((resident_pages * 4096) / (1024 * 1024), 2)
|
|
69
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
70
|
+
return 0.0
|
|
71
|
+
return 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_process_comm(pid: int) -> str:
|
|
75
|
+
"""Read executable name deterministically from /proc/[pid]/comm."""
|
|
76
|
+
comm_path = Path(f"/proc/{pid}/comm")
|
|
77
|
+
if not comm_path.exists():
|
|
78
|
+
return ""
|
|
79
|
+
try:
|
|
80
|
+
with open(comm_path, "r", encoding="utf-8") as f:
|
|
81
|
+
return f.readline().strip()
|
|
82
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def purge_idle_processes(threshold_mb: float = 50.0) -> PurgeSummary:
|
|
87
|
+
"""Scan and terminate processes exceeding threshold_mb, preserving whitelist."""
|
|
88
|
+
self_pid = os.getpid()
|
|
89
|
+
self_ppid = os.getppid()
|
|
90
|
+
proc_dir = Path("/proc")
|
|
91
|
+
summary = PurgeSummary(threshold_mb=threshold_mb, scanned_count=0)
|
|
92
|
+
|
|
93
|
+
if not proc_dir.exists():
|
|
94
|
+
logger.warning("/proc filesystem not accessible. Clean-room purge aborted.")
|
|
95
|
+
return summary
|
|
96
|
+
|
|
97
|
+
targets_to_terminate: List[Tuple[int, str, float]] = []
|
|
98
|
+
|
|
99
|
+
for entry in proc_dir.iterdir():
|
|
100
|
+
if not entry.is_dir() or not entry.name.isdigit():
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
pid = int(entry.name)
|
|
104
|
+
summary.scanned_count += 1
|
|
105
|
+
|
|
106
|
+
# 1. 자기 자신 및 직속 부모 프로세스 영구 보호
|
|
107
|
+
if pid == self_pid or pid == self_ppid:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
comm = get_process_comm(pid)
|
|
111
|
+
|
|
112
|
+
# 2. 불사 화이트리스트 보호
|
|
113
|
+
if comm in IMMORTAL_WHITELIST_COMM:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# 3. RSS 측정 및 임계치 검사
|
|
117
|
+
rss_mb = get_process_rss_mb(pid)
|
|
118
|
+
if rss_mb >= threshold_mb:
|
|
119
|
+
targets_to_terminate.append((pid, comm, rss_mb))
|
|
120
|
+
|
|
121
|
+
# 프로세스 종료 실행: SIGTERM 전송 후 0.5초 대기, 생존 시 SIGKILL 단행
|
|
122
|
+
for pid, comm, rss_mb in targets_to_terminate:
|
|
123
|
+
try:
|
|
124
|
+
logger.info("Purging process %d (%s) consuming %.1f MB", pid, comm, rss_mb)
|
|
125
|
+
os.kill(pid, signal.SIGTERM)
|
|
126
|
+
except (ProcessLookupError, PermissionError):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if targets_to_terminate:
|
|
130
|
+
time.sleep(0.5)
|
|
131
|
+
|
|
132
|
+
for pid, comm, rss_mb in targets_to_terminate:
|
|
133
|
+
try:
|
|
134
|
+
# 여전히 생존해 있는지 확인 (0 시그널 프로빙)
|
|
135
|
+
os.kill(pid, 0)
|
|
136
|
+
logger.warning("Process %d (%s) still alive, escalating to SIGKILL", pid, comm)
|
|
137
|
+
os.kill(pid, signal.SIGKILL)
|
|
138
|
+
summary.killed_processes.append(ProcessKillRecord(pid=pid, name=comm, rss_mb=rss_mb))
|
|
139
|
+
except ProcessLookupError:
|
|
140
|
+
# 정상 종료됨
|
|
141
|
+
summary.killed_processes.append(ProcessKillRecord(pid=pid, name=comm, rss_mb=rss_mb))
|
|
142
|
+
except PermissionError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
return summary
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561.
|