@@ -5,13 +5,16 @@ import { delimiter, join } from 'node:path';
55import { NodeBridge } from '../src/runtime/node.js' ;
66import { resolvePythonExecutable } from '../src/utils/python.js' ;
77import { isNodejs } from '../src/utils/runtime.js' ;
8+ import { PYTHON_AVAILABLE , hasPythonModule } from './helpers/python-probe.js' ;
89
9- const shouldRun = isNodejs ( ) && process . env . TYWRAP_ADVERSARIAL === '1' ;
10+ const scriptPath = join ( process . cwd ( ) , 'runtime' , 'python_bridge.py' ) ;
11+ const fixturesRoot = join ( process . cwd ( ) , 'test' , 'fixtures' , 'python' ) ;
12+ const shouldRun =
13+ isNodejs ( ) && PYTHON_AVAILABLE && existsSync ( scriptPath ) && existsSync ( fixturesRoot ) ;
1014const describeAdversarial = shouldRun ? describe : describe . skip ;
1115const testTimeoutMs = shouldRun ? 15_000 : 5_000 ;
16+ const SKLEARN_AVAILABLE = shouldRun && hasPythonModule ( 'sklearn' ) ;
1217
13- const scriptPath = join ( process . cwd ( ) , 'runtime' , 'python_bridge.py' ) ;
14- const fixturesRoot = join ( process . cwd ( ) , 'test' , 'fixtures' , 'python' ) ;
1518const fixturesDir = join ( process . cwd ( ) , 'test' , 'fixtures' ) ;
1619const moduleName = 'adversarial_module' ;
1720
@@ -68,7 +71,10 @@ const createBridge = async (
6871 return new NodeBridge ( {
6972 scriptPath,
7073 pythonPath,
71- timeoutMs : options . timeoutMs ?? 2000 ,
74+ // Default to the suite budget: error-surfacing tests assert LOUDNESS, not
75+ // latency, and must absorb worker cold-start/import costs under full-suite
76+ // load (#285). Timeout-behavior tests pass explicit small values.
77+ timeoutMs : options . timeoutMs ?? testTimeoutMs ,
7278 env : {
7379 // Why: include local adversarial fixtures without mutating global env.
7480 PYTHONPATH : buildPythonPath ( ) ,
@@ -79,7 +85,11 @@ const createBridge = async (
7985
8086const createFixtureBridge = async (
8187 scriptName : string ,
82- options : { timeoutMs ?: number ; env ?: Record < string , string | undefined > } = { }
88+ options : {
89+ timeoutMs ?: number ;
90+ maxConcurrentPerProcess ?: number ;
91+ env ?: Record < string , string | undefined > ;
92+ } = { }
8393) : Promise < NodeBridge | null > => {
8494 const fixtureScript = join ( fixturesDir , scriptName ) ;
8595 if ( ! existsSync ( fixtureScript ) ) {
@@ -92,7 +102,11 @@ const createFixtureBridge = async (
92102 return new NodeBridge ( {
93103 scriptPath : fixtureScript ,
94104 pythonPath,
95- timeoutMs : options . timeoutMs ?? 2000 ,
105+ maxConcurrentPerProcess : options . maxConcurrentPerProcess ,
106+ // Default to the suite budget: error-surfacing tests assert LOUDNESS, not
107+ // latency, and must absorb worker cold-start/import costs under full-suite
108+ // load (#285). Timeout-behavior tests pass explicit small values.
109+ timeoutMs : options . timeoutMs ?? testTimeoutMs ,
96110 env : options . env ?? { } ,
97111 } ) ;
98112} ;
@@ -106,15 +120,20 @@ describeAdversarial('Adversarial playground', () => {
106120 it (
107121 'keeps the bridge usable after a timeout' ,
108122 async ( ) => {
109- const bridge = await createBridge ( { timeoutMs : 200 } ) ;
123+ // Timings must absorb the worker's first-serialize import cost when the
124+ // scientific stack is installed (#285): the 4s budget covers a cold
125+ // start (~2s under load) but not the 6s sleep, so the first call times out and
126+ // the recovery echo still fits — with or without numpy present.
127+ const bridge = await createBridge ( { timeoutMs : 4_000 } ) ;
110128 if ( ! bridge ) return ;
111129
112130 try {
113- await expect ( callAdversarial ( bridge , 'sleep_and_return' , [ 'ok' , 0.4 ] ) ) . rejects . toThrow (
131+ await expect ( callAdversarial ( bridge , 'sleep_and_return' , [ 'ok' , 6.0 ] ) ) . rejects . toThrow (
114132 / t i m e d o u t / i
115133 ) ;
116- // Why: allow the slow call to finish so the next request is not blocked.
117- await delay ( 500 ) ;
134+ // Why: allow the slow call (and any worker restart) to fully drain so
135+ // the recovery echo is not queued behind it.
136+ await delay ( 6_000 ) ;
118137
119138 const result = await callAdversarial ( bridge , 'echo' , [ 'still-alive' ] ) ;
120139 expect ( result ) . toBe ( 'still-alive' ) ;
@@ -243,7 +262,7 @@ describeAdversarial('Adversarial playground', () => {
243262
244263 try {
245264 await expect ( callAdversarial ( bridge , 'return_scipy_complex_sparse' , [ ] ) ) . rejects . toThrow (
246- / C o m p l e x s p a r s e m a t r i c e s a r e n o t s u p p o r t e d /
265+ / C o m p l e x s c i p y s p a r s e m a t r i c e s a r e n o t s u p p o r t e d /
247266 ) ;
248267 } finally {
249268 await bridge . dispose ( ) ;
@@ -252,18 +271,16 @@ describeAdversarial('Adversarial playground', () => {
252271 testTimeoutMs
253272 ) ;
254273
255- it (
274+ it . skipIf ( ! SKLEARN_AVAILABLE ) (
256275 'surfaces explicit sklearn non-serializable params errors' ,
257276 async ( ) => {
258- if ( ! ( await pythonModuleAvailable ( 'sklearn' ) ) ) return ;
259-
260- const bridge = await createBridge ( ) ;
277+ const bridge = await createBridge ( { timeoutMs : testTimeoutMs } ) ;
261278 if ( ! bridge ) return ;
262279
263280 try {
264281 await expect (
265282 callAdversarial ( bridge , 'return_sklearn_unserializable_estimator' , [ ] )
266- ) . rejects . toThrow ( / s c i k i t - l e a r n e s t i m a t o r p a r a m s a r e n o t J S O N - s e r i a l i z a b l e / ) ;
283+ ) . rejects . toThrow ( / s c i k i t - l e a r n e s t i m a t o r p a r a m . * i s n o t J S O N - s e r i a l i z a b l e / ) ;
267284 } finally {
268285 await bridge . dispose ( ) ;
269286 }
@@ -379,7 +396,7 @@ describeAdversarial('Adversarial playground', () => {
379396 it (
380397 'surfaces invalid JSON payloads explicitly' ,
381398 async ( ) => {
382- const bridge = await createBridge ( ) ;
399+ const bridge = await createBridge ( { timeoutMs : testTimeoutMs } ) ;
383400 if ( ! bridge ) return ;
384401
385402 try {
@@ -396,7 +413,7 @@ describeAdversarial('Adversarial playground', () => {
396413 it (
397414 'treats stdout noise as a protocol error and recovers' ,
398415 async ( ) => {
399- const bridge = await createBridge ( ) ;
416+ const bridge = await createBridge ( { timeoutMs : testTimeoutMs } ) ;
400417 if ( ! bridge ) return ;
401418
402419 try {
@@ -433,19 +450,23 @@ describeAdversarial('Adversarial playground', () => {
433450 it (
434451 'includes recent stderr in timeout errors' ,
435452 async ( ) => {
436- const bridge = await createBridge ( { timeoutMs : 200 } ) ;
453+ // The 4s budget must outlast the worker's first-serialize import cost
454+ // (#285, ~2s under load with the scientific stack installed) so the
455+ // fixture gets to write stderr BEFORE the timeout fires; the 6s sleep
456+ // guarantees the timeout still fires after that.
457+ const bridge = await createBridge ( { timeoutMs : 4_000 } ) ;
437458 if ( ! bridge ) return ;
438459
439460 try {
440- await callAdversarial ( bridge , 'write_stderr_then_sleep' , [ 'stderr-timeout' , 0.5 ] ) ;
461+ await callAdversarial ( bridge , 'write_stderr_then_sleep' , [ 'stderr-timeout' , 6.0 ] ) ;
441462 throw new Error ( 'Expected timeout did not occur' ) ;
442463 } catch ( error ) {
443464 const message = error instanceof Error ? error . message : String ( error ) ;
444465 expect ( message ) . toMatch ( / R e c e n t s t d e r r / ) ;
445466 expect ( message ) . toMatch ( / s t d e r r - t i m e o u t / ) ;
446467 } finally {
447468 // Why: adversarial test verifies post-timeout recovery even if it masks the original error.
448- await delay ( 600 ) ;
469+ await delay ( 6_000 ) ;
449470 const result = await callAdversarial ( bridge , 'echo' , [ 'post-timeout' ] ) ;
450471 expect ( result ) . toBe ( 'post-timeout' ) ;
451472 await bridge . dispose ( ) ;
@@ -568,34 +589,6 @@ describeAdversarial('Adversarial playground', () => {
568589 name : 'return_bad_codec_version' ,
569590 pattern : / U n s u p p o r t e d d a t a f r a m e e n v e l o p e c o d e c V e r s i o n : 9 9 9 / ,
570591 } ,
571- {
572- name : 'return_bad_encoding' ,
573- pattern : / I n v a l i d d a t a f r a m e e n v e l o p e : u n s u p p o r t e d e n c o d i n g / ,
574- } ,
575- {
576- name : 'return_missing_b64' ,
577- pattern : / I n v a l i d d a t a f r a m e e n v e l o p e : m i s s i n g b 6 4 / ,
578- } ,
579- {
580- name : 'return_missing_data' ,
581- pattern : / I n v a l i d n d a r r a y e n v e l o p e : m i s s i n g d a t a / ,
582- } ,
583- {
584- name : 'return_invalid_sparse_format' ,
585- pattern : / I n v a l i d s c i p y \. s p a r s e e n v e l o p e : u n s u p p o r t e d f o r m a t / ,
586- } ,
587- {
588- name : 'return_invalid_sparse_shape' ,
589- pattern : / I n v a l i d s c i p y \. s p a r s e e n v e l o p e : s h a p e m u s t b e a 2 - i t e m n o n - n e g a t i v e i n t e g e r \[ \] / ,
590- } ,
591- {
592- name : 'return_invalid_torch_value' ,
593- pattern : / I n v a l i d t o r c h \. t e n s o r e n v e l o p e : v a l u e m u s t b e a n n d a r r a y e n v e l o p e / ,
594- } ,
595- {
596- name : 'return_invalid_sklearn_payload' ,
597- pattern : / I n v a l i d s k l e a r n \. e s t i m a t o r e n v e l o p e / ,
598- } ,
599592 ] ;
600593
601594 for ( const { name, pattern } of cases ) {
@@ -695,7 +688,14 @@ describeAdversarial('Adversarial playground', () => {
695688 it (
696689 'handles out-of-order responses' ,
697690 async ( ) => {
698- const bridge = await createFixtureBridge ( 'out_of_order_bridge.py' , { timeoutMs : 2000 } ) ;
691+ // The fixture only responds once it has TWO requests in flight, so
692+ // pipelining must be opted into: the pool default is one in-flight
693+ // request per worker (#284), under which the second call would never
694+ // be sent and the first would deadlock into its timeout.
695+ const bridge = await createFixtureBridge ( 'out_of_order_bridge.py' , {
696+ timeoutMs : 2000 ,
697+ maxConcurrentPerProcess : 2 ,
698+ } ) ;
699699 if ( ! bridge ) return ;
700700
701701 try {
@@ -722,6 +722,7 @@ describeAdversarial('Multi-worker adversarial tests', () => {
722722 options : {
723723 minProcesses ?: number ;
724724 maxProcesses ?: number ;
725+ maxConcurrentPerProcess ?: number ;
725726 timeoutMs ?: number ;
726727 env ?: Record < string , string | undefined > ;
727728 } = { }
@@ -738,7 +739,11 @@ describeAdversarial('Multi-worker adversarial tests', () => {
738739 pythonPath,
739740 minProcesses : options . minProcesses ?? 2 ,
740741 maxProcesses : options . maxProcesses ?? 4 ,
741- timeoutMs : options . timeoutMs ?? 2000 ,
742+ maxConcurrentPerProcess : options . maxConcurrentPerProcess ,
743+ // Default to the suite budget: error-surfacing tests assert LOUDNESS, not
744+ // latency, and must absorb worker cold-start/import costs under full-suite
745+ // load (#285). Timeout-behavior tests pass explicit small values.
746+ timeoutMs : options . timeoutMs ?? testTimeoutMs ,
742747 env : {
743748 PYTHONPATH : buildPythonPath ( ) ,
744749 ...options . env ,
@@ -806,21 +811,32 @@ describeAdversarial('Multi-worker adversarial tests', () => {
806811 pythonPath,
807812 minProcesses : 2 ,
808813 maxProcesses : 2 ,
809- maxConcurrentPerProcess : 1 , // Key: enforce one request per worker for isolation
810- timeoutMs : 1000 ,
814+ // explicit: worker is serial; see #284
815+ maxConcurrentPerProcess : 1 ,
816+ // 5s (not 1s): the post-timeout recovery call below must cover a worker
817+ // restart, whose fresh Python process pays the first-serialize import
818+ // cost on current main (see #285). Slow call sleeps 8s so it still
819+ // exceeds this timeout by a wide margin.
820+ timeoutMs : 5000 ,
811821 env : { PYTHONPATH : buildPythonPath ( ) } ,
812822 } ) ;
813823
814824 try {
815825 // Initialize to spawn both workers
816826 await bridge . init ( ) ;
817827
818- // Warm up both workers to ensure they're ready
819- await callAdversarial ( bridge , 'echo' , [ 'warmup1' ] ) ;
820- await callAdversarial ( bridge , 'echo' , [ 'warmup2' ] ) ;
828+ // Warm up both workers CONCURRENTLY: with maxConcurrentPerProcess: 1 the two
829+ // in-flight calls must land on distinct workers, so each worker pays its
830+ // first-serialize cost here rather than inside the timed fast call below.
831+ // (Sequential warmups both land on worker 1 — it frees up between calls —
832+ // leaving worker 2 cold; see #285 for the first-call import cost.)
833+ await Promise . all ( [
834+ callAdversarial ( bridge , 'echo' , [ 'warmup1' ] ) ,
835+ callAdversarial ( bridge , 'echo' , [ 'warmup2' ] ) ,
836+ ] ) ;
821837
822838 // Start a slow request (will timeout) - occupies worker 1
823- const slow = callAdversarial ( bridge , 'sleep_and_return' , [ 'slow' , 2 .0] ) ;
839+ const slow = callAdversarial ( bridge , 'sleep_and_return' , [ 'slow' , 8 .0] ) ;
824840
825841 // Give slow request time to start processing
826842 await delay ( 150 ) ;
@@ -981,7 +997,12 @@ describeAdversarial('Multi-worker adversarial tests', () => {
981997 it (
982998 'maintains pool health after protocol errors' ,
983999 async ( ) => {
984- const bridge = await createPooledBridge ( { minProcesses : 2 , maxProcesses : 2 } ) ;
1000+ const bridge = await createPooledBridge ( {
1001+ minProcesses : 2 ,
1002+ maxProcesses : 2 ,
1003+ // explicit: worker is serial; see #284
1004+ maxConcurrentPerProcess : 1 ,
1005+ } ) ;
9851006 if ( ! bridge ) return ;
9861007
9871008 try {
0 commit comments