feat(llvm): Phase 97 - Call/PHI/Plugin強化 + リファクタリング準備

## 概要
LLVM backend のCall処理、PHI wiring、Plugin loader を強化。
次のリファクタリング(箱化モジュール化)のための準備も含む。

## 変更内容

### LLVM Call処理強化
- `mir_call/__init__.py`: Call ルーティングロジック改善
- `mir_call/global_call.py`: print処理の marshal強化
- `mir_call/method_call.py`: メソッド呼び出し処理改善
- `boxcall.py`: BoxCall処理改善

### PHI処理強化
- `phi_manager.py`: PHI管理改善
- `phi_wiring/wiring.py`: PHI配線ロジック強化(+17行)
- `phi_wiring/tagging.py`: Type tagging改善
- `resolver.py`: Value解決ロジック強化(+34行)

### Copy伝播
- `copy.py`: Copy命令のType tag伝播追加(+10行)

### Plugin loader強化
- `library.rs`: エラー出力改善、[plugin/missing]ログ追加(+34行)
- fail-fast強化

### テスト
- `phase97_json_loader_escape_llvm_exe.sh`: Phase 97 E2Eテスト追加
- `phase97_next_non_ws_llvm_exe.sh`: Phase 97 E2Eテスト追加

### その他
- `nyash_kernel/lib.rs`: Kernel側の改善(+23行)

## 統計
- 14ファイル変更
- +256行 / -53行 = +203 net

## 次のリファクタリング準備
以下の箇所がリファクタリング対象として識別済み:
1. Call ルーティング箱の明文化
2. print の marshal 箱
3. TypeFacts/Tagging 箱の一本化
4. PHI Snapshot 契約のSSOT
5. Plugin loader のエラー出力統合

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
nyash-codex
2025-12-17 03:51:03 +09:00
parent 1a0d9ce8a0
commit 65763c1ed6
14 changed files with 256 additions and 53 deletions

View File

@ -779,10 +779,29 @@ pub extern "C" fn main() -> i32 {
}
// SAFETY: if not linked, calling will be an unresolved symbol at link-time; we rely on link step to include ny_main.
let v = ny_main();
let exit_code: i64 = {
use nyash_rust::{
box_trait::{IntegerBox, NyashBox},
runtime::host_handles as handles,
};
if v > 0 {
if let Some(obj) = handles::get(v as u64) {
if let Some(ib) = obj.as_any().downcast_ref::<IntegerBox>() {
ib.value as i64
} else {
0
}
} else {
v
}
} else {
v
}
};
// Print standardized result line for golden comparisons (can be silenced for tests)
let silent = std::env::var("NYASH_NYRT_SILENT_RESULT").ok().as_deref() == Some("1");
if !silent {
println!("Result: {}", v);
println!("Result: {}", exit_code);
}
// Optional GC metrics after program completes
let want_json = std::env::var("NYASH_GC_METRICS_JSON").ok().as_deref() == Some("1");
@ -857,7 +876,7 @@ pub extern "C" fn main() -> i32 {
// ✂️ REMOVED: Legacy JIT leak diagnostics - part of 42% deletable functions
// Leak diagnostics functionality removed with JIT archival
// handles::type_tally() no longer available in Plugin-First architecture
v as i32
exit_code as i32
}
}

View File

@ -181,23 +181,38 @@ def lower_function(builder, func_data: Dict[str, Any]):
if entry_bid is None and blocks:
entry_bid = blocks[0].get("id", 0)
# Compute approx preds-first order
visited = set()
order: List[int] = []
# Compute reverse-postorder over successors (SSOT):
# - Ensures a stable, mostly-forward lowering order (preds before succs) even with loops.
# - Avoids lowering a block before its dominating setup/copies when possible.
visited: set[int] = set()
post: List[int] = []
try:
from cfg.utils import build_preds_succs as _build_preds_succs
_preds2, succs2 = _build_preds_succs(block_by_id)
except Exception:
succs2 = {}
def visit(bid: int):
def dfs(bid: int):
if bid in visited:
return
visited.add(bid)
for p in builder.preds.get(bid, []):
visit(p)
order.append(bid)
try:
succ_list = list(succs2.get(bid, []) or [])
succ_list = [int(x) for x in succ_list]
succ_list.sort()
except Exception:
succ_list = []
for s in succ_list:
dfs(s)
post.append(bid)
if entry_bid is not None:
visit(entry_bid)
for bid in block_by_id.keys():
dfs(int(entry_bid))
# Include unreachable blocks deterministically
for bid in sorted(block_by_id.keys()):
if bid not in visited:
visit(bid)
dfs(int(bid))
order: List[int] = list(reversed(post))
# Prepass: collect PHI metadata and placeholders
_setup_phi_placeholders(builder, blocks)

View File

@ -9,6 +9,7 @@ from instructions.safepoint import insert_automatic_safepoint
from naming_helper import encode_static_method
from console_bridge import emit_console_call # Phase 133: Console 箱化モジュール
from instructions.stringbox import emit_stringbox_call # Phase 134-B: StringBox 箱化モジュール
from utils.values import resolve_i64_strict
def _declare(module: ir.Module, name: str, ret, args):
for f in module.functions:
@ -102,9 +103,10 @@ def lower_boxcall(
except Exception:
pass
def _res_i64(vid: int):
if r is not None and p is not None and bev is not None and bbm is not None:
# SSOT: Use the common resolver policy (prefer local SSA, then global vmap, then PHI-localize).
if r is not None and p is not None and bev is not None:
try:
return r.resolve_i64(vid, builder.block, p, bev, vmap, bbm)
return resolve_i64_strict(r, vid, builder.block, p, bev, vmap, bbm)
except Exception:
return None
return vmap.get(vid)

View File

@ -48,3 +48,13 @@ def lower_copy(
if os.environ.get('NYASH_LLVM_VMAP_TRACE') == '1':
print(f"[vmap/id] copy dst={dst} src={src} vmap id={id(vmap)} before_write", file=sys.stderr)
safe_vmap_write(vmap, dst, val, "copy", resolver=resolver)
# TypeFacts propagation (SSOT): preserve "stringish" tagging across Copy.
# Many MIR patterns materialize a temp then Copy into a local; without this,
# string equality/concat may incorrectly fall back to integer/handle ops.
try:
if resolver is not None and hasattr(resolver, "is_stringish") and resolver.is_stringish(src):
if hasattr(resolver, "mark_string"):
resolver.mark_string(dst)
except Exception:
pass

View File

@ -117,10 +117,20 @@ def lower_mir_call(owner, builder: ir.IRBuilder, mir_call: Dict[str, Any], dst_v
method = callee.get("name")
box_name = callee.get("box_name")
receiver = callee.get("receiver")
# v1 JSON: receiver is implicit as first arg if missing
if receiver is None and args:
certainty = callee.get("certainty")
# SSOT: Method calls split into two routes:
# - Static method (receiver=null, certainty=Known): lower as direct function call `Box.method/$arity`
# - Instance method (receiver omitted in v1 JSON): receiver is implicit as first arg
if receiver is None:
if certainty == "Known" and box_name and method:
func_name = f"{box_name}.{method}/{len(args)}"
lower_global_call(builder, owner.module, func_name, args, dst_vid, vmap, resolver, owner)
return
if args:
receiver = args[0]
args = args[1:] # Remove receiver from args
lower_method_call(builder, owner.module, box_name, method, receiver, args, dst_vid, vmap, resolver, owner)
elif callee_type == "Constructor":

View File

@ -82,19 +82,42 @@ def lower_global_call(builder, module, func_name, args, dst_vid, vmap, resolver,
if i < len(func.args):
expected_type = func.args[i].type
if expected_type.is_pointer and isinstance(arg_val.type, ir.IntType):
# Phase 131-15-P1: Convert i64 handle to i8* for C ABI functions
# Use nyash.string.to_i8p_h for proper handle-to-pointer conversion
# Convert i64 to i8* for C ABI-style functions (print/panic/error).
#
# IMPORTANT: `print()` in AOT must not interpret unboxed integers as handles
# (handle collision prints wrong strings). Use type facts to decide:
# - stringish -> treat as handle and bridge via nyash.string.to_i8p_h
# - otherwise -> box integer (nyash.box.from_i64) then bridge
if arg_val.type.width == 64:
i8p = ir.IntType(8).as_pointer()
to_i8p = None
for f in module.functions:
if f.name == "nyash.string.to_i8p_h":
to_i8p = f
break
if not to_i8p:
i8p = ir.IntType(8).as_pointer()
to_i8p_type = ir.FunctionType(i8p, [ir.IntType(64)])
to_i8p = ir.Function(module, to_i8p_type, name="nyash.string.to_i8p_h")
arg_val = builder.call(to_i8p, [arg_val], name=f"global_h2p_{i}")
is_stringish = False
try:
if resolver is not None and hasattr(resolver, "is_stringish") and resolver.is_stringish(int(arg_id)):
is_stringish = True
except Exception:
is_stringish = False
v_to_print = arg_val
if func_name == "print" and not is_stringish:
boxer = None
for f in module.functions:
if f.name == "nyash.box.from_i64":
boxer = f
break
if boxer is None:
boxer = ir.Function(module, ir.FunctionType(ir.IntType(64), [ir.IntType(64)]), name="nyash.box.from_i64")
v_to_print = builder.call(boxer, [arg_val], name=f"global_box_i64_{i}")
arg_val = builder.call(to_i8p, [v_to_print], name=f"global_h2p_{i}")
else:
arg_val = builder.inttoptr(arg_val, expected_type, name=f"global_i2p_{i}")
elif isinstance(expected_type, ir.IntType) and arg_val.type.is_pointer:

View File

@ -64,13 +64,20 @@ def lower_method_call(builder, module, box_name, method, receiver, args, dst_vid
# Resolve receiver and arguments
def _resolve_arg(vid: int):
# Prefer same-block SSA (including function params) from vmap.
try:
v = vmap.get(vid)
if v is not None:
return v
except Exception:
pass
if resolver and hasattr(resolver, 'resolve_i64'):
try:
return resolver.resolve_i64(vid, builder.block, owner.preds,
owner.block_end_values, vmap, owner.bb_map)
except:
pass
return vmap.get(vid)
return None
recv_val = _resolve_arg(receiver)
if recv_val is None:
@ -154,8 +161,16 @@ def lower_method_call(builder, module, box_name, method, receiver, args, dst_vid
else:
# Generic plugin method invocation
method_str = method.encode('utf-8') + b'\0'
method_global = ir.GlobalVariable(module, ir.ArrayType(i8, len(method_str)), name=f"unified_method_{method}")
method_global.initializer = ir.Constant(ir.ArrayType(i8, len(method_str)), bytearray(method_str))
method_gname = f"unified_method_{method}"
if method_gname in module.globals:
method_global = module.get_global(method_gname)
else:
method_global = ir.GlobalVariable(
module, ir.ArrayType(i8, len(method_str)), name=method_gname
)
method_global.initializer = ir.Constant(
ir.ArrayType(i8, len(method_str)), bytearray(method_str)
)
method_global.global_constant = True
mptr = builder.gep(method_global, [ir.Constant(ir.IntType(32), 0), ir.Constant(ir.IntType(32), 0)])

View File

@ -29,10 +29,7 @@ class PhiManager:
"""
result = {}
for vid, val in vmap.items():
if hasattr(val, 'add_incoming'): # Is PHI?
if self.is_phi_owned(target_bid, vid):
result[vid] = val
else:
# PHIs are valid SSA values across dominated blocks; keep them in the per-block view.
result[vid] = val
# Phase 132-P0: Add PHIs from predeclared that aren't in vmap yet

View File

@ -103,9 +103,14 @@ def setup_phi_placeholders(builder, blocks: List[Dict[str, Any]]):
dst_type0 = inst.get("dst_type")
mark_str = (
isinstance(dst_type0, dict)
and dst_type0.get("kind") == "handle"
and (
dst_type0.get("kind") == "string"
or (
dst_type0.get("kind") == "handle"
and dst_type0.get("box_type") == "StringBox"
)
)
)
if not mark_str:
# JSON v0 incoming pairs are (value, block)
for (v_src_i, _b_decl_i) in incoming0:

View File

@ -267,5 +267,22 @@ def finalize_phis(builder, context):
total_dsts += 1
wired = wire_incomings(builder, int(block_id), int(dst_vid), incoming, context=context)
total_wired += int(wired or 0)
# TypeFacts propagation (SSOT): if any incoming source is stringish, mark dst stringish.
# This prevents string PHIs from falling back to integer/handle paths (e.g., out += ch).
try:
if (
hasattr(builder, "resolver")
and hasattr(builder.resolver, "is_stringish")
and hasattr(builder.resolver, "mark_string")
):
for (_decl_b, v_src) in (incoming or []):
try:
if builder.resolver.is_stringish(int(v_src)):
builder.resolver.mark_string(int(dst_vid))
break
except Exception:
continue
except Exception:
pass
trace({"phi": "finalize", "block": int(block_id), "dst": int(dst_vid), "wired": int(wired or 0)})
trace({"phi": "finalize_summary", "blocks": int(total_blocks), "dsts": int(total_dsts), "incoming_wired": int(total_wired)})

View File

@ -97,7 +97,24 @@ class Resolver:
def mark_string(self, value_id: int) -> None:
try:
self.string_ids.add(int(value_id))
vid = int(value_id)
self.string_ids.add(vid)
# TypeFacts SSOT: keep value_types in sync so downstream decisions
# (e.g., '+' concat tag checks) can treat this as a StringBox handle.
try:
if not hasattr(self, 'value_types') or self.value_types is None:
self.value_types = {}
cur = self.value_types.get(vid) if isinstance(self.value_types, dict) else None
is_already_string = False
if isinstance(cur, dict):
if cur.get('kind') == 'string':
is_already_string = True
if cur.get('kind') == 'handle' and cur.get('box_type') == 'StringBox':
is_already_string = True
if not is_already_string and isinstance(self.value_types, dict):
self.value_types[vid] = {'kind': 'handle', 'box_type': 'StringBox'}
except Exception:
pass
except Exception:
pass
@ -478,16 +495,11 @@ class Resolver:
except Exception:
pass
if is_phi_val:
# Accept PHI only when it belongs to the same block (dominates end-of-block).
try:
belongs_here = (getattr(getattr(val, 'basic_block', None), 'name', b'').decode() if hasattr(getattr(val, 'basic_block', None), 'name') else str(getattr(getattr(val, 'basic_block', None), 'name', ''))) == f"bb{block_id}"
except Exception:
belongs_here = False
if belongs_here:
# PHIs are valid SSA values to carry through snapshots: a PHI defined at a
# dominating block head can be used at the end of successor blocks.
coerced = self._coerce_in_block_to_i64(val, block_id, bb_map)
self._end_i64_cache[key] = coerced
return coerced
# Otherwise, PHI from wrong block -> treat as miss
else:
coerced = self._coerce_in_block_to_i64(val, block_id, bb_map)
self._end_i64_cache[key] = coerced

View File

@ -2,6 +2,7 @@ use super::specs;
use super::util::dbg_on;
use super::PluginLoaderV2;
use crate::bid::{BidError, BidResult};
use crate::config::env::env_bool;
use crate::config::nyash_toml_v2::LibraryDefinition;
use crate::runtime::get_global_ring0;
use libloading::{Library, Symbol};
@ -11,6 +12,10 @@ use std::sync::Arc;
pub(super) fn load_all_plugins(loader: &PluginLoaderV2) -> BidResult<()> {
let config = loader.config.as_ref().ok_or(BidError::PluginError)?;
// Strict mode policy (SSOT): reuse JoinIR strict flag to avoid env-var sprawl.
// - strict=0: Phase 134 best-effort load (continue on failure)
// - strict=1: Fail-Fast on first plugin/library load error
let strict = env_bool("HAKO_JOINIR_STRICT");
// Phase 134 P0: Best-effort loading
// Failures don't stop the entire load process
@ -24,8 +29,11 @@ pub(super) fn load_all_plugins(loader: &PluginLoaderV2) -> BidResult<()> {
for (lib_name, lib_def) in lib_items {
match load_plugin(loader, lib_name, lib_def) {
Ok(()) => loaded_count += 1,
Err(_) => {
Err(e) => {
failed_count += 1;
if strict {
return Err(e);
}
// Log already printed by load_plugin, continue
}
}
@ -38,8 +46,11 @@ pub(super) fn load_all_plugins(loader: &PluginLoaderV2) -> BidResult<()> {
for (plugin_name, root) in plugin_items {
match load_plugin_from_root(loader, plugin_name, root) {
Ok(()) => loaded_count += 1,
Err(_) => {
Err(e) => {
failed_count += 1;
if strict {
return Err(e);
}
// Log already printed by load_plugin_from_root, continue
}
}
@ -82,7 +93,24 @@ pub(super) fn load_plugin(
}
}
}
let lib_path = lib_path.unwrap_or_else(|| base.to_path_buf());
let lib_path = match lib_path {
Some(path) => path,
None => {
let mut attempted = candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>();
attempted.sort();
attempted.dedup();
get_global_ring0().log.error(&format!(
"[plugin/missing] {}: no existing file for configured path='{}' (attempted={})",
lib_name,
base.display(),
attempted.join(", ")
));
return Err(BidError::PluginError);
}
};
if dbg_on() {
get_global_ring0().log.debug(&format!(
"[PluginLoaderV2] load_plugin: lib='{}' path='{}'",

View File

@ -20,8 +20,33 @@ fi
FILEBOX_SO="$NYASH_ROOT/plugins/nyash-filebox-plugin/libnyash_filebox_plugin.so"
MAPBOX_SO="$NYASH_ROOT/plugins/nyash-map-plugin/libnyash_map_plugin.so"
# Phase 98 P0: Ensure required dynamic plugin artifacts exist and are loadable.
echo "[INFO] Ensuring plugin artifacts (FileBox/MapBox)"
if ! bash "$NYASH_ROOT/tools/plugins/build-all.sh" nyash-filebox-plugin nyash-map-plugin >/dev/null; then
echo "[FAIL] tools/plugins/build-all.sh failed for FileBox/MapBox"
exit 1
fi
if [ ! -f "$FILEBOX_SO" ] || [ ! -f "$MAPBOX_SO" ]; then
test_skip "Required plugins not found (FileBox/MapBox)"; exit 0
echo "[FAIL] Required plugin artifacts still missing after build-all (FileBox/MapBox)"
echo "[INFO] FileBox: $FILEBOX_SO"
echo "[INFO] MapBox: $MAPBOX_SO"
exit 1
fi
if ! python3 - <<PY 2>/dev/null; then
import ctypes
ctypes.CDLL(r"$FILEBOX_SO")
ctypes.CDLL(r"$MAPBOX_SO")
print("OK")
PY
echo "[FAIL] Plugin dlopen check failed for FileBox/MapBox"
python3 - <<PY 2>&1 | tail -n 80
import ctypes
ctypes.CDLL(r"$FILEBOX_SO")
ctypes.CDLL(r"$MAPBOX_SO")
print("OK")
PY
exit 1
fi
mkdir -p "$NYASH_ROOT/tmp"
@ -32,7 +57,7 @@ OUTPUT_EXE="$NYASH_ROOT/tmp/phase97_json_loader_escape_llvm_exe"
echo "[INFO] Building: $INPUT_HAKO$OUTPUT_EXE"
BUILD_LOG="/tmp/phase97_json_loader_escape_build.log"
if ! env NYASH_DISABLE_PLUGINS=1 "$NYASH_ROOT/tools/build_llvm.sh" "$INPUT_HAKO" -o "$OUTPUT_EXE" 2>&1 | tee "$BUILD_LOG"; then
if ! env NYASH_DISABLE_PLUGINS=0 "$NYASH_ROOT/tools/build_llvm.sh" "$INPUT_HAKO" -o "$OUTPUT_EXE" 2>&1 | tee "$BUILD_LOG"; then
echo "[FAIL] build_llvm.sh failed"
tail -n 80 "$BUILD_LOG"
exit 1
@ -47,7 +72,7 @@ fi
echo "[INFO] Executing: $OUTPUT_EXE"
set +e
OUTPUT=$(timeout "${RUN_TIMEOUT_SECS:-10}" env NYASH_DISABLE_PLUGINS=1 "$OUTPUT_EXE" 2>&1)
OUTPUT=$(timeout "${RUN_TIMEOUT_SECS:-10}" env NYASH_DISABLE_PLUGINS=0 "$OUTPUT_EXE" 2>&1)
EXIT_CODE=$?
set -e

View File

@ -20,8 +20,33 @@ fi
FILEBOX_SO="$NYASH_ROOT/plugins/nyash-filebox-plugin/libnyash_filebox_plugin.so"
MAPBOX_SO="$NYASH_ROOT/plugins/nyash-map-plugin/libnyash_map_plugin.so"
# Phase 98 P0: Ensure required dynamic plugin artifacts exist and are loadable.
echo "[INFO] Ensuring plugin artifacts (FileBox/MapBox)"
if ! bash "$NYASH_ROOT/tools/plugins/build-all.sh" nyash-filebox-plugin nyash-map-plugin >/dev/null; then
echo "[FAIL] tools/plugins/build-all.sh failed for FileBox/MapBox"
exit 1
fi
if [ ! -f "$FILEBOX_SO" ] || [ ! -f "$MAPBOX_SO" ]; then
test_skip "Required plugins not found (FileBox/MapBox)"; exit 0
echo "[FAIL] Required plugin artifacts still missing after build-all (FileBox/MapBox)"
echo "[INFO] FileBox: $FILEBOX_SO"
echo "[INFO] MapBox: $MAPBOX_SO"
exit 1
fi
if ! python3 - <<PY 2>/dev/null; then
import ctypes
ctypes.CDLL(r"$FILEBOX_SO")
ctypes.CDLL(r"$MAPBOX_SO")
print("OK")
PY
echo "[FAIL] Plugin dlopen check failed for FileBox/MapBox"
python3 - <<PY 2>&1 | tail -n 80
import ctypes
ctypes.CDLL(r"$FILEBOX_SO")
ctypes.CDLL(r"$MAPBOX_SO")
print("OK")
PY
exit 1
fi
mkdir -p "$NYASH_ROOT/tmp"
@ -32,7 +57,7 @@ OUTPUT_EXE="$NYASH_ROOT/tmp/phase97_next_non_ws_llvm_exe"
echo "[INFO] Building: $INPUT_HAKO$OUTPUT_EXE"
BUILD_LOG="/tmp/phase97_next_non_ws_build.log"
if ! env NYASH_DISABLE_PLUGINS=1 "$NYASH_ROOT/tools/build_llvm.sh" "$INPUT_HAKO" -o "$OUTPUT_EXE" 2>&1 | tee "$BUILD_LOG"; then
if ! env NYASH_DISABLE_PLUGINS=0 "$NYASH_ROOT/tools/build_llvm.sh" "$INPUT_HAKO" -o "$OUTPUT_EXE" 2>&1 | tee "$BUILD_LOG"; then
echo "[FAIL] build_llvm.sh failed"
tail -n 80 "$BUILD_LOG"
exit 1
@ -47,7 +72,7 @@ fi
echo "[INFO] Executing: $OUTPUT_EXE"
set +e
OUTPUT=$(timeout "${RUN_TIMEOUT_SECS:-10}" env NYASH_DISABLE_PLUGINS=1 "$OUTPUT_EXE" 2>&1)
OUTPUT=$(timeout "${RUN_TIMEOUT_SECS:-10}" env NYASH_DISABLE_PLUGINS=0 "$OUTPUT_EXE" 2>&1)
EXIT_CODE=$?
set -e