Skip to content

Commit a97508e

Browse files
committed
stop basing the ignore mechanism on a fixed time frame
1 parent 6738a44 commit a97508e

10 files changed

+937
-85
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ Logs to console instead of files.
4242
- Tray icon DPI nonsense: I tried to attach a [manifest](https://learn.microsoft.com/en-us/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process) to both `pkg` and `node:sea` .exes using both `mt` and `rcedit`. The `pkg` exe shrinks by 4 MB and doesn't work any more. With the `node:sea` exe, `rcedit` freezes. `mt` works but would require a environment variable override: "Node.js is only supported on Windows 8.1, Windows Server 2012 R2, or higher. Setting the NODE_SKIP_PLATFORM_CHECK environment variable to 1 skips this check, ...". Since it's not possible to bake environment variables into the .exe with `node:sea`, this is obviously not acceptable either. The only thing I can think of to resolve this would be to create a non-node wrapper that runs the .exe. Or maybe `bun` or `deno` become a viable alternative at some point (As of October 2024, I don't consider either of them anywhere near production ready. `bun` can't handle windows paths, at least in with projects like this one. `deno` still [doesn't clean up unneeded npm packages](https://github.com/denoland/deno/issues/21261).)
4343
- Multiple strategies are needed to prevent infinite loops:
4444
1. Whenever we do something on S3, it triggers SNS and we would repeat the same thing locally that we just did on S3, which would result in: Syncing to S3 => getting SNS => Syncing to S3 => infinite loop. To avoid this, the last modified date is synced after uploading/downloading and checked before uploading/downloading.
45-
2. Syncing timestamps triggers chokidar. For that, there is `ignoreFiles` in global state.
46-
3. Allowing frequent operations on the same file (which Cryptomator does), is done using the following: All local operations are debounced. If even with debouncing, the same file keeps changing without regularly changing in size, the client exits at some point because then it's probably a bug.
45+
2. Whenever a file is manipulated locally, it is added to `ignoreMaps`. Which makes it so that when the file watcher triggers because of what the client does rather than what the user does, that trigger is ignored.
46+
3. Allowing frequent operations on the same file (which Cryptomator does), is achieved using the following: All local operations are debounced. If even with debouncing, the same file keeps changing without regularly changing in size, the client exits at some point because then it's probably a bug.
4747

4848
- `tsimp` fails without providing an error message, `tsx` works. 🤷‍♀️
4949

client/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
"dev": "tsx . debug",
1515
"dev:cli": "tsx . cli",
1616
"silenceexe": "create-nodew-exe s3-smart-sync.exe s3-smart-sync.exe",
17-
"playground": "tsx ./playground.ts"
17+
"playground": "tsx ./playground.cts"
1818
},
1919
"dependencies": {
20-
"@aws-sdk/client-s3": "^3.701.0",
20+
"@aws-sdk/client-s3": "^3.758.0",
2121
"@aws-sdk/lib-storage": "^3.758.0",
2222
"@s3-smart-sync/shared": "workspace:*",
2323
"auto-launch": "^5.0.6",
File renamed without changes.

client/src/fileWatcher.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export enum FileOperationType {
1212

1313
export const UNIGNORE_DURATION = 200; // short because it only has to cover: end of operation -> file watcher trigger -> ignore that call
1414
export const WATCHER_DEBOUNCE_DURATION = 500;
15-
export const IGNORE_CLEANUP_DURATION = WATCHER_DEBOUNCE_DURATION * 2;
15+
export const IGNORE_CLEANUP_DURATION = 12 * 60 * 60 * 1000; // presumably, not even a large file transfer lasts longer than this. And this is only an emergency measure anyway.
1616

1717
let watcher: FSWatcher | undefined;
1818
const ignoreMaps = {
@@ -26,25 +26,46 @@ export function ignore(fileOperationType: FileOperationType, filePath: string) {
2626
filePath.endsWith(path.sep)
2727
? filePath.slice(0, -path.sep.length)
2828
: filePath,
29-
Date.now(),
29+
Date.now() + 1000000,
3030
);
3131
}
3232

33+
export function resetIgnoreMaps() {
34+
logger.debug("Resetting ignore maps due to connection loss");
35+
ignoreMaps[FileOperationType.Remove].clear();
36+
ignoreMaps[FileOperationType.Sync].clear();
37+
}
38+
3339
export function unignore(
3440
fileOperationType: FileOperationType,
3541
filePath: string,
3642
) {
43+
// logger.debug(
44+
// `unignore: ${fileOperationType} ${filePath} ${JSON.stringify(
45+
// Array.from(ignoreMaps[fileOperationType].entries()),
46+
// )}`,
47+
// );
3748
setTimeout(() => {
3849
ignoreMaps[fileOperationType].delete(filePath);
3950
}, UNIGNORE_DURATION);
4051
}
4152

4253
function shouldIgnore(fileOperationType: FileOperationType, filePath: string) {
54+
// logger.debug(
55+
// `shouldIgnore: ${fileOperationType} ${filePath} ${JSON.stringify(
56+
// Array.from(ignoreMaps[fileOperationType].entries()),
57+
// )}`,
58+
// );
4359
const timestamp = ignoreMaps[fileOperationType].get(filePath);
4460
if (!timestamp) return false;
4561

4662
// If the ignore entry is older than IGNORE_CLEANUP_DURATION, it is probably fair to assume that although we handle errors and call unignore(), something unexpected must have happened and this is a stale entry.
4763
if (Date.now() - timestamp > IGNORE_CLEANUP_DURATION) {
64+
logger.error(
65+
`Had to clean up ignore for a ${
66+
fileOperationType === FileOperationType.Sync ? "sync" : "remove"
67+
} operation for ${filePath}.`,
68+
);
4869
ignoreMaps[fileOperationType].delete(filePath);
4970
return false;
5071
}

client/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ async function main() {
104104
async function removeFile(localPath: string) {
105105
const preliminaryKey = await convertAbsolutePathToKey(localPath);
106106

107-
// because the file was already deleted locally, we don't know whether it was a directory
107+
// Because the file was already deleted locally, we don't know whether it was a directory
108+
// (We could pass through the info from chokidar but that would be messy architecturally)
108109
let isDirectory: boolean | undefined;
109110
try {
110111
await getLastModified(preliminaryKey + "/");

client/src/s3Operations.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,12 @@ export async function upload(localPath: string, key: string) {
176176
Bucket: S3_BUCKET,
177177
Key: key,
178178
Body: key.endsWith("/") ? "" : createReadStream(localPath),
179-
// Hopefully will be optional in the future: https://github.com/aws/aws-sdk-js-v3/issues/6922
180-
ChecksumAlgorithm: "CRC32",
181179
},
182180
}).done();
181+
logger.info(`Uploaded: ${key}`);
183182

184183
// We have to sync timestamps to avoid redundant, potentially infinite, operations.
185184
await syncLastModified(localPath, await getLastModified(key));
186-
logger.info(`Uploaded: ${key}`);
187185
}
188186

189187
export async function upToDate(key: string) {

client/src/setUpWebsocket.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { getErrorMessage } from "@s3-smart-sync/shared/getErrorMessage.js";
44
import { logger } from "@s3-smart-sync/shared/logger.js";
55
import { biDirectionalSync } from "./biDirectionalSync.js";
66
import { RECONNECT_DELAY, WEBSOCKET_URL } from "./consts.js";
7-
import { resumeFileWatcher, suspendFileWatcher } from "./fileWatcher.js";
7+
import {
8+
resetIgnoreMaps,
9+
resumeFileWatcher,
10+
suspendFileWatcher,
11+
} from "./fileWatcher.js";
812
import { changeTrayIconState, TrayIconState } from "./trayIcon.js";
913
import { updateTrayTooltip } from "./trayWrapper.js";
1014
import { getHeartbeatInterval } from "@s3-smart-sync/shared/getHeartbeatInterval.js";
@@ -137,6 +141,7 @@ export function setUpWebsocket(
137141
logger.error("Disconnected from WebSocket server");
138142
changeTrayIconState(TrayIconState.Disconnected);
139143
updateTrayTooltip("S3 Smart Sync (Disconnected)");
144+
resetIgnoreMaps();
140145
setTimeout(
141146
setUpWebsocket,
142147
RECONNECT_DELAY,

client/tests/e2e.spec.mts

+33-22
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { fileExists } from "@s3-smart-sync/shared/fileExists.js";
22
import { mkdir, readFile, rm, stat } from "node:fs/promises";
33
import { join } from "node:path";
44
import {
5-
IGNORE_CLEANUP_DURATION,
5+
UNIGNORE_DURATION,
66
WATCHER_DEBOUNCE_DURATION,
77
} from "../src/fileWatcher.js";
88
import {
99
cleanupLocalDirectories,
1010
cleanupS3,
11+
clientLogs,
1112
createClientDirectories,
1213
createFile,
1314
list,
@@ -28,21 +29,20 @@ let clientDirectories: Record<number, string>;
2829
describe("E2E Tests", () => {
2930
beforeAll(async () => {
3031
await startServer();
31-
startClients(clientIds);
32-
await pause(500);
3332
});
3433

3534
afterAll(async () => {
36-
const results = [
37-
...(await Promise.allSettled([
38-
withTimeout(stopClients()),
39-
withTimeout(stopServer()),
40-
])),
35+
const results = await Promise.allSettled([
36+
withTimeout(stopClients()),
37+
withTimeout(stopServer()),
38+
]);
39+
40+
results.push(
4141
...(await Promise.allSettled([
4242
withTimeout(cleanupS3()),
43-
withTimeout(cleanupLocalDirectories()),
43+
waitUntil(() => cleanupLocalDirectories()),
4444
])),
45-
];
45+
);
4646

4747
const errors = results.filter((result) => result.status === "rejected");
4848
if (errors.length > 0) {
@@ -53,9 +53,17 @@ describe("E2E Tests", () => {
5353
});
5454

5555
beforeEach(async () => {
56-
await cleanupS3();
57-
await cleanupLocalDirectories();
5856
clientDirectories = await createClientDirectories(clientIds);
57+
await startClients(clientIds);
58+
});
59+
60+
afterEach(async () => {
61+
await stopClients();
62+
await Promise.all([
63+
cleanupS3(),
64+
// Due to windows potentially aggressively locking down the directories, we retry until it works
65+
waitUntil(() => cleanupLocalDirectories()),
66+
]);
5967
});
6068

6169
it("should sync correctly on startup", async () => {
@@ -92,13 +100,13 @@ describe("E2E Tests", () => {
92100
}
93101
}
94102

95-
await stopClients([0]);
103+
await stopClients([1]);
96104
await Promise.all(
97105
Object.entries(TEST_FILES).map(([key, content]) =>
98-
createFile(1, key, content),
106+
createFile(0, key, content),
99107
),
100108
);
101-
startClients([0]);
109+
await startClients([1]);
102110

103111
await waitUntil(async () => {
104112
await verifyFiles(clientDirectories[0]!);
@@ -119,6 +127,12 @@ describe("E2E Tests", () => {
119127
).toBe(largeContent),
120128
{ timeout: 10000 },
121129
);
130+
131+
// Client 1 shouldn't do anything after the download has finished. (Which means that the ignore mechanism works with large files.)
132+
await pause(WATCHER_DEBOUNCE_DURATION * 2);
133+
expect(clientLogs[1]!.trim().split("\n").at(-1)?.trim()).toMatch(
134+
/Downloaded: large-file\.txt$/,
135+
);
122136
}, 20000);
123137

124138
it("should sync file changes between clients", async () => {
@@ -128,8 +142,7 @@ describe("E2E Tests", () => {
128142
await readFile(join(clientDirectories[1]!, "new-file.txt"), "utf-8"),
129143
).toBe("New content"),
130144
);
131-
132-
await pause(IGNORE_CLEANUP_DURATION + 10);
145+
await pause(UNIGNORE_DURATION + 10);
133146

134147
await createFile(1, "new-file.txt", "Changed content");
135148
await waitUntil(async () =>
@@ -146,9 +159,8 @@ describe("E2E Tests", () => {
146159
);
147160

148161
await rm(join(clientDirectories[0]!, "file-then-directory"));
149-
await pause(WATCHER_DEBOUNCE_DURATION + 10);
162+
await pause(WATCHER_DEBOUNCE_DURATION + 300);
150163
await sendSnsMessage("file-then-directory", "delete");
151-
await pause(IGNORE_CLEANUP_DURATION + 10);
152164

153165
await mkdir(join(clientDirectories[0]!, "file-then-directory"));
154166
// First, the debounced upload. Then we have to wait for the upload to actually have finished
@@ -182,7 +194,6 @@ describe("E2E Tests", () => {
182194
return Contents === undefined;
183195
});
184196
await sendSnsMessage("directory-then-file/", "delete");
185-
await pause(IGNORE_CLEANUP_DURATION + 10);
186197

187198
await createFile(0, "directory-then-file", "now it's a file");
188199
await waitUntil(async () => {
@@ -195,14 +206,14 @@ describe("E2E Tests", () => {
195206
});
196207
});
197208

198-
it("handles duplicate file/directory on S3", async () => {
209+
it("handles duplicate file/directory on S3 by deleting the older", async () => {
199210
await stopClients();
200211
await upload("duplicate-file/", "");
201212
await upload("duplicate-file/nested/", "");
202213
await upload("duplicate-file/nested/file.txt", "...");
203214
await upload("duplicate-file", "");
204215

205-
startClients(clientIds);
216+
await startClients(clientIds);
206217
await waitUntil(() =>
207218
readFile(join(clientDirectories[0]!, "duplicate-file"), "utf-8"),
208219
);

0 commit comments

Comments
 (0)