diff --git a/packages/@jsii/kernel/src/api-environment.test.ts b/packages/@jsii/kernel/src/api-environment.test.ts new file mode 100644 index 0000000000..3b9e2494e8 --- /dev/null +++ b/packages/@jsii/kernel/src/api-environment.test.ts @@ -0,0 +1,432 @@ +import * as api from './api'; + +describe('Environment Change API Types', () => { + describe('EnvironmentChangeRequest', () => { + test('should have correct structure for set operation', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'TEST_VAR', + value: 'test_value', + type: 'set', + }; + + expect(request.api).toBe('env.notifyChange'); + expect(request.key).toBe('TEST_VAR'); + expect(request.value).toBe('test_value'); + expect(request.type).toBe('set'); + }); + + test('should have correct structure for delete operation', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'TEST_VAR', + type: 'delete', + }; + + expect(request.api).toBe('env.notifyChange'); + expect(request.key).toBe('TEST_VAR'); + expect(request.value).toBeUndefined(); + expect(request.type).toBe('delete'); + }); + + test('should allow undefined value for delete operations', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'DELETE_VAR', + type: 'delete', + value: undefined, + }; + + expect(request.value).toBeUndefined(); + expect(request.type).toBe('delete'); + }); + + test('should serialize correctly as JSON', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'JSON_TEST', + value: 'json_value', + type: 'set', + }; + + const jsonString = JSON.stringify(request); + const parsed = JSON.parse(jsonString); + + expect(parsed.api).toBe('env.notifyChange'); + expect(parsed.key).toBe('JSON_TEST'); + expect(parsed.value).toBe('json_value'); + expect(parsed.type).toBe('set'); + }); + + test('should handle special characters in key and value', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'VAR_WITH-SPECIAL$CHARS', + value: 'value with spaces and "quotes"', + type: 'set', + }; + + const jsonString = JSON.stringify(request); + const parsed = JSON.parse(jsonString); + + expect(parsed.key).toBe('VAR_WITH-SPECIAL$CHARS'); + expect(parsed.value).toBe('value with spaces and "quotes"'); + }); + + test('should handle unicode values', () => { + const unicodeValue = 'Hello 世界 🌍 Ñiño'; + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'UNICODE_VAR', + value: unicodeValue, + type: 'set', + }; + + const jsonString = JSON.stringify(request); + const parsed = JSON.parse(jsonString); + + expect(parsed.value).toBe(unicodeValue); + }); + }); + + describe('EnvironmentChangeResponse', () => { + test('should have success field', () => { + const response: api.EnvironmentChangeResponse = { + success: true, + }; + + expect(response.success).toBe(true); + }); + + test('should support both success and failure states', () => { + const successResponse: api.EnvironmentChangeResponse = { + success: true, + }; + + const failureResponse: api.EnvironmentChangeResponse = { + success: false, + }; + + expect(successResponse.success).toBe(true); + expect(failureResponse.success).toBe(false); + }); + + test('should serialize correctly as JSON', () => { + const response: api.EnvironmentChangeResponse = { + success: true, + }; + + const jsonString = JSON.stringify(response); + const parsed = JSON.parse(jsonString); + + expect(parsed.success).toBe(true); + }); + }); + + describe('Callback interface extension', () => { + test('should support envChange field in callback', () => { + const callback: api.Callback = { + cbid: 'test-callback-id', + cookie: 'env-sync', + envChange: { + type: 'set', + key: 'CALLBACK_TEST_VAR', + value: 'callback_value', + oldValue: undefined, + }, + }; + + expect(callback.cbid).toBe('test-callback-id'); + expect(callback.cookie).toBe('env-sync'); + expect(callback.envChange).toBeDefined(); + expect(callback.envChange!.type).toBe('set'); + expect(callback.envChange!.key).toBe('CALLBACK_TEST_VAR'); + expect(callback.envChange!.value).toBe('callback_value'); + }); + + test('should support envChange with delete operation', () => { + const callback: api.Callback = { + cbid: 'delete-callback-id', + cookie: 'env-sync', + envChange: { + type: 'delete', + key: 'DELETED_VAR', + oldValue: 'previous_value', + }, + }; + + expect(callback.envChange!.type).toBe('delete'); + expect(callback.envChange!.key).toBe('DELETED_VAR'); + expect(callback.envChange!.value).toBeUndefined(); + expect(callback.envChange!.oldValue).toBe('previous_value'); + }); + + test('should allow callback without envChange for backwards compatibility', () => { + const callback: api.Callback = { + cbid: 'regular-callback-id', + cookie: 'other', + invoke: { + objref: { [api.TOKEN_REF]: 'test-obj' }, + method: 'testMethod', + args: [], + }, + }; + + expect(callback.envChange).toBeUndefined(); + expect(callback.invoke).toBeDefined(); + }); + + test('should serialize callback with envChange correctly', () => { + const callback: api.Callback = { + cbid: 'serialize-test', + cookie: 'env-sync', + envChange: { + type: 'set', + key: 'SERIALIZE_VAR', + value: 'serialize_value', + oldValue: 'old_serialize_value', + }, + }; + + const jsonString = JSON.stringify(callback); + const parsed = JSON.parse(jsonString); + + expect(parsed.cbid).toBe('serialize-test'); + expect(parsed.cookie).toBe('env-sync'); + expect(parsed.envChange.type).toBe('set'); + expect(parsed.envChange.key).toBe('SERIALIZE_VAR'); + expect(parsed.envChange.value).toBe('serialize_value'); + expect(parsed.envChange.oldValue).toBe('old_serialize_value'); + }); + }); + + describe('KernelRequest union type', () => { + test('should include EnvironmentChangeRequest in union', () => { + const envChangeRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'UNION_TEST', + value: 'union_value', + type: 'set', + }; + + // Should be assignable to KernelRequest union + const kernelRequest: api.KernelRequest = envChangeRequest; + expect((kernelRequest as api.EnvironmentChangeRequest).api).toBe( + 'env.notifyChange', + ); + }); + + test('should handle EnvironmentChangeRequest alongside other request types', () => { + const createRequest: api.CreateRequest = { + fqn: 'test.Class', + args: [], + }; + + const envChangeRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'MIXED_TEST', + value: 'mixed_value', + type: 'set', + }; + + const requests: api.KernelRequest[] = [createRequest, envChangeRequest]; + + expect(requests).toHaveLength(2); + + // Check that environment change request is properly typed + const envRequest = requests[1] as api.EnvironmentChangeRequest; + expect(envRequest.api).toBe('env.notifyChange'); + expect(envRequest.key).toBe('MIXED_TEST'); + }); + }); + + describe('KernelResponse union type', () => { + test('should include EnvironmentChangeResponse in union', () => { + const envChangeResponse: api.EnvironmentChangeResponse = { + success: true, + }; + + // Should be assignable to KernelResponse union + const kernelResponse: api.KernelResponse = envChangeResponse; + expect((kernelResponse as api.EnvironmentChangeResponse).success).toBe( + true, + ); + }); + + test('should handle EnvironmentChangeResponse alongside other response types', () => { + const loadResponse: api.LoadResponse = { + assembly: 'test-assembly', + types: 5, + }; + + const envChangeResponse: api.EnvironmentChangeResponse = { + success: true, + }; + + const responses: api.KernelResponse[] = [loadResponse, envChangeResponse]; + + expect(responses).toHaveLength(2); + + // Check that environment change response is properly typed + const envResponse = responses[1] as api.EnvironmentChangeResponse; + expect(envResponse.success).toBe(true); + }); + }); + + describe('Type validation', () => { + test('should enforce required fields in EnvironmentChangeRequest', () => { + // This test ensures the TypeScript compiler catches missing required fields + + // Valid request should compile + const validRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'VALID_TEST', + type: 'set', + value: 'valid_value', + }; + + expect(validRequest.api).toBe('env.notifyChange'); + expect(validRequest.key).toBe('VALID_TEST'); + expect(validRequest.type).toBe('set'); + + // Missing required fields would cause TypeScript compilation errors + // These are commented out as they would prevent compilation: + + // const invalidRequest1: api.EnvironmentChangeRequest = { + // // missing 'api' field + // key: 'TEST', + // type: 'set', + // }; + + // const invalidRequest2: api.EnvironmentChangeRequest = { + // api: 'env.notifyChange', + // // missing 'key' field + // type: 'set', + // }; + + // const invalidRequest3: api.EnvironmentChangeRequest = { + // api: 'env.notifyChange', + // key: 'TEST', + // // missing 'type' field + // }; + }); + + test('should allow only valid type values', () => { + // Valid types should work + const setRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'SET_TEST', + type: 'set', + value: 'set_value', + }; + + const deleteRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'DELETE_TEST', + type: 'delete', + }; + + expect(setRequest.type).toBe('set'); + expect(deleteRequest.type).toBe('delete'); + + // Invalid type values would cause TypeScript compilation errors: + // const invalidTypeRequest: api.EnvironmentChangeRequest = { + // api: 'env.notifyChange', + // key: 'INVALID_TEST', + // type: 'invalid', // This would cause a compilation error + // }; + }); + + test('should enforce boolean type for success field', () => { + const successResponse: api.EnvironmentChangeResponse = { + success: true, + }; + + const failureResponse: api.EnvironmentChangeResponse = { + success: false, + }; + + expect(typeof successResponse.success).toBe('boolean'); + expect(typeof failureResponse.success).toBe('boolean'); + + // Non-boolean values would cause TypeScript compilation errors: + // const invalidResponse: api.EnvironmentChangeResponse = { + // success: 'true', // This would cause a compilation error + // }; + }); + }); + + describe('Edge cases and data integrity', () => { + test('should handle empty string values', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'EMPTY_STRING_TEST', + value: '', + type: 'set', + }; + + expect(request.value).toBe(''); + expect(request.value).not.toBeUndefined(); + }); + + test('should handle very long variable names and values', () => { + const longKey = 'A'.repeat(1000); + const longValue = 'B'.repeat(10000); + + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: longKey, + value: longValue, + type: 'set', + }; + + expect(request.key).toBe(longKey); + expect(request.value).toBe(longValue); + expect(request.key.length).toBe(1000); + expect(request.value!.length).toBe(10000); + }); + + test('should maintain data integrity through JSON serialization round trip', () => { + const originalRequest: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'ROUND_TRIP_TEST', + value: 'Special chars: \n\t\r"\'\\', + type: 'set', + }; + + const jsonString = JSON.stringify(originalRequest); + const parsedRequest = JSON.parse( + jsonString, + ) as api.EnvironmentChangeRequest; + + expect(parsedRequest.api).toBe(originalRequest.api); + expect(parsedRequest.key).toBe(originalRequest.key); + expect(parsedRequest.value).toBe(originalRequest.value); + expect(parsedRequest.type).toBe(originalRequest.type); + }); + + test('should handle callback with both envChange and other fields', () => { + // Although this might not occur in normal operation, + // the type system should allow it for flexibility + const hybridCallback: api.Callback = { + cbid: 'hybrid-callback', + cookie: 'env-sync', + envChange: { + type: 'set', + key: 'HYBRID_VAR', + value: 'hybrid_value', + }, + // Other fields should still be allowed + invoke: { + objref: { [api.TOKEN_REF]: 'test-obj' }, + method: 'testMethod', + args: [], + }, + }; + + expect(hybridCallback.envChange).toBeDefined(); + expect(hybridCallback.invoke).toBeDefined(); + expect(hybridCallback.cbid).toBe('hybrid-callback'); + }); + }); +}); diff --git a/packages/@jsii/kernel/src/api.ts b/packages/@jsii/kernel/src/api.ts index d5e421e9fe..9511f6423b 100644 --- a/packages/@jsii/kernel/src/api.ts +++ b/packages/@jsii/kernel/src/api.ts @@ -77,6 +77,12 @@ export interface Callback { readonly invoke?: InvokeRequest; readonly get?: GetRequest; readonly set?: SetRequest; + readonly envChange?: { + readonly type: 'set' | 'delete'; + readonly key: string; + readonly value?: string; + readonly oldValue?: string; + }; } export interface HelloResponse { @@ -256,6 +262,17 @@ export interface StatsResponse { readonly objectCount: number; } +export interface EnvironmentChangeRequest { + readonly api: 'env.notifyChange'; + readonly key: string; + readonly value?: string; + readonly type: 'set' | 'delete'; +} + +export interface EnvironmentChangeResponse { + readonly success: boolean; +} + export type KernelRequest = | LoadRequest | CreateRequest @@ -268,7 +285,8 @@ export type KernelRequest = | CallbacksRequest | CompleteRequest | NamingRequest - | StatsRequest; + | StatsRequest + | EnvironmentChangeRequest; export type KernelResponse = | HelloResponse @@ -283,7 +301,8 @@ export type KernelResponse = | CallbacksResponse | CompleteResponse | NamingResponse - | StatsResponse; + | StatsResponse + | EnvironmentChangeResponse; export interface OkayResponse { readonly ok: any; diff --git a/packages/@jsii/kernel/src/environment-monitor.test.ts b/packages/@jsii/kernel/src/environment-monitor.test.ts new file mode 100644 index 0000000000..977df47f5a --- /dev/null +++ b/packages/@jsii/kernel/src/environment-monitor.test.ts @@ -0,0 +1,423 @@ +import { + EnvironmentMonitor, + EnvironmentChangeEvent, +} from './environment-monitor'; + +describe('EnvironmentMonitor', () => { + let originalEnv: typeof process.env; + let changeHandler: jest.Mock; + let monitor: EnvironmentMonitor; + + beforeEach(() => { + // Save original process.env + originalEnv = { ...process.env }; + + // Clear test environment variables + delete process.env.TEST_VAR; + delete process.env.TEST_VAR_2; + delete process.env.DELETE_TEST; + + // Create mock change handler + changeHandler = jest.fn(); + + // Create monitor instance + monitor = new EnvironmentMonitor(changeHandler); + }); + + afterEach(() => { + // Restore original process.env + process.env = { ...originalEnv }; + + // Clear mock + changeHandler.mockClear(); + }); + + describe('constructor', () => { + test('should create monitor and wrap process.env', () => { + expect(monitor).toBeDefined(); + expect(typeof changeHandler).toBe('function'); + }); + + test('should preserve existing environment variables', () => { + const existingVar = process.env.PATH; + expect(existingVar).toBeDefined(); + + // Create new monitor + const newHandler = jest.fn(); + new EnvironmentMonitor(newHandler); + + // Should still have existing variable + expect(process.env.PATH).toBe(existingVar); + }); + }); + + describe('environment variable setting', () => { + test('should detect when environment variable is set', () => { + process.env.TEST_VAR = 'test_value'; + + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: 'test_value', + oldValue: undefined, + }); + }); + + test('should detect when environment variable is updated', () => { + // Set initial value + process.env.TEST_VAR = 'initial'; + changeHandler.mockClear(); + + // Update value + process.env.TEST_VAR = 'updated'; + + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: 'updated', + oldValue: 'initial', + }); + }); + + test('should handle empty string values', () => { + process.env.TEST_VAR = ''; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: '', + oldValue: undefined, + }); + }); + + test('should handle unicode values', () => { + const unicodeValue = 'Hello 世界 🌍 Ñiño'; + process.env.TEST_VAR = unicodeValue; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: unicodeValue, + oldValue: undefined, + }); + }); + + test('should handle large values', () => { + const largeValue = 'x'.repeat(1024); + process.env.TEST_VAR = largeValue; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: largeValue, + oldValue: undefined, + }); + }); + }); + + describe('environment variable deletion', () => { + test('should detect when environment variable is deleted', () => { + // Set initial value + process.env.DELETE_TEST = 'to_be_deleted'; + changeHandler.mockClear(); + + // Delete variable + delete process.env.DELETE_TEST; + + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(changeHandler).toHaveBeenCalledWith({ + type: 'delete', + key: 'DELETE_TEST', + oldValue: 'to_be_deleted', + }); + }); + + test('should handle deleting non-existent variable', () => { + delete process.env.NON_EXISTENT_VAR; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'delete', + key: 'NON_EXISTENT_VAR', + oldValue: undefined, + }); + }); + }); + + describe('multiple operations', () => { + test('should track multiple variable changes', () => { + process.env.VAR1 = 'value1'; + process.env.VAR2 = 'value2'; + process.env.VAR3 = 'value3'; + + expect(changeHandler).toHaveBeenCalledTimes(3); + + expect(changeHandler).toHaveBeenNthCalledWith(1, { + type: 'set', + key: 'VAR1', + value: 'value1', + oldValue: undefined, + }); + + expect(changeHandler).toHaveBeenNthCalledWith(2, { + type: 'set', + key: 'VAR2', + value: 'value2', + oldValue: undefined, + }); + + expect(changeHandler).toHaveBeenNthCalledWith(3, { + type: 'set', + key: 'VAR3', + value: 'value3', + oldValue: undefined, + }); + }); + + test('should handle rapid consecutive changes', () => { + for (let i = 0; i < 5; i++) { + process.env.RAPID_TEST = `value_${i}`; + } + + expect(changeHandler).toHaveBeenCalledTimes(5); + + // Check last call + expect(changeHandler).toHaveBeenLastCalledWith({ + type: 'set', + key: 'RAPID_TEST', + value: 'value_4', + oldValue: 'value_3', + }); + }); + }); + + describe('applyChange method', () => { + test('should apply set change without triggering handler', () => { + const event: EnvironmentChangeEvent = { + type: 'set', + key: 'EXTERNAL_VAR', + value: 'external_value', + }; + + monitor.applyChange(event); + + expect(process.env.EXTERNAL_VAR).toBe('external_value'); + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should apply delete change without triggering handler', () => { + // Set initial value through normal means + process.env.TO_DELETE = 'initial'; + changeHandler.mockClear(); + + const event: EnvironmentChangeEvent = { + type: 'delete', + key: 'TO_DELETE', + }; + + monitor.applyChange(event); + + expect(process.env.TO_DELETE).toBeUndefined(); + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should handle applying change for non-existent variable', () => { + const event: EnvironmentChangeEvent = { + type: 'delete', + key: 'NON_EXISTENT', + }; + + // Should not throw + expect(() => monitor.applyChange(event)).not.toThrow(); + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should apply set with undefined value as no-op', () => { + const event: EnvironmentChangeEvent = { + type: 'set', + key: 'UNDEFINED_VALUE', + value: undefined, + }; + + monitor.applyChange(event); + + expect(process.env.UNDEFINED_VALUE).toBeUndefined(); + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should re-enable monitoring after applying change', () => { + const event: EnvironmentChangeEvent = { + type: 'set', + key: 'EXTERNAL_VAR', + value: 'external_value', + }; + + monitor.applyChange(event); + + // Monitoring should be re-enabled + process.env.TEST_AFTER_APPLY = 'test'; + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_AFTER_APPLY', + value: 'test', + oldValue: undefined, + }); + }); + }); + + describe('monitoring control', () => { + test('should disable monitoring', () => { + monitor.disableMonitoring(); + + process.env.TEST_VAR = 'test_value'; + + expect(process.env.TEST_VAR).toBe('test_value'); + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should re-enable monitoring', () => { + monitor.disableMonitoring(); + monitor.enableMonitoring(); + + process.env.TEST_VAR = 'test_value'; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'TEST_VAR', + value: 'test_value', + oldValue: undefined, + }); + }); + }); + + describe('type conversion', () => { + test('should convert non-string values to strings', () => { + (process.env as any).NUMBER_VAR = 123; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'NUMBER_VAR', + value: '123', + oldValue: undefined, + }); + }); + + test('should handle boolean values', () => { + (process.env as any).BOOL_VAR = true; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'BOOL_VAR', + value: 'true', + oldValue: undefined, + }); + }); + + test('should handle null values as deletion', () => { + // Set initial value + process.env.NULL_TEST = 'initial'; + changeHandler.mockClear(); + + // Set to null + (process.env as any).NULL_TEST = null; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'delete', + key: 'NULL_TEST', + oldValue: 'initial', + }); + + expect(process.env.NULL_TEST).toBeUndefined(); + }); + + test('should handle undefined values as deletion', () => { + // Set initial value + process.env.UNDEFINED_TEST = 'initial'; + changeHandler.mockClear(); + + // Set to undefined + (process.env as any).UNDEFINED_TEST = undefined; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'delete', + key: 'UNDEFINED_TEST', + oldValue: 'initial', + }); + + expect(process.env.UNDEFINED_TEST).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + test('should handle special characters in variable names', () => { + process.env['VAR_WITH-DASH'] = 'dash_value'; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: 'VAR_WITH-DASH', + value: 'dash_value', + oldValue: undefined, + }); + }); + + test('should handle numeric-like variable names', () => { + process.env['123'] = 'numeric_name'; + + expect(changeHandler).toHaveBeenCalledWith({ + type: 'set', + key: '123', + value: 'numeric_name', + oldValue: undefined, + }); + }); + + test('should handle symbol properties gracefully', () => { + const sym = Symbol('test'); + + // Should not throw when setting symbol property + expect(() => { + (process.env as any)[sym] = 'symbol_value'; + }).not.toThrow(); + + // Should not have called handler for symbol + expect(changeHandler).not.toHaveBeenCalled(); + }); + + test('should maintain process.env behavior for in operator', () => { + process.env.EXISTENCE_TEST = 'exists'; + + expect('EXISTENCE_TEST' in process.env).toBe(true); + expect('NON_EXISTENT' in process.env).toBe(false); + }); + + test('should maintain process.env behavior for Object.keys', () => { + const keys = Object.keys(process.env); + expect(Array.isArray(keys)).toBe(true); + expect(keys.length).toBeGreaterThan(0); + }); + }); + + describe('concurrent access', () => { + test('should handle concurrent modifications', async () => { + const promises = []; + + for (let i = 0; i < 10; i++) { + promises.push( + Promise.resolve().then(() => { + process.env[`CONCURRENT_${i}`] = `value_${i}`; + }), + ); + } + + await Promise.all(promises); + + expect(changeHandler).toHaveBeenCalledTimes(10); + + // Verify all variables are set + for (let i = 0; i < 10; i++) { + expect(process.env[`CONCURRENT_${i}`]).toBe(`value_${i}`); + } + }); + }); +}); diff --git a/packages/@jsii/kernel/src/environment-monitor.ts b/packages/@jsii/kernel/src/environment-monitor.ts new file mode 100644 index 0000000000..89fe1f1d58 --- /dev/null +++ b/packages/@jsii/kernel/src/environment-monitor.ts @@ -0,0 +1,113 @@ +export interface EnvironmentChangeEvent { + readonly type: 'set' | 'delete'; + readonly key: string; + readonly value?: string; + readonly oldValue?: string; +} + +export type EnvironmentChangeHandler = (event: EnvironmentChangeEvent) => void; + +/** + * Monitors Node.js process.env for changes and provides automatic synchronization + * with external processes through callback notifications. + */ +export class EnvironmentMonitor { + private readonly originalEnv: typeof process.env; + private readonly changeHandler: EnvironmentChangeHandler; + private monitoringEnabled = true; + + public constructor(changeHandler: EnvironmentChangeHandler) { + this.changeHandler = changeHandler; + this.originalEnv = process.env; + this.wrapProcessEnv(); + } + + /** + * Wraps process.env with a Proxy to intercept all property operations + */ + private wrapProcessEnv(): void { + // Create a proxy that intercepts all process.env operations + process.env = new Proxy(this.originalEnv, { + set: (target: any, prop: string | symbol, value: any): boolean => { + if (typeof prop === 'string' && this.monitoringEnabled) { + const oldValue = target[prop]; + const newValue = value != null ? String(value) : undefined; + + // Set the value first + if (newValue !== undefined) { + target[prop] = newValue; + } else { + delete target[prop]; + } + + // Immediately notify of change (synchronous) + this.changeHandler({ + type: newValue !== undefined ? 'set' : 'delete', + key: prop, + value: newValue, + oldValue, + }); + } else { + // Direct assignment without monitoring + if (value != null) { + target[prop as string] = String(value); + } else { + delete target[prop as string]; + } + } + return true; + }, + + deleteProperty: (target: any, prop: string | symbol): boolean => { + if (typeof prop === 'string' && this.monitoringEnabled) { + const oldValue = target[prop]; + delete target[prop]; + + // Immediately notify of deletion (synchronous) + this.changeHandler({ + type: 'delete', + key: prop, + oldValue, + }); + } else { + // Direct deletion without monitoring + delete target[prop as string]; + } + return true; + }, + }); + } + + /** + * Apply changes from external source (Python) without triggering monitoring + */ + public applyChange(event: EnvironmentChangeEvent): void { + // Temporarily disable monitoring to avoid infinite loop + this.monitoringEnabled = false; + + try { + if (event.type === 'set' && event.value !== undefined) { + this.originalEnv[event.key] = event.value; + } else if (event.type === 'delete') { + delete this.originalEnv[event.key]; + } + } finally { + // Re-enable monitoring + this.monitoringEnabled = true; + } + } + + /** + * Disable monitoring temporarily + */ + public disableMonitoring(): void { + this.monitoringEnabled = false; + } + + /** + * Re-enable monitoring + */ + public enableMonitoring(): void { + this.monitoringEnabled = true; + } +} diff --git a/packages/@jsii/kernel/src/kernel-environment.test.ts b/packages/@jsii/kernel/src/kernel-environment.test.ts new file mode 100644 index 0000000000..a5e61c995f --- /dev/null +++ b/packages/@jsii/kernel/src/kernel-environment.test.ts @@ -0,0 +1,415 @@ +import * as api from './api'; +import { Kernel } from './kernel'; + +describe('Kernel Environment Change Integration', () => { + let originalEnv: typeof process.env; + let callbackHandler: jest.Mock; + let kernel: Kernel; + + beforeEach(() => { + // Save original process.env + originalEnv = { ...process.env }; + + // Clear test environment variables + delete process.env.JSII_KERNEL_TEST_VAR; + delete process.env.JSII_KERNEL_DELETE_TEST; + delete process.env.EXTERNAL_SYNC_TEST; + + // Create mock callback handler + callbackHandler = jest.fn(); + + // Create kernel instance + kernel = new Kernel(callbackHandler); + }); + + afterEach(() => { + // Restore original process.env + process.env = { ...originalEnv }; + + // Clear mock + callbackHandler.mockClear(); + }); + + describe('kernel initialization', () => { + test('should create kernel with environment monitoring enabled', () => { + expect(kernel).toBeDefined(); + expect(callbackHandler).toBeDefined(); + }); + + test('should automatically detect environment changes after kernel creation', () => { + process.env.JSII_KERNEL_TEST_VAR = 'test_value'; + + // Callback handler should be called with environment change notification + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + expect(call.cookie).toBe('env-sync'); + expect(call.envChange).toEqual({ + type: 'set', + key: 'JSII_KERNEL_TEST_VAR', + value: 'test_value', + oldValue: undefined, + }); + }); + }); + + describe('environment change detection', () => { + test('should detect environment variable setting', () => { + process.env.TEST_SET_VAR = 'new_value'; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + expect(call.cookie).toBe('env-sync'); + expect(call.envChange.type).toBe('set'); + expect(call.envChange.key).toBe('TEST_SET_VAR'); + expect(call.envChange.value).toBe('new_value'); + expect(call.envChange.oldValue).toBeUndefined(); + }); + + test('should detect environment variable updating', () => { + // Set initial value + process.env.TEST_UPDATE_VAR = 'initial'; + callbackHandler.mockClear(); + + // Update value + process.env.TEST_UPDATE_VAR = 'updated'; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.type).toBe('set'); + expect(call.envChange.key).toBe('TEST_UPDATE_VAR'); + expect(call.envChange.value).toBe('updated'); + expect(call.envChange.oldValue).toBe('initial'); + }); + + test('should detect environment variable deletion', () => { + // Set initial value + process.env.JSII_KERNEL_DELETE_TEST = 'to_be_deleted'; + callbackHandler.mockClear(); + + // Delete variable + delete process.env.JSII_KERNEL_DELETE_TEST; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.type).toBe('delete'); + expect(call.envChange.key).toBe('JSII_KERNEL_DELETE_TEST'); + expect(call.envChange.oldValue).toBe('to_be_deleted'); + }); + + test('should generate unique callback IDs for each change', () => { + process.env.VAR1 = 'value1'; + process.env.VAR2 = 'value2'; + + expect(callbackHandler).toHaveBeenCalledTimes(2); + + const call1 = callbackHandler.mock.calls[0][0]; + const call2 = callbackHandler.mock.calls[1][0]; + + expect(call1.cbid).toBeDefined(); + expect(call2.cbid).toBeDefined(); + expect(call1.cbid).not.toBe(call2.cbid); + }); + + test('should handle callback handler errors gracefully', () => { + // Make callback handler throw + callbackHandler.mockImplementation(() => { + throw new Error('Callback error'); + }); + + // Should not throw when environment changes + expect(() => { + process.env.ERROR_TEST = 'error_value'; + }).not.toThrow(); + + // Should still have attempted to call the handler + expect(callbackHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('env.notifyChange API', () => { + test('should handle environment change request from external source', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'EXTERNAL_SYNC_TEST', + value: 'external_value', + type: 'set', + }; + + const response = kernel['env.notifyChange'](request); + + expect(response.success).toBe(true); + expect(process.env.EXTERNAL_SYNC_TEST).toBe('external_value'); + + // Should not trigger callback since this was an external change + expect(callbackHandler).not.toHaveBeenCalled(); + }); + + test('should handle environment variable deletion from external source', () => { + // Set initial value + process.env.EXTERNAL_DELETE_TEST = 'initial'; + callbackHandler.mockClear(); + + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'EXTERNAL_DELETE_TEST', + type: 'delete', + }; + + const response = kernel['env.notifyChange'](request); + + expect(response.success).toBe(true); + expect(process.env.EXTERNAL_DELETE_TEST).toBeUndefined(); + expect(callbackHandler).not.toHaveBeenCalled(); + }); + + test('should handle set request with undefined value', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'UNDEFINED_VALUE_TEST', + value: undefined, + type: 'set', + }; + + const response = kernel['env.notifyChange'](request); + + expect(response.success).toBe(true); + // Should not set the variable if value is undefined + expect(process.env.UNDEFINED_VALUE_TEST).toBeUndefined(); + }); + + test('should track oldValue when applying external changes', () => { + // Set initial value + process.env.OLD_VALUE_TEST = 'original'; + callbackHandler.mockClear(); + + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'OLD_VALUE_TEST', + value: 'updated_externally', + type: 'set', + }; + + const response = kernel['env.notifyChange'](request); + + expect(response.success).toBe(true); + expect(process.env.OLD_VALUE_TEST).toBe('updated_externally'); + }); + }); + + describe('bidirectional synchronization', () => { + test('should handle environment changes after external sync', () => { + // Apply external change + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'BIDIRECTIONAL_TEST', + value: 'from_external', + type: 'set', + }; + + kernel['env.notifyChange'](request); + expect(process.env.BIDIRECTIONAL_TEST).toBe('from_external'); + + // Now change from Node.js side + process.env.BIDIRECTIONAL_TEST = 'from_nodejs'; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.type).toBe('set'); + expect(call.envChange.key).toBe('BIDIRECTIONAL_TEST'); + expect(call.envChange.value).toBe('from_nodejs'); + expect(call.envChange.oldValue).toBe('from_external'); + }); + + test('should handle multiple rapid external changes', () => { + for (let i = 0; i < 5; i++) { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'RAPID_EXTERNAL_TEST', + value: `external_value_${i}`, + type: 'set', + }; + + kernel['env.notifyChange'](request); + } + + expect(process.env.RAPID_EXTERNAL_TEST).toBe('external_value_4'); + expect(callbackHandler).not.toHaveBeenCalled(); + }); + + test('should maintain monitoring after external changes', () => { + // Apply external change + kernel['env.notifyChange']({ + api: 'env.notifyChange', + key: 'MONITORING_TEST', + value: 'external', + type: 'set', + }); + + // Verify monitoring is still active + process.env.AFTER_EXTERNAL_TEST = 'test'; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + expect(callbackHandler.mock.calls[0][0].envChange.key).toBe( + 'AFTER_EXTERNAL_TEST', + ); + }); + }); + + describe('special values and edge cases', () => { + test('should handle empty string values', () => { + process.env.EMPTY_STRING_TEST = ''; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.value).toBe(''); + expect(call.envChange.type).toBe('set'); + }); + + test('should handle unicode values', () => { + const unicodeValue = 'Hello 世界 🌍 Ñiño'; + process.env.UNICODE_TEST = unicodeValue; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.value).toBe(unicodeValue); + }); + + test('should handle large values', () => { + const largeValue = 'x'.repeat(1024); + process.env.LARGE_VALUE_TEST = largeValue; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.value).toBe(largeValue); + }); + + test('should handle numeric-like variable names', () => { + process.env['123'] = 'numeric_name'; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.key).toBe('123'); + expect(call.envChange.value).toBe('numeric_name'); + }); + + test('should handle special characters in variable names', () => { + process.env['VAR_WITH-DASH'] = 'dash_value'; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.key).toBe('VAR_WITH-DASH'); + expect(call.envChange.value).toBe('dash_value'); + }); + + test('should convert non-string values to strings', () => { + (process.env as any).NUMBER_TEST = 123; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.value).toBe('123'); + expect(typeof call.envChange.value).toBe('string'); + }); + + test('should handle null values as deletion', () => { + // Set initial value + process.env.NULL_TEST = 'initial'; + callbackHandler.mockClear(); + + // Set to null + (process.env as any).NULL_TEST = null; + + const call = callbackHandler.mock.calls[0][0]; + expect(call.envChange.type).toBe('delete'); + expect(call.envChange.key).toBe('NULL_TEST'); + expect(call.envChange.oldValue).toBe('initial'); + expect(process.env.NULL_TEST).toBeUndefined(); + }); + }); + + describe('callback structure validation', () => { + test('should generate callbacks with correct structure', () => { + process.env.CALLBACK_STRUCTURE_TEST = 'test_value'; + + expect(callbackHandler).toHaveBeenCalledTimes(1); + + const call = callbackHandler.mock.calls[0][0]; + + // Validate callback structure + expect(call).toHaveProperty('cbid'); + expect(call).toHaveProperty('cookie', 'env-sync'); + expect(call).toHaveProperty('envChange'); + + // Validate envChange structure + expect(call.envChange).toHaveProperty('type'); + expect(call.envChange).toHaveProperty('key'); + expect(['set', 'delete']).toContain(call.envChange.type); + + if (call.envChange.type === 'set') { + expect(call.envChange).toHaveProperty('value'); + } + }); + + test('should not include other callback properties for env changes', () => { + process.env.CALLBACK_PROPS_TEST = 'test'; + + const call = callbackHandler.mock.calls[0][0]; + + // Should not have other callback properties + expect(call.invoke).toBeUndefined(); + expect(call.get).toBeUndefined(); + expect(call.set).toBeUndefined(); + }); + }); + + describe('error handling', () => { + test('should handle malformed environment change requests', () => { + const malformedRequest = { + api: 'env.notifyChange', + // missing required fields + } as any; + + // Should not throw + expect(() => { + kernel['env.notifyChange'](malformedRequest); + }).not.toThrow(); + }); + + test('should return success for valid requests even with missing optional fields', () => { + const request: api.EnvironmentChangeRequest = { + api: 'env.notifyChange', + key: 'MINIMAL_REQUEST_TEST', + type: 'delete', + // value is optional for delete + }; + + const response = kernel['env.notifyChange'](request); + expect(response.success).toBe(true); + }); + }); + + describe('performance and stability', () => { + test('should handle many consecutive environment changes', () => { + const numChanges = 100; + + for (let i = 0; i < numChanges; i++) { + process.env[`PERF_TEST_${i}`] = `value_${i}`; + } + + expect(callbackHandler).toHaveBeenCalledTimes(numChanges); + + // Verify all changes were captured + for (let i = 0; i < numChanges; i++) { + expect(process.env[`PERF_TEST_${i}`]).toBe(`value_${i}`); + } + }); + + test('should handle alternating set and delete operations', () => { + for (let i = 0; i < 10; i++) { + process.env.ALTERNATING_TEST = `value_${i}`; + delete process.env.ALTERNATING_TEST; + } + + expect(callbackHandler).toHaveBeenCalledTimes(20); // 10 sets + 10 deletes + expect(process.env.ALTERNATING_TEST).toBeUndefined(); + }); + }); +}); diff --git a/packages/@jsii/kernel/src/kernel.ts b/packages/@jsii/kernel/src/kernel.ts index e83b2aa284..2f8b3d64dc 100644 --- a/packages/@jsii/kernel/src/kernel.ts +++ b/packages/@jsii/kernel/src/kernel.ts @@ -7,6 +7,10 @@ import * as path from 'path'; import * as api from './api'; import { TOKEN_REF } from './api'; +import { + EnvironmentMonitor, + EnvironmentChangeEvent, +} from './environment-monitor'; import { jsiiTypeFqn, ObjectTable, tagJsiiConstructor } from './objects'; import * as onExit from './on-exit'; import * as wire from './serialization'; @@ -55,6 +59,7 @@ export class Kernel { readonly #promises = new Map(); readonly #serializerHost: wire.SerializerHost; + readonly #environmentMonitor: EnvironmentMonitor; #nextid = 20000; // incrementing counter for objid, cbid, promiseid #syncInProgress?: string; // forbids async calls (begin) while processing sync calls (get/set/invoke) @@ -77,6 +82,11 @@ export class Kernel { findSymbol: this.#findSymbol.bind(this), lookupType: this.#typeInfoForFqn.bind(this), }; + + // Set up automatic environment monitoring + this.#environmentMonitor = new EnvironmentMonitor( + this.#handleEnvironmentChange.bind(this), + ); } public load(req: api.LoadRequest): api.LoadResponse { @@ -573,6 +583,55 @@ export class Kernel { }; } + /** + * Handle environment change notifications from Python runtime + */ + public ['env.notifyChange']( + req: api.EnvironmentChangeRequest, + ): api.EnvironmentChangeResponse { + const { key, value, type } = req; + + this.#debug('applying env change from Python:', req); + + // Apply the change from Python to Node.js + this.#environmentMonitor.applyChange({ + type: type, + key, + value, + oldValue: process.env[key], + }); + + return { success: true }; + } + + /** + * Handle environment change detected in Node.js process.env + */ + #handleEnvironmentChange(event: EnvironmentChangeEvent): void { + this.#debug('env change detected:', event); + + // Immediately send notification to Python (synchronous) + this.#sendEnvironmentChangeNotification(event); + } + + /** + * Send environment change notification to Python runtime via callback + */ + #sendEnvironmentChangeNotification(event: EnvironmentChangeEvent): void { + // Use the existing callback mechanism for immediate notification + if (this.callbackHandler) { + try { + this.callbackHandler({ + cbid: this.#makecbid(), + cookie: 'env-sync', + envChange: event, + } as any); + } catch (error) { + this.#debug('Failed to send environment change notification:', error); + } + } + } + #addAssembly(assm: Assembly) { this.#assemblies.set(assm.metadata.name, assm); diff --git a/packages/@jsii/python-runtime/src/jsii/_environment_monitor.py b/packages/@jsii/python-runtime/src/jsii/_environment_monitor.py new file mode 100644 index 0000000000..e7c1e016ba --- /dev/null +++ b/packages/@jsii/python-runtime/src/jsii/_environment_monitor.py @@ -0,0 +1,193 @@ +import os +import threading +from typing import Dict, Callable, Optional, Any +from typing_extensions import Literal +from types import MappingProxyType + + +class EnvironmentChangeEvent: + """Represents an environment variable change event""" + + def __init__( + self, + type: Literal["set", "delete"], + key: str, + value: Optional[str] = None, + old_value: Optional[str] = None, + ): + self.type = type + self.key = key + self.value = value + self.old_value = old_value + + def __repr__(self): + return f"EnvironmentChangeEvent(type='{self.type}', key='{self.key}', value='{self.value}', old_value='{self.old_value}')" + + +class PythonEnvironmentMonitor: + """Monitors Python os.environ for changes and provides automatic sync""" + + def __init__(self, change_handler: Callable[[EnvironmentChangeEvent], None]): + self.change_handler = change_handler + self.original_environ = os.environ + self._monitoring_enabled = True + self._lock = threading.Lock() + self.wrap_os_environ() + + def wrap_os_environ(self): + """Replace os.environ with a monitoring wrapper""" + monitor = self + + class MonitoredEnviron: + def __init__(self, original_environ): + self._original = original_environ + + def __getitem__(self, key): + return self._original[key] + + def __setitem__(self, key, value): + with monitor._lock: + if monitor._monitoring_enabled: + old_value = self._original.get(key) + self._original[key] = value + + # Immediately notify of change + monitor.change_handler( + EnvironmentChangeEvent( + type="set", key=key, value=value, old_value=old_value + ) + ) + else: + self._original[key] = value + + def __delitem__(self, key): + with monitor._lock: + if monitor._monitoring_enabled: + old_value = self._original.get(key) + del self._original[key] + + # Immediately notify of deletion + monitor.change_handler( + EnvironmentChangeEvent( + type="delete", key=key, old_value=old_value + ) + ) + else: + del self._original[key] + + def __contains__(self, key): + return key in self._original + + def __iter__(self): + return iter(self._original) + + def __len__(self): + return len(self._original) + + def get(self, key, default=None): + return self._original.get(key, default) + + def keys(self): + return self._original.keys() + + def values(self): + return self._original.values() + + def items(self): + return self._original.items() + + def update(self, other): + if monitor._monitoring_enabled: + for key, value in other.items(): + self[key] = value # Use monitored setitem + else: + self._original.update(other) + + def pop(self, key, *default): + with monitor._lock: + if monitor._monitoring_enabled: + old_value = self._original.get(key) + result = self._original.pop(key, *default) + if old_value is not None: # Only notify if key existed + monitor.change_handler( + EnvironmentChangeEvent( + type="delete", key=key, old_value=old_value + ) + ) + return result + else: + return self._original.pop(key, *default) + + def popitem(self): + with monitor._lock: + if monitor._monitoring_enabled: + key, value = self._original.popitem() + monitor.change_handler( + EnvironmentChangeEvent( + type="delete", key=key, old_value=value + ) + ) + return key, value + else: + return self._original.popitem() + + def clear(self): + with monitor._lock: + if monitor._monitoring_enabled: + # Notify for each deleted key + for key, value in list(self._original.items()): + monitor.change_handler( + EnvironmentChangeEvent( + type="delete", key=key, old_value=value + ) + ) + self._original.clear() + + def setdefault(self, key, default=None): + with monitor._lock: + if key not in self._original: + if monitor._monitoring_enabled: + self._original[key] = default + monitor.change_handler( + EnvironmentChangeEvent( + type="set", key=key, value=default, old_value=None + ) + ) + else: + self._original[key] = default + return default + return self._original[key] + + def __repr__(self): + return repr(self._original) + + def __str__(self): + return str(self._original) + + # Replace os.environ with our monitored version + os.environ = MonitoredEnviron(self.original_environ) + + def apply_change(self, event: EnvironmentChangeEvent): + """Apply change from external source (Node.js) without triggering monitoring""" + with self._lock: + # Temporarily disable monitoring + self._monitoring_enabled = False + try: + if event.type == "set" and event.value is not None: + os.environ[event.key] = event.value + elif event.type == "delete": + if event.key in os.environ: + del os.environ[event.key] + finally: + # Re-enable monitoring + self._monitoring_enabled = True + + def disable_monitoring(self): + """Disable monitoring temporarily""" + with self._lock: + self._monitoring_enabled = False + + def enable_monitoring(self): + """Re-enable monitoring""" + with self._lock: + self._monitoring_enabled = True diff --git a/packages/@jsii/python-runtime/src/jsii/_kernel/providers/process.py b/packages/@jsii/python-runtime/src/jsii/_kernel/providers/process.py index 0e6fb9fb64..a7b0edf096 100644 --- a/packages/@jsii/python-runtime/src/jsii/_kernel/providers/process.py +++ b/packages/@jsii/python-runtime/src/jsii/_kernel/providers/process.py @@ -23,6 +23,7 @@ from ...__meta__ import __jsii_runtime_version__ from ..._compat import importlib_resources +from ..._environment_monitor import PythonEnvironmentMonitor, EnvironmentChangeEvent from ..._utils import memoized_property from .base import BaseProvider from ..types import ( @@ -60,6 +61,8 @@ CompleteResponse, StatsRequest, StatsResponse, + EnvironmentChangeRequest, + EnvironmentChangeResponse, Callback, CompleteRequest, CompleteResponse, @@ -216,6 +219,10 @@ def __init__(self): StatsRequest, _with_api_key("stats", self._serializer.unstructure_attrs_asdict), ) + self._serializer.register_unstructure_hook( + EnvironmentChangeRequest, + self._serializer.unstructure_attrs_asdict, + ) self._serializer.register_unstructure_hook( Override, self._serializer.unstructure_attrs_asdict ) @@ -335,7 +342,17 @@ def send( if isinstance(resp, _OkayResponse): return self._serializer.structure(resp.ok, response_type) elif isinstance(resp, _CallbackResponse): - return resp.callback + callback = resp.callback + + # Check if this is an environment change callback + if hasattr(callback, "cookie") and callback.cookie == "env-sync": + # Handle environment change callback + if hasattr(self, "_provider"): + result = self._provider._handle_node_env_change(callback) + if result is not None: + return result + + return callback else: if resp.name == ErrorType.JSII_FAULT.value: raise JSIIError(resp.error) from JavaScriptError(resp.stack) @@ -343,11 +360,20 @@ def send( class ProcessProvider(BaseProvider): + def __init__(self): + super().__init__() + + # Set up automatic environment monitoring + self._env_monitor = PythonEnvironmentMonitor(self._handle_python_env_change) + @memoized_property def _process(self) -> _NodeProcess: process = _NodeProcess() process.start() + # Set the process reference for environment sync + process._provider = self + return process def load(self, request: LoadRequest) -> LoadResponse: @@ -408,6 +434,47 @@ def stats(self, request: Optional[StatsRequest] = None) -> StatsResponse: request = StatsRequest() return self._process.send(request, StatsResponse) + def _handle_python_env_change(self, event: EnvironmentChangeEvent): + """Handle environment change from Python side""" + try: + # Immediately send to Node.js (synchronous) + request = EnvironmentChangeRequest( + api="env.notifyChange", + key=event.key, + value=event.value, + type=event.type, + ) + response = self._process.send(request, EnvironmentChangeResponse) + + if not response.success: + # Debug logging would go here + pass + + except Exception as e: + # Debug logging would go here + pass + + def _handle_node_env_change(self, callback): + """Handle environment change callback from Node.js""" + if hasattr(callback, "envChange"): + env_change = callback.envChange + + # Apply the change from Node.js to Python + event = EnvironmentChangeEvent( + type=env_change["type"], + key=env_change["key"], + value=env_change.get("value"), + old_value=env_change.get("oldValue"), + ) + + # Apply change to Python environment + self._env_monitor.apply_change(event) + + # Return success to Node.js + return {"success": True} + + return None + def stderr_sink(reader: IO[AnyStr]) -> None: # An empty string is used to signal EOF... diff --git a/packages/@jsii/python-runtime/src/jsii/_kernel/types.py b/packages/@jsii/python-runtime/src/jsii/_kernel/types.py index 2810a3bf21..69607f5dcf 100644 --- a/packages/@jsii/python-runtime/src/jsii/_kernel/types.py +++ b/packages/@jsii/python-runtime/src/jsii/_kernel/types.py @@ -1,5 +1,5 @@ from typing import Any, Dict, Generic, List, Optional, Mapping, TypeVar, Union -from typing_extensions import Protocol +from typing_extensions import Protocol, Literal import attr @@ -211,6 +211,19 @@ class StatsResponse: objectCount: int +@attr.s(auto_attribs=True, frozen=True, slots=True) +class EnvironmentChangeRequest: + api: Literal["env.notifyChange"] + key: str + type: Literal["set", "delete"] + value: Optional[str] = None + + +@attr.s(auto_attribs=True, frozen=True, slots=True) +class EnvironmentChangeResponse: + success: bool + + KernelRequest = Union[ LoadRequest, CreateRequest, @@ -222,6 +235,7 @@ class StatsResponse: InvokeScriptRequest, StaticInvokeRequest, StatsRequest, + EnvironmentChangeRequest, ] KernelResponse = Union[ @@ -234,5 +248,6 @@ class StatsResponse: InvokeScriptResponse, SetResponse, StatsResponse, + EnvironmentChangeResponse, Callback, ] diff --git a/packages/@jsii/python-runtime/tests/test_auto_env_sync.py b/packages/@jsii/python-runtime/tests/test_auto_env_sync.py new file mode 100644 index 0000000000..ccc9068d88 --- /dev/null +++ b/packages/@jsii/python-runtime/tests/test_auto_env_sync.py @@ -0,0 +1,275 @@ +import os +import pytest +import tempfile +from typing import Dict, Optional +import jsii_calc + + +class TestAutomaticEnvironmentSync: + """ + Test automatic bidirectional environment variable synchronization + between Python and Node.js processes in the JSII runtime. + """ + + def setup_method(self): + """Clean up test environment variables before each test""" + self.test_vars = [ + "JSII_TEST_VAR", + "JSII_TEST_VAR2", + "TEST_DB_URL", + "TEST_API_KEY", + "AWS_SECRET_ACCESS_KEY", + "DATABASE_PASSWORD", + "JSII_SYNC_TEST", + "PYTHON_TO_NODE_TEST", + "NODE_TO_PYTHON_TEST", + ] + + for var in self.test_vars: + if var in os.environ: + del os.environ[var] + + def teardown_method(self): + """Clean up test environment variables after each test""" + for var in self.test_vars: + if var in os.environ: + del os.environ[var] + + def test_node_to_python_sync(self): + """Test automatic sync from Node.js to Python""" + # Node.js sets environment variable + env_utils = jsii_calc.EnvironmentUtils() + env_utils.set_environment_variable("JSII_TEST_VAR", "from_node") + + # Should be automatically available in Python + assert os.environ.get("JSII_TEST_VAR") == "from_node" + + # Test deletion + env_utils.delete_environment_variable("JSII_TEST_VAR") + assert "JSII_TEST_VAR" not in os.environ + + def test_python_to_node_sync(self): + """Test automatic sync from Python to Node.js""" + # Python sets environment variable + os.environ["JSII_TEST_VAR2"] = "from_python" + + # Should be automatically available in Node.js + env_utils = jsii_calc.EnvironmentUtils() + node_value = env_utils.get_environment_variable("JSII_TEST_VAR2") + assert node_value == "from_python" + + # Test deletion from Python + del os.environ["JSII_TEST_VAR2"] + node_value = env_utils.get_environment_variable("JSII_TEST_VAR2") + assert node_value is None + + def test_bidirectional_sync(self): + """Test bidirectional synchronization in both directions""" + env_utils = jsii_calc.EnvironmentUtils() + + # Python sets first + os.environ["PYTHON_TO_NODE_TEST"] = "python_value" + assert ( + env_utils.get_environment_variable("PYTHON_TO_NODE_TEST") == "python_value" + ) + + # Node sets second + env_utils.set_environment_variable("NODE_TO_PYTHON_TEST", "node_value") + assert os.environ.get("NODE_TO_PYTHON_TEST") == "node_value" + + # Modify from both sides + os.environ["PYTHON_TO_NODE_TEST"] = "modified_by_python" + assert ( + env_utils.get_environment_variable("PYTHON_TO_NODE_TEST") + == "modified_by_python" + ) + + env_utils.set_environment_variable("NODE_TO_PYTHON_TEST", "modified_by_node") + assert os.environ.get("NODE_TO_PYTHON_TEST") == "modified_by_node" + + def test_dotenv_like_integration(self): + """Test automatic sync with dotenv-like loading""" + env_string = """ +# Test environment file +TEST_DB_URL=postgres://test/db +TEST_API_KEY=abc123 +# Another comment +LOG_LEVEL=debug +""" + + # Load via Node.js dotenv-like functionality + env_utils = jsii_calc.EnvironmentUtils() + env_utils.load_environment_from_string(env_string) + + # Should be automatically available in Python + assert os.environ.get("TEST_DB_URL") == "postgres://test/db" + assert os.environ.get("TEST_API_KEY") == "abc123" + assert os.environ.get("LOG_LEVEL") == "debug" + + def test_sensitive_variables_sync(self): + """Test that sensitive variables are synced (as per design)""" + env_utils = jsii_calc.EnvironmentUtils() + + # Set sensitive variable in Node.js + env_utils.set_environment_variable("AWS_SECRET_ACCESS_KEY", "secret123") + + # Should be automatically available in Python + assert os.environ.get("AWS_SECRET_ACCESS_KEY") == "secret123" + + # Set sensitive variable in Python + os.environ["DATABASE_PASSWORD"] = "secret456" + + # Should be automatically available in Node.js + node_value = env_utils.get_environment_variable("DATABASE_PASSWORD") + assert node_value == "secret456" + + def test_system_variables_sync(self): + """Test that system variables can be synced""" + env_utils = jsii_calc.EnvironmentUtils() + original_path = os.environ.get("PATH", "") + + try: + # Modify PATH from Python + custom_path = "/custom/test/path:" + original_path + os.environ["PATH"] = custom_path + + # Should be available in Node.js + node_path = env_utils.get_environment_variable("PATH") + assert node_path == custom_path + + finally: + # Restore original PATH + if original_path: + os.environ["PATH"] = original_path + + def test_multiple_rapid_changes(self): + """Test rapid consecutive environment changes""" + env_utils = jsii_calc.EnvironmentUtils() + + # Rapid changes from Python + for i in range(5): + os.environ["RAPID_TEST"] = f"python_value_{i}" + assert ( + env_utils.get_environment_variable("RAPID_TEST") == f"python_value_{i}" + ) + + # Rapid changes from Node.js + for i in range(5): + env_utils.set_environment_variable("RAPID_TEST", f"node_value_{i}") + assert os.environ.get("RAPID_TEST") == f"node_value_{i}" + + def test_empty_and_whitespace_values(self): + """Test synchronization of empty and whitespace values""" + env_utils = jsii_calc.EnvironmentUtils() + + # Empty string + os.environ["EMPTY_TEST"] = "" + assert env_utils.get_environment_variable("EMPTY_TEST") == "" + + # Whitespace + env_utils.set_environment_variable("WHITESPACE_TEST", " ") + assert os.environ.get("WHITESPACE_TEST") == " " + + # Tab and newline + os.environ["SPECIAL_CHARS"] = "\t\n" + assert env_utils.get_environment_variable("SPECIAL_CHARS") == "\t\n" + + def test_unicode_values(self): + """Test synchronization of Unicode values""" + env_utils = jsii_calc.EnvironmentUtils() + + unicode_value = "Hello 世界 🌍 Ñiño" + + # Python to Node + os.environ["UNICODE_TEST"] = unicode_value + assert env_utils.get_environment_variable("UNICODE_TEST") == unicode_value + + # Node to Python + env_utils.set_environment_variable("UNICODE_TEST2", unicode_value) + assert os.environ.get("UNICODE_TEST2") == unicode_value + + def test_large_environment_variables(self): + """Test synchronization of large environment variable values""" + env_utils = jsii_calc.EnvironmentUtils() + + # Create a large value (1KB) + large_value = "x" * 1024 + + # Python to Node + os.environ["LARGE_VAR"] = large_value + assert env_utils.get_environment_variable("LARGE_VAR") == large_value + + # Node to Python + env_utils.set_environment_variable("LARGE_VAR2", large_value) + assert os.environ.get("LARGE_VAR2") == large_value + + def test_environment_variable_existence_checks(self): + """Test environment variable existence synchronization""" + env_utils = jsii_calc.EnvironmentUtils() + + # Set variable in Python + os.environ["EXISTENCE_TEST"] = "value" + + # Check existence in Node.js + assert env_utils.has_environment_variable("EXISTENCE_TEST") == True + + # Delete in Python + del os.environ["EXISTENCE_TEST"] + + # Check non-existence in Node.js + assert env_utils.has_environment_variable("EXISTENCE_TEST") == False + + # Set in Node.js + env_utils.set_environment_variable("NODE_EXISTENCE_TEST", "value") + + # Check existence in Python + assert "NODE_EXISTENCE_TEST" in os.environ + + # Delete in Node.js + env_utils.delete_environment_variable("NODE_EXISTENCE_TEST") + + # Check non-existence in Python + assert "NODE_EXISTENCE_TEST" not in os.environ + + def test_concurrent_modifications(self): + """Test handling of concurrent modifications from both sides""" + env_utils = jsii_calc.EnvironmentUtils() + + # Set initial value + os.environ["CONCURRENT_TEST"] = "initial" + assert env_utils.get_environment_variable("CONCURRENT_TEST") == "initial" + + # Modify from Python + os.environ["CONCURRENT_TEST"] = "python_modified" + + # Verify Node.js sees the change + assert ( + env_utils.get_environment_variable("CONCURRENT_TEST") == "python_modified" + ) + + # Modify from Node.js (should override) + env_utils.set_environment_variable("CONCURRENT_TEST", "node_override") + + # Verify Python sees the override + assert os.environ.get("CONCURRENT_TEST") == "node_override" + + def test_all_environment_variables_accessible(self): + """Test that all environment variables are accessible from both sides""" + env_utils = jsii_calc.EnvironmentUtils() + + # Set multiple variables from Python + test_vars = {"VAR1": "value1", "VAR2": "value2", "VAR3": "value3"} + + for key, value in test_vars.items(): + os.environ[key] = value + + # Get all environment variables from Node.js + all_env = env_utils.list_all_environment_variables() + + # Verify all test variables are present + for key, value in test_vars.items(): + assert all_env.get(key) == value + + # Cleanup + for key in test_vars: + del os.environ[key] diff --git a/packages/jsii-calc/lib/environment-utils.ts b/packages/jsii-calc/lib/environment-utils.ts new file mode 100644 index 0000000000..fed541cc82 --- /dev/null +++ b/packages/jsii-calc/lib/environment-utils.ts @@ -0,0 +1,64 @@ +/** + * Utility class for testing environment variable synchronization + */ +export class EnvironmentUtils { + /** + * Set an environment variable in Node.js process.env + */ + public setEnvironmentVariable(key: string, value: string): void { + process.env[key] = value; + } + + /** + * Get an environment variable from Node.js process.env + */ + public getEnvironmentVariable(key: string): string | undefined { + return process.env[key]; + } + + /** + * Delete an environment variable from Node.js process.env + */ + public deleteEnvironmentVariable(key: string): void { + delete process.env[key]; + } + + /** + * Load environment variables from a simple key=value format + * Simulates dotenv functionality for testing + */ + public loadEnvironmentFromString(envString: string): void { + const lines = envString.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && valueParts.length > 0) { + const value = valueParts.join('='); + process.env[key.trim()] = value.trim(); + } + } + } + } + + /** + * Get all environment variables as a plain object + */ + public listAllEnvironmentVariables(): Record { + // Filter out undefined values since JSII doesn't support optional types in return values + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value; + } + } + return env; + } + + /** + * Check if an environment variable exists + */ + public hasEnvironmentVariable(key: string): boolean { + return key in process.env; + } +} diff --git a/packages/jsii-calc/lib/index.ts b/packages/jsii-calc/lib/index.ts index 996a520bd4..57a6615827 100644 --- a/packages/jsii-calc/lib/index.ts +++ b/packages/jsii-calc/lib/index.ts @@ -3,6 +3,7 @@ export * from './compliance'; export * from './date'; export * from './documented'; export * from './duplicate-inherited-prop'; +export * from './environment-utils'; export * from './erasures'; export * from './nested-class'; export * from './stability'; diff --git a/packages/jsii-calc/test/assembly.jsii b/packages/jsii-calc/test/assembly.jsii index b0e5dfcd15..5bb431fe25 100644 --- a/packages/jsii-calc/test/assembly.jsii +++ b/packages/jsii-calc/test/assembly.jsii @@ -210,14 +210,14 @@ "jsii-calc.anonymous": { "locationInModule": { "filename": "lib/index.ts", - "line": 29 + "line": 30 }, "symbolId": "lib/anonymous/index:" }, "jsii-calc.cdk16625": { "locationInModule": { "filename": "lib/index.ts", - "line": 24 + "line": 25 }, "symbolId": "lib/cdk16625/index:" }, @@ -231,7 +231,7 @@ "jsii-calc.cdk22369": { "locationInModule": { "filename": "lib/index.ts", - "line": 25 + "line": 26 }, "symbolId": "lib/cdk22369/index:" }, @@ -245,7 +245,7 @@ "jsii-calc.homonymousForwardReferences": { "locationInModule": { "filename": "lib/index.ts", - "line": 31 + "line": 32 }, "readme": { "markdown": "Verifies homonymous forward references don't trip the Python type checker\n\nThis has been an issue when stub functions were introduced to create a reliable source for type checking\ninformation, which was reported in https://github.com/aws/jsii/issues/3818.\n" @@ -269,42 +269,42 @@ "jsii-calc.jsii3656": { "locationInModule": { "filename": "lib/index.ts", - "line": 26 + "line": 27 }, "symbolId": "lib/jsii3656/index:" }, "jsii-calc.jsii4894": { "locationInModule": { "filename": "lib/index.ts", - "line": 27 + "line": 28 }, "symbolId": "lib/jsii4894:" }, "jsii-calc.module2530": { "locationInModule": { "filename": "lib/index.ts", - "line": 21 + "line": 22 }, "symbolId": "lib/module2530/index:" }, "jsii-calc.module2617": { "locationInModule": { "filename": "lib/index.ts", - "line": 17 + "line": 18 }, "symbolId": "lib/module2617/index:" }, "jsii-calc.module2647": { "locationInModule": { "filename": "lib/index.ts", - "line": 16 + "line": 17 }, "symbolId": "lib/module2647/index:" }, "jsii-calc.module2689": { "locationInModule": { "filename": "lib/index.ts", - "line": 18 + "line": 19 }, "symbolId": "lib/module2689/index:" }, @@ -339,7 +339,7 @@ "jsii-calc.module2692": { "locationInModule": { "filename": "lib/index.ts", - "line": 20 + "line": 21 }, "symbolId": "lib/module2692/index:" }, @@ -360,21 +360,21 @@ "jsii-calc.module2700": { "locationInModule": { "filename": "lib/index.ts", - "line": 22 + "line": 23 }, "symbolId": "lib/module2700/index:" }, "jsii-calc.module2702": { "locationInModule": { "filename": "lib/index.ts", - "line": 19 + "line": 20 }, "symbolId": "lib/module2702/index:" }, "jsii-calc.nodirect": { "locationInModule": { "filename": "lib/index.ts", - "line": 15 + "line": 16 }, "symbolId": "lib/no-direct-types/index:" }, @@ -395,14 +395,14 @@ "jsii-calc.onlystatic": { "locationInModule": { "filename": "lib/index.ts", - "line": 14 + "line": 15 }, "symbolId": "lib/only-static/index:" }, "jsii-calc.submodule": { "locationInModule": { "filename": "lib/index.ts", - "line": 13 + "line": 14 }, "readme": { "markdown": "Read you, read me\n=================\n\nThis is the readme of the `jsii-calc.submodule` module.\n" @@ -464,7 +464,7 @@ "jsii-calc.union": { "locationInModule": { "filename": "lib/index.ts", - "line": 30 + "line": 31 }, "symbolId": "lib/union:" } @@ -5548,6 +5548,161 @@ "name": "EnumDispenser", "symbolId": "lib/compliance:EnumDispenser" }, + "jsii-calc.EnvironmentUtils": { + "assembly": "jsii-calc", + "docs": { + "stability": "stable", + "summary": "Utility class for testing environment variable synchronization." + }, + "fqn": "jsii-calc.EnvironmentUtils", + "initializer": { + "docs": { + "stability": "stable" + } + }, + "kind": "class", + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 4 + }, + "methods": [ + { + "docs": { + "stability": "stable", + "summary": "Delete an environment variable from Node.js process.env." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 22 + }, + "name": "deleteEnvironmentVariable", + "parameters": [ + { + "name": "key", + "type": { + "primitive": "string" + } + } + ] + }, + { + "docs": { + "stability": "stable", + "summary": "Get an environment variable from Node.js process.env." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 15 + }, + "name": "getEnvironmentVariable", + "parameters": [ + { + "name": "key", + "type": { + "primitive": "string" + } + } + ], + "returns": { + "optional": true, + "type": { + "primitive": "string" + } + } + }, + { + "docs": { + "stability": "stable", + "summary": "Check if an environment variable exists." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 61 + }, + "name": "hasEnvironmentVariable", + "parameters": [ + { + "name": "key", + "type": { + "primitive": "string" + } + } + ], + "returns": { + "type": { + "primitive": "boolean" + } + } + }, + { + "docs": { + "stability": "stable", + "summary": "Get all environment variables as a plain object." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 47 + }, + "name": "listAllEnvironmentVariables", + "returns": { + "type": { + "collection": { + "elementtype": { + "primitive": "string" + }, + "kind": "map" + } + } + } + }, + { + "docs": { + "stability": "stable", + "summary": "Load environment variables from a simple key=value format Simulates dotenv functionality for testing." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 30 + }, + "name": "loadEnvironmentFromString", + "parameters": [ + { + "name": "envString", + "type": { + "primitive": "string" + } + } + ] + }, + { + "docs": { + "stability": "stable", + "summary": "Set an environment variable in Node.js process.env." + }, + "locationInModule": { + "filename": "lib/environment-utils.ts", + "line": 8 + }, + "name": "setEnvironmentVariable", + "parameters": [ + { + "name": "key", + "type": { + "primitive": "string" + } + }, + { + "name": "value", + "type": { + "primitive": "string" + } + } + ] + } + ], + "name": "EnvironmentUtils", + "symbolId": "lib/environment-utils:EnvironmentUtils" + }, "jsii-calc.EraseUndefinedHashValues": { "assembly": "jsii-calc", "docs": { @@ -19131,5 +19286,5 @@ } }, "version": "3.20.120", - "fingerprint": "O9HJsUWsfrb1HrOQ1znKEfsWfa9DuDhrkIFb7nHuB2g=" + "fingerprint": "4fuyP3WMlBh+QrvJqH0lwrQVsQksPxD2zcezz9q8jH8=" } \ No newline at end of file