mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
1 Commits
fix/9626-p
...
fix/9156-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a1732a89 |
@@ -52,8 +52,11 @@ export class ServerSupervisor {
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: macOS launchd cannot resolve bare "node" because its PATH is
|
||||
// minimal. Always use process.execPath (the absolute path to the running
|
||||
// Node.js binary) so the supervisor never depends on PATH resolution.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
|
||||
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): use process.execPath for macOS launchd autostart
|
||||
@@ -1 +0,0 @@
|
||||
- fix(playground): surface provider model loading errors and offer retry (#9626)
|
||||
@@ -134,7 +134,7 @@ export function LlmChatCard({
|
||||
}: Props) {
|
||||
const t = useTranslations("miniPlayground");
|
||||
const { keys } = useApiKey();
|
||||
const { models, loading, error, retry } = useProviderModels(providerId);
|
||||
const { models } = useProviderModels(providerId);
|
||||
|
||||
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
|
||||
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
|
||||
@@ -392,31 +392,15 @@ export function LlmChatCard({
|
||||
<select
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
disabled={loading}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{modelOptions.length === 0 && !loading && <option value="">{initialModel || "—"}</option>}
|
||||
{loading && <option value="">{t("loading") ?? "Loading…"}</option>}
|
||||
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1" role="alert">
|
||||
<span className="truncate max-w-[180px]" title={String(error)}>
|
||||
{String(error)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
className="shrink-0 text-xs text-primary hover:text-primary-strong underline"
|
||||
>
|
||||
{t("retry") ?? "Retry"}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Key select */}
|
||||
{keys.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -18,8 +18,6 @@ interface UseProviderModelsResult {
|
||||
models: ProviderModel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Re-runs the model fetch for the current provider. Useful for a Retry action. */
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,14 +32,15 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Cancels any in-flight load (component unmount or a retry superseding the
|
||||
// previous request) so a stale response never overwrites a newer one.
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
cleanupRef.current?.();
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -110,33 +109,11 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
const cleanup = () => {
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
cleanupRef.current = cleanup;
|
||||
return cleanup;
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
return load();
|
||||
}, [providerId, load]);
|
||||
|
||||
// Release the current in-flight cleanup on unmount so no state updates leak.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (!providerId) return;
|
||||
load();
|
||||
}, [providerId, load]);
|
||||
|
||||
return { models, loading, error, retry };
|
||||
return { models, loading, error };
|
||||
}
|
||||
|
||||
111
tests/unit/repro-9156.test.ts
Normal file
111
tests/unit/repro-9156.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// #9156: macOS launchd autostart fails because the supervisor spawns the child
|
||||
// with bare "node", but launchd's PATH cannot resolve it. process.execPath is
|
||||
// always the absolute path to the running Node.js binary and is always resolvable.
|
||||
//
|
||||
// We verify the fix via:
|
||||
// 1. Static source analysis — the spawn() call must use process.execPath
|
||||
// unconditionally (no fallback to bare "node"). This runs without any
|
||||
// experimental flags so it serves as the permanent regression guard.
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// that captures the actual spawn arguments.
|
||||
|
||||
const __filename = new URL(import.meta.url).pathname;
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SUPERVISOR_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
const supervisorSrc = fs.readFileSync(SUPERVISOR_PATH, "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Source-level verification (no experimental flag required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("spawn() uses process.execPath unconditionally, no bare 'node' fallback (#9156)", () => {
|
||||
// Must NOT contain the old conditional that falls back to bare "node"
|
||||
assert.ok(
|
||||
!supervisorSrc.includes('process.versions.bun ? process.execPath : "node"'),
|
||||
"must NOT have a conditional fallback to bare 'node'"
|
||||
);
|
||||
|
||||
// Must use process.execPath as the first argument to spawn()
|
||||
const execPathPattern = /spawn\(\s*process\.execPath\s*,/;
|
||||
assert.ok(
|
||||
execPathPattern.test(supervisorSrc),
|
||||
"spawn() must receive process.execPath as first argument"
|
||||
);
|
||||
});
|
||||
|
||||
test("process.execPath is an absolute path to the running Node.js binary", () => {
|
||||
assert.ok(
|
||||
path.isAbsolute(process.execPath),
|
||||
`process.execPath must be absolute, got: ${process.execPath}`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(process.execPath),
|
||||
`process.execPath must exist: ${process.execPath}`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Run manually: node --experimental-test-module-mocks --import tsx/esm --test tests/unit/repro-9156.test.ts
|
||||
|
||||
import { mock } from "node:test";
|
||||
|
||||
if (typeof mock.module === "function") {
|
||||
test("(runtime) ServerSupervisor.start() spawns with process.execPath (#9156)", async () => {
|
||||
let spawnExecutable: string | undefined;
|
||||
const { EventEmitter } = await import("node:events");
|
||||
|
||||
const mockChild = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
kill: () => {},
|
||||
});
|
||||
|
||||
mock.module("node:child_process", {
|
||||
exports: {
|
||||
spawn: (...args: unknown[]) => {
|
||||
spawnExecutable = args[0] as string;
|
||||
return mockChild;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.PORT = "0";
|
||||
|
||||
const { ServerSupervisor } = await import(
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: "/fake/server.js",
|
||||
env: {},
|
||||
maxRestarts: 0,
|
||||
});
|
||||
|
||||
spawnExecutable = undefined;
|
||||
supervisor.start();
|
||||
|
||||
assert.ok(spawnExecutable, "spawn() must have been called");
|
||||
assert.equal(
|
||||
spawnExecutable,
|
||||
process.execPath,
|
||||
`expected process.execPath, got: ${spawnExecutable}`
|
||||
);
|
||||
assert.notEqual(spawnExecutable, "node", "must not be bare 'node'");
|
||||
|
||||
mockChild.removeAllListeners();
|
||||
delete process.env.PORT;
|
||||
});
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const llmChatCardPath =
|
||||
"src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx";
|
||||
const src = readFileSync(join(root, llmChatCardPath), "utf8");
|
||||
|
||||
const DISABLED_ON_LOADING = /disabled\s*=\s*\{\s*loading\s*\}/;
|
||||
const MODELS_LOADING_MARKER = /modelsLoading|Loading…|Loading\.\.\./;
|
||||
const ERROR_BRANCH = /error\s*&&/;
|
||||
const RETRY_ACTION = /onClick\s*=\s*\{[^}]*retry|retry[A-Za-z]*\s*\(\)|const\s+\[reload/i;
|
||||
const NO_MODELS_AFTER_EMPTY = /modelOptions\.length\s*===?\s*0|models\.length\s*===?\s*0/;
|
||||
|
||||
test("LlmChatCard destructures loading and error from useProviderModels (#9626)", () => {
|
||||
const match = src.match(/const\s*\{\s*([^}]+)\s*\}\s*=\s*useProviderModels\(/);
|
||||
assert.ok(match, "Expected to find a destructuring of useProviderModels");
|
||||
|
||||
const destructured = match[1];
|
||||
assert.ok(
|
||||
destructured.includes("loading"),
|
||||
"loading state must be destructured from useProviderModels"
|
||||
);
|
||||
assert.ok(destructured.includes("error"), "error state must be destructured from useProviderModels");
|
||||
});
|
||||
|
||||
test("LlmChatCard disables the model selector while models are loading (#9626)", () => {
|
||||
assert.ok(
|
||||
DISABLED_ON_LOADING.test(src),
|
||||
"The model <select> must be disabled while the models request is pending"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard shows a visible loading label while models are pending (#9626)", () => {
|
||||
assert.ok(
|
||||
MODELS_LOADING_MARKER.test(src),
|
||||
"A visible loading text (e.g. 'Loading…') must appear while the models request is pending"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard surfaces the provider model error in the UI (#9626)", () => {
|
||||
assert.ok(
|
||||
ERROR_BRANCH.test(src),
|
||||
"An error branch that renders the captured error message must exist"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard offers a retry action when the model request fails (#9626)", () => {
|
||||
assert.ok(
|
||||
RETRY_ACTION.test(src),
|
||||
"A retry action must be offered next to the model error"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard keeps the empty-state message distinct from an error (#9626)", () => {
|
||||
assert.ok(
|
||||
NO_MODELS_AFTER_EMPTY.test(src),
|
||||
"The empty-state (no models) message must only be shown for a successful empty response"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user