fix(rpc): close the service-collision fail-open and the fix-wave gaps
Critical: - router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files scan to the same service name, instead of silently letting directory-walk order pick a winner. Important: - server.ts: wrap a throwing input schema so its raw message cannot escape invoke(); returns RPC_INVALID and logs server-side instead. - client.ts: race timeoutMs against transport.call so a stalled transport cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT). - client.ts: the proxy returns undefined for undeclared properties (incl. then/catch/finally) instead of a function that throws, closing the await-client thenable trap. - gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER from @wrnexus/rpc instead of hardcoding local copies. - gateway.test.ts: cover the RPC-prefix edge block and internal-header stripping across casing variants. - http.test.ts / client.test.ts: cover anonymous-call header omission, the internal marker, the retryable-status sweep, network/malformed/HTML failures, AbortSignal propagation, the timeout path, and timer cleanup. Minor: - transport.ts: Object.hasOwn for handler lookup; note the entry-only abort check. - client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError (RPC_IDENTITY) instead of a bare Error. - rpc/package.json: drop the unused @wrnexus/authz dependency. - server.ts: implement() now throws at construction time if a declared procedure has no own handler. Verified: reverting the service-collision check and the client timeout race each make their new test fail, then restore green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,24 +32,53 @@ export function serviceClient<Procedures extends AnyProcedures>(
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
return new Proxy({} as ServiceClient<Procedures>, {
|
||||
get(_target, property) {
|
||||
if (typeof property !== "string") return undefined;
|
||||
// Every declared procedure is a string key; anything else (including
|
||||
// `then`/`catch`/`finally`) is not one of ours. Returning a function for
|
||||
// those makes `await client` or `return client` from an async function
|
||||
// read the proxy as thenable — the runtime then calls `then(resolve,
|
||||
// reject)`, which throws "Unknown procedure". Returning undefined lets
|
||||
// the caller be treated as a plain (non-thenable) object instead.
|
||||
if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property)) {
|
||||
return undefined;
|
||||
}
|
||||
return async (input: unknown) => {
|
||||
if (!Object.hasOwn(contract.procedures, property)) {
|
||||
throw new ServiceError(RPC_ERROR_CODES.unknown, "Unknown procedure");
|
||||
let identity: string | undefined;
|
||||
if (options.as) {
|
||||
try {
|
||||
identity = await exportSubjectContext(options.as, app);
|
||||
} catch (error) {
|
||||
// A missing/misconfigured WRNEXUS_RPC_SECRET otherwise rejects with
|
||||
// a bare Error, so a caller matching on ServiceError treats
|
||||
// misconfiguration as a crash instead of a handled RPC failure.
|
||||
// The operator-facing message names no secret value, so it is safe
|
||||
// to preserve.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ServiceError(RPC_ERROR_CODES.identity, message);
|
||||
}
|
||||
}
|
||||
const identity = options.as ? await exportSubjectContext(options.as, app) : undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
controller.signal.addEventListener("abort", () => {
|
||||
reject(new ServiceError(RPC_ERROR_CODES.transport, "Call timed out"));
|
||||
});
|
||||
});
|
||||
const callPromise = options.transport.call(
|
||||
{ app, service: contract.name, procedure: property },
|
||||
input,
|
||||
{ signal: controller.signal, ...(identity ? { identity } : {}) },
|
||||
);
|
||||
try {
|
||||
const result = await options.transport.call(
|
||||
{ app, service: contract.name, procedure: property },
|
||||
input,
|
||||
{ signal: controller.signal, ...(identity ? { identity } : {}) },
|
||||
);
|
||||
const result = await Promise.race([callPromise, timeout]);
|
||||
if (result.ok) return result.value;
|
||||
throw new ServiceError(result.code, result.message);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
// If the timeout won the race, the transport call may still settle
|
||||
// later — a transport that ignores the abort signal keeps running.
|
||||
// Nothing awaits it again, so swallow a late rejection here rather
|
||||
// than let it surface as an unhandled promise rejection.
|
||||
callPromise.catch(() => {});
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user