feat: distinguish remote operation and request activity

This commit is contained in:
vorotamoroz
2026-07-16 04:09:53 +00:00
parent 9ffde2dd3e
commit 7439b75cd0
15 changed files with 579 additions and 91 deletions
+2 -1
View File
@@ -64,7 +64,8 @@ This plug-in may be particularly useful for researchers, engineers, and develope
Synchronisation status is shown in the status bar with the following icons. Synchronisation status is shown in the status bar with the following icons.
- Activity Indicator - Activity Indicator
- 📲 Remote activity - 📲 A finite remote operation is in progress
- 🌐N Approximate remote requests currently in progress
- Status - Status
- ⏹️ Stopped - ⏹️ Stopped
- 💤 LiveSync enabled. Waiting for changes - 💤 LiveSync enabled. Waiting for changes
+9 -3
View File
@@ -58,10 +58,14 @@ Fetch rebuilds temporarily suspend file watching. Their visibility event still r
If the desktop background setting applies, its existing continuous or periodic policy remains authoritative. When a Desktop LiveSync window becomes visible during bounded activity, the normal continuous-channel teardown and resume sequence is also deferred until that activity ends, so recovery does not abort the finite operation. If the desktop background setting applies, its existing continuous or periodic policy remains authoritative. When a Desktop LiveSync window becomes visible during bounded activity, the normal continuous-channel teardown and resume sequence is also deferred until that activity ends, so recovery does not abort the finite operation.
The status-bar remote-work indicator is shown when either the existing HTTP request balance is non-zero or the bounded remote activity count is non-zero. This preserves coverage from the existing counters while adding finite replication, peer waiting, and remote chunk fetching. The combined icon reports broader remote work, not an exact physical connection state; connection-level telemetry remains a separate concern. The status bar separates the two meanings. `📲` is shown while the bounded remote activity count is non-zero. It therefore reports a finite logical operation, including periods such as P2P peer selection or chunk-fetch queueing when no request is currently crossing the network. An adjacent `🌐N` reports the approximate number of tracked physical-request units currently in progress. The icon values and the physical indicator's 150 ms minimum display time are named constants so their presentation can be revised without changing the activity contract.
Each physical HTTP attempt owns one balanced counter pair. The request counter is incremented immediately before invoking the selected fetch implementation, and the response counter is incremented in `finally`, whether the attempt returns or rejects. A web-fetch failure followed by the native fallback is two physical attempts and therefore contributes two balanced pairs. Callers must not add another pair around `performFetch`, because duplicated or missing increments leave the status indicator permanently active. Each physical HTTP attempt owns one balanced counter pair. The request counter is incremented immediately before invoking the selected fetch implementation, and the response counter is incremented in `finally`, whether the attempt returns or rejects. A web-fetch failure followed by the native fallback is two physical attempts and therefore contributes two balanced pairs. Callers must not add another pair around `performFetch`, because duplicated or missing increments leave the status indicator permanently active.
Object Storage contributes one approximate unit for each AWS SDK command issued by its adapter, including upload, download, listing, deletion, availability, and usage requests. A download remains active until its response body has been consumed. This boundary is above the request-handler choice, so it covers both the standard SDK handler and Obsidian's internal request API without double counting. SDK-internal retries remain within one reported command. The displayed value is therefore intentionally approximate and is not an exact count of sockets, HTTP exchanges, or bytes transferred.
P2P does not yet contribute to the physical-request count because it does not have a request unit comparable with CouchDB HTTP attempts or Object Storage SDK commands. Its finite operations remain visible through `📲`. A future P2P transfer metric should be added only when it has a stable meaning, rather than being inferred from the broad logical-operation count.
## Ownership ## Ownership
`ReplicatorService` is the shared ownership point because both `ReplicationService` and `ChunkFetcher` already depend on it. It owns the broad activity count and classifies the semantic subset which represents finite replication. Placing either activity state in `ReplicationService` would make chunk fetching depend in the opposite direction and risk a service dependency cycle. Adding another Service Hub service would introduce a wider capability surface without a distinct lifecycle owner. `ReplicatorService` is the shared ownership point because both `ReplicationService` and `ChunkFetcher` already depend on it. It owns the broad activity count and classifies the semantic subset which represents finite replication. Placing either activity state in `ReplicationService` would make chunk fetching depend in the opposite direction and risk a service dependency cycle. Adding another Service Hub service would introduce a wider capability surface without a distinct lifecycle owner.
@@ -72,7 +76,7 @@ The platform activity runner remains injected. Common library and headless consu
- Do not count continuous replication as a bounded activity. - Do not count continuous replication as a bounded activity.
- Do not reinterpret the count as an exact number of network connections or HTTP requests. - Do not reinterpret the count as an exact number of network connections or HTTP requests.
- Do not use this logical-operation count as a replacement for future connection-level telemetry. - Do not use either diagnostic count for replication completion, throttling, protocol correctness, or power-policy decisions.
- Do not claim or implement privileged mobile background execution. - Do not claim or implement privileged mobile background execution.
- Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action. - Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action.
- Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work. - Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work.
@@ -106,6 +110,8 @@ Unit tests cover:
- hidden-to-visible transitions before the final activity avoiding an unmatched resume; and - hidden-to-visible transitions before the final activity avoiding an unmatched resume; and
- continuous-channel recovery being deferred when a desktop window becomes visible during bounded activity. - continuous-channel recovery being deferred when a desktop window becomes visible during bounded activity.
Additional tests cover balanced physical-request counters after success and rejection, each Object Storage command boundary, response-body consumption for downloads, the split `📲` and `🌐N` status labels, and a deterministic real-Obsidian CouchDB request held while the physical indicator is observed. The Object Storage integration and real-Obsidian MinIO workflows verify that actual AWS SDK operations advance and rebalance the shared counters.
The exact Fancy Kit screen wake-lock behaviour is covered by its package and Harness tests. A real Obsidian smoke test remains appropriate when changing the platform adapter or lifecycle integration, but is not required for changes confined to the already-tested injected activity-runner contract. The exact Fancy Kit screen wake-lock behaviour is covered by its package and Harness tests. A real Obsidian smoke test remains appropriate when changing the platform adapter or lifecycle integration, but is not required for changes confined to the already-tested injected activity-runner contract.
## Consequences ## Consequences
@@ -113,4 +119,4 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har
- One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals. - One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals.
- Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation. - Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation.
- Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild. - Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild.
- Future remote-work indicators should distinguish logical activity from connection-level telemetry when that distinction matters to users. - Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it.
+1 -1
Submodule src/lib updated: 4639e4537f...09bfafbd6c
+41 -41
View File
@@ -31,7 +31,12 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock"; import { serialized } from "octagonal-wheels/concurrency/lock";
import { $msg } from "@lib/common/i18n.ts"; import { $msg } from "@lib/common/i18n.ts";
import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts"; import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts";
import { hasRemoteActivity } from "./RemoteActivityStatus.ts"; import {
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
import type { LiveSyncCore } from "@/main.ts"; import type { LiveSyncCore } from "@/main.ts";
import { LiveSyncError } from "@lib/common/LSError.ts"; import { LiveSyncError } from "@lib/common/LSError.ts";
import { isValidPath } from "@/common/utils.ts"; import { isValidPath } from "@/common/utils.ts";
@@ -118,48 +123,43 @@ export class ModuleLog extends AbstractObsidianModule {
p2pLogCollector = new P2PLogCollector(); p2pLogCollector = new P2PLogCollector();
observeForLogs() { observeForLogs() {
const padSpaces = `\u{2007}`.repeat(10); const registerDisplay = <T extends { dispose(): void }>(display: T): T => {
// const emptyMark = `\u{2003}`; this.plugin.register(() => display.dispose());
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) { return display;
const formatted = reactiveSource(""); };
let timer: number | undefined = undefined; const labelReplication = registerDisplay(
let maxLen = 1; createPaddedCounterLabel(this.services.replication.replicationResultCount, `📥`)
numI.onChanged((numX) => { );
const num = numX.value; const labelDBCount = registerDisplay(
const numLen = `${Math.abs(num)}`.length + 1; createPaddedCounterLabel(this.services.replication.databaseQueueCount, `📄`)
maxLen = maxLen < numLen ? numLen : maxLen; );
if (timer) compatGlobal.clearTimeout(timer); const labelStorageCount = registerDisplay(
if (num == 0) { createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
timer = compatGlobal.setTimeout(() => { );
formatted.value = ""; const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
maxLen = 1; const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
}, 3000); const labelConflictProcessCount = registerDisplay(
} createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`; );
});
return computed(() => formatted.value);
}
const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`);
const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`);
const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`);
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`);
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value); const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`); const labelHiddenFilesCount = registerDisplay(createPaddedCounterLabel(hiddenFilesCount, `⚙️`));
const queueCountLabelX = reactive(() => { const queueCountLabelX = reactive(() => {
return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`; return `${labelReplication.value}${labelDBCount.value}${labelStorageCount.value}${labelChunkCount.value}${labelPluginScanCount.value}${labelHiddenFilesCount.value}${labelConflictProcessCount.value}`;
}); });
const queueCountLabel = () => queueCountLabelX.value; const queueCountLabel = () => queueCountLabelX.value;
const trackedRequestCount = reactive(() => {
return getTrackedRequestCount(this.services.API.requestCount.value, this.services.API.responseCount.value);
});
const displayedTrackedRequestCount = registerDisplay(
createMinimumVisibleActivityCount(trackedRequestCount, REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS)
);
const requestingStatLabel = computed(() => { const requestingStatLabel = computed(() => {
return hasRemoteActivity( return formatRemoteActivityStatusLabel({
this.services.API.requestCount.value, remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
this.services.API.responseCount.value, trackedRequestCount: displayedTrackedRequestCount.value,
this.services.replicator.boundedRemoteActivityCount.value });
)
? "📲 "
: "";
}); });
const replicationStatLabel = computed(() => { const replicationStatLabel = computed(() => {
@@ -215,11 +215,11 @@ export class ModuleLog extends AbstractObsidianModule {
} }
return { w, sent, pushLast, arrived, pullLast }; return { w, sent, pushLast, arrived, pullLast };
}); });
const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, ``); const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, ``));
const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`); const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`); const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
const waitingLabel = computed(() => { const waitingLabel = computed(() => {
return `${labelProc()}${labelPend()}${labelInBatchDelay()}`; return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
}); });
const statusLineLabel = computed(() => { const statusLineLabel = computed(() => {
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel(); const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
@@ -1,13 +0,0 @@
import { describe, expect, it } from "vitest";
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
describe("hasRemoteActivity", () => {
it("preserves the existing HTTP request balance signal", () => {
expect(hasRemoteActivity(2, 1, 0)).toBe(true);
expect(hasRemoteActivity(2, 2, 0)).toBe(false);
});
it("reports bounded remote activity without an HTTP request imbalance", () => {
expect(hasRemoteActivity(2, 2, 1)).toBe(true);
});
});
+26 -3
View File
@@ -1,4 +1,27 @@
/** Returns whether the status UI should report HTTP traffic or a finite remote operation in progress. */ /** Status icon for a finite remote operation whose lifetime is known. */
export function hasRemoteActivity(requestCount: number, responseCount: number, boundedRemoteActivityCount: number) { export const REMOTE_OPERATION_ACTIVITY_ICON = "📲";
return requestCount - responseCount !== 0 || boundedRemoteActivityCount !== 0;
/** Status icon for approximate physical remote-request activity. */
export const REMOTE_REQUEST_ACTIVITY_ICON = "🌐";
/** Avoids hiding very short remote requests before the status bar can render them. */
export const REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS = 150;
export type RemoteActivityStatus = {
remoteOperationCount: number;
trackedRequestCount: number;
};
/** Returns the non-negative difference between tracked request starts and completions. */
export function getTrackedRequestCount(requestCount: number, responseCount: number): number {
return Math.max(0, requestCount - responseCount);
}
/** Formats the compact prefix shown before the replication status. */
export function formatRemoteActivityStatusLabel(status: RemoteActivityStatus): string {
const labels = [
status.remoteOperationCount > 0 ? REMOTE_OPERATION_ACTIVITY_ICON : "",
status.trackedRequestCount > 0 ? `${REMOTE_REQUEST_ACTIVITY_ICON}${status.trackedRequestCount}` : "",
].filter((label) => label !== "");
return labels.length > 0 ? `${labels.join(" ")} ` : "";
} }
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
describe("getTrackedRequestCount", () => {
it("reports the non-negative difference between starts and completions", () => {
expect(getTrackedRequestCount(3, 2)).toBe(1);
expect(getTrackedRequestCount(2, 2)).toBe(0);
expect(getTrackedRequestCount(2, 3)).toBe(0);
});
});
describe("formatRemoteActivityStatusLabel", () => {
it("separates a finite remote operation from tracked physical requests", () => {
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 0 })).toBe(
`${REMOTE_OPERATION_ACTIVITY_ICON} `
);
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 1 })).toBe(
`${REMOTE_REQUEST_ACTIVITY_ICON}1 `
);
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 2 })).toBe(
`${REMOTE_OPERATION_ACTIVITY_ICON} ${REMOTE_REQUEST_ACTIVITY_ICON}2 `
);
});
it("omits inactive and invalid negative activity counts", () => {
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 0 })).toBe("");
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: -1, trackedRequestCount: -1 })).toBe("");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
export type DisposableReactiveValue<T> = ReactiveValue<T> & {
dispose(): void;
};
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
return {
get value() {
return value.value;
},
onChanged(handler) {
value.onChanged(handler);
},
offChanged(handler) {
value.offChanged(handler);
},
dispose,
};
}
/**
* Mirrors an activity count while keeping each visible period on screen for a
* minimum total lifetime. The delay applies only when the source becomes zero.
*/
export function createMinimumVisibleActivityCount(
source: ReactiveValue<number>,
minimumVisibleMs: number
): DisposableReactiveValue<number> {
const minimumLifetime = Math.max(0, minimumVisibleMs);
const displayed = reactiveSource(Math.max(0, source.value));
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
let hideTimer: number | undefined;
let disposed = false;
const cancelHide = () => {
if (hideTimer === undefined) return;
compatGlobal.clearTimeout(hideTimer);
hideTimer = undefined;
};
const hideIfIdle = () => {
hideTimer = undefined;
if (disposed || Math.max(0, source.value) > 0) return;
displayed.value = 0;
visibleSince = undefined;
};
const update = () => {
if (disposed) return;
const nextCount = Math.max(0, source.value);
cancelHide();
if (nextCount > 0) {
if (displayed.value === 0) {
visibleSince = Date.now();
}
displayed.value = nextCount;
return;
}
if (displayed.value === 0) {
visibleSince = undefined;
return;
}
const elapsed = Date.now() - (visibleSince ?? Date.now());
const remaining = Math.max(0, minimumLifetime - elapsed);
if (remaining === 0) {
hideIfIdle();
} else {
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
}
};
source.onChanged(update);
return asDisposableReactiveValue(displayed, () => {
if (disposed) return;
disposed = true;
cancelHide();
source.offChanged(update);
});
}
/**
* Formats a counter with a stable width and briefly retains its zero value so
* that the completion of queued work remains visible.
*/
export function createPaddedCounterLabel(
source: ReactiveValue<number>,
mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
): DisposableReactiveValue<string> {
const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource("");
let maximumLength = 1;
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const format = (count: number) => {
const requiredLength = `${Math.abs(count)}`.length + 1;
maximumLength = Math.max(maximumLength, requiredLength);
return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`;
};
const update = () => {
if (disposed) return;
cancelClear();
const count = source.value;
formatted.value = format(count);
if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
formatted.value = "";
maximumLength = 1;
}, linger);
};
source.onChanged(update);
return asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
});
}
@@ -0,0 +1,139 @@
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
STATUS_COUNTER_INACTIVE_LINGER_MS,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
describe("createMinimumVisibleActivityCount", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps a short activity visible for the configured minimum lifetime", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
const rendered = reactive(() => `active:${display.value}`);
expect(rendered.value).toBe("active:0");
source.value = 1;
expect(rendered.value).toBe("active:1");
vi.advanceTimersByTime(50);
source.value = 0;
expect(display.value).toBe(1);
vi.advanceTimersByTime(99);
expect(display.value).toBe(1);
vi.advanceTimersByTime(1);
expect(display.value).toBe(0);
expect(rendered.value).toBe("active:0");
display.dispose();
});
it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
source.value = 1;
vi.advanceTimersByTime(25);
source.value = 2;
expect(display.value).toBe(2);
source.value = 0;
vi.advanceTimersByTime(50);
source.value = 1;
expect(display.value).toBe(1);
vi.advanceTimersByTime(75);
source.value = 0;
expect(display.value).toBe(0);
source.value = 3;
source.value = 0;
expect(display.value).toBe(3);
vi.advanceTimersByTime(150);
expect(display.value).toBe(0);
display.dispose();
});
it("cancels pending work and stops observing its source when disposed", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
source.value = 1;
source.value = 0;
display.dispose();
vi.advanceTimersByTime(150);
source.value = 2;
expect(display.value).toBe(1);
});
});
describe("createPaddedCounterLabel", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps the widest counter label until its inactive linger period ends", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📥");
expect(display.value).toBe("");
source.value = 9;
expect(display.value).toBe(" 📥\u20079");
source.value = 123;
expect(display.value).toBe(" 📥\u2007123");
source.value = 0;
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
vi.advanceTimersByTime(1);
expect(display.value).toBe("");
source.value = 7;
expect(display.value).toBe(" 📥\u20077");
display.dispose();
});
it("cancels the pending clear when counter activity resumes", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄");
source.value = 1;
source.value = 0;
vi.advanceTimersByTime(1_000);
source.value = 2;
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
expect(display.value).toBe(" 📄\u20072");
display.dispose();
});
it("cancels its inactive timer and source subscription when disposed", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄");
source.value = 4;
source.value = 0;
display.dispose();
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
source.value = 5;
expect(display.value).toBe(" 📄\u20070");
});
});
+2 -2
View File
@@ -66,7 +66,7 @@ npm run test:e2e:obsidian:local-suite:services
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. `test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
The same workflow checks the remote-activity status boundary. It holds the real one-shot replication immediately before its replicator call, confirms that the status bar contains `📲` while finite replication is active, releases it, and then requires the finite and bounded activity counts to return to zero, the HTTP request and response counts to balance, and `📲` to disappear. It then creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active state deterministic without replacing either remote operation. The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation.
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory. If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
@@ -87,7 +87,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
npm run test:e2e:obsidian:cli-to-obsidian-sync npm run test:e2e:obsidian:cli-to-obsidian-sync
``` ```
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. `test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. `test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
+71 -14
View File
@@ -2,8 +2,24 @@ import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Page } from "playwright"; import type { Page } from "playwright";
import { withObsidianPage } from "./ui.ts"; import { withObsidianPage } from "./ui.ts";
import {
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
} from "../../../src/modules/features/RemoteActivityStatus.ts";
export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity"; export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity";
export const REMOTE_ACTIVITY_GATE_KIND = {
chunkFetch: "chunk-fetch",
oneShot: "one-shot",
trackedRequest: "tracked-request",
} as const;
export const REMOTE_ACTIVITY_EXPECTED_STATE = {
chunkFetchActive: "chunk-fetch-active",
finiteReplicationActive: "finite-replication-active",
idle: "idle",
trackedRequestActive: "tracked-request-active",
} as const;
export type RemoteActivityGateKind = (typeof REMOTE_ACTIVITY_GATE_KIND)[keyof typeof REMOTE_ACTIVITY_GATE_KIND];
export type RemoteActivitySnapshot = { export type RemoteActivitySnapshot = {
boundedRemoteActivityCount: number; boundedRemoteActivityCount: number;
@@ -11,14 +27,17 @@ export type RemoteActivitySnapshot = {
gateDone?: boolean; gateDone?: boolean;
gateEntered?: boolean; gateEntered?: boolean;
gateError?: string; gateError?: string;
gateKind?: string; gateKind?: RemoteActivityGateKind;
requestCount: number; requestCount: number;
remoteOperationIndicatorVisible: boolean;
remoteRequestIndicatorVisible: boolean;
responseCount: number; responseCount: number;
statusBarFound: boolean; statusBarFound: boolean;
statusBarText: string; statusBarText: string;
}; };
export type ExpectedRemoteActivityState = "chunk-fetch-active" | "finite-replication-active" | "idle"; export type ExpectedRemoteActivityState =
(typeof REMOTE_ACTIVITY_EXPECTED_STATE)[keyof typeof REMOTE_ACTIVITY_EXPECTED_STATE];
type RuntimeCounter = { value?: number }; type RuntimeCounter = { value?: number };
@@ -39,7 +58,7 @@ type RuntimeGate = {
done?: boolean; done?: boolean;
entered?: boolean; entered?: boolean;
error?: string; error?: string;
kind?: string; kind?: RemoteActivityGateKind;
}; };
type RendererGlobals = typeof globalThis & { type RendererGlobals = typeof globalThis & {
@@ -52,7 +71,7 @@ type RendererGlobals = typeof globalThis & {
async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteActivitySnapshot> { async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteActivitySnapshot> {
return await page.evaluate( return await page.evaluate(
({ pluginId, stateKey }) => { ({ operationIcon, pluginId, requestIcon, stateKey }) => {
const globals = globalThis as RendererGlobals; const globals = globalThis as RendererGlobals;
const core = globals.app?.plugins?.plugins?.[pluginId]?.core; const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`); if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`);
@@ -68,12 +87,23 @@ async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteAct
gateError: gate?.error, gateError: gate?.error,
gateKind: gate?.kind, gateKind: gate?.kind,
requestCount: Number(core.services?.API?.requestCount?.value ?? -1), requestCount: Number(core.services?.API?.requestCount?.value ?? -1),
remoteOperationIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(operationIcon)
),
remoteRequestIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(requestIcon)
),
responseCount: Number(core.services?.API?.responseCount?.value ?? -1), responseCount: Number(core.services?.API?.responseCount?.value ?? -1),
statusBarFound: statusBars.length > 0, statusBarFound: statusBars.length > 0,
statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"), statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"),
} satisfies RemoteActivitySnapshot; } satisfies RemoteActivitySnapshot;
}, },
{ pluginId: "obsidian-livesync", stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY } {
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
pluginId: "obsidian-livesync",
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
}
); );
} }
@@ -105,7 +135,7 @@ export async function waitForRemoteActivityState(
try { try {
return await withObsidianPage(port, async (page) => { return await withObsidianPage(port, async (page) => {
await page.waitForFunction( await page.waitForFunction(
({ expectedState, pluginId, stateKey }) => { ({ expectedState, expectedStates, gateKinds, operationIcon, pluginId, requestIcon, stateKey }) => {
const globals = globalThis as RendererGlobals; const globals = globalThis as RendererGlobals;
const core = globals.app?.plugins?.plugins?.[pluginId]?.core; const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
if (!core) return false; if (!core) return false;
@@ -117,31 +147,58 @@ export async function waitForRemoteActivityState(
const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1); const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1);
const requests = Number(core.services?.API?.requestCount?.value ?? -1); const requests = Number(core.services?.API?.requestCount?.value ?? -1);
const responses = Number(core.services?.API?.responseCount?.value ?? -1); const responses = Number(core.services?.API?.responseCount?.value ?? -1);
const iconVisible = statusBarText.includes("📲"); const operationIconVisible = statusBarText.includes(operationIcon);
const requestIconVisible = statusBarText.includes(requestIcon);
const trackedRequests = Math.max(0, requests - responses);
if (expectedState === "finite-replication-active") { if (expectedState === expectedStates.finiteReplicationActive) {
return ( return (
gate?.kind === "one-shot" && gate?.kind === gateKinds.oneShot &&
gate.entered === true && gate.entered === true &&
bounded > 0 && bounded > 0 &&
finite > 0 && finite > 0 &&
iconVisible operationIconVisible &&
!requestIconVisible &&
trackedRequests === 0
); );
} }
if (expectedState === "chunk-fetch-active") { if (expectedState === expectedStates.chunkFetchActive) {
return ( return (
gate?.kind === "chunk-fetch" && gate?.kind === gateKinds.chunkFetch &&
gate.entered === true && gate.entered === true &&
bounded > 0 && bounded > 0 &&
finite === 0 && finite === 0 &&
iconVisible operationIconVisible &&
!requestIconVisible &&
trackedRequests === 0
); );
} }
return bounded === 0 && finite === 0 && requests === responses && !iconVisible; if (expectedState === expectedStates.trackedRequestActive) {
return (
gate?.kind === gateKinds.trackedRequest &&
gate.entered === true &&
bounded === 0 &&
finite === 0 &&
trackedRequests > 0 &&
!operationIconVisible &&
statusBarText.includes(`${requestIcon}${trackedRequests}`)
);
}
return (
bounded === 0 &&
finite === 0 &&
requests === responses &&
!operationIconVisible &&
!requestIconVisible
);
}, },
{ {
expectedState: expected, expectedState: expected,
expectedStates: REMOTE_ACTIVITY_EXPECTED_STATE,
gateKinds: REMOTE_ACTIVITY_GATE_KIND,
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
pluginId: "obsidian-livesync", pluginId: "obsidian-livesync",
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY, stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
}, },
{ timeout: timeoutMs } { timeout: timeoutMs }
@@ -1,11 +1,15 @@
import { evalObsidianJson } from "./cli.ts"; import { evalObsidianJson } from "./cli.ts";
import { REMOTE_ACTIVITY_E2E_STATE_KEY } from "./remoteActivity.ts"; import {
REMOTE_ACTIVITY_E2E_STATE_KEY,
REMOTE_ACTIVITY_GATE_KIND,
type RemoteActivityGateKind,
} from "./remoteActivity.ts";
export type HeldRemoteActivityResult = { export type HeldRemoteActivityResult = {
done: boolean; done: boolean;
entered: boolean; entered: boolean;
error?: string; error?: string;
kind: "chunk-fetch" | "one-shot"; kind: RemoteActivityGateKind;
requestedIds?: string[]; requestedIds?: string[];
result?: boolean; result?: boolean;
resultCount?: number; resultCount?: number;
@@ -27,7 +31,7 @@ export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS
"const original=replicator.openReplication;", "const original=replicator.openReplication;",
"let releaseGate;", "let releaseGate;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});", "const gate=new Promise((resolve)=>{releaseGate=resolve;});",
"const state={kind:'one-shot',entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};", `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{replicator.openReplication=original;};", "state.restore=()=>{replicator.openReplication=original;};",
"host[stateKey]=state;", "host[stateKey]=state;",
@@ -74,7 +78,7 @@ export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.Process
"let resolveDone;", "let resolveDone;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});", "const gate=new Promise((resolve)=>{releaseGate=resolve;});",
"const donePromise=new Promise((resolve)=>{resolveDone=resolve;});", "const donePromise=new Promise((resolve)=>{resolveDone=resolve;});",
"const state={kind:'chunk-fetch',entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};", `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.chunkFetch)},entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{replicator.fetchRemoteChunks=original;};", "state.restore=()=>{replicator.fetchRemoteChunks=original;};",
"host[stateKey]=state;", "host[stateKey]=state;",
@@ -103,6 +107,51 @@ export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.Process
); );
} }
export async function startHeldTrackedRequest(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<{ started: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const host=globalThis;",
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const remote=core.services.remote;",
"const api=core.services.API;",
"const settings=core.services.setting.currentSettings();",
"const original=api.webCompatFetch;",
"let releaseGate;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.trackedRequest)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{api.webCompatFetch=original;};",
"host[stateKey]=state;",
"api.webCompatFetch=async function(...args){",
"state.entered=true;",
"await gate;",
"return await original.apply(this,args);",
"};",
"state.promise=(async()=>{",
"try{",
"const base=String(settings.couchDB_URI).replace(/\\/$/,'');",
"const database=encodeURIComponent(settings.couchDB_DBNAME);",
"const credentials=btoa(`${settings.couchDB_USER}:${settings.couchDB_PASSWORD}`);",
"const response=await remote.performFetch(`${base}/${database}/_all_docs?limit=0`,{headers:{Authorization:`Basic ${credentials}`}});",
"state.result=response.ok;",
"}catch(error){",
"state.error=error instanceof Error?error.message:String(error);",
"}finally{",
"state.restore();",
"state.done=true;",
"}",
"})();",
"return JSON.stringify({started:true});",
"})()",
].join(""),
env
);
}
export async function finishHeldRemoteActivity( export async function finishHeldRemoteActivity(
cliBinary: string, cliBinary: string,
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
+45 -8
View File
@@ -16,13 +16,18 @@ import {
waitForLiveSyncCoreReady, waitForLiveSyncCoreReady,
type LocalDatabaseEntry, type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts"; } from "../runner/liveSyncWorkflow.ts";
import { captureRemoteActivityDiagnostics, waitForRemoteActivityState } from "../runner/remoteActivity.ts"; import {
REMOTE_ACTIVITY_EXPECTED_STATE,
captureRemoteActivityDiagnostics,
waitForRemoteActivityState,
} from "../runner/remoteActivity.ts";
import { import {
cleanUpHeldRemoteActivity, cleanUpHeldRemoteActivity,
clearHeldRemoteActivity, clearHeldRemoteActivity,
finishHeldRemoteActivity, finishHeldRemoteActivity,
startHeldChunkFetch, startHeldChunkFetch,
startHeldOneShotReplication, startHeldOneShotReplication,
startHeldTrackedRequest,
waitForRestoredChunk, waitForRestoredChunk,
} from "../runner/remoteActivityWorkflow.ts"; } from "../runner/remoteActivityWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
@@ -115,21 +120,46 @@ async function main(): Promise<void> {
await prepareRemote(cli.binary, session.cliEnv); await prepareRemote(cli.binary, session.cliEnv);
activityStage = "initial-idle"; activityStage = "initial-idle";
const initialIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle"); const initialIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive;
await startHeldTrackedRequest(cli.binary, session.cliEnv);
const trackedRequestActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive
);
const trackedRequestResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(trackedRequestResult.error, undefined, "The observed CouchDB request failed.");
assertEqual(trackedRequestResult.result, true, "The observed CouchDB request did not report success.");
activityStage = "tracked-request-idle";
const trackedRequestIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (trackedRequestIdle.requestCount <= initialIdle.requestCount) {
throw new Error("The held CouchDB request did not advance the tracked remote-request count.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
activityStage = "one-shot-active"; activityStage = "one-shot-active";
await startHeldOneShotReplication(cli.binary, session.cliEnv); await startHeldOneShotReplication(cli.binary, session.cliEnv);
const oneShotActive = await waitForRemoteActivityState( const oneShotActive = await waitForRemoteActivityState(
session.remoteDebuggingPort, session.remoteDebuggingPort,
"finite-replication-active" REMOTE_ACTIVITY_EXPECTED_STATE.finiteReplicationActive
); );
const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed."); assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed.");
assertEqual(oneShotResult.result, true, "One-shot replication did not report success."); assertEqual(oneShotResult.result, true, "One-shot replication did not report success.");
activityStage = "one-shot-idle"; activityStage = "one-shot-idle";
const oneShotIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle"); const oneShotIdle = await waitForRemoteActivityState(
if (oneShotIdle.requestCount <= initialIdle.requestCount) { session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (oneShotIdle.requestCount <= trackedRequestIdle.requestCount) {
throw new Error("One-shot replication did not make an observed remote request."); throw new Error("One-shot replication did not make an observed remote request.");
} }
await clearHeldRemoteActivity(cli.binary, session.cliEnv); await clearHeldRemoteActivity(cli.binary, session.cliEnv);
@@ -154,9 +184,12 @@ async function main(): Promise<void> {
const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk; const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk;
const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`; const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`;
await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId }); await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId });
activityStage = "chunk-fetch-active"; activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive;
await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId); await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId);
const chunkFetchActive = await waitForRemoteActivityState(session.remoteDebuggingPort, "chunk-fetch-active"); const chunkFetchActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive
);
const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual( assertEqual(
chunkFetchResult.error, chunkFetchResult.error,
@@ -172,7 +205,10 @@ async function main(): Promise<void> {
const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId); const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId);
assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk."); assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk.");
activityStage = "chunk-fetch-idle"; activityStage = "chunk-fetch-idle";
const chunkFetchIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle"); const chunkFetchIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) { if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) {
throw new Error("On-demand chunk fetching did not make an observed remote request."); throw new Error("On-demand chunk fetching did not make an observed remote request.");
} }
@@ -183,6 +219,7 @@ async function main(): Promise<void> {
); );
console.log( console.log(
[ [
`Tracked request: ${trackedRequestActive.statusBarText.trim()} -> idle`,
`One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`, `One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`,
`Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`, `Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`,
`Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`, `Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`,
+18 -1
View File
@@ -17,6 +17,7 @@ import {
} from "../runner/objectStorage.ts"; } from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -115,13 +116,29 @@ async function main(): Promise<void> {
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow."); assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
await prepareRemote(cli.binary, session.cliEnv); await prepareRemote(cli.binary, session.cliEnv);
const activityBeforeUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv); await pushLocalChanges(cli.binary, session.cliEnv);
const activityAfterUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) {
throw new Error("Object Storage synchronisation did not advance the tracked remote-request count.");
}
assertEqual(
activityAfterUpload.responseCount,
activityAfterUpload.requestCount,
"Object Storage remote-request counters did not rebalance after synchronisation."
);
const keys = await waitForObjectStorageObjects(bucketPrefix); const keys = await waitForObjectStorageObjects(bucketPrefix);
console.log( console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))` `Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
); );
} finally { } finally {
if (session) { if (session) {
+4
View File
@@ -10,6 +10,10 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
- Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed. - Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed.
- Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes. - Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes.
### Improved
- Split the status-bar remote activity display into `📲` for a finite remote operation and `🌐N` for the approximate number of tracked CouchDB or Object Storage requests currently in progress.
## 0.25.82 ## 0.25.82
15th July, 2026 15th July, 2026