From 9aa3657e5e892b012d9f76d1f2932ca21480d446 Mon Sep 17 00:00:00 2001 From: bbopen Date: Fri, 13 Feb 2026 09:07:24 -0800 Subject: [PATCH 1/3] feat(runtime): add getBridgeInfo() meta call --- src/runtime/bridge-protocol.ts | 82 ++++++++++++++++++++++++++++++++++ test/runtime_node.test.ts | 29 +++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/runtime/bridge-protocol.ts b/src/runtime/bridge-protocol.ts index c6ec15e9..9a32b5c5 100644 --- a/src/runtime/bridge-protocol.ts +++ b/src/runtime/bridge-protocol.ts @@ -16,14 +16,26 @@ * @see https://github.com/bbopen/tywrap/issues/149 */ +import type { BridgeInfo } from '../types/index.js'; + import { BoundedContext, type ExecuteOptions } from './bounded-context.js'; +import { BridgeProtocolError } from './errors.js'; import { SafeCodec, type CodecOptions } from './safe-codec.js'; +import { TYWRAP_PROTOCOL_VERSION } from './protocol.js'; import { PROTOCOL_ID, type Transport, type ProtocolMessage } from './transport.js'; // ============================================================================= // TYPES // ============================================================================= +export interface GetBridgeInfoOptions { + /** + * If true, bypasses the cached info and queries the bridge again. + * This is useful when you want up-to-date instance counts or diagnostics. + */ + refresh?: boolean; +} + /** * Configuration options for BridgeProtocol. */ @@ -38,6 +50,48 @@ export interface BridgeProtocolOptions { defaultTimeoutMs?: number; } +function validateBridgeInfoPayload(value: unknown): BridgeInfo { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + + const candidate = value as BridgeInfo; + if (candidate.protocol !== PROTOCOL_ID || candidate.protocolVersion !== TYWRAP_PROTOCOL_VERSION) { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + + if (candidate.bridge !== 'python-subprocess') { + throw new BridgeProtocolError(`Unexpected bridge identifier: ${candidate.bridge}`); + } + + if (typeof candidate.pythonVersion !== 'string' || candidate.pythonVersion.length === 0) { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.pid !== 'number' || !Number.isFinite(candidate.pid)) { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (candidate.codecFallback !== 'json' && candidate.codecFallback !== 'none') { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.arrowAvailable !== 'boolean') { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.scipyAvailable !== 'boolean') { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.torchAvailable !== 'boolean') { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.sklearnAvailable !== 'boolean') { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + if (typeof candidate.instances !== 'number' || !Number.isFinite(candidate.instances)) { + throw new BridgeProtocolError('Invalid bridge info payload'); + } + + return candidate; +} + // ============================================================================= // BRIDGE PROTOCOL BASE CLASS // ============================================================================= @@ -80,6 +134,9 @@ export class BridgeProtocol extends BoundedContext { /** Counter for generating unique request IDs */ private requestId = 0; + /** Cached bridge diagnostics info (populated by getBridgeInfo). */ + private bridgeInfoCache?: BridgeInfo; + /** * Create a new BridgeProtocol instance. * @@ -316,4 +373,29 @@ export class BridgeProtocol extends BoundedContext { }, }); } + + /** + * Fetch bridge diagnostics and feature availability. + * + * The Python bridge supports a `meta` method that returns protocol and environment info + * (including optional codec availability and current instance count). + */ + async getBridgeInfo(options: GetBridgeInfoOptions = {}): Promise { + if (!options.refresh && this.bridgeInfoCache) { + return this.bridgeInfoCache; + } + + const info = await this.sendMessage( + { + method: 'meta', + params: {}, + }, + { + validate: validateBridgeInfoPayload, + } + ); + + this.bridgeInfoCache = info; + return info; + } } diff --git a/test/runtime_node.test.ts b/test/runtime_node.test.ts index 373ea55a..991a5360 100644 --- a/test/runtime_node.test.ts +++ b/test/runtime_node.test.ts @@ -130,6 +130,33 @@ describeNodeOnly('Node.js Runtime Bridge', () => { }, testTimeout ); + + it( + 'should report bridge info and track instance counts', + async () => { + const pythonAvailable = await isPythonAvailable(); + if (!pythonAvailable || !isBridgeScriptAvailable()) return; + + const info = await bridge.getBridgeInfo(); + expect(info.protocol).toBe('tywrap/1'); + expect(info.protocolVersion).toBeGreaterThan(0); + expect(info.bridge).toBe('python-subprocess'); + expect(info.pythonVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(typeof info.scipyAvailable).toBe('boolean'); + expect(typeof info.torchAvailable).toBe('boolean'); + expect(typeof info.sklearnAvailable).toBe('boolean'); + + const before = info.instances; + const handle = await bridge.instantiate('collections', 'Counter', [[1, 2, 2]]); + const mid = await bridge.getBridgeInfo({ refresh: true }); + expect(mid.instances).toBe(before + 1); + + await bridge.disposeInstance(handle); + const after = await bridge.getBridgeInfo({ refresh: true }); + expect(after.instances).toBe(before); + }, + testTimeout + ); }); describe('Stdlib Serialization', () => { @@ -478,7 +505,7 @@ def get_path(): it.each(['__proto__', 'prototype', 'constructor'])( 'should reject dangerous environment override key %s', - (dangerousKey) => { + dangerousKey => { const envOverrides = Object.create(null) as Record; Object.defineProperty(envOverrides, dangerousKey, { value: 'blocked', From a531a4095b328eeafd6813418b967f3f9b6bc61c Mon Sep 17 00:00:00 2001 From: bbopen Date: Fri, 13 Feb 2026 22:30:53 -0800 Subject: [PATCH 2/3] fix(runtime): harden BridgeInfo validation and tests --- src/runtime/bridge-protocol.ts | 127 +++++++++++++++++++++++++++------ test/runtime_node.test.ts | 6 +- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/src/runtime/bridge-protocol.ts b/src/runtime/bridge-protocol.ts index 9a32b5c5..58df2c2b 100644 --- a/src/runtime/bridge-protocol.ts +++ b/src/runtime/bridge-protocol.ts @@ -52,44 +52,125 @@ export interface BridgeProtocolOptions { function validateBridgeInfoPayload(value: unknown): BridgeInfo { if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new BridgeProtocolError('Invalid bridge info payload'); + const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; + throw new BridgeProtocolError(`Invalid bridge info payload: expected object, got ${kind}`); } - const candidate = value as BridgeInfo; - if (candidate.protocol !== PROTOCOL_ID || candidate.protocolVersion !== TYWRAP_PROTOCOL_VERSION) { - throw new BridgeProtocolError('Invalid bridge info payload'); + interface BridgeInfoWire { + protocol?: unknown; + protocolVersion?: unknown; + bridge?: unknown; + pythonVersion?: unknown; + pid?: unknown; + codecFallback?: unknown; + arrowAvailable?: unknown; + scipyAvailable?: unknown; + torchAvailable?: unknown; + sklearnAvailable?: unknown; + instances?: unknown; } - if (candidate.bridge !== 'python-subprocess') { - throw new BridgeProtocolError(`Unexpected bridge identifier: ${candidate.bridge}`); + const formatValue = (val: unknown): string => { + try { + const serialized = JSON.stringify(val); + return serialized ?? String(val); + } catch { + return String(val); + } + }; + + const obj = value as BridgeInfoWire; + + const protocol = obj.protocol; + if (protocol !== PROTOCOL_ID) { + throw new BridgeProtocolError( + `Invalid bridge info payload: protocol expected "${PROTOCOL_ID}", got ${formatValue(protocol)}` + ); } - if (typeof candidate.pythonVersion !== 'string' || candidate.pythonVersion.length === 0) { - throw new BridgeProtocolError('Invalid bridge info payload'); + const protocolVersion = obj.protocolVersion; + if (protocolVersion !== TYWRAP_PROTOCOL_VERSION) { + throw new BridgeProtocolError( + `Invalid bridge info payload: protocolVersion expected ${TYWRAP_PROTOCOL_VERSION}, got ${formatValue(protocolVersion)}` + ); } - if (typeof candidate.pid !== 'number' || !Number.isFinite(candidate.pid)) { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const bridge = obj.bridge; + if (bridge !== 'python-subprocess') { + throw new BridgeProtocolError( + `Invalid bridge info payload: bridge expected "python-subprocess", got ${formatValue(bridge)}` + ); } - if (candidate.codecFallback !== 'json' && candidate.codecFallback !== 'none') { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const pythonVersion = obj.pythonVersion; + if (typeof pythonVersion !== 'string' || pythonVersion.length === 0) { + throw new BridgeProtocolError( + `Invalid bridge info payload: pythonVersion expected non-empty string, got ${formatValue(pythonVersion)}` + ); } - if (typeof candidate.arrowAvailable !== 'boolean') { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const pid = obj.pid; + if (typeof pid !== 'number' || !Number.isFinite(pid)) { + throw new BridgeProtocolError( + `Invalid bridge info payload: pid expected finite number, got ${formatValue(pid)}` + ); } - if (typeof candidate.scipyAvailable !== 'boolean') { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const codecFallback = obj.codecFallback; + if (codecFallback !== 'json' && codecFallback !== 'none') { + throw new BridgeProtocolError( + `Invalid bridge info payload: codecFallback expected "json" or "none", got ${formatValue(codecFallback)}` + ); } - if (typeof candidate.torchAvailable !== 'boolean') { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const arrowAvailable = obj.arrowAvailable; + if (typeof arrowAvailable !== 'boolean') { + throw new BridgeProtocolError( + `Invalid bridge info payload: arrowAvailable expected boolean, got ${formatValue(arrowAvailable)}` + ); + } + + const scipyAvailable = obj.scipyAvailable; + if (typeof scipyAvailable !== 'boolean') { + throw new BridgeProtocolError( + `Invalid bridge info payload: scipyAvailable expected boolean, got ${formatValue(scipyAvailable)}` + ); + } + + const torchAvailable = obj.torchAvailable; + if (typeof torchAvailable !== 'boolean') { + throw new BridgeProtocolError( + `Invalid bridge info payload: torchAvailable expected boolean, got ${formatValue(torchAvailable)}` + ); } - if (typeof candidate.sklearnAvailable !== 'boolean') { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const sklearnAvailable = obj.sklearnAvailable; + if (typeof sklearnAvailable !== 'boolean') { + throw new BridgeProtocolError( + `Invalid bridge info payload: sklearnAvailable expected boolean, got ${formatValue(sklearnAvailable)}` + ); } - if (typeof candidate.instances !== 'number' || !Number.isFinite(candidate.instances)) { - throw new BridgeProtocolError('Invalid bridge info payload'); + + const instances = obj.instances; + if (typeof instances !== 'number' || !Number.isFinite(instances)) { + throw new BridgeProtocolError( + `Invalid bridge info payload: instances expected finite number, got ${formatValue(instances)}` + ); } - return candidate; + return { + protocol: PROTOCOL_ID, + protocolVersion: TYWRAP_PROTOCOL_VERSION, + bridge: 'python-subprocess', + pythonVersion, + pid, + codecFallback, + arrowAvailable, + scipyAvailable, + torchAvailable, + sklearnAvailable, + instances, + }; } // ============================================================================= diff --git a/test/runtime_node.test.ts b/test/runtime_node.test.ts index 991a5360..4ece6d47 100644 --- a/test/runtime_node.test.ts +++ b/test/runtime_node.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'; import { delimiter, join } from 'path'; import { NodeBridge } from '../src/runtime/node.js'; import { BridgeProtocolError } from '../src/runtime/errors.js'; +import { TYWRAP_PROTOCOL_VERSION } from '../src/runtime/protocol.js'; import { getDefaultPythonPath, resolvePythonExecutable } from '../src/utils/python.js'; import { isNodejs, getVenvBinDir } from '../src/utils/runtime.js'; @@ -139,13 +140,16 @@ describeNodeOnly('Node.js Runtime Bridge', () => { const info = await bridge.getBridgeInfo(); expect(info.protocol).toBe('tywrap/1'); - expect(info.protocolVersion).toBeGreaterThan(0); + expect(info.protocolVersion).toBe(TYWRAP_PROTOCOL_VERSION); expect(info.bridge).toBe('python-subprocess'); expect(info.pythonVersion).toMatch(/^\d+\.\d+\.\d+$/); expect(typeof info.scipyAvailable).toBe('boolean'); expect(typeof info.torchAvailable).toBe('boolean'); expect(typeof info.sklearnAvailable).toBe('boolean'); + const cached = await bridge.getBridgeInfo(); + expect(cached).toBe(info); + const before = info.instances; const handle = await bridge.instantiate('collections', 'Counter', [[1, 2, 2]]); const mid = await bridge.getBridgeInfo({ refresh: true }); From 28a6725392659b379939c3e5f466647513675cb4 Mon Sep 17 00:00:00 2001 From: bbopen Date: Fri, 13 Feb 2026 22:39:01 -0800 Subject: [PATCH 3/3] fix(runtime): tighten BridgeInfo pid/instances validation --- src/runtime/bridge-protocol.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/runtime/bridge-protocol.ts b/src/runtime/bridge-protocol.ts index 58df2c2b..766435fd 100644 --- a/src/runtime/bridge-protocol.ts +++ b/src/runtime/bridge-protocol.ts @@ -110,9 +110,9 @@ function validateBridgeInfoPayload(value: unknown): BridgeInfo { } const pid = obj.pid; - if (typeof pid !== 'number' || !Number.isFinite(pid)) { + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { throw new BridgeProtocolError( - `Invalid bridge info payload: pid expected finite number, got ${formatValue(pid)}` + `Invalid bridge info payload: pid expected positive integer, got ${formatValue(pid)}` ); } @@ -152,9 +152,9 @@ function validateBridgeInfoPayload(value: unknown): BridgeInfo { } const instances = obj.instances; - if (typeof instances !== 'number' || !Number.isFinite(instances)) { + if (typeof instances !== 'number' || !Number.isInteger(instances) || instances < 0) { throw new BridgeProtocolError( - `Invalid bridge info payload: instances expected finite number, got ${formatValue(instances)}` + `Invalid bridge info payload: instances expected non-negative integer, got ${formatValue(instances)}` ); } @@ -255,6 +255,7 @@ export class BridgeProtocol extends BoundedContext { * but should not need to dispose the transport manually. */ protected async doDispose(): Promise { + this.bridgeInfoCache = undefined; // Transport is tracked and will be disposed by BoundedContext // Subclasses can override to add additional cleanup }