This is my entry for DEV's Summer Bug Smash — Clear the Lineup.
Status update (Aug 7), and I want it up top rather than buried: the fix is merged. The
LocalSend maintainer merged the PR the same week, with no change requests. When this article first
went up, the note here said the PR was open and might never land — I'm leaving that honesty policy
in place and just updating the fact: it landed.
PR: https://github.com/localsend/localsend/pull/3246
Picking something real
LocalSend is a local file-transfer app — a Flutter UI on
top of a Rust protocol implementation. Issue #3165:
When LocalSend is minimized to the system tray, selecting a file in File Explorer and using the
context menu "Send to → LocalSend" triggers a second instance of LocalSend to launch. This second
instance fails to bind to the default port (53317) because the tray instance already holds it,
causing an "Address already in use" error and opening a duplicate main window.
I liked it for reasons that had nothing to do with how interesting it sounded:
- Open, unassigned, zero comments, no PR referencing it.
- The reporter gave a precise, mechanical reproduction. No "sometimes it crashes."
- The project's contributing rules are explicit about what they'll take: bug fixes, small changes, everything covered by tests.
- The symptom is a resource conflict on a fixed port, which means the failure is observable from outside the process. I could count processes and sockets instead of arguing about screenshots.
That last one is the real selection criterion. A bug you can measure from the outside is a bug you can
prove you fixed.
Rule one: reproduce it before you have a theory
I installed the official release — 1.17.0, straight from winget — and ran the reporter's steps on a
real Windows desktop. Started it, let it bind 53317, right-clicked a test file in Explorer, chose
Send to → LocalSend.
One process. Port still held by the first instance. No duplicate window.
I traced the second process from launch out to thirty seconds, in case I was just missing a window
that appeared and vanished:
2nd PID exited code 0 in <1 s, no window
It handed over and quit. Exactly as designed.
This is the part of debugging that people skip, and it is the part that pays. I had a theory ready to
go — Explorer's Send-To is a plain shortcut, so of course you get a second process — and if I'd
started coding against that theory I'd have written a patch for a bug that wasn't there.
Instead I had a question: why does the reporter see this and I don't?
The uncomfortable answer
I went looking at when the relevant code last changed. The commit that rewrote the internal handover
route into Rust is dated 2026-07-15. The issue was filed 2026-07-03. The release I'd just
tested was tagged in February 2025.
So the shipping app and the current main no longer run the same code for this. Whatever the reporter
hit on 1.17.0, I couldn't reproduce it — but the same flow on main was now completely unexplored
territory, and it had been rewritten twelve days after the issue was filed.
I had to build main.
Three fights before a single line of app code
I want to be honest about the shape of this work. The diagnosis took an afternoon. Getting the
toolchain to produce a binary took longer, and none of the blockers were LocalSend's fault. If you
build Flutter-plus-native on Windows, one of these will eventually eat your day.
1. A space in the path, and an executable that wasn't quoted
The build died here:
'C:\Users\me\Side' is not recognized as an internal or external command
My working directory was C:\Users\me\Side Projects\localsend\.... Flutter's native-assets hook
runner shells out to dart compile kernel, and it quotes the arguments but not the executable
path. The space split the command in two.
The obvious fixes both failed, and the reason is worth knowing:
-
8.3 short paths (
C:\Users\me\SIDEPR~1\...) — the bashflutterwrapper resolves its own location withpwd -P, which expands the short name straight back to the long one. -
subst(mapping a drive letter to the folder) —flutter.batuses%~f, which does the same expansion.
Both tools are designed to give you the canonical path. That is precisely what you do not want here.
What worked was an NTFS junction:
mklink /J C:\lsflutter "C:\Users\me\Side Projects\localsend\sdk\flutter"
A junction isn't an alias that resolves back — it's a real directory entry, so pwd -P and %~f
both stop at C:\lsflutter. Space-free, canonical, done. Then invoke
C:\lsflutter\bin\flutter.bat and never mention the long path again.
Transferable lesson: when a tool insists on canonicalising your path, don't fight the
canonicaliser — change what canonical is.
2. A build script that loaded my shell profile
Next failure was stranger. The native build resolver started walking a path that didn't exist:
...\app\Users\me\Side Projects\...
An absolute path had been glued onto a relative one. cargokit — the Rust/Flutter build glue — invokes
a helper like this:
powershell -ExecutionPolicy Bypass -File resolve_symlinks.ps1
No -NoProfile. So PowerShell loaded my profile first. My profile has prompt and icon modules in it
that emit errors on load in a non-interactive host. Those errors landed in the stream the CMake step
was reading, and the resolver parsed the wreckage as a path.
Running the identical command with -NoProfile returned the correct path instantly.
I used that locally to get unblocked and then reverted it — it's a weakness in a vendored build tool,
not part of my fix, and it has no business in a bug-fix diff.
Transferable lesson: any script your build shells out to inherits your shell's personality. If a
build works on CI and dies on your machine with output that looks parsed wrong rather than
missing, suspect your own profile before you suspect the build.
3. A build artifact that has to exist before the build
Third one was mundane, and I mention it only because it cost me twenty minutes of confusion: the
Windows CMake step installs a .msix helper file that isn't tracked in the repo, because it's
generated. The project's own release scripts and CI build it. Locally, nobody had. One makeappx.exe and the build ran to completion.
pack
Transferable lesson: "works in CI" often means "CI runs a step that nobody wrote down as a
prerequisite." Read the CI workflow as build documentation. It is usually the most accurate build
documentation a project has.
After all three: √ Built build\windows\x64\runner\Release\localsend_app.exe.
Now reproduce it on main
Same steps. Same test file.
Two processes. Two visible windows. The first still holds 53317; the second can't bind and sits
there as a duplicate. Fifteen seconds later, still both.
The bug the reporter described, on the current branch, deterministically.
Two defects, one request
Here's the mechanism. Explorer's "Send to" entry is a plain shortcut to the executable, with the
selected file paths appended as arguments. There is no OS-level single-instance lock. The only thing
standing between you and a second app is a small piece of startup code: post to the running instance's
internal show endpoint on loopback, and if it answers 200, exit.
That request was failing for two independent reasons, either of which is sufficient on its own.
Defect one: it asked for the wrong URL.
The URL was built by passing a protocol version through a route builder. The version passed was a
constant '1.0', and the route builder maps '1.0' to the v1 path. The Rust server registers exactly
one show route:
(&Method::POST, "/api/localsend/v2/show") => internal::show(req, state).await,
There is no v1 route in that server at all. So: 404, not 200, second instance carries on.
The conceptual error underneath is the interesting bit. Protocol-version negotiation exists so you can
talk to someone else's device running some other version. This endpoint talks to another copy of
the same binary on the same machine. There is nothing to negotiate. Feeding it through the negotiation
helper was a category mistake that happened to be harmless for as long as both paths were served.
Defect two: it never proved who it was.
LocalSend uses TLS with per-device self-signed certificates, and the server requires a client
certificate whenever it isn't also serving browser pages:
let mandatory_client_auth = app_state.web.is_none() && !app_state.web_upload;
The startup code used a bare HttpClient with a badCertificateCallback that returns true. That
callback is a very common source of false confidence: it overrides validation of the server's
certificate. It does nothing whatsoever about the server demanding one from you.
I put a standalone probe against the real Rust server to watch both halves:
no client cert -> HttpException: TLSV1_ALERT_CERTIFICATE_REQUIRED (ssl/tls_record.cc:486) error 268436572
client cert, v2 path -> 200, and the server emits RsServerEvent.show_(args: [a.txt])
The handshake is refused before the request is ever sent. The URL never even gets a chance to be
wrong.
git blame is not an accusation, it's a timeline
I wanted to know how two defects landed on one request without anyone noticing, because "how did this
survive review" usually explains the bug better than the bug does.
-
2026-07-14, a commit that removes an HTTP dependency swaps
createRhttpClient(timeout, securityContext)for a bareHttpClient(). The certificate is dropped right here. But the server at that moment was still the Dart one — and the Dart server didn't ask for client certificates, and served both v1 and v2. Nothing broke. The defect went latent. - 2026-07-15, one day later, an unrelated-looking commit moves the show endpoint into the Rust server. That server serves v2 only, and it demands a client certificate.
Both defects went live in the second commit, and neither commit is wrong when you read it on its own.
The first is a faithful dependency swap. The second is a clean feature move. The bug lives in the
space between them, which is exactly the kind of bug that code review is structurally bad at catching
— because review looks at diffs, and no diff contains it.
That's also why the shipping release is fine: 1.17.0 predates both.
The amplifier
None of this had to be a mystery, because the startup code looked like this:
try {
// ...
} catch (_) {
} finally {
client.close(force: true);
}
A bare catch (_). Every failure mode above — the 404, the refused handshake — was swallowed here.
The user gets a duplicate window and a port error, with nothing anywhere saying why the handover
didn't happen.
I'd argue the empty catch is the more expensive defect. The other two are a wrong path and a missing
certificate; they'd have been ten-minute fixes if anything had said "the handover was refused."
The fix
Small, and mostly about putting the request somewhere it can be tested:
- A new
notifyRunningInstance(...)helper in the package that already owns the route definitions and the stored certificate. - It targets
ApiRoute.show.v2directly, with a comment explaining that this endpoint is internal and must not be version-negotiated. The next person who reaches for the negotiation helper should hit a wall. - It hands the client the device's own certificate from the persisted security context.
- Timeouts unchanged — 100 ms connect, 500 ms response. This runs on every app start, and a startup path is not the place to get generous with waiting.
- The empty catch becomes outcome-aware. "Nothing is listening" is the normal cold-start case and
logs at
fine, i.e. silently. A refusal or a failed handshake logs atwarning, because both mean something answered on that port and then denied the handover. That is never normal, and it's exactly this bug's signature.
Net effect on the startup file: +10 / −32.
One detail I got wrong on my own first pass, caught on a second read: I'd added a drain() on the
response to avoid leaving a socket half-read, with no timeout on it. The response timeout only
wrapped the request. So anything squatting on port 53317 that sends a status line and then stalls
would have hung app startup forever. Low probability, unacceptable failure mode — this code runs every
time the app opens. Now the status code is captured first, the drain is bounded, and a body that never
arrives can't turn a genuine 200 into a duplicate instance.
Reviewing your own patch as if someone else wrote it is worth the twenty minutes. That one was mine
and I'd have shipped it.
The test, and what it honestly can't do
Three cases: the handover succeeds and forwards the arguments; a rejected token doesn't exit; nothing
listening doesn't exit. The stub server encodes the real server's contract as an ordered switch:
| Condition | Answer |
|---|---|
| no client certificate | 401 |
path isn't /api/localsend/v2/show
|
404 |
| token mismatch | 403 |
| otherwise | 200 |
Which means I could put back each defect on its own and watch the suite fail for it — wrong path
alone: fail. Missing certificate alone: fail. That matters more than a passing test does. A regression
test that has never been seen to fail is a decoration.
What it can't do: the stub is a Dart server, not the real Rust one. I tried running the real server
in-process under the test runner, and the Rust TLS stack and the client's BoringSSL deadlocked hard
enough to take the runner down with them. So the real-server verification happens out of band, and the
test guards the contract rather than the implementation.
And one more caveat I put in the PR rather than hiding: the test mints its throwaway identity through
the Rust bridge, so it self-skips when the Rust library isn't built — and the project's CI doesn't
build it. These tests will skip on CI. An existing test in the same package already skips for the
same reason, so it's a known shape there. The alternative was committing a static certificate and
private key to the repo to remove the Rust dependency, and I'd rather ship a visible skip than an
invisible security smell. I said so in the PR and offered to do it whichever way the maintainers
prefer.
Proving it, from outside the process
Back to why I picked a bug with a port conflict in it. The final check doesn't involve my opinion.
I built main twice from the same tree — once with the startup file at upstream, once with the fix —
pointed the real Explorer Send-To shortcut at each build in turn, and invoked it through
ShellExecute with a 27-byte test file, which is exactly how Explorer launches a Send-To target.
| Build | Processes | Windows | Port 53317 |
|---|---|---|---|
unfixed main
|
2 | 2 | first PID holds it, second can't bind |
| with the fix | 1 | 1 | never contested |
Sustained across fifteen seconds of sampling, not a single snapshot.
And the piece that makes it a fix rather than a workaround: the surviving instance came to the
foreground on its Send page showing Files: 1, Size: 27 B. The arguments reached the running
instance. I wasn't just suppressing the second window — I was doing the thing the second window was
supposed to have done.
What is true right now
- On current
main, the flow reproduces deterministically, for two concrete reasons in two named files. - The fix is three files, one of which is a test, and it passes format, analyze and test in both affected packages.
- The end-to-end Windows behaviour is measurably fixed.
- The PR is open and awaiting review: https://github.com/localsend/localsend/pull/3246. Not merged. Not approved. The maintainers may want a different shape entirely, and that's their call.
- I could not reproduce the original reporter's symptom on the shipping 1.17.0 build. I said so in the PR and left the issue open rather than auto-closing it. What I fixed is real, and it's the same user-visible behaviour — but claiming I'd solved someone else's 2026-07-03 report on a February 2025 binary would be a story, not a result.
The most useful thing I did on this bug was the first thing: install the release and try to reproduce
it, and then take "it doesn't reproduce" seriously instead of treating it as an inconvenience. That
single negative result is what turned a guess into a diagnosis.
Top comments (0)