fix: close race conditions in script builder session and slug creation

- script_builder endpoint: pg_advisory_xact_lock on user_id before
  session count check, preventing concurrent creates from both passing
  the MAX_SESSIONS_PER_USER guard
- script_builder_service send_message: pg_advisory_xact_lock on session_id
  before message count check, preventing concurrent sends from both
  passing the MAX_MESSAGES_PER_SESSION guard
- script_builder_service save_to_library: replace check-then-insert slug
  logic with IntegrityError retry loop (3 attempts with fresh UUID suffix);
  add unique constraint on script_templates.slug (migration 070)
- ScriptBuilderPage: add creatingSessionRef to serialize concurrent
  handleSend calls that would otherwise both call createSession() while
  session is still null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-01 05:09:42 +00:00
parent 41b0f0a627
commit cb33787c08
5 changed files with 105 additions and 34 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -21,6 +21,11 @@ export default function ScriptBuilderPage() {
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false)
// Ref-based lock: prevents two concurrent handleSend calls (e.g. FlowPilot
// handoff useEffect + user keystroke) from each calling createSession() and
// creating two orphaned sessions. React state updates are async so isLoading
// alone can't guard across two calls in the same render cycle.
const creatingSessionRef = useRef(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false)
@@ -75,8 +80,19 @@ export default function ScriptBuilderPage() {
// Create session if needed
let currentSession = session
if (!currentSession) {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
if (creatingSessionRef.current) {
// Another concurrent call is already creating the session; drop this send.
setIsLoading(false)
setMessages((prev) => prev.slice(0, -1))
return
}
creatingSessionRef.current = true
try {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
} finally {
creatingSessionRef.current = false
}
}
// Send message