內部機制
本指南說明 lip 的底層工作方式。
流程
lip setup、lip install、lip uninstall 和 lip update 執行相同的步驟:
- 狀態文件:從
.lip/state.json加載該工作區的狀態。沒有該文件的工作區沒有已安裝的包,除非它有 lip v0.x 的tooth_lock.json,此時改為讀取後者(參見 lip v0.x 的工作區)。 - 環境:確定運行端和平臺(參見環境)。
- 解析器:把每個來源轉換為一個候選序列:它所指向的歸檔,最新版本在前,每個都帶有所請求變體在該環境下的條目。
- 依賴圖求解器:為每個請求和每個依賴各選出一個候選,使每個身份只出現一次,且沒有兩個 mod 的 mod 名稱相同。
- 規劃:規劃按安裝順序的逆序卸載離開集合的包、再按依賴優先的順序安裝進入集合的包,每一步都基於之前各步完成後的工作區。規劃會下載所有要寫入的歸檔和資源,並檢查一切可能失敗的情形:文件所有權、
preserve、路徑、放置規則的src、mod 的 LeviLaminamanifest.json中的 mod 名稱,以及(當操作寫入或刪除文件時)mods是否為鏈接。早期格式的tooth.json在解析時已被完整地轉換為包文件(參見早期格式),因此所有包都由同一個安裝器處理,且只按lip.toml的規則處理。 - 執行:當操作要為 Windows 平臺運行鉤子命令,或要設置 Windows 版服務器時,先安裝它們需要的系統組件(參見系統依賴)。當操作寫入或刪除文件時,若
mods不存在則創建它(參見安裝依賴),然後運行規劃好的鉤子與文件修改。匹配preserve的文件是否保留,取決於即將寫入它時(在此之前的鉤子命令運行之後)它是否存在;包只擁有它實際寫入的文件。 - 狀態文件:所有步驟成功之後,保存一次狀態文件。
操作要麼全部完成,要麼全部不做(lip-workspace(7) "Atomicity")。第 6 步之前的任何失敗都不會修改工作區。執行時某一步失敗(文件無法寫入、鉤子命令以非零碼退出,或按下 Ctrl+C),lip 會撤銷該操作的全部修改,狀態文件保持原樣。鉤子命令所做的事無法撤銷:post_install 或 pre_uninstall 命令失敗時,操作同樣失敗並撤銷 lip 的文件修改,但已運行命令的效果不會被撤銷。
包解析
lip 不使用中心化的包註冊表或模塊代理。像 github.com/user/repo 這樣的包 ID 直接對應 git 倉庫 https://github.com/user/repo.git,lip 通過內置的 git smart-HTTP 協議(第 2 版)客戶端與其通信。無需安裝 git 命令行工具,lip 也從不調用它。
- 版本:lip 通過
ls-refs列出倉庫標籤(只請求refs/tags/v*)。可用版本即名稱恰為v<SemVer 2.0.0>的標籤(例如v1.2.3);其他標籤,包括v1.2或v01.2.3這類寬鬆寫法,都會被忽略。附註標籤會被解析到其指向的提交。 - git 引用:帶 git 引用的來源只有一個候選。
@branch:<name>和@tag:<name>通過ls-refs恰好請求refs/heads/<name>或refs/tags/<name>來解析(附註標籤會被解析到其提交),每條命令只解析一次;@rev:<commit>無需請求,提交按其 ID 獲取。不接受縮寫的提交 ID:解析縮寫需要倉庫的全部歷史,而 lip 從不下載全部歷史。 - 惰性候選:倉庫的候選從最新標籤開始按需產出,因此 lip 只會打開求解器實際需要的標籤。
- lip.toml:讀取某個版本的
lip.toml時,lip 只獲取該標籤提交的樹對象(使用filter blob:none的淺層部分獲取,不傳輸任何文件內容),然後只下載這一個文件(沒有lip.toml時則為tooth.json)。因此無論倉庫多大,解析依賴時每個版本只需幾 KB 流量。 - 候選:只有當某個版本的
lip.toml合法、其id是該倉庫的路徑、其version等於標籤版本,且所請求的變體有對應工作區運行端和平臺的條目時,該版本才是候選。否則 lip 繼續查看下一個版本。 - 安裝:只有安裝器需要時才下載文件:
self資源從包自身歸檔中放置的文件,以及zip和mcmod資源;後者在包安裝時下載,與其他 HTTP 下載相同。 - 完整性:每個下載的 git 對象都會按其 git 對象 ID(SHA-1)校驗。ZIP 歸檔和資源會被檢查是否為有效的 ZIP 歸檔;其內容的校驗和既不記錄也不檢查。
- 緩存:git 歸檔不會被緩存:每次只獲取操作需要讀取的文件。通過 HTTP(S) 下載的內容一律在緩存中存為
http-<URL 的 SHA-256>;不是有效 ZIP 歸檔的緩存文件會被重新下載。下載內容先寫入緩存目錄中的.tmp文件,完成後再重命名到位;lip 不向系統臨時目錄寫入任何內容。標籤列表和樹對象僅在單次命令執行期間緩存在內存中;每次運行都會重新查詢標籤,因此被移動的標籤會立即生效。 - 代理:若
github_proxy設置為 URL,對github.com倉庫的請求會先經由該代理發送;若代理請求失敗,lip 會直接向github.com重試。從github.comURL 下載發佈資源時遵循同樣的規則。
只有當某個版本的標籤仍存在於倉庫中時,該版本才能被安裝。目前只支持可通過 HTTPS 訪問的公開倉庫。比較包 ID 時不區分 ASCII 大小寫。
git 引用所給的分支、提交或名稱不是 v<SemVer> 的標籤沒有標籤版本:使用該處 lip.toml 的 version。版本標籤處早期格式的 tooth.json 按標籤版本安裝,而不論其 version 寫的是什麼。
求解
- 固定:已安裝的包按原樣進入求解器,從狀態文件重建,無需訪問網絡。若這樣無法滿足請求,lip 會只固定顯式安裝的包重新求解,此時依賴可以改變版本;求解時優先嚐試它們已安裝的版本,因此只有必須改變的依賴才會改變。
lip update從一開始就這樣求解,因此已安裝的依賴不會妨礙被更新的包取得最新版本。 - 優先級:靠前的候選優先:倉庫的最新版本。
- 順序:先安裝依賴;依賴成環的包按身份排序。
- 清理:不再被任何顯式安裝的包需要的依賴會被卸載。
- 同一個包:只有 lip 解析出的包在身份、版本或提交上不同時,才會替換已安裝的包;來自 URL 或本地路徑的包沒有提交。
- 按狀態文件重裝:當 lip 再次需要已安裝包的文件時,會用狀態文件中記錄的包文件安裝該包,而不是重新解析該包:對於來自 git 倉庫的包,從記錄的提交安裝;否則從其來源現在指向的歸檔安裝。
包文件校驗
lip.toml 和 config.toml 分別由以 TOML 編寫的 JSON Schema protocol/lip.schema.toml 和 protocol/config.schema.toml 描述,狀態文件由 JSON Schema protocol/state.schema.json 描述。lip v0.x 的文件曾由原 specs/schemas/ 目錄中的 JSON Schema 描述,legacy/v<格式>/ 下:tooth.json 格式 1、2、3 為 tooth.schema.yaml,tooth_lock.json 為 tooth_lock.schema.yaml,liprc.json 為 liprc.schema.yaml(均為格式 3)。
- 校驗:除 schema 之外,lip 還檢查 lip.toml(5) 的要求:合法的包 ID、版本、依賴鍵和範圍、運行端和平臺,同一變體中沒有兩個條目對應相同的運行端和平臺,每個條目至多一個
mcmod資源,以及合法的 glob。lip.toml無效的歸檔不是包,不產出候選,lip 會報告原因。 - lip v0.x 的文件:沒有
lip.toml的歸檔從其格式 1、2 或 3 的tooth.json讀取:格式 1 和 2 會在內存中轉換為格式 3,格式 3 再針對環境完整地轉換為包文件(lip-compatibility(7) "tooth.json");如需在包倉庫中用lip.toml取代tooth.json,請在倉庫中運行lip pkg migrate("lip pkg migrate")。tooth_lock.json會被自動遷移為.lip/state.json("tooth_lock.json"),liprc.json會被自動遷移為config.toml("liprc.json")。
工作區狀態
lip 在保存於工作區中 .lip/state.json 的狀態文件裡維護已安裝包狀態(參見狀態文件),記錄:
- 用戶顯式請求的包與隱式依賴;
- 安裝每個包時所用的包文件(因而包括其精確版本)、其來源,以及來自 git 倉庫的包所在的提交;
- 每個包擁有的文件;
- 工作區的運行端和平臺。
為保證操作原子性而寫入的文件都是隱藏文件,並位於目標旁邊:狀態文件先寫入 .lip/state.json.tmp,再重命名覆蓋 .lip/state.json;操作要覆蓋或刪除的文件先在同一目錄中重命名為 .<name>.bak(若該名稱已被佔用,則為 .<name>.bak1、.<name>.bak2……)。狀態文件保存後刪除這些備份(無法刪除的備份,例如被某個程序打開的,會保留並在警告中列出),操作失敗時則改回原名。卸載後變空的目錄也在此時刪除。在 Windows 上,這些文件還會被設置隱藏屬性。
下面的日誌和工作區鎖與狀態文件一起保存在 .lip 中,因此隨工作區移動:移動或複製後的工作區會帶走被中斷的操作,日誌中記錄的是相對於工作區的路徑。它們的格式定義在 protocol/journal.md 和 protocol/lip-workspace.md 中,其他程序也可以參與。
- 日誌:操作修改工作區期間,lip 在做出每項修改之前,先把它記錄到
.lip/journal中。若 lip 被終止,下一次在該工作區中運行的lip setup、install、uninstall、update或run會先讀取日誌:撤銷被中斷的操作(恢復備份)並報告;若該操作已進行到保存狀態文件的階段,則刪除備份以完成它。試運行(dry run)不獲取鎖,只報告日誌的存在。 - 同一時間只有一個操作:操作期間獨佔打開
.lip/lock(在 Linux 和 macOS 上為獨佔flock;該文件會保留)。對同一工作區的第二個lip setup、install、uninstall或update會立即失敗,並提示"Another process is changing the workspace";請在第一個操作結束後重新運行。lip run只在啟動服務器之前持有該鎖。
系統依賴
平臺以 win 開頭的工作區中的程序(鉤子命令和 Windows 版服務器,無論是否帶 LeviLamina)需要系統提供兩樣東西,由 lip 自行安裝(lip-workspace(7) "System dependencies"):
- Wine,在 Linux 和 macOS 上:lip 的版本指定的一個 Wine 構建,只下載一次,解壓到數據目錄的
wine/中,由所有工作區共用:x64 的 Linux 上為 Kron4ek/Wine-Builds(amd64-wow64),macOS 上為 Gcenx/macOS_Wine_builds(wine-devel),在 arm64 上由 Rosetta 2 運行。lip 從不運行其他的wine,例如PATH中的。 - Microsoft Visual C++ 可再發行組件包(x64):當註冊表中
HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64下沒有值Installed= 1 時,lip 下載vc_redist.x64.exe並以/install /quiet /norestart運行它:在 Windows 上以管理員身份運行,可能彈出用戶賬戶控制提示;在其他系統上通過 Wine 在工作區的 Wine 前綴中運行,並用wine reg query讀取該前綴的註冊表。
每個下載的 URL 和 SHA-256 都寫在 lip 中,使用前會先校驗:SHA-256 不符的下載會失敗,既不會被解壓也不會被運行。下載寫入數據目錄,用完即刪除;Wine 先解壓到一個 .tmp 目錄,再重命名到位,因此不會運行解壓到一半的構建。同時進行安裝的多個 lip 進程通過數據目錄中的 lock 文件相互等待。
lip 在會運行鉤子命令的操作或構建為 windows 的 lip setup 開始修改工作區之前安裝它們,因此此處的失敗會讓工作區保持原樣;在 lip run 啟動 Windows 版服務器之前也會這樣做。Linux 版服務器兩者都不需要。若一切都已安裝,這隻需查詢一次註冊表。
守護進程
除 lip daemon 外,每個命令以及交互界面都由用戶的守護進程執行,它是以唯一參數 --daemon 啟動的 lip 進程(lip-daemon(1))。你啟動的 lip 是它的客戶端:
- 客戶端連接守護進程:在 Windows 上通過只有你的用戶能打開的命名管道
\\.\pipe\lip-<SID>;在 Linux 和 macOS 上通過 Unix 域套接字$XDG_RUNTIME_DIR/lip/daemon.sock,未設置XDG_RUNTIME_DIR時為狀態目錄中的daemon.sock,其模式為0600,所在目錄的模式為0700。 - 沒有進程監聽時,客戶端用自己的可執行文件啟動守護進程,使其脫離終端且不帶客戶端的標準流,並在守護進程開始監聽後連接。兩個客戶端同時啟動守護進程時,最終只有一個:第二個守護進程無法創建端點,隨即退出。
- 守護進程屬於另一個 lip 版本或運行另一個可執行文件時(例如
lip self-update之後),客戶端請求它退出並啟動新的守護進程。正在執行命令或運行託管服務器的守護進程會繼續運行;此時客戶端自己執行它的命令,但lip run --managed仍由舊的守護進程執行。 - 客戶端發送命令,以及它的工作目錄、環境變量、標準流是否為終端、終端的大小和顏色。守護進程據此執行命令,與
lip自己執行時相同:不同客戶端的命令並行執行,各自使用自己的目錄、環境、代理和失敗報告。 - 守護進程只在命令需要時讀取客戶端的標準輸入,例如
lip run的服務器的輸入,並把命令及其運行的程序輸出的內容發回。客戶端收到的信號(Ctrl+C、SIGTERM、關閉終端)會被轉發:Ctrl+C 取消並撤銷正在修改工作區的操作,並請求lip run的服務器停止。服務器運行時客戶端退出,服務器會被請求停止;但託管服務器(lip run --managed)由守護進程繼續運行,客戶端只是與它斷開連接。客戶端以命令的退出碼退出。
界面(不帶參數的 lip)也以同樣的方式執行。守護進程繪製界面,客戶端把繪製的內容寫到終端;客戶端讀取你按下的鍵並轉發,終端大小改變時也會轉發新的大小。你在每個終端中打開的界面各自獨立,而工作區列表(狀態目錄中的 workspaces.json)和從界面啟動的服務器由守護進程保存,並由所有界面共享。從界面啟動的服務器是託管服務器:退出界面或關閉其終端後它們繼續運行。
守護進程無法啟動或無法連接時,客戶端會說明這一點並自己執行命令或運行界面。守護進程在一段時間既沒有連接也沒有運行託管服務器後、在 lip self-update 替換了 lip 之後,或執行 lip daemon stop 時退出。
關於規範的說明
lip 只管理服務端工作區:它從不從工作區判斷運行端,而是總是使用 server(lip(1) "Environment")。客戶端工作區交由實現同一協議的啟動器(如 LeviLauncher)處理。
lip 實現 lip 協議。