MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

macOS 下讀取 OneDrive 串流模式檔案的終極解法:NSFileCoordinator

最近在處理一個自動化腳本的時候,踩到一個超級噁心的坑:OneDrive 在 macOS 上的串流模式(Files On-Demand),讓我所有的 CLI 工具全部陣亡。花了好幾天才搞清楚根本原因,順便也對 macOS 的 File Provider Extension 機制有了更深的理解。這篇把整個除錯過程和最終解法記錄下來,希望能幫到遇到同樣問題的人。

問題:Resource deadlock avoided

事情是這樣的。我需要寫一個腳本去自動讀取 OneDrive 目錄裡的檔案,做一些資料處理後再寫回去。聽起來很簡單對吧?

結果一跑就炸了:

1
2
$ cat ~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx
cat: Resource deadlock avoided

好,cat 不行,那試試 cp

1
2
$ cp ~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx /tmp/
cp: Resource deadlock avoided

dd 呢?

1
2
$ dd if=~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx of=/tmp/output
dd: Resource deadlock avoided

全死。連 Python 也不行:

1
2
f = open("/Users/xxx/Library/CloudStorage/OneDrive-xxx/some-file.xlsx", "rb")
# OSError: [Errno 11] Resource deadlock avoided

這個 Resource deadlock avoided 錯誤(errno 35, EDEADLK)在 Unix 世界裡通常跟 file lock 有關,但這裡的情況完全不是傳統意義上的 deadlock。

為什麼會這樣?

OneDrive 在 macOS 上使用的是 File Provider Extension 來實現串流模式。這是 Apple 從 macOS 11 開始大力推廣的機制,讓雲端儲存服務可以把檔案「虛擬化」——你在 Finder 裡看得到檔案,但實際上本機可能只有一個 placeholder,真正的內容要等你存取時才從雲端下載。

問題就在這裡:File Provider Extension 有自己的一套檔案存取協議。當你透過 Finder 雙擊開啟檔案,macOS 會自動走 File Provider 的協調機制,觸發下載、取得讀取權限,一切正常。但是!當你從 Terminal 用 catcp 這些 POSIX IO 工具去讀,它們是直接對 kernel 發 open() syscall,完全繞過了 File Provider 的協調機制

kernel 一看:「這個檔案歸 File Provider Extension 管,你沒有經過正規管道就想讀,不行。」於是直接回一個 EDEADLK,意思是「你這個存取方式會造成協調上的死結」。

這也解釋了為什麼所有 POSIX-based 的工具都會失敗——它們本來就不知道 File Provider Extension 的存在。

試過的各種 workaround(和它們失敗的原因)

在找到正解之前,我試了不少歪路:

1. open -g 指令

1
open -g ~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx

open 指令因為走的是 macOS 的 LaunchServices,所以它確實會觸發 File Provider 去下載檔案。加 -g 是在背景開啟,不會跳到前景。

問題是:它會用預設應用程式打開檔案(比如 Excel),而且你無法程式化地知道「下載完了沒」、「可以讀了沒」。就算加上 sleep 去等,也不穩定。在自動化腳本裡,這種做法完全不可靠。

2. brctl download

1
brctl download ~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx

brctl 是 macOS 內建的工具,可以觸發雲端檔案的下載。看起來很完美對吧?

然而坑就是:**brctl 只支援 iCloud Drive**。對,它是 Apple 專門給 iCloud 用的工具,OneDrive 的 File Provider Extension 完全不吃這一套。執行後不報錯,但也不會觸發任何下載。

3. open -a "Microsoft Excel"

1
open -a "Microsoft Excel" ~/Library/CloudStorage/OneDrive-xxx/some-file.xlsx

指定用 Excel 開啟,確實可以觸發下載。但問題一堆:需要安裝 Excel、Excel 開啟後會鎖定檔案、多個檔案處理時 Excel 的行為不可預測、而且在 headless 環境(比如 SSH 遠端連線)根本無法使用。

4. 透過 Finder AppleScript

也試過用 AppleScript 去操作 Finder 打開檔案,想藉此觸發下載。結果一樣不穩定,而且 Finder 的 AppleScript 支援本來就很有限,錯誤處理幾乎不存在。

真正的解法:NSFileCoordinator

翻了大量 Apple 文件和開發者論壇之後,我終於找到了正確答案:NSFileCoordinator

NSFileCoordinator 是 Apple 的 Foundation framework 裡提供的類別,專門用來協調多個 process 之間的檔案存取。關鍵是:File Provider Extension 就是透過 FileCoordination protocol 來管理檔案存取的。當你用 NSFileCoordinator 去讀寫檔案,它會自動跟 File Provider Extension 協調,觸發下載、取得存取權限,一切都走正規管道。

讀取檔案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Foundation

let url = URL(fileURLWithPath: "FILE_PATH")
let coordinator = NSFileCoordinator()
var error: NSError?

coordinator.coordinate(readingItemAt: url, options: [], error: &error) { newURL in
let data = try! Data(contentsOf: newURL)
try! data.write(to: URL(fileURLWithPath: "/tmp/output"))
print("OK - \(data.count) bytes")
}

if let error = error {
print("Coordination failed: \(error)")
}

重點解說:

  1. coordinate(readingItemAt:options:error:) 會跟 File Provider Extension 協調,告訴它「我要讀這個檔案」
  2. File Provider Extension 收到請求後,會觸發雲端下載(如果還沒下載的話)
  3. 下載完成後,closure 裡的 newURL 就是你可以安全讀取的路徑
  4. 在 closure 內部,用標準的 Data(contentsOf:) 讀取,完全不會遇到 deadlock

寫入檔案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Foundation

