Skip to content

Commit c9d0841

Browse files
committed
fix(sync): let a device with no local copy join a File Sync folder whose attachment files have not arrived yet instead of refusing the folder forever
1 parent 1ca8c9f commit c9d0841

5 files changed

Lines changed: 108 additions & 2 deletions

File tree

docs/release-notes/diagnostics-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Field names are checked against the log sanitizer by `packages/core/src/release-
1919
### Added
2020

2121
- **`v1.2.8/desktop-reminder-fired`**`apps/desktop/src/lib/notification-service.tsx`, in `checkDueAndNotify` (`logReminderFired`), at all three fire sites: due-time repeats, task reminders and project review reminders. Message: `Desktop reminder fired`. Fields: `kind` (`due-repeat` | `task` | `project`), `entity` (`task` | `project`), `fireAt` (ISO occurrence time), `appState` (`focused` | `hidden`). No task title or body is ever logged. Tester's log: a user who sees no toast now has one line proving the scheduler fired, which separates a scheduling bug from a delivery bug (#1146).
22+
- **`v1.2.8/file-activation-absent-blobs`**`packages/core/src/sync-run.ts`, in `prepareRemoteWriteData` after `assertActivationAttachmentsProven`. Message: `Sync folder activation accepted attachments the folder does not hold yet`. Fields: `backend` (`file`), `deferred` (count), `ids` (first five attachment ids). Tester's log: a fresh desktop joining a Syncthing or mounted folder whose `attachments/` has not arrived now shows this line and the switch completes, where 1.2.7 logged `Sync failed … Candidate attachment proof failed for <id>` and left the previous sync settings active; the ids listed must later download on a normal cycle (in-app feedback, 2026-09-04).
2223
- **`v1.2.8/desktop-notification-path`** — same file, in `sendNotification` (`logNotificationSent` / `logNotificationFailed`). Messages: `Desktop notification sent` and `Desktop notification send failed`. Fields: `path` (`flatpak` | `windows-packaged` | `plugin` | `web`) and, on the failure line, `error`. Tester's log: on a Microsoft Store install the line must read `path=windows-packaged`; a Store install that still falls through to `plugin` means the packaged command rejected, and the warning names the HRESULT it rejected with (#1146).
2324

2425
## v1.2.7 (add before tagging, trim in the release after)

docs/release-notes/unreleased.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Changes collected after `v1.2.7` and before the next version tag.
66

77
- Desktop Timeline: the view is easier to read. A line now separates one project from the next, task bars are drawn in a lighter tint of the project color so the project bar stands out as their parent, a task title is shown once in the name column on the left instead of also on the bar, task bars are thinner than the solid project bar, the daily and weekly gridlines are drawn as crisp lines instead of a repeating pattern that blurred into soft vertical bands on scaled displays, and the view opens centered on today and re-centers when you change the zoom. (#1111)
88

9+
- Sync: a desktop joining a File Sync folder whose attachment files had not arrived yet (a Syncthing or mounted folder that carried `data.json` but not `attachments/`) refused the folder with "Candidate attachment proof failed for <id>" and kept the previous sync settings, on every retry. When the device holds no copy of the file, the switch now completes and the record stays downloadable, so the file arrives on a later sync once the folder delivers it. A device that does hold the file is still refused. (from in-app feedback)
10+
911
- Sync: leaving the app mid-sync (switching to another app on a phone, or a background sync hitting its deadline) could leave the shared sync lock behind on Dropbox and WebDAV, and every device then reported "Remote sync is temporarily reserved by mindwtr-mobile" and waited up to five minutes before syncing again. The abort cancelled the request that removes the lock. Lock requests on desktop and mobile now finish independently of the abort, so an interrupted cycle still releases its lock. The "Sync follow-up scheduled" log line also now reports the delay that actually applies instead of only the pacing delay. (from a v1.2.7 device log)
1012

1113
- Sync: a phone could keep syncing every second or two with no changes, warning "syncConflictDiscarded" for the same attachments on every cycle, after another device had deleted attachments the phone still listed. Two causes are fixed. The app kept its own older copy of a task whenever only its attachments had changed, and then wrote that copy back over what sync had just stored. And two devices that hold the same records in a different order were treated as different, so every cycle uploaded again and a self-hosted server answered with a merge each time. The order of records no longer counts as a change, and attachment-only changes now replace the in-memory task. (#1136)

packages/core/src/release-diagnostics-fields.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const RELEASE_CHECK_FIELD_NAMES = [
2121
'decision', 'registered', 'storedInterval', 'interval', 'appState',
2222
// desktop-reminder-fired / desktop-notification-path (apps/desktop/src/lib/notification-service.tsx)
2323
'kind', 'entity', 'fireAt', 'path', 'error',
24+
'deferred', 'ids',
2425
];
2526

2627
describe('release diagnostics field names', () => {

packages/core/src/sync-run.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2523,6 +2523,78 @@ describe('activation proof with unrecoverable attachments (#1119)', () => {
25232523
]);
25242524
});
25252525

2526+
it('activates a sync folder whose blob has not arrived yet when this device holds no bytes', async () => {
2527+
// A fresh desktop joins a Syncthing folder that carries data.json but not
2528+
// attachments/ yet: the file backend finds no blob, tombstones nothing, and
2529+
// leaves the record keyed + missing. Refusing here strands the folder
2530+
// forever; the record stays downloadable for a later cycle instead.
2531+
const remoteTask = createTask('t-absent-blob', 'Blob not synced yet');
2532+
remoteTask.attachments = [fileAttachment('attachment-absent', 'Arrives later')];
2533+
const syncAttachments = vi.fn(async (data: AppData) => data);
2534+
const { harness, io, run } = createHarness({
2535+
backend: 'file',
2536+
local: createData(),
2537+
remote: createData([remoteTask]),
2538+
activationProbe: true,
2539+
io: { syncAttachments },
2540+
});
2541+
2542+
const result = await run();
2543+
2544+
expect(result.success).toBe(true);
2545+
expect(io.writeRemote).toHaveBeenCalledTimes(1);
2546+
// The key survives (localStatus is device-local and never written remotely)
2547+
// and nothing was tombstoned, so a later cycle can still download it.
2548+
expect(harness.remote?.tasks[0]?.attachments).toEqual([
2549+
expect.objectContaining({
2550+
id: 'attachment-absent',
2551+
cloudKey: 'attachments/attachment-absent.txt',
2552+
}),
2553+
]);
2554+
expect(harness.remote?.tasks[0]?.attachments?.[0]?.deletedAt).toBeUndefined();
2555+
expect(harness.warnings).toContainEqual(
2556+
expect.objectContaining({ message: 'Sync folder activation accepted attachments the folder does not hold yet' }),
2557+
);
2558+
});
2559+
2560+
it('still refuses a sync folder whose blob is absent when this device held bytes for the record', async () => {
2561+
const localTask = createTask('t-held-absent', 'Held here, absent there');
2562+
localTask.attachments = [{
2563+
id: 'attachment-held-absent',
2564+
kind: 'file',
2565+
title: 'Held locally',
2566+
uri: '/managed/held-absent.txt',
2567+
localStatus: 'available',
2568+
fileHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
2569+
createdAt: STAMP,
2570+
updatedAt: STAMP,
2571+
}];
2572+
const remoteTask = cloneAppData(createData([localTask])).tasks[0]!;
2573+
remoteTask.attachments![0] = {
2574+
...remoteTask.attachments![0]!,
2575+
uri: '',
2576+
cloudKey: 'attachments/attachment-held-absent.txt',
2577+
fileHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
2578+
localStatus: 'missing',
2579+
};
2580+
const syncAttachments = vi.fn(async (data: AppData) => data);
2581+
const { io, run } = createHarness({
2582+
backend: 'file',
2583+
local: createData([localTask]),
2584+
remote: createData([remoteTask]),
2585+
activationProbe: true,
2586+
io: { syncAttachments },
2587+
});
2588+
2589+
const result = await run();
2590+
2591+
expect(result).toMatchObject({
2592+
success: false,
2593+
error: expect.stringContaining('Candidate attachment proof failed for attachment-held-absent'),
2594+
});
2595+
expect(io.writeRemote).not.toHaveBeenCalled();
2596+
});
2597+
25262598
it('refuses activation when the trial tombstones a record this device held bytes for', async () => {
25272599
const localTask = createTask('t-held-bytes', 'Held bytes');
25282600
localTask.attachments = [{

packages/core/src/sync-run.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,9 @@ const assertActivationAttachmentsProven = (
425425
noLocalBytesIds: ReadonlySet<string>,
426426
backend: SyncBackend,
427427
originalCloudKeys: ReadonlyMap<string, string>,
428-
): void => {
428+
): string[] => {
429429
const resolved = new Set<string>();
430+
const deferred: string[] = [];
430431
const owners: Array<['task' | 'project', { title?: string; deletedAt?: string; attachments?: Attachment[] }]> = [
431432
...data.tasks.map((task) => ['task', task] as ['task', typeof task]),
432433
...data.projects.map((project) => ['project', project] as ['project', typeof project]),
@@ -463,6 +464,23 @@ const assertActivationAttachmentsProven = (
463464
if (expectedIds.has(attachment.id)) resolved.add(attachment.id);
464465
continue;
465466
}
467+
// File Sync: a blob the folder does not hold yet is not a verdict. A
468+
// replicator (Syncthing, a mounted drive) can deliver it after the
469+
// switch, and this device holds no bytes that waiting could lose. Keep
470+
// the key, leave the record missing for a later cycle to download,
471+
// instead of refusing the folder forever (a fresh desktop joining a
472+
// folder whose attachments/ had not arrived, 2026-09-04 feedback).
473+
if (
474+
backend === 'file'
475+
&& noLocalBytesIds.has(attachment.id)
476+
&& attachment.cloudKey
477+
&& attachment.localStatus === 'missing'
478+
&& attachment.pendingContentUpload !== true
479+
) {
480+
deferred.push(attachment.id);
481+
if (expectedIds.has(attachment.id)) resolved.add(attachment.id);
482+
continue;
483+
}
466484
if (
467485
!attachment.cloudKey
468486
|| attachment.localStatus !== 'available'
@@ -478,6 +496,7 @@ const assertActivationAttachmentsProven = (
478496
if (resolved.size !== expectedIds.size) {
479497
throw new Error(`Candidate attachment proof incomplete: expected ${expectedIds.size}, proved ${resolved.size}`);
480498
}
499+
return deferred;
481500
};
482501

483502
class SharedSyncRunMachine {
@@ -1443,14 +1462,25 @@ class SharedSyncRunMachine {
14431462
? result
14441463
: fallbackRetry.data;
14451464
}
1446-
assertActivationAttachmentsProven(
1465+
const deferredIds = assertActivationAttachmentsProven(
14471466
provenData,
14481467
activationSnapshot.expectedIds,
14491468
activationSnapshot.metadataOnlyIds,
14501469
activationSnapshot.noLocalBytesIds,
14511470
this.backend,
14521471
activationSnapshot.originalCloudKeys,
14531472
);
1473+
if (deferredIds.length > 0) {
1474+
this.notifier.logWarningExtra(
1475+
'Sync folder activation accepted attachments the folder does not hold yet',
1476+
{
1477+
releaseCheck: '1.2.8/file-activation-absent-blobs',
1478+
backend: this.backend,
1479+
deferred: String(deferredIds.length),
1480+
ids: deferredIds.slice(0, 5).join(','),
1481+
},
1482+
);
1483+
}
14541484
this.ensureLocalSnapshotFresh();
14551485
this.notifier.onDiagnostic?.({
14561486
event: 'attachments-prepare-complete',

0 commit comments

Comments
 (0)