diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index baf33257..e3db3279 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -16,8 +16,8 @@ |-------|------|--------|----------|-------|--------| | Cursor-Auto | 2026-09-20-p0-hotfixes(Wave 0 文档) | docs/p0-triage-2026-09-20 | - | docs | done | | Cursor-Auto | 2026-09-20-p0-capability-00058 | fix/capability-merge-00058-2026-09-20 | - | infra+chat | done | -| Cursor-Auto | 2026-09-20-p0-idor | fix/authz-idor-hotfixes-2026-09-20 | - | authz | in_progress | -| Cursor-Auto | 2026-09-20-p0-runtime-stability | fix/runtime-panic-races-2026-09-20 | - | taskrun+chat+web | planned | +| Cursor-Auto | 2026-09-20-p0-idor | fix/authz-idor-hotfixes-2026-09-20 | - | authz | done | +| Cursor-Auto | 2026-09-20-p0-runtime-stability | fix/runtime-panic-races-2026-09-20 | - | taskrun+chat+web | in_progress | | Cursor-Auto | 2026-09-20-p0-credentials | fix/credential-hardening-2026-09-20 | - | ops+infra | planned | | Cursor-Auto | 2026-09-20-proposed-authoritative-reports | feature/capability-enablement-impact-plan | - | docgov | done | | Cursor-Auto | 2026-09-20-pi-narrative-clipped-diff | feature/capability-enablement-impact-plan | - | docgov | done | @@ -165,7 +165,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor-Auto | merging | 2026-09-20 | fix/authz-idor-hotfixes | fix/runtime-panic-races → fix/credential-hardening | +| Cursor-Auto | merging | 2026-09-20 | fix/runtime-panic-races | fix/credential-hardening | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/internal/chat/session.go b/internal/chat/session.go index af294a82..b4f4ad1b 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -3,6 +3,7 @@ package chat import ( "context" "fmt" + "log/slog" "strings" "sync" "time" @@ -602,6 +603,11 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str iName := string(coordRes.Intent) bName := string(recRes.Branch) go func() { + defer func() { + if rec := recover(); rec != nil { + slog.Error("chat_turn_memory_panic", "session_id", sessID, "recover", rec) + } + }() bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, _ = s.CtxMgr.RecordTurnMemory(bgCtx, sessID, turnNo, uid, pid, q, ans, iName, bName, isMilestone) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index a81b67c5..ac764b63 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -15,6 +15,7 @@ import ( "chat-agent-system/internal/app/docflow" "chat-agent-system/internal/app/uow" "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/httpx" @@ -252,7 +253,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } s.bindSessionDocumentSummaryReader(sess, user) - resp, err := sess.SendMessage(r.Context(), req.Message) + var resp *chat.ChatResponse + err = sess.WithExecLock(func() error { + var sendErr error + resp, sendErr = sess.SendMessage(r.Context(), req.Message) + return sendErr + }) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error()) return diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index 7566f799..0e0f9e4b 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -262,16 +262,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen for _, s := range parallelGroup { sCopy := s wg.Add(1) - go func() { - defer wg.Done() - if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { - mu.Lock() - if groupErr == nil { - groupErr = err - } - mu.Unlock() - } - }() + go func() { + defer wg.Done() + defer func() { + if rec := recover(); rec != nil { + mu.Lock() + if groupErr == nil { + groupErr = fmt.Errorf("parallel step panic: %v", rec) + } + mu.Unlock() + } + }() + if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { + mu.Lock() + if groupErr == nil { + groupErr = err + } + mu.Unlock() + } + }() } wg.Wait() if groupErr != nil { diff --git a/internal/taskrun/manager.go b/internal/taskrun/manager.go index 4df35073..6e0288cc 100644 --- a/internal/taskrun/manager.go +++ b/internal/taskrun/manager.go @@ -287,17 +287,32 @@ func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, tas }) } - go m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir) + m.runGuarded("taskrun_worker", func() { + m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir) + }) cpy := *record return &cpy, nil } +func (m *Manager) runGuarded(name string, fn func()) { + go func() { + defer func() { + if rec := recover(); rec != nil { + slog.Error("taskrun_goroutine_panic", "name", name, "recover", rec) + } + }() + fn() + }() +} + func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) { defer close(ar.done) hbCtx, hbCancel := context.WithCancel(ctx) defer hbCancel() if m.store != nil && m.store.Available() { - go m.heartbeatLoop(hbCtx, ar.record.RunID) + m.runGuarded("taskrun_heartbeat", func() { + m.heartbeatLoop(hbCtx, ar.record.RunID) + }) } taskReq.RunID = ar.record.RunID @@ -478,9 +493,12 @@ func (m *Manager) appendEvent(ar *activeRun, ev stream.StreamEvent) { ar.events = append(ar.events, stored) runID := ar.record.RunID seq := ar.nextSeq - subs := make([]chan StoredEvent, 0, len(ar.subs)) + // 持锁发送:与 Unsubscribe 的 close 互斥,避免向已关闭 channel 发送 panic。 for _, ch := range ar.subs { - subs = append(subs, ch) + select { + case ch <- stored: + default: + } } ar.mu.Unlock() @@ -489,13 +507,6 @@ func (m *Manager) appendEvent(ar *activeRun, ev stream.StreamEvent) { slog.Warn("taskrun_event_persist_failed", "run_id", runID, "seq", seq, "error", err) } } - - for _, ch := range subs { - select { - case ch <- stored: - default: - } - } } func (m *Manager) GetRun(runID string) (*RunRecord, error) { @@ -651,7 +662,9 @@ func (m *Manager) ResumeInterrupted(parent context.Context, record *RunRecord, r m.runs[cpy.RunID] = ar m.mu.Unlock() - go m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir) + m.runGuarded("taskrun_worker_resume", func() { + m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir) + }) return &cpy, nil } diff --git a/internal/taskrun/subscribe_race_test.go b/internal/taskrun/subscribe_race_test.go new file mode 100644 index 00000000..1959a189 --- /dev/null +++ b/internal/taskrun/subscribe_race_test.go @@ -0,0 +1,48 @@ +package taskrun + +import ( + "sync" + "testing" + "time" + + "chat-agent-system/internal/stream" +) + +func TestSubscribeUnsubscribeRace(t *testing.T) { + m := NewManager(nil, nil) + ar := &activeRun{ + record: &RunRecord{RunID: "run-race-1", Status: StatusRunning}, + subs: make(map[int]chan StoredEvent), + done: make(chan struct{}), + } + m.mu.Lock() + m.runs[ar.record.RunID] = ar + m.mu.Unlock() + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(3) + go func() { + defer wg.Done() + ch, _, err := m.Subscribe(ar.record.RunID, 0) + if err != nil { + return + } + time.Sleep(time.Millisecond) + m.Unsubscribe(ar.record.RunID, ch) + }() + go func() { + defer wg.Done() + m.appendEvent(ar, stream.StreamEvent{Type: stream.EventDelta, Delta: "x"}) + }() + go func() { + defer wg.Done() + ch, _, err := m.Subscribe(ar.record.RunID, 0) + if err != nil { + return + } + m.Unsubscribe(ar.record.RunID, ch) + }() + } + wg.Wait() +} diff --git a/web/src/components/doc/BlockEditorModal.tsx b/web/src/components/doc/BlockEditorModal.tsx index f2249d12..23076a4c 100644 --- a/web/src/components/doc/BlockEditorModal.tsx +++ b/web/src/components/doc/BlockEditorModal.tsx @@ -24,6 +24,7 @@ import { import katex from 'katex'; import 'katex/dist/katex.min.css'; import { renderInlineMarkdown } from '../../engine/renderMarkdown'; +import { escapeHtml } from '../../engine/replyDocument/sanitize'; import { BLOCK_EDITOR_DIALOG_LABEL, BLOCK_EDITOR_SAVE_NAME } from '../../lib/blockEditorDialog'; interface BlockEditorModalProps { @@ -202,8 +203,9 @@ export const BlockEditorModal: React.FC = ({ block, onSav throwOnError: true, }); return `
${rendered}
`; - } catch (err: any) { - return `
LaTeX 语法解析异常: ${err?.message || '公式排版错误'}
${cleanMath}
`; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '公式排版错误'; + return `
LaTeX 语法解析异常: ${escapeHtml(message)}
${escapeHtml(cleanMath)}
`; } } diff --git a/web/src/components/doc/blocks/MathBlockView.tsx b/web/src/components/doc/blocks/MathBlockView.tsx index dd87c9ca..078cf093 100644 --- a/web/src/components/doc/blocks/MathBlockView.tsx +++ b/web/src/components/doc/blocks/MathBlockView.tsx @@ -3,6 +3,7 @@ import { DocumentBlock } from '../../../types/block'; import katex from 'katex'; import 'katex/dist/katex.min.css'; import { Calculator } from 'lucide-react'; +import { escapeHtml } from '../../../engine/replyDocument/sanitize'; interface MathBlockViewProps { block: DocumentBlock; @@ -16,8 +17,9 @@ export const MathBlockView: React.FC = ({ block }) => { displayMode: true, throwOnError: false, }); - } catch (err: any) { - return `LaTeX 解析错误: ${err.message}`; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'unknown'; + return `LaTeX 解析错误: ${escapeHtml(message)}`; } }, [block.content]); diff --git a/web/src/engine/replyDocument/replyDocument.test.ts b/web/src/engine/replyDocument/replyDocument.test.ts index 04b3e93f..54380ffd 100644 --- a/web/src/engine/replyDocument/replyDocument.test.ts +++ b/web/src/engine/replyDocument/replyDocument.test.ts @@ -4,7 +4,7 @@ import { parseReplyMarkdown } from './parseReplyMarkdown'; import { renderClipboardHtml } from './renderClipboardHtml'; import { renderPlainText } from './renderPlainText'; import { serializeMarkdown } from './serializeMarkdown'; -import { stripUnsafeHtml, sanitizeLinkHref } from './sanitize'; +import { stripUnsafeHtml, sanitizeLinkHref, escapeHtml } from './sanitize'; import { deriveReplyTitle, sanitizeFilename } from '../../features/replyExport/title'; describe('Reply Document IR & Export Engine', () => { @@ -137,4 +137,13 @@ $$ assert.ok(clean.includes('Normal')); assert.equal(sanitizeLinkHref('javascript:alert(1)'), '#'); }); + + test('escapeHtml escapes math error payloads used by BlockEditorModal catch path', () => { + const dirty = ``; + const escaped = escapeHtml(dirty); + assert.ok(!escaped.includes('