diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 20:30:20 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 20:30:20 +0200 |
| commit | e34c1203500451e65f0439435df55a2fa4c0f4ab (patch) | |
| tree | f00d46d6e6535cc0fd60a2e99066117b98de0b7c | |
| parent | bc013618059fb5b4d08f23fb039252918c1aec99 (diff) | |
fix(chat): three concurrency holes found reviewing my own patch (v4.6.77)
1. chat.py renamed the temp file AFTER releasing the flock, so two writers could each
read the pre-replace snapshot and the later rename silently dropped the earlier
message. os.replace now happens inside the lock.
2. The agent cursor was global. After hermes-reset it pointed past the end of the new
empty thread, so every read returned nothing — the agent would think it had been
ignored. Key is now per thread and reset clears every chat cursor.
3. chat-relay.sh had no overlap guard: a slow agent run (up to 5 min) could collide with
the next tick and answer the same thread twice. flock -n on /tmp/chat-relay.lock.
Verified: stale cursor 'webx11_chat_agent_cursor_oldthread00000' set to 9999999999999,
hermes-reset -> zero cursor keys remain, read returns {messages:[], note:'no new messages...'}.
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 9 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 5 | ||||
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 4 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rwxr-xr-x | scripts/chat.py | 4 |
6 files changed, 21 insertions, 9 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.76"></script> +<script src="modules/core/core-bundle.js?v=v4.6.77"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.76"></script> +<script src="modules/core/module-loader.js?v=v4.6.77"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.76"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.77"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 697b5ec..90e2593 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -11098,8 +11098,9 @@ buildSystemPrompt() { // runs every 30 s and advances the shared cursor, so a read that used it // returned nothing — the answer was printed for the human and the model // never saw it. Two readers, two cursors. First read is from 0. + const acKey = 'webx11_chat_agent_cursor_' + (st.thread || 'new'); let ac = 0; - try { ac = parseInt(localStorage.getItem('webx11_chat_agent_cursor') || '0', 10) || 0; } catch (e) { ac = 0; } + try { ac = parseInt(localStorage.getItem(acKey) || '0', 10) || 0; } catch (e) { ac = 0; } const resp = await fetch( '/spaces/newos4/api/chat/poll.php' + (ac ? '?since=' + ac : ''), { headers: { 'X-Chat-Token': st.token } }); @@ -11107,7 +11108,7 @@ buildSystemPrompt() { if (!resp.ok || !j || !j.success) { return JSON.stringify({ error: (j && j.error) || ('HTTP ' + resp.status) }); } - if (j.cursor) { try { localStorage.setItem('webx11_chat_agent_cursor', String(j.cursor)); } catch (e) {} } + if (j.cursor) { try { localStorage.setItem(acKey, String(j.cursor)); } catch (e) {} } const msgs = (j.messages || []).slice(-10).map(m => ({ author: m.author, ts: m.ts, text: m.text })); @@ -20789,6 +20790,10 @@ class DebugSupervisorModule { try { localStorage.removeItem('webx11_chat_token'); localStorage.removeItem('webx11_chat_state'); + // a stale agent cursor would point past the end of the new, empty thread + Object.keys(localStorage) + .filter(k => k.indexOf('webx11_chat_agent_cursor') === 0) + .forEach(k => localStorage.removeItem(k)); this.terminal.print('🔄 Chat token rotated — a new thread starts with your next message', 'success'); } catch (e) { this.terminal.print('❌ ' + e.message, 'error'); diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index cefff5d..c3ebe1f 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -2711,8 +2711,9 @@ buildSystemPrompt() { // runs every 30 s and advances the shared cursor, so a read that used it // returned nothing — the answer was printed for the human and the model // never saw it. Two readers, two cursors. First read is from 0. + const acKey = 'webx11_chat_agent_cursor_' + (st.thread || 'new'); let ac = 0; - try { ac = parseInt(localStorage.getItem('webx11_chat_agent_cursor') || '0', 10) || 0; } catch (e) { ac = 0; } + try { ac = parseInt(localStorage.getItem(acKey) || '0', 10) || 0; } catch (e) { ac = 0; } const resp = await fetch( '/spaces/newos4/api/chat/poll.php' + (ac ? '?since=' + ac : ''), { headers: { 'X-Chat-Token': st.token } }); @@ -2720,7 +2721,7 @@ buildSystemPrompt() { if (!resp.ok || !j || !j.success) { return JSON.stringify({ error: (j && j.error) || ('HTTP ' + resp.status) }); } - if (j.cursor) { try { localStorage.setItem('webx11_chat_agent_cursor', String(j.cursor)); } catch (e) {} } + if (j.cursor) { try { localStorage.setItem(acKey, String(j.cursor)); } catch (e) {} } const msgs = (j.messages || []).slice(-10).map(m => ({ author: m.author, ts: m.ts, text: m.text })); diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 6768df1..79c9acc 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -173,6 +173,10 @@ class DebugSupervisorModule { try { localStorage.removeItem('webx11_chat_token'); localStorage.removeItem('webx11_chat_state'); + // a stale agent cursor would point past the end of the new, empty thread + Object.keys(localStorage) + .filter(k => k.indexOf('webx11_chat_agent_cursor') === 0) + .forEach(k => localStorage.removeItem(k)); this.terminal.print('🔄 Chat token rotated — a new thread starts with your next message', 'success'); } catch (e) { this.terminal.print('❌ ' + e.message, 'error'); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index e52a846..b177626 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.76'; + const LATEST_VER = 'v4.6.77'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/scripts/chat.py b/scripts/chat.py index e19fb6b..5b44b64 100755 --- a/scripts/chat.py +++ b/scripts/chat.py @@ -78,9 +78,11 @@ def reply(thread, text): with open(tmp, 'w') as fh: json.dump(msgs, fh, ensure_ascii=False) os.chmod(tmp, 0o664) + # replace INSIDE the lock: doing it after unlock lets a second writer read + # the pre-replace snapshot and rename over this message (lost update). + os.replace(tmp, path) finally: fcntl.flock(lock, fcntl.LOCK_UN) - os.replace(tmp, path) # atomic: a poll never sees a half-written file return msg |