more-compute 0.3.3__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.
- frontend/app/globals.css +9 -7
- frontend/app/layout.tsx +43 -1
- frontend/components/Notebook.tsx +22 -1
- frontend/components/cell/CellButton.tsx +5 -4
- frontend/components/cell/MonacoCell.tsx +42 -21
- frontend/components/output/ErrorDisplay.tsx +14 -1
- frontend/contexts/PodWebSocketContext.tsx +1 -1
- frontend/lib/websocket.ts +2 -2
- frontend/next.config.mjs +2 -2
- kernel_run.py +107 -17
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/METADATA +30 -28
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/RECORD +25 -24
- morecompute/__version__.py +1 -1
- morecompute/execution/executor.py +113 -44
- morecompute/execution/worker.py +319 -107
- morecompute/notebook.py +65 -6
- morecompute/server.py +72 -40
- morecompute/utils/cell_magics.py +35 -4
- morecompute/utils/notebook_converter.py +129 -0
- morecompute/utils/py_percent_parser.py +190 -0
- morecompute/utils/special_commands.py +126 -49
- frontend/.DS_Store +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/WHEEL +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/entry_points.txt +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/top_level.txt +0 -0
morecompute/execution/worker.py
CHANGED
|
@@ -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
|
-
#
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
#
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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 !
|
|
240
|
-
is_special_cmd = 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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 {
|
|
494
|
+
'evalue': f'Command failed with return code {return_code}',
|
|
282
495
|
'traceback': [f'Shell command failed: {shell_cmd}']
|
|
283
496
|
}
|
|
284
|
-
|
|
285
|
-
|
|
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': '
|
|
289
|
-
'evalue': '
|
|
290
|
-
'traceback': [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
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:
|