Skip to content

Commit 0b13731

Browse files
fix: better handling of symlink (Windows,...) (#1272)
Add finer handling of symlink as it is not supported on Windows. Fixes #1268 --------- Signed-off-by: Jeff MAURY <[email protected]> Co-authored-by: coyotte508 <[email protected]>
1 parent c001002 commit 0b13731

File tree

5 files changed

+138
-5
lines changed

5 files changed

+138
-5
lines changed

packages/hub/src/lib/download-file-to-cache-dir.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Stats } from "node:fs";
77
import { getHFHubCachePath, getRepoFolderName } from "./cache-management";
88
import { toRepoId } from "../utils/toRepoId";
99
import { downloadFileToCacheDir } from "./download-file-to-cache-dir";
10+
import { createSymlink } from "../utils/symlink";
1011

1112
vi.mock("node:fs/promises", () => ({
1213
writeFile: vi.fn(),
@@ -21,6 +22,10 @@ vi.mock("./paths-info", () => ({
2122
pathsInfo: vi.fn(),
2223
}));
2324

25+
vi.mock("../utils/symlink", () => ({
26+
createSymlink: vi.fn(),
27+
}));
28+
2429
const DUMMY_REPO: RepoId = {
2530
name: "hello-world",
2631
type: "model",
@@ -196,7 +201,7 @@ describe("downloadFileToCacheDir", () => {
196201
expect(vi.mocked(lstat).mock.calls[0][0]).toBe(expectedBlob);
197202

198203
// symlink should have been created
199-
expect(symlink).toHaveBeenCalledOnce();
204+
expect(createSymlink).toHaveBeenCalledOnce();
200205
// no download done
201206
expect(fetchMock).not.toHaveBeenCalled();
202207

@@ -283,6 +288,6 @@ describe("downloadFileToCacheDir", () => {
283288
// 2. should rename the incomplete to the blob expected name
284289
expect(rename).toHaveBeenCalledWith(incomplete, expectedBlob);
285290
// 3. should create symlink pointing to blob
286-
expect(symlink).toHaveBeenCalledWith(expectedBlob, expectPointer);
291+
expect(createSymlink).toHaveBeenCalledWith(expectedBlob, expectPointer);
287292
});
288293
});

packages/hub/src/lib/download-file-to-cache-dir.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { getHFHubCachePath, getRepoFolderName } from "./cache-management";
22
import { dirname, join } from "node:path";
3-
import { writeFile, rename, symlink, lstat, mkdir, stat } from "node:fs/promises";
3+
import { writeFile, rename, lstat, mkdir, stat } from "node:fs/promises";
44
import type { CommitInfo, PathInfo } from "./paths-info";
55
import { pathsInfo } from "./paths-info";
66
import type { CredentialsParams, RepoDesignation } from "../types/public";
77
import { toRepoId } from "../utils/toRepoId";
88
import { downloadFile } from "./download-file";
9+
import { createSymlink } from "../utils/symlink";
910

1011
export const REGEX_COMMIT_HASH: RegExp = new RegExp("^[0-9a-f]{40}$");
1112

@@ -107,7 +108,7 @@ export async function downloadFileToCacheDir(
107108
// shortcut the download if needed
108109
if (await exists(blobPath)) {
109110
// create symlinks in snapshot folder to blob object
110-
await symlink(blobPath, pointerPath);
111+
await createSymlink(blobPath, pointerPath);
111112
return pointerPath;
112113
}
113114

@@ -127,6 +128,6 @@ export async function downloadFileToCacheDir(
127128
// rename .incomplete file to expect blob
128129
await rename(incomplete, blobPath);
129130
// create symlinks in snapshot folder to blob object
130-
await symlink(blobPath, pointerPath);
131+
await createSymlink(blobPath, pointerPath);
131132
return pointerPath;
132133
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, test, expect, vi, beforeEach } from "vitest";
2+
import * as fs from "node:fs/promises";
3+
import { createSymlink } from "./symlink";
4+
import * as path from "node:path";
5+
import type { FileHandle } from "node:fs/promises";
6+
7+
vi.mock("node:fs/promises", () => ({
8+
rm: vi.fn(),
9+
symlink: vi.fn(),
10+
rename: vi.fn(),
11+
copyFile: vi.fn(),
12+
mkdir: vi.fn(),
13+
mkdtemp: vi.fn(),
14+
open: vi.fn(),
15+
}));
16+
17+
vi.mock("node:os", () => ({
18+
platform: vi.fn(),
19+
}));
20+
21+
describe("createSymlink", () => {
22+
const src = "/path/to/src";
23+
const dst = "/path/to/dst";
24+
25+
beforeEach(() => {
26+
vi.resetAllMocks();
27+
vi.mocked(fs.mkdtemp).mockImplementation(async (prefix) => `${prefix}/temp`);
28+
vi.mocked(fs.open).mockResolvedValue({ close: vi.fn() } as unknown as FileHandle);
29+
});
30+
31+
test("should remove existing destination", async () => {
32+
await createSymlink(dst, src);
33+
expect(fs.rm).toHaveBeenCalledWith(dst);
34+
});
35+
36+
describe("symlink not supported (Windows)", () => {
37+
beforeEach(() => {
38+
vi.mocked(fs.symlink).mockRejectedValue(new Error("Symlink not supported"));
39+
});
40+
41+
test("should copyfile", async () => {
42+
await createSymlink(dst, src);
43+
expect(fs.copyFile).toHaveBeenCalledWith(path.resolve(src), path.resolve(dst));
44+
});
45+
46+
test("should rename file if new_blob is true", async () => {
47+
await createSymlink(dst, src, true);
48+
expect(fs.rename).toHaveBeenCalledWith(path.resolve(src), path.resolve(dst));
49+
});
50+
});
51+
52+
describe("symlink supported", () => {
53+
test("should symlink", async () => {
54+
await createSymlink(dst, src);
55+
expect(fs.symlink).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
56+
});
57+
58+
test("should symlink if new_blob is true", async () => {
59+
await createSymlink(dst, src, true);
60+
expect(fs.symlink).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
61+
});
62+
});
63+
});

packages/hub/src/utils/symlink.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Heavily inspired by https://github.com/huggingface/huggingface_hub/blob/fcfd14361bd03f23f82efced1aa65a7cbfa4b922/src/huggingface_hub/file_download.py#L517
3+
*/
4+
5+
import * as fs from "node:fs/promises";
6+
import * as path from "node:path";
7+
import * as os from "node:os";
8+
9+
function expandUser(path: string): string {
10+
if (path.startsWith("~")) {
11+
return path.replace("~", os.homedir());
12+
}
13+
return path;
14+
}
15+
16+
/**
17+
* Create a symbolic link named dst pointing to src.
18+
*
19+
* By default, it will try to create a symlink using a relative path. Relative paths have 2 advantages:
20+
* - If the cache_folder is moved (example: back-up on a shared drive), relative paths within the cache folder will
21+
* not break.
22+
* - Relative paths seems to be better handled on Windows. Issue was reported 3 times in less than a week when
23+
* changing from relative to absolute paths. See https://github.com/huggingface/huggingface_hub/issues/1398,
24+
* https://github.com/huggingface/diffusers/issues/2729 and https://github.com/huggingface/transformers/pull/22228.
25+
* NOTE: The issue with absolute paths doesn't happen on admin mode.
26+
* When creating a symlink from the cache to a local folder, it is possible that a relative path cannot be created.
27+
* This happens when paths are not on the same volume. In that case, we use absolute paths.
28+
*
29+
* The result layout looks something like
30+
* └── [ 128] snapshots
31+
* ├── [ 128] 2439f60ef33a0d46d85da5001d52aeda5b00ce9f
32+
* │ ├── [ 52] README.md -> ../../../blobs/d7edf6bd2a681fb0175f7735299831ee1b22b812
33+
* │ └── [ 76] pytorch_model.bin -> ../../../blobs/403450e234d65943a7dcf7e05a771ce3c92faa84dd07db4ac20f592037a1e4bd
34+
*
35+
* If symlinks cannot be created on this platform (most likely to be Windows), the workaround is to avoid symlinks by
36+
* having the actual file in `dst`. If it is a new file (`new_blob=True`), we move it to `dst`. If it is not a new file
37+
* (`new_blob=False`), we don't know if the blob file is already referenced elsewhere. To avoid breaking existing
38+
* cache, the file is duplicated on the disk.
39+
*
40+
* In case symlinks are not supported, a warning message is displayed to the user once when loading `huggingface_hub`.
41+
* The warning message can be disabled with the `HF_HUB_DISABLE_SYMLINKS_WARNING` environment variable.
42+
*/
43+
export async function createSymlink(dst: string, src: string, new_blob?: boolean): Promise<void> {
44+
try {
45+
await fs.rm(dst);
46+
} catch (_e: unknown) {
47+
/* empty */
48+
}
49+
const abs_src = path.resolve(expandUser(src));
50+
const abs_dst = path.resolve(expandUser(dst));
51+
52+
try {
53+
await fs.symlink(abs_dst, abs_src);
54+
} catch (_e: unknown) {
55+
if (new_blob) {
56+
console.info(`Symlink not supported. Moving file from ${abs_src} to ${abs_dst}`);
57+
await fs.rename(abs_src, abs_dst);
58+
} else {
59+
console.info(`Symlink not supported. Copying file from ${abs_src} to ${abs_dst}`);
60+
await fs.copyFile(abs_src, abs_dst);
61+
}
62+
}
63+
}

packages/hub/vitest-browser.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default defineConfig({
55
exclude: [
66
...configDefaults.exclude,
77
"src/utils/FileBlob.spec.ts",
8+
"src/utils/symlink.spec.ts",
89
"src/lib/cache-management.spec.ts",
910
"src/lib/download-file-to-cache-dir.spec.ts",
1011
"src/lib/snapshot-download.spec.ts",

0 commit comments

Comments
 (0)