simplify 3
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-09-22 08:00:29 +01:00
commit 96d720eed9
7 changed files with 1318 additions and 995 deletions

View file

@ -13,7 +13,8 @@ Test-only code shared by the deterministic and seeded clients.
| `restart-reconciliation.test.ts` | Interrupted file application followed by user edits, two-client/server convergence and a second restart |
| `server-restore.test.ts` | History checkpoints, server backup restores and interrupted metadata resets |
| `content-limits.test.ts`, `protocol-audit.test.ts` | Content validation and API invariants |
| `real-disk.test.ts` | Host filesystem exclusive create/rename, unsafe links, and rename cycles |
| `real-disk.test.ts` | Production Node adapter exclusive create/move, unsafe links, and rename cycles |
| `production-adapters.test.ts` | Actual CLI processes, raw watchers, Obsidian adapter, complete metadata and restart after SIGKILL |
The filesystem interruption tests assert the supported contract: complete metadata
remains usable, scans observe the current files, and a second scan is stable.
@ -30,9 +31,12 @@ after replacement. Abandoned filesystem sessions are fenced against later writes
`crash-worker.ts` runs a complete client in a worker, which the parent terminates
at accepted-response checkpoints without graceful shutdown. A new runtime receives
the saved disk and metadata. The separate real-disk fixture uses a test-only native
exclusive-rename helper and ordinary writes without flushes. It is not a production
adapter or a sandbox against hostile concurrent changes to ancestor directories.
the saved disk and metadata. Real-disk tests exercise the production Node adapter.
The production integration suite launches built CLI processes and an Obsidian
filesystem adapter against a real server. Obsidian unit tests exercise unsaved editor
text, selections and mobile storage contracts; they do not launch the Obsidian app.
Filesystem path checks are not a sandbox against hostile concurrent changes to
ancestor directories.
Scripted and seeded suites run multiple clients against real servers. They check
visible bytes, UUID mappings, canonical server state, content heads and event cursors.
@ -46,7 +50,8 @@ Run from the repository root:
E2E_WORKERS=32 E2E_SEED=12 E2E_TIMEOUT_SECONDS=9000 E2E_ITERATIONS=100 scripts/e2e.sh
```
The command rebuilds peers, runs unit/harness/protocol/scripted suites, and then
The command rebuilds peers and both production integrations, runs core and adapter
unit tests plus harness/protocol/scripted suites, and then
runs seeds 12 through 43. `E2E_ARTIFACTS` optionally selects the artifact directory.
The runner tests also verify subprocess exit codes, log errors, spawn failures,
timeouts, workspace dependency consistency and CI artifact wiring.

View file

@ -3,7 +3,7 @@ import { PermanentSyncError } from "../sync-client/src/errors/errors";
import { test } from "node:test";
import { Database, emptyState } from "../sync-client/src/persistence/database";
import { Syncer } from "../sync-client/src/sync-operations/syncer";
import { SyncClient } from "../sync-client/src/sync-client";
import { SyncClient, type StoredClient } from "../sync-client/src/sync-client";
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
import { allocatePortablePath } from "../sync-client/src/utils/portable-path";
import { MemoryDisk, MemoryPersistence } from "./storage";
@ -83,7 +83,7 @@ test("legacy content notifications preserve the acknowledged namespace prefix on
.hash
};
initial.lastAppliedLocalChangeId = "consumed-update";
const saved = {
const saved: StoredClient = {
database: initial,
settings: { remoteUri: "http://offline.test", vaultName: "test" },
localChangesVaultKey: initial.vaultKey,

View file

@ -1,27 +0,0 @@
/* Test-only POSIX primitives missing from Node's filesystem API. A preflight
* exists() followed by fs.rename() is not an exclusive rename. */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#if defined(__linux__)
#include <sys/syscall.h>
#endif
int main(int argc, char **argv) {
if (argc == 4 && strcmp(argv[1], "rename") == 0) {
int result;
#if defined(__APPLE__)
result = renamex_np(argv[2], argv[3], RENAME_EXCL);
#elif defined(__linux__)
result = syscall(SYS_renameat2, AT_FDCWD, argv[2], AT_FDCWD, argv[3], 1 /* RENAME_NOREPLACE */);
#else
#error "The real filesystem tests require macOS or Linux"
#endif
if (result != 0) { perror("exclusive rename"); return 1; }
return 0;
}
fprintf(stderr, "usage: native-fs rename FROM TO\n");
return 2;
}

View file

@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { Logger, SyncClient } from "sync-client";
import { ServerControl } from "../deterministic-tests/src/server-control";
import { ClientPersistence } from "../local-client-cli/src/client-persistence";
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
import { ObsidianFileSystemOperations } from "../obsidian-plugin/src/obsidian-file-system";
import { getJson, type CanonicalSnapshot } from "./canonical";
const root = path.resolve(__dirname, "../..");
const token = "test-token-change-me";
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
async function until(check: () => Promise<boolean>, message: string) {
const deadline = Date.now() + 15_000;
while (!(await check())) {
assert(Date.now() < deadline, message);
await pause();
}
}
async function contents(file: string) {
try {
return await fs.readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return undefined;
throw error;
}
}
async function stop(child: ChildProcess, signal: NodeJS.Signals) {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill(signal);
await exited;
}
test(
"production CLI watchers, Obsidian adapter, and full metadata survive process replacement",
{ timeout: 90_000 },
async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-production-")
);
const server = new ServerControl(
path.join(root, "sync-server/target/release/sync_server"),
path.join(root, "sync-server/config-e2e.yml"),
new Logger()
);
const children: ChildProcess[] = [];
let obsidian: SyncClient | undefined;
t.after(async () => {
await obsidian?.destroy();
await Promise.all(children.map((child) => stop(child, "SIGKILL")));
await server.stop();
await fs.rm(directory, { recursive: true, force: true });
});
await server.start();
const a = path.join(directory, "a"),
b = path.join(directory, "b"),
c = path.join(directory, "obsidian");
await Promise.all([a, b, c].map((p) => fs.mkdir(p)));
await fs.writeFile(path.join(a, "note.md"), "initial\n");
async function launch(vault: string) {
const child = spawn(
process.execPath,
[
path.join(root, "frontend/local-client-cli/dist/cli.js"),
"-l",
vault,
"-r",
server.remoteUri,
"-t",
token,
"-v",
"production",
"--ignore-pattern",
"**/*.tmp"
],
{ stdio: ["ignore", "pipe", "pipe"] }
);
children.push(child);
let log = "";
child.stdout!.on("data", (data: Buffer) => {
log += data.toString();
});
child.stderr!.on("data", (data: Buffer) => {
log += data.toString();
});
await until(async () => {
assert(
child.exitCode === null && child.signalCode === null,
`CLI exited: ${log}`
);
return log.includes("File watcher started");
}, `CLI did not start: ${log}`);
return child;
}
let first = await launch(a);
await launch(b);
await until(
async () =>
(await contents(path.join(b, "note.md"))) === "initial\n",
"CLI initial download failed"
);
const persistence = (vault: string) =>
new ClientPersistence(
path.join(vault, ".vaultlink/sync-data.json"),
{}
);
const original = (await persistence(a).load()).database!;
const [documentId] = Object.keys(original.local!);
assert(documentId);
const settings = {
remoteUri: server.remoteUri,
token,
vaultName: "production",
isSyncEnabled: true,
enableTelemetry: false,
ignorePatterns: [".vaultlink/**"],
syncIntervalMs: 0
};
obsidian = await SyncClient.create({
fs: new ObsidianFileSystemOperations(
new NodeFileSystemOperations(c),
() => undefined
),
persistence: new ClientPersistence(
path.join(c, ".vaultlink/sync-data.json"),
settings
)
});
await obsidian.start();
await obsidian.waitUntilFinished();
assert.equal(await contents(path.join(c, "note.md")), "initial\n");
await fs.writeFile(
path.join(a, "replacement.tmp"),
"atomic editor save\n"
);
await fs.rename(
path.join(a, "replacement.tmp"),
path.join(a, "note.md")
);
await until(
async () =>
(await contents(path.join(b, "note.md"))) ===
"atomic editor save\n" &&
(await contents(path.join(c, "note.md"))) ===
"atomic editor save\n",
"Editor replacement did not converge"
);
assert.equal(
(await persistence(a).load()).database!.local![documentId],
"note.md"
);
await fs.rename(path.join(b, "note.md"), path.join(b, "renamed.md"));
await until(
async () =>
(await contents(path.join(a, "renamed.md"))) ===
"atomic editor save\n" &&
(await contents(path.join(a, "note.md"))) === undefined,
"CLI rename did not converge"
);
await until(
async () =>
(await persistence(a).load()).database!.local![documentId] ===
"renamed.md",
"Rename changed document identity"
);
const saved = await persistence(a).load();
assert(
saved.historyCheckpoint?.checkpoint,
"CLI dropped the history checkpoint"
);
await stop(first, "SIGKILL");
await fs.writeFile(path.join(a, "renamed.md"), "edited after kill\n");
first = await launch(a);
await until(
async () =>
(await contents(path.join(b, "renamed.md"))) ===
"edited after kill\n" &&
(await contents(path.join(c, "renamed.md"))) ===
"edited after kill\n",
"Restart failed to reconcile current files"
);
const snapshot = await getJson<CanonicalSnapshot>(
`${server.remoteUri}/vaults/production/vault-snapshot`,
token
);
assert.deepEqual(snapshot.fileManifest.entries, {
[documentId]: "renamed.md"
});
await fs.unlink(path.join(b, "renamed.md"));
await until(
async () =>
(await contents(path.join(a, "renamed.md"))) === undefined &&
(await contents(path.join(c, "renamed.md"))) === undefined,
"Delete did not converge"
);
await stop(first, "SIGTERM");
assert.equal(first.exitCode, 0, "CLI shutdown failed");
}
);

View file

@ -3,9 +3,7 @@ import { test } from "node:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { RealDisk } from "./real-disk";
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
import { Database, emptyState } from "../sync-client/src/persistence/database";
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
import type { ServerConfig } from "../sync-client/src/services/server-config";
@ -21,16 +19,7 @@ test(
try {
const root = path.join(directory, "vault");
await fs.mkdir(root);
const helper = path.join(directory, "native-fs");
await promisify(execFile)("cc", [
"-Wall",
"-Wextra",
"-O2",
path.join(__dirname, "native-fs.c"),
"-o",
helper
]);
const disk = new RealDisk(root, helper);
const disk = new NodeFileSystemOperations(root);
const snapshot = (text: string) => ({
content: new TextEncoder().encode(text)
});
@ -50,6 +39,32 @@ test(
).toString()
)
);
for (const unsafe of [
"../outside",
"/absolute",
"folder/../outside"
]) {
await assert.rejects(
disk.write(unsafe, snapshot("blocked")),
/Unsafe path/
);
await assert.rejects(disk.readSnapshot(unsafe), /Unsafe path/);
}
// POSIX filenames can contain colons. The engine, not the adapter,
// allocates their portable names for sync.
if (process.platform !== "win32") {
await fs.writeFile(
path.join(root, "local:name.md"),
"local name"
);
assert.equal(
Buffer.from(
(await disk.readSnapshot("local:name.md"))!.content
).toString(),
"local name"
);
await disk.rename("local:name.md", "portable-name.md");
}
await disk.write("a.md", snapshot("A"));
await disk.write("b.md", snapshot("B"));
// A file in an ancestor position makes descendants absent.

View file

@ -1,171 +0,0 @@
import assert from "node:assert/strict";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { constants } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { FileSystemOperations, FileSnapshot } from "sync-client";
const exec = promisify(execFile);
/** Small integration fixture, NOT a supported application adapter. Safety
* preflights detect static symlinks/hardlinks; they are not an openat sandbox
* against a hostile process replacing ancestors concurrently. */
export class RealDisk implements FileSystemOperations {
public constructor(
private readonly root: string,
private readonly helper: string
) {}
private async resolve(relative: string): Promise<string> {
assert(
relative === "" ||
relative
.split("/")
.every(
(p) => p && p !== "." && p !== ".." && !p.includes("\\")
),
"Unsafe path"
);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
current = path.join(current, component);
const entry = await this.info(current);
if (entry)
assert(
entry.isDirectory() ||
(entry.isFile() && entry.nlink === 1),
"Symlink, hardlink or special file"
);
}
return current;
}
private async info(absolute: string) {
try {
return await fs.lstat(absolute);
} catch (error) {
if (
["ENOENT", "ENOTDIR"].includes(
(error as NodeJS.ErrnoException).code ?? ""
)
)
return undefined;
throw error;
}
}
public async stat(relative: string) {
const info = await this.info(await this.resolve(relative));
return info
? {
kind: info.isFile()
? ("file" as const)
: ("directory" as const),
size: info.size
}
: undefined;
}
public async exists(relative: string) {
return (await this.stat(relative)) !== undefined;
}
public async readSnapshot(
relative: string
): Promise<FileSnapshot | undefined> {
const absolute = await this.resolve(relative);
let handle;
try {
handle = await fs.open(
absolute,
constants.O_RDONLY | constants.O_NOFOLLOW
);
} catch (error) {
if (
["ENOENT", "ENOTDIR"].includes(
(error as NodeJS.ErrnoException).code ?? ""
)
)
return undefined;
throw error;
}
try {
const before = await handle.stat({ bigint: true });
assert(
before.isFile() && before.nlink === 1n,
"Not an independent regular file"
);
const content = new Uint8Array(await handle.readFile());
const after = await handle.stat({ bigint: true });
const atPath = await fs.lstat(absolute, { bigint: true });
assert(
before.ino === after.ino &&
after.ino === atPath.ino &&
before.size === after.size &&
before.mtimeNs === after.mtimeNs &&
before.ctimeNs === after.ctimeNs,
"File changed during read"
);
return { content };
} finally {
await handle.close();
}
}
public async listFilesRecursively(root = ""): Promise<string[]> {
const result: string[] = [];
for (const name of await fs.readdir(await this.resolve(root))) {
const relative = root ? `${root}/${name}` : name;
if ((await this.stat(relative))!.kind === "directory")
result.push(...(await this.listFilesRecursively(relative)));
else result.push(relative);
}
return result.sort();
}
public async createDirectory(relative: string): Promise<void> {
await this.resolve(relative);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
const next = path.join(current, component);
try {
await fs.mkdir(next);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
throw error;
}
assert((await fs.lstat(next)).isDirectory());
current = next;
}
}
public async write(
relative: string,
snapshot: FileSnapshot
): Promise<void> {
assert(
!snapshot.cursors?.length,
"This disk-only fixture has no editor selections"
);
const destination = await this.resolve(relative);
await fs.writeFile(destination, snapshot.content, {
flag: "wx",
mode: 0o600
});
}
public async rename(from: string, to: string): Promise<void> {
const source = await this.resolve(from),
destination = await this.resolve(to);
assert((await this.info(source))?.isFile());
await exec(this.helper, ["rename", source, destination]);
}
public async delete(relative: string): Promise<void> {
assert(relative, "Cannot prune root");
const absolute = await this.resolve(relative);
const entry = await this.info(absolute);
if (!entry) return;
assert(entry.isDirectory(), "Cannot delete regular file");
for (const child of await fs.readdir(absolute))
await this.delete(`${relative}/${child}`);
await fs.rmdir(absolute);
}
public async deleteFile(relative: string): Promise<void> {
const absolute = await this.resolve(relative);
const entry = await this.info(absolute);
if (!entry) return;
assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file");
await fs.unlink(absolute);
}
}

View file

@ -2,14 +2,11 @@ import assert from "node:assert/strict";
import type {
FileSnapshot,
FileSystemOperations,
StoredDatabase,
SyncSettings
StoredDatabase
} from "sync-client";
export type StoredClient = Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>;
import type { StoredClient } from "sync-client";
export type { StoredClient } from "sync-client";
export type Boundary = (label: string) => void | Promise<void>;
type Entry = FileSnapshot | null; // null is a directory; absence is not a directory.
export interface DiskImage {