#17315

fix(cli): autostart still never converges on 3.8.52 — tray loader imports systray2 as a directory URL and unwraps a non-constructor; worker taskkilled at the 240s deadline (follow-up to #15464)

Openphoriahopened 2d ago

OmniRoute Version

3.8.52 (contains #15855)

Installation Method

npm (global)

Operating System

Windows

OS Version

Windows 11 Pro 25H2 26200.9550

Node.js Version

24.14.1

Provider(s) Involved

No response

Model(s) Involved

No response

Client Tool

No response

Description

Follow-up to #15464. The #15855 deadline fix (60s → 240s, configurable) works as shipped, but on my machine autostart still never converged, because the worker can never reach notifyTrayReady at all: the tray loader (bin/cli/runtime/trayRuntime.ts + bin/cli/tray/traySystray.mjs) has two independent bugs that both fail silently. The result is the exact same invisible-kill signature as in #15464, just at +240s instead of +60s — the launcher taskkill /T /Fs a healthy, fully-listening worker at the deadline (detachedTray.mjs:176).

Bug 1 — systray2 is imported as a directory URL (trayRuntime.ts:26-28, :74)

export function systrayModuleSpecifier(runtimeDir: string): string {
  return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href;
}

A URL/import path that resolves to a directory is never treated as a package root by the ESM loader — Node does not consult main/exports for URL-path imports, so the import throws regardless of how correct the file:// scheme construction is (this is separate from and unaffected by the #11238 fix, which correctly addressed the scheme error):

Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '<HOME>\.omniroute\runtime\node_modules\systray2' is not supported resolving ES modules
Did you mean to import "file:///<HOME>/.omniroute/runtime/node_modules/systray2/index.js"?

loadSystray catches this, warns to stderr, and returns null → initSystrayUnix returns null → maybeStartTray does not succeed → notifyTrayReady never fires → parent kills the (listening) worker at 240s.

Bug 2 — the default-export unwrap produces a non-constructor (trayRuntime.ts:74-75)

return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null;

systray2@2.1.4 compiles to CJS exporting { __esModule: true, default: SysTray }. Under the CJS→ESM interop, mod.default is that exports object, not the class (module namespace keys: __esModule, default, module.exports). So even with Bug 1 fixed and the package cached, new SysTray(...) throws:

TypeError: SysTray is not a constructor

which initSystrayUnix's bare catch {} (traySystray.mjs:74-75) swallows → null again, with no trace. The correct unwrap is the classic interop pattern: mod.default.default when typeof mod.default?.default === "function".

Why the install premise of #15855 never holds

Across all my boots (including pre-#15855 ones), the runtime dir never gained systray2 — only better-sqlite3 (from nativeDeps.mjs). The lazy install never converged anywhere, so "install (~120s) + boot (~90s) < 240s" was never the operative question here: readiness was structurally unreachable. After I cached systray2@2.1.4 manually and fixed the loader locally, the tray came up on the first try — which also confirms tray_windows_release.exe spawning and AV behavior are fine.

Secondary (same class of problem): the failure remains invisible by construction

Everything about this bug chain logs only to stderr, and on the autostart path stderr is discarded twice over: the VBS runs hidden (window style 0), and the worker is spawned detached: true, stdio: "ignore", windowsHide: true (detachedTray.mjs:38). None of it lands in app.log — my last pre-fix boots show a worker that reaches EmbedWsProxy listening ~77s in and then stops mid-normal-operation with no shutdown line, no error, and autostart status still reporting enabled: true / vbs-startup. isEnabledWin() checks only the VBS file's existence.

The kill-timing also matches the readiness deadline rather than any boot failure: the first post-#15855 boot writes its .pid seconds after the worker spawn, and its final log line lands ~240s after the spawn — right at the deadline, while the server had been listening and fully operational for the previous ~2.5 minutes.

Steps to Reproduce

Works from a fresh install (systray2 uncached) and after manually caching it, so it reproduces on warm and cold states:

  1. npm install -g omniroute (3.8.52) + omniroute autostart enable, then run the generated %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\OmniRoute.vbs (or omniroute serve --no-open --tray in a visible terminal).
  2. Wait ~4 minutes → ✖ Tray worker did not become ready, exit code 1, after the full 240s deadline; the worker was actually listening the entire time.

Direct probes of the loader mechanism (outside the hidden worker; <HOME> = the user's home dir):

node -e "import('file:///<HOME>/.omniroute/runtime/node_modules/systray2/index.js').then(() => console.log('ok'), e => console.error(e.code, e.message))"
# -> with systray2 cached:
#    ERR_UNSUPPORTED_DIR_IMPORT: Directory import '<HOME>\.omniroute\runtime\node_modules\systray2' is not supported resolving ES modules

After pointing the import at systray2/index.js:

const mod = await import(systray2IndexFileUrl);
const SysTray = mod.default ?? mod.SysTray ?? mod;
new SysTray({ menu: { ... } });
// -> TypeError: SysTray is not a constructor   (mod.default is the exports object; the class is mod.default.default)

Expected Behavior

After omniroute autostart enable and a reboot (or running the VBS directly), OmniRoute serves and the tray is ready within the (now 240s) deadline — and when the tray fails for any reason, the reason is obtainable somewhere a user can find it.

Actual Behavior

The worker boots fully, listens, and is taskkilled at the deadline. systray2 is never cached (the lazy install runs to completion but its result is never reachable by the loader), so every boot repeats the cycle. app.log shows a silent mid-operation stop; autostart status reports enabled: true; no error anywhere visible.

Test Impact

Both failure layers are unit-testable without a GUI:

  • importing the resolved module spec must not throw ERR_UNSUPPORTED_DIR_IMPORT (assert systrayModuleSpecifier() returns a file URL ending in the package entry, and that import() succeeds against a temp-installed systray2);
  • loadSystray() must return a typeof === "function" value for the real systray2 CJS shape { __esModule, default: SysTray } (and still for ESM shapes), instead of returning the exports object;
  • initSystrayUnix should surface (not swallow) constructor/import failures per its PR #1080 comment intent.

Error Logs / Output

(ongoing boot on 3.8.52, relative to worker start:)
[T+77s]  [EmbedWsProxy] Listening on 127.0.0.1:20131
…(normal operation for ~2.5 more minutes)…
[T+~198s] [ConnectionRecovery] proactively restored 1 connection(s)…
(end of log — no shutdown, no error; process tree gone, killed at the 240s readiness deadline)

loader probe 1: Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '<HOME>\.omniroute\runtime\node_modules\systray2' is not supported resolving ES modules
loader probe 2 (after pointing at index.js): TypeError: SysTray is not a constructor

Screenshots

No response

Additional Context

Both fixes are in bin/cli/runtime/trayRuntime.ts (patch applied locally, tray + autostart verified working end-to-end afterwards: ✔ OmniRoute tray started in background, tray binary healthy from the runtime cache):

// 1) resolve the package entry instead of the directory
export function systrayModuleSpecifier(runtimeDir: string): string {
  const pkgPath = join(runtimeDir, "node_modules", SYSTRAY_PACKAGE, "package.json");
  let entry = "index.js";
  try {
    const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { main?: string };
    if (pkg.main) entry = pkg.main;
  } catch {}
  return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE, entry)).href;
}

// 2) unwrap the CJS interop layer, only accept a constructor
let exported: unknown = (mod as { default?: unknown }).default ?? mod;
if (exported && typeof exported === "object" && typeof (exported as { default?: unknown }).default === "function") {
  exported = (exported as { default: unknown }).default;
}
if (typeof exported !== "function") return null;
return exported as new (...args: unknown[]) => unknown;

Bug 1 would hit macOS/Linux identically (both resolve a directory file-URL for the same code path); I only have Windows here to confirm, but the ESM resolution rule is platform-independent. Filed as a follow-up since #15855's author note said "if the problem is still there on 3.8.52, comment here" — it is, via a mechanism the deadline change can't reach.

Activity

0

No activity yet.