From a78acc799dbfea2c06ceec763814b16cb74254b4 Mon Sep 17 00:00:00 2001 From: algiadev8 <57441686+algiadev8@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:24:47 +0900 Subject: [PATCH] fix(cli): stop watch after terminal sync failures --- apps/cli/README.md | 2 +- apps/cli/src/app/context.ts | 5 + apps/cli/src/commands/watch.ts | 98 ++++++++++++++++--- packages/sync-client/src/engine.ts | 1 + .../sync-client/src/sync/engine/auto-sync.ts | 12 ++- .../src/sync/runtime/sync-engine.ts | 6 +- 6 files changed, 107 insertions(+), 17 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index bdd8fa97..2921b985 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ synch logout # sign out, clear stored keys synch vault connect --vault-id # unlock a remote vault for a directory synch pull # download only; never upload local changes synch sync # one-shot synchronization -synch watch # keep syncing until interrupted +synch watch # keep syncing until interrupted or stopped by a terminal sync error synch status # account, vault, and sync state ``` diff --git a/apps/cli/src/app/context.ts b/apps/cli/src/app/context.ts index 6276596b..ac1cfa02 100644 --- a/apps/cli/src/app/context.ts +++ b/apps/cli/src/app/context.ts @@ -20,6 +20,7 @@ import { InMemorySyncDiagnostics } from "@synch/sync-client/diagnostics"; import { SyncEngine, + type SyncTerminalStopReason, type UserVisibleSyncProgress, type UserVisibleSyncState, } from "@synch/sync-client/engine"; @@ -69,6 +70,7 @@ export class CliAppContext { syncProgress: UserVisibleSyncProgress = { completedEntries: 0, totalEntries: 0 }; onSyncStatusChange: (() => void) | null = null; onReconcileRequested: (() => void) | null = null; + onSyncTerminalStop: ((reason: SyncTerminalStopReason) => void) | null = null; private store: SqliteSyncStore | null = null; private lock: VaultLock | null = null; @@ -183,6 +185,9 @@ export class CliAppContext { this.lastSyncError = error; this.logger.error(`sync failed (${phase}): ${describeError(error)}`); }, + onSyncTerminalStop: (reason) => { + this.onSyncTerminalStop?.(reason); + }, notifySyncConflict: (event) => { this.logger.log(formatSyncConflictNotice(event)); }, diff --git a/apps/cli/src/commands/watch.ts b/apps/cli/src/commands/watch.ts index 740b4628..0dc4da5e 100644 --- a/apps/cli/src/commands/watch.ts +++ b/apps/cli/src/commands/watch.ts @@ -1,3 +1,4 @@ +import type { SyncTerminalStopReason } from "@synch/sync-client/engine"; import type { CliAppContext } from "../app/context"; import { describeError } from "../app/context"; import { formatSyncProgressSuffix, formatSyncStatusLabel } from "../app/notices"; @@ -41,23 +42,92 @@ export async function runWatch(ctx: CliAppContext): Promise { ctx.engine.registerVaultEvents(); await ctx.engine.reconcileOnce(); await ctx.engine.waitForLocalMutationWork(); - await ctx.engine.startAutoSync(); - await ctx.engine.syncNow(); - ctx.logger.log("Watching for changes. Press Ctrl+C to stop."); + const watchEnd = waitForWatchEnd(ctx); + try { + await ctx.engine.startAutoSync(); + await ctx.engine.syncNow(); + if (watchEnd.isSettled()) { + const result = await watchEnd.promise; + if (result.kind === "terminal_stop") { + return reportTerminalStop(ctx, result.reason); + } + ctx.logger.log("Stopping..."); + return 0; + } - await waitForShutdownSignal(); - ctx.logger.log("Stopping..."); - return 0; + watchEnd.listenForShutdownSignals(); + ctx.logger.log("Watching for changes. Press Ctrl+C to stop."); + const result = await watchEnd.promise; + if (result.kind === "signal") { + ctx.logger.log("Stopping..."); + return 0; + } + return reportTerminalStop(ctx, result.reason); + } finally { + watchEnd.dispose(); + ctx.onSyncTerminalStop = null; + } } -function waitForShutdownSignal(): Promise { - return new Promise((resolve) => { - const onSignal = () => { +type WatchEndResult = + | { kind: "signal" } + | { kind: "terminal_stop"; reason: SyncTerminalStopReason }; + +function waitForWatchEnd(ctx: CliAppContext): { + promise: Promise; + isSettled: () => boolean; + listenForShutdownSignals: () => void; + dispose: () => void; +} { + let settled = false; + let resolveResult: (result: WatchEndResult) => void = () => {}; + const promise = new Promise((resolve) => { + resolveResult = resolve; + }); + const finish = (result: WatchEndResult) => { + if (settled) { + return; + } + settled = true; + resolveResult(result); + }; + const onSignal = () => finish({ kind: "signal" }); + ctx.onSyncTerminalStop = (reason) => finish({ kind: "terminal_stop", reason }); + let listeningForSignals = false; + + return { + promise, + isSettled: () => settled, + listenForShutdownSignals: () => { + if (listeningForSignals) { + return; + } + listeningForSignals = true; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + }, + dispose: () => { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); - resolve(); - }; - process.on("SIGINT", onSignal); - process.on("SIGTERM", onSignal); - }); + }, + }; +} + +function reportTerminalStop(ctx: CliAppContext, reason: SyncTerminalStopReason): 1 { + switch (reason.type) { + case "remote_vault_unavailable": + ctx.logger.error( + `Sync stopped: remote vault is unavailable (${reason.error.reason}): ${describeError(reason.error)}`, + ); + return 1; + case "sync_history_mismatch": + // The engine already reports the detailed history mismatch error. + ctx.logger.error("Watch stopped because sync cannot continue with this history."); + return 1; + case "storage_quota_exceeded": + ctx.logger.error( + "Sync stopped: remote vault storage quota exceeded. Free space in the remote vault, then restart `synch watch`.", + ); + return 1; + } } diff --git a/packages/sync-client/src/engine.ts b/packages/sync-client/src/engine.ts index d58d7630..f21023d3 100644 --- a/packages/sync-client/src/engine.ts +++ b/packages/sync-client/src/engine.ts @@ -1,4 +1,5 @@ export type { ReconcileOnceResult } from "./sync/engine/local-reconcile-service"; +export type { SyncTerminalStopReason } from "./sync/engine/auto-sync"; export * from "./sync/runtime/change-source"; export * from "./sync/runtime/sync-engine"; export * from "./sync/runtime/user-visible-status"; diff --git a/packages/sync-client/src/sync/engine/auto-sync.ts b/packages/sync-client/src/sync/engine/auto-sync.ts index d4ef024a..688a75f3 100644 --- a/packages/sync-client/src/sync/engine/auto-sync.ts +++ b/packages/sync-client/src/sync/engine/auto-sync.ts @@ -55,10 +55,16 @@ export interface SyncAutoLoopDeps { onIdle?: () => void; onRetryScheduled?: (input: { attempt: number; delayMs: number }) => void; onError?: (error: unknown) => void; + onTerminalStop?: (reason: SyncTerminalStopReason) => void; onRemoteVaultUnavailable?: (error: RemoteVaultUnavailableError) => void | Promise; onStorageQuotaExceeded?: () => void | Promise; } +export type SyncTerminalStopReason = + | { type: "remote_vault_unavailable"; error: RemoteVaultUnavailableError } + | { type: "sync_history_mismatch"; error: Error } + | { type: "storage_quota_exceeded" }; + export interface SyncRealtimeClientLike { openSession: SyncRealtimeClient["openSession"]; } @@ -472,6 +478,7 @@ export class SyncAutoLoop { if (isCursorAheadOfServerError(error)) { this.stop(); this.handleError(error); + this.deps.onTerminalStop?.({ type: "sync_history_mismatch", error }); return; } @@ -708,6 +715,7 @@ export class SyncAutoLoop { await this.deps.onStorageQuotaExceeded?.(); } finally { this.stop(); + this.deps.onTerminalStop?.({ type: "storage_quota_exceeded" }); } return; } @@ -727,6 +735,7 @@ export class SyncAutoLoop { if (isCursorAheadOfServerError(error)) { this.stop(); this.handleError(error); + this.deps.onTerminalStop?.({ type: "sync_history_mismatch", error }); return; } @@ -823,6 +832,7 @@ export class SyncAutoLoop { private handleRemoteVaultUnavailable(error: RemoteVaultUnavailableError): void { this.stop(); + this.deps.onTerminalStop?.({ type: "remote_vault_unavailable", error }); void this.deps.onRemoteVaultUnavailable?.(error); } } @@ -831,7 +841,7 @@ function isRealtimeConnectionError(error: unknown): boolean { return error instanceof SyncRealtimeConnectionError; } -function isCursorAheadOfServerError(error: unknown): boolean { +function isCursorAheadOfServerError(error: unknown): error is SyncRealtimeError { return ( error instanceof SyncRealtimeError && error.code === "cursor_ahead_of_server" ); diff --git a/packages/sync-client/src/sync/runtime/sync-engine.ts b/packages/sync-client/src/sync/runtime/sync-engine.ts index eb5d28f7..61e7128c 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.ts @@ -9,7 +9,7 @@ import type { SyncDiagnostics, SyncFailurePhase, } from "../diagnostics/types"; -import { SyncAutoLoop } from "../engine/auto-sync"; +import { SyncAutoLoop, type SyncTerminalStopReason } from "../engine/auto-sync"; import type { SyncTokenResponse } from "../remote/client"; import { SyncEventGate } from "../engine/event-gate"; import { SyncEventRecorder } from "../engine/event-recorder"; @@ -99,6 +99,7 @@ export interface SyncEngineDeps { hasActiveRemoteVaultSession: () => boolean; diagnostics: SyncDiagnostics; onSyncError: (error: unknown, phase: SyncFailurePhase) => void | Promise; + onSyncTerminalStop?: (reason: SyncTerminalStopReason) => void; notifySyncConflict: (event: { op: "upsert" | "delete"; reason?: "local_pending_mutation" | "remote_path_collision"; @@ -302,6 +303,9 @@ export class SyncEngine { onError: (error) => { void this.deps.onSyncError(error, "auto_sync"); }, + onTerminalStop: (reason) => { + this.deps.onSyncTerminalStop?.(reason); + }, onRetryScheduled: ({ attempt, delayMs }) => { this.deps.diagnostics.record({ type: "retry_scheduled",