# P0-03 进程级环境构造说明

> 日期：2026-10-16
> 产出脚本：`D:\NX_AI\core\nx_env.ps1`、`D:\NX_AI\core\nx_launch.ps1`、`D:\NX_AI\core\nx_version_resolve.ps1`

---

## 1. 为什么必须进程级、不能全局

### 1.1 本机真实案例（教训）

本机现在有 **两个 NX**：
- `E:\Program Files\Siemens\NX2212`
- `E:\Program Files\Siemens\NX2412`

但当前全局环境变量残留：

| 变量 | 作用域 | 值 | 问题 |
|---|---|---|---|
| `UGII_VENDOR_DIR` | **用户级** | `E:\...\NX2412\UGII\vendor` | **单值！** NX2212 读不到它，NX2212 的 vendor 目录形同虚设 |
| `UGII_BASE_DIR` | **机器级** | `E:\...\NX2412` | 单值！装第二个版本时第一个就废了 |
| `UGII_USER_DIR` | 用户级 | `C:\Users\...\NxCamToolkit\bridge` | 还好是用户级，可以配合 |
| `UGII_LOCAL_USER_DEFAULTS` | 用户级 | `...\nx_user.dpv` | 全局关掉了 NXOpen 签名警告（安全隐患） |

**旧做法**（`install.ps1`）：写 `[Environment]::SetEnvironmentVariable('UGII_BASE_DIR', 'NX2412', 'Machine')` → 立刻破坏 NX2212。

**正确做法**：每个子进程**自己拿**一份干净环境，进程内注入对应版本的变量，进程退出即销毁。

### 1.2 两种进程级注入方式（已实测）

| 方式 | 适用场景 | 实现 | 父进程影响 |
|---|---|---|---|
| **方式 A：ProcessStartInfo.EnvironmentVariables 替换式注入** | 拉起独立子进程（gui 模式 / journal 模式） | 先 `foreach ($k in [Environment]::GetEnvironmentVariables('Process').Keys)` 拷贝当前进程环境，覆盖 NX 变量，赋值给 `psi.EnvironmentVariables` | **零**——完全不动父进程 `$env:` |
| **方式 B：Invoke-WithNxEnvironment 当前进程临时切换** | 必须在当前进程做的操作（如 dot-source NX 内部模块） | 进入前**备份**所有 NX 变量原值 → 覆盖 → try { 执行 ScriptBlock } finally { 无条件恢复 }；"恢复"包括"原本不存在该变量"的情况（用 `Remove-Item Env:XXX`） | **恢复后零**——try/finally 保证无论正常返回/continue/break/throw 都走恢复 |

**实测证据**：重跑 6 条 journal 通路后，机器级 `UGII_BASE_DIR=NX2412`（原值），用户级 `UGII_VENDOR_DIR=NX2412\vendor`（原值）——**全局零污染**。

---

## 2. nx_env.ps1 函数说明

### 2.1 `Get-NxEnvironment`

```powershell
$envMap = Get-NxEnvironment -NxRoot 'E:\...\NX2412' -UserDir '...'
# 返回 hashtable，包含完整子进程环境
```

**生成的变量**（以 NX2212 为例）：

| 变量 | 值 | 来源 |
|---|---|---|
| `UGII_BASE_DIR` | `E:\Program Files\Siemens\NX2212` | 必填参数 |
| `UGII_ROOT_DIR` | `E:\Program Files\Siemens\NX2212\UGII` | `Join-Path NxRoot 'UGII'` |
| `UGII_NXBIN` | `E:\Program Files\Siemens\NX2212\NXBIN` | `Join-Path NxRoot 'NXBIN'` |
| `UGII_USER_DIR` | (可选) | -UserDir 参数 |
| `UGII_CAM_POST_DIR` | (可选) | -CamPostDir 参数 |
| `PATH` | `NX2212\NXBIN;NX2212\UGII;{原进程 PATH 去重}` | NXBIN + UGII 前置，原 PATH 去重后追加 |
| *(其余进程继承变量)* | 原样保留 | 当前 Process target 的所有变量 |

**DryRun**：`-DryRun` 开关下只打印将要生成的变量名，零副作用。

### 2.2 `Invoke-WithNxEnvironment`

```powershell
Invoke-WithNxEnvironment -Env $envMap -ScriptBlock {
    # 在当前进程临时运行 NX2412 环境的代码
    Start-Some-NxCommand
}
# 这里自动恢复原状，$env:UGII_BASE_DIR 回到进入前的值
```

**核心机制**：

```
进入前:  备份 Process target 所有被覆盖变量的原值（含 null 表示原本不存在）
         foreach ($k in $envMap.Keys) {
             $backup[$k] = [Environment]::GetEnvironmentVariable($k, 'Process')
         }

执行中:  覆盖 Process target + 同步 $env: drive
         foreach ($k in $envMap.Keys) {
             [Environment]::SetEnvironmentVariable($k, $envMap[$k], 'Process')
         }
         return & $ScriptBlock

finally: 无条件恢复（try/finally 保证）
         foreach ($k in $backup.Keys) {
             if ($null -eq $backup[$k]) {
                 # 原本不存在 → 删掉
                 [Environment]::SetEnvironmentVariable($k, $null, 'Process')
             } else {
                 # 原本有值 → 还原
                 [Environment]::SetEnvironmentVariable($k, $backup[$k], 'Process')
             }
         }
```

### 2.3 `Assert-GlobalEnvUnchanged`

```powershell
$snap = _SnapshotGlobal   # 进入前拍一张
# ... 做一些事 ...
Assert-GlobalEnvUnchanged -BeforeSnapshot $snap   # 执行完后比对
```

比对的变量（机器级 + 用户级）：`UGII_BASE_DIR`、`UGII_VENDOR_DIR`、`UGII_USER_DIR`、`UGII_LOCAL_USER_DEFAULTS`、`UGII_ROOT_DIR`。不一致则打印差异行并返回 `$false`。

---

## 3. nx_launch.ps1 启动器用法

```powershell
# DryRun：看看要拉起什么（零副作用）
powershell -ExecutionPolicy Bypass -File D:\NX_AI\core\nx_launch.ps1 `
    -Version latest -Mode gui -DryRun

# GUI 模式（独立进程，不等待）
powershell -ExecutionPolicy Bypass -File D:\NX_AI\core\nx_launch.ps1 `
    -Version 2412 -Mode gui

# journal 模式（调 P0-01 run_journal_probe.ps1，等待完成）
powershell -ExecutionPolicy Bypass -File D:\NX_AI\core\nx_launch.ps1 `
    -Version 2212 -Mode journal -Journal D:\NX_AI\tools\hello_journal.py

# 显式关闭全局自检（默认开启）
powershell -ExecutionPolicy Bypass -File D:\NX_AI\core\nx_launch.ps1 `
    -Version 2212 -Mode gui -AssertNoGlobalChange:$false
```

**关键行为**：
1. **启动前 tasklist 检查**：用 `Get-CimInstance Win32_Process` 扫描 ugraf.exe 和 run_journal.exe 的 CommandLine，匹配目标 NX 根目录的大写形式（`E:\...\NX2212`），若已运行则打印 WARN 但继续（同 NX 根多实例允许）
2. **GUI 模式用 ProcessStartInfo.EnvironmentVariables 替换式注入** → 干净子进程
3. **journal 模式调 P0-01 的 run_journal_probe.ps1** → 后者自己也用 ProcessStartInfo 注入（两层保险）
4. **启动器自身 finally 块做全局自检** → 无论成功/失败/异常都执行

---

## 4. 实测结果（2026-10-16 13:43–13:45）

| 调用 | 耗时 | exitcode | 全局自检 |
|---|---|---|---|
| `-Version 2212 -Mode journal hello_journal.py` | 34.5s | 0 | ✅ 通过 |
| `-Version 2412 -Mode journal hello_journal.py` | 36.8s | 0 | ✅ 通过 |

journal_result.txt 末尾两行（新增，nx_launch.ps1 直接触发的）：
```
PY_JOURNAL OK  NX=2212  Python=3.10.6  host_pid=10404
PY_JOURNAL OK  NX=2412  Python=3.11.9  host_pid=21284
```

---

## 5. 坑点记录（PS5.1）

### 坑 1：dot-source 另一个有顶层 param 块的脚本 → 父脚本参数绑定失效

**现象**：`nx_launch.ps1` 顶层 `param([Parameter(Mandatory=$true)] [string] $Version)`，dot-source `. nx_version_resolve.ps1`（后者也有顶层 `param([Parameter(Mandatory=$true)] [string] $Version)`）→ PS5.1 报 "Missing mandatory parameters: Version"。

**根因**：dot-source 是在当前作用域执行，被 dot-source 的脚本的顶层 `param()` 块**也会执行绑定**（不是只定义函数）。如果被 dot-source 脚本有必选参数、调用时没传，它就报错退出——但这个错误会被 PS 当作文档解析错误，导致父脚本的参数绑定也被跳过。

**修复**：所有需要 dot-source 引入的辅助脚本改成**纯函数模块**——去掉顶层 `[CmdletBinding()]` + `param(...)` 块，所有逻辑都在函数里。入口脚本（nx_launch.ps1）才保留顶层 param 块。

### 坑 2：`Export-ModuleMember` 在 dot-source 下抛警告

**现象**：`Export-ModuleMember -Function XXX` 在 dot-source 调用时抛 "Modules_CanOnlyExecuteExportModuleMemberInsideAModule"。

**根因**：`Export-ModuleMember` 仅在 `.psm1` + `Import-Module` 上下文有效。

**修复**：dot-source 直接引入函数即可，完全放弃 `Export-ModuleMember`。

### 坑 3：PowerShell 脚本 UTF-8 必须带 BOM

**现象**：Write 工具写出的 UTF-8 无 BOM `.ps1` 含中文 → PS5.1 把中文当 GBK 解析 → parse error / 参数绑定失效。

**修复**：所有 `.ps1` 写完后用 `[System.Text.UTF8Encoding]::new($false)/new($true)` 加 BOM：
```powershell
$c = [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($path, $c, [System.Text.UTF8Encoding]::new($true))
```
（或直接用 `Set-Content -Encoding UTF8`）

### 坑 4：Write 工具写的脚本不一定能参数绑定

**现象**：Write 工具写出的 `nx_launch.ps1` 跟 Set-Content 写出的**完全相同**内容，但 Write 工具写的那个 PS5.1 始终报 "Missing mandatory parameters: Version"。

**根因**：不明（可能 Write 工具加了某些不可见控制字符）。对比 bytes 也没发现差异。

**规避**：核心启动脚本一律用 `[System.IO.File]::WriteAllText()` + UTF8 BOM 方式写入。Write 工具用于非脚本文件（`.py`、`.cs`、`.md`、`.json`）和简单内容。