let dst = URL(fileURLWithPath: "DESTINATION_PATH")
let data = "Hello, OneDrive!".data(using: .utf8)!
let coordinator = NSFileCoordinator()
var error: NSError?

coordinator.coordinate(writingItemAt: dst, options: .forReplacing, error: &error) { newURL in
try! data.write(to: newURL)
print("Write OK")
}

if let error = error {
print("Coordination failed: \(error)")
}

寫入也是同樣的模式,只是把 readingItemAt 換成 writingItemAt,加上 .forReplacing option 表示要覆寫檔案。File Provider Extension 會處理好上傳同步的事情。

為什麼這個方法有效?

讓我把整個機制串起來:

  1. File Provider Extension 的設計哲學:macOS 的 File Provider 是一個 kernel-level 的檔案系統擴展,它要求所有對其管理的檔案的存取都必須透過 FileCoordination protocol 來協調。這不是 bug,是 by design。

  2. POSIX IO 的局限catcp、Python 的 open() 都是直接發 open() syscall,kernel 看到這是 File Provider 管理的檔案,但呼叫方沒有走 FileCoordination,所以直接拒絕。

  3. NSFileCoordinator 的角色:它是 Apple 提供的「正規管道」,會自動處理跟 File Provider Extension 的溝通、觸發下載、取得存取權限。用了它,kernel 就知道「這個存取是經過協調的」,自然放行。

  4. Finder 為什麼可以:Finder 內部就是用 NSFileCoordinator(或等效機制)來存取檔案的,所以雙擊開啟檔案時完全正常。

注意事項與進階技巧

實際用了一段時間之後,還有幾個值得注意的地方:

超時處理NSFileCoordinatorcoordinate 方法是同步阻塞的——它會一直等到 File Provider Extension 完成協調才返回。如果檔案很大(幾百 MB 的影片之類)或網路很慢,等待時間可能長到令人崩潰。在自動化腳本裡,建議搭配 DispatchSemaphore 設定超時機制:

1
2
3
4
5
6
7
8
9
10
11
12
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
coordinator.coordinate(readingItemAt: url, options: [], error: &error) { newURL in
defer { semaphore.signal() }
let data = try! Data(contentsOf: newURL)
try! data.write(to: dst)
}
}
if semaphore.wait(timeout: .now() + 120) == .timedOut {
print("Coordination timed out after 120 seconds")
exit(1)
}

批次處理的眉角:如果需要一次讀取大量檔案,同一個 NSFileCoordinator 實例可以重複使用,但每個檔案仍需獨立呼叫 coordinate。要注意的是 File Provider Extension 對並發請求數量有限制,太激進的並行處理會被 throttle,甚至直接回錯誤。我的經驗是保守一點比較穩——用 serial queue 依序處理,一次不超過五到十個檔案,跑起來最穩定。

不只是 OneDrive 的問題:這個坑不只存在於 OneDrive。任何使用 macOS File Provider Extension 的雲端服務都可能遇到同樣的狀況,包括 Dropbox 和 Google Drive(取決於它們的實作方式)。所以 NSFileCoordinator 的解法是通用的,學一次就能應付所有類似的情境。

判斷是否需要走 File Provider:有時候你的腳本需要同時處理本地檔案和雲端檔案。可以透過路徑前綴來判斷——File Provider 管理的檔案通常在 ~/Library/CloudStorage/ 底下。如果路徑包含這個前綴就走 NSFileCoordinator,否則用一般的 POSIX IO 就好,不需要每個檔案都繞遠路。

包裝成實用工具

知道原理後,把它包裝成 CLI 工具就很簡單了。你可以寫一個 Swift 小程式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// fpread.swift - 讀取 File Provider 管理的檔案
import Foundation

guard CommandLine.arguments.count >= 3 else {
print("Usage: fpread <source> <destination>")
exit(1)
}

let src = URL(fileURLWithPath: CommandLine.arguments[1])
let dst = URL(fileURLWithPath: CommandLine.arguments[2])
let coordinator = NSFileCoordinator()
var error: NSError?

coordinator.coordinate(readingItemAt: src, options: [], error: &error) { newURL in
do {
let data = try Data(contentsOf: newURL)
try data.write(to: dst)
print("OK - \(data.count) bytes written to \(dst.path)")
} catch {
print("Error: \(error)")
exit(1)
}
}

if let error = error {
print("Coordination error: \(error)")
exit(1)
}

編譯後就能在任何 shell script 裡使用:

1
2
swiftc fpread.swift -o fpread
./fpread ~/Library/CloudStorage/OneDrive-xxx/report.xlsx /tmp/report.xlsx

要批次處理的話,套個 find + xargs 就搞定了。終於可以在自動化腳本裡正常讀寫 OneDrive 的檔案了。

結語

這個問題讓我深刻體會到 macOS 在檔案系統層面的變化。隨著雲端儲存越來越普遍,Apple 透過 File Provider Extension 把「本地檔案」和「雲端檔案」的界線模糊化了——在 Finder 裡看起來一模一樣,但底層的存取機制完全不同。

這種設計對一般使用者來說是透明的,但對開發者和 power user 來說,就需要理解這些底層機制。特別是當你要寫自動化腳本、CI/CD pipeline、或任何需要程式化存取雲端檔案的場景,傳統的 POSIX IO 可能已經不夠用了。

NSFileCoordinator 不是什麼新東西,它在 macOS 上已經存在很久了,原本是用來協調不同 app 之間的檔案存取。但在 File Provider Extension 的時代,它有了全新的重要性。如果你的工作會碰到 macOS 上的雲端儲存整合,強烈建議花點時間研究一下 Apple 的 File ProviderNSFileCoordinator 文件,會省掉很多除錯的時間。