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:
2026-08-05 20:10:20 +05:30
co-authored by Claude Opus 5
parent 7c4b484d0a
commit ce68803471
11 changed files with 347 additions and 31 deletions
+38 -9
View File
@@ -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(() => {});
}
};
},
+21 -1
View File
@@ -34,6 +34,16 @@ export function implement<Procedures extends AnyProcedures>(
handlers: ServiceHandlers<Procedures>,
options: ImplementOptions,
): ServiceImplementation<Procedures> {
// A declared procedure with no own handler would otherwise only surface at
// invoke time as RPC_UNKNOWN — a silent, permanent 404. Catch it now.
for (const procedureName of Object.keys(contract.procedures)) {
if (!Object.hasOwn(handlers, procedureName)) {
throw new Error(
`WRN-RPC-HANDLER: service "${contract.name}" declares procedure "${procedureName}" ` +
`but implement() was not given a handler for it.`,
);
}
}
return {
contract,
async invoke(procedureName, payload, identity) {
@@ -69,7 +79,17 @@ export function implement<Procedures extends AnyProcedures>(
let input: unknown = payload;
if (definition.input) {
const parsed = definition.input.parse(payload as Record<string, unknown>);
// InputSchema is structural: any custom or wrapped schema may throw
// instead of returning { ok: false }. A throw must not escape invoke()
// with its raw message — that text can carry internals — so it is
// caught the same way the permission check above is.
let parsed: { ok: boolean; value?: unknown };
try {
parsed = definition.input.parse(payload as Record<string, unknown>);
} catch (error) {
console.error(`[wrnexus] RPC input schema threw for ${String(procedureName)}`, error);
return failure(RPC_ERROR_CODES.invalid, "Invalid input");
}
if (!parsed.ok) return failure(RPC_ERROR_CODES.invalid, "Invalid input");
input = parsed.value;
}
+9 -1
View File
@@ -25,8 +25,16 @@ export type InProcessHandler = (
export function inProcessTransport(handlers: Record<string, InProcessHandler>): Transport {
return {
async call(target, payload, options) {
// Checked only at entry: this in-process transport does no I/O, so
// nothing yields between here and the handler call below, and a signal
// that aborts mid-flight is never observed. In-process tests therefore
// cannot exercise a mid-call timeout path — that needs a real transport.
if (options.signal?.aborted) return failure(RPC_ERROR_CODES.transport, "Call aborted");
const handler = handlers[`${target.service}/${target.procedure}`];
const key = `${target.service}/${target.procedure}`;
// Object.hasOwn, not plain indexing: a prototype-inherited key (e.g.
// "constructor/toString") would otherwise resolve to a function that
// is not one of our handlers.
const handler = Object.hasOwn(handlers, key) ? handlers[key] : undefined;
if (!handler) return failure(RPC_ERROR_CODES.unknown, "Unknown procedure");
try {
return await handler(payload, options.identity);