Compare commits

...
Author SHA1 Message Date
bincxz 667e91152b fix(sftp): wait for pending write OPEN before fastPut fallback
Expose pendingWriteOpenPathGate on the transfer so non-resumable concurrent
isolated failures wait for their own truncating OPEN to settle before
fastPut rewrites the same stage.
2026-08-06 08:55:49 +08:00
bincxz 7872a3049c fix(sftp): classify superseded before cancelled cleanup
When a cancelled attempt loses ownership to a same-id retry mid-OPEN, the
error is Transfer superseded while cancelled is still true. Handle supersede
first so cancel cleanup cannot delete the retry stage, free its leases, or
emit a terminal cancelled event for the live owner.
2026-08-06 08:50:41 +08:00
bincxz 3a939b64a8 fix(sftp): wait for live owner without fixed superseded deadline
Superseded invokes now wait only on the live same-id transfer's terminal
status (or cancel). A 30-minute timeout could mark a still-running long
transfer failed and stick that status after the owner later completed.
2026-08-06 08:45:32 +08:00
bincxz e1e92dc255 fix(sftp): hold path gate until late unlink settles
Transfer cancel can settle after a 2s unlink grace so dead channels do not
hang invokes, but the host/path gate stays held until the unlink callback
actually fires so a delayed unlink cannot delete a same-path retry stage.
2026-08-06 08:39:26 +08:00
bincxz 46ed3722c3 fix(sftp): never force-release isolated write path gates
Hold host/path gates for isolated truncating OPENs until the OPEN callback
settles, matching shared-channel behavior. Force-releasing after sftp.end()
allowed a late OPEN to wipe a completed same-path destination with no live
transfer left to invalidate.
2026-08-06 08:33:48 +08:00
bincxz c30e1734ea fix(sftp): narrow fastPut unlink skip; handle superseded in all callers
Only suppress late-stage unlink when the same transfer moved to
fastPut/stream — ordinary cancel still unlinks. Background restart and
dedicated resume wait for the live same-id owner instead of treating
superseded invokes as completed.
2026-08-06 08:28:54 +08:00
bincxz df18a67d9f fix(sftp): do not unlink stage after same-transfer fastPut fallback
When concurrent isolated OPEN dies without a callback and upload falls back
to fastPut on the same generated stage, a late OPEN must not unlink that
stage. Skip unlink when the same transfer already accepted an OPEN, owns
stagedRemote, or moved to a fastPut/stream strategy.
2026-08-06 08:19:57 +08:00
bincxz 98b26f31e7 fix(sftp): pass host IDs on background restart; scope stale-OPEN invalidation
Background agent resume now includes source/target host IDs so path gates stay
host-scoped after openSftpForSession. Stale OPEN invalidation requires a
matching host or sftp session instead of treating missing host as a wildcard.
2026-08-06 08:14:02 +08:00
bincxz 4d19404121 fix(sftp): do not complete directory children on superseded invoke
When a same-id retry steals ownership mid-OPEN, the old startStreamTransfer
returns { superseded: true }. Wait for the live owner's terminal task status
instead of treating that result as a successful child completion.
2026-08-06 08:03:54 +08:00
bincxz b17f64e98b fix(sftp): copy host IDs onto transfer for path-gate keys
dedicatedTransferResume passes targetHostId with a new targetSftpId. Keep
those host IDs on the transfer object so sharedWriteOpenSessionKey stays
host-scoped across session replacement instead of falling back to sftpId.
2026-08-06 07:55:32 +08:00
bincxz d44886e601 fix(sftp): close late shared read OPEN handles after settle
Restores close of late shared/sudo OPEN "r" handles after cancel or channel
error settle. Write-only gate invalidation remains for isolated late OPENs.
2026-08-06 07:48:52 +08:00
bincxz 298d155e1c fix(sftp): hold path gate for isolated write OPENs until settle
Isolated truncating OPENs previously released the host/path gate on cancel
as soon as sftp.end() was called, allowing a same-path retry to race a still
in-flight OPEN that can truncate the stage without invalidation. Keep the gate
until the OPEN callback (or a bounded force-release after end), and run late
OPEN invalidation for isolated channels too.
2026-08-06 07:43:40 +08:00
bincxz 760155758c fix(sftp): keep cancel active during post-gate checkpoint revalidation
After the path gate opens, afterPathGate may stat a remote stage. Keep the
transfer abort hook and channel-error listener live for that await so cancel
or a dead channel can still release the path gate and lease.
2026-08-06 07:36:29 +08:00
bincxz f642580d4e fix(sftp): clear staleOpenTruncatedStage after gated full restart
When a late truncating OPEN zeros the stage while a retry waits on the path
gate, afterPathGate restarts from offset zero. Consume the stale flag there so
a successful full rewrite is not failed by the post-upload guard.
2026-08-06 07:29:35 +08:00
bincxz 81979ebbef fix(sftp): dynamic truncating flags; cross-id stale OPEN; bound unlink
Resolve r+/w at OPEN/unlink time so afterPathGate flag getters still unlink
generated stages on cancel. Invalidate any same-path active transfer (not only
same-id) when a late truncating OPEN lands, and force checkpoint restart when
the stage was wiped before OPEN accepted. Bound best-effort unlink so a dead
channel cannot hang cancel settle.
2026-08-06 07:16:59 +08:00
bincxz 4639cdfdb6 fix(sftp): path-gate isolated writes; revalidate checkpoint after gate
Gate all write OPENs including isolated channels so a shared stale OPEN "w"
cannot race. After waiting on the path gate, re-stat the stage and shrink
the resume checkpoint (and flags) before issuing r+/w.
2026-08-06 07:02:42 +08:00
bincxz 40db393fd7 fix(sftp): serialize shared and isolated OPENs by host+path
Path gates keyed only by SFTP object let an isolated-channel retry race a
stale shared truncating OPEN on the same host path. Prefer targetHostId so
both channels wait on the same gate.
2026-08-06 06:50:28 +08:00
bincxz 294b7a4bcc fix(sftp): path-gate r+ resume OPENs; release only unused superseded leases
Serialize all shared write opens including resume r+. On superseded, release
only SFTP leases not also held by the live same-id retry.
2026-08-06 06:41:05 +08:00
bincxz 3030820305 fix(sftp): hold path gate until unlink callback settles
Remove the 2s force-complete on late shared OPEN unlink so a delayed
server unlink cannot race a same-id retry that already reopened the stage.
2026-08-06 06:21:50 +08:00
bincxz 0ee643860d fix(sftp): bound CLOSE on pre-settle cancel OPEN path
Reuse the 2s close timeout for cancel-before-settle so a dead channel
cannot leave the path gate held after finishCancel never runs.
2026-08-06 06:12:59 +08:00
bincxz e6dfdc9ec7 fix(sftp): do not return result.error for superseded transfer attempts
Renderer directory ops treat any startStreamTransfer error as failure.
Superseded same-id OPEN attempts should return a non-error marker so the
live retry is not marked failed.
2026-08-06 05:55:27 +08:00
bincxz f4a92074ae fix(sftp): keep SFTP leases when suppressing superseded attempts
Superseded OPEN cleanup must not release transferId leases — a same-id
retry shares that lease set entry and would lose its hold if we release.
2026-08-06 05:38:15 +08:00
bincxz ee05822755 chore: nudge Codex automation after cancelled run 2026-08-06 05:15:36 +08:00
bincxz 5f3e3e2a35 chore: re-trigger Codex for path-gate head 2026-08-06 05:00:04 +08:00
bincxz f9a2cdd158 fix(sftp): always suppress terminal events for superseded OPEN attempts
Superseded means this attempt lost ownership. Do not require the retry to
still own activeTransfers — if the retry already completed, emitting
cancelled for the same transferId rewrites a completed transfer.
2026-08-06 04:49:49 +08:00
bincxz 52f0248c14 chore: re-trigger Codex review automation 2026-08-06 04:39:21 +08:00
bincxz 8f898653dd fix(sftp): bound late OPEN close before releasing path gate
After drain force-complete, a late truncating OPEN must still close the
handle before releasing the path gate — but a dead channel may never
invoke CLOSE. Race close with a 2s timeout so same-path uploads cannot
wait forever on pathGate.waitForPrior.
2026-08-06 04:12:16 +08:00
bincxz 1a8cac206c fix(sftp): skip upload fallback when OPEN is superseded by a retry
A same-id retry can already own the transfer while the old OPEN callback
still rejects. Mark that rejection noTransferFallback so we never truncate
the shared stage via prepareUploadFallbackCheckpoint under the retry.
2026-08-06 03:58:47 +08:00
bincxz 6834bed158 fix(sftp): never force-release path gate on OPEN drain force-complete
A dead-channel force-complete must free the transfer/lease, but keep the
path gate held until the OPEN callback actually settles. Force-releasing
lets a same-path retry write then get wiped by a late truncating OPEN.
2026-08-06 03:48:19 +08:00
bincxz a0b2ce010b fix(sftp): hold path gate until OPEN settles; 30s dead-channel grace
Force-complete no longer frees the gate immediately. Late OPEN release
keeps retries serialized; a 30s grace frees dead channels.
2026-08-06 03:33:08 +08:00
bincxz 9d87df6c72 fix(sftp): release path gate on OPEN force-complete for dead channels
When the OPEN callback never arrives, free same-path waiters after drain
force-complete so later uploads do not hang forever on the gate.
2026-08-06 03:23:23 +08:00
bincxz f45f6a776a fix(sftp): silence superseded OPEN terminal events for live retries
When a same-id retry already owns activeTransfers, do not emit cancelled
or failed for the stale superseded attempt or the transfer center sticks.
2026-08-06 03:04:16 +08:00
bincxz dbfb411af4 fix(sftp): emit cancelled channel for superseded OPEN failures
Worker fan-out only maps transfer:error as cancelled for cancel messages.
Superseded attempts now use transfer:cancelled so retries are not failed.
2026-08-06 02:55:01 +08:00
bincxz d777a66aa8 fix(sftp): treat superseded OPEN as cancelled, not failed
Late OPEN rejections after a same-id retry must not broadcast a terminal
failed event that marks the live retry task failed in transfer center.
2026-08-06 02:46:23 +08:00
bincxz 5d8e23232e fix(sftp): reject path-gate waits when the SFTP channel errors
Queued same-path OPEN waiters now observe channel error while waiting on
the prior gate, so a dead session cannot pin lease/admission forever.
2026-08-06 02:37:05 +08:00
bincxz 1f08f82c1b test(sftp): accept normal {transferId,totalBytes} retry success
startTransferNow returns transferId/totalBytes on success, not success:true.
2026-08-06 02:19:00 +08:00
bincxz 9c783e2f14 fix(sftp): keep path gate held until OPEN settles (no hard cap)
Remove the post-force-complete gate timeout so a different transferId
cannot reopen the same target while a stale truncating OPEN is pending.
2026-08-06 02:09:35 +08:00
bincxz 7f1f3c0088 fix(sftp): hold truncating OPEN path gates for in-place targets too
In-place shared OPEN force-complete no longer frees the path gate
immediately, and late OPEN invalidation applies to final-path retries so
a stale truncating OPEN cannot wipe an in-flight same-id rewrite.
2026-08-06 01:58:58 +08:00
bincxz 709b27a1bb test(sftp): wait for generated-stage path gate hold on retry OPEN
After force-complete, generated stages keep the path gate for 10s more.
Align the late-OPEN retry test wait with that hold.
2026-08-06 01:47:09 +08:00
bincxz b1c2b84812 fix(sftp): hold reusable-stage path gate past OPEN force-complete
Force-completing drain no longer frees the path gate for generated stages
immediately. Same-id retries stay serialized until the late OPEN settles
(or a 10s hard cap) so promotion cannot race a still-processing OPEN w.
2026-08-06 01:37:15 +08:00
bincxz c2ad7030ad fix(sftp): keep path gate chained when canceling a queued OPEN wait
Releasing a cancelled waiter's gate immediately cleared the barrier while
the prior truncating OPEN was still in flight. Chain the waiter's release
to waitForPrior so later same-path attempts stay serialized.
2026-08-06 01:27:09 +08:00
bincxz ae0ae72262 fix(sftp): refuse stage promote after stale OPEN truncation
A late force-completed OPEN w from a prior same-id attempt can zero the
deterministic stage after concurrent write finishes. Latch
staleOpenTruncatedStage through assertCanPromote and uploadFileConcurrent
so promotion cannot rename a corrupt stage.
2026-08-06 01:18:04 +08:00
bincxz 2898c4c04c fix(sftp): scope truncating OPEN path gates per SFTP session
A module-global path-only key serialized unrelated hosts that shared the
same remote pathname. Include transfer/sftp session identity in the gate
key so only same-endpoint opens wait on each other.
2026-08-06 01:08:34 +08:00
bincxz c3682460e3 fix(sftp): make truncating shared OPEN path-gate waits cancelable
A same-path upload queued behind a prior truncating OPEN could keep its
lease/admission slot forever if cancelled before runOpen. Race the gate
wait with transfer.abort and release the gate + drain on cancel.
2026-08-06 00:59:12 +08:00
bincxz 411f12233f fix(sftp): bound best-effort unlink so cancel cannot hang
Shared/sudo cancel after OPEN "w" waited on unlink before finishCancel.
A dead channel that never invokes the unlink callback left the transfer
and its session lease active indefinitely. Cap unlink at the same 2s
grace used for shared-write drain force-complete.
2026-08-06 00:49:52 +08:00
bincxz 3f0f460839 fix(sftp): gate truncating shared OPEN against same-path retries
Serialize truncating shared-write OPENs per remote path so a same-id
retry cannot issue OPEN "w" while a prior attempt is still unsettled.
When a drain force-complete already released the gate and a late stale
OPEN re-truncates a retry-owned resume stage, invalidate that retry
instead of allowing a sparse promote. Keep late-OPEN unlink skip for
retry-owned stages.
2026-08-06 00:35:12 +08:00
bincxz 42a27ef7d1 fix(sftp): compare late OPEN stage paths in the same representation
OPEN filePath may be a session-encoded Buffer while stagedRemote.path is the
logical string, so strict equality failed and could unlink a same-id retry
stage. Normalize via encodePathForSession / Buffer equals before ownership skip.
2026-08-06 00:07:23 +08:00
bincxz db916b141c fix(sftp): gate late OPEN unlink skip on actual staged path
Only suppress late generated-stage unlink when the same-id retry's
active.stagedRemote.path matches. Do not treat resumable+targetPath or
path shape alone as ownership for in-place retries.
2026-08-05 23:53:36 +08:00
bincxz 39e20bfa83 fix(sftp): only skip late OPEN unlink for reusable resume stages
Same-id retry ownership must not suppress unlink for every generated
stage path. Non-resumable uploads use unique .netcatty-upload-* paths;
a stale late OPEN on those must still clean orphans. Only deterministic
.netcatty-<transferId>.part resume stages are protected.
2026-08-05 22:55:43 +08:00
bincxz 7c446c7bd7 fix(sftp): skip late OPEN unlink when same-id retry owns stage
After shared write OPEN drain force-complete, a late truncating "w"
callback still closed and unlinked generated stages. Resumable retries
reuse `.netcatty-<transferId>.part`, so a stale callback could delete
the new attempt's stage. Gate unlink on activeTransfers ownership
(generated stages + truncating only), matching the in-place final
target guard.
2026-08-05 22:40:04 +08:00
bincxzandCursor d19ecb88e0 fix(sftp): unlink late staged OPEN after channel-error settle
Late truncating generated-stage OPENs after settle must unlink even when
transfer.cancelled is false, so channel-error force-complete cannot leave
a recreated .netcatty-upload orphan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:39:51 +08:00
bincxzandCursor bd42c51cdc fix(sftp): only unlink generated stages on cancel OPEN
Gate cancel-time truncating OPEN unlink on generatedStagePath so in-place
and symlink destinations keep their final path; cover with a late-OPEN test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:33:52 +08:00
bincxzandCursor a9f748c881 fix(sftp): bound cancel OPEN drain and unlink late truncates
Restore cancel drain force-complete so stalled shared OPENs cannot hang
the lease, and best-effort unlink late truncating "w" opens after close so
force-complete cannot leave a recreated stage orphan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:23:17 +08:00
bincxzandCursor cd57d96034 fix(sftp): keep cancel shared write OPEN drain gated on callback
Cancel settle must not force-complete sharedWriteOpenDrain — only channel
error does — so stage cleanup cannot race a late truncating OPEN after #2720.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:14:54 +08:00
5 changed files with 2140 additions and 66 deletions

No files matched your search

+27 -1
View File
@@ -409,6 +409,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
targetType: task.direction === "download" ? "local" : "sftp",
sourceSftpId: task.direction === "download" ? sftpId : undefined,
targetSftpId: task.direction === "upload" ? sftpId : undefined,
// Keep host-scoped path gates across session reopen (Codex P1).
sourceHostId: task.sourceHostId,
targetHostId: task.targetHostId,
totalBytes: task.totalBytes,
resumable: task.resumable !== false,
checkpointBytes,
@@ -420,7 +423,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
});
},
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
// Same-id retry stole ownership; wait for the live owner's terminal
// status instead of treating this invoke as completed (Codex P2).
if (result?.superseded === true) {
// Wait for live owner terminal status only (no fixed deadline).
for (;;) {
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed" || status === "cancelled" || status === "failed") {
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "cancelled",
transferId: task.id,
endedAt: Date.now(),
});
}
// completed: events already applied; cancelled handled above.
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
} else if (result?.error) {
throw new Error(result.error);
@@ -29,6 +29,7 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
hosts: readonly Host[];
@@ -708,8 +709,19 @@ async function resumeSingleFileWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.error) {
throw new Error(streamResult.error);
if (streamResult?.superseded === true) {
// Live same-id owner still running; wait for terminal events only.
for (;;) {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
}, { retries: 1, delayMs: 600 });
return { transferId: task.id };
@@ -1258,7 +1270,17 @@ async function resumeDirectoryWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
+28 -1
View File
@@ -357,13 +357,40 @@ export function useSftpDirectoryTransferOps({
await new Promise((resolve) => setTimeout(resolve, 80));
}
})();
let result: { error?: string; cancelled?: boolean } | undefined;
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
try {
result = await transferPromise;
} finally {
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff