@@ -387,8 +387,8 @@ class _DaytonaPtySessionEntry:
387387 output_chunks : deque [bytes ] = field (default_factory = deque )
388388 output_lock : asyncio .Lock = field (default_factory = asyncio .Lock )
389389 output_notify : asyncio .Event = field (default_factory = asyncio .Event )
390+ output_closed : asyncio .Event = field (default_factory = asyncio .Event )
390391 last_used : float = field (default_factory = time .monotonic )
391- done : bool = False
392392 exit_code : int | None = None
393393 worker_task : asyncio .Task [None ] | None = None
394394
@@ -755,7 +755,7 @@ async def _on_data(chunk: bytes | str) -> None:
755755 )
756756
757757 yield_time_ms = 10_000 if yield_time_s is None else int (yield_time_s * 1000 )
758- output , original_token_count = await self ._collect_pty_output (
758+ output , original_token_count , output_closed = await self ._collect_pty_output (
759759 entry = entry ,
760760 yield_time_ms = clamp_pty_yield_time_ms (yield_time_ms ),
761761 max_output_tokens = max_output_tokens ,
@@ -765,6 +765,7 @@ async def _on_data(chunk: bytes | str) -> None:
765765 entry = entry ,
766766 output = output ,
767767 original_token_count = original_token_count ,
768+ output_closed = output_closed ,
768769 )
769770
770771 async def _run_pty_waiter (self , entry : _DaytonaPtySessionEntry ) -> None :
@@ -776,7 +777,10 @@ async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None:
776777 except Exception :
777778 pass
778779 finally :
779- entry .done = True
780+ # AsyncPtyHandle.wait() completes only after its WebSocket reader exits.
781+ # That reader awaits every async on_data callback before it can finish,
782+ # so this is Daytona's authoritative output-stream close boundary.
783+ entry .output_closed .set ()
780784 entry .output_notify .set ()
781785
782786 async def _run_session_reader (
@@ -801,11 +805,13 @@ async def _run_session_reader(
801805 cmd = await self ._sandbox .process .get_session_command (session_id , cmd_id )
802806 if cmd .exit_code is not None :
803807 entry .exit_code = int (cmd .exit_code )
804- entry .done = True
805808 except Exception :
806809 pass
807- if not logs_failed :
808- entry .done = True
810+ # Once the log callback stream has returned, or has failed after the
811+ # provider reports a final exit code, this worker is the only output
812+ # producer and no later callback can append bytes.
813+ if not logs_failed or entry .exit_code is not None :
814+ entry .output_closed .set ()
809815 entry .output_notify .set ()
810816
811817 async def pty_write_stdin (
@@ -832,7 +838,7 @@ async def pty_write_stdin(
832838 await asyncio .sleep (0.1 )
833839
834840 yield_time_ms = 250 if yield_time_s is None else int (yield_time_s * 1000 )
835- output , original_token_count = await self ._collect_pty_output (
841+ output , original_token_count , output_closed = await self ._collect_pty_output (
836842 entry = entry ,
837843 yield_time_ms = resolve_pty_write_yield_time_ms (
838844 yield_time_ms = yield_time_ms , input_empty = chars == ""
@@ -845,6 +851,7 @@ async def pty_write_stdin(
845851 entry = entry ,
846852 output = output ,
847853 original_token_count = original_token_count ,
854+ output_closed = output_closed ,
848855 )
849856
850857 async def _finalize_pty_update (
@@ -854,11 +861,12 @@ async def _finalize_pty_update(
854861 entry : _DaytonaPtySessionEntry ,
855862 output : bytes ,
856863 original_token_count : int | None ,
864+ output_closed : bool ,
857865 ) -> PtyExecUpdate :
858- exit_code = entry .exit_code if entry . done else None
866+ exit_code = entry .exit_code if output_closed else None
859867 live_process_id : int | None = process_id
860868
861- if entry . done :
869+ if output_closed :
862870 async with self ._pty_lock :
863871 removed = self ._pty_sessions .pop (process_id , None )
864872 self ._reserved_pty_process_ids .discard (process_id )
@@ -887,12 +895,12 @@ async def _collect_pty_output(
887895 entry : _DaytonaPtySessionEntry ,
888896 yield_time_ms : int ,
889897 max_output_tokens : int | None ,
890- ) -> tuple [bytes , int | None ]:
898+ ) -> tuple [bytes , int | None , bool ]:
891899 return await collect_pty_output (
892900 output_chunks = entry .output_chunks ,
893901 output_lock = entry .output_lock ,
894902 output_notify = entry .output_notify ,
895- is_done = lambda : entry .done ,
903+ is_done = entry .output_closed . is_set ,
896904 yield_time_ms = yield_time_ms ,
897905 max_output_tokens = max_output_tokens ,
898906 )
@@ -901,7 +909,8 @@ def _prune_pty_sessions_if_needed(self) -> _DaytonaPtySessionEntry | None:
901909 if len (self ._pty_sessions ) < PTY_PROCESSES_MAX :
902910 return None
903911 meta : list [tuple [int , float , bool ]] = [
904- (pid , entry .last_used , entry .done ) for pid , entry in self ._pty_sessions .items ()
912+ (pid , entry .last_used , entry .output_closed .is_set ())
913+ for pid , entry in self ._pty_sessions .items ()
905914 ]
906915 pid = process_id_to_prune_from_meta (meta )
907916 if pid is None :
0 commit comments