Skip to content

fix(desktop): keep dots in the app name when resolving the runtime library - #36006

Merged
crowlKats merged 5 commits into
denoland:mainfrom
crowlKats:fix/desktop-dotted-app-name
Aug 26, 2026
Merged

fix(desktop): keep dots in the app name when resolving the runtime library#36006
crowlKats merged 5 commits into
denoland:mainfrom
crowlKats:fix/desktop-dotted-app-name

Conversation

@crowlKats

@crowlKats crowlKats commented Jul 13, 2026

Copy link
Copy Markdown
Member

deno desktop --output my-app-2.9.2 (or any output basename with a dot in it, e.g. template-deno2.9.2-desktop-vue3-vite8.AppImage) produced an app that exited immediately on Linux with:

No runtime library found. Set LAUFEY_RUNTIME_PATH or use --runtime <path>

Two places treated the text after the last dot of the app name as a file extension:

  • get_desktop_specific_filepath used PathBuf::with_extension, so the compiled library for my-app-2.9.2 became my-app-2.9.so — which also truncated the app dir and launcher names to my-app-2.9, silently renaming the app.
  • The launcher then did the same thing to itself: laufey's LaufeyFindColocatedRuntime chops its own file name at the last dot and appends .so, so the my-app-2.9 launcher looked for my-app-2.so, which doesn't exist.

The fix appends the dylib extension instead of replacing it (so the library and the app name keep every dot), and names the library shipped on Linux the way the launcher resolves it.

All three platforms are affected, not just Linux

app_name comes from dylib_parts(dylib_path).file_stem(), so before append_extension a --output my-app-2.9.2 also produced a my-app-2.9.app bundle on macOS, and on Windows a my-app-2.9.dll that the my-app-2.9.2.exe launcher would never find. Linux is only where it surfaced as a clean error message.

Windows needs no linux_colocated_runtime_name equivalent: its launcher is <app>.exe, so the same chop-at-the-last-dot lookup consumes a real .exe and lands on <app>.dll for any name, dotted or not. Linux launchers have no extension for the chop to eat, which is why only that side pre-truncates. That asymmetry is now noted in the code so it doesn't read as an omission.

Clobber guards

An app name whose runtime library or launcher would land on a file the backend ships (libcef.so, libcef.dll, d3dcompiler_47.dll, the helper executables) — or, for a Linux name like myapp.so, on the launcher itself — is now rejected instead of silently overwriting it. Both fs::copy and fs::rename replace without complaint, so the result was an app that couldn't start with nothing in the build output to say why.

Both packagers resolve their paths through resolve_app_dir_targets, which returns them only once every check has passed. The ordering (all guards before any write) is therefore structural rather than incidental.

Coupling to laufey

linux_colocated_runtime_name mirrors LaufeyFindColocatedRuntime as of laufey 0.7.0, and that coupling crosses a repo boundary with nothing to enforce it — the chop lives in laufey's C++ backend, so it is verified only by apps starting. If laufey ever changes that lookup to strip only a known extension, every app built by this version of Deno stops starting, because the launcher would begin looking for my-app-2.9.2.so, which we deliberately don't ship. The doc comment says so, and says to re-check on a laufey bump.

Verification

Verified on Linux with the issue's repro: deno desktop --output ./out/my-app-2.9.2.AppImage main.ts now builds an app dir with launcher my-app-2.9.2 next to my-app-2.9.so, and both the app dir and the AppImage start and report Runtime loaded successfully. Renaming the launcher back to the old truncated my-app-2.9 reproduces the original error.

Closes #35971

…brary

`deno desktop --output my-app-2.9.2` produced an app that exited with
"No runtime library found" on Linux.

Two places treated the text after the last dot of the app name as a file
extension. `get_desktop_specific_filepath` used `PathBuf::with_extension`,
turning `my-app-2.9.2` into `my-app-2.9.so` and thereby also truncating the
app dir and launcher names to `my-app-2.9`. The launcher then did the same
thing to itself: laufey's `LaufeyFindColocatedRuntime` chops the executable's
file name at the last dot and appends `.so`, so it looked for `my-app-2.so`.

Append the dylib extension instead of replacing it, so the compiled library
and the app name keep every dot, and name the shipped library on Linux the way
the launcher resolves it, so a dotted app name loads the runtime it ships with.
Refuse an app name whose resolved library name would land on the launcher or on
one of the backend's own libraries, since that would silently overwrite them.

Closes denoland#35971
@bartlomieju

Copy link
Copy Markdown
Member

Nice fix. One question on scope: the new clobber guard in package_linux_app_dir (refusing an app name whose runtime library resolves onto a backend file like libcef.so) is Linux-only. The same risk exists on the Windows path — package_windows_app_dir copies the dylib as parts.file_name with no existence check, so e.g. deno desktop --target x86_64-pc-windows-msvc --output d3dcompiler_47 would overwrite a CEF-shipped DLL and ship a broken app with no error, where Linux now bails.

Should we add the equivalent guard on the Windows side here, or is that intentionally out of scope for this PR?

The clobber guard added for Linux was missing on the Windows path:
`package_windows_app_dir` copies the compiled dylib as `<app>.dll` and
renames the backend binary to `<app>.exe` into a directory that is a copy
of the LAUFEY backend dir, so `deno desktop --target
x86_64-pc-windows-msvc --output d3dcompiler_47` silently overwrote a
CEF-shipped DLL and shipped an app that cannot start.

Both platforms now share one `reject_backend_file_collision` helper, and
both check the launcher name as well as the runtime library name — on
Linux `--output chrome-sandbox` could eat a backend file through the
rename the same way.

@crowlbot crowlbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Diagnosis and fix both look right, and the two-sided nature of it (append instead of replace on the build side, mirror the launcher's own chop on the ship side) is the correct shape.

Worth noting the description undersells the scope: this fixes Windows and macOS too. app_name comes from dylib_parts(dylib_path).file_stem(), so before append_extension a --output my-app-2.9.2 silently produced a my-app-2.9.app bundle and a my-app-2.9.dll that the my-app-2.9.2.exe launcher would never find. Windows needs no linux_colocated_runtime_name equivalent because the launcher's chop removes a real .exe, so <app>.dll lines up naturally — that asymmetry is worth a line in the comment, since it looks like an omission otherwise.

linux_colocated_runtime_name mirrors a laufey bug across a repo boundary

Nothing ties the two sides together. If laufey ever fixes LaufeyFindColocatedRuntime to strip only a known extension, every app built by this version of Deno breaks: the launcher would start looking for my-app-2.9.2.so, which we deliberately don't ship. Right now that's an invisible coupling between a Rust function here and C++ behaviour in another repo, discoverable only by an app failing to start.

At minimum the doc comment should name the laufey version whose behaviour it mirrors, so a future bump has something to check against. If the size cost is tolerable, shipping the library under both names would make the app survive the change in either direction.

Relatedly, the dot > 0 carve-out:

// A dot at index 0 is part of the name (`.hidden`), not an extension — the
// launcher treats it that way too.

That's asserted as fact and pinned by linux_colocated_runtime_name_leading_dot_is_not_an_extension, but nothing verifies laufey agrees — a plain strrchr(name, '.') would find the dot at index 0 and produce .so. Contrived enough not to matter in practice, but the test reads as if the behaviour were verified.

Test coverage stops short of the code that changed

linux_colocated_runtime_name and reject_backend_file_collision are both well covered in isolation, but nothing exercises the call sites this PR actually rewrote — the dest_dylib == launcher_path bail in package_linux_app_dir (reachable with an app name like myapp.so), or either of the new guards in package_windows_app_dir. Those are the paths where the ordering matters: the guards have to run before the fs::copy and the fs::rename, and that ordering is exactly what a refactor would break silently.

One thing I checked and it's fine: reserve_app_dir + copy_dir_all means the app dir contains only backend files when reject_backend_file_collision runs, so a rebuild can't produce a false "already part of the app" failure from its own previous output.

Addresses review feedback on denoland#36006:

- The Linux and Windows packagers had grown near-identical guard
  sequences that had to run before the fs::copy and fs::rename, with
  nothing enforcing that ordering. Both now go through
  resolve_app_dir_targets, which returns the paths only once every check
  has passed - a caller can't reach a path without having passed them, so
  a refactor can't reorder them apart. Covered by seven tests exercising
  the call-site paths that had none: the library-lands-on-the-launcher
  bail, both Windows guards, the backend-binary-is-the-launcher no-op,
  and that a rejected name leaves the app dir untouched.
- linux_colocated_runtime_name now records that it mirrors laufey's
  LaufeyFindColocatedRuntime as of 0.7.0, that the coupling is only
  verified by apps starting, and that a laufey change to that lookup
  breaks every app built by this version - so bumping laufey means
  re-checking it.
- The leading-dot carve-out no longer claims the launcher agrees; it says
  outright that it is unverified and what to check if a dotfile-named app
  fails to start.
- Note on the Windows path why it needs no truncation equivalent: its
  launcher has a real .exe for the chop to consume.
@crowlKats

Copy link
Copy Markdown
Member Author

@bartlomieju — good catch, and not out of scope: the same guard is on the Windows path as of 3fe7bde, which predates this reply but postdates your question, so it may not have been visible when you asked.

package_windows_app_dir now runs the same checks before the fs::copy and the fs::rename, so your --output d3dcompiler_47 example bails instead of shipping a broken app:

app name "d3dcompiler_47" resolves its runtime library to d3dcompiler_47.dll,
which is already part of the app. Choose a different --output name.

Since your question was really "why does one platform have this and not the other", I've gone further in c743657: both packagers now resolve their paths through a single resolve_app_dir_targets, so the two sides can't drift apart again. It returns the paths only once every check has passed, which makes "all guards before any write" structural instead of a property of how the statements happen to be ordered. There's a test covering exactly that — a rejected name leaves the backend files byte-identical and the backend binary un-renamed.

@crowlKats

Copy link
Copy Markdown
Member Author

Addressing the review — all four in c743657, on top of a main merge (the branch was ~7 months of commits behind, and the laufey version it was written against had since moved).

Scope was undersold

Right. The description now says outright that macOS and Windows were hit too — app_name comes from dylib_parts().file_stem(), so --output my-app-2.9.2 also produced a my-app-2.9.app bundle and a my-app-2.9.dll the my-app-2.9.2.exe launcher would never find. Linux is just where it surfaced as a clean error.

The Windows asymmetry now has a comment at the call site: no truncation equivalent is needed there because the launcher is <app>.exe, so the same chop consumes a real extension and lands on <app>.dll for any name. Agreed it read as an omission otherwise.

The cross-repo coupling

Documented, with the version. Worth noting the merge made this concrete rather than hypothetical: the branch was pinned to laufey 0.5.0 while main had moved to 0.7.0, so a version named before the merge would have been wrong on arrival. The doc comment now names 0.7.0, says the coupling is verified only by apps starting, spells out the breakage direction (a laufey fix to strip only a known extension makes every app built by this version look for my-app-2.9.2.so, which we deliberately don't ship), and says to re-check on a bump.

I did not ship the library under both names. A hard link makes the on-disk cost nil, but the duplicate lands in the .deb/.rpm/AppImage payloads, and whether each packager collapses it back into a link is per-format and untested — that's a bigger, riskier change than the problem currently warrants. Happy to do it as a follow-up if you'd rather have the belt-and-braces.

The dot > 0 carve-out

Fair — it asserted something I can't verify from this repo. The comment now says so directly: the truncation is mirrored from observed behaviour, but the leading-dot case is not verified, a plain strrchr in the backend would find the dot at index 0 and look for .so, and if a dotfile-named app ever fails to start that's the first thing to check. The test comment no longer implies it was confirmed.

Tests stopping short of the call sites

This was the most useful point, and it turned into a refactor rather than more tests. Both packagers had grown near-identical guard sequences with nothing tying them together or enforcing that they precede the fs::copy/fs::rename — exactly the silent-reorder hazard you named. They now share resolve_app_dir_targets, which returns the paths only once every check has passed, so a caller cannot reach a path without having passed them.

Seven tests cover the call-site paths that had none:

  • the dotted-name happy path (launcher keeps every dot, library pre-truncated)
  • dest_dylib == launcher_path (--output myapp.so)
  • clobbering a backend library, both Linux (libcef.so) and Windows (d3dcompiler_47.dll)
  • clobbering a backend helper executable via the launcher rename
  • the backend binary already being the launcher (no-op, must not be treated as a clash)
  • and one asserting a rejected name leaves the app dir byte-identical — the ordering property itself, rather than the guards in isolation

Thanks also for checking the reserve_app_dir + copy_dir_all interaction; that a rebuild can't produce a false positive from its own previous output is the non-obvious part of why the exists() check is safe at all.

Verified: 100 tools::desktop tests pass, clippy clean, formatted.

@crowlbot crowlbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at c743657.

All three points are addressed, and resolve_app_dir_targets is a better answer than the one I suggested. Returning AppDirTargets only on success means a caller can't obtain the paths without having passed the checks, so the "guards must run before fs::copy/fs::rename" ordering is enforced by the type rather than by comment — and resolve_app_dir_targets_checks_before_returning_any_path pins exactly that. Extracting it also made the Windows call site testable, which is where the guard was previously unreachable from tests.

The laufey coupling section is the right shape: naming 0.7.0, saying it's verified only by apps starting, and spelling out the failure mode on a bump gives a future reader something concrete to check. And relabelling the .hidden carve-out as unverified — with the strrchr counterexample and how to recognise the symptom — is more useful than the confident version was.

One thing to keep an eye on rather than a review comment: test integration (2/2) debug macos-x86_64 is failing here and on #36573 as well. Same job on both, so it reads as unrelated to either change, but worth a glance before merge.

@crowlbot crowlbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Following up on the CI question from my last comment — the failure is unrelated to this change:

failed tests:
    integration::repl::pty_regex_literal_with_quote

panicked at tests/integration/repl_tests.rs:67:13:
Timed out.

A PTY REPL timeout on macos-x86_64, failing identically on #36573 and #36006, neither of which touches the REPL. Flake — safe to ignore or re-run.

@crowlbot crowlbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at the current head — this round is a merge of main with no changes of your own, so nothing new on the code.

Since that merge pulls in #36575 and #36574, which touch the same three desktop files this branch does, I checked the resolution rather than assuming: no conflict markers anywhere, this branch's own changes are intact, and both merged PRs' changes survived in runtime/ops/desktop.rs (op_desktop_alert_async + ERROR_DIALOG_SHOWING from #36575, the .backup gate on the update sentinel from #36574). Clean.

CI is still running; nothing red so far.

@crowlKats
crowlKats merged commit 366b2f1 into denoland:main Aug 26, 2026
136 checks passed
bartlomieju added a commit that referenced this pull request Aug 27, 2026
…brary (#36006)

`deno desktop --output my-app-2.9.2` (or any output basename with a dot
in it, e.g. `template-deno2.9.2-desktop-vue3-vite8.AppImage`) produced
an app that exited immediately on Linux with:

```
No runtime library found. Set LAUFEY_RUNTIME_PATH or use --runtime <path>
```

Two places treated the text after the last dot of the app name as a file
extension:

- `get_desktop_specific_filepath` used `PathBuf::with_extension`, so the
compiled library for `my-app-2.9.2` became `my-app-2.9.so` — which also
truncated the app dir and launcher names to `my-app-2.9`, silently
renaming the app.
- The launcher then did the same thing to itself: laufey's
`LaufeyFindColocatedRuntime` chops its own file name at the last dot and
appends `.so`, so the `my-app-2.9` launcher looked for `my-app-2.so`,
which doesn't exist.

The fix appends the dylib extension instead of replacing it (so the
library and the app name keep every dot), and names the library shipped
on Linux the way the launcher resolves it.

## All three platforms are affected, not just Linux

`app_name` comes from `dylib_parts(dylib_path).file_stem()`, so before
`append_extension` a `--output my-app-2.9.2` also produced a
`my-app-2.9.app` bundle on macOS, and on Windows a `my-app-2.9.dll` that
the `my-app-2.9.2.exe` launcher would never find. Linux is only where it
surfaced as a clean error message.

Windows needs no `linux_colocated_runtime_name` equivalent: its launcher
is `<app>.exe`, so the same chop-at-the-last-dot lookup consumes a real
`.exe` and lands on `<app>.dll` for any name, dotted or not. Linux
launchers have no extension for the chop to eat, which is why only that
side pre-truncates. That asymmetry is now noted in the code so it
doesn't read as an omission.

## Clobber guards

An app name whose runtime library or launcher would land on a file the
backend ships (`libcef.so`, `libcef.dll`, `d3dcompiler_47.dll`, the
helper executables) — or, for a Linux name like `myapp.so`, on the
launcher itself — is now rejected instead of silently overwriting it.
Both `fs::copy` and `fs::rename` replace without complaint, so the
result was an app that couldn't start with nothing in the build output
to say why.

Both packagers resolve their paths through `resolve_app_dir_targets`,
which returns them only once every check has passed. The ordering (all
guards before any write) is therefore structural rather than incidental.

## Coupling to laufey

`linux_colocated_runtime_name` mirrors `LaufeyFindColocatedRuntime` as
of **laufey 0.7.0**, and that coupling crosses a repo boundary with
nothing to enforce it — the chop lives in laufey's C++ backend, so it is
verified only by apps starting. If laufey ever changes that lookup to
strip only a known extension, every app built by this version of Deno
stops starting, because the launcher would begin looking for
`my-app-2.9.2.so`, which we deliberately don't ship. The doc comment
says so, and says to re-check on a laufey bump.

## Verification

Verified on Linux with the issue's repro: `deno desktop --output
./out/my-app-2.9.2.AppImage main.ts` now builds an app dir with launcher
`my-app-2.9.2` next to `my-app-2.9.so`, and both the app dir and the
AppImage start and report `Runtime loaded successfully`. Renaming the
launcher back to the old truncated `my-app-2.9` reproduces the original
error.

Closes #35971

---------

Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
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.

deno desktop: Linux launcher fails to find runtime .so when output basename contains dots

3 participants