.
This commit is contained in:
parent
a5b3cc5f3a
commit
d23750f15b
6 changed files with 90 additions and 17 deletions
|
|
@ -15,6 +15,10 @@ import {
|
||||||
} from "./consts";
|
} from "./consts";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
class ConflictFilesDetectedError extends Error {
|
||||||
|
public override readonly name = "ConflictFilesDetectedError";
|
||||||
|
}
|
||||||
|
|
||||||
export class TestRunner {
|
export class TestRunner {
|
||||||
private agents: DeterministicAgent[] = [];
|
private agents: DeterministicAgent[] = [];
|
||||||
private readonly serverControl: ServerControl;
|
private readonly serverControl: ServerControl;
|
||||||
|
|
@ -224,6 +228,9 @@ export class TestRunner {
|
||||||
this.logger.info("Barrier complete: all clients converged");
|
this.logger.info("Barrier complete: all clients converged");
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ConflictFilesDetectedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
lastError =
|
lastError =
|
||||||
error instanceof Error ? error : new Error(String(error));
|
error instanceof Error ? error : new Error(String(error));
|
||||||
this.logger.info("Barrier: not yet converged, retrying...");
|
this.logger.info("Barrier: not yet converged, retrying...");
|
||||||
|
|
@ -289,6 +296,25 @@ export class TestRunner {
|
||||||
clientFiles.push(fileMap);
|
clientFiles.push(fileMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conflictsByClient = clientFiles.map((files) =>
|
||||||
|
Array.from(files.keys()).filter((path) =>
|
||||||
|
CONFLICT_PATH_REGEX.test(path)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (conflictsByClient.some((conflicts) => conflicts.length > 0)) {
|
||||||
|
const summary = conflictsByClient
|
||||||
|
.map((conflicts, i) =>
|
||||||
|
conflicts.length > 0
|
||||||
|
? `client ${i}: [${conflicts.join(", ")}]`
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
.filter((s): s is string => s !== null)
|
||||||
|
.join("; ");
|
||||||
|
throw new ConflictFilesDetectedError(
|
||||||
|
`Found local conflict file(s): ${summary}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const referenceFiles = Array.from(clientFiles[0].keys());
|
const referenceFiles = Array.from(clientFiles[0].keys());
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
|
|
@ -327,15 +353,6 @@ export class TestRunner {
|
||||||
|
|
||||||
this.logger.info("✓ All clients are consistent");
|
this.logger.info("✓ All clients are consistent");
|
||||||
|
|
||||||
const conflictFiles = referenceFiles.filter((path) =>
|
|
||||||
CONFLICT_PATH_REGEX.test(path)
|
|
||||||
);
|
|
||||||
if (conflictFiles.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Found ${conflictFiles.length} conflict file(s) — local displacements indicate a reconciliation regression: [${conflictFiles.join(", ")}]`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verify) {
|
if (verify) {
|
||||||
this.logger.info("Running custom verification...");
|
this.logger.info("Running custom verification...");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||||
import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path";
|
import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path";
|
||||||
import { removeFromArray } from "../utils/remove-from-array";
|
import { removeFromArray } from "../utils/remove-from-array";
|
||||||
|
import { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
||||||
|
|
||||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
public async getConfig(): Promise<ServerConfigData> {
|
public async getConfig(): Promise<ServerConfigData> {
|
||||||
|
|
@ -72,7 +73,8 @@ function makeOps(): {
|
||||||
const ops = new FileOperations(
|
const ops = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
fs,
|
fs,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
new ExpectedFsEvents()
|
||||||
);
|
);
|
||||||
return { fs, ops };
|
return { fs, ops };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { isBinary } from "../utils/is-binary";
|
||||||
import { buildConflictFileName } from "../sync-operations/conflict-path";
|
import { buildConflictFileName } from "../sync-operations/conflict-path";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||||
|
import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
||||||
|
|
||||||
export enum MoveOnConflict {
|
export enum MoveOnConflict {
|
||||||
EXISTING = "EXISTING",
|
EXISTING = "EXISTING",
|
||||||
|
|
@ -22,6 +23,7 @@ export class FileOperations {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
|
private readonly expectedFsEvents: ExpectedFsEvents,
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
) {
|
) {
|
||||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||||
|
|
@ -74,6 +76,10 @@ export class FileOperations {
|
||||||
moveOnConflict: MoveOnConflict
|
moveOnConflict: MoveOnConflict
|
||||||
): Promise<RelativePath> {
|
): Promise<RelativePath> {
|
||||||
const actualPath = await this.ensureClearPath(path, moveOnConflict);
|
const actualPath = await this.ensureClearPath(path, moveOnConflict);
|
||||||
|
// ensureClearPath leaves actualPath empty: either the file never
|
||||||
|
// existed, or it was just renamed away. The upcoming write therefore
|
||||||
|
// looks like a fresh create to the watcher.
|
||||||
|
this.expectedFsEvents.expectCreate(actualPath);
|
||||||
await this.fs.write(actualPath, this.toNativeLineEndings(newContent));
|
await this.fs.write(actualPath, this.toNativeLineEndings(newContent));
|
||||||
return actualPath;
|
return actualPath;
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +120,7 @@ export class FileOperations {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||||
);
|
);
|
||||||
|
this.expectedFsEvents.expectUpdate(path);
|
||||||
await this.fs.write(
|
await this.fs.write(
|
||||||
path,
|
path,
|
||||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||||
|
|
@ -135,10 +142,12 @@ export class FileOperations {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite`
|
`3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite`
|
||||||
);
|
);
|
||||||
|
this.expectedFsEvents.expectUpdate(path);
|
||||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.expectedFsEvents.expectUpdate(path);
|
||||||
await this.fs.atomicUpdateText(
|
await this.fs.atomicUpdateText(
|
||||||
path,
|
path,
|
||||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||||
|
|
@ -177,6 +186,7 @@ export class FileOperations {
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
|
this.expectedFsEvents.expectDelete(path);
|
||||||
await this.fs.delete(path);
|
await this.fs.delete(path);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -203,6 +213,7 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualPath = await this.ensureClearPath(newPath, moveOnConflict);
|
const actualPath = await this.ensureClearPath(newPath, moveOnConflict);
|
||||||
|
this.expectedFsEvents.expectRename(oldPath, actualPath);
|
||||||
await this.fs.rename(oldPath, actualPath);
|
await this.fs.rename(oldPath, actualPath);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
return actualPath;
|
return actualPath;
|
||||||
|
|
@ -223,6 +234,7 @@ export class FileOperations {
|
||||||
`Displacing existing file at ${path} to '${conflictPath}' to make room`
|
`Displacing existing file at ${path} to '${conflictPath}' to make room`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.expectedFsEvents.expectRename(path, conflictPath);
|
||||||
await this.fs.rename(path, conflictPath);
|
await this.fs.rename(path, conflictPath);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { DIFF_CACHE_SIZE_MB } from "./consts";
|
||||||
import { ServerConfig } from "./services/server-config";
|
import { ServerConfig } from "./services/server-config";
|
||||||
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||||
import { Lock } from "./utils/data-structures/locks";
|
import { Lock } from "./utils/data-structures/locks";
|
||||||
|
import { ExpectedFsEvents } from "./sync-operations/expected-fs-events";
|
||||||
|
|
||||||
export class SyncClient {
|
export class SyncClient {
|
||||||
private hasFinishedOfflineSync = false;
|
private hasFinishedOfflineSync = false;
|
||||||
|
|
@ -50,6 +51,7 @@ export class SyncClient {
|
||||||
private readonly contentCache: FixedSizeDocumentCache,
|
private readonly contentCache: FixedSizeDocumentCache,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly syncService: SyncService,
|
private readonly syncService: SyncService,
|
||||||
|
private readonly expectedFsEvents: ExpectedFsEvents,
|
||||||
private readonly persistence: PersistenceProvider<
|
private readonly persistence: PersistenceProvider<
|
||||||
Partial<{
|
Partial<{
|
||||||
settings: Partial<SyncSettings>;
|
settings: Partial<SyncSettings>;
|
||||||
|
|
@ -178,10 +180,13 @@ export class SyncClient {
|
||||||
|
|
||||||
const serverConfig = new ServerConfig(syncService);
|
const serverConfig = new ServerConfig(syncService);
|
||||||
|
|
||||||
|
const expectedFsEvents = new ExpectedFsEvents();
|
||||||
|
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
logger,
|
logger,
|
||||||
fs,
|
fs,
|
||||||
serverConfig,
|
serverConfig,
|
||||||
|
expectedFsEvents,
|
||||||
nativeLineEndings
|
nativeLineEndings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -229,6 +234,7 @@ export class SyncClient {
|
||||||
contentCache,
|
contentCache,
|
||||||
serverConfig,
|
serverConfig,
|
||||||
syncService,
|
syncService,
|
||||||
|
expectedFsEvents,
|
||||||
persistence
|
persistence
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -363,14 +369,22 @@ export class SyncClient {
|
||||||
public syncLocallyCreatedFile(relativePath: RelativePath): void {
|
public syncLocallyCreatedFile(relativePath: RelativePath): void {
|
||||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
|
if (this.expectedFsEvents.matchCreate(relativePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.syncer.syncLocallyCreatedFile(relativePath);
|
this.syncer.syncLocallyCreatedFile(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public syncLocallyDeletedFile(relativePath: RelativePath): void {
|
public syncLocallyDeletedFile(relativePath: RelativePath): void {
|
||||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
|
if (this.expectedFsEvents.matchDelete(relativePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.syncer.syncLocallyDeletedFile(relativePath);
|
this.syncer.syncLocallyDeletedFile(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,7 +397,11 @@ export class SyncClient {
|
||||||
}): void {
|
}): void {
|
||||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
|
if (this.expectedFsEvents.matchUpdate(relativePath, oldPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.syncer.syncLocallyUpdatedFile({
|
this.syncer.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath
|
relativePath
|
||||||
|
|
@ -485,6 +503,15 @@ export class SyncClient {
|
||||||
this.syncService.stop();
|
this.syncService.stop();
|
||||||
await this.webSocketManager.stop();
|
await this.webSocketManager.stop();
|
||||||
await this.waitUntilFinished();
|
await this.waitUntilFinished();
|
||||||
|
// Clear the offline-scan gate so a subsequent `startSyncing()`
|
||||||
|
// re-runs the scan; otherwise any local changes made while sync was
|
||||||
|
// paused (offline edits, deletes, renames) wouldn't be detected, and
|
||||||
|
// an incoming remote update would silently overwrite them.
|
||||||
|
this.syncer.clearOfflineScanGate();
|
||||||
|
// Drop any expected fs events that were registered but never matched
|
||||||
|
// (e.g. an op aborted by SyncResetError). Otherwise a real user edit
|
||||||
|
// at the same path after re-enable would be swallowed.
|
||||||
|
this.expectedFsEvents.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetInMemoryState(): void {
|
private resetInMemoryState(): void {
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,19 @@ export async function scheduleOfflineChanges(
|
||||||
}) => void,
|
}) => void,
|
||||||
enqueueDelete: (path: RelativePath) => void
|
enqueueDelete: (path: RelativePath) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allLocalFiles = await operations.listFilesRecursively();
|
const allLocalFiles = new Set(await operations.listFilesRecursively());
|
||||||
logger.info(`Scheduling sync for ${allLocalFiles.length} local files`);
|
logger.info(`Scheduling sync for ${allLocalFiles.size} local files`);
|
||||||
const allDocuments = queue.allSettledDocuments();
|
const allDocuments = queue.allSettledDocuments();
|
||||||
|
|
||||||
|
// A doc is "possibly deleted" only if it has no local file. Including
|
||||||
|
// docs that still exist locally would queue a spurious delete alongside
|
||||||
|
// the update below.
|
||||||
const locallyPossiblyDeletedFiles: DocumentWithPath[] = [];
|
const locallyPossiblyDeletedFiles: DocumentWithPath[] = [];
|
||||||
|
|
||||||
for (const [path, record] of allDocuments.entries()) {
|
for (const [path, record] of allDocuments.entries()) {
|
||||||
|
if (!allLocalFiles.has(path)) {
|
||||||
locallyPossiblyDeletedFiles.push({ path, record });
|
locallyPossiblyDeletedFiles.push({ path, record });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const locallyPossibleCreatedFiles: RelativePath[] = [];
|
const locallyPossibleCreatedFiles: RelativePath[] = [];
|
||||||
const syncedLocalFiles: RelativePath[] = [];
|
const syncedLocalFiles: RelativePath[] = [];
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,17 @@ export class Syncer {
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.queue.clearPending();
|
this.queue.clearPending();
|
||||||
|
this.clearOfflineScanGate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the "have we already scanned this session" gate so a later
|
||||||
|
* `scheduleSyncForOfflineChanges()` actually performs a fresh scan
|
||||||
|
* instead of returning the previous (resolved) promise. Called when
|
||||||
|
* sync is paused so the next start picks up any offline edits made
|
||||||
|
* while sync was off.
|
||||||
|
*/
|
||||||
|
public clearOfflineScanGate(): void {
|
||||||
const current = this.runningScheduleSyncForOfflineChanges;
|
const current = this.runningScheduleSyncForOfflineChanges;
|
||||||
if (current !== undefined) {
|
if (current !== undefined) {
|
||||||
void current.finally(() => {
|
void current.finally(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue