Skip to content

Commit ed4786f

Browse files
committed
fix(ipc): restore stdout/stderr streaming through daemon relay loop
StreamRedirector was silently dropping all output: the zero-frame buffering refactor assumed StringIO capture but StreamRedirector had no getvalue(), so _captured_stdout was always ''. Meanwhile the relay loop had gutted stream frame forwarding, leaving no path for output to reach the C dispatcher at all. - Add _buf list + getvalue() to StreamRedirector so captured content is available for the COMPLETED frame even when streaming live - Restore else branch in _run_cli relay loop to forward stream frames to C dispatcher via conn.sendall() - Fix StreamRedirector.buffer.write() to decode bytes before passing to write() to avoid double-encoding - Remove dead elif msg.get("status") block that could never be reached (COMPLETED already matched by the string-check if above it) - Switch assign_spec READY wait from direct process.stdout.readline() to stdout_queue.get() — eliminates race with _reader_thread stealing the READY line and causing infinite hang on every cold worker spawn - Add stdout/stderr fields to COMPLETED frame for any output that arrives after the last stream frame flush
1 parent 2ae5956 commit ed4786f

2 files changed

Lines changed: 35 additions & 39 deletions

File tree

src/omnipkg/dispatcher.c

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -627,8 +627,8 @@ static int write_all(int fd, const void *buf, size_t count) {
627627

628628
static int send_json_msg(int sock, const char *json_str) {
629629
uint64_t len = (uint64_t)strlen(json_str);
630-
uint8_t be_len[8];
631-
for (int i = 0; i < 8; i++)
630+
631+
/* Pack header + payload into one buffer → one write_all() → one syscall.
632632
* This eliminates the double kernel wake-up that was costing ~110us:
633633
* Python would wake on the 8-byte header, block waiting for the payload,
634634
* OS context-switched back to C, C sent payload, switched back to Python.
@@ -1015,6 +1015,13 @@ static int try_daemon_cli(const char *target_python, int argc, char **argv,
10151015
if (strncmp(stream_type, "COMPLETED", 9) == 0) {
10161016
int exit_code = 0;
10171017
json_get_int(msg, "exit_code", &exit_code);
1018+
char *out_field;
1019+
if (json_get_raw_str(msg, "stdout", &out_field))
1020+
print_unescaped(out_field, stdout);
1021+
char *err_field;
1022+
if (json_get_raw_str(msg, "stderr", &err_field))
1023+
print_unescaped(err_field, stderr);
1024+
fflush(stdout); fflush(stderr);
10181025
free(msg); sock_close(sock);
10191026
exit(exit_code);
10201027
} else if (strncmp(stream_type, "NEEDS_INPUT", 11) == 0) {

src/omnipkg/isolation/worker_daemon.py

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -991,13 +991,17 @@ def fatal_error(msg, error=None):
991991
class StreamRedirector(io.TextIOBase):
992992
def __init__(self, stream_name):
993993
self.stream_name = stream_name
994+
self._buf = []
994995
def write(self, s):
995996
if isinstance(s, bytes):
996997
s = s.decode('utf-8', 'replace')
998+
self._buf.append(s)
997999
msg = {'stream': self.stream_name, 'data': s}
9981000
_original_stdout.write(json.dumps(msg) + '\\n')
9991001
_original_stdout.flush()
10001002
return len(s)
1003+
def getvalue(self):
1004+
return ''.join(self._buf)
10011005
def isatty(self):
10021006
return setup_data.get('isatty', False)
10031007
def flush(self):
@@ -1006,7 +1010,7 @@ def flush(self):
10061010
def buffer(self):
10071011
class DummyBuffer:
10081012
def write(self_buf, b):
1009-
return self.write(b)
1013+
return self.write(b.decode('utf-8', 'replace') if isinstance(b, bytes) else b)
10101014
def flush(self_buf):
10111015
pass
10121016
return DummyBuffer()
@@ -1061,6 +1065,8 @@ def flush(self_buf):
10611065
sys.stderr.write(traceback.format_exc())
10621066
exit_code = 1
10631067
1068+
_captured_stdout = sys.stdout.getvalue() if hasattr(sys.stdout, 'getvalue') else ''
1069+
_captured_stderr = sys.stderr.getvalue() if hasattr(sys.stderr, 'getvalue') else ''
10641070
sys.stdout = old_stdout
10651071
sys.stderr = old_stderr
10661072
@@ -1092,7 +1098,7 @@ def flush(self_buf):
10921098
_t_done = _wtime.perf_counter()
10931099
sys.stderr.write(f'[WORKER-TIMING] worker total req\u2192COMPLETED: {(_t_done-_t_req)*1000:.2f}ms\\n')
10941100
sys.stderr.flush()
1095-
_original_stdout.write(json.dumps({'status': 'COMPLETED', 'exit_code': exit_code}) + '\\n')
1101+
_original_stdout.write(json.dumps({'status': 'COMPLETED', 'exit_code': exit_code, 'stdout': _captured_stdout, 'stderr': _captured_stderr}) + '\\n')
10961102
_original_stdout.flush()
10971103
10981104
elif _req_type == 'run_uv':
@@ -2815,32 +2821,26 @@ def _start_worker(self):
28152821
self.force_shutdown()
28162822
raise RuntimeError(_('Failed to send setup: {}').format(e))
28172823

2818-
# Wait for READY with timeout
2824+
# Wait for READY with timeout — read from stdout_queue (fed by _reader_thread)
2825+
import queue as _q
28192826
try:
2820-
# Windows-compatible waiting for READY
2827+
deadline = time.time() + 30.0
28212828
ready_line = None
2822-
if IS_WINDOWS:
2823-
result = [None]
2824-
def try_read():
2825-
try: result[0] = self.process.stdout.readline()
2826-
except: pass
2827-
t = threading.Thread(target=try_read, daemon=True)
2828-
t.start()
2829-
t.join(timeout=30.0)
2830-
ready_line = result[0]
2831-
else:
2832-
readable, unused, unused = select.select([self.process.stdout], [], [], 30.0)
2833-
if readable:
2834-
ready_line = self.process.stdout.readline()
2829+
while time.time() < deadline:
2830+
try:
2831+
ready_line = self.stdout_queue.get(timeout=max(0.1, deadline - time.time()))
2832+
break
2833+
except _q.Empty:
2834+
if self.process.poll() is not None:
2835+
raise RuntimeError(f"Worker crashed during startup (check {DAEMON_LOG_FILE})")
2836+
continue
28352837

28362838
if not ready_line:
2837-
# Check if process died
28382839
if self.process.poll() is not None:
28392840
raise RuntimeError(f"Worker crashed during startup (check {DAEMON_LOG_FILE})")
28402841
raise RuntimeError("Worker timeout waiting for READY")
28412842

28422843
ready_line = ready_line.strip()
2843-
28442844
if not ready_line:
28452845
raise RuntimeError("Worker sent blank READY line")
28462846

@@ -2852,7 +2852,6 @@ def try_read():
28522852
if ready_status.get("status") != "READY":
28532853
raise RuntimeError(_('Worker failed to initialize: {}').format(ready_status))
28542854

2855-
# Success!
28562855
self.last_health_check = time.time()
28572856
self.health_check_failures = 0
28582857

@@ -4023,6 +4022,7 @@ def _run_cli(self, req, conn):
40234022
# fatal - breaking on parse failure was causing "Worker failed to
40244023
# send READY" and forcing a cold execv fallback on every call.
40254024
ready_parsed = None
4025+
_drain_count = 0
40264026
ready_line = None
40274027
_deadline = _rt.perf_counter() + 30.0
40284028
while _rt.perf_counter() < _deadline:
@@ -4052,7 +4052,7 @@ def _run_cli(self, req, conn):
40524052
_t_ready = _rt.perf_counter()
40534053
if os.environ.get('OMNIPKG_DEBUG') == '1':
40544054
_elapsed_us = int((_t_ready - locals().get('_t_post_flush', _t_ready)) * 1000000)
4055-
safe_print(f"[RUN-CLI-T] queue.get READY={_elapsed_us}us drained={_drain_count} line={repr(ready_line)}", file=sys.stderr)
4055+
safe_print(f"[RUN-CLI-T] queue.get READY={_elapsed_us}us drained=? line={repr(ready_line)!r}", file=sys.stderr)
40564056

40574057
_t_first_frame = None
40584058
_frame_count = 0
@@ -4143,25 +4143,14 @@ def _run_cli(self, req, conn):
41434143
worker.process.stdin.flush()
41444144
except Exception:
41454145
break
4146-
# COMPLETED / ERROR - forward and finish
4147-
elif msg.get("status") in ("COMPLETED", "ERROR"):
4148-
_t_completed = _rt.perf_counter()
4149-
safe_print(
4150-
f"[RUN-CLI] COMPLETED in {(_t_completed-_t0)*1000:.2f}ms total "
4151-
f"(worker->daemon->client relay)",
4152-
file=sys.stderr
4153-
)
4154-
# Store last result so the recycle gate can read
4155-
# _worker_abi_contaminated before putting this worker
4156-
# back into idle_pools. _run_cli bypasses execute_shm_task
4157-
# so we must set it here directly.
4158-
worker._last_result = msg
4146+
else:
4147+
# stream frame ({"stream":"stdout/stderr","data":"..."}) — forward to C dispatcher
4148+
_frame_count += 1
4149+
raw = line.encode("utf-8")
41594150
try:
4160-
send_json(conn, msg)
4151+
conn.sendall(len(raw).to_bytes(8, "big") + raw)
41614152
except Exception:
41624153
pass
4163-
_completed_cleanly = True
4164-
break
41654154

41664155
except Exception as e:
41674156
try:

0 commit comments

Comments
 (0)