Skip to content

fix(receive): rename incoming files on a directory collision - #168

Merged
Ishannaik merged 1 commit into
Ishannaik:mainfrom
vsolano9:fix/receive-filename-collision
Aug 5, 2026
Merged

Ishannaik merged 1 commit into
Ishannaik:mainfrom
vsolano9:fix/receive-filename-collision

Conversation

@vsolano9

@vsolano9 vsolano9 commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

What & why

The directory-picker save path de-duped filenames only against the other files in the same batch, then called getFileHandle(name, { create: true }). A file already sitting in the chosen folder was overwritten silently, so receiving a second report.pdf destroyed the first.

uniqueName now probes the folder as well as the batch: the existing file keeps its name and the incoming one becomes report (1).pdf. The probe stays cheap — a single getFileHandle for the exact candidate, never a directory scan.

existsInDir reports three states rather than two. NotFoundError means the slot is free; any other failure is "unknown", and on that we stop probing and return the current candidate so the real create: true write surfaces the real error. Treating an unreadable directory as free would overwrite a file we cannot see, and treating it as taken would spin forever looking for a free slot.

The dir argument is optional, so the single-file showSaveFilePicker path and the in-memory tray keep their existing behaviour.

One note on the acceptance criteria: in-batch de-duplication already worked before this change — three same-named files in one batch already became a.txt, a (1).txt, a (2).txt. Only the on-disk case was broken. That criterion now has a regression check so it stays working, but it isn't a behaviour change.

Closes #133

Type

  • feat
  • fix
  • docs / chore / refactor / test / perf

Checklist

  • pnpm lint && pnpm typecheck pass locally
  • pnpm --filter @warp/web build is green (web changes)
  • Engine changes: the relevant web/src/lib/warp/*.check.mjs harness passes (and covers the new behaviour)
  • Design tokens + inline-style component style respected (no drive-by restyling)

This is a web-only, two-file engine change, so per the template's "delete lines that genuinely don't apply" I've dropped the server-test and mobile-width rows: the diff contains no server/ files and no UI.

Hard constraints

  • No new recurring-cost infrastructure — no TURN, database, storage, or paid API
  • Still STUN-only — no relay in the file path
  • The signaling server still never reads, stores, or understands file contents
  • SEND_HIGH_WATER in peer.ts untouched — peer.ts is not in this diff at all
  • Transient network drops still recover — accept/resume logic untouched

Testing

useWarpTransfer.check.mjs gains 13 collision cases (50 checks pass in total): the on-disk collision, three-in-one-batch, the no-collision case, combined disk + batch, probe cost, an inconclusive probe, dotfiles, and extensionless names.

Because that harness uses a fake directory handle, it proves the algorithm but not the API contract. So the behaviour was also verified against the real File System Access API through OPFS in Chrome:

  • a probe for an absent name throws exactly NotFoundError — the assumption the whole fix rests on
  • three report.pdf receives into a folder that already holds one leave four files with the original intact
  • .env becomes .env (1)

As a control, the pre-fix code exercised the same way leaves one file with the original's contents replaced — the bug reproduced as real data loss.

Known gaps, deliberately out of scope

  • DefaultReceiveHost in peer.ts carries a second copy of the same in-memory-only de-dupe. It is not the app's path — useWarpTransfer injects its own ReceiveHost at both new WarpPeer sites — but a library consumer who doesn't inject one would still overwrite. Happy to fix in a follow-up if you'd like it in scope.
  • Concurrent receives are not made atomic. Names resolve when the offer is accepted while handles are created lazily, so two simultaneous accepts could still pick the same free name. getFileHandle has no exclusive-create option, and createWritable() defaults to mode: "siloed", so the last writer can win. createWritable({ mode: "exclusive" }) would instead surface the clash as NoModificationAllowedError and could drive a retry under a fresh name, but that needs a wider FsFileHandle type, a change to the write path, and updated mocks, so it is a separate follow-up rather than part of this naming fix. This is pre-existing behaviour and unchanged here.
  • useWarpTransfer.check.mjs isn't wired into pnpm test (web has no test script), so it has to be run explicitly, as its own header says.
  • I could not drive a real showDirectoryPicker — it needs a user gesture and an OS dialog. OPFS exercises the same getFileHandle interface and is the closest substitute, which is why the control above matters.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented directory-based transfers from overwriting existing files.
    • Improved filename handling for duplicate files, including dotfiles and extensionless names.
    • Added safeguards for collisions between files transferred in the same batch.
    • Improved error handling when the destination cannot be checked reliably.

Greptile Summary

This PR fixes a silent data-loss bug (#133) where receiving a file into a folder that already contained a same-named file would overwrite it. uniqueName is promoted to async and extended to probe the chosen directory with a single cheap getFileHandle call per candidate, so the existing file keeps its name and the incoming file steps aside.

  • existsInDir introduces a three-state return (true / false / \"unknown\") so an unreadable directory neither causes infinite looping nor silently overwrites an invisible file — an inconclusive probe returns the current candidate immediately, deferring the real error to the subsequent create: true write.
  • The accept callback in useWarpTransfer.ts was already async, so the addition of await uniqueName(…, dir) fits naturally into the existing flow.
  • Nine new scenarios in useWarpTransfer.check.mjs cover on-disk collision, pure in-batch deduplication, combined disk+batch, the "unknown" probe path, probe cost, dotfiles, and extensionless names.

Confidence Score: 4/5

Safe to merge — the overwrite bug is correctly fixed, all edge cases are covered, and the async change fits naturally into an already-async accept callback.

The algorithm is correct and well-documented. The only concern is that the test helpers in the .check.mjs file are manually duplicated from the production TypeScript, meaning a future algorithm change won't automatically be reflected in the tests unless the test file is also updated.

Files Needing Attention: web/src/lib/warp/useWarpTransfer.check.mjs — the hand-copied existsInDir and uniqueName helpers need to stay in sync with their counterparts in useWarpTransfer.ts.

Important Files Changed

Filename Overview
web/src/lib/warp/useWarpTransfer.ts Refactors uniqueName from sync to async, adds existsInDir helper, and threads dir probe into accept()'s folder-target path. Logic is correct: unknown probe state defers to the real write, used-set tracks in-batch deduplication, and the dotfile/extensionless edge cases are handled.
web/src/lib/warp/useWarpTransfer.check.mjs Adds 9 well-structured test scenarios covering on-disk collision, in-batch deduplication, combined disk+batch, the unknown probe path, dotfiles, and extensionless names. The existsInDir and uniqueName helpers are hand-copied from the .ts source rather than imported, which creates a drift risk if the production algorithm is updated without also updating the test file.

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
### Issue 1
web/src/lib/warp/useWarpTransfer.check.mjs:461-493
**Test helpers hand-copied from production source**

`existsInDir` and `uniqueName` in this file are manually duplicated from `useWarpTransfer.ts` (the comment even calls this out: "reproduced 1:1"). Because the test is run with bare `node` and can't import TypeScript, that constraint is real — but it means any future change to the production algorithm that isn't mirrored here will pass all 9 collision checks while silently testing the old logic. The "1:1" comment depends on a human catching the drift, not tooling.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(receive): rename incoming files on a..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

The directory-picker save path de-duped names only against the other files in
the same batch, then called getFileHandle(name, { create: true }). A file
already sitting in the chosen folder was overwritten silently, so receiving a
second report.pdf destroyed the first.

uniqueName now probes the folder as well as the batch: the existing file keeps
its name and the incoming one becomes "report (1).pdf". The probe stays cheap,
a single getFileHandle for the exact candidate, never a directory scan.

existsInDir reports three states rather than two. NotFoundError means the slot
is free; any other failure is "unknown", and on that we stop probing and return
the current candidate so the real create:true write surfaces the real error.
Treating an unreadable directory as free would overwrite a file we cannot see,
and treating it as taken would spin forever looking for a free slot.

The dir argument is optional, so the single-file showSaveFilePicker path and
the in-memory tray keep their existing behaviour. In-batch de-duplication is
unchanged: it already worked, and it now has a regression check so it stays
that way.

Verified against the real File System Access API through OPFS: an absent-name
probe throws NotFoundError, and three report.pdf receives into a folder that
already holds one leave four files with the original intact. The pre-fix code
exercised the same way leaves one file with the original's contents replaced.

useWarpTransfer.check.mjs gains collision cases for the on-disk collision, the
three-in-one-batch case, the no-collision case, combined disk and batch
collisions, probe cost, an inconclusive probe, dotfiles, and extensionless
names.

Closes Ishannaik#133

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Filename collision handling

Layer / File(s) Summary
Directory-aware filename resolution
web/src/lib/warp/useWarpTransfer.ts
uniqueName probes the target directory, avoids existing and earlier batch filenames, preserves extensions and dotfiles, and defers inconclusive errors to the write.
Collision resolution validation
web/src/lib/warp/useWarpTransfer.check.mjs
Tests cover collision resolution, exact probes, fallback errors, dotfiles, extensionless names, and no-directory behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Transfer
  participant uniqueName
  participant FileSystemDirectoryHandle
  Transfer->>uniqueName: resolve candidate filename
  uniqueName->>FileSystemDirectoryHandle: probe candidate name
  FileSystemDirectoryHandle-->>uniqueName: available, existing, or inconclusive result
  uniqueName-->>Transfer: selected filename
  Transfer->>FileSystemDirectoryHandle: write selected file
Loading

Suggested reviewers: ishannaik

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue [#133] by handling disk and batch collisions, preserving names safely, and adding regression coverage.
Out of Scope Changes check ✅ Passed The changes remain within the client-side filename collision scope and do not alter networking, signaling, or transfer recovery.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: renaming incoming files when they collide with files in the selected directory.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/lib/warp/useWarpTransfer.ts`:
- Around line 793-796: The directory-target write flow around uniqueName must
open each FileSystemWritableFileStream with mode "exclusive" instead of the
default siloed mode. Catch NoModificationAllowedError from createWritable,
generate a new unique name, and retry the write; update the related FsFileHandle
typing and test mocks so createWritable accepts the mode option.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e510328-b408-44bb-8999-12b523204cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 5c9c4ac and ee1a6e6.

📒 Files selected for processing (2)
  • web/src/lib/warp/useWarpTransfer.check.mjs
  • web/src/lib/warp/useWarpTransfer.ts

Comment on lines 793 to +796
const dir = target.dirHandle;
// Probes the folder as well as this batch, so an existing file keeps its
// name and the incoming one becomes "name (1).ext" (issue #133).
savedName = await uniqueName(usedNames, it.name, dir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify that the local file-handle type accepts createWritable options.
rg -n -C 4 'interface FsFileHandle|createWritable' web/src/lib/warp/peer.ts web/src/lib/warp

Repository: Ishannaik/warp

Length of output: 6064


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== useWarpTransfer.ts relevant sections =="
sed -n '730,805p' web/src/lib/warp/useWarpTransfer.ts
sed -n '805,880p' web/src/lib/warp/useWarpTransfer.ts

echo "== uniqueName definition and dir handling =="
rg -n -C 5 'function uniqueName|const uniqueName|uniqueName\s*=|export.*uniqueName|async function.*dir|dirHandle|createWritable' web/src/lib/warp peer.ts 2>/dev/null | sed -n '1,220p'

echo "== peer.ts target setup relevant section =="
sed -n '180,245p' web/src/lib/warp/peer.ts

echo "== type declarations mentions for FileSystemWritableFileStream mode =="
rg -n 'createWritable|FileSystemWritableFileStream|mode|NoModificationAllowedError' web -g '*.ts' -g '*.tsx' -g '*.d.ts'

Repository: Ishannaik/warp

Length of output: 23237


🌐 Web query:

FileSystemFileHandle createWritable mode exclusive default siloed MDN

💡 Result:

In the File System Access API, the createWritable() method of FileSystemFileHandle accepts an optional mode property within its options object to control locking behavior [1][2]. The mode parameter has two possible values: "siloed" (Default) This is the default behavior [1][2]. Multiple FileSystemWritableFileStream writers can be opened simultaneously for the same file handle [2][3]. Each writer operates on its own swap file, and the last writer to close its stream determines the final content of the file [2][3][4]. "exclusive" When set to "exclusive", only one FileSystemWritableFileStream can be open for a given file handle at a time [1][2]. If an attempt is made to open a subsequent writer before the first one is closed, the browser will throw a NoModificationAllowedError [1][2]. This mode is intended to prevent multiple writers from clobbering each other's changes [5][4]. These locking modes were introduced to provide developers with more control over file access, particularly in scenarios involving multiple tabs or windows [3][4][6].

Citations:


Use an exclusive writable stream for directory targets.

createWritable() defaults to mode: "siloed", so concurrent receives can open writers for the same name (1).ext and the last writer wins. Open the FileSystemWritableFileStream with { mode: "exclusive" }, catch NoModificationAllowedError, retry with a new unique name, and update the FsFileHandle/test mocks to support createWritable({ mode }).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lib/warp/useWarpTransfer.ts` around lines 793 - 796, The
directory-target write flow around uniqueName must open each
FileSystemWritableFileStream with mode "exclusive" instead of the default siloed
mode. Catch NoModificationAllowedError from createWritable, generate a new
unique name, and retry the write; update the related FsFileHandle typing and
test mocks so createWritable accepts the mode option.

Comment on lines +461 to +493
return err?.name === "NotFoundError" ? false : "unknown";
}
}

async function uniqueName(used, name, dir) {
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";

let candidate = name;
for (let n = 1; ; n += 1) {
if (!used.has(candidate)) {
const onDisk = dir ? await existsInDir(dir, candidate) : false;
if (onDisk !== true) {
used.add(candidate);
return candidate;
}
}
candidate = `${stem} (${n})${ext}`;
}
}

/** A fake directory handle over a set of existing names. Records every probe. */
function fakeDir(existing = [], opts = {}) {
const files = new Set(existing);
return {
files,
probes: [],
created: [],
async getFileHandle(name, options) {
if (options?.create) {
this.created.push(name);
files.add(name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test helpers hand-copied from production source

existsInDir and uniqueName in this file are manually duplicated from useWarpTransfer.ts (the comment even calls this out: "reproduced 1:1"). Because the test is run with bare node and can't import TypeScript, that constraint is real — but it means any future change to the production algorithm that isn't mirrored here will pass all 9 collision checks while silently testing the old logic. The "1:1" comment depends on a human catching the drift, not tooling.

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/lib/warp/useWarpTransfer.check.mjs
Line: 461-493

Comment:
**Test helpers hand-copied from production source**

`existsInDir` and `uniqueName` in this file are manually duplicated from `useWarpTransfer.ts` (the comment even calls this out: "reproduced 1:1"). Because the test is run with bare `node` and can't import TypeScript, that constraint is real — but it means any future change to the production algorithm that isn't mirrored here will pass all 9 collision checks while silently testing the old logic. The "1:1" comment depends on a human catching the drift, not tooling.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

@Ishannaik Ishannaik left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — APPROVE

Verified locally on pr-168: all 8 engine harnesses pass, including 13 collision assertions covering every acceptance criterion in #133 (disk collision, batch triple, no-collision passthrough, composed disk+batch, probe economy, unknown-probe handling, no-dir path, dotfiles, extensionless). Lint/typecheck/build clean.

Logic review: no off-by-one in the (n) sequence; dot > 0 keeps dotfiles whole; the three-state probe is the right call and the "unknown" handling (defer to the real create:true write) avoids both clobbering unseen files and infinite loops. The auto-resume path reuses durable registry entries, so reconnects do not re-resolve names. Single-file picker, IDB, and memory paths untouched.

On the bot findings:

  • CodeRabbit's mode:"exclusive" idea is reasonable hardening but note it is a concurrent-writer lock per MDN, not a file-existence check, so it narrows rather than closes the probe-to-create race. The race needs two simultaneous receives into the same folder, which the current single-pairing flow does not produce. Fine as a follow-up.
  • Greptile's 1:1-copied-test-helpers point is valid and inherent to bare-node harnesses; the copies match production exactly today.

Nice, well-scoped fix. Ship it.

@Ishannaik
Ishannaik merged commit ede4440 into Ishannaik:main Aug 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rename incoming files on a filename collision instead of overwriting them

2 participants