「因為要更新應用程式,請大家用 X 按鈕關閉視窗」──每次更新都要透過館內廣播這樣通知」「在夜間安排了會替換檔案伺服器上 exe 的批次程式,結果隔天早上一看,卻因為『處理程序無法存取檔案』而失敗了」── 在業務應用程式的發布・更新諮詢中,這個「檔案使用中」問題真的非常常見。安裝程式最後顯示的「需要重新啟動」訊息,根源也是同一個:某個人正佔用著的檔案無法被替換,這就是所有問題的原因。
麻煩的是,這個問題「只是偶爾發生」。在開發機上,因為是自己先關閉應用程式再更新,所以不會重現;而在正式環境的共用環境中,只要「有某一個人開著應用程式沒關就下班了」,當晚的更新就會全軍覆沒。而且,光看錯誤訊息,根本無法得知是誰佔用著檔案。
其實 Windows 針對這個問題準備了 OS 標準的機制,那就是 Restart Manager API:它能列舉出「是誰佔用著」、讓對方乖乖地結束程式,並在更新後重新啟動它們。此外,只要善用「執行中的 exe 可以改名」這項特性,也能設計出不停止處理程序、從下次啟動起切換成新版本的更新方式。本文以在業務應用程式的自動更新・安裝程式中,為「檔案使用中」所苦的開發者為對象,從鎖定的機制、Restart Manager 的用法、應用程式端的作法,到自動更新的實作模式,佐以官方文件的依據與 C# 診斷程式碼,做一次整理。
1. 先講結論
- 執行中的 exe・已載入的 DLL 無法「覆寫」與「刪除」,但同一磁碟區內的「改名(移動)」是可行的。先把舊檔案改名為暫存名稱、再放上新檔案的 rename-then-replace,是自製更新程式的基本形式。
- Restart Manager 是 Windows Vista 以降作業系統標準內建的 API,正是為了「檔案使用中問題」而存在。只要註冊要更新的檔案,它就會列舉出佔用該檔案的應用程式・服務,並一路照顧到結束與重新啟動。目的是減少乃至消除 OS 重新開機。1
- API 的流程是 RmStartSession → RmRegisterResources → RmGetList → RmShutdown →(更新)→ RmRestart → RmEndSession。停止順序是 GUI 應用程式 → 主控台應用程式 → 服務 → 檔案總管,重新啟動則以相反順序進行。12
- 光是 RmGetList,就能做出顯示「是誰佔用著」的診斷工具。用 C# 透過 P/Invoke 呼叫,只要幾十行程式碼(請參考後述程式碼)。3
- MSI(Windows Installer 4.0 以降)會自動使用 Restart Manager。只要在封裝檔中加入 MsiRMFilesInUse 對話方塊,就能向使用者提供「自動關閉應用程式並重新啟動」的選項。4
- 應用程式端也有一套作法。用 RegisterApplicationRestart 註冊重新啟動命令列、回應 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP)、在 WM_ENDSESSION 中保存未儲存的資料後再結束程式。實作了這 3 點的應用程式,就能做到「即使因更新而被關閉,更新後也能原樣重新啟動」。56
- 為了防止重新啟動迴圈,啟動未滿 60 秒的應用程式不會被重新啟動。此外,Restart Manager 強制終止的逾時時間,應用程式為 30 秒、服務為 20 秒。62
- 無論如何都無法替換時的最終手段,是 MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT(預約在 OS 重新開機時置換)。需要系統管理員權限,預約內容會記錄在登錄檔的 PendingFileRenameOperations 中。7
2. 為什麼使用中的檔案無法替換 ── 以及「改名可以通過」這個突破口
當 Windows 執行 exe,或載入 DLL 時,作業系統會把該檔案映射為記憶體對應檔案(映像區段,image section)。由於分頁機制的緣故,執行期間會持續參照檔案本體,因此內容的改寫與刪除都會被封鎖。用檔案總管嘗試複製時會出現「另一個處理程序正在使用這個檔案」、用 File.Copy 會丟出 IOException(共用違規)、用 File.Delete 則會丟出 UnauthorizedAccessException——就是那種行為。
這裡的關鍵,是 Windows 一項不太為人所知的特性。即使檔案的「內容」被鎖定,目錄上的「名稱」仍然可以變更。即便是執行中的 exe,只要在同一個磁碟區內,改名(移動)依然會成功。因為執行中的映像檔所抓住的是檔案本體,而不是路徑名稱。
從這種不對稱性,就導出了自製更新的基本形式 ── rename-then-replace 模式。
1. 將 MyApp.exe(執行中)改名為 MyApp.exe.old ← 即使在執行中也會成功
2. 把新的 MyApp.exe 放到原本的路徑 ← 因為名稱已經空出來,所以會成功
3. 從下次啟動起,就會使用新的 MyApp.exe
4. 舊處理程序結束後,在某個時間點刪除 .old
(因為執行中無法刪除,所以在下次更新或啟動時的清理程序中刪除)
不停止處理程序就能實現「下次啟動起使用新版」,正是這種模式的價值所在,Chrome 的更新程式、Squirrel/Velopack 之類的更新框架,本質上也是建立在這項特性之上。有 3 點要注意:改名只能在同一磁碟區內使用(移動到不同磁碟區會變成複製+刪除,而刪除會失敗);設計時必須包含 .old 檔案的清理;以及「目前正在執行的舊處理程序」終究還是會繼續以舊版本運作,所以會存在新舊處理程序並存的一段時間。
另外,如果想以故障調查的方式,手動先查出「究竟是誰佔用著」,用 Process Explorer 或 Handle 命令搜尋控制代碼・已載入 DLL 是最快的方法。相關步驟已整理在同日發布的姊妹文章「Process Explorer / Handle / VMMap 追蹤 Hang 與 Leak」中。本文則是要從程式的角度,也就是把它內建到更新程式本身的方法,來使用 Restart Manager。
3. Restart Manager 的機制 ── 從 RmStartSession 到 RmRestart
Restart Manager 是 Windows Vista/Windows Server 2008 以降標準內建的一組 API(rstrtmgr.dll),其目的在文件開頭就寫得很清楚:「安裝或更新之所以會要求系統重新開機,主要原因是要更新的檔案正被執行中的應用程式或服務所使用;Restart Manager 透過結束並重新啟動非關鍵的應用程式・服務,來減少甚至消除這種重新開機的需求」1。各位應該都看過在安裝 MSI 封裝檔的過程中,跳出「以下應用程式正在使用檔案…」這種處理程序清單對話方塊,Visual Studio、Office 這類大型產品的安裝程式・更新,也都是這套機制的使用者。
API 的流程是一直線的。
| 步驟 | 函式 | 做的事 |
|---|---|---|
| 1 | RmStartSession | 開始一個 Session,取得控制代碼與 Session key(GUID 字串) |
| 2 | RmRegisterResources | 註冊想要更新的檔案路徑(也可以是處理程序・服務名稱) |
| 3 | RmGetList | 列舉出正在使用已註冊資源的應用程式・服務3 |
| 4 | RmShutdown | 讓它們結束(通常是溫和地結束,指定的話則強制結束)2 |
| 5 | ── | 在這段期間替換檔案 |
| 6 | RmRestart | 重新啟動已註冊為可重新啟動的應用程式 |
| 7 | RmEndSession | 關閉 Session |
「什麼時候該替換檔案」是最容易搞錯的地方,這裡依時間軸整理一次。可以替換檔案的時機,只有 RmShutdown 傳回之後、呼叫 RmRestart 之前的那一瞬間而已。
sequenceDiagram
participant U as 更新程式
participant RM as Restart Manager<br/>(rstrtmgr.dll)
participant A as 使用中的應用程式<br/>(GUI/服務)
U->>RM: ① RmStartSession
RM-->>U: Session 控制代碼 + Session key
U->>RM: ② RmRegisterResources(更新對象檔案)
U->>RM: ③ RmGetList
RM-->>U: 使用中的處理程序清單 + RebootReasons
Note over U: 這裡確定「是誰佔用著」<br/>RebootReasons≠0 代表需要 OS 重新開機
U->>RM: ④ RmShutdown
RM->>A: WM_QUERYENDSESSION / WM_ENDSESSION<br/>(主控台則收到 CTRL_C_EVENT)
A-->>RM: 儲存後結束
RM-->>U: 結束完成
Note over U,A: ⑤ 在這裡替換檔案<br/>← 只有這段區間鎖定會解除
U->>RM: ⑥ RmRestart
RM->>A: 依相反順序重新啟動已註冊的應用程式
U->>RM: ⑦ RmEndSession
有幾項規格值得留意。
- 停止順序與重新啟動順序。停止順序為 GUI 應用程式 → 主控台應用程式 → 服務 → 檔案總管,更新後則以相反順序重新啟動已註冊的應用程式。1
- 結束方式是依「循規蹈矩的順序」進行的。對 GUI 應用程式會送出 WM_QUERYENDSESSION/WM_ENDSESSION(lParam=ENDSESSION_CLOSEAPP),對不回應的應用程式也會送出 WM_CLOSE。主控台應用程式會收到 CTRL_C_EVENT,服務則透過 SCM 停止。即使指定了 RmForceShutdown,也會先嘗試溫和地結束,對沒有回應的應用程式才在 30 秒(服務則是 20 秒)後強制終止。52
- 能被重新啟動的,只有透過 RegisterApplicationRestart 註冊過的應用程式。在 RmShutdown 指定 RmShutdownOnlyRegistered,就能做成「只有在所有對象都已註冊重新啟動時才結束」這種偏向安全的行為。25
- 無法跨 Session 結束處理程序。以 LocalSystem 服務身分執行的安裝程式,無法結束・重新啟動在使用者 Session 中執行的應用程式。這是設計夜間無人更新時最容易卡關的地方(具體對策見下一節)。2
- 重要的系統服務與關鍵處理程序不在對象範圍內。這種情況下,會回傳「需要 OS 重新開機」的判定結果(RM_REBOOT_REASON)。13
3.1 如何跨越 Session 邊界
只要設計「從服務端在夜間推送更新」,就一定會撞上這個限制。從 LocalSystem(Session 0),無法結束・重新啟動已登入的使用者 Session 中的應用程式。2 迴避的方法,並不是「從 Session 0 伸手過去」,而是徹底做到把手腳放進目標 Session 之中。現實可行的選項有 3 個。
| 方法 | 做什麼 | 適用・不適用 |
|---|---|---|
| (A) 用工作排程器以目標使用者身分啟動 | 把更新代理程式的工作,以目標使用者帳號註冊為「僅在使用者已登入時執行」,並在更新訊號到來時啟動 | 最省事。適合能確定登入中使用者的公司內部端點 |
| (B) 登入時啟動常駐代理程式 | 在每個使用者 Session 中執行一個小型常駐處理程序,透過共用資料夾或事件等待更新訊號 | 目標使用者不固定・多個使用者同時登入時也有效。常駐處理程序有維護成本 |
| (C) 從服務把處理程序拉進使用者 Session | LocalSystem 服務取得目標 Session 的權杖,再用該權杖啟動更新程式 | 自由度最高,但實作與權限管理的負擔也最重 |
(A) 可以用 schtasks 這樣註冊。重點在於用 /RU 指定目標使用者,並加上 /IT,讓工作變成「只有在該使用者登入時才以互動方式執行」。
:: 註冊一個讓更新代理程式在「目標使用者的 Session 內」執行的工作
:: 認證資訊的指定 (/RP) 或覆寫既有工作 (/F),請依照環境的原則調整
schtasks /create /TN "MyApp Update Agent" /TR "C:\App\Updater.exe /shutdown-and-update" ^
/SC ONCE /ST 02:00 /RU CORP\taro /IT
:: 也可以從夜間批次作業端,不等排定時間就立即執行
schtasks /run /TN "MyApp Update Agent"
若選擇 (C),就用 WTSGetActiveConsoleSessionId 或 WTSEnumerateSessions 取得目標 Session ID,再用 WTSQueryUserToken 取得該使用者的主要權杖,然後用 CreateProcessAsUser 啟動更新程式。呼叫 WTSQueryUserToken 需要以 LocalSystem 帳戶執行,並具備 SE_TCB_NAME 權限,官方文件也明確寫著「面向高度受信任的服務」「務必注意不要洩漏權杖,使用完畢後一定要關閉控制代碼」。8 一旦處理不當就會成為權限提升的漏洞,所以如果 (A) 或 (B) 就夠用,就沒有必要硬選這一個。
不論用哪一種方法,由在 Session 內執行的處理程序,負責從 RmStartSession 到 RmRestart 為止的整體結構都是相同的。Session 0 的服務只負責「分發更新檔案」「發出訊號」。
這裡也一併整理與 MSI 的關係。Windows Installer 4.0 以降的 MSI,會自動使用 Restart Manager。預設行為是「不重新開機 OS,而是盡可能結束・重新啟動應用程式」。封裝檔作者能做的,是加入MsiRMFilesInUse 對話方塊,在完整 UI 模式下向使用者提供「自動關閉應用程式並重新啟動」的選項(較舊版本的 Installer 則會回退到傳統的 FilesInUse 對話方塊);用 MSIRESTARTMANAGERCONTROL 等屬性控制行為;以及從自訂動作透過 MsiRestartManagerSessionKey 屬性呼叫 RmJoinSession,追加註冊資源(這個自訂動作要排在偵測使用中檔案的 InstallValidate 動作之前)。在無聲安裝(silent install)中則一律使用 Restart Manager,應用程式會被自動結束。4
也就是說,如果採用 MSI 發布,幾乎不需要自己寫「呼叫 Restart Manager 的程式碼」,整頓好應用程式端的作法(下下一節)才是重點。只有在自製更新程式時,直接呼叫這個 API 才有價值。發布方式本身的選型,已在「Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表」中討論過。
4. 用 C# 找出「是誰佔用著」── RmGetList 診斷程式碼
在 Restart Manager 之中,就算不寫更新處理邏輯的人也用得上的,是 RmGetList。因為可以透過 API 取得「正在使用這個檔案的處理程序與服務清單」,能把更新程式的錯誤訊息,從「檔案使用中」改成「是會計部門終端機上的 MyApp.exe(PID 4132)佔用著」。
4.1 骨架 ── 只需要呼叫 4 個函式
除去 P/Invoke 宣告與結構定義,處理的核心就只有這些。請先把這個形狀記在腦中,再閱讀接下來的完整版。
// 【骨架】結構定義與 P/Invoke 宣告放在 4.3 的完整版中
var sessionKey = new StringBuilder(CCH_RM_SESSION_KEY + 1);
// ① 開始 Session
int rc = RmStartSession(out uint session, 0, sessionKey);
if (rc != 0) throw new Win32Exception(rc);
try
{
// ② 註冊要調查的檔案(必須是完整路徑)
var fullPaths = Array.ConvertAll(args, Path.GetFullPath);
rc = RmRegisterResources(session, (uint)fullPaths.Length, fullPaths, 0, null, 0, null);
if (rc != 0) throw new Win32Exception(rc);
// ③ 列舉使用中的處理程序/服務
// 緩衝區不足時會回傳 ERROR_MORE_DATA 與所需筆數,配置後重新取得
uint count = 0;
RM_PROCESS_INFO[] apps = null;
while (true)
{
rc = RmGetList(session, out uint needed, ref count, apps, out uint reasons);
if (rc == 0) break;
if (rc != ERROR_MORE_DATA) throw new Win32Exception(rc);
count = needed;
apps = new RM_PROCESS_INFO[needed];
}
// apps[0] ~ apps[count-1] 中存放著「佔用者是誰」的資訊
}
finally
{
RmEndSession(session); // ④ 一定要關閉
}
4.2 會回傳什麼 ── 輸出的形式與 RM_APP_TYPE
像 WhoLocks.exe C:\App\MyApp.exe 這樣執行完整版(4.3)的話,輸出會是下面這種形式。這裡的數值只是用來說明的範例,實際內容會因環境而異。
使用中的處理程序/服務: 2 件 (RebootReasons=0)
PID=4132 種類=1 可重新啟動=True Session=2 名稱=MyApp 服務=
PID=6284 種類=3 可重新啟動=False Session=0 名稱=MyAppAgent 服務=MyAppAgent
判讀重點有 3 個。種類(RM_APP_TYPE)決定了結束的方式、可重新啟動(bRestartable)若為 False,更新後就不會自動啟動、Session(TSSessionId)若與自己不同,就會撞上第 3 章「無法跨越 Session」的限制。
種類的數值與意義如下。9
| 值 | 名稱 | 意義 |
|---|---|---|
| 0 | RmUnknownApp | 無法歸類到其他任何一種的應用程式。只能靠強制終止來停止 |
| 1 | RmMainWindow | 以單一處理程序執行、擁有頂層視窗的 Windows 應用程式 |
| 2 | RmOtherWindow | 不是單一處理程序、也沒有頂層視窗的 Windows 應用程式 |
| 3 | RmService | Windows 服務 |
| 4 | RmExplorer | Windows 檔案總管 |
| 5 | RmConsole | 單一的主控台應用程式 |
| 1000 | RmCritical | 因為無法停止,安裝要完成必須 OS 重新開機。原因是關鍵處理程序、權限不足、或是啟動 Restart Manager 的處理程序本身,三者之一 |
實務上,只要出現 0 或 1000,就可以判斷「無法溫和地更新」。若是 1・5,就能用第 3 章的作法(WM_QUERYENDSESSION/CTRL_C_EVENT)關閉;若是 3,則會透過服務控制管理員停止。
4.3 完整版
用 C# 的 P/Invoke 寫出來會是這樣(.NET Framework 4.8/.NET 8 都能運作)。
// WhoLocks.cs ── 用 Restart Manager 列舉出指定檔案「是誰佔用著」
// 用法: WhoLocks.exe C:\App\MyApp.exe C:\App\MyLib.dll
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
internal static class Program
{
private const int CCH_RM_SESSION_KEY = 32; // Session key 是 GUID 字串(32 個字元+結尾)
private const int CCH_RM_MAX_APP_NAME = 255;
private const int CCH_RM_MAX_SVC_NAME = 63;
private const int ERROR_MORE_DATA = 234;
[StructLayout(LayoutKind.Sequential)]
private struct RM_UNIQUE_PROCESS
{
public uint dwProcessId;
public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;
}
// RM_APP_TYPE: 1=GUI 應用程式, 2=其他視窗, 3=服務, 4=Explorer, 5=主控台, 1000=關鍵
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct RM_PROCESS_INFO
{
public RM_UNIQUE_PROCESS Process;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_APP_NAME + 1)]
public string strAppName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_SVC_NAME + 1)]
public string strServiceShortName;
public int ApplicationType;
public uint AppStatus;
public uint TSSessionId;
[MarshalAs(UnmanagedType.Bool)]
public bool bRestartable;
}
// Restart Manager API 不使用 GetLastError,而是直接用回傳值傳回 Win32 錯誤碼
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
private static extern int RmStartSession(
out uint pSessionHandle, int dwSessionFlags, StringBuilder strSessionKey);
[DllImport("rstrtmgr.dll")]
private static extern int RmEndSession(uint dwSessionHandle);
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
private static extern int RmRegisterResources(uint dwSessionHandle,
uint nFiles, string[] rgsFileNames,
uint nApplications, RM_UNIQUE_PROCESS[] rgApplications,
uint nServices, string[] rgsServiceNames);
[DllImport("rstrtmgr.dll")]
private static extern int RmGetList(uint dwSessionHandle,
out uint pnProcInfoNeeded, ref uint pnProcInfo,
[In, Out] RM_PROCESS_INFO[] rgAffectedApps, out uint lpdwRebootReasons);
private static void Main(string[] args)
{
if (args.Length == 0)
{
Console.Error.WriteLine("用法: WhoLocks <檔案路徑> ...");
Environment.Exit(2);
}
var sessionKey = new StringBuilder(CCH_RM_SESSION_KEY + 1);
int rc = RmStartSession(out uint session, 0, sessionKey);
if (rc != 0) throw new Win32Exception(rc, $"RmStartSession 失敗 (rc={rc})");
try
{
// 依照 API 的約定,註冊的檔案名稱必須是完整路徑。
// 為了讓相對路徑呼叫也能運作,在註冊前先正規化
var fullPaths = Array.ConvertAll(args, Path.GetFullPath);
rc = RmRegisterResources(session, (uint)fullPaths.Length, fullPaths, 0, null, 0, null);
if (rc != 0) throw new Win32Exception(rc, $"RmRegisterResources 失敗 (rc={rc})");
// 先查詢所需筆數→配置陣列→取得。若兩次呼叫之間處理程序數量
// 增加了,會再次回傳 ERROR_MORE_DATA,因此用迴圈重試
uint count = 0;
uint rebootReasons;
RM_PROCESS_INFO[] apps = null;
while (true)
{
rc = RmGetList(session, out uint needed, ref count, apps, out rebootReasons);
if (rc == 0) break;
if (rc != ERROR_MORE_DATA) throw new Win32Exception(rc, $"RmGetList 失敗 (rc={rc})");
count = needed;
apps = new RM_PROCESS_INFO[needed];
}
Console.WriteLine($"使用中的處理程序/服務: {count} 件 (RebootReasons={rebootReasons})");
for (int i = 0; i < count; i++)
{
RM_PROCESS_INFO a = apps[i];
Console.WriteLine(
$" PID={a.Process.dwProcessId,-6} 種類={a.ApplicationType} " +
$"可重新啟動={a.bRestartable} Session={a.TSSessionId} " +
$"名稱={a.strAppName} 服務={a.strServiceShortName}");
}
}
finally
{
RmEndSession(session); // Session 的釋放一定要放在 finally 中
}
}
}
重點有 3 個。第一,Restart Manager API 的回傳值本身就是 Win32 錯誤碼,不使用 GetLastError(DllImport 上不需要 SetLastError = true)。第二,RmGetList 的呼叫慣例是「緩衝區不足時回傳 ERROR_MORE_DATA(234) 與所需筆數」,所以要寫成查詢→配置→取得的迴圈。3 第三,lpdwRebootReasons 若不是 0(RmRebootReasonNone),就表示「光靠結束應用程式不夠,需要 OS 重新開機」的判定結果。3 關於把結構中的字串以 ByValTStr 編組的寫法,以及這類 P/Invoke 的一般性安全寫法,請參考「在 C# 中安全呼叫 Win32 API — P/Invoke 實務指南(DllImport / LibraryImport / CsWin32)」。
只要在這段程式碼上,再加上 RmShutdown/RmRestart 這兩個 P/Invoke,就會成為「列舉→溫和結束→替換→重新啟動」這種自製更新程式的骨架。不過要注意,可以呼叫 RmShutdown 的,僅限自己呼叫過 RmStartSession 的處理程序,不能從 MSI 的自訂動作中呼叫(因為 MSI 本身已經在管理 Session)。4
5. 應用程式端的作法 ── 讓程式「乖乖被關閉、又能原樣重新啟動」的 3 件套
不論是 Restart Manager 還是 MSI,除非明確指定強制,否則都不會用 terminate 不由分說地殺掉處理程序。如果應用程式端沒有回應,結果還是得靠「使用中的檔案」對話方塊來煩勞使用者。要讓業務應用程式「對更新有韌性」,就要在應用程式端實作接下來的 3 件套。5
| (1) 用 RegisterApplicationRestart 註冊重新啟動。在啟動後立刻呼叫一次即可。Restart Manager 能重新啟動的,僅限已註冊的應用程式,這是唯一「把重新啟動時的命令列告訴 OS 的手段」。主要規格是:命令列不要包含 exe 名稱(OS 會自動附加)、最大長度為 RESTART_MAX_CMD_LINE、以及啟動後 60 秒內,為了防止重新啟動迴圈而不會被重新啟動。若不需要在當機・沒回應時重新啟動,可以指定 RESTART_NO_CRASH | RESTART_NO_HANG,限縮成「只有更新時才重新啟動」。6 |
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterApplicationRestart(string commandLine, int flags);
// 在啟動時先註冊好。用「/restored」旗標,可以分支到重新啟動後的還原處理
// RESTART_NO_CRASH(1) | RESTART_NO_HANG(2) = 只有因更新而結束時才重新啟動
RegisterApplicationRestart("/restored", 1 | 2);
(2) 回應 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP)。Restart Manager 在結束前,會用這個訊息詢問「可以關閉了嗎」。此時還不要真的結束,若已準備就緒就回傳 TRUE(因為其他應用程式可能還沒準備好)。回傳 FALSE 雖然可以取消關機,但強制指定時終究還是會被結束,所以應避免依賴 FALSE 的設計。這個時機點,也是在更新場景中重新呼叫 RegisterApplicationRestart 的最後機會。把還原所需的狀態(例如開啟中的表單 ID 等)寫進命令列引數並重新登錄,就能在重新啟動後回到「原本的畫面」。56
(3) 在 WM_ENDSESSION 保存未儲存的資料後結束。實際的結束指令會透過 WM_ENDSESSION(wParam=TRUE、lParam=ENDSESSION_CLOSEAPP)送達。要在逾時時間內(強制時應用程式為 30 秒)完成未儲存資料的自動儲存、工作狀態的序列化、連線的關閉後再結束程式。官方指引建議「應該平時就定期儲存使用者資料」。主控台應用程式的情況下,收到的不是 WM_ 訊息而是 CTRL_C_EVENT,因此用 SetConsoleCtrlHandler(C# 則是 Console.CancelKeyPress)做同樣的事。52
在 WPF/WinForms 中,Application.SessionEnding / Form.FormClosing(CloseReason.WindowsShutDown)是入口,但如果要細看 ENDSESSION_CLOSEAPP 旗標(區分「不是 OS 重新開機,而是只關閉應用程式、稍後再重新啟動」),就需要掛鉤視窗程序(window procedure)。此外,為了避免重新啟動後的處理程序,跟「前一個正在結束中的自己」互相衝突,也請一併重新檢視防止多重啟動的 Mutex 設計(「防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置」)。
只要備齊這 3 件套,MSI 更新的體驗,就會從「用館內廣播請大家關閉」,變成「一旦執行更新,應用程式就會自動關閉,更新後又能自動回到原本的畫面」。這和 Office「重新啟動後會自動重新開啟文件」的行為,是同一套機制。
6. 自動更新的實作模式 ── 另開程序、rename-then-replace、重新開機時置換
這裡整理自製自動更新時的設計模式。大前提只有一個:無法自己覆寫自己(執行中的 exe),所以更新處理,一定要在某個地方逃到「另一個處理程序」或「另一個名稱」去。
模式 A:另開一個更新程式的處理程序+等待後替換。本體應用程式偵測到更新後,啟動更新程式 exe(或複製出的暫存 exe)並自行結束。更新程式等待本體處理程序結束(Process.WaitForExit 或等待 Mutex 釋放),替換檔案後再重新啟動本體。這個做法樸實而可靠,但為了應付「本體遲遲不結束」「多處理程序架構下要等的對象很多」的情況,搭配前一節的 RmGetList 先確認佔用者,再用 RmShutdown 關閉,會很有效。
模式 B:rename-then-replace(不停止的更新)。如第 2 節所述,把執行中的 exe/DLL 改名、放上新版本,做成「下次啟動起使用新版」。優點是不會中斷使用者的作業,適合常駐型・長時間運作型的業務應用程式。Squirrel、Velopack 這類針對 .NET 的更新框架,也是以「依版本分資料夾+替換進入點 exe」的形式運用這項特性,把「更新在背地裡套用、下次啟動起使用新版」整套框架提供給你。若是自行實作,流程會落在:下載→驗證→展開→改名保留→部署→在下次啟動時清理保留檔案。
模式 C:MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT(重新開機時置換)。對於服務的宿主處理程序、殼層擴充功能這種「無論如何都無法停止・也無法用改名逃脫」的東西,最終手段就是預約在 OS 重新開機時置換。預約內容會記錄在登錄檔的 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations 中,並在下次啟動的早期階段(AUTOCHK 之後、建立分頁檔之前)依註冊順序執行。使用前必須理解:只能由系統管理員群組或 LocalSystem 呼叫;不能與 MOVEFILE_COPY_ALLOWED 併用(也就是僅限同一磁碟區);若置換目的地的檔案已經存在,必須併用 MOVEFILE_REPLACE_EXISTING(不併用的話,即使預約本身成功,重新開機時的置換也不會執行);以及函式的成功,指的是「預約成功」,並不代表實際置換結果。安裝程式說「需要重新開機」時,內部堆積的正是這個。7
不論哪一種模式,都有兩個共通的注意事項。一個是服務的更新:Windows 服務無法自己替換自己,因此把「用 SCM 執行 Stop→替換→Start」的更新用處理程序(或專用的更新服務)獨立出來,是常見做法(服務設計的基礎請見「Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化」)。另一個是更新檔案的驗證:自製置換機制,同時也等於自製了「散布任意 exe 並讓它執行的機制」,若省略簽章驗證或下載路徑的保護,更新機制本身就會變成攻擊途徑。這個議題已在「自動更新功能的安全性基本 - 糟糕的模式與最佳實踐」中獨立討論過。
7. 更新方式判斷表
| 方式 | 適合的狀況 | 注意事項 |
|---|---|---|
| MSI +容許 OS 重新開機 | 發布頻率低(一年數次)、能取得夜間・假日的維護時段 | 實作最輕鬆。但會持續讓使用者看到「需要重新開機」的訊息。仰賴重新開機時置換(PendingFileRenameOperations)7 |
| MSI + Restart Manager 整合(MsiRMFilesInUse +應用程式端 3 件套) | 想在維持 MSI 發布的前提下改善更新體驗。公司內部發布的標準應用程式 | 安裝程式端幾乎全自動。效果取決於應用程式端 RegisterApplicationRestart/WM_QUERYENDSESSION 的實作程度45 |
| 自製更新程式(另開處理程序+rename-then-replace,含 Squirrel/Velopack) | 更新頻率高(每週以上)、使用者沒有系統管理員權限、不想中斷作業 | 必須有心理準備自行實作清理處理・簽章驗證・失敗時回溯。用 RmGetList/RmShutdown 加入「佔用者是誰」的對策會更穩固3 |
| ClickOnce | 公司內部的 Windows 用戶端,只想要「啟動時自動更新」 | 若是啟動時更新模型,本身就不容易發生使用中問題。相關限制請見「ClickOnce 是什麼 - 以實務視角整理機制、更新、適合場面・不適合場面」 |
| 服務端的自我更新(獨立出更新用處理程序) | 無人環境・裝置 PC 上 24 小時運作的服務無法停止 | 服務本身無法替換自己。負責 Stop→替換→Start 的另一個處理程序是必要的。要注意 Session 邊界(LocalSystem 無法關閉使用者應用程式)2 |
猶豫不決時,請先考慮「MSI + Restart Manager 整合」。因為搭上了 OS 標準機制,實作量最小,而應用程式端的 3 件套,即使日後轉移到自製更新程式,也能直接沿用成為資產。
8. 總結
- 使用中的 exe/DLL 無法覆寫・刪除,但同一磁碟區內的改名是可行的。利用這種不對稱性的 rename-then-replace,是「不停止的更新」的基本形式。
- Restart Manager 是一套會照顧到「列舉是誰佔用著」(RmGetList)、溫和地結束(RmShutdown)、更新後重新啟動(RmRestart)的 OS 標準 API,Windows Installer 4.0 以降的 MSI 會自動使用它。
- 應用程式端的作法是 3 件套:用 RegisterApplicationRestart 註冊重新啟動、對 WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP)回傳 TRUE、在 WM_ENDSESSION 保存未儲存的資料後結束。光是這樣,就能成為「更新後原樣重新啟動的應用程式」。
- 60 秒規則(剛啟動時不會被重新啟動)、強制終止逾時(應用程式 30 秒・服務 20 秒)、Session 邊界(LocalSystem 無法關閉使用者應用程式),是實際運用上容易絆倒的地方。
- 無論如何都無法替換時,就用 MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT 預約在 OS 重新開機時置換。必須具備系統管理員權限・僅限同一磁碟區・成敗指的是預約的成敗。
- 自製更新機制,就是自製「散布任意 exe 的機制」。請把簽章驗證・路徑保護・回溯都納入設計之中。
相關文章
- Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表
- 自動更新功能的安全性基本 - 糟糕的模式與最佳實踐
- 在 C# 中安全呼叫 Win32 API — P/Invoke 實務指南(DllImport / LibraryImport / CsWin32)
- 防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置
- Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化
- Process Explorer / Handle / VMMap 實戰 ── 從「此刻」的狀態追查無回應、洩漏、「檔案使用中」
相關諮詢領域
合同會社小村軟體提供業務應用程式自動更新機制的設計・實作(Restart Manager 整合、自製更新程式、導入 Squirrel/Velopack)、改善「每次更新都要請所有人關閉應用程式」的運用方式,以及既有安裝程式「檔案使用中」「需要重新開機」問題的調查與對策。
參考連結
-
Microsoft Learn, About Restart Manager。關於 Restart Manager 的目的(減少・消除因使用中檔案而需要的重新開機)、以 GUI 應用程式→主控台應用程式→服務→檔案總管的順序停止並以相反順序重新啟動、不支援跨 Session 的關機、Windows Installer 4.0 會自動使用 Restart Manager、重要的系統服務若不重新開機 OS 就無法停止。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, RmShutdown function。關於指定 RmForceShutdown 時,沒有回應的應用程式也會在 30 秒・服務則在 20 秒後被強制終止;用 RmShutdownOnlyRegistered 可以做成「只有在所有對象都已註冊重新啟動時才結束」;LocalSystem 的服務無法結束・重新啟動其他使用者 Session 中的應用程式;以及 ERROR_FAIL_NOACTION_REBOOT 等回傳值。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Microsoft Learn, RmGetList function。關於以 RM_PROCESS_INFO 陣列傳回正在使用已註冊資源的應用程式・服務、緩衝區不足時回傳 ERROR_MORE_DATA(234) 與所需筆數的呼叫慣例、透過 lpdwRebootReasons 傳回需要 OS 重新開機的原因(RM_REBOOT_REASON)、Windows Vista 以降由 rstrtmgr.dll 提供。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Using Windows Installer with Restart Manager。關於 Windows Installer 4.0 會自動使用 Restart Manager,預設會優先結束・重新啟動應用程式而非重新開機 OS;MsiRMFilesInUse 對話方塊可以在完整 UI 模式下提供自動關閉・重新啟動的選項(較舊環境會回退到 FilesInUse);無聲安裝時一律使用 Restart Manager 並結束應用程式;自訂動作應排在 InstallValidate 之前,並透過 MsiRestartManagerSessionKey 呼叫 RmJoinSession,不應呼叫 RmShutdown 等函式;以及 MSIRESTARTMANAGERCONTROL 等控制屬性。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Guidelines for Applications (Restart Manager)。關於會對 GUI 應用程式送出 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP),準備就緒時回傳 TRUE 且此時尚未結束;實際的結束在 WM_ENDSESSION 進行;不回應的應用程式也會收到 WM_CLOSE;主控台應用程式會收到 CTRL_C_EVENT;重新啟動必須透過 RegisterApplicationRestart 註冊。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, RegisterApplicationRestart function。關於重新啟動時命令列的註冊(不包含 exe 名稱,最大長度為 RESTART_MAX_CMD_LINE)、RESTART_NO_CRASH/NO_HANG/NO_PATCH/NO_REBOOT 旗標、因更新而重新啟動是自動的,當機・沒回應時則需要使用者同意、為防止迴圈啟動未滿 60 秒不會重新啟動、更新時最後的註冊機會是在處理 WM_QUERYENDSESSION 的時候。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, MoveFileExW function。關於 MOVEFILE_DELAY_UNTIL_REBOOT 會把預約寫入 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager 的 PendingFileRenameOperations(REG_MULTI_SZ),在 AUTOCHK 執行後・建立分頁檔之前依註冊順序執行;需要系統管理員群組或 LocalSystem 的權限;不能與 MOVEFILE_COPY_ALLOWED 併用;若移動目的地的檔案已存在,需要指定 MOVEFILE_REPLACE_EXISTING;回傳值指的是預約的成敗,並非實際移動的成敗;對 lpNewFileName 傳入 NULL 則會變成重新開機時刪除。 ↩ ↩2 ↩3
-
Microsoft Learn, WTSQueryUserToken function (wtsapi32.h)。關於這是一個指定 Session ID 以取得已登入使用者主要存取權杖的函式;呼叫時需要在 LocalSystem 帳戶的內容下執行,並具備 SE_TCB_NAME 權限;適用於高度受信任的服務,須注意不要洩漏權杖,取得的控制代碼務必用 CloseHandle 關閉;可以用 WTSEnumerateSessions 列舉 Session ID。 ↩
-
Microsoft Learn, RM_APP_TYPE enumeration (restartmanager.h)。關於 RM_PROCESS_INFO 結構所表示的應用程式種類,定義了 RmUnknownApp(0,無法歸類到其他種類,只能靠強制終止停止)、RmMainWindow(1,擁有頂層視窗的單一處理程序 Windows 應用程式)、RmOtherWindow(2,非單一處理程序也沒有頂層視窗的 Windows 應用程式)、RmService(3,Windows 服務)、RmExplorer(4,Windows 檔案總管)、RmConsole(5,單一的主控台應用程式)、RmCritical(1000,因為無法停止處理程序,安裝要完成需要系統重新開機。原因是關鍵處理程序、權限不足、或啟動 Restart Manager 的安裝程式本身三者之一)。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Arm 版 Windows 能執行業務應用程式嗎 ── x64 模擬(Prism)與原生 DLL・COM 的現實
本文為開發者・資訊系統部門解答「Arm 版 Windows 能執行業務應用程式嗎」這個問題,整理 x64 模擬(Prism)的運作原理、驅動程式等無法執行的層級、.NET 的 AnyCPU 與 P/Invoke 問題,以及 Arm 對應檢查清單。
在 C# 中安全呼叫 Win32 API — P/Invoke 實務指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中透過 P/Invoke 呼叫 Win32 API 或原生 DLL 時的實務要點:DllImport 與 LibraryImport 的差異、CsWin32 自動產生簽章、字串封送的陷阱、用 SafeHandle 管理控制代碼、SetLastError 與...
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表
整理 Windows 應用程式的發布方式,把 MSI、MSIX、ClickOnce、xcopy 與自訂 updater 各自的適用情境攤開比較。重點不是哪一種 installer 比較新,而是與 OS 的耦合深度與更新責任由誰扛兩條軸線;讀完能依 per-user/per-...
.NET 的 Native AOT 是什麼 - 先釐清與 JIT、ReadyToRun、trimming 的差異
把 .NET 的 Native AOT 與 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起釐清,並從啟動、發布、相依性的角度整理它合適與不合適的情境,幫助讀者判斷該不該採用。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 為什麼執行中的 exe 或已載入的 DLL 無法覆寫,卻可以改名?
- Windows 會把執行中的 exe 或已載入的 DLL 抓成記憶體映射(映像區段),所以改寫或刪除檔案內容都會發生錯誤(共用違規或拒絕存取)。另一方面,目錄上的「名稱」與檔案內容是獨立的,因此在同一磁碟區內改名(移動)即使在執行中也會成功。利用這種不對稱性的,就是 rename-then-replace 模式:先把舊 exe 改名為暫存名稱,再用新 exe 的原本名稱放上去,讓下次啟動起就使用新版。Chrome、Squirrel/Velopack 系列的自動更新,本質上也是建立在這項特性之上。
- Restart Manager 是一套會做什麼事的 API?
- 只要註冊想要更新的檔案,它就會列舉出「目前正在使用該檔案的應用程式・服務清單」,可能的話讓它們結束,並在更新後重新啟動它們,是 Windows 標準 API(Windows Vista 以降)。流程是:用 RmStartSession 建立 Session,用 RmRegisterResources 註冊目標檔案,用 RmGetList 列舉佔用中的處理程序,用 RmShutdown 結束,用 RmRestart 重新啟動,最後用 RmEndSession。停止順序為 GUI 應用程式→主控台應用程式→服務→檔案總管,重新啟動則以相反順序進行。這是用來減少「需要重新開機」的機制,Windows Installer 4.0 以降的 MSI 會自動使用它。
- 先呼叫 RegisterApplicationRestart 會發生什麼事?
- 應用程式可以把自己的重新啟動命令列註冊到 OS,因更新而關機後,Restart Manager 就會用該命令列自動重新啟動應用程式(Restart Manager 能重新啟動的,僅限已註冊的應用程式)。當機或沒回應時也會成為重新啟動的對象,但那種情況會夾雜使用者的同意,因更新而重新啟動則是自動進行的。請注意:為了防止迴圈,啟動未滿 60 秒不會被重新啟動;命令列中不可以包含 exe 名稱。還原所需的狀態(例如原本開啟的檔案等),依作法應寫進命令列引數中重新註冊。
- MoveFileEx 的 MOVEFILE_DELAY_UNTIL_REBOOT 該在什麼時候使用?
- 這是在無論如何都無法停止處理程序、也無法使用 rename-then-replace 時的最終手段,會預約在 OS 重新開機時進行檔案的移動・刪除。預約內容會寫入登錄檔的 PendingFileRenameOperations(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager),並在下次啟動的早期階段(AUTOCHK 之後、建立分頁檔之前)依註冊順序執行。呼叫時需要系統管理員群組或 LocalSystem 的權限,且不能與 MOVEFILE_COPY_ALLOWED 併用,因此無法用於移動到不同的磁碟區。也應該掌握一點:函式的成敗指的是「預約的成敗」,並非實際置換的成敗。
- 在 MSI 安裝程式中,該如何溫和地處理「使用中的檔案」?
- Windows Installer 4.0 以降會自動與 Restart Manager 連動,預設會優先結束・重新啟動應用程式,而不是重新開機 OS。只要在封裝檔中加入 MsiRMFilesInUse 對話方塊,在完整 UI 安裝時就會向使用者提供「自動關閉應用程式並重新啟動」的選項。若在較舊的 Windows Installer 上執行,會回退到傳統的 FilesInUse 對話方塊,因此把兩者都加進去是常見做法。行為可以用 MSIRESTARTMANAGERCONTROL 等屬性控制,無聲安裝時則一律使用 Restart Manager,應用程式會被自動結束。只要應用程式端實作了 RegisterApplicationRestart 與 WM_QUERYENDSESSION 回應,就能實現更新後應用程式原樣重新啟動的「近乎不中斷的更新」。