Merge pull request #1039 from andrewleech/fix/float-mtime-mobile-crash

fix: prevent float mtime from crashing mobile clients
This commit is contained in:
vorotamoroz
2026-08-09 19:04:35 +09:00
committed by GitHub
8 changed files with 129 additions and 10 deletions
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
return {
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
stat: {
ctime: stats?.ctimeMs ?? Date.now(),
mtime: stats?.mtimeMs ?? Date.now(),
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
// precision, and timestamps are stored as integer ms everywhere else.
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
size: stats?.size ?? 0,
type: "file",
},
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(created.stat?.size).toBe(42);
});
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
const basePath = "/vault/base";
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
filePath: string,
stats: any
) => void;
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
addCallback(`${basePath}/note.md`, floatStats);
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
expect(created.stat?.ctime).toBe(1778511180024);
expect(created.stat?.mtime).toBe(1778511180999);
});
it("close() calls watcher.close()", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const handlers = makeHandlers();
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { Stat, App } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Storage adapter implementation for Obsidian
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
}
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.write(path, data, options);
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
}
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.append(path, data, options);
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
}
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
describe("ObsidianStorageAdapter", () => {
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeBinary = vi.fn().mockResolvedValue(undefined);
const append = vi.fn().mockResolvedValue(undefined);
const app = {
vault: {
adapter: {
write,
writeBinary,
append,
},
},
} as unknown as App;
const adapter = new ObsidianStorageAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.write("note.md", "text", options);
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
await adapter.append("log.md", "text", options);
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { TFile, App, TFolder } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Vault adapter implementation for Obsidian
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
}
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modify(file, data, options);
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
}
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
}
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.create(path, data, options);
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
}
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async rename(file: TFile, newPath: string): Promise<void> {
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { App, TFile } from "obsidian";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
describe("ObsidianVaultAdapter.read", () => {
describe("ObsidianVaultAdapter", () => {
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
const path = "Transcripts/字幕.md";
const contentWithoutBom = "字幕の検証行です。\n";
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
expect(adapterRead).toHaveBeenCalledWith(path);
expect(read).not.toHaveBeenCalled();
});
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
const modify = vi.fn().mockResolvedValue(undefined);
const modifyBinary = vi.fn().mockResolvedValue(undefined);
const create = vi.fn().mockResolvedValue({});
const createBinary = vi.fn().mockResolvedValue({});
const app = {
vault: {
modify,
modifyBinary,
create,
createBinary,
},
} as unknown as App;
const file = { path: "note.md" } as TFile;
const adapter = new ObsidianVaultAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.modify(file, "text", options);
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
await adapter.create("created.md", "text", options);
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});
@@ -0,0 +1,25 @@
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
* Coerce the timestamp fields of a write-options object to integer milliseconds.
*
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
* A non-integer (float) timestamp makes that cast throw
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
* launch as soon as such a document is replicated in. Float timestamps can
* enter the database from any client that stores `fs.Stats.mtimeMs` without
* flooring. Flooring at the storage boundary guarantees every Obsidian write
* carries an integer, so a float timestamp already present in the mesh cannot
* brick the app.
*
* Returns a shallow copy so the caller's options object is not mutated; passes
* `undefined` through unchanged.
*/
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
if (!options) return options;
const sanitized: UXDataWriteOptions = { ...options };
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
return sanitized;
}
+7
View File
@@ -12,6 +12,13 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
### Synchronisation and storage
#### Fixed
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
## 1.0.10
9th August, 2026