more-compute 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,11 +13,17 @@ import re
13
13
  import subprocess
14
14
  import shlex
15
15
  import platform
16
+ import threading
16
17
 
17
18
  # Import shared shell command utilities
18
19
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
20
  from utils.shell_utils import prepare_shell_command, prepare_shell_environment
20
21
 
22
+ # Global reference to current subprocess for interrupt handling
23
+ _current_subprocess = None
24
+ _interrupt_requested = False
25
+ _current_process_group = None # Store process group ID
26
+
21
27
  def _preprocess_shell_commands(code: str) -> str:
22
28
  """
23
29
  Preprocess code to transform IPython-style shell commands (!cmd) into Python function calls.
@@ -48,51 +54,106 @@ def _inject_shell_command_function(globals_dict: dict):
48
54
  if '_run_shell_command' not in globals_dict:
49
55
  def _run_shell_command(cmd: str):
50
56
  """Execute a shell command synchronously with streaming output"""
57
+ global _current_subprocess, _interrupt_requested, _current_process_group
58
+
59
+ # Check if already interrupted before starting new command
60
+ if _interrupt_requested:
61
+ print(f"[WORKER] Shell command skipped due to previous interrupt", file=sys.stderr, flush=True)
62
+ raise KeyboardInterrupt("Execution was interrupted")
63
+
51
64
  # Prepare command and environment (using shared utilities)
52
65
  shell_cmd = prepare_shell_command(cmd)
53
66
  env = prepare_shell_environment(cmd)
54
67
 
55
- # Use Popen for real-time streaming
68
+ # Use Popen for real-time streaming, CREATE NEW PROCESS GROUP
56
69
  process = subprocess.Popen(
57
70
  shell_cmd,
58
71
  stdout=subprocess.PIPE,
59
72
  stderr=subprocess.PIPE,
60
73
  text=True,
61
74
  bufsize=1, # Line buffered
62
- env=env
75
+ env=env,
76
+ preexec_fn=os.setsid if os.name != 'nt' else None # Create new process group on Unix
63
77
  )
64
78
 
65
- # Stream output line by line
66
- import threading
79
+ # Store reference globally for interrupt handling
80
+ _current_subprocess = process
81
+ if os.name != 'nt':
82
+ _current_process_group = os.getpgid(process.pid)
83
+ # Also create a new process group for clean killing
84
+ print(f"[WORKER] Started subprocess PID={process.pid}, PGID={_current_process_group}", file=sys.stderr, flush=True)
85
+ else:
86
+ print(f"[WORKER] Started subprocess PID={process.pid}", file=sys.stderr, flush=True)
87
+
88
+ sys.stderr.flush()
67
89
 
68
- def read_stream(stream, output_type):
90
+ try:
91
+ # Stream output line by line
92
+ def read_stream(stream, output_type):
93
+ try:
94
+ for line in iter(stream.readline, ''):
95
+ if not line:
96
+ break
97
+ if output_type == 'stdout':
98
+ print(line, end='')
99
+ sys.stdout.flush()
100
+ else:
101
+ print(line, end='', file=sys.stderr)
102
+ sys.stderr.flush()
103
+ except Exception:
104
+ pass
105
+ finally:
106
+ stream.close()
107
+
108
+ stdout_thread = threading.Thread(target=read_stream, args=(process.stdout, 'stdout'))
109
+ stderr_thread = threading.Thread(target=read_stream, args=(process.stderr, 'stderr'))
110
+ stdout_thread.daemon = True
111
+ stderr_thread.daemon = True
112
+ stdout_thread.start()
113
+ stderr_thread.start()
114
+
115
+ # Wait for process to complete, checking interrupt flag
116
+ while True:
117
+ try:
118
+ return_code = process.wait(timeout=0.01) # 10ms for faster interrupt response
119
+ break
120
+ except subprocess.TimeoutExpired:
121
+ # Check if interrupted
122
+ if _interrupt_requested:
123
+ print(f"[WORKER] Interrupt detected, killing subprocess", file=sys.stderr, flush=True)
124
+ try:
125
+ process.kill()
126
+ except Exception:
127
+ pass
128
+ # Close pipes to unblock reader threads
129
+ try:
130
+ process.stdout.close()
131
+ except Exception:
132
+ pass
133
+ try:
134
+ process.stderr.close()
135
+ except Exception:
136
+ pass
137
+ # Don't wait for process or threads - raise immediately
138
+ print(f"[WORKER] Raising KeyboardInterrupt immediately", file=sys.stderr, flush=True)
139
+ raise KeyboardInterrupt("Execution interrupted by user")
140
+
141
+ # Normal completion - join threads briefly
142
+ stdout_thread.join(timeout=0.1)
143
+ stderr_thread.join(timeout=0.1)
144
+
145
+ return return_code
146
+ except KeyboardInterrupt:
147
+ # Kill subprocess on interrupt
69
148
  try:
70
- for line in iter(stream.readline, ''):
71
- if not line:
72
- break
73
- if output_type == 'stdout':
74
- print(line, end='')
75
- sys.stdout.flush()
76
- else:
77
- print(line, end='', file=sys.stderr)
78
- sys.stderr.flush()
149
+ process.kill()
150
+ process.wait(timeout=1)
79
151
  except Exception:
80
152
  pass
81
- finally:
82
- stream.close()
83
-
84
- stdout_thread = threading.Thread(target=read_stream, args=(process.stdout, 'stdout'))
85
- stderr_thread = threading.Thread(target=read_stream, args=(process.stderr, 'stderr'))
86
- stdout_thread.daemon = True
87
- stderr_thread.daemon = True
88
- stdout_thread.start()
89
- stderr_thread.start()
90
-
91
- return_code = process.wait()
92
- stdout_thread.join()
93
- stderr_thread.join()
94
-
95
- return return_code
153
+ raise
154
+ finally:
155
+ _current_subprocess = None
156
+ _current_process_group = None
96
157
 
97
158
  globals_dict['_run_shell_command'] = _run_shell_command
98
159
 
@@ -170,34 +231,131 @@ def _capture_matplotlib(pub, cell_index):
170
231
  return
171
232
 
172
233
 
234
+ def control_thread_main(ctrl, current_cell_ref):
235
+ """Run control channel in separate thread (Jupyter pattern)"""
236
+ global _interrupt_requested, _current_subprocess, _current_process_group
237
+
238
+ print(f"[CONTROL] Control thread started", file=sys.stderr, flush=True)
239
+
240
+ while True:
241
+ try:
242
+ # Block waiting for control messages
243
+ identity = ctrl.recv()
244
+ msg = ctrl.recv_json()
245
+
246
+ print(f"[CONTROL] Received: {msg}", file=sys.stderr, flush=True)
247
+
248
+ mtype = msg.get('type')
249
+ if mtype == 'interrupt':
250
+ requested_cell = msg.get('cell_index')
251
+ current_cell = current_cell_ref[0]
252
+
253
+ print(f"[CONTROL] Interrupt check: requested={requested_cell}, current={current_cell}, subprocess={_current_subprocess}, pgid={_current_process_group}", file=sys.stderr)
254
+ sys.stderr.flush()
255
+
256
+ if requested_cell is None or requested_cell == current_cell:
257
+ print(f"[CONTROL] ✓ Match! Processing interrupt for cell {requested_cell}", file=sys.stderr)
258
+ sys.stderr.flush()
259
+
260
+ # Set global flag
261
+ _interrupt_requested = True
262
+
263
+ # Send SIGINT to process group (Jupyter pattern)
264
+ if _current_process_group and os.name != 'nt':
265
+ try:
266
+ print(f"[CONTROL] Sending SIGINT to process group {_current_process_group}", file=sys.stderr)
267
+ sys.stderr.flush()
268
+ os.killpg(_current_process_group, signal.SIGINT)
269
+ print(f"[CONTROL] SIGINT sent successfully", file=sys.stderr)
270
+ sys.stderr.flush()
271
+ except Exception as e:
272
+ print(f"[CONTROL] Failed to kill process group: {e}", file=sys.stderr)
273
+ sys.stderr.flush()
274
+
275
+ # Also kill subprocess directly
276
+ if _current_subprocess:
277
+ try:
278
+ print(f"[CONTROL] Killing subprocess PID={_current_subprocess.pid}", file=sys.stderr)
279
+ sys.stderr.flush()
280
+ _current_subprocess.kill()
281
+ print(f"[CONTROL] Subprocess killed", file=sys.stderr)
282
+ sys.stderr.flush()
283
+ except Exception as e:
284
+ print(f"[CONTROL] Failed to kill subprocess: {e}", file=sys.stderr)
285
+ sys.stderr.flush()
286
+
287
+ # Don't send SIGINT to self - let the execution thread finish gracefully
288
+ # Sending SIGINT here can interrupt the execution thread before it sends
289
+ # completion messages, leaving the frontend in a confused state
290
+ print(f"[CONTROL] Interrupt signal sent, waiting for execution thread to finish", file=sys.stderr)
291
+ sys.stderr.flush()
292
+ else:
293
+ print(f"[CONTROL] ✗ NO MATCH! Ignoring interrupt (requested cell {requested_cell} != current cell {current_cell})", file=sys.stderr)
294
+ sys.stderr.flush()
295
+
296
+ # Reply
297
+ ctrl.send(identity, zmq.SNDMORE)
298
+ ctrl.send_json({'ok': True, 'pid': os.getpid()})
299
+
300
+ elif mtype == 'shutdown':
301
+ ctrl.send(identity, zmq.SNDMORE)
302
+ ctrl.send_json({'ok': True, 'pid': os.getpid()})
303
+ break
304
+
305
+ except Exception as e:
306
+ print(f"[CONTROL] Error: {e}", file=sys.stderr, flush=True)
307
+ import traceback
308
+ traceback.print_exc()
309
+
310
+
173
311
  def worker_main():
312
+ global _current_subprocess, _interrupt_requested, _current_process_group
313
+
314
+ print(f"[WORKER] ========================================", file=sys.stderr, flush=True)
315
+ print(f"[WORKER] Starting THREADED worker (new code!)", file=sys.stderr, flush=True)
316
+ print(f"[WORKER] PID: {os.getpid()}", file=sys.stderr, flush=True)
317
+ print(f"[WORKER] ========================================", file=sys.stderr, flush=True)
318
+
174
319
  _setup_signals()
175
320
  cmd_addr = os.environ['MC_ZMQ_CMD_ADDR']
176
321
  pub_addr = os.environ['MC_ZMQ_PUB_ADDR']
322
+ ctrl_addr = os.environ.get('MC_ZMQ_CTRL_ADDR', cmd_addr.replace('5555', '5557'))
323
+
324
+ print(f"[WORKER] Binding to control socket: {ctrl_addr}", file=sys.stderr, flush=True)
177
325
 
178
326
  ctx = zmq.Context.instance()
179
327
  rep = ctx.socket(zmq.REP)
180
328
  rep.bind(cmd_addr)
181
- # Set timeout so we can check for signals during execution
182
- rep.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout
329
+ rep.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout for heartbeat
183
330
 
184
331
  pub = ctx.socket(zmq.PUB)
185
332
  pub.bind(pub_addr)
186
333
 
334
+ # Separate control socket for interrupts
335
+ ctrl = ctx.socket(zmq.ROUTER)
336
+ ctrl.bind(ctrl_addr)
337
+
338
+ # Shared reference for current cell (thread-safe via list)
339
+ current_cell_ref = [None]
340
+
341
+ # Start control thread (Jupyter pattern)
342
+ ctrl_thread = threading.Thread(target=control_thread_main, args=(ctrl, current_cell_ref), daemon=True)
343
+ ctrl_thread.start()
344
+ print(f"[WORKER] Started control thread", file=sys.stderr, flush=True)
345
+
187
346
  # Persistent REPL state
188
347
  g = {"__name__": "__main__"}
189
348
  l = g
190
349
  exec_count = 0
191
350
 
192
351
  last_hb = time.time()
193
- current_cell = None
194
352
  shutdown_requested = False
195
353
 
196
354
  while True:
197
355
  try:
198
356
  msg = rep.recv_json()
199
357
  except zmq.Again:
200
- # Timeout - check if we should send heartbeat
358
+ # Timeout - send heartbeat
201
359
  if time.time() - last_hb > 5.0:
202
360
  pub.send_json({'type': 'heartbeat', 'ts': time.time()})
203
361
  last_hb = time.time()
@@ -208,6 +366,7 @@ def worker_main():
208
366
  if shutdown_requested:
209
367
  break
210
368
  continue
369
+
211
370
  mtype = msg.get('type')
212
371
  if mtype == 'ping':
213
372
  rep.send_json({'ok': True, 'pid': os.getpid()})
@@ -215,95 +374,157 @@ def worker_main():
215
374
  if mtype == 'shutdown':
216
375
  rep.send_json({'ok': True, 'pid': os.getpid()})
217
376
  shutdown_requested = True
218
- # Don't break immediately - let the loop handle cleanup
219
- continue
220
- if mtype == 'interrupt':
221
- requested = msg.get('cell_index') if isinstance(msg, dict) else None
222
- if requested is None or requested == current_cell:
223
- try:
224
- os.kill(os.getpid(), signal.SIGINT)
225
- except Exception:
226
- pass
227
- rep.send_json({'ok': True, 'pid': os.getpid()})
228
377
  continue
229
378
  if mtype == 'execute_cell':
230
379
  code = msg.get('code', '')
231
380
  cell_index = msg.get('cell_index')
232
381
  requested_count = msg.get('execution_count')
233
- current_cell = cell_index
382
+
383
+ print(f"[WORKER] Setting current_cell_ref[0] = {cell_index}", file=sys.stderr, flush=True)
384
+ current_cell_ref[0] = cell_index # Update for control thread
385
+ print(f"[WORKER] Confirmed current_cell_ref[0] = {current_cell_ref[0]}", file=sys.stderr, flush=True)
386
+
234
387
  if isinstance(requested_count, int):
235
388
  exec_count = requested_count - 1
236
- command_type = msg.get('command_type')
389
+
390
+ # Reset interrupt flag
391
+ _interrupt_requested = False
392
+
237
393
  pub.send_json({'type': 'execution_start', 'cell_index': cell_index, 'execution_count': exec_count + 1})
238
394
 
239
- # Check if this is a special command (shell command starting with ! or magic command)
240
- is_special_cmd = code.strip().startswith('!') or code.strip().startswith('%')
395
+ # Check if this is a special command (shell command starting with !)
396
+ is_special_cmd = code.strip().startswith('!')
241
397
 
242
398
  if is_special_cmd:
243
- # Handle special commands on remote worker
399
+ # Handle shell commands
244
400
  exec_count += 1
245
401
  status = 'ok'
246
402
  error_payload = None
247
403
  start = time.time()
248
404
 
249
405
  try:
250
- import subprocess
251
- import shlex
252
-
253
- # Strip the ! prefix for shell commands
254
- if code.strip().startswith('!'):
255
- shell_cmd = code.strip()[1:].strip()
256
-
257
- # Run shell command
258
- process = subprocess.Popen(
259
- ['/bin/bash', '-c', shell_cmd],
260
- stdout=subprocess.PIPE,
261
- stderr=subprocess.PIPE,
262
- text=True
263
- )
264
- stdout, stderr = process.communicate()
265
-
266
- # Send stdout
267
- if stdout:
268
- pub.send_json({'type': 'stream', 'name': 'stdout', 'text': stdout, 'cell_index': cell_index})
269
-
270
- # Send stderr
271
- if stderr:
272
- pub.send_json({'type': 'stream', 'name': 'stderr', 'text': stderr, 'cell_index': cell_index})
273
-
274
- # Check return code
275
- if process.returncode != 0:
276
- status = 'error'
277
- # Only set error if we don't have detailed stderr
278
- if not stderr.strip():
406
+ shell_cmd = code.strip()[1:].strip()
407
+ print(f"[WORKER] Executing shell: {shell_cmd[:50]}...", file=sys.stderr, flush=True)
408
+
409
+ # Run shell command with streaming
410
+ process = subprocess.Popen(
411
+ ['/bin/bash', '-c', shell_cmd],
412
+ stdout=subprocess.PIPE,
413
+ stderr=subprocess.PIPE,
414
+ text=True,
415
+ bufsize=1, # Line buffered
416
+ preexec_fn=os.setsid if os.name != 'nt' else None # New process group
417
+ )
418
+
419
+ # Store reference for interrupt handling
420
+ _current_subprocess = process
421
+ if os.name != 'nt':
422
+ _current_process_group = os.getpgid(process.pid)
423
+
424
+ try:
425
+ # Stream output in real-time
426
+ def stream_output(stream, stream_name):
427
+ try:
428
+ for line in iter(stream.readline, ''):
429
+ if not line:
430
+ break
431
+ pub.send_json({
432
+ 'type': 'stream',
433
+ 'name': stream_name,
434
+ 'text': line,
435
+ 'cell_index': cell_index
436
+ })
437
+ except Exception:
438
+ pass
439
+ finally:
440
+ stream.close()
441
+
442
+ stdout_thread = threading.Thread(target=stream_output, args=(process.stdout, 'stdout'))
443
+ stderr_thread = threading.Thread(target=stream_output, args=(process.stderr, 'stderr'))
444
+ stdout_thread.daemon = True
445
+ stderr_thread.daemon = True
446
+ stdout_thread.start()
447
+ stderr_thread.start()
448
+
449
+ # Wait for process, checking interrupt flag
450
+ while True:
451
+ try:
452
+ return_code = process.wait(timeout=0.01) # 10ms for faster interrupt response
453
+ break
454
+ except subprocess.TimeoutExpired:
455
+ if _interrupt_requested:
456
+ print(f"[WORKER] Interrupt detected, killing shell process", file=sys.stderr, flush=True)
457
+ try:
458
+ process.kill()
459
+ except Exception:
460
+ pass
461
+ # Close pipes to unblock reader threads
462
+ try:
463
+ process.stdout.close()
464
+ except Exception:
465
+ pass
466
+ try:
467
+ process.stderr.close()
468
+ except Exception:
469
+ pass
470
+ # Set interrupted status immediately
471
+ print(f"[WORKER] Setting error status for interrupted shell command", file=sys.stderr, flush=True)
472
+ status = 'error'
473
+ error_payload = {
474
+ 'ename': 'KeyboardInterrupt',
475
+ 'evalue': 'Execution interrupted',
476
+ 'traceback': []
477
+ }
478
+ # Break out and send completion immediately
479
+ break
480
+
481
+ # For normal completion (not interrupted), join threads and check return code
482
+ if not _interrupt_requested:
483
+ # Give threads brief time to finish
484
+ stdout_thread.join(timeout=0.1)
485
+ stderr_thread.join(timeout=0.1)
486
+
487
+ print(f"[WORKER] Shell process finished: return_code={return_code}", file=sys.stderr, flush=True)
488
+
489
+ # Check return code
490
+ if return_code != 0:
491
+ status = 'error'
279
492
  error_payload = {
280
493
  'ename': 'ShellCommandError',
281
- 'evalue': f'Command failed with return code {process.returncode}',
494
+ 'evalue': f'Command failed with return code {return_code}',
282
495
  'traceback': [f'Shell command failed: {shell_cmd}']
283
496
  }
284
- else:
285
- # Magic commands not fully supported on remote yet
497
+ print(f"[WORKER] Set error_payload to ShellCommandError", file=sys.stderr, flush=True)
498
+ except KeyboardInterrupt:
286
499
  status = 'error'
287
500
  error_payload = {
288
- 'ename': 'NotImplementedError',
289
- 'evalue': 'Magic commands (%) not yet supported on remote GPU pods',
290
- 'traceback': ['Use ! for shell commands instead']
501
+ 'ename': 'KeyboardInterrupt',
502
+ 'evalue': 'Execution interrupted',
503
+ 'traceback': []
291
504
  }
505
+ finally:
506
+ _current_subprocess = None
507
+ _current_process_group = None
292
508
 
293
509
  except Exception as exc:
294
510
  status = 'error'
295
511
  error_payload = {'ename': type(exc).__name__, 'evalue': str(exc), 'traceback': traceback.format_exc().split('\n')}
296
512
 
297
513
  duration_ms = f"{(time.time()-start)*1000:.1f}ms"
514
+ print(f"[WORKER] Sending completion messages: status={status}, error={error_payload is not None}", file=sys.stderr, flush=True)
298
515
  if error_payload:
299
516
  pub.send_json({'type': 'execution_error', 'cell_index': cell_index, 'error': error_payload})
517
+ print(f"[WORKER] Sent execution_error", file=sys.stderr, flush=True)
300
518
  pub.send_json({'type': 'execution_complete', 'cell_index': cell_index, 'result': {'status': status, 'execution_count': exec_count, 'execution_time': duration_ms, 'outputs': [], 'error': error_payload}})
519
+ print(f"[WORKER] Sent execution_complete", file=sys.stderr, flush=True)
301
520
  rep.send_json({'ok': True, 'pid': os.getpid()})
302
- current_cell = None
521
+
522
+ print(f"[WORKER] Clearing current_cell_ref[0] (was {current_cell_ref[0]})", file=sys.stderr, flush=True)
523
+ current_cell_ref[0] = None
524
+ print(f"[WORKER] Confirmed current_cell_ref[0] = {current_cell_ref[0]}", file=sys.stderr, flush=True)
303
525
  continue
304
526
 
305
527
  # Regular Python code execution
306
- # Redirect streams
307
528
  sf = _StreamForwarder(pub, cell_index)
308
529
  old_out, old_err = sys.stdout, sys.stderr
309
530
  class _O:
@@ -317,51 +538,36 @@ def worker_main():
317
538
  error_payload = None
318
539
  start = time.time()
319
540
  try:
320
- # Preprocess shell commands (!cmd) to Python function calls
321
- # This allows code like "import os; !pip install pandas" to work
541
+ # Preprocess shell commands
322
542
  preprocessed_code = _preprocess_shell_commands(code)
323
543
 
324
- # Inject shell command function into globals if needed
544
+ # Inject shell command function into globals
325
545
  _inject_shell_command_function(g)
326
546
 
327
547
  compiled = compile(preprocessed_code, '<cell>', 'exec')
328
548
  exec(compiled, g, l)
329
549
 
330
- # Try to evaluate last expression for display (like Jupyter)
550
+ # Try to evaluate last expression for display
331
551
  lines = code.strip().split('\n')
332
552
  if lines:
333
553
  last = lines[-1].strip()
334
- # Skip comments and empty lines
335
554
  if not last or last.startswith('#'):
336
555
  last = None
337
- # Skip orphaned closing brackets from multi-line expressions
338
- # e.g., the ')' from: model = func(\n arg1,\n arg2\n)
339
556
  elif last.lstrip().startswith(')') or last.lstrip().startswith('}') or last.lstrip().startswith(']'):
340
557
  last = None
341
558
 
342
559
  if last:
343
- # Check if it looks like a statement (assignment, import, etc)
344
560
  is_statement = False
345
-
346
- # Check for assignment (but not comparison operators)
347
561
  if '=' in last and not any(op in last for op in ['==', '!=', '<=', '>=', '=<', '=>']):
348
562
  is_statement = True
349
-
350
- # Check for statement keywords (handle both "assert x" and "assert(x)")
351
563
  statement_keywords = ['import', 'from', 'def', 'class', 'if', 'elif', 'else',
352
564
  'for', 'while', 'try', 'except', 'finally', 'with',
353
565
  'assert', 'del', 'global', 'nonlocal', 'pass', 'break',
354
566
  'continue', 'return', 'raise', 'yield']
355
-
356
- # Get first word, handling cases like "assert(...)" by splitting on non-alphanumeric
357
567
  first_word_match = re.match(r'^(\w+)', last)
358
568
  first_word = first_word_match.group(1) if first_word_match else ''
359
-
360
569
  if first_word in statement_keywords:
361
570
  is_statement = True
362
-
363
- # Don't eval function calls - they were already executed by exec()
364
- # This prevents double execution of code like: what()
365
571
  if '(' in last and ')' in last:
366
572
  is_statement = True
367
573
 
@@ -370,8 +576,8 @@ def worker_main():
370
576
  res = eval(last, g, l)
371
577
  if res is not None:
372
578
  pub.send_json({'type': 'execute_result', 'cell_index': cell_index, 'execution_count': exec_count + 1, 'data': {'text/plain': repr(res)}})
373
- except Exception as e:
374
- print(f"[WORKER] Failed to eval last expression '{last[:50]}...': {e}", file=sys.stderr, flush=True)
579
+ except Exception:
580
+ pass
375
581
 
376
582
  _capture_matplotlib(pub, cell_index)
377
583
  except KeyboardInterrupt:
@@ -384,14 +590,20 @@ def worker_main():
384
590
  sys.stdout, sys.stderr = old_out, old_err
385
591
  exec_count += 1
386
592
  duration_ms = f"{(time.time()-start)*1000:.1f}ms"
593
+ print(f"[WORKER] Sending completion messages (Python): status={status}, error={error_payload is not None}", file=sys.stderr, flush=True)
387
594
  if error_payload:
388
595
  pub.send_json({'type': 'execution_error', 'cell_index': cell_index, 'error': error_payload})
596
+ print(f"[WORKER] Sent execution_error", file=sys.stderr, flush=True)
389
597
  pub.send_json({'type': 'execution_complete', 'cell_index': cell_index, 'result': {'status': status, 'execution_count': exec_count, 'execution_time': duration_ms, 'outputs': [], 'error': error_payload}})
598
+ print(f"[WORKER] Sent execution_complete", file=sys.stderr, flush=True)
390
599
  rep.send_json({'ok': True, 'pid': os.getpid()})
391
- current_cell = None
600
+
601
+ print(f"[WORKER] Clearing current_cell_ref[0] (was {current_cell_ref[0]})", file=sys.stderr, flush=True)
602
+ current_cell_ref[0] = None
603
+ print(f"[WORKER] Confirmed current_cell_ref[0] = {current_cell_ref[0]}", file=sys.stderr, flush=True)
392
604
 
393
605
  try:
394
- rep.close(0); pub.close(0)
606
+ rep.close(0); pub.close(0); ctrl.close(0)
395
607
  except Exception:
396
608
  pass
397
609
  try: