@@ -749,10 +749,18 @@ async function copyAndVerifyFile(srcPath, dstPath, progressCallback, cancelCheck
749749 await copyFileStream ( srcE , dstE , progressCallback , cancelCheck ) ;
750750 const ac = new AbortController ( ) ;
751751 if ( cancelCheck ( ) ) ac . abort ( ) ;
752- const [ hSrc , hDst ] = await Promise . all ( [
753- hashFile ( srcE , ac . signal ) ,
754- hashFile ( dstE , ac . signal ) ,
755- ] ) ;
752+ // Poll cancelCheck during hashing so a mid-hash cancel of a large file aborts both
753+ // reads within ~200ms instead of running two full file reads to completion.
754+ const cancelPoll = setInterval ( ( ) => { if ( cancelCheck ( ) ) ac . abort ( ) ; } , 200 ) ;
755+ let hSrc , hDst ;
756+ try {
757+ [ hSrc , hDst ] = await Promise . all ( [
758+ hashFile ( srcE , ac . signal ) ,
759+ hashFile ( dstE , ac . signal ) ,
760+ ] ) ;
761+ } finally {
762+ clearInterval ( cancelPoll ) ;
763+ }
756764 if ( hSrc === hDst ) {
757765 try { const fd = await fs . promises . open ( dstE , 'r+' ) ; await fd . sync ( ) ; await fd . close ( ) ; } catch ( _ ) { }
758766 if ( srcStat ) {
@@ -1152,6 +1160,11 @@ async function buildFtpManifest(ftpConfig, rootPath, progressCallback, cancelChe
11521160
11531161 const queue = [ ] ; // seeded by probe below
11541162 const visited = new Set ( [ rootPath ] ) ;
1163+ // Dirs whose LIST never succeeded (even after reconnect/retries). For a real download
1164+ // walk (!sizeOnly) any such dir means the manifest is INCOMPLETE — we fail loudly so a
1165+ // Move never deletes the source after a partial transfer. sizeOnly stays best-effort.
1166+ const failedDirs = [ ] ;
1167+ const dirAttempts = new Map ( ) ; // dir -> LIST attempts, caps re-enqueues (no infinite loop)
11551168
11561169 // Event-based wake-up: replaces 5ms polling with zero-latency notification.
11571170 // Each time a new dir is pushed (or the last in-flight worker finishes),
@@ -1237,6 +1250,9 @@ async function buildFtpManifest(ftpConfig, rootPath, progressCallback, cancelChe
12371250 e . message . includes ( 'ECONNRESET' ) ||
12381251 e . message . includes ( 'closed' )
12391252 ) ;
1253+ const attempts = ( dirAttempts . get ( dir ) || 0 ) + 1 ;
1254+ dirAttempts . set ( dir , attempts ) ;
1255+ let recovered = false ;
12401256 if ( isBrokenState ) {
12411257 try { client . close ( ) ; } catch ( _ ) { }
12421258 if ( accessOpts_w ) {
@@ -1248,18 +1264,27 @@ async function buildFtpManifest(ftpConfig, rootPath, progressCallback, cancelChe
12481264 await newClient . access ( accessOpts_w ) ;
12491265 client = newClient ;
12501266 console . log ( '[FTP Manifest] Worker reconnected after client error' ) ;
1251- // BUG FIX: re-enqueue the failed dir so its files are counted.
1252- // Without this, a timed-out dir is permanently lost from the walk.
1253- enqueueDir ( dir ) ;
1267+ recovered = true ;
12541268 } catch ( reconnErr ) {
12551269 console . warn ( '[FTP Manifest] Worker reconnect failed:' , reconnErr . message ) ;
1256- // BUG FIX: do NOT decrement inFlight here — finally does it.
1257- // Previous code had an explicit inFlight-- + return which caused
1258- // double-decrement with finally → inFlight went negative →
1259- // all workers parked forever and sizing hung indefinitely.
1270+ // Do NOT decrement inFlight here — finally does it (a previous explicit
1271+ // inFlight-- caused a double-decrement → workers parked forever).
12601272 workerDead = true ; // signal loop to break after finally runs
12611273 }
12621274 }
1275+ } else {
1276+ recovered = true ; // client still usable — retry the dir
1277+ }
1278+ // Re-enqueue for another attempt (capped at 3 to avoid an infinite re-list
1279+ // loop on a persistently-failing dir); otherwise record it as failed so the
1280+ // manifest-incomplete guard below aborts a Move instead of deleting a source.
1281+ // visited.delete is required: enqueueDir() skips already-visited dirs, so the
1282+ // re-enqueue would silently no-op without it.
1283+ if ( recovered && attempts < 3 ) {
1284+ visited . delete ( dir ) ;
1285+ enqueueDir ( dir ) ;
1286+ } else {
1287+ failedDirs . push ( dir ) ;
12631288 }
12641289 // fall through to finally (inFlight--, wakeWorkers)
12651290 }
@@ -1369,6 +1394,13 @@ async function buildFtpManifest(ftpConfig, rootPath, progressCallback, cancelChe
13691394 clients . forEach ( c => { try { c . close ( ) ; } catch ( _ ) { } } ) ;
13701395 }
13711396
1397+ // For a real download walk, a directory we never managed to list means the manifest
1398+ // is incomplete — fail loudly so downloadFtpFolder rethrows and a Move never deletes
1399+ // the source after a partial transfer. sizeOnly walks remain best-effort.
1400+ if ( ! sizeOnly && failedDirs . length > 0 ) {
1401+ throw new Error ( `Manifest incomplete: failed to list ${ failedDirs . length } dir(s), e.g. ${ failedDirs [ 0 ] } ` ) ;
1402+ }
1403+
13721404 const result = { files, totalSize, fileCount : files . length , dirCount, fromCache : false } ;
13731405 sizeCache . set ( cacheKey , result ) ;
13741406 diskSizeCache [ cacheKey ] = { totalSize, fileCount : files . length , topLevelCount, cachedAt : Date . now ( ) } ;
@@ -2399,11 +2431,22 @@ async function downloadFtpFolder(ftpConfig, remotePath, localPath, progressCallb
23992431 const localDest = path . join ( localPath , ...fileEntry . relPath . split ( '/' ) ) ;
24002432 await fs . promises . mkdir ( toExtendedPath ( path . dirname ( localDest ) ) , { recursive : true } ) ;
24012433
2434+ // Resume skip: if the destination already exists at the exact expected size,
2435+ // it was fully downloaded on a prior run — skip it (partial files differ in size
2436+ // and are still re-fetched). Mirrors the local copy resume behavior.
2437+ const _existing = await fs . promises . stat ( toExtendedPath ( localDest ) ) . catch ( ( ) => null ) ;
2438+ if ( _existing && _existing . size === fileEntry . size && fileEntry . size > 0 ) {
2439+ bytesCopied += fileEntry . size ;
2440+ progressCallback ?. ( { type : 'go-file-progress' , fileRel : path . basename ( fileEntry . remotePath ) , totalBytesCopied : bytesCopied , totalBytes : totalSize } ) ;
2441+ continue ;
2442+ }
2443+
24022444 // Retry up to 5 attempts on transient FTP errors — also auto-reconnects on disconnect
24032445 let downloaded = false ;
24042446 for ( let attempt = 1 ; attempt <= 5 && ! downloaded ; attempt ++ ) {
24052447 try {
2406- await client . downloadTo ( localDest , fileEntry . remotePath ) ;
2448+ // Extended path so long PS5 game trees (>260 chars) don't fail on Windows.
2449+ await client . downloadTo ( toExtendedPath ( localDest ) , fileEntry . remotePath ) ;
24072450 downloaded = true ;
24082451 } catch ( e ) {
24092452 if ( cancelCheck ?. ( ) ) throw new Error ( 'Cancelled' ) ;
@@ -2576,21 +2619,49 @@ async function uploadFtpRecursive(client, localPath, remotePath, progressCallbac
25762619 if ( cancelCheck ( ) ) throw new Error ( 'Cancelled' ) ;
25772620 const localItem = path . join ( localPath , ent . name ) ;
25782621 const remoteItem = path . posix . join ( remotePath , ent . name ) ;
2579- if ( ent . isFile ( ) ) {
2622+ // NTFS junctions/symlinks (PS5 Sc0/Sc1/-app game-data folders) classify as
2623+ // neither isFile() nor isDirectory() under withFileTypes on Windows. stat()
2624+ // follows the junction (as findAllParamJsons/listAllFilesWithStats do) — without
2625+ // this the whole subtree is silently skipped, producing an incomplete upload that
2626+ // is then reported as success (and the local source deleted on a Move).
2627+ const _st = ( ent . isFile ( ) || ent . isDirectory ( ) ) ? null : await fs . promises . stat ( localItem ) . catch ( ( ) => null ) ;
2628+ const isFile = ent . isFile ( ) || ( _st && _st . isFile ( ) ) ;
2629+ const isDir = ent . isDirectory ( ) || ( _st && _st . isDirectory ( ) ) ;
2630+ if ( isFile ) {
25802631 let uploaded = false ;
25812632 let lastErr = null ;
25822633 for ( let attempt = 1 ; attempt <= 5 && ! uploaded ; attempt ++ ) {
25832634 try {
25842635 if ( speedLimitBps > 0 ) {
2585- const rs = fs . createReadStream ( localItem , { highWaterMark : bufBytes } ) ;
2586- const throttle = new ThrottledStream ( speedLimitBps ) ;
2587- // Forward stream errors so the transfer fails cleanly rather than hanging
2588- rs . on ( 'error' , ( e ) => throttle . destroy ( e ) ) ;
2589- rs . pipe ( throttle ) ;
2590- await client . uploadFrom ( throttle , remoteItem ) ;
2636+ let rs , throttle ;
2637+ try {
2638+ rs = fs . createReadStream ( localItem , { highWaterMark : bufBytes } ) ;
2639+ throttle = new ThrottledStream ( speedLimitBps ) ;
2640+ // Forward stream errors so the transfer fails cleanly rather than hanging
2641+ rs . on ( 'error' , ( e ) => throttle . destroy ( e ) ) ;
2642+ rs . pipe ( throttle ) ;
2643+ await client . uploadFrom ( throttle , remoteItem ) ;
2644+ } finally {
2645+ // Release the fd/stream on every attempt (success or failure) so a failed
2646+ // retry doesn't leak a file handle or keep a detached pipe alive.
2647+ try { rs && rs . destroy ( ) ; } catch ( _ ) { }
2648+ try { throttle && throttle . destroy ( ) ; } catch ( _ ) { }
2649+ }
25912650 } else {
25922651 await client . uploadFrom ( localItem , remoteItem ) ;
25932652 }
2653+ // Verify the remote byte count matches local before declaring success.
2654+ // basic-ftp resolves uploadFrom on the server's 226 reply; PS5 FTP can 226 a
2655+ // short write, so confirm SIZE. If the server lacks SIZE (<0/throws), trust
2656+ // the 226 rather than failing forever.
2657+ {
2658+ const localSize = ( await fs . promises . stat ( localItem ) . catch ( ( ) => ( { size : - 1 } ) ) ) . size ;
2659+ let remoteSize = - 1 ;
2660+ try { remoteSize = await client . size ( remoteItem ) ; } catch ( _ ) { remoteSize = - 1 ; }
2661+ if ( remoteSize >= 0 && localSize >= 0 && remoteSize !== localSize ) {
2662+ throw new Error ( `size mismatch for ${ ent . name } : local ${ localSize } remote ${ remoteSize } ` ) ;
2663+ }
2664+ }
25942665 uploaded = true ;
25952666 } catch ( e ) {
25962667 lastErr = e ;
@@ -2622,7 +2693,7 @@ async function uploadFtpRecursive(client, localPath, remotePath, progressCallbac
26222693 if ( ! uploaded ) throw new Error ( `FTP upload failed for file: ${ ent . name } — ${ lastErr ?. message || 'unknown error' } ` ) ;
26232694 const size = ( await fs . promises . stat ( localItem ) . catch ( ( ) => ( { size : 0 } ) ) ) . size || 0 ;
26242695 progressCallback ?. ( { type : 'go-file-complete' , fileRel : ent . name , totalBytesCopied : size , totalBytes : totalSize } ) ;
2625- } else if ( ent . isDirectory ( ) ) {
2696+ } else if ( isDir ) {
26262697 await uploadFtpRecursive ( client , localItem , remoteItem , progressCallback , cancelCheck , totalSize , speedLimitBps , bufBytes , accessConfig ) ;
26272698 }
26282699 }
@@ -2898,10 +2969,12 @@ async function doEnsureAndPopulate(event, opts) {
28982969
28992970 const results = [ ] ;
29002971 // Pre-compute once so progressFn doesn't call .reduce() on every IPC event
2901- const _grandTotalBytes = items . reduce ( ( s , x ) => s + ( x . totalSize || 0 ) , 0 ) ;
2972+ // Sum only positive sizes — the -1 "size unavailable" sentinel (and 0) must not
2973+ // subtract from or pollute the free-space estimate.
2974+ const _grandTotalBytes = items . reduce ( ( s , x ) => s + ( x . totalSize > 0 ? x . totalSize : 0 ) , 0 ) ;
29022975 try {
2903- // ── Free-space pre-check ─────────────────────────────────────────────── ──
2904- if ( ( action === 'copy' || action === 'copy-fast' ) && _grandTotalBytes > 0 ) {
2976+ // ── Free-space pre-check (also covers Move: a cross-device move is a full copy) ──
2977+ if ( ( action === 'copy' || action === 'copy-fast' || action === 'move' ) && _grandTotalBytes > 0 ) {
29052978 try {
29062979 if ( ! ftpConfig && ! ftpDestConfig ) {
29072980 // Local-to-local: use OS free space API
@@ -3167,7 +3240,13 @@ async function doEnsureAndPopulate(event, opts) {
31673240 try {
31683241 await dc . access ( { host : ftpDestConfig . host , port : parseInt ( ftpDestConfig . port ) , user : ftpDestConfig . user || 'anonymous' , password : ftpDestConfig . pass || '' , secure : false } ) ;
31693242 await ftpDeleteRecursive ( dc , ftpDestRemotePath ) ;
3170- } catch ( e ) { console . warn ( '[FTP] Pre-overwrite delete failed:' , e . message ) ; }
3243+ } catch ( e ) {
3244+ // ftpDeleteRecursive no-ops on a missing target, so a thrown error here is
3245+ // a real failure (shallow/protected path or remove error). Abort the item
3246+ // rather than uploading on top of the old version (silent merge/corruption).
3247+ console . warn ( '[FTP] Pre-overwrite delete failed:' , e . message ) ;
3248+ throw new Error ( 'Overwrite aborted — could not clear existing folder: ' + e . message ) ;
3249+ }
31713250 finally { dc . close ( ) ; }
31723251 }
31733252 } else {
@@ -3186,11 +3265,15 @@ async function doEnsureAndPopulate(event, opts) {
31863265 applyFtpPassive ( rc , ftpDestConfig ) ;
31873266 try {
31883267 await rc . access ( { host : ftpDestConfig . host , port : parseInt ( ftpDestConfig . port ) , user : ftpDestConfig . user || 'anonymous' , password : ftpDestConfig . pass || '' , secure : false } ) ;
3268+ let renamed = false ;
31893269 for ( let n = 1 ; n <= 100 ; n ++ ) {
31903270 const tryR = ftpDestRemotePath + ` (${ n } )` ;
31913271 try { await rc . cd ( tryR ) ; await rc . list ( ) ; }
3192- catch ( _ ) { ftpDestRemotePath = tryR ; finalTarget = finalTarget + ` (${ n } )` ; break ; }
3272+ catch ( _ ) { ftpDestRemotePath = tryR ; finalTarget = finalTarget + ` (${ n } )` ; renamed = true ; break ; }
31933273 }
3274+ // All 100 numbered candidates exist — fall back to a timestamp suffix so we
3275+ // never merge into the original conflicting folder (matches the catch below).
3276+ if ( ! renamed ) { const ts = Date . now ( ) ; ftpDestRemotePath += ` (${ ts } )` ; finalTarget += ` (${ ts } )` ; }
31943277 } catch ( e ) {
31953278 const ts = Date . now ( ) ; ftpDestRemotePath += ` (${ ts } )` ; finalTarget += ` (${ ts } )` ;
31963279 } finally { rc . close ( ) ; }
@@ -3228,6 +3311,18 @@ async function doEnsureAndPopulate(event, opts) {
32283311 if ( ftpConfig && action === 'move' &&
32293312 ftpConfig . host === ftpDestConfig . host &&
32303313 String ( ftpConfig . port ) === String ( ftpDestConfig . port ) ) {
3314+ // Guard: when the destination resolves to the SAME server path as the
3315+ // source (e.g. moving/restoring a game in-place with overwrite), a
3316+ // server-side rename is a no-op and the download+upload+delete fallback
3317+ // would delete the just-restored game. Treat as already-satisfied.
3318+ const _nSrc = ( '/' + String ( srcFolder ) . replace ( / ^ \/ + / , '' ) ) . replace ( / \/ \/ + / g, '/' ) . replace ( / \/ $ / , '' ) ;
3319+ const _nDst = ( '/' + String ( remotePath ) . replace ( / ^ \/ + / , '' ) ) . replace ( / \/ \/ + / g, '/' ) . replace ( / \/ $ / , '' ) ;
3320+ if ( _nSrc === _nDst ) {
3321+ console . warn ( '[FTP] Same-server move: source === destination — skipping (no-op):' , _nDst ) ;
3322+ results . push ( { item : safeGameName , target : finalTarget , moved : true , skipped : true , source : originalSrcFolder , safeGameName, totalSize : itemTotalBytes } ) ;
3323+ progressFn ( { type : 'go-file-complete' , fileRel : path . basename ( finalTarget ) , totalBytesCopied : 0 , totalBytes : 0 } ) ;
3324+ continue ;
3325+ }
32313326 const client = new ftp . Client ( 15000 ) ; // 15s: rename is fast but needs round-trips
32323327 applyFtpPassive ( client , ftpConfig ) ;
32333328 let renameOk = false ;
0 commit comments