進度條停在 85%,到底是在下載、安裝,還是已經失敗了?
這次在 TencentCloud Octop 裏處理的,就是這樣一個在線升級問題。用户反饋 Windows 便攜版升級遲遲沒有完成。我用官方 1.0.2b4 包復現升級到 1.0.2b5 的過程,最後等到的不是新版,而是多次超時,目標目錄裏仍然是舊版本。
繼續查下去,我發現需要分開回答兩個問題:為什麼升級會重新安裝一批已經存在的依賴?即使安裝程序正常退出,又憑什麼確認升級真的完成了?
前一個問題影響用户要等多久,後一個問題決定頁面上的“成功”是否可信。最後的修改合併在 PR #1576。這篇想記錄的,是我怎樣把這兩個問題拆開,又把它們放回同一條升級流程裏。
不過,這次不是我一個人在處理升級問題。學弟也在修相鄰的一段鏈路。我們從差點重複修改,到把問題拆開、分工,再檢查兩份修改接到一起後的行為。對我來説,這也是這次貢獻裏很重要的一部分。
同樣是“升級後還是舊版”,先拆清我們各自負責什麼h2
剛開始看,兩邊遇到的現象很像:用户明明嘗試了升級,最後卻還是舊版本。但繼續查根因後,我們發現需要分成兩個問題,而不是往同一份修復裏不斷加東西。
| 問題 | 分工與對應修改 |
|---|---|
| 換了新版桌面程序,啓動器卻繼續使用舊運行時 | 學弟 MakerFlyFly 負責,跟蹤在 Issue #1560 和 PR #1561 |
| 程序內在線升級反覆安裝依賴、超時,升級結果也缺少可靠校驗 | 我負責,跟蹤在 Issue #1562 和 PR #1576 |
我們先對齊修改範圍。我把自己這邊重複的 Go 版本比較和啓動器改動拿掉,集中處理程序內安裝、uv 打包和升級結果校驗。啓動器已經傳入的 OCTOP_GREEN_PACKAGES 繼續沿用,讓升級仍然寫到應用真正使用的 packages 目錄,不額外發明一套接口。
分工清楚之後,還要檢查交界處。例如:VERSION.txt 仍寫着 b4,但程序內升級已經把實際包更新成 b6,此時再啓動內置 b5 的桌面程序,應該保留 b6,而不是把它換回 b5。這裏的 b6 是構造的測試版本,用來確認“在線升級”和“啓動器準備運行時”不會互相拆台。
學弟在啓動器側補了這類聯動用例,我也在 Python 側對齊了 Go 比較器的版本排序用例。兩份 PR 因此不只是各自修好一段,還要確認它們對版本先後、目標目錄和不降級行為有一致的理解。
卡住的不是進度條,而是一次重新開始的依賴安裝h2
在 Issue #1562 的排查裏,我沒有隻看界面上的百分比,而是用隔離的官方便攜包調用實際升級 worker,去看安裝日誌和最後留下的版本。
那次復現中,升級依次嘗試了五個軟件源,每個安裝過程都有 90 秒的超時限制。日誌裏反覆出現依賴收集,最後仍然失敗。界面的 85% 只是進度計時邏輯的上限,不能據此判斷它正卡在哪個安裝步驟。
便攜版把 Python 包放在自己的 packages 目錄中,升級時使用 pip install --target 寫回這個位置。我原本需要確認的是:目錄裏已經有依賴,pip 會不會直接用它們?
查到這裏,關鍵區別才清楚了:文件存在於目標目錄,不等於 pip 的依賴解析器會把它們當成當前環境中已安裝的包。 --target 走的安裝路徑會忽略已安裝狀態,因此一次只更新 Octop 的操作,也可能重新解析、下載並安裝整套依賴。
這也解釋了為什麼補一個 --upgrade-strategy only-if-needed 不夠。它沒有改變解析器看待這個目標目錄的方式。換一個軟件源,或者把等待時間調長,能緩解部分失敗,卻沒有解決“為什麼又從頭裝了一遍”的問題。
複用依賴之前,先證明這些依賴還能用h2
但另一個極端也不行:既然依賴都在目錄裏,直接加上 --no-deps,只更新 Octop,不就好了?
這樣確實可以少裝很多東西,可新版本可能新增依賴,也可能提高了版本要求。安裝得快,和安裝後的環境完整,是兩件事。
所以這次沒有把所有便攜版都改成無條件跳過依賴,而是保留了兩條路徑:
| 環境 | 安裝策略 |
|---|---|
| 能使用 uv 的便攜版 | 優先使用隨包提供的 uv,再考慮外部 uv |
| 沒有 uv 的舊便攜包 | 繼續支持 pip,符合條件時複用已有依賴,否則走完整依賴安裝 |
新打包的便攜版會帶上 uv,但舊包只更新 Octop 的 wheel,並不會憑空多出一個 uv。因此,舊包的 pip 路徑仍然要修,不能讓用户先手動換一整套環境。
這條 pip 快速路徑先下載 Octop 自身的 wheel,暫時不動目標目錄。接着讀取 wheel 的依賴信息,對照目標目錄中的包,檢查直接依賴、傳遞依賴、版本約束,以及 extras、環境標記和 Python 版本要求。
只有這些條件都滿足,才從本地 wheel 執行不聯網、不重新解析依賴的安裝:
先下载 Octop wheel → 检查已有依赖 → 本地安装 --no-index --no-deps如果依賴不滿足、元數據不完整,或者快速安裝後的校驗沒有通過,就回到完整安裝路徑。下載本身失敗時,則直接換下一個軟件源,目標目錄還沒有被修改。
我想保留的不是“一定走最快的路線”,而是:能確認安全時少做重複工作,確認不了時仍然有原來的安裝方式可以走。
超時要給修復留機會,也不能讓每次重試重新計時h2
排查時我也對照了 PR #1575 對超時和新進程版本校驗的處理。完整依賴安裝確實可能需要比 90 秒更長的時間,但如果每換一個軟件源,就重新給一整段很長的超時,用户還是不知道要等到什麼時候。
這次對舊包的 pip 路徑做了區分:單獨下載 wheel 和本地快速安裝通常各有 90 秒限制;一旦進入完整安裝回退,就啓用一個 900 秒的共享時間窗口。後續換源或安裝步驟使用剩餘時間,而不是每次重新獲得 900 秒。
這個窗口約束的是進入回退後的安裝嘗試,不是整個升級的總耗時上限。首次回退前的嘗試和最後的版本探測,還有各自的耗時。
安裝器的返回碼,不能替用户確認升級結果h2
前面的復現確實是超時失敗。繼續檢查代碼時,我還發現了另一處需要加固的判斷:安裝程序返回 0 後,如果沒有讀到可信的新版本,也不應該兜底報告成功。
這裏不能只看包的安裝元數據。目標目錄裏可能留下了較新的 dist-info,實際導入的代碼卻還是舊的;服務進程也可能一直在運行,內存裏的模塊不會因為磁盤上的文件被替換,就自動變成新版。
因此,安裝結束後的檢查放到一個新的 Python 解釋器裏執行:從目標目錄加載 Octop,處理 .pth,確認模塊確實來自這個目錄,再讀取代碼中的 __version__。它還會執行一次 CLI 的 --version,確認入口能正常運行;但用來判斷版本的是加載到的代碼,不是 CLI 打印的元數據版本。
最終要求也很直接:實際版本必須可讀取,比升級前更新;如果用户指定了版本,還必須與指定版本一致。探測最多嘗試三次,滿足條件才返回成功。
這也影響升級開始前的判斷:同版本重裝或降級請求應該先被拒絕,而不是等文件被修改後再發現。讀取目標目錄裏的實際版本,也能避免服務尚未重啓時,用內存中的舊版本做錯誤判斷。
驗證時,我關心的是文件最後變成了什麼h2
這類修改只驗證“命令拼得對不對”,還是不夠。它寫的是用户正在使用的目錄,安裝前後的實際狀態才是重點。
我在 PR 中記錄的驗證,主要圍繞下面幾種情況展開:
| 要確認的判斷 | 對應驗證 |
|---|---|
| 已有依賴滿足時,能否只更新 Octop | 用官方 b4 便攜包、真實 Python / pip 和 b5 wheel 跑升級 worker,已有 194 個依賴的版本保持不變 |
| 依賴已經不滿足時,會不會錯誤跳過安裝 | 構造已安裝 1.0、新版本要求 2.0 的依賴,確認回到完整安裝 |
| 元數據看起來很新,是否就算升級完成 | 檢查新元數據搭配舊代碼、模塊無法導入等情況,確認不能誤報成功 |
| 換源後會不會重新拿到完整等待時間 | 用模擬時鐘檢查完整安裝回退的共享時間窗口 |
第一項使用了本地 wheel 來源,也限制了依賴 wheel 的可用性,重點是確認快速路徑真的複用了已有依賴,不是換了個地方把依賴又下載了一遍。
這次也沒有把升級系統全部重做。Launcher 重新安裝舊版的問題由另一條修改處理;完整安裝回退仍然是在現有目標上修復,不代表最終失敗時可以自動回滾整套便攜包。優化哪條路徑、成功由什麼證明、失敗後保留什麼能力,需要分別講清楚。
“成功”的條件,應該落在用户真正使用的東西上h2
這次最讓我重新理解的,是幾個看起來很接近、實際卻不同的狀態。
目錄裏有依賴,不等於解析器會複用;安裝元數據寫着新版,不等於導入的代碼就是新版;進程正常退出,也不等於用户下一次啓動就能用上它。
經歷這次排查後,再看到一個操作返回成功,我會多問一句:它證明的是哪個環節成功了?離用户真正要的結果,還差哪一步?
快速路徑也是一樣。少裝一批依賴並不難,難的是把“什麼時候可以少裝”判斷清楚,再給不能走快速路徑的環境留下退路。我意識到,性能優化不只是減少工作量,也是在補齊允許省略這些工作的條件。
以後再遇到“完成”“成功”這樣的狀態,我會更願意沿着它往下查一層:真正要交到用户手裏的東西,到底有沒有變成我們承諾的樣子。
把問題一起修好,比搶先把它做成自己的 PR 更重要h2
這次也是我第一次和這位學弟一起,分工修復一個牽涉不同鏈路的問題。
坦白説,一開始我也有過想和他搶進度的念頭:能不能趕在前面,把這一整塊都放進自己的 PR?但想到他已經花了時間排查、寫了修復,我再把這些工作覆蓋掉,就覺得這樣做不太厚道。而且我們面對的明明是一個還沒修完整的問題,沒必要先把彼此當成競爭對手。
我當時甚至有點不敢找他,怕他覺得我是在搶他的工作。後來還是決定把話説開:你負責啓動器,我負責程序內升級,重複的部分我拿掉,我們把各自的修改做成能一起合併、一起運行的方案。
真正開始合作後,我發現要做的事情並沒有變少。我們需要重新劃分 Issue 的範圍,確認誰改哪段代碼,沿用已有接口,再補上兩邊接起來時的測試。有些已經寫過的東西要刪掉,有些不屬於自己 PR 的邊界,也要一起確認。只是這些時間終於花在了互相補齊上,而不是各做一套、再擔心誰覆蓋了誰。
這次讓我意識到,別人先做了一部分,並不意味着我的貢獻空間就少了一塊。把那部分接住,繼續解決剩下的問題,同樣是有價值的工作。開源貢獻當然會留下各自的提交記錄,但一個問題能不能真正解決,並不由某個人的 PR 包含了多少內容決定。
最後,我們分別完成了啓動器和程序內升級這兩部分修復。我挺慶幸當時還是去找他聊了,也願意把自己已經做過的一部分拿掉。比起搶先把整個問題做成“我的 PR”,這次能和學弟一起把鏈路接好,是我更想記下來的一件事。