Skip to content

Commit b0cbf1f

Browse files
ralyodioclaude
andauthored
fix(fleet-sync): create the destination path, keep same-path syncs flat, show the real error (#66)
A Fleet Sync of ~/Desktop/me/ideas from bonita to dev2 failed with code 11 ("io/write error") and the error was unreadable. Four fixes: - Fleet Sync now passes --mkpath (via buildPlan overrides; the planner drops it with a warning on rsync older than 3.2.3), so a path that does not exist yet on a destination is created instead of failing with code 11. - An empty (or identical) destination now sends the source into its parent, so the same path lands at the same path. Before, rsync's trailing-slash rule would have produced ~/Desktop/me/ideas/ideas. - rsync's "mkdir ... failed: No such file or directory" is reported as what it is (a missing parent folder), and code 11 no longer claims the destination "may be full or read-only". - A failed destination shows an error block under its row: the cause, rsync's own lines (kept, not overwritten by the exit summary), selectable text, and a Copy error button. It used to be a faint truncated snippet in an otherwise empty panel, and the app's no-select default made it uncopyable. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 17d3dbd commit b0cbf1f

6 files changed

Lines changed: 158 additions & 17 deletions

File tree

‎apps/desktop/electron/main/services/fleet-sync.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export async function startFleetSync(request: FleetSyncRequest, sender: WebConte
4747
// A bucket is not a fleet host: refuse it on its own row.
4848
const target = await resolveConnection(destId)
4949
if (isS3Connection(target)) throw new Error(`${target.name} is an S3 bucket; Fleet Sync pushes to servers.`)
50-
const built = await buildPlan(req)
50+
// The same path rarely exists on every host already: create it (--mkpath,
51+
// dropped with a warning by the planner when either rsync is older than 3.2.3).
52+
const built = await buildPlan(req, { mkpath: true })
5153
cleanup = built.cleanup
5254
const handle = runPlan(built.plan)
5355
cancels.set(destId, handle.cancel)

‎apps/desktop/src/components/fleet-sync-panel.tsx‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client'
22

33
import { useCallback, useEffect, useMemo, useState } from 'react'
4-
import { CircleAlert, CircleCheck, CircleDashed, HardDriveDownload, Loader2, Play, Square } from 'lucide-react'
4+
import { CircleAlert, CircleCheck, CircleDashed, Copy, HardDriveDownload, Loader2, Play, Square } from 'lucide-react'
55
import { Button } from '@/components/ui/button'
66
import { Checkbox } from '@/components/ui/checkbox'
77
import { PathInput, type PathSource } from '@/components/path-input'
8+
import { destinationFor, errorReport } from '@/lib/fleet-sync'
89
import { ScrollArea } from '@/components/ui/scroll-area'
910
import { api, unwrap, type Connection, type FleetSyncEvent } from '@/lib/api'
1011

@@ -18,7 +19,19 @@ import { api, unwrap, type Connection, type FleetSyncEvent } from '@/lib/api'
1819
*/
1920

2021
type DestState = 'pending' | 'running' | 'done' | 'error'
21-
type DestView = { id: string; name: string; host: string; state: DestState; percent: number; message: string }
22+
type DestView = {
23+
id: string
24+
name: string
25+
host: string
26+
state: DestState
27+
percent: number
28+
/** The last line worth showing on the row while it runs, then the outcome. */
29+
message: string
30+
/** rsync's own stderr, kept so a failure shows its real cause, not just the summary. */
31+
log: string[]
32+
}
33+
34+
const LOG_LINES = 30
2235

2336
const STATE_ICON: Record<DestState, React.ReactNode> = {
2437
pending: <CircleDashed className="size-3.5 text-faint" />,
@@ -61,7 +74,10 @@ export function FleetSyncPanel() {
6174
else if (e.type === 'progress') {
6275
next.state = 'running'
6376
next.percent = Math.max(0, Math.min(100, Math.round(e.progress.percent)))
64-
} else if (e.type === 'stderr') next.message = e.line
77+
} else if (e.type === 'stderr') {
78+
next.message = e.line
79+
next.log = [...cur.log, e.line].slice(-LOG_LINES)
80+
}
6581
else if (e.type === 'error') {
6682
next.state = 'error'
6783
next.message = e.message
@@ -100,7 +116,7 @@ export function FleetSyncPanel() {
100116
const initial: Record<string, DestView> = {}
101117
for (const id of ids) {
102118
const s = servers.find((x) => x.id === id)
103-
initial[id] = { id, name: s?.name ?? id, host: s?.host ?? '', state: 'pending', percent: 0, message: '' }
119+
initial[id] = { id, name: s?.name ?? id, host: s?.host ?? '', state: 'pending', percent: 0, message: '', log: [] }
104120
}
105121
setDests(initial)
106122

@@ -113,14 +129,25 @@ export function FleetSyncPanel() {
113129
api()?.fleet.sync({
114130
source,
115131
destinationConnectionIds: ids,
116-
destinationPath: destPath.trim() || sourcePath.trim(),
132+
destinationPath: destinationFor(sourcePath, destPath),
117133
options: {},
118134
concurrency: 4,
119135
}),
120136
)
121137
setSyncId(res?.syncId ?? null)
122138
}, [selected, sourceId, sourcePath, destPath, servers])
123139

140+
const [copied, setCopied] = useState<string | null>(null)
141+
const copyError = useCallback(async (d: DestView) => {
142+
try {
143+
await navigator.clipboard.writeText(errorReport(d))
144+
setCopied(d.id)
145+
setTimeout(() => setCopied((cur) => (cur === d.id ? null : cur)), 1500)
146+
} catch {
147+
// The text stays selectable in the block, so a refused clipboard is not a dead end.
148+
}
149+
}, [])
150+
124151
const cancel = useCallback(async () => {
125152
if (syncId) await api()?.fleet.cancelSync(syncId)
126153
}, [syncId])
@@ -203,17 +230,41 @@ export function FleetSyncPanel() {
203230
<ScrollArea className="min-h-0 flex-1 rounded-md border border-line">
204231
<div className="flex flex-col">
205232
{Object.values(dests).map((d) => (
206-
<div key={d.id} className="flex items-center gap-2 px-2 py-1.5 text-[12px]">
207-
{STATE_ICON[d.state]}
208-
<span className="w-40 truncate">{d.name}</span>
209-
<div className="h-1.5 flex-1 overflow-hidden rounded bg-secondary">
210-
<div
211-
className={`h-full ${d.state === 'error' ? 'bg-destructive' : 'bg-primary'}`}
212-
style={{ width: `${d.percent}%` }}
213-
/>
233+
<div key={d.id} className="border-b border-line last:border-b-0">
234+
<div className="flex items-center gap-2 px-2 py-1.5 text-[12px]">
235+
{STATE_ICON[d.state]}
236+
<span className="w-40 truncate">{d.name}</span>
237+
<div className="h-1.5 flex-1 overflow-hidden rounded bg-secondary">
238+
<div
239+
className={`h-full ${d.state === 'error' ? 'bg-destructive' : 'bg-primary'}`}
240+
style={{ width: `${d.percent}%` }}
241+
/>
242+
</div>
243+
<span className="w-10 text-right text-faint">{d.percent}%</span>
244+
{d.message && d.state !== 'error' ? <span className="max-w-[40%] truncate text-faint">{d.message}</span> : null}
214245
</div>
215-
<span className="w-10 text-right text-faint">{d.percent}%</span>
216-
{d.message ? <span className="max-w-[40%] truncate text-faint">{d.message}</span> : null}
246+
{d.state === 'error' ? (
247+
<div className="mx-2 mb-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-[12px]" role="alert">
248+
<div className="flex items-start gap-2">
249+
<p className="flex-1 font-medium text-destructive selectable">{d.message || 'The transfer failed.'}</p>
250+
<Button
251+
variant="outline"
252+
size="sm"
253+
onClick={() => void copyError(d)}
254+
className="h-7 shrink-0 gap-1.5 text-[11px]"
255+
aria-label={`Copy the error for ${d.name}`}
256+
>
257+
<Copy className="size-3" />
258+
{copied === d.id ? 'Copied' : 'Copy error'}
259+
</Button>
260+
</div>
261+
{d.log.length ? (
262+
<pre className="mt-2 max-h-40 overflow-auto rounded bg-background/60 p-2 font-mono text-[11px] leading-relaxed whitespace-pre-wrap text-faint selectable">
263+
{d.log.join('\n')}
264+
</pre>
265+
) : null}
266+
</div>
267+
) : null}
217268
</div>
218269
))}
219270
{Object.keys(dests).length === 0 ? (
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { destinationFor, errorReport } from './fleet-sync'
3+
4+
describe('errorReport', () => {
5+
it('is the summary, the host, and rsync\'s own lines: what a person pastes', () => {
6+
const text = errorReport({
7+
name: 'dev2',
8+
host: 'dev2.profullstack.com',
9+
message: 'The destination folder /home/anthony/x could not be created',
10+
log: ['rsync: [Receiver] mkdir "/home/anthony/x" failed: No such file or directory (2)', 'rsync error: error in file IO (code 11)'],
11+
})
12+
expect(text).toBe(
13+
'Fleet Sync to dev2 (dev2.profullstack.com) failed: The destination folder /home/anthony/x could not be created\n\n' +
14+
'rsync: [Receiver] mkdir "/home/anthony/x" failed: No such file or directory (2)\nrsync error: error in file IO (code 11)',
15+
)
16+
})
17+
18+
it('stands alone when rsync said nothing', () => {
19+
expect(errorReport({ name: 'web1', host: '', message: 'Connection refused', log: [] })).toBe('Fleet Sync to web1 failed: Connection refused')
20+
})
21+
})
22+
23+
describe('destinationFor', () => {
24+
it('lands the same path at the same path when the destination is left empty', () => {
25+
// rsync would otherwise nest the folder: ~/Desktop/me/ideas/ideas.
26+
expect(destinationFor('~/Desktop/me/ideas', '')).toBe('~/Desktop/me/')
27+
expect(destinationFor('/srv/app/releases', ' ')).toBe('/srv/app/')
28+
expect(destinationFor('~/Desktop/me/ideas', '~/Desktop/me/ideas')).toBe('~/Desktop/me/')
29+
})
30+
31+
it('keeps a trailing-slash source as is: its contents go to the same folder', () => {
32+
expect(destinationFor('~/Desktop/me/ideas/', '')).toBe('~/Desktop/me/ideas/')
33+
})
34+
35+
it('never rewrites a destination the user typed', () => {
36+
expect(destinationFor('~/Desktop/me/ideas', '/backup/ideas')).toBe('/backup/ideas')
37+
})
38+
})

‎apps/desktop/src/lib/fleet-sync.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Fleet Sync's pure helpers, kept out of the component so they are tested
3+
* without the app's module aliases.
4+
*/
5+
6+
export type FailedDestination = { name: string; host: string; message: string; log: string[] }
7+
8+
/** The text a person pastes into an issue or a chat: what failed, where, and rsync's own words. */
9+
export function errorReport(d: FailedDestination): string {
10+
return [`Fleet Sync to ${d.name}${d.host ? ` (${d.host})` : ''} failed: ${d.message}`, ...(d.log.length ? ['', ...d.log] : [])].join('\n')
11+
}
12+
13+
/**
14+
* Where the source lands when the destination box is empty (or says the same
15+
* path): the same path on every host. rsync copies a folder named without a
16+
* trailing slash INTO its destination, so sending `~/a/ideas` to `~/a/ideas`
17+
* would make `~/a/ideas/ideas`. Sending it to the parent, `~/a/`, makes
18+
* `~/a/ideas`, and works for a single file too. A path the user typed that
19+
* differs from the source is theirs and goes through untouched.
20+
*/
21+
export function destinationFor(sourcePath: string, destPath: string): string {
22+
const source = sourcePath.trim()
23+
const dest = destPath.trim()
24+
if (dest && dest !== source) return dest
25+
if (source.endsWith('/')) return source
26+
const slash = source.lastIndexOf('/')
27+
if (slash === -1) return '.'
28+
return source.slice(0, slash + 1)
29+
}

‎packages/rsync-core/src/errors.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,17 @@ describe('humanizeStderr', () => {
5656
expect(humanizeStderr('some unrecognised failure')).toBeNull()
5757
})
5858
})
59+
60+
describe('a destination folder that cannot be created', () => {
61+
it('names the folder and the real cause, not a full disk', () => {
62+
const stderr = 'rsync: [Receiver] mkdir "/home/anthony/Desktop/me/ideas" failed: No such file or directory (2)\nrsync error: error in file IO (code 11) at main.c(800) [Receiver=3.4.1]'
63+
const message = humanizeStderr(stderr)
64+
expect(message).toContain('/home/anthony/Desktop/me/ideas')
65+
expect(message).toContain('a folder above it does not exist')
66+
expect(message).not.toMatch(/full|read-only/)
67+
})
68+
69+
it('no longer guesses "full or read-only" for a bare code 11', () => {
70+
expect(interpretExit(11).message).not.toMatch(/full|read-only/)
71+
})
72+
})

‎packages/rsync-core/src/errors.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const MESSAGES: Record<number, { message: string; resumable?: boolean; ok?: bool
2222
10: { message: 'Connection interrupted (socket I/O error). This transfer can be resumed.', resumable: true },
2323
11: {
2424
message:
25-
'File I/O error. The destination filesystem may be full or read-only. Partial transfer data was preserved where possible.',
25+
'rsync hit a file I/O error on one side (code 11). The rsync output says which path and why. Partial transfer data was preserved where possible.',
2626
resumable: true,
2727
},
2828
12: { message: 'The rsync data stream failed, usually because the connection dropped. This transfer can be resumed.', resumable: true },
@@ -67,6 +67,13 @@ export function interpretExit(code: number, signal: NodeJS.Signals | null = null
6767

6868
/** Recognises the common stderr shapes worth rewriting before a user sees them. */
6969
export function humanizeStderr(stderr: string): string | null {
70+
// Checked before the disk-full and permission cases: it is the commonest code-11
71+
// failure, and the old generic wording ("filesystem full or read-only") sent people
72+
// looking in the wrong place.
73+
const mkdir = /mkdir "([^"]+)" failed: No such file or directory/i.exec(stderr)
74+
if (mkdir) {
75+
return `The destination folder ${mkdir[1]} could not be created because a folder above it does not exist on that side. Create it first, or let DiskPush create the whole path (Fleet Sync does, with --mkpath, on rsync 3.2.3 or newer).`
76+
}
7077
if (/No space left on device/i.test(stderr)) {
7178
return 'Transfer stopped because the destination filesystem is out of space. Partial transfer data was preserved where possible.'
7279
}

0 commit comments

Comments
 (0)