diff --git a/application/single_app/config.py b/application/single_app/config.py
index 36591a2e6..4b44e54ca 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -97,9 +97,17 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.003"
+VERSION = "0.261.007"
IS_DEVELOPMENT = is_development_env_enabled()
+# Opt-out for deployments where App Service Easy Auth is active but the platform
+# /.auth/logout endpoint is not reachable on the public host (for example, when a
+# custom domain or gateway does not route /.auth/* to the App Service origin).
+DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT = os.getenv(
+ 'DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT',
+ ''
+).strip().lower() == 'true'
+
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false'
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
diff --git a/application/single_app/example.env b/application/single_app/example.env
index 7803e0b38..ca4e052c2 100644
--- a/application/single_app/example.env
+++ b/application/single_app/example.env
@@ -20,4 +20,12 @@ AZURE_ENVIRONMENT="public"
# Optional Graph overrides (for cross-cloud identity/Graph scenarios)
# Example values:
# CUSTOM_GRAPH_URL_VALUE="https://graph.microsoft.com"
-# CUSTOM_GRAPH_AUTHORITY_URL_VALUE="https://login.microsoftonline.com"
\ No newline at end of file
+# CUSTOM_GRAPH_AUTHORITY_URL_VALUE="https://login.microsoftonline.com"
+
+# Logout behavior on Azure App Service
+# SimpleChat detects App Service Easy Auth from the X-MS-CLIENT-PRINCIPAL request headers
+# the platform injects, and routes logout through /.auth/logout so the platform session is
+# cleared. Set this to "true" only if Easy Auth is active but /.auth/logout is not reachable
+# on your public host, for example when a custom domain or gateway does not route /.auth/*
+# to the App Service origin. Has no effect when running locally.
+# DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT="true"
diff --git a/application/single_app/functions_data_management_search_write_fence.py b/application/single_app/functions_data_management_search_write_fence.py
index 3e83b24e2..96c06f48f 100644
--- a/application/single_app/functions_data_management_search_write_fence.py
+++ b/application/single_app/functions_data_management_search_write_fence.py
@@ -2,6 +2,7 @@
"""Cross-app Azure AI Search write fencing for Data Management migrations."""
import copy
+import threading
import time
import uuid
from contextlib import contextmanager
@@ -25,6 +26,7 @@
DATA_MANAGEMENT_TARGET_MIGRATION_COORDINATOR_TYPE = (
"data_management_target_migration_coordinator"
)
+DATA_MANAGEMENT_SEARCH_WRITE_GATE_LOCAL_LOCK = threading.Lock()
class DataManagementSearchWriteGateError(RuntimeError):
@@ -186,7 +188,8 @@ def _open_expired_gate(container, gate, now=None):
def acquire_data_management_search_write_slot(container):
"""Reserve one bounded target Search write before issuing the data-plane request."""
- for _attempt in range(12):
+ deadline = time.monotonic() + DATA_MANAGEMENT_SEARCH_WRITE_REQUEST_TIMEOUT_SECONDS
+ while time.monotonic() < deadline:
now = _now_utc()
gate = _create_or_read_gate(container)
if gate.get("type") != DATA_MANAGEMENT_SEARCH_WRITE_GATE_TYPE:
@@ -199,6 +202,7 @@ def acquire_data_management_search_write_slot(container):
)
if gate.get("state") != DATA_MANAGEMENT_SEARCH_WRITE_GATE_STATE_OPEN:
if _open_expired_gate(container, gate, now) is None:
+ time.sleep(DATA_MANAGEMENT_SEARCH_WRITE_GATE_POLL_SECONDS)
continue
continue
lease_token = uuid.uuid4().hex
@@ -217,8 +221,9 @@ def acquire_data_management_search_write_slot(container):
})
if _replace_gate(container, gate, replacement) is not None:
return lease_token
+ time.sleep(DATA_MANAGEMENT_SEARCH_WRITE_GATE_POLL_SECONDS)
raise DataManagementSearchWriteGateError(
- "The Data Management Search write gate changed too often to reserve a write slot."
+ "The Data Management Search write gate could not reserve a write slot before the request timeout."
)
@@ -251,14 +256,15 @@ def release_data_management_search_write_slot(container, lease_token):
@contextmanager
def hold_data_management_search_write_slot(container):
"""Hold a target Search write slot until a response is known or its ambiguity lease expires."""
- lease_token = acquire_data_management_search_write_slot(container)
- response_confirmed = False
- try:
- yield
- response_confirmed = True
- finally:
- if response_confirmed:
- release_data_management_search_write_slot(container, lease_token)
+ with DATA_MANAGEMENT_SEARCH_WRITE_GATE_LOCAL_LOCK:
+ lease_token = acquire_data_management_search_write_slot(container)
+ response_confirmed = False
+ try:
+ yield
+ response_confirmed = True
+ finally:
+ if response_confirmed:
+ release_data_management_search_write_slot(container, lease_token)
def acquire_data_management_search_write_fence(
diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py
index 346617244..a055c13b4 100644
--- a/application/single_app/functions_documents.py
+++ b/application/single_app/functions_documents.py
@@ -3,6 +3,7 @@
import re
import shutil
import subprocess
+import time
import traceback
import zipfile
from io import BytesIO
@@ -45,6 +46,11 @@ class DocumentSearchAclProjectionDeferredError(RuntimeError):
"""Raised when an authorization-reducing Search ACL update must be retried safely."""
+MARKDOWN_ORDERED_DICT_MUTATION_MESSAGE = "OrderedDict mutated during iteration"
+MARKDOWN_ORDERED_DICT_RETRY_ATTEMPTS = 2
+MARKDOWN_ORDERED_DICT_RETRY_DELAY_SECONDS = 0.5
+
+
def _search_indexing_results_succeeded(results):
"""Return whether Azure AI Search acknowledged every requested document mutation."""
normalized_results = list(results or [])
@@ -6598,7 +6604,7 @@ def process_md(document_id, user_id, temp_file_path, original_filename, enable_e
if current_word_count >= min_chunk_words or i == len(initial_chunks_content) - 1:
# If the combined chunk meets min size OR it's the last chunk, save it
if current_chunk_text.strip():
- final_chunks.append(current_chunk_text)
+ final_chunks.append(current_chunk_text)
buffer_chunk = "" # Reset buffer
else:
# Accumulate in buffer if below min size and not the last chunk
@@ -6612,32 +6618,36 @@ def process_md(document_id, user_id, temp_file_path, original_filename, enable_e
num_chunks_final = len(final_chunks)
update_callback(number_of_pages=num_chunks_final)
- for idx, chunk_content in enumerate(final_chunks, start=1):
- update_callback(
- current_file_chunk=idx,
- status=f"Saving chunk {idx}/{num_chunks_final}..."
- )
- args = {
+ all_chunks = []
+ for chunk_content in final_chunks:
+ if not chunk_content or not chunk_content.strip():
+ continue
+ all_chunks.append({
"page_text_content": chunk_content,
- "page_number": idx,
+ "page_number": len(all_chunks) + 1,
"file_name": original_filename,
- "user_id": user_id,
- "document_id": document_id
- }
-
- if is_public_workspace:
- args["public_workspace_id"] = public_workspace_id
- elif is_group:
- args["group_id"] = group_id
+ })
- token_usage = save_chunks(**args)
- total_chunks_saved += 1
+ if all_chunks:
+ if len(all_chunks) != num_chunks_final:
+ num_chunks_final = len(all_chunks)
+ update_callback(number_of_pages=num_chunks_final)
+ update_callback(
+ current_file_chunk=1,
+ status=f"Batch saving {num_chunks_final} Markdown chunk(s)..."
+ )
+ token_usage = save_chunks_batch(
+ all_chunks,
+ user_id,
+ document_id,
+ group_id=group_id,
+ public_workspace_id=public_workspace_id
+ )
+ total_chunks_saved = len(all_chunks)
- # Accumulate embedding tokens
if token_usage:
- total_embedding_tokens += token_usage.get('total_tokens', 0)
- if not embedding_model_name:
- embedding_model_name = token_usage.get('model_deployment_name')
+ total_embedding_tokens = token_usage.get('total_tokens', 0)
+ embedding_model_name = token_usage.get('model_deployment_name')
except Exception as e:
raise Exception(f"Failed processing Markdown file {original_filename}: {e}")
@@ -9042,6 +9052,50 @@ def _resolve_processing_complete_status(total_chunks_saved, file_ext, image_exte
return "Processing complete"
+
+def _is_markdown_ordered_dict_mutation_error(exc):
+ """Return whether Markdown processing hit the transient OrderedDict mutation failure."""
+ return MARKDOWN_ORDERED_DICT_MUTATION_MESSAGE in str(exc or "")
+
+
+def _process_markdown_with_ordered_dict_retry(processor_args, update_callback):
+ """Retry Markdown processing when the parser hits a transient OrderedDict mutation."""
+ for attempt in range(MARKDOWN_ORDERED_DICT_RETRY_ATTEMPTS + 1):
+ try:
+ return process_md(**{k: v for k, v in processor_args.items() if k != "file_ext"})
+ except Exception as exc:
+ if (
+ not _is_markdown_ordered_dict_mutation_error(exc) or
+ attempt >= MARKDOWN_ORDERED_DICT_RETRY_ATTEMPTS
+ ):
+ raise
+
+ retry_number = attempt + 1
+ original_filename = processor_args.get("original_filename")
+ document_id = processor_args.get("document_id")
+ log_event(
+ "[DOCUMENTS] Retrying Markdown processing after transient OrderedDict mutation.",
+ extra={
+ "document_id": document_id,
+ "file_name": original_filename,
+ "retry_number": retry_number,
+ "max_retries": MARKDOWN_ORDERED_DICT_RETRY_ATTEMPTS,
+ "error_type": type(exc).__name__,
+ },
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+ update_callback(
+ status=(
+ "Retrying Markdown processing after a transient parser concurrency error "
+ f"({retry_number}/{MARKDOWN_ORDERED_DICT_RETRY_ATTEMPTS})..."
+ )
+ )
+ time.sleep(MARKDOWN_ORDERED_DICT_RETRY_DELAY_SECONDS * retry_number)
+
+ raise RuntimeError("Markdown processing retry loop exited unexpectedly.")
+
+
def process_document_upload_background(document_id, user_id, temp_file_path, original_filename, group_id=None, public_workspace_id=None, extraction_mode_override=None):
"""
Main background task dispatcher for document processing.
@@ -9167,7 +9221,10 @@ def update_doc_callback(**kwargs):
else:
total_chunks_saved = result
elif file_ext == '.md':
- result = process_md(**{k: v for k, v in processor_args_without_auto_metadata.items() if k != "file_ext"})
+ result = _process_markdown_with_ordered_dict_retry(
+ processor_args_without_auto_metadata,
+ update_doc_callback,
+ )
if isinstance(result, tuple) and len(result) == 3:
total_chunks_saved, total_embedding_tokens, embedding_model_name = result
else:
diff --git a/application/single_app/gunicorn.conf.py b/application/single_app/gunicorn.conf.py
index eed906759..24e35571b 100644
--- a/application/single_app/gunicorn.conf.py
+++ b/application/single_app/gunicorn.conf.py
@@ -31,6 +31,9 @@ def _env_bool(name, default):
# Request-count recycling can terminate in-process background exports mid-batch.
max_requests = _env_int('GUNICORN_MAX_REQUESTS', 0 if background_tasks_enabled else 500)
max_requests_jitter = _env_int('GUNICORN_MAX_REQUESTS_JITTER', 0 if max_requests == 0 else 50)
+# Azure AD auth callback query strings can exceed Gunicorn's default (4094).
+# Use the Gunicorn maximum by default, while still allowing override via env var.
+limit_request_line = _env_int('GUNICORN_LIMIT_REQUEST_LINE', 8190)
accesslog = '-'
errorlog = '-'
capture_output = True
diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py
index c07b51519..db795d249 100644
--- a/application/single_app/route_frontend_authentication.py
+++ b/application/single_app/route_frontend_authentication.py
@@ -6,6 +6,7 @@
import requests
from config import *
+from config import DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT
from functions_activity_logging import log_user_login, record_user_login_session_activity
from functions_terms_of_use import (
apply_pending_pre_auth_terms_of_use,
@@ -42,16 +43,36 @@ def build_front_door_urls(front_door_url):
def _use_app_service_easy_auth_logout():
- """Return True when the current request is running behind App Service Easy Auth."""
+ """
+ Determine whether logout should route through the App Service Easy Auth endpoint.
+
+ Args:
+ None.
+
+ Returns:
+ bool: True when the current request is being served behind App Service Easy Auth.
+ Raises:
+ None.
+ """
+ if DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT:
+ debug_print("Easy Auth logout disabled by DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT; using local logout.")
+ return False
+
if not os.getenv('WEBSITE_HOSTNAME'):
return False
+ # Easy Auth injects these headers only on requests it actually intercepts, so they are
+ # the reliable per-request signal that /.auth/logout is being served for this host.
easy_auth_headers = (
request.headers.get('X-MS-CLIENT-PRINCIPAL'),
request.headers.get('X-MS-CLIENT-PRINCIPAL-ID'),
request.headers.get('X-MS-CLIENT-PRINCIPAL-NAME'),
)
- return any(easy_auth_headers) or bool(os.getenv('WEBSITE_AUTH_AAD_ALLOWED_TENANTS'))
+ if not any(easy_auth_headers):
+ debug_print("No App Service Easy Auth principal headers on this request; using local logout.")
+ return False
+
+ return True
def _build_app_service_easy_auth_logout_url():
diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js
index 3893d4715..3410be79a 100644
--- a/application/single_app/static/js/public/public_workspace.js
+++ b/application/single_app/static/js/public/public_workspace.js
@@ -1980,6 +1980,29 @@ async function onPublicUploadClick() {
let completed = 0;
let failed = 0;
+ function updatePublicUploadRequestSummary() {
+ if (uploadStatus) uploadStatus.textContent = `Queued ${completed}/${files.length}${failed ? `, Upload requests not confirmed: ${failed}` : ''}`;
+ }
+
+ function finishPublicUploadRequests() {
+ fileInput.value = '';
+ publicDocsCurrentPage = 1;
+ fetchPublicDocs();
+
+ if (uploadBtn) {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = 'Upload Document(s)';
+ }
+
+ if (uploadStatus) {
+ uploadStatus.textContent = failed
+ ? `Upload requests complete. Queued ${completed}/${files.length}; ${failed} request(s) did not confirm. Check the document list below for final processing status.`
+ : `Queued ${completed}/${files.length} file(s). Check the document list below for processing status.`;
+ }
+
+ if (progressContainer) progressContainer.innerHTML = '';
+ }
+
// Helper to create a unique ID for each file
function makeId(file) {
return 'progress-' + Math.random().toString(36).slice(2, 10) + '-' + encodeURIComponent(file.name.replace(/\W+/g, ''));
@@ -2034,7 +2057,7 @@ async function onPublicUploadClick() {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Uploaded ${file.name} (100%)`;
+ statusText.textContent = `Queued ${file.name} (100%)`;
}
completed++;
} else {
@@ -2044,26 +2067,13 @@ async function onPublicUploadClick() {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
}
- // Update summary status
- if (uploadStatus) uploadStatus.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updatePublicUploadRequestSummary();
if (completed + failed === files.length) {
- fileInput.value = '';
- publicDocsCurrentPage = 1;
- fetchPublicDocs();
-
- // Re-enable upload button if it exists
- if (uploadBtn) {
- uploadBtn.disabled = false;
- uploadBtn.textContent = 'Upload Document(s)';
- }
-
- // Clear upload progress bars after all uploads and table refresh
- const progressContainer = document.getElementById('public-upload-progress-container');
- if (progressContainer) progressContainer.innerHTML = '';
+ finishPublicUploadRequests();
}
};
@@ -2074,24 +2084,12 @@ async function onPublicUploadClick() {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
- if (uploadStatus) uploadStatus.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updatePublicUploadRequestSummary();
if (completed + failed === files.length) {
- fileInput.value = '';
- publicDocsCurrentPage = 1;
- fetchPublicDocs();
-
- // Re-enable upload button if it exists
- if (uploadBtn) {
- uploadBtn.disabled = false;
- uploadBtn.textContent = 'Upload Document(s)';
- }
-
- // Clear upload progress bars after all uploads and table refresh
- const progressContainer = document.getElementById('public-upload-progress-container');
- if (progressContainer) progressContainer.innerHTML = '';
+ finishPublicUploadRequests();
}
};
diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js
index a887786f5..92c1de3f3 100644
--- a/application/single_app/static/js/workspace/workspace-documents.js
+++ b/application/single_app/static/js/workspace/workspace-documents.js
@@ -1481,6 +1481,20 @@ async function uploadWorkspaceFiles(files) {
let completed = 0;
let failed = 0;
+ function updateWorkspaceUploadRequestSummary() {
+ uploadStatusSpan.textContent = `Queued ${completed}/${files.length}${failed ? `, Upload requests not confirmed: ${failed}` : ''}`;
+ }
+
+ function finishWorkspaceUploadRequests() {
+ fileInput.value = '';
+ docsCurrentPage = 1;
+ fetchUserDocuments();
+ uploadStatusSpan.textContent = failed
+ ? `Upload requests complete. Queued ${completed}/${files.length}; ${failed} request(s) did not confirm. Check the document list below for final processing status.`
+ : `Queued ${completed}/${files.length} file(s). Check the document list below for processing status.`;
+ if (progressContainer) progressContainer.innerHTML = '';
+ }
+
// Helper to create a unique ID for each file
function makeId(file) {
return 'progress-' + Math.random().toString(36).slice(2, 10) + '-' + encodeURIComponent(file.name.replace(/\W+/g, ''));
@@ -1535,7 +1549,7 @@ async function uploadWorkspaceFiles(files) {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Uploaded ${file.name} (100%)`;
+ statusText.textContent = `Queued ${file.name} (100%)`;
}
completed++;
} else {
@@ -1545,18 +1559,13 @@ async function uploadWorkspaceFiles(files) {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
}
- // Update summary status
- uploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateWorkspaceUploadRequestSummary();
if (completed + failed === files.length) {
- fileInput.value = '';
- docsCurrentPage = 1;
- fetchUserDocuments();
- // Clear upload progress bars after all uploads and table refresh
- if (progressContainer) progressContainer.innerHTML = '';
+ finishWorkspaceUploadRequests();
}
};
@@ -1567,15 +1576,12 @@ async function uploadWorkspaceFiles(files) {
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
- uploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateWorkspaceUploadRequestSummary();
if (completed + failed === files.length) {
- fileInput.value = '';
- docsCurrentPage = 1;
- fetchUserDocuments();
- if (progressContainer) progressContainer.innerHTML = '';
+ finishWorkspaceUploadRequests();
}
};
diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html
index 12e2b08cf..68b120eaa 100644
--- a/application/single_app/templates/group_workspaces.html
+++ b/application/single_app/templates/group_workspaces.html
@@ -3404,6 +3404,20 @@
Delete Group Workflow
let completed = 0;
let failed = 0;
+ function updateGroupUploadRequestSummary() {
+ groupUploadStatusSpan.textContent = `Queued ${completed}/${files.length}${failed ? `, Upload requests not confirmed: ${failed}` : ''}`;
+ }
+
+ function finishGroupUploadRequests() {
+ groupFileInput.value = '';
+ groupDocsCurrentPage = 1;
+ fetchGroupDocuments();
+ groupUploadStatusSpan.textContent = failed
+ ? `Upload requests complete. Queued ${completed}/${files.length}; ${failed} request(s) did not confirm. Check the document list below for final processing status.`
+ : `Queued ${completed}/${files.length} file(s). Check the document list below for processing status.`;
+ if (progressContainer) progressContainer.innerHTML = '';
+ }
+
// Helper to create a unique ID for each file
function makeId(file) {
return 'group-progress-' + Math.random().toString(36).slice(2, 10) + '-' + encodeURIComponent(file.name.replace(/\W+/g, ''));
@@ -3458,7 +3472,7 @@ Delete Group Workflow
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Uploaded ${file.name} (100%)`;
+ statusText.textContent = `Queued ${file.name} (100%)`;
}
completed++;
} else {
@@ -3468,18 +3482,13 @@ Delete Group Workflow
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
}
- // Update summary status
- groupUploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateGroupUploadRequestSummary();
if (completed + failed === files.length) {
- groupFileInput.value = '';
- groupDocsCurrentPage = 1;
- fetchGroupDocuments();
- // Clear upload progress bars after all uploads and table refresh
- if (progressContainer) progressContainer.innerHTML = '';
+ finishGroupUploadRequests();
}
};
@@ -3490,15 +3499,12 @@ Delete Group Workflow
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
- groupUploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateGroupUploadRequestSummary();
if (completed + failed === files.length) {
- groupFileInput.value = '';
- groupDocsCurrentPage = 1;
- fetchGroupDocuments();
- if (progressContainer) progressContainer.innerHTML = '';
+ finishGroupUploadRequests();
}
};
@@ -6846,6 +6852,22 @@ Delete Group Document
let completed = 0;
let failed = 0;
+ function updateGroupUploadRequestSummary() {
+ groupUploadStatusSpan.textContent = `Queued ${completed}/${files.length}${failed ? `, Upload requests not confirmed: ${failed}` : ''}`;
+ }
+
+ function finishGroupUploadRequests() {
+ groupFileInput.value = '';
+ groupDocsCurrentPage = 1;
+ fetchGroupDocuments();
+ groupUploadBtn.disabled = false;
+ groupUploadBtn.innerHTML = "Upload Document(s)";
+ groupUploadStatusSpan.textContent = failed
+ ? `Upload requests complete. Queued ${completed}/${files.length}; ${failed} request(s) did not confirm. Check the document list below for final processing status.`
+ : `Queued ${completed}/${files.length} file(s). Check the document list below for processing status.`;
+ if (progressContainer) progressContainer.innerHTML = '';
+ }
+
// Helper to create a unique ID for each file
function makeId(file) {
return 'group-progress-' + Math.random().toString(36).slice(2, 10) + '-' + encodeURIComponent(file.name.replace(/\W+/g, ''));
@@ -6900,7 +6922,7 @@ Delete Group Document
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Uploaded ${file.name} (100%)`;
+ statusText.textContent = `Queued ${file.name} (100%)`;
}
completed++;
} else {
@@ -6910,20 +6932,13 @@ Delete Group Document
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
}
- // Update summary status
- groupUploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateGroupUploadRequestSummary();
if (completed + failed === files.length) {
- groupFileInput.value = '';
- groupDocsCurrentPage = 1;
- fetchGroupDocuments();
- groupUploadBtn.disabled = false;
- groupUploadBtn.innerHTML = "Upload Document(s)";
- // Clear upload progress bars after all uploads and table refresh
- if (progressContainer) progressContainer.innerHTML = '';
+ finishGroupUploadRequests();
}
};
@@ -6934,17 +6949,12 @@ Delete Group Document
progressBar.classList.remove('progress-bar-animated');
}
if (statusText) {
- statusText.textContent = `Failed to upload ${file.name}`;
+ statusText.textContent = `Upload request did not confirm for ${file.name}`;
}
failed++;
- groupUploadStatusSpan.textContent = `Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}`;
+ updateGroupUploadRequestSummary();
if (completed + failed === files.length) {
- groupFileInput.value = '';
- groupDocsCurrentPage = 1;
- fetchGroupDocuments();
- groupUploadBtn.disabled = false;
- groupUploadBtn.innerHTML = "Upload Document(s)";
- if (progressContainer) progressContainer.innerHTML = '';
+ finishGroupUploadRequests();
}
};
diff --git a/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md b/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md
new file mode 100644
index 000000000..cfd27ef27
--- /dev/null
+++ b/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md
@@ -0,0 +1,85 @@
+# Easy Auth Logout Detection Fix
+
+Fixed/Implemented in version: **0.261.002**
+
+## Issue Description
+
+User-initiated logout and idle-timeout logout could redirect to
+`/.auth/logout?post_logout_redirect_uri=%2Flogin` and return a 404 on Azure App Service
+deployments that were not actually serving App Service Easy Auth. The problem was first
+reported on a development custom domain, but it was never limited to development
+environments; any deployment matching the same conditions was affected, including
+production.
+
+## Root Cause Analysis
+
+`_use_app_service_easy_auth_logout()` in
+`application/single_app/route_frontend_authentication.py` decided that Easy Auth was active
+when either the `X-MS-CLIENT-PRINCIPAL` request headers were present **or** the
+`WEBSITE_AUTH_AAD_ALLOWED_TENANTS` environment variable was set:
+
+```python
+return any(easy_auth_headers) or bool(os.getenv('WEBSITE_AUTH_AAD_ALLOWED_TENANTS'))
+```
+
+That environment variable is not evidence that Easy Auth is running. SimpleChat's own
+advanced configuration guidance in
+`application/single_app/example_advance_edit_environment_variables.json` instructs
+operators to set it by hand, so any deployment that followed those instructions without
+enabling Easy Auth was misdetected. Logout then redirected to a platform endpoint that the
+App Service was not serving, producing the 404.
+
+A secondary case exists where Easy Auth genuinely is enabled but `/.auth/*` is not routed
+through to the App Service origin, for example behind a custom domain, gateway or front
+door with restrictive path routing. Request-based detection cannot distinguish that case,
+so it needs an explicit opt-out.
+
+## Technical Details
+
+- Modified `application/single_app/route_frontend_authentication.py` so Easy Auth detection
+ relies only on the `X-MS-CLIENT-PRINCIPAL`, `X-MS-CLIENT-PRINCIPAL-ID` and
+ `X-MS-CLIENT-PRINCIPAL-NAME` headers that App Service injects into requests it actually
+ intercepts. The `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` fallback was removed.
+- Added `debug_print` output on both non-Easy-Auth paths so the logout routing decision and
+ its reason are visible with `FLASK_DEBUG=1`.
+- Added the `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT` environment flag in
+ `application/single_app/config.py` for deployments where Easy Auth is active but
+ `/.auth/logout` is unreachable on the public host.
+- Documented the flag in `application/single_app/example.env` and added a
+ "Logout Behavior Across Environments" section to
+ `docs/explanation/running_simplechat_locally.md` covering the behavior per environment
+ and the troubleshooting steps for a logout 404.
+- Updated `application/single_app/config.py` to version `0.261.002`.
+- Reworked regression coverage in `functional_tests/test_app_service_easy_auth_logout.py`.
+
+### Behavior by environment
+
+| Environment | Easy Auth headers | Logout path |
+| --- | --- | --- |
+| Local machine (`python app.py`) | No | Local logout |
+| App Service with Easy Auth enabled | Yes | Easy Auth logout via `/.auth/logout` |
+| App Service without Easy Auth enabled | No | Local logout |
+| Easy Auth enabled, `/.auth/*` not routed | Yes | Local logout after setting the opt-out flag |
+
+Local development was never affected by the original defect, because `WEBSITE_HOSTNAME` is
+not set outside App Service and the function returned early.
+
+## Validation
+
+- `functional_tests/test_app_service_easy_auth_logout.py` — 5/5 passing, covering Easy Auth
+ local logout, Easy Auth full logout, the reported no-headers case, preservation of Easy
+ Auth logout on a non-production host, and the opt-out flag.
+- `functional_tests/test_idle_logout_timeout.py` — 4/4 passing. The idle-timeout path routes
+ through `local_logout`, so it inherits the corrected behavior.
+- Confirmed deployments genuinely behind Easy Auth still redirect through `/.auth/logout`,
+ so the upstream platform session continues to be cleared.
+- Confirmed deployments with Azure hosting variables but no Easy Auth headers now perform a
+ local logout instead of redirecting to a missing platform endpoint.
+
+## Notes
+
+The previous iteration of this fix skipped Easy Auth logout whenever the `is_development`
+environment flag was set. That approach was replaced because it left the underlying
+detection defect in place for production, it reused a flag documented for Latest Features
+navigation to control session termination, and it disabled platform logout even in
+development environments where Easy Auth was genuinely active and working.
diff --git a/docs/explanation/fixes/MARKDOWN_RETRY_RETURN_CONTRACT_FIX.md b/docs/explanation/fixes/MARKDOWN_RETRY_RETURN_CONTRACT_FIX.md
new file mode 100644
index 000000000..a7ecbab9f
--- /dev/null
+++ b/docs/explanation/fixes/MARKDOWN_RETRY_RETURN_CONTRACT_FIX.md
@@ -0,0 +1,24 @@
+# Markdown Retry Return Contract Fix
+
+Fixed in version: **0.261.007**
+
+## Issue
+
+GitHub Advanced Security flagged the Markdown retry helper for mixing explicit returns with an implicit fall-through return. The helper returned the result from Markdown processing on success and raised the original exception when retries were exhausted, but the loop structure still allowed static analysis to see a possible implicit `None` return.
+
+## Root Cause
+
+The retry loop was logically exhaustive because it either returned from `process_md` or raised when the known transient error exceeded the retry limit. CodeQL could not prove that control flow, so the function contract was ambiguous to static analysis.
+
+## Technical Details
+
+Files modified:
+
+- `application/single_app/functions_documents.py`
+- `application/single_app/config.py`
+
+The helper now raises an explicit defensive `RuntimeError` after the retry loop. This keeps the runtime behavior unchanged for normal success and retry-exhaustion paths while making the no-fall-through contract visible to static analysis.
+
+## Validation
+
+Validated with Python compilation and the Markdown processing regression test.
\ No newline at end of file
diff --git a/docs/explanation/fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md b/docs/explanation/fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md
new file mode 100644
index 000000000..3b1625a58
--- /dev/null
+++ b/docs/explanation/fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md
@@ -0,0 +1,27 @@
+# Search Write Gate Upload Contention Fix
+
+Fixed in version: **0.261.006**
+
+## Issue
+
+Large uploads of many small Markdown, JSON, or YAML files could partially fail while processing chunks for Azure AI Search in personal, group, or public workspaces. The user-facing error reported that the Data Management Search write gate changed too often to reserve a write slot. Concurrent Markdown uploads could also intermittently fail with `OrderedDict mutated during iteration` while processing different files in different environments.
+
+## Root Cause
+
+Document chunk writes and Data Management migrations share a Cosmos-backed write gate so migrations can safely freeze target Azure AI Search writes. This gate is used before writes to the personal, group, and public workspace Search indexes. Under high upload fan-in, many small files can attempt to reserve writer slots against the same gate document at once. The previous reservation path gave up after 12 rapid optimistic-concurrency conflicts, and the first retry fix still allowed worker-local upload threads to stampede the same gate document. Markdown processing also wrote one chunk at a time, multiplying gate reservations for a single file.
+
+## Technical Details
+
+Files modified:
+
+- `application/single_app/functions_data_management_search_write_fence.py`
+- `application/single_app/functions_documents.py`
+- `functional_tests/test_data_management_search_write_fence.py`
+- `functional_tests/test_markdown_processing_batches_search_writes.py`
+- `application/single_app/config.py`
+
+The write-slot reservation loop now waits until the existing request timeout budget is exhausted, sleeps briefly after each ETag conflict, and serializes Search writes inside each worker process. Markdown processing now collects non-empty chunks and sends them through `save_chunks_batch`, matching the existing batched processors and reducing both Search write gate mutations and embedding/Search client churn. If Markdown processing still hits the known transient `OrderedDict mutated during iteration` error, the background processor retries the same queued document before marking it failed.
+
+## Validation
+
+Added regression coverage for repeated transient write-gate conflicts before a successful reservation, worker-local Search write serialization, Markdown use of the batch chunk writer, and the OrderedDict retry dispatch. Validated the concurrency scenarios with direct Python execution because `pytest` was not installed in the selected environment, and compiled the touched Python files successfully.
diff --git a/docs/explanation/fixes/WORKSPACE_UPLOAD_STATUS_COUNTER_FIX.md b/docs/explanation/fixes/WORKSPACE_UPLOAD_STATUS_COUNTER_FIX.md
new file mode 100644
index 000000000..2119a7239
--- /dev/null
+++ b/docs/explanation/fixes/WORKSPACE_UPLOAD_STATUS_COUNTER_FIX.md
@@ -0,0 +1,27 @@
+# Workspace Upload Status Counter Fix
+
+Fixed in version: **0.261.005**
+
+## Issue
+
+During large workspace uploads, the temporary progress summary could display a high failed count, such as `Uploaded 77/204, Failed: 127`, even when most of those documents later appeared in the document list and finished successfully.
+
+## Root Cause
+
+The progress summary counted browser upload request responses, but its wording implied final document processing results. If a request did not confirm cleanly while the backend had already queued the document, the summary could permanently show that request as failed even though the document row later became the authoritative final state.
+
+## Technical Details
+
+Files modified:
+
+- `application/single_app/templates/group_workspaces.html`
+- `application/single_app/static/js/workspace/workspace-documents.js`
+- `application/single_app/static/js/public/public_workspace.js`
+- `ui_tests/test_workspace_upload_request_status_copy.py`
+- `application/single_app/config.py`
+
+The upload progress summary now says `Queued` for confirmed requests and `Upload requests not confirmed` for request-level failures. When all upload requests settle, the final status directs users to the refreshed document list for final processing status instead of leaving a stale failed count.
+
+## Validation
+
+Added UI regression coverage that checks personal, group, and public workspace upload summaries no longer use the misleading `Uploaded ... Failed` counter wording. VS Code diagnostics reported no errors for the changed JavaScript/template files, and `config.py` compiled successfully.
diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md
index a7c05cdc0..5557504c1 100644
--- a/docs/explanation/fixes/index.md
+++ b/docs/explanation/fixes/index.md
@@ -37,6 +37,7 @@ category: Version History
- [New Chat Conversation Documents Drawer Reset Fix](NEW_CHAT_CONVERSATION_DOCUMENTS_DRAWER_RESET_FIX.md)
- [Collaboration Mention Tab Autocomplete Fix](COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md)
- [Generated Artifact Paging, Truncation, and Guidance Carry-Forward Fix](GENERATED_ARTIFACT_PAGING_AND_GUIDANCE_FIX.md)
+- [Easy Auth Logout Detection Fix](EASY_AUTH_LOGOUT_DETECTION_FIX.md)
- [Admin Settings Pane Variable Scope Fix](ADMIN_SETTINGS_PANE_VARIABLE_SCOPE_FIX.md)
- [Inline Media Cited-Only Gating Fix](INLINE_MEDIA_CITED_ONLY_GATING_FIX.md)
- [Agent Actions With Workspace Evidence Fix](AGENT_ACTIONS_WITH_WORKSPACE_EVIDENCE_FIX.md)
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 65727de9f..f3f2b74d2 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,45 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.007)**
+
+#### Bug Fixes
+
+* **Markdown Retry Helper Return Contract Clarified**
+ * Added an explicit defensive exception at the end of the Markdown `OrderedDict` retry helper so static analysis no longer sees a possible implicit `None` return.
+ * Runtime behavior is unchanged for normal success and retry-exhaustion paths.
+ * (Ref: `functions_documents.py`, [Markdown Retry Return Contract Fix](fixes/MARKDOWN_RETRY_RETURN_CONTRACT_FIX.md))
+
+### **(v0.261.006)**
+
+#### Bug Fixes
+
+* **Markdown Uploads Retry Transient OrderedDict Parser Failures**
+ * Markdown document processing now retries the known transient `OrderedDict mutated during iteration` parser failure before marking a document failed.
+ * The retry is limited to this specific Markdown failure signature, so unrelated parsing, validation, or service errors still fail normally with their original error.
+ * (Ref: Markdown upload processing, `functions_documents.py`, `test_markdown_processing_batches_search_writes.py`, [Search Write Gate Upload Contention Fix](fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md))
+
+### **(v0.261.005)**
+
+#### User Interface Enhancements
+
+* **Workspace Upload Progress Now Separates Request Status From Document Processing Status**
+ * The temporary upload summary no longer labels unconfirmed browser upload requests as final document failures. This avoids misleading summaries such as `Uploaded 77/204, Failed: 127` when the document list later shows that most documents were queued and processed successfully.
+ * Personal, group, and public workspace uploads now use `Queued` for confirmed upload requests and direct users to the refreshed document list for final processing status.
+ * (Ref: workspace upload progress summary, `workspace-documents.js`, `public_workspace.js`, `group_workspaces.html`, [Workspace Upload Status Counter Fix](fixes/WORKSPACE_UPLOAD_STATUS_COUNTER_FIX.md))
+
+### **(v0.261.004)**
+
+#### Bug Fixes
+
+* **Large Workspace Uploads No Longer Fail On Search Write Gate Contention**
+ * Fixed partial failures when uploading many small Markdown, JSON, or YAML files to personal, group, or public workspaces at once. Document processing could fail with a message that the Data Management Search write gate changed too often to reserve a write slot.
+ * The shared write gate now waits within the existing request timeout budget, briefly backs off after transient Cosmos ETag conflicts, and serializes Search writes inside each worker process. This prevents local upload threads from stampeding the same gate document while preserving the migration freeze protection for Azure AI Search writes.
+ * Markdown processing now batches its chunk embeddings and Search upload instead of reserving the gate once per chunk, which reduces contention and avoids the intermittent `OrderedDict mutated during iteration` failures seen during concurrent Markdown ingestion.
+ * Added regression coverage for repeated transient gate conflicts, local worker serialization, and Markdown use of the batch chunk writer.
+ * (Ref: `functions_data_management_search_write_fence.py`, `functions_documents.py`, `test_data_management_search_write_fence.py`, `test_markdown_processing_batches_search_writes.py`, [Search Write Gate Upload Contention Fix](fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md))
+
+
### **(v0.261.003)**
#### Bug Fixes
@@ -69,6 +108,20 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
* No shipping default changed. Only custom overrides that could never have been indexed are affected.
* (Ref: `get_chunk_size_cap`, `get_chunk_size_config`, Document Extraction settings, `admin_settings.js`)
+* **Logout No Longer Redirects To A Missing Easy Auth Endpoint**
+ * Logout could redirect to `/.auth/logout?post_logout_redirect_uri=%2Flogin` and return a 404 on Azure App Service deployments that were not actually serving App Service Easy Auth. This affected production deployments as well as development ones.
+ * The root cause was Easy Auth detection treating the manually configured `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` application setting as proof that Easy Auth was intercepting requests. SimpleChat's own advanced environment variable guidance instructs operators to set that value by hand, so it was never a reliable signal.
+ * Detection now relies only on the `X-MS-CLIENT-PRINCIPAL` request headers that App Service Easy Auth injects on requests it actually intercepts, so deployments genuinely behind Easy Auth still clear the upstream platform session, and everyone else gets a clean local logout.
+ * Idle-timeout logout uses the same local logout path, so automatic session expiration follows the corrected behavior as well.
+ * (Ref: `route_frontend_authentication.py`, `_use_app_service_easy_auth_logout`, `test_app_service_easy_auth_logout.py`, [Easy Auth Logout Detection Fix](fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md))
+
+#### New Features
+
+* **Opt-Out For App Service Easy Auth Logout**
+ * Added the `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT` environment variable for deployments where Easy Auth is genuinely active but the platform `/.auth/logout` endpoint is not reachable on the public host, such as when a custom domain or gateway does not route `/.auth/*` to the App Service origin.
+ * Setting it to `true` keeps logout on the local path instead of redirecting to the platform endpoint. Logout routing decisions are now also traced through debug logging, so `FLASK_DEBUG=1` shows which path was taken and why.
+ * (Ref: `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT`, `config.py`, `example.env`, [Running SimpleChat Locally](running_simplechat_locally.md))
+
### **(v0.261.001)**
#### New Features
@@ -6290,4 +6343,4 @@ The individual patch builds consolidated into v0.260.001 are preserved below for
---
-Release notes for versions before v0.230.001 have moved to the [archived release notes](/explanation/archive_release_notes/).
+Release notes for versions before v0.230.001 have moved to the [archived release notes](/explanation/archive_release_notes/).
\ No newline at end of file
diff --git a/docs/explanation/running_simplechat_locally.md b/docs/explanation/running_simplechat_locally.md
index b1d70f58e..3a5181a6d 100644
--- a/docs/explanation/running_simplechat_locally.md
+++ b/docs/explanation/running_simplechat_locally.md
@@ -181,6 +181,53 @@ If you want to test the scheduler separately, run:
python simplechat_scheduler.py
```
+## Logout Behavior Across Environments
+
+Logout takes one of two paths, chosen per request. Knowing which one you are on explains
+most unexpected logout behavior.
+
+- **Local logout** clears the Flask session and redirects to the app home page.
+- **Easy Auth logout** first redirects through the App Service platform endpoint
+ `/.auth/logout` so the platform sign-in session is cleared too, then returns to the
+ SimpleChat login page.
+
+SimpleChat picks Easy Auth logout only when the request carries the
+`X-MS-CLIENT-PRINCIPAL` headers that App Service Easy Auth injects into requests it
+intercepts. That gives the following behavior:
+
+| Where you are running | Easy Auth headers present | Logout path |
+| --- | --- | --- |
+| Local machine (`python app.py`) | No | Local logout |
+| App Service with Easy Auth enabled | Yes | Easy Auth logout |
+| App Service without Easy Auth enabled | No | Local logout |
+
+Running locally, you always get local logout, because `WEBSITE_HOSTNAME` is not set and no
+platform headers exist. There is nothing to configure for local development.
+
+### Troubleshooting a 404 on logout
+
+If logout lands on a 404 at `/.auth/logout`, the app believed Easy Auth was serving that
+host but the endpoint was not reachable. Work through the following:
+
+1. Run with `FLASK_DEBUG=1` and sign out again. The logout path decision is written to the
+ debug log, including the reason when local logout is chosen.
+2. Confirm whether Easy Auth is actually enabled for the App Service. Setting only the
+ `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` application setting does not enable it.
+3. If Easy Auth is enabled, confirm that `/.auth/*` is routed through to the App Service
+ origin. A custom domain, gateway or front door that does not forward those paths will
+ return a 404 even though Easy Auth is running.
+
+If Easy Auth must stay enabled and `/.auth/*` cannot be routed, keep logout on the local
+path with:
+
+```bash
+DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT=true
+```
+
+Use that only when the routing issue cannot be fixed. While it is set, signing out no
+longer clears the App Service platform session, so the next sign-in can complete without
+prompting for credentials.
+
## Practical Guidance
- Create `.venv` with Python 3.12 and select it in VS Code before installing dependencies.
diff --git a/functional_tests/test_app_service_easy_auth_logout.py b/functional_tests/test_app_service_easy_auth_logout.py
index 569202a87..f832cd1f0 100644
--- a/functional_tests/test_app_service_easy_auth_logout.py
+++ b/functional_tests/test_app_service_easy_auth_logout.py
@@ -1,20 +1,23 @@
# test_app_service_easy_auth_logout.py
"""
-Functional test for Azure App Service Easy Auth logout recovery.
-Version: 0.241.095
-Implemented in: 0.241.095
-
-This test ensures Azure-hosted logout routes clear the upstream App Service
-authentication session by redirecting through /.auth/logout before re-entering
-the Flask login flow.
+Functional test for Azure App Service Easy Auth logout detection.
+Version: 0.261.002
+Implemented in: 0.261.002
+
+This test ensures logout routes through /.auth/logout only when App Service Easy Auth is
+actually serving the request, so deployments that are not behind Easy Auth no longer hit a
+404 on the platform logout endpoint, while deployments that are behind it still clear the
+upstream platform session.
"""
from pathlib import Path
+import importlib
import os
import sys
-from unittest.mock import patch
+import types
+from unittest.mock import patch, Mock
-from flask import Flask, session
+from flask import Blueprint, Flask, session
ROOT = Path(__file__).resolve().parents[1]
@@ -24,7 +27,103 @@
sys.path.insert(0, str(APP_DIR))
-import route_frontend_authentication as route_module # noqa: E402
+class FakeConfigCosmosContainer:
+ """Minimal Cosmos container stand-in for config.py import-time setup."""
+
+ def read(self):
+ return {}
+
+
+class FakeConfigCosmosDatabase:
+ """Minimal Cosmos database stand-in for importing config.py without live I/O."""
+
+ def __init__(self):
+ self.containers = {}
+
+ def create_container_if_not_exists(self, id, **kwargs):
+ if id not in self.containers:
+ self.containers[id] = FakeConfigCosmosContainer()
+ return self.containers[id]
+
+ def get_container_client(self, id):
+ return self.containers.setdefault(id, FakeConfigCosmosContainer())
+
+
+class FakeConfigCosmosClient:
+ """Minimal Cosmos client stand-in for config.py import-time container setup."""
+
+ def __init__(self, *args, **kwargs):
+ self.database = FakeConfigCosmosDatabase()
+
+ def create_database_if_not_exists(self, *args, **kwargs):
+ return self.database
+
+
+def import_module_without_live_cosmos(module_name):
+ """Import app modules without letting config.py connect to live Cosmos."""
+ if module_name in sys.modules:
+ return sys.modules[module_name]
+
+ import azure.cosmos as azure_cosmos
+
+ original_cosmos_client = azure_cosmos.CosmosClient
+ azure_cosmos.CosmosClient = FakeConfigCosmosClient
+ stub_modules = _install_route_dependency_stubs()
+ try:
+ return importlib.import_module(module_name)
+ finally:
+ azure_cosmos.CosmosClient = original_cosmos_client
+ for stub_name in stub_modules:
+ sys.modules.pop(stub_name, None)
+
+
+def _install_route_dependency_stubs():
+ """Install lightweight stubs for dependencies unrelated to logout routing."""
+ stub_modules = {}
+
+ functions_activity_logging = types.ModuleType("functions_activity_logging")
+ functions_activity_logging.log_user_login = Mock()
+ functions_activity_logging.record_user_login_session_activity = Mock()
+ stub_modules["functions_activity_logging"] = functions_activity_logging
+
+ functions_terms_of_use = types.ModuleType("functions_terms_of_use")
+ functions_terms_of_use.apply_pending_pre_auth_terms_of_use = Mock()
+ functions_terms_of_use.get_terms_of_use_config = Mock(return_value={"enabled": False})
+ functions_terms_of_use.has_terms_of_use_acceptance = Mock(return_value=True)
+ stub_modules["functions_terms_of_use"] = functions_terms_of_use
+
+ functions_authentication = types.ModuleType("functions_authentication")
+ functions_authentication._build_msal_app = Mock()
+ functions_authentication._load_cache = Mock(return_value=None)
+ functions_authentication._save_cache = Mock()
+ functions_authentication.clear_requested_oauth_scopes = Mock()
+ functions_authentication.create_ci_bearer_session = Mock(return_value=("", 204))
+ functions_authentication.get_graph_authority = Mock(return_value="https://graph.microsoft.com")
+ functions_authentication.get_graph_endpoint = Mock(side_effect=lambda path: f"https://graph.microsoft.com/v1.0{path}")
+ functions_authentication.get_requested_oauth_scopes = Mock(return_value=[])
+ stub_modules["functions_authentication"] = functions_authentication
+
+ functions_debug = types.ModuleType("functions_debug")
+ functions_debug.debug_print = Mock()
+ stub_modules["functions_debug"] = functions_debug
+
+ functions_settings = types.ModuleType("functions_settings")
+ functions_settings.get_settings = Mock(return_value={})
+ functions_settings.sanitize_settings_for_user = Mock(side_effect=lambda settings: settings)
+ stub_modules["functions_settings"] = functions_settings
+
+ swagger_wrapper = types.ModuleType("swagger_wrapper")
+ swagger_wrapper.swagger_route = Mock(side_effect=lambda *args, **kwargs: (lambda function: function))
+ swagger_wrapper.get_auth_security = Mock(return_value=[])
+ stub_modules["swagger_wrapper"] = swagger_wrapper
+
+ for stub_name, stub_module in stub_modules.items():
+ sys.modules[stub_name] = stub_module
+
+ return stub_modules
+
+
+route_module = import_module_without_live_cosmos("route_frontend_authentication")
EXPECTED_EASY_AUTH_LOGOUT = "/.auth/logout?post_logout_redirect_uri=%2Flogin"
@@ -34,11 +133,14 @@ def _build_test_app():
app = Flask(__name__)
app.secret_key = "test-secret"
- @app.route("/")
def index():
return "ok"
- route_module.register_route_frontend_authentication(app)
+ app.add_url_rule("/", endpoint="public_app.index", view_func=index)
+
+ auth_blueprint = Blueprint("frontend_authentication", __name__)
+ route_module.register_route_frontend_authentication(auth_blueprint)
+ app.register_blueprint(auth_blueprint)
return app
@@ -55,7 +157,7 @@ def test_local_logout_uses_app_service_easy_auth_logout():
"WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id",
},
clear=False,
- ):
+ ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False):
with app.test_request_context(
"/logout/local",
base_url="https://example.azurewebsites.net",
@@ -63,7 +165,7 @@ def test_local_logout_uses_app_service_easy_auth_logout():
):
session["user"] = {"name": "Test User"}
- response = app.view_functions["local_logout"]()
+ response = app.view_functions["frontend_authentication.local_logout"]()
assert response.status_code == 302, f"Expected redirect response, got {response.status_code}"
assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, (
@@ -87,7 +189,7 @@ def test_full_logout_uses_app_service_easy_auth_logout():
"WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id",
},
clear=False,
- ):
+ ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False):
with app.test_request_context(
"/logout",
base_url="https://example.azurewebsites.net",
@@ -98,7 +200,7 @@ def test_full_logout_uses_app_service_easy_auth_logout():
"preferred_username": "user@example.com",
}
- response = app.view_functions["logout"]()
+ response = app.view_functions["frontend_authentication.logout"]()
assert response.status_code == 302, f"Expected redirect response, got {response.status_code}"
assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, (
@@ -109,10 +211,123 @@ def test_full_logout_uses_app_service_easy_auth_logout():
print("App Service Easy Auth full logout redirects through /.auth/logout")
+def test_logout_skips_easy_auth_when_platform_headers_absent():
+ """Verify logout stays local when Easy Auth is not actually serving the request.
+
+ This is the reported failure: an App Service deployment that sets
+ WEBSITE_AUTH_AAD_ALLOWED_TENANTS by hand without Easy Auth enabled used to be
+ redirected to /.auth/logout, which returned a 404.
+ """
+ print("Testing logout avoids /.auth/logout when Easy Auth headers are absent...")
+
+ app = _build_test_app()
+
+ with patch.dict(
+ os.environ,
+ {
+ "WEBSITE_HOSTNAME": "example-dev.contoso.com",
+ "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id",
+ },
+ clear=False,
+ ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False), patch.object(
+ route_module, "get_settings", Mock(return_value={})
+ ):
+ with app.test_request_context(
+ "/logout/local",
+ base_url="https://example-dev.contoso.com",
+ ):
+ session["user"] = {"name": "Test User"}
+
+ response = app.view_functions["frontend_authentication.local_logout"]()
+
+ assert response.status_code == 302, f"Expected redirect response, got {response.status_code}"
+ assert response.headers.get("Location") == "/", (
+ f"Unexpected local logout redirect: {response.headers.get('Location')}"
+ )
+ assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}"
+
+ print("Logout without Easy Auth headers avoids /.auth/logout")
+
+
+def test_easy_auth_logout_still_used_when_headers_present():
+ """Verify Easy Auth logout is preserved wherever Easy Auth genuinely intercepts requests.
+
+ Detection is per request, so a non-production host behind Easy Auth still clears the
+ upstream platform session instead of leaving it alive.
+ """
+ print("Testing Easy Auth logout is preserved on a non-production host...")
+
+ app = _build_test_app()
+
+ with patch.dict(
+ os.environ,
+ {
+ "WEBSITE_HOSTNAME": "example-dev.contoso.com",
+ },
+ clear=False,
+ ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False):
+ with app.test_request_context(
+ "/logout/local",
+ base_url="https://example-dev.contoso.com",
+ headers={"X-MS-CLIENT-PRINCIPAL-ID": "user-oid"},
+ ):
+ session["user"] = {"name": "Test User"}
+
+ response = app.view_functions["frontend_authentication.local_logout"]()
+
+ assert response.status_code == 302, f"Expected redirect response, got {response.status_code}"
+ assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, (
+ f"Unexpected local logout redirect: {response.headers.get('Location')}"
+ )
+ assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}"
+
+ print("Easy Auth logout preserved when platform headers are present")
+
+
+def test_logout_override_disables_easy_auth_logout():
+ """Verify DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT keeps logout local.
+
+ This is the documented escape hatch for deployments where Easy Auth is active but
+ /.auth/* is not routed through to the App Service origin.
+ """
+ print("Testing DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT override...")
+
+ app = _build_test_app()
+
+ with patch.dict(
+ os.environ,
+ {
+ "WEBSITE_HOSTNAME": "example-dev.contoso.com",
+ },
+ clear=False,
+ ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", True), patch.object(
+ route_module, "get_settings", Mock(return_value={})
+ ):
+ with app.test_request_context(
+ "/logout/local",
+ base_url="https://example-dev.contoso.com",
+ headers={"X-MS-CLIENT-PRINCIPAL-ID": "user-oid"},
+ ):
+ session["user"] = {"name": "Test User"}
+
+ response = app.view_functions["frontend_authentication.local_logout"]()
+
+ assert response.status_code == 302, f"Expected redirect response, got {response.status_code}"
+ assert response.headers.get("Location") == "/", (
+ f"Unexpected overridden logout redirect: {response.headers.get('Location')}"
+ )
+ assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}"
+
+ print("Override keeps logout on the local path")
+
+
if __name__ == "__main__":
tests = [
test_local_logout_uses_app_service_easy_auth_logout,
test_full_logout_uses_app_service_easy_auth_logout,
+ test_logout_skips_easy_auth_when_platform_headers_absent,
+ test_easy_auth_logout_still_used_when_headers_present,
+ test_logout_override_disables_easy_auth_logout,
]
results = []
diff --git a/functional_tests/test_data_management_search_write_fence.py b/functional_tests/test_data_management_search_write_fence.py
index 66ee05aa6..9dddad138 100644
--- a/functional_tests/test_data_management_search_write_fence.py
+++ b/functional_tests/test_data_management_search_write_fence.py
@@ -1,8 +1,9 @@
# test_data_management_search_write_fence.py
"""
Functional test for Data Management target AI Search write fencing.
-Version: 0.250.071
+Version: 0.261.004
Implemented in: 0.250.071
+Updated in: 0.261.004 for upload contention retry coverage.
This test ensures target SimpleChat Search writes drain before a migration
freezes them and cannot resume until the owning migration releases the fence.
@@ -76,6 +77,21 @@ def delete_item(self, item, partition_key, etag=None, **_kwargs):
self.items.pop(item, None)
+class ConflictHeavyGateContainer(FakeGateContainer):
+ """Simulate repeated optimistic-concurrency conflicts under upload fan-in."""
+
+ def __init__(self, conflicts_before_success):
+ super().__init__()
+ self.conflicts_before_success = conflicts_before_success
+
+ def replace_item(self, item, body, etag=None, **kwargs):
+ with self.lock:
+ if self.conflicts_before_success > 0:
+ self.conflicts_before_success -= 1
+ raise ConflictError("simulated concurrent writer")
+ return super().replace_item(item, body, etag=etag, **kwargs)
+
+
def load_fence_module():
"""Load the standalone fence module without the Flask application."""
module_name = "data_management_search_write_fence_test_module"
@@ -204,3 +220,55 @@ def test_ambiguous_target_search_write_retains_slot_until_quarantine_expires(mon
lease_seconds=150,
)
assert fence["migration_id"] == "55555555-5555-5555-5555-555555555555"
+
+
+def test_target_search_write_slot_waits_through_transient_contention(monkeypatch):
+ """Large small-file upload batches should not fail after a few rapid ETag conflicts."""
+ module = load_fence_module()
+ monkeypatch.setattr(module, "DATA_MANAGEMENT_SEARCH_WRITE_GATE_POLL_SECONDS", 0.001)
+ monkeypatch.setattr(module, "DATA_MANAGEMENT_SEARCH_WRITE_REQUEST_TIMEOUT_SECONDS", 1)
+ container = ConflictHeavyGateContainer(conflicts_before_success=20)
+
+ lease_token = module.acquire_data_management_search_write_slot(container)
+
+ assert lease_token
+ assert module.release_data_management_search_write_slot(container, lease_token) is True
+
+
+def test_local_search_writes_are_serialized_before_touching_gate(monkeypatch):
+ """Concurrent uploads in one worker should not stampede the shared Cosmos gate document."""
+ module = load_fence_module()
+ monkeypatch.setattr(module, "DATA_MANAGEMENT_SEARCH_WRITE_GATE_POLL_SECONDS", 0.001)
+ container = FakeGateContainer()
+ started = threading.Event()
+ release_first_write = threading.Event()
+ second_write_entered = []
+
+ def first_write():
+ with module.hold_data_management_search_write_slot(container):
+ started.set()
+ release_first_write.wait(timeout=2.0)
+
+ def second_write():
+ started.wait(timeout=2.0)
+ with module.hold_data_management_search_write_slot(container):
+ second_write_entered.append(True)
+
+ first_thread = threading.Thread(target=first_write)
+ second_thread = threading.Thread(target=second_write)
+ first_thread.start()
+ second_thread.start()
+ started.wait(timeout=2.0)
+ time.sleep(0.05)
+
+ assert second_write_entered == []
+ gate = container.items[module.DATA_MANAGEMENT_SEARCH_WRITE_GATE_ID]
+ assert gate["active_writer_count"] == 1
+
+ release_first_write.set()
+ first_thread.join(timeout=2.0)
+ second_thread.join(timeout=2.0)
+
+ assert second_write_entered == [True]
+ gate = container.items[module.DATA_MANAGEMENT_SEARCH_WRITE_GATE_ID]
+ assert gate["active_writer_count"] == 0
diff --git a/functional_tests/test_idle_logout_timeout.py b/functional_tests/test_idle_logout_timeout.py
index 10d388baf..0fc47ddd7 100644
--- a/functional_tests/test_idle_logout_timeout.py
+++ b/functional_tests/test_idle_logout_timeout.py
@@ -15,6 +15,7 @@
import traceback
from test_support.templates import compose_if_admin_settings
+from test_support.versioning import assert_app_version_at_least
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
@@ -302,12 +303,10 @@ def test_server_idle_timeout_wiring():
has_heartbeat_refresh_call = True
assert has_heartbeat_refresh_call, "Missing get_idle_timeout_settings(get_request_settings()) in session_heartbeat"
- required_config_markers = [
- "VERSION = \"0.250.004\""
- ]
-
- missing_config_markers = [marker for marker in required_config_markers if marker not in config_content]
- assert not missing_config_markers, f"Missing config markers: {missing_config_markers}"
+ assert_app_version_at_least(
+ "0.250.004",
+ reason="Idle session auto-logout wiring was implemented in 0.250.004.",
+ )
auth_register_def = _find_top_level_function(auth_tree, "register_route_frontend_authentication")
assert auth_register_def is not None, "Missing register_route_frontend_authentication function"
diff --git a/functional_tests/test_markdown_processing_batches_search_writes.py b/functional_tests/test_markdown_processing_batches_search_writes.py
new file mode 100644
index 000000000..78cd74a18
--- /dev/null
+++ b/functional_tests/test_markdown_processing_batches_search_writes.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""
+Functional test for Markdown document processing batch Search writes.
+Version: 0.261.006
+Implemented in: 0.261.004
+Updated in: 0.261.006 for transient OrderedDict retry coverage.
+
+This test ensures Markdown processing uses the batch chunk writer and retries
+transient OrderedDict parser concurrency errors during large workspace uploads.
+"""
+
+import ast
+import sys
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+FUNCTIONS_DOCUMENTS_PATH = REPO_ROOT / "application" / "single_app" / "functions_documents.py"
+
+
+def _find_function(module_tree, function_name):
+ for node in module_tree.body:
+ if isinstance(node, ast.FunctionDef) and node.name == function_name:
+ return node
+ raise AssertionError(f"Could not find {function_name} in functions_documents.py")
+
+
+def _called_function_names(function_node):
+ names = []
+ for node in ast.walk(function_node):
+ if not isinstance(node, ast.Call):
+ continue
+ if isinstance(node.func, ast.Name):
+ names.append(node.func.id)
+ elif isinstance(node.func, ast.Attribute):
+ names.append(node.func.attr)
+ return names
+
+
+def test_process_md_uses_batch_chunk_writer():
+ """Markdown processing should use one batched Search write path per file."""
+ module_tree = ast.parse(FUNCTIONS_DOCUMENTS_PATH.read_text(encoding="utf-8"))
+ process_md = _find_function(module_tree, "process_md")
+ call_names = _called_function_names(process_md)
+
+ assert "save_chunks_batch" in call_names
+ assert "save_chunks" not in call_names
+
+
+def test_markdown_dispatch_uses_ordered_dict_retry_helper():
+ """The background dispatcher should retry the known transient Markdown parser error."""
+ module_tree = ast.parse(FUNCTIONS_DOCUMENTS_PATH.read_text(encoding="utf-8"))
+ dispatcher = _find_function(module_tree, "process_document_upload_background")
+ retry_helper = _find_function(module_tree, "_process_markdown_with_ordered_dict_retry")
+ source = FUNCTIONS_DOCUMENTS_PATH.read_text(encoding="utf-8")
+ dispatcher_calls = _called_function_names(dispatcher)
+ retry_helper_calls = _called_function_names(retry_helper)
+
+ assert "_process_markdown_with_ordered_dict_retry" in dispatcher_calls
+ assert "process_md" in retry_helper_calls
+ assert "MARKDOWN_ORDERED_DICT_MUTATION_MESSAGE" in source
+ assert "OrderedDict mutated during iteration" in source
+
+
+if __name__ == "__main__":
+ try:
+ test_process_md_uses_batch_chunk_writer()
+ test_markdown_dispatch_uses_ordered_dict_retry_helper()
+ except Exception as exc:
+ print(f"Test failed: {exc}")
+ raise
+
+ print("Markdown processing batch Search write test passed")
+ sys.exit(0)
diff --git a/ui_tests/test_workspace_upload_request_status_copy.py b/ui_tests/test_workspace_upload_request_status_copy.py
new file mode 100644
index 000000000..7d961eb5d
--- /dev/null
+++ b/ui_tests/test_workspace_upload_request_status_copy.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""
+UI test for workspace upload request status copy.
+Version: 0.261.005
+Implemented in: 0.261.005
+
+This test ensures workspace upload progress summaries distinguish request
+confirmation from final document processing results.
+"""
+
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+UPLOAD_SURFACES = [
+ REPO_ROOT / "application" / "single_app" / "static" / "js" / "workspace" / "workspace-documents.js",
+ REPO_ROOT / "application" / "single_app" / "static" / "js" / "public" / "public_workspace.js",
+ REPO_ROOT / "application" / "single_app" / "templates" / "group_workspaces.html",
+]
+
+
+def test_upload_summaries_do_not_label_request_failures_as_document_failures():
+ """Upload summaries should not present unconfirmed requests as final document failures."""
+ for path in UPLOAD_SURFACES:
+ source = path.read_text(encoding="utf-8")
+
+ assert "Upload requests not confirmed" in source
+ assert "Check the document list below for final processing status" in source
+ assert "Uploaded ${completed}/${files.length}${failed ? `, Failed: ${failed}` : ''}" not in source
+
+
+if __name__ == "__main__":
+ test_upload_summaries_do_not_label_request_failures_as_document_failures()
+ print("Workspace upload request status copy test passed")