HongzeFu commited on
Commit
cd45b78
·
1 Parent(s): 6d95d0c
gradio-web/config.py CHANGED
@@ -19,9 +19,11 @@ RIGHT_TOP_LOG_SCALE = 1
19
  UI_GLOBAL_FONT_SIZE = "24px"
20
 
21
  # Session / queue 配置
22
- SESSION_TIMEOUT = 39999 # 30秒无用户主动操作后,交由 gr.State TTL 自动回收 session
23
- SESSION_CONCURRENCY_ID = "session_slots"
24
  SESSION_CONCURRENCY_LIMIT = 2
 
 
25
 
26
  # 兜底执行次数配置
27
  EXECUTE_LIMIT_OFFSET = 4 # 兜底执行次数 = non_demonstration_task_length + EXECUTE_LIMIT_OFFSET
 
19
  UI_GLOBAL_FONT_SIZE = "24px"
20
 
21
  # Session / queue 配置
22
+ SESSION_TIMEOUT = 20 # 30秒无用户主动操作后,交由 gr.State TTL 自动回收 session
23
+ SESSION_CONCURRENCY_ID = "session_actions"
24
  SESSION_CONCURRENCY_LIMIT = 2
25
+ SESSION_INIT_CONCURRENCY_ID = "session_init"
26
+ SESSION_INIT_CONCURRENCY_LIMIT = 2
27
 
28
  # 兜底执行次数配置
29
  EXECUTE_LIMIT_OFFSET = 4 # 兜底执行次数 = non_demonstration_task_length + EXECUTE_LIMIT_OFFSET
gradio-web/gradio_callbacks.py CHANGED
@@ -26,6 +26,7 @@ from state_manager import (
26
  set_play_button_clicked,
27
  set_task_start_time,
28
  set_ui_phase,
 
29
  )
30
  from image_utils import draw_marker, save_video, concatenate_frames_horizontally
31
  from user_manager import user_manager
@@ -75,6 +76,13 @@ def touch_session(uid):
75
  return uid if uid and get_session(uid) is not None else None
76
 
77
 
 
 
 
 
 
 
 
78
  def cleanup_user_session(uid):
79
  """Unified cleanup entry for gr.State TTL deletion and unload hooks."""
80
  if not uid:
@@ -687,6 +695,10 @@ def init_session_and_load_task(uid):
687
  if get_session(uid) is None:
688
  create_session(uid)
689
 
 
 
 
 
690
  LOGGER.debug("init_session_and_load_task: init_session uid=%s", _uid_for_log(uid))
691
  success, msg, status = user_manager.init_session(uid)
692
  LOGGER.debug(
@@ -703,6 +715,42 @@ def init_session_and_load_task(uid):
703
  return _load_status_task(uid, status)
704
 
705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  def load_next_task_wrapper(uid):
707
  """Move to a random episode within the same env and reload task."""
708
  if not uid or get_session(uid) is None:
@@ -977,13 +1025,20 @@ def init_app(request: gr.Request):
977
  uid = getattr(request, "session_hash", None)
978
  LOGGER.info("init_app: session_hash=%s", _uid_for_log(uid))
979
  LOGGER.info("init_app: created uid=%s", _uid_for_log(uid))
980
- result = init_session_and_load_task(uid)
981
- LOGGER.debug("init_app: init_session_and_load_task returned %s outputs", len(result))
 
 
 
 
982
  return result
983
  except Exception as e:
984
  LOGGER.exception("init_app exception")
985
  # Return a safe fallback that hides the loading overlay and shows error
986
- return _task_load_failed_response("", _ui_text("errors", "init_failed", error=e))
 
 
 
987
 
988
 
989
  def precheck_execute_inputs(uid, option_idx, coords_str):
 
26
  set_play_button_clicked,
27
  set_task_start_time,
28
  set_ui_phase,
29
+ try_create_session,
30
  )
31
  from image_utils import draw_marker, save_video, concatenate_frames_horizontally
32
  from user_manager import user_manager
 
76
  return uid if uid and get_session(uid) is not None else None
77
 
78
 
79
+ def touch_session_or_preserve_pending(uid, init_pending=False):
80
+ """Keep pending init uid alive until a real session is created."""
81
+ if init_pending:
82
+ return uid
83
+ return touch_session(uid)
84
+
85
+
86
  def cleanup_user_session(uid):
87
  """Unified cleanup entry for gr.State TTL deletion and unload hooks."""
88
  if not uid:
 
695
  if get_session(uid) is None:
696
  create_session(uid)
697
 
698
+ return _load_initialized_session_task(uid)
699
+
700
+
701
+ def _load_initialized_session_task(uid):
702
  LOGGER.debug("init_session_and_load_task: init_session uid=%s", _uid_for_log(uid))
703
  success, msg, status = user_manager.init_session(uid)
704
  LOGGER.debug(
 
715
  return _load_status_task(uid, status)
716
 
717
 
718
+ def try_init_session_and_load_task(uid):
719
+ """Try to initialize the session without blocking on a full slot queue."""
720
+ if not uid:
721
+ return {
722
+ "status": "ready",
723
+ "load_result": _task_load_failed_response(uid, _session_error_text()),
724
+ }
725
+
726
+ if get_session(uid) is None:
727
+ ready, queue_position = try_create_session(uid)
728
+ if not ready:
729
+ LOGGER.info(
730
+ "try_init_session_and_load_task pending uid=%s queue_position=%s",
731
+ _uid_for_log(uid),
732
+ queue_position,
733
+ )
734
+ return {
735
+ "status": "pending",
736
+ "uid": uid,
737
+ "queue_position": queue_position,
738
+ }
739
+
740
+ return {
741
+ "status": "ready",
742
+ "load_result": _load_initialized_session_task(uid),
743
+ }
744
+
745
+
746
+ def resume_pending_init(uid, init_pending=False, request: gr.Request | None = None):
747
+ """Retry a previously pending init attempt."""
748
+ if not init_pending:
749
+ return {"status": "skip"}
750
+ effective_uid = uid or getattr(request, "session_hash", None)
751
+ return try_init_session_and_load_task(effective_uid)
752
+
753
+
754
  def load_next_task_wrapper(uid):
755
  """Move to a random episode within the same env and reload task."""
756
  if not uid or get_session(uid) is None:
 
1025
  uid = getattr(request, "session_hash", None)
1026
  LOGGER.info("init_app: session_hash=%s", _uid_for_log(uid))
1027
  LOGGER.info("init_app: created uid=%s", _uid_for_log(uid))
1028
+ result = try_init_session_and_load_task(uid)
1029
+ if isinstance(result, dict) and result.get("status") == "ready":
1030
+ LOGGER.debug(
1031
+ "init_app: init_session_and_load_task returned %s outputs",
1032
+ len(result.get("load_result", ()) or ()),
1033
+ )
1034
  return result
1035
  except Exception as e:
1036
  LOGGER.exception("init_app exception")
1037
  # Return a safe fallback that hides the loading overlay and shows error
1038
+ return {
1039
+ "status": "ready",
1040
+ "load_result": _task_load_failed_response("", _ui_text("errors", "init_failed", error=e)),
1041
+ }
1042
 
1043
 
1044
  def precheck_execute_inputs(uid, option_idx, coords_str):
gradio-web/state_manager.py CHANGED
@@ -16,6 +16,7 @@ LOGGER = logging.getLogger("robomme.state_manager")
16
  # --- 全局会话存储 ---
17
  GLOBAL_SESSIONS = {}
18
  ACTIVE_SESSION_SLOTS = set()
 
19
 
20
  # --- 任务索引存储(用于进度显示) ---
21
  TASK_INDEX_MAP = {} # {uid: {"task_index": int, "total_tasks": int}}
@@ -42,6 +43,88 @@ def get_session(uid):
42
  return GLOBAL_SESSIONS.get(uid)
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def reserve_session_slot(uid):
46
  """
47
  Block until a session slot is available for this uid.
@@ -55,22 +138,17 @@ def reserve_session_slot(uid):
55
  from config import SESSION_CONCURRENCY_LIMIT
56
 
57
  with _session_slot_condition:
58
- if uid in ACTIVE_SESSION_SLOTS:
59
- return
60
- while len(ACTIVE_SESSION_SLOTS) >= int(SESSION_CONCURRENCY_LIMIT):
 
61
  LOGGER.info(
62
- "reserve_session_slot waiting uid=%s active_slots=%s limit=%s",
63
  uid,
 
64
  len(ACTIVE_SESSION_SLOTS),
65
- SESSION_CONCURRENCY_LIMIT,
66
  )
67
  _session_slot_condition.wait(timeout=0.1)
68
- ACTIVE_SESSION_SLOTS.add(uid)
69
- LOGGER.info(
70
- "reserve_session_slot acquired uid=%s active_slots=%s",
71
- uid,
72
- len(ACTIVE_SESSION_SLOTS),
73
- )
74
 
75
 
76
  def release_session_slot(uid):
@@ -80,6 +158,7 @@ def release_session_slot(uid):
80
  with _session_slot_condition:
81
  if uid in ACTIVE_SESSION_SLOTS:
82
  ACTIVE_SESSION_SLOTS.remove(uid)
 
83
  LOGGER.info(
84
  "release_session_slot uid=%s active_slots=%s",
85
  uid,
@@ -88,6 +167,38 @@ def release_session_slot(uid):
88
  _session_slot_condition.notify_all()
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def create_session(uid):
92
  """
93
  为指定 session key 创建 ProcessSessionProxy。
@@ -220,6 +331,8 @@ def cleanup_session(uid):
220
 
221
  with _state_lock:
222
  session = GLOBAL_SESSIONS.pop(uid, None)
 
 
223
  TASK_INDEX_MAP.pop(uid, None)
224
  UI_PHASE_MAP.pop(uid, None)
225
  PLAY_BUTTON_CLICKED.pop(uid, None)
 
16
  # --- 全局会话存储 ---
17
  GLOBAL_SESSIONS = {}
18
  ACTIVE_SESSION_SLOTS = set()
19
+ WAITING_SESSION_QUEUE = []
20
 
21
  # --- 任务索引存储(用于进度显示) ---
22
  TASK_INDEX_MAP = {} # {uid: {"task_index": int, "total_tasks": int}}
 
43
  return GLOBAL_SESSIONS.get(uid)
44
 
45
 
46
+ def _cleanup_waiting_session_queue_locked():
47
+ if not WAITING_SESSION_QUEUE:
48
+ return
49
+
50
+ cleaned_queue = []
51
+ seen = set()
52
+ for queued_uid in WAITING_SESSION_QUEUE:
53
+ if not queued_uid or queued_uid in seen:
54
+ continue
55
+ if queued_uid in ACTIVE_SESSION_SLOTS:
56
+ continue
57
+ if queued_uid in GLOBAL_SESSIONS:
58
+ continue
59
+ seen.add(queued_uid)
60
+ cleaned_queue.append(queued_uid)
61
+
62
+ WAITING_SESSION_QUEUE[:] = cleaned_queue
63
+
64
+
65
+ def _ensure_waiting_session_locked(uid):
66
+ _cleanup_waiting_session_queue_locked()
67
+ if uid not in WAITING_SESSION_QUEUE:
68
+ WAITING_SESSION_QUEUE.append(uid)
69
+ return WAITING_SESSION_QUEUE.index(uid) + 1
70
+
71
+
72
+ def _try_reserve_session_slot_locked(uid, session_concurrency_limit):
73
+ if uid in ACTIVE_SESSION_SLOTS:
74
+ return True, 0
75
+
76
+ _cleanup_waiting_session_queue_locked()
77
+
78
+ if len(ACTIVE_SESSION_SLOTS) < int(session_concurrency_limit):
79
+ if WAITING_SESSION_QUEUE and WAITING_SESSION_QUEUE[0] != uid:
80
+ queue_position = _ensure_waiting_session_locked(uid)
81
+ LOGGER.debug(
82
+ "try_reserve_session_slot delayed uid=%s queue_position=%s active_slots=%s limit=%s",
83
+ uid,
84
+ queue_position,
85
+ len(ACTIVE_SESSION_SLOTS),
86
+ session_concurrency_limit,
87
+ )
88
+ return False, queue_position
89
+
90
+ if WAITING_SESSION_QUEUE and WAITING_SESSION_QUEUE[0] == uid:
91
+ WAITING_SESSION_QUEUE.pop(0)
92
+
93
+ ACTIVE_SESSION_SLOTS.add(uid)
94
+ LOGGER.info(
95
+ "try_reserve_session_slot acquired uid=%s active_slots=%s",
96
+ uid,
97
+ len(ACTIVE_SESSION_SLOTS),
98
+ )
99
+ return True, 0
100
+
101
+ queue_position = _ensure_waiting_session_locked(uid)
102
+ LOGGER.info(
103
+ "try_reserve_session_slot queued uid=%s queue_position=%s active_slots=%s limit=%s",
104
+ uid,
105
+ queue_position,
106
+ len(ACTIVE_SESSION_SLOTS),
107
+ session_concurrency_limit,
108
+ )
109
+ return False, queue_position
110
+
111
+
112
+ def try_reserve_session_slot(uid):
113
+ """
114
+ Try to reserve a session slot without blocking.
115
+
116
+ Returns:
117
+ tuple[bool, int]: (acquired, queue_position)
118
+ """
119
+ if not uid:
120
+ raise ValueError("Session uid cannot be empty")
121
+
122
+ from config import SESSION_CONCURRENCY_LIMIT
123
+
124
+ with _session_slot_condition:
125
+ return _try_reserve_session_slot_locked(uid, SESSION_CONCURRENCY_LIMIT)
126
+
127
+
128
  def reserve_session_slot(uid):
129
  """
130
  Block until a session slot is available for this uid.
 
138
  from config import SESSION_CONCURRENCY_LIMIT
139
 
140
  with _session_slot_condition:
141
+ while True:
142
+ acquired, queue_position = _try_reserve_session_slot_locked(uid, SESSION_CONCURRENCY_LIMIT)
143
+ if acquired:
144
+ return
145
  LOGGER.info(
146
+ "reserve_session_slot waiting uid=%s queue_position=%s active_slots=%s",
147
  uid,
148
+ queue_position,
149
  len(ACTIVE_SESSION_SLOTS),
 
150
  )
151
  _session_slot_condition.wait(timeout=0.1)
 
 
 
 
 
 
152
 
153
 
154
  def release_session_slot(uid):
 
158
  with _session_slot_condition:
159
  if uid in ACTIVE_SESSION_SLOTS:
160
  ACTIVE_SESSION_SLOTS.remove(uid)
161
+ _cleanup_waiting_session_queue_locked()
162
  LOGGER.info(
163
  "release_session_slot uid=%s active_slots=%s",
164
  uid,
 
167
  _session_slot_condition.notify_all()
168
 
169
 
170
+ def try_create_session(uid):
171
+ """
172
+ Try to create a ProcessSessionProxy without blocking on session slot wait.
173
+
174
+ Returns:
175
+ tuple[bool, int]: (ready, queue_position)
176
+ """
177
+ if not uid:
178
+ raise ValueError("Session uid cannot be empty")
179
+
180
+ with _state_lock:
181
+ if GLOBAL_SESSIONS.get(uid) is not None:
182
+ return True, 0
183
+
184
+ acquired, queue_position = try_reserve_session_slot(uid)
185
+ if not acquired:
186
+ return False, queue_position
187
+
188
+ try:
189
+ with _state_lock:
190
+ session = GLOBAL_SESSIONS.get(uid)
191
+ if session is None:
192
+ session = ProcessSessionProxy()
193
+ GLOBAL_SESSIONS[uid] = session
194
+ except Exception:
195
+ release_session_slot(uid)
196
+ raise
197
+
198
+ LOGGER.info("try_create_session uid=%s total_sessions=%s", uid, len(GLOBAL_SESSIONS))
199
+ return True, 0
200
+
201
+
202
  def create_session(uid):
203
  """
204
  为指定 session key 创建 ProcessSessionProxy。
 
331
 
332
  with _state_lock:
333
  session = GLOBAL_SESSIONS.pop(uid, None)
334
+ if uid in WAITING_SESSION_QUEUE:
335
+ WAITING_SESSION_QUEUE[:] = [queued_uid for queued_uid in WAITING_SESSION_QUEUE if queued_uid != uid]
336
  TASK_INDEX_MAP.pop(uid, None)
337
  UI_PHASE_MAP.pop(uid, None)
338
  PLAY_BUTTON_CLICKED.pop(uid, None)
gradio-web/test/test_queue_session_limit_e2e.py CHANGED
@@ -106,6 +106,46 @@ def _read_progress_overlay_snapshot(page) -> dict[str, float | bool | None]:
106
  )
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def _mount_demo(demo):
110
  port = _free_port()
111
  host = "127.0.0.1"
@@ -141,7 +181,7 @@ def test_gradio_queue_respects_configured_limit_on_init_load(monkeypatch):
141
  with sync_playwright() as p:
142
  browser = p.chromium.launch(headless=True)
143
  pages = []
144
- total_pages = int(config.SESSION_CONCURRENCY_LIMIT) + 1
145
  for _ in range(total_pages):
146
  page = browser.new_page(viewport={"width": 1280, "height": 900})
147
  page.goto(root_url, wait_until="domcontentloaded")
@@ -150,26 +190,35 @@ def test_gradio_queue_respects_configured_limit_on_init_load(monkeypatch):
150
 
151
  def _queue_snapshot_ready():
152
  progress_texts = [_read_progress_text(page) for page in pages]
153
- first_four_ready = all(
154
  text and config.UI_TEXT["progress"]["episode_loading"] in text
155
- for text in progress_texts[: config.SESSION_CONCURRENCY_LIMIT]
156
  )
157
- queued_text = progress_texts[-1] or ""
158
- queued_ready = (
159
- config.UI_TEXT["progress"]["queue_wait"] in queued_text
160
- and "queue:" in queued_text.lower()
 
161
  )
162
- return first_four_ready and queued_ready
163
 
164
  _wait_until(_queue_snapshot_ready, timeout_s=10.0)
165
- active_pages = [_read_progress_text(page) or "" for page in pages[: config.SESSION_CONCURRENCY_LIMIT]]
166
- queued_text = _read_progress_text(pages[-1]) or ""
167
-
168
- assert all(config.UI_TEXT["progress"]["episode_loading"] in text for text in active_pages)
169
- assert config.UI_TEXT["progress"]["queue_wait"] in queued_text
170
- assert "queue:" in queued_text.lower()
 
 
 
 
 
 
 
 
171
  assert pages[0].evaluate("() => !!document.getElementById('loading_overlay_group')") is False
172
- overlay_snapshot = _read_progress_overlay_snapshot(pages[0])
173
  assert overlay_snapshot["present"] is True
174
  assert overlay_snapshot["width"] and overlay_snapshot["width"] > 0
175
  assert overlay_snapshot["height"] and overlay_snapshot["height"] >= 400
@@ -199,6 +248,10 @@ def test_gradio_state_ttl_cleans_up_idle_session(monkeypatch):
199
  def __init__(self, uid):
200
  self.uid = uid
201
 
 
 
 
 
202
  def close(self):
203
  closed.append(self.uid)
204
 
@@ -423,18 +476,18 @@ def test_late_user_waits_for_active_session_slot_release(monkeypatch):
423
  page3.goto(root_url, wait_until="domcontentloaded")
424
 
425
  _wait_until(
426
- lambda: (_read_progress_text(page3) or "").startswith(config.UI_TEXT["progress"]["episode_loading"]),
427
  timeout_s=10.0,
428
  )
429
  time.sleep(1.0)
430
  assert len(state_manager.GLOBAL_SESSIONS) == config.SESSION_CONCURRENCY_LIMIT
431
  assert len(state_manager.ACTIVE_SESSION_SLOTS) == config.SESSION_CONCURRENCY_LIMIT
432
- assert _read_progress_text(page3) is not None
433
 
434
  page1.close()
435
 
436
  _wait_until(lambda: len(closed) >= 1, timeout_s=10.0)
437
- _wait_until(lambda: _read_progress_text(page3) is None, timeout_s=15.0)
438
  _wait_until(lambda: len(state_manager.GLOBAL_SESSIONS) == config.SESSION_CONCURRENCY_LIMIT, timeout_s=10.0)
439
  _wait_until(lambda: len(state_manager.ACTIVE_SESSION_SLOTS) == config.SESSION_CONCURRENCY_LIMIT, timeout_s=10.0)
440
 
@@ -443,3 +496,188 @@ def test_late_user_waits_for_active_session_slot_release(monkeypatch):
443
  server.should_exit = True
444
  thread.join(timeout=10)
445
  demo.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  )
107
 
108
 
109
+ def _read_session_wait_overlay_snapshot(page) -> dict[str, float | bool | None]:
110
+ return page.evaluate(
111
+ """() => {
112
+ const host = document.getElementById('native_progress_host');
113
+ if (!host) {
114
+ return { present: false, visible: false, width: null, height: null, background: null, content: null };
115
+ }
116
+ const markdown = host.querySelector('[data-testid="markdown"]');
117
+ const prose = markdown ? markdown.querySelector('.prose, .md') || markdown : null;
118
+ const text = prose ? ((prose.innerText || prose.textContent || '').trim()) : '';
119
+ const rect = host.getBoundingClientRect();
120
+ const style = getComputedStyle(host);
121
+ return {
122
+ present: true,
123
+ visible: style.display !== 'none' && rect.width > 0 && rect.height > 0 && text.length > 0,
124
+ width: rect.width,
125
+ height: rect.height,
126
+ background: style.backgroundColor || null,
127
+ content: text || null,
128
+ };
129
+ }"""
130
+ )
131
+
132
+
133
+ def _read_log_output_value(page) -> str | None:
134
+ return page.evaluate(
135
+ """() => {
136
+ const root = document.getElementById('log_output');
137
+ if (!root) return null;
138
+ const field = root.querySelector('textarea, input');
139
+ if (field && typeof field.value === 'string') {
140
+ const value = field.value.trim();
141
+ return value || null;
142
+ }
143
+ const value = (root.textContent || '').trim();
144
+ return value || null;
145
+ }"""
146
+ )
147
+
148
+
149
  def _mount_demo(demo):
150
  port = _free_port()
151
  host = "127.0.0.1"
 
181
  with sync_playwright() as p:
182
  browser = p.chromium.launch(headless=True)
183
  pages = []
184
+ total_pages = int(config.SESSION_INIT_CONCURRENCY_LIMIT) + 2
185
  for _ in range(total_pages):
186
  page = browser.new_page(viewport={"width": 1280, "height": 900})
187
  page.goto(root_url, wait_until="domcontentloaded")
 
190
 
191
  def _queue_snapshot_ready():
192
  progress_texts = [_read_progress_text(page) for page in pages]
193
+ processing_ready = all(
194
  text and config.UI_TEXT["progress"]["episode_loading"] in text
195
+ for text in progress_texts[: config.SESSION_INIT_CONCURRENCY_LIMIT]
196
  )
197
+ queued_ready = all(
198
+ text
199
+ and config.UI_TEXT["progress"]["queue_wait"] in text
200
+ and "queue:" in text.lower()
201
+ for text in progress_texts[config.SESSION_INIT_CONCURRENCY_LIMIT :]
202
  )
203
+ return processing_ready and queued_ready
204
 
205
  _wait_until(_queue_snapshot_ready, timeout_s=10.0)
206
+ processing_pages = [
207
+ _read_progress_text(page) or ""
208
+ for page in pages[: config.SESSION_INIT_CONCURRENCY_LIMIT]
209
+ ]
210
+ queued_pages = [
211
+ _read_progress_text(page) or ""
212
+ for page in pages[config.SESSION_INIT_CONCURRENCY_LIMIT :]
213
+ ]
214
+
215
+ assert all(config.UI_TEXT["progress"]["episode_loading"] in text for text in processing_pages)
216
+ assert all(
217
+ config.UI_TEXT["progress"]["queue_wait"] in text and "queue:" in text.lower()
218
+ for text in queued_pages
219
+ )
220
  assert pages[0].evaluate("() => !!document.getElementById('loading_overlay_group')") is False
221
+ overlay_snapshot = _read_progress_overlay_snapshot(pages[-1])
222
  assert overlay_snapshot["present"] is True
223
  assert overlay_snapshot["width"] and overlay_snapshot["width"] > 0
224
  assert overlay_snapshot["height"] and overlay_snapshot["height"] >= 400
 
248
  def __init__(self, uid):
249
  self.uid = uid
250
 
251
+ def get_pil_image(self, use_segmented=False):
252
+ _ = use_segmented
253
+ return Image.new("RGB", (32, 32), color=(10, 20, 30))
254
+
255
  def close(self):
256
  closed.append(self.uid)
257
 
 
476
  page3.goto(root_url, wait_until="domcontentloaded")
477
 
478
  _wait_until(
479
+ lambda: _read_session_wait_overlay_snapshot(page3)["visible"] is True,
480
  timeout_s=10.0,
481
  )
482
  time.sleep(1.0)
483
  assert len(state_manager.GLOBAL_SESSIONS) == config.SESSION_CONCURRENCY_LIMIT
484
  assert len(state_manager.ACTIVE_SESSION_SLOTS) == config.SESSION_CONCURRENCY_LIMIT
485
+ assert _read_session_wait_overlay_snapshot(page3)["visible"] is True
486
 
487
  page1.close()
488
 
489
  _wait_until(lambda: len(closed) >= 1, timeout_s=10.0)
490
+ _wait_until(lambda: _read_session_wait_overlay_snapshot(page3)["visible"] is False, timeout_s=15.0)
491
  _wait_until(lambda: len(state_manager.GLOBAL_SESSIONS) == config.SESSION_CONCURRENCY_LIMIT, timeout_s=10.0)
492
  _wait_until(lambda: len(state_manager.ACTIVE_SESSION_SLOTS) == config.SESSION_CONCURRENCY_LIMIT, timeout_s=10.0)
493
 
 
496
  server.should_exit = True
497
  thread.join(timeout=10)
498
  demo.close()
499
+
500
+
501
+ def test_second_and_later_late_users_show_queue_overlay(monkeypatch):
502
+ config = importlib.reload(importlib.import_module("config"))
503
+ state_manager = importlib.reload(importlib.import_module("state_manager"))
504
+ callbacks = importlib.reload(importlib.import_module("gradio_callbacks"))
505
+ ui_layout = importlib.reload(importlib.import_module("ui_layout"))
506
+
507
+ class _FakeProxy:
508
+ def __init__(self):
509
+ self.env_id = None
510
+ self.episode_idx = None
511
+ self.language_goal = "goal"
512
+ self.available_options = [("pick", 0)]
513
+ self.raw_solve_options = [{"label": "a", "action": "pick", "available": False}]
514
+ self.demonstration_frames = []
515
+
516
+ def load_episode(self, env_id, episode_idx):
517
+ self.env_id = env_id
518
+ self.episode_idx = episode_idx
519
+ return Image.new("RGB", (32, 32), color=(10, 20, 30)), "loaded"
520
+
521
+ def get_pil_image(self, use_segmented=False):
522
+ _ = use_segmented
523
+ return Image.new("RGB", (32, 32), color=(10, 20, 30))
524
+
525
+ def close(self):
526
+ return None
527
+
528
+ monkeypatch.setattr(state_manager, "ProcessSessionProxy", _FakeProxy)
529
+ monkeypatch.setattr(
530
+ callbacks.user_manager,
531
+ "init_session",
532
+ lambda uid: (
533
+ True,
534
+ "ok",
535
+ {"current_task": {"env_id": "BinFill", "episode_idx": 1}, "completed_count": 0},
536
+ ),
537
+ )
538
+ monkeypatch.setattr(callbacks, "get_task_hint", lambda env_id: "")
539
+ monkeypatch.setattr(callbacks, "should_show_demo_video", lambda env_id: False)
540
+
541
+ demo = ui_layout.create_ui_blocks()
542
+ root_url, demo, server, thread = _mount_demo(demo)
543
+
544
+ try:
545
+ with sync_playwright() as p:
546
+ browser = p.chromium.launch(headless=True)
547
+
548
+ active_pages = []
549
+ for _ in range(config.SESSION_CONCURRENCY_LIMIT):
550
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
551
+ page.goto(root_url, wait_until="domcontentloaded")
552
+ active_pages.append(page)
553
+ time.sleep(0.25)
554
+
555
+ for page in active_pages:
556
+ _wait_until(lambda page=page: _read_progress_text(page) is None, timeout_s=15.0)
557
+
558
+ waiting_page = browser.new_page(viewport={"width": 1280, "height": 900})
559
+ waiting_page.goto(root_url, wait_until="domcontentloaded")
560
+
561
+ queued_pages = []
562
+ for _ in range(2):
563
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
564
+ page.goto(root_url, wait_until="domcontentloaded")
565
+ queued_pages.append(page)
566
+ time.sleep(0.25)
567
+
568
+ pending_pages = [waiting_page, *queued_pages]
569
+
570
+ def _pending_pages_ready():
571
+ snapshots = [_read_session_wait_overlay_snapshot(page) for page in pending_pages]
572
+ return all(snapshot["visible"] is True for snapshot in snapshots)
573
+
574
+ _wait_until(_pending_pages_ready, timeout_s=10.0)
575
+
576
+ for page in pending_pages:
577
+ overlay_snapshot = _read_session_wait_overlay_snapshot(page)
578
+ assert overlay_snapshot["present"] is True
579
+ assert overlay_snapshot["width"] and overlay_snapshot["width"] > 0
580
+ assert overlay_snapshot["height"] and overlay_snapshot["height"] >= 400
581
+ assert config.UI_TEXT["progress"]["queue_wait"] in str(overlay_snapshot["content"] or "")
582
+
583
+ browser.close()
584
+ finally:
585
+ server.should_exit = True
586
+ thread.join(timeout=10)
587
+ demo.close()
588
+
589
+
590
+ def test_active_user_execute_is_not_blocked_by_waiting_init_loads(monkeypatch):
591
+ config = importlib.reload(importlib.import_module("config"))
592
+ state_manager = importlib.reload(importlib.import_module("state_manager"))
593
+ callbacks = importlib.reload(importlib.import_module("gradio_callbacks"))
594
+ ui_layout = importlib.reload(importlib.import_module("ui_layout"))
595
+
596
+ class _FakeProxy:
597
+ def __init__(self):
598
+ self.env_id = None
599
+ self.episode_idx = None
600
+ self.language_goal = "goal"
601
+ self.available_options = [("pick", 0)]
602
+ self.raw_solve_options = [{"label": "a", "action": "pick", "available": False}]
603
+ self.demonstration_frames = []
604
+ self.base_frames = [Image.new("RGB", (8, 8), color=(1, 2, 3))]
605
+ self.difficulty = None
606
+ self.seed = None
607
+
608
+ def load_episode(self, env_id, episode_idx):
609
+ self.env_id = env_id
610
+ self.episode_idx = episode_idx
611
+ return Image.new("RGB", (32, 32), color=(10, 20, 30)), "loaded"
612
+
613
+ def get_pil_image(self, use_segmented=False):
614
+ _ = use_segmented
615
+ return Image.new("RGB", (32, 32), color=(10, 20, 30))
616
+
617
+ def execute_action(self, option_idx, click_coords):
618
+ _ = option_idx, click_coords
619
+ time.sleep(0.5)
620
+ return Image.new("RGB", (32, 32), color=(30, 40, 50)), "SUCCESS", False
621
+
622
+ def update_observation(self, use_segmentation=False):
623
+ _ = use_segmentation
624
+ return self.get_pil_image(), ""
625
+
626
+ def close(self):
627
+ return None
628
+
629
+ monkeypatch.setattr(state_manager, "ProcessSessionProxy", _FakeProxy)
630
+ monkeypatch.setattr(
631
+ callbacks.user_manager,
632
+ "init_session",
633
+ lambda uid: (
634
+ True,
635
+ "ok",
636
+ {"current_task": {"env_id": "BinFill", "episode_idx": 1}, "completed_count": 0},
637
+ ),
638
+ )
639
+ monkeypatch.setattr(callbacks, "get_task_hint", lambda env_id: "")
640
+ monkeypatch.setattr(callbacks, "should_show_demo_video", lambda env_id: False)
641
+
642
+ demo = ui_layout.create_ui_blocks()
643
+ root_url, demo, server, thread = _mount_demo(demo)
644
+
645
+ try:
646
+ with sync_playwright() as p:
647
+ browser = p.chromium.launch(headless=True)
648
+
649
+ page1 = browser.new_page(viewport={"width": 1280, "height": 900})
650
+ page1.goto(root_url, wait_until="domcontentloaded")
651
+ _wait_until(lambda: _read_progress_text(page1) is None, timeout_s=15.0)
652
+
653
+ page2 = browser.new_page(viewport={"width": 1280, "height": 900})
654
+ page2.goto(root_url, wait_until="domcontentloaded")
655
+ _wait_until(lambda: _read_progress_text(page2) is None, timeout_s=15.0)
656
+
657
+ waiting_page = browser.new_page(viewport={"width": 1280, "height": 900})
658
+ waiting_page.goto(root_url, wait_until="domcontentloaded")
659
+
660
+ queued_page = browser.new_page(viewport={"width": 1280, "height": 900})
661
+ queued_page.goto(root_url, wait_until="domcontentloaded")
662
+
663
+ _wait_until(lambda: _read_session_wait_overlay_snapshot(waiting_page)["visible"] is True, timeout_s=10.0)
664
+ _wait_until(lambda: _read_session_wait_overlay_snapshot(queued_page)["visible"] is True, timeout_s=10.0)
665
+
666
+ page1.locator("#action_radio input[type='radio']").first.check()
667
+ page1.locator("#exec_btn button, button#exec_btn").first.click()
668
+
669
+ _wait_until(
670
+ lambda: "SUCCESS" in (_read_log_output_value(page1) or ""),
671
+ timeout_s=6.0,
672
+ )
673
+ page1_progress = _read_progress_text(page1) or ""
674
+ assert "queue:" not in page1_progress.lower()
675
+ assert config.UI_TEXT["progress"]["queue_wait"] not in page1_progress
676
+ assert _read_session_wait_overlay_snapshot(waiting_page)["visible"] is True
677
+ assert _read_session_wait_overlay_snapshot(queued_page)["visible"] is True
678
+
679
+ browser.close()
680
+ finally:
681
+ server.should_exit = True
682
+ thread.join(timeout=10)
683
+ demo.close()
gradio-web/ui_layout.py CHANGED
@@ -15,6 +15,8 @@ from config import (
15
  LIVE_OBS_POINT_WAIT_CLASS,
16
  SESSION_CONCURRENCY_ID,
17
  SESSION_CONCURRENCY_LIMIT,
 
 
18
  SESSION_TIMEOUT,
19
  LIVE_OBS_REFRESH_HZ,
20
  POINT_SELECTION_SCALE,
@@ -37,11 +39,13 @@ from gradio_callbacks import (
37
  on_video_end_transition,
38
  precheck_execute_inputs,
39
  refresh_live_obs,
 
40
  restart_episode_wrapper,
41
  switch_env_wrapper,
42
  switch_to_action_phase,
43
  switch_to_execute_phase,
44
  touch_session,
 
45
  )
46
  from user_manager import user_manager
47
 
@@ -403,6 +407,91 @@ PROGRESS_TEXT_REWRITE_JS = f"""
403
  }}
404
  }};
405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  const splitSegments = (text) =>
407
  text
408
  .split("|")
@@ -461,6 +550,7 @@ PROGRESS_TEXT_REWRITE_JS = f"""
461
  const rewriteAll = () => {{
462
  ensureOverlayStyles();
463
  document.querySelectorAll(".progress-text").forEach(rewriteNode);
 
464
  }};
465
 
466
  const scheduleRewrite = () => {{
@@ -534,7 +624,6 @@ CSS = f"""
534
  min-height: 100vh !important;
535
  }}
536
 
537
-
538
  #reference_action_btn button:not(:disabled),
539
  button#reference_action_btn:not(:disabled) {{
540
  background: #1f8b4c !important;
@@ -677,11 +766,13 @@ def _phase_from_updates(main_interface_update, video_phase_update):
677
 
678
  def _with_phase_from_load(load_result):
679
  phase = _phase_from_updates(load_result[1], load_result[14])
680
- return (*load_result, phase)
681
-
682
-
683
- def _skip_load_flow():
684
- return tuple(gr.skip() for _ in range(20))
 
 
685
 
686
 
687
  def _phase_visibility_updates(phase):
@@ -750,9 +841,11 @@ def create_ui_blocks():
750
  delete_callback=cleanup_user_session,
751
  )
752
  ui_phase_state = gr.State(value=PHASE_INIT)
 
753
  current_task_env_state = gr.State(value=None)
754
  suppress_next_option_change_state = gr.State(value=False)
755
  live_obs_timer = gr.Timer(value=1.0 / LIVE_OBS_REFRESH_HZ, active=True)
 
756
 
757
  task_info_box = gr.Textbox(visible=False, elem_id="task_info_box")
758
  progress_info_box = gr.Textbox(visible=False)
@@ -900,12 +993,65 @@ def create_ui_blocks():
900
  task_hint_display,
901
  reference_action_btn,
902
  ui_phase_state,
 
 
 
903
  ]
904
  phase_visibility_outputs = [
905
  video_phase_group,
906
  action_phase_group,
907
  control_panel_group,
908
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
909
 
910
  def _normalize_env_choice(env_value, choices):
911
  if env_value is None:
@@ -949,7 +1095,10 @@ def create_ui_blocks():
949
  )
950
 
951
  def init_app_with_phase(request: gr.Request):
952
- return _with_phase_from_load(init_app(request))
 
 
 
953
 
954
  def load_next_task_with_phase(uid):
955
  return _with_phase_from_load(load_next_task_wrapper(uid))
@@ -994,11 +1143,10 @@ def create_ui_blocks():
994
  fn=maybe_switch_env_with_phase,
995
  inputs=[uid_state, header_task_box, current_task_env_state],
996
  outputs=load_flow_outputs,
997
- concurrency_id=SESSION_CONCURRENCY_ID,
998
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
999
  show_progress="full",
1000
  js=SET_EPISODE_LOAD_MODE_IF_SWITCH_JS,
1001
  show_progress_on=[native_progress_host],
 
1002
  ).then(
1003
  fn=_phase_visibility_updates,
1004
  inputs=[ui_phase_state],
@@ -1035,11 +1183,10 @@ def create_ui_blocks():
1035
  fn=load_next_task_with_phase,
1036
  inputs=[uid_state],
1037
  outputs=load_flow_outputs,
1038
- concurrency_id=SESSION_CONCURRENCY_ID,
1039
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
1040
  show_progress="full",
1041
  js=SET_EPISODE_LOAD_MODE_JS,
1042
  show_progress_on=[native_progress_host],
 
1043
  ).then(
1044
  fn=_phase_visibility_updates,
1045
  inputs=[ui_phase_state],
@@ -1076,11 +1223,10 @@ def create_ui_blocks():
1076
  fn=restart_episode_with_phase,
1077
  inputs=[uid_state],
1078
  outputs=load_flow_outputs,
1079
- concurrency_id=SESSION_CONCURRENCY_ID,
1080
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
1081
  show_progress="full",
1082
  js=SET_EPISODE_LOAD_MODE_JS,
1083
  show_progress_on=[native_progress_host],
 
1084
  ).then(
1085
  fn=_phase_visibility_updates,
1086
  inputs=[ui_phase_state],
@@ -1208,8 +1354,7 @@ def create_ui_blocks():
1208
  fn=on_reference_action,
1209
  inputs=[uid_state, options_radio],
1210
  outputs=[img_display, options_radio, coords_box, log_output, suppress_next_option_change_state],
1211
- concurrency_id=SESSION_CONCURRENCY_ID,
1212
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
1213
  ).then(
1214
  fn=touch_session,
1215
  inputs=[uid_state],
@@ -1252,9 +1397,8 @@ def create_ui_blocks():
1252
  fn=execute_step,
1253
  inputs=[uid_state, options_radio, coords_box],
1254
  outputs=[img_display, log_output, task_info_box, progress_info_box, restart_episode_btn, next_task_btn, exec_btn],
1255
- concurrency_id=SESSION_CONCURRENCY_ID,
1256
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
1257
  show_progress="hidden",
 
1258
  ).then(
1259
  fn=switch_to_action_phase,
1260
  inputs=[uid_state],
@@ -1317,11 +1461,10 @@ def create_ui_blocks():
1317
  fn=init_app_with_phase,
1318
  inputs=[],
1319
  outputs=load_flow_outputs,
1320
- concurrency_id=SESSION_CONCURRENCY_ID,
1321
- concurrency_limit=SESSION_CONCURRENCY_LIMIT,
1322
  show_progress="full",
1323
  js=SET_EPISODE_LOAD_MODE_JS,
1324
  show_progress_on=[native_progress_host],
 
1325
  ).then(
1326
  fn=_phase_visibility_updates,
1327
  inputs=[ui_phase_state],
@@ -1335,8 +1478,8 @@ def create_ui_blocks():
1335
  queue=False,
1336
  show_progress="hidden",
1337
  ).then(
1338
- fn=touch_session,
1339
- inputs=[uid_state],
1340
  outputs=[uid_state],
1341
  queue=False,
1342
  show_progress="hidden",
@@ -1354,6 +1497,32 @@ def create_ui_blocks():
1354
  show_progress="hidden",
1355
  )
1356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1357
  demo.unload(fn=cleanup_current_request_session)
1358
  demo.queue(max_size=None, default_concurrency_limit=None)
1359
 
 
15
  LIVE_OBS_POINT_WAIT_CLASS,
16
  SESSION_CONCURRENCY_ID,
17
  SESSION_CONCURRENCY_LIMIT,
18
+ SESSION_INIT_CONCURRENCY_ID,
19
+ SESSION_INIT_CONCURRENCY_LIMIT,
20
  SESSION_TIMEOUT,
21
  LIVE_OBS_REFRESH_HZ,
22
  POINT_SELECTION_SCALE,
 
39
  on_video_end_transition,
40
  precheck_execute_inputs,
41
  refresh_live_obs,
42
+ resume_pending_init,
43
  restart_episode_wrapper,
44
  switch_env_wrapper,
45
  switch_to_action_phase,
46
  switch_to_execute_phase,
47
  touch_session,
48
+ touch_session_or_preserve_pending,
49
  )
50
  from user_manager import user_manager
51
 
 
407
  }}
408
  }};
409
 
410
+ const resetManualOverlayStyles = (host, markdown, prose) => {{
411
+ host.style.setProperty("pointer-events", "none", "important");
412
+ if (markdown instanceof HTMLElement) {{
413
+ [
414
+ "position",
415
+ "inset",
416
+ "display",
417
+ "align-items",
418
+ "justify-content",
419
+ "padding",
420
+ ].forEach((prop) => markdown.style.removeProperty(prop));
421
+ }}
422
+ if (prose instanceof HTMLElement) {{
423
+ [
424
+ "min-width",
425
+ "max-width",
426
+ "margin",
427
+ "padding",
428
+ "border-radius",
429
+ "background",
430
+ "border",
431
+ "box-shadow",
432
+ "text-align",
433
+ "color",
434
+ "font-size",
435
+ "font-weight",
436
+ "line-height",
437
+ "white-space",
438
+ ].forEach((prop) => prose.style.removeProperty(prop));
439
+ }}
440
+ }};
441
+
442
+ const updateManualWaitOverlay = () => {{
443
+ const host = document.getElementById("native_progress_host");
444
+ if (!(host instanceof HTMLElement)) {{
445
+ return;
446
+ }}
447
+
448
+ const markdown = host.querySelector('[data-testid="markdown"]');
449
+ const prose =
450
+ markdown instanceof HTMLElement
451
+ ? markdown.querySelector(".prose, .md") || markdown
452
+ : null;
453
+ const progressNode = host.querySelector(".progress-text");
454
+ const text =
455
+ prose instanceof HTMLElement
456
+ ? (prose.innerText || prose.textContent || "").trim()
457
+ : "";
458
+ const manualVisible =
459
+ Boolean(text) &&
460
+ !progressNode &&
461
+ text.toLowerCase().includes(queueWaitText.toLowerCase());
462
+
463
+ if (!manualVisible) {{
464
+ resetManualOverlayStyles(host, markdown, prose);
465
+ return;
466
+ }}
467
+
468
+ host.style.setProperty("pointer-events", "auto", "important");
469
+ if (markdown instanceof HTMLElement) {{
470
+ markdown.style.setProperty("position", "fixed", "important");
471
+ markdown.style.setProperty("inset", "0", "important");
472
+ markdown.style.setProperty("display", "flex", "important");
473
+ markdown.style.setProperty("align-items", "center", "important");
474
+ markdown.style.setProperty("justify-content", "center", "important");
475
+ markdown.style.setProperty("padding", "24px", "important");
476
+ }}
477
+ if (prose instanceof HTMLElement) {{
478
+ prose.style.setProperty("min-width", "min(560px, calc(100vw - 48px))", "important");
479
+ prose.style.setProperty("max-width", "calc(100vw - 48px)", "important");
480
+ prose.style.setProperty("margin", "0", "important");
481
+ prose.style.setProperty("padding", "28px 32px", "important");
482
+ prose.style.setProperty("border-radius", "16px", "important");
483
+ prose.style.setProperty("background", "rgba(255, 255, 255, 0.96)", "important");
484
+ prose.style.setProperty("border", "1px solid rgba(15, 23, 42, 0.08)", "important");
485
+ prose.style.setProperty("box-shadow", "0 24px 60px rgba(15, 23, 42, 0.14)", "important");
486
+ prose.style.setProperty("text-align", "center", "important");
487
+ prose.style.setProperty("color", "#0f172a", "important");
488
+ prose.style.setProperty("font-size", "var(--text-lg)", "important");
489
+ prose.style.setProperty("font-weight", "600", "important");
490
+ prose.style.setProperty("line-height", "1.5", "important");
491
+ prose.style.setProperty("white-space", "pre-line", "important");
492
+ }}
493
+ }};
494
+
495
  const splitSegments = (text) =>
496
  text
497
  .split("|")
 
550
  const rewriteAll = () => {{
551
  ensureOverlayStyles();
552
  document.querySelectorAll(".progress-text").forEach(rewriteNode);
553
+ updateManualWaitOverlay();
554
  }};
555
 
556
  const scheduleRewrite = () => {{
 
624
  min-height: 100vh !important;
625
  }}
626
 
 
627
  #reference_action_btn button:not(:disabled),
628
  button#reference_action_btn:not(:disabled) {{
629
  background: #1f8b4c !important;
 
766
 
767
  def _with_phase_from_load(load_result):
768
  phase = _phase_from_updates(load_result[1], load_result[14])
769
+ return (
770
+ *load_result,
771
+ phase,
772
+ False,
773
+ gr.update(value=""),
774
+ gr.update(active=False),
775
+ )
776
 
777
 
778
  def _phase_visibility_updates(phase):
 
841
  delete_callback=cleanup_user_session,
842
  )
843
  ui_phase_state = gr.State(value=PHASE_INIT)
844
+ session_boot_pending_state = gr.State(value=False)
845
  current_task_env_state = gr.State(value=None)
846
  suppress_next_option_change_state = gr.State(value=False)
847
  live_obs_timer = gr.Timer(value=1.0 / LIVE_OBS_REFRESH_HZ, active=True)
848
+ session_init_retry_timer = gr.Timer(value=0.5, active=False)
849
 
850
  task_info_box = gr.Textbox(visible=False, elem_id="task_info_box")
851
  progress_info_box = gr.Textbox(visible=False)
 
993
  task_hint_display,
994
  reference_action_btn,
995
  ui_phase_state,
996
+ session_boot_pending_state,
997
+ native_progress_host,
998
+ session_init_retry_timer,
999
  ]
1000
  phase_visibility_outputs = [
1001
  video_phase_group,
1002
  action_phase_group,
1003
  control_panel_group,
1004
  ]
1005
+ action_queue_kwargs = {
1006
+ "concurrency_id": SESSION_CONCURRENCY_ID,
1007
+ "concurrency_limit": SESSION_CONCURRENCY_LIMIT,
1008
+ }
1009
+ init_queue_kwargs = {
1010
+ "concurrency_id": SESSION_INIT_CONCURRENCY_ID,
1011
+ "concurrency_limit": SESSION_INIT_CONCURRENCY_LIMIT,
1012
+ }
1013
+
1014
+ def _skip_load_flow():
1015
+ return tuple(gr.skip() for _ in range(len(load_flow_outputs)))
1016
+
1017
+ def _pending_init_flow(uid, queue_position):
1018
+ _ = queue_position
1019
+ return (
1020
+ uid,
1021
+ gr.update(visible=True),
1022
+ gr.update(interactive=False),
1023
+ "",
1024
+ gr.update(choices=[], value=None),
1025
+ "",
1026
+ "",
1027
+ gr.update(value=None, visible=False),
1028
+ gr.update(visible=False, interactive=False),
1029
+ "",
1030
+ "",
1031
+ gr.update(interactive=False),
1032
+ gr.update(interactive=False),
1033
+ gr.update(interactive=False),
1034
+ gr.update(visible=False),
1035
+ gr.update(visible=False),
1036
+ gr.update(visible=False),
1037
+ gr.update(value=""),
1038
+ gr.update(interactive=False),
1039
+ PHASE_INIT,
1040
+ True,
1041
+ gr.update(value=f'{UI_TEXT["progress"]["queue_wait"]}\\n\\nqueue: {max(1, int(queue_position or 1))}'),
1042
+ gr.update(active=True),
1043
+ )
1044
+
1045
+ def _coerce_init_load_result(result):
1046
+ if isinstance(result, dict):
1047
+ status = result.get("status")
1048
+ if status == "pending":
1049
+ return _pending_init_flow(result.get("uid"), result.get("queue_position"))
1050
+ if status == "skip":
1051
+ return _skip_load_flow()
1052
+ if status == "ready":
1053
+ return _with_phase_from_load(result.get("load_result"))
1054
+ return _with_phase_from_load(result)
1055
 
1056
  def _normalize_env_choice(env_value, choices):
1057
  if env_value is None:
 
1095
  )
1096
 
1097
  def init_app_with_phase(request: gr.Request):
1098
+ return _coerce_init_load_result(init_app(request))
1099
+
1100
+ def resume_pending_init_with_phase(uid, init_pending, request: gr.Request):
1101
+ return _coerce_init_load_result(resume_pending_init(uid, init_pending, request))
1102
 
1103
  def load_next_task_with_phase(uid):
1104
  return _with_phase_from_load(load_next_task_wrapper(uid))
 
1143
  fn=maybe_switch_env_with_phase,
1144
  inputs=[uid_state, header_task_box, current_task_env_state],
1145
  outputs=load_flow_outputs,
 
 
1146
  show_progress="full",
1147
  js=SET_EPISODE_LOAD_MODE_IF_SWITCH_JS,
1148
  show_progress_on=[native_progress_host],
1149
+ **action_queue_kwargs,
1150
  ).then(
1151
  fn=_phase_visibility_updates,
1152
  inputs=[ui_phase_state],
 
1183
  fn=load_next_task_with_phase,
1184
  inputs=[uid_state],
1185
  outputs=load_flow_outputs,
 
 
1186
  show_progress="full",
1187
  js=SET_EPISODE_LOAD_MODE_JS,
1188
  show_progress_on=[native_progress_host],
1189
+ **action_queue_kwargs,
1190
  ).then(
1191
  fn=_phase_visibility_updates,
1192
  inputs=[ui_phase_state],
 
1223
  fn=restart_episode_with_phase,
1224
  inputs=[uid_state],
1225
  outputs=load_flow_outputs,
 
 
1226
  show_progress="full",
1227
  js=SET_EPISODE_LOAD_MODE_JS,
1228
  show_progress_on=[native_progress_host],
1229
+ **action_queue_kwargs,
1230
  ).then(
1231
  fn=_phase_visibility_updates,
1232
  inputs=[ui_phase_state],
 
1354
  fn=on_reference_action,
1355
  inputs=[uid_state, options_radio],
1356
  outputs=[img_display, options_radio, coords_box, log_output, suppress_next_option_change_state],
1357
+ **action_queue_kwargs,
 
1358
  ).then(
1359
  fn=touch_session,
1360
  inputs=[uid_state],
 
1397
  fn=execute_step,
1398
  inputs=[uid_state, options_radio, coords_box],
1399
  outputs=[img_display, log_output, task_info_box, progress_info_box, restart_episode_btn, next_task_btn, exec_btn],
 
 
1400
  show_progress="hidden",
1401
+ **action_queue_kwargs,
1402
  ).then(
1403
  fn=switch_to_action_phase,
1404
  inputs=[uid_state],
 
1461
  fn=init_app_with_phase,
1462
  inputs=[],
1463
  outputs=load_flow_outputs,
 
 
1464
  show_progress="full",
1465
  js=SET_EPISODE_LOAD_MODE_JS,
1466
  show_progress_on=[native_progress_host],
1467
+ **init_queue_kwargs,
1468
  ).then(
1469
  fn=_phase_visibility_updates,
1470
  inputs=[ui_phase_state],
 
1478
  queue=False,
1479
  show_progress="hidden",
1480
  ).then(
1481
+ fn=touch_session_or_preserve_pending,
1482
+ inputs=[uid_state, session_boot_pending_state],
1483
  outputs=[uid_state],
1484
  queue=False,
1485
  show_progress="hidden",
 
1497
  show_progress="hidden",
1498
  )
1499
 
1500
+ session_init_retry_timer.tick(
1501
+ fn=resume_pending_init_with_phase,
1502
+ inputs=[uid_state, session_boot_pending_state],
1503
+ outputs=load_flow_outputs,
1504
+ show_progress="hidden",
1505
+ **init_queue_kwargs,
1506
+ ).then(
1507
+ fn=_phase_visibility_updates,
1508
+ inputs=[ui_phase_state],
1509
+ outputs=phase_visibility_outputs,
1510
+ queue=False,
1511
+ show_progress="hidden",
1512
+ ).then(
1513
+ fn=sync_header_from_task,
1514
+ inputs=[task_info_box, goal_box],
1515
+ outputs=[header_task_box, header_goal_box, current_task_env_state],
1516
+ queue=False,
1517
+ show_progress="hidden",
1518
+ ).then(
1519
+ fn=touch_session_or_preserve_pending,
1520
+ inputs=[uid_state, session_boot_pending_state],
1521
+ outputs=[uid_state],
1522
+ queue=False,
1523
+ show_progress="hidden",
1524
+ )
1525
+
1526
  demo.unload(fn=cleanup_current_request_session)
1527
  demo.queue(max_size=None, default_concurrency_limit=None)
1528