From 5dfa2294132bbe4eabe7b23ac4e2ceeaef2ab1a7 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 14:49:57 -0800 Subject: [PATCH 1/7] init with analysis --- .serena/.gitignore | 1 + .serena/memories/project_overview.md | 24 + .serena/memories/suggested_commands.md | 26 + .serena/memories/task_completion.md | 7 + .serena/project.yml | 112 ++++ async-structured-concurrency-analysis.md | 565 ++++++++++++++++++ build-tools/.serena/.gitignore | 1 + build-tools/.serena/project.yml | 112 ++++ .../async-structured-concurrency-analysis.md | 329 ++++++++++ 9 files changed, 1177 insertions(+) create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/project_overview.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion.md create mode 100644 .serena/project.yml create mode 100644 async-structured-concurrency-analysis.md create mode 100644 build-tools/.serena/.gitignore create mode 100644 build-tools/.serena/project.yml create mode 100644 build-tools/async-structured-concurrency-analysis.md diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000000..14d86ad62301 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 000000000000..7ff418ae77ac --- /dev/null +++ b/.serena/memories/project_overview.md @@ -0,0 +1,24 @@ +# Fluid Framework - Project Overview + +## Purpose +Distributed real-time collaborative web application framework using JavaScript/TypeScript. + +## Tech Stack +- TypeScript (strict mode) +- pnpm monorepo +- ESLint + Biome for linting/formatting +- API Extractor for API docs +- Mocha for testing, c8 for coverage + +## Structure +- packages/ - Core packages (@fluidframework/*) +- experimental/ - Experimental packages +- examples/ - Example apps +- azure/packages/ - Azure-specific +- tools/ - Build tools + +## Key Conventions +- Dual ESM/CJS builds (lib/ for ESM, dist/ for CJS) +- Package exports use release tags: /public, /beta, /alpha, /legacy, /internal +- workspace:~ for internal deps +- Conventional commits diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 000000000000..0f061f5a0d03 --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,26 @@ +# Suggested Commands + +## Package Manager +- `corepack enable` - Enable corepack +- `pnpm install` - Install deps + +## Build +- `pnpm build` - Build all +- `pnpm build --filter @fluidframework/` - Build specific package + +## Test +- `pnpm test` - Run tests (in package dir) +- `pnpm test:coverage` - With coverage + +## Lint/Format +- `pnpm lint` - Lint +- `pnpm lint:fix` - Fix lint issues +- `pnpm format` - Format with Biome + +## Clean +- `pnpm clean` - Clean build artifacts + +## System Utils (Darwin) +- fd instead of find +- sd instead of sed +- rg instead of grep diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md new file mode 100644 index 000000000000..1b82925a8d05 --- /dev/null +++ b/.serena/memories/task_completion.md @@ -0,0 +1,7 @@ +# Task Completion Checklist + +1. Ensure code compiles: `pnpm build --filter @fluidframework/` +2. Run tests: `pnpm test` in the package directory +3. Run lint: `pnpm lint` in the package directory +4. Format: `pnpm format` +5. Use conventional commits diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000000..a103dc0bc632 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,112 @@ +# the name by which the project can be referenced within Serena +project_name: "main" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# powershell python python_jedi r rego +# ruby ruby_solargraph rust scala swift +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in all projects +# same syntax as gitignore, so you can use * and ** +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" diff --git a/async-structured-concurrency-analysis.md b/async-structured-concurrency-analysis.md new file mode 100644 index 000000000000..00e424b0018e --- /dev/null +++ b/async-structured-concurrency-analysis.md @@ -0,0 +1,565 @@ +# Fluid Framework Client Packages: Async & Structured Concurrency Benefit Analysis + +Analysis of packages in the client workspace, rated by async code density and potential benefit +from automatic resource handling using structured concurrency principles (e.g., the effection library). + +## Background: What Effection Provides + +[Effection](https://frontside.com/effection) is a structured concurrency library for JavaScript/TypeScript +that provides: + +- **Scoped resource lifecycles**: Resources created in a scope are automatically cleaned up when the scope exits +- **`resource()`**: Define managed resources (connections, timers, listeners) with guaranteed teardown +- **`spawn()`**: Launch concurrent tasks that are automatically halted when their parent completes +- **`call()`**: Wrap async functions into scope-aware operations with error boundaries +- **Automatic cancellation**: No need for manual AbortController/signal wiring — scope exit halts everything + +These replace manual patterns like cleanup arrays, dispose chains, clearTimeout calls, +removeListener bookkeeping, and AbortController propagation. + +## Rating System + +**Async Density** (amount of async code): Low / Medium / High / Very High + +**Effection Benefit** (how much structured concurrency would help): 1-5 stars +- `*` = Minimal benefit (mostly sync, simple patterns) +- `**` = Some benefit (moderate async, manageable cleanup) +- `***` = Moderate benefit (significant async, manual resource tracking) +- `****` = High benefit (complex lifecycles, multiple concurrent resources) +- `*****` = Transformative benefit (deeply nested async lifecycles, error-prone teardown) + +--- + +## Tier 1: Highest Benefit from Structured Concurrency + +### `@fluidframework/container-runtime` + +| Metric | Value | +|---|---| +| Async Density | **Very High** | +| Async functions | ~132 across 39 files | +| `await` statements | ~964 across 45 files | +| `new Promise` | 37 across 14 files | +| `Deferred` usage | 33 across 10 files | +| dispose/close refs | ~150 across 36 files | +| `.off()` cleanup | 52 across 14 files | +| Timer usage | 20 across 6 files | +| **Effection Benefit** | **`*****`** | + +**Why:** This is the most complex async package in the entire workspace. It manages summarization +(RunningSummarizer, SummaryManager), garbage collection with session timers, blob management, +PendingStateManager, and data store lifecycles. It has hand-rolled cleanup arrays +(`eventsCleanup: (() => void)[]`), multiple timer types (idle timers, ack timers, expiry timers), +and deeply nested concurrent operations via `Promise.all/race` (24 occurrences). Effection's +`resource()` pattern would replace all manual dispose chains, `spawn()` would manage the +summarizer/GC background tasks, and scope-based teardown would eliminate the risk of missed cleanup +in error paths. + +Key subsystems that would benefit: +- `RunningSummarizer` with its `eventsCleanup` array and `pendingAckTimer` +- `SummaryGenerator` with `summarizeTimer` +- `SummarizerHeuristics` with `idleTimer` +- `GarbageCollection` with session expiry timer and unreferenced node timers +- `ContainerRuntime.dispose()` comprehensive cleanup of all subsystems + +--- + +### `@fluidframework/container-loader` (packages/loader/container-loader) + +| Metric | Value | +|---|---| +| Async Density | **Very High** | +| Async functions | ~95 | +| `.then()` calls | 51 across 13 files | +| `new Promise` | ~6 | +| dispose/close refs | ~60+ | +| AbortController | 13 across 8 files | +| Socket/connection mgmt | Heavy (ConnectionManager, DeltaManager) | +| `finally` blocks | 10 files | +| **Effection Benefit** | **`*****`** | + +**Why:** Manages the entire container lifecycle: WebSocket delta stream connections, reconnection +loops with retry/backoff, abort signals, connection state machines, and coordinated teardown of +ConnectionManager + DeltaManager + Protocol. The `ConnectionManager.setupNewSuccessfulConnection` / +`disconnectFromDeltaStream` pattern is a textbook case for effection's `resource()` — the connection +could be modeled as a resource that auto-cleans on scope exit. The reconnection loop with +cancellation is exactly what `spawn()` + scope teardown replaces. + +Key patterns that map directly to effection: +- `ConnectionManager` WebSocket lifecycle -> `resource()` +- Reconnection loop with backoff -> `spawn()` with automatic cancellation +- `DeltaManager.dispose()` with `removeAllListeners()` -> scope exit +- AbortController propagation (13 occurrences) -> native scope cancellation + +--- + +### `@fluidframework/odsp-driver` + +| Metric | Value | +|---|---| +| Async Density | **Very High** | +| Async functions | ~123 across 37 files | +| `.catch()` | 70 across 27 files | +| `finally` blocks | 15 across 6 files | +| Socket/HTTP refs | ~1,510 across 59 files | +| Timer usage | 18 across 10 files | +| AbortController | 25 across 12 files | +| **Effection Benefit** | **`*****`** | + +**Why:** The most I/O-intensive package. Manages socket connection pooling with reference counting +(`SocketReference` class with delayed 2-second cleanup), token refresh timers +(`joinSessionRefreshTimer`), OpsCache with timer-based flushing, and extensive HTTP fetch operations. +The socket pool with manual reference counting is exactly the kind of resource lifecycle that +effection's scoped resources eliminate. The `AbortController` usage (25 occurrences) shows the team +is already fighting cancellation problems that structured concurrency solves natively. + +Key patterns: +- `SocketReference` class with manual ref counting -> `resource()` with scope ownership +- `OpsCache.dispose()` clearing timers and batches -> scope-based teardown +- `joinSessionRefreshTimer` management -> `spawn()` background task +- 25 AbortController usages -> eliminated by scope cancellation + +--- + +### `@fluid-internal/test-service-load` + +| Metric | Value | +|---|---| +| Async Density | **High** | +| Async functions | ~37 | +| `.then()` calls | 6 | +| `new Promise` | 12 | +| `.catch()` | 9 across 3 files | +| Event listeners | 35+ registrations | +| Timer cleanup | 10 setTimeout, only 1 clearTimeout | +| **Effection Benefit** | **`****`** | + +**Why:** Long-running stress test orchestrator managing multiple containers, runners, and data +stores concurrently. Has timer-based operations, event-driven coordination between containers, and +fault injection patterns. The timer imbalance (10 setTimeout vs 1 clearTimeout) is a concrete +resource leak risk that structured concurrency eliminates by design. Effection's `sleep()` is +automatically cancelled when the parent scope exits. + +--- + +## Tier 2: Significant Benefit + +### `@fluidframework/routerlicious-driver` + +| Metric | Value | +|---|---| +| Async Density | **High** | +| Async functions | ~76 across 21 files | +| `.catch()` | 13 across 7 files | +| dispose pattern | **Empty `dispose()` method** | +| **Effection Benefit** | **`****`** | + +**Why:** Has an **empty `dispose()` method** in `DocumentService` — a clear sign that cleanup is +under-implemented. Manages WebSocket connections (via base class), REST API calls with rate limiting +(24 concurrent max), and multiple cache implementations without explicit disposal. Effection would +enforce proper cleanup through scope-based resource management. + +--- + +### `@fluidframework/agent-scheduler` + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~12 | +| `new Promise` | 1 | +| **No dispose method** | Despite being long-lived | +| **Effection Benefit** | **`***`** | + +**Why:** No `dispose()` method despite being a long-lived object with quorum event listeners. +Event listener on quorum (line 276) may not be removed. Effection's scope-based teardown would +auto-cleanup quorum listeners when the scheduler scope exits. + +--- + +### `@fluidframework/datastore` + +| Metric | Value | +|---|---| +| Async Density | **Medium-High** | +| Async functions | 26 across 6 files | +| `await` | 45 across 8 files | +| dispose refs | 8 across 3 files | +| **Effection Benefit** | **`***`** | + +**Why:** Manages data store runtime, channel contexts (remote and local), and storage services. +Relies on parent container-runtime for lifecycle but has its own async loading paths. The `Deferred` +pattern usage (2 instances) and channel delta connections suggest moderate benefit from structured +task management. + +--- + +### `@fluidframework/dds/tree` (SharedTree) + +| Metric | Value | +|---|---| +| Async Density | **Medium** (mostly sync core, async in lifecycle) | +| dispose calls | 50+ (branches, checkouts, views, transactions, indexes) | +| removeListener | Significant cleanup code | +| **Effection Benefit** | **`***`** | + +**Why:** While the core tree algorithms are synchronous, the lifecycle management is complex. +TreeCheckout, branches, transactions, views, and indexes all implement `dispose()` with cascading +teardown (e.g., `TreeCheckout.dispose()` disposes transaction branch, transaction, revertibles, +and all views). This cascading dispose pattern maps well to effection's nested scopes. + +--- + +### `@fluidframework/dds/sequence` + `@fluidframework/dds/merge-tree` + +| Metric | Value | +|---|---| +| Async Density | **Low-Medium** | +| dispose patterns | Intervals, interval collections, revertibles | +| removeListener | 5+ per package | +| **Effection Benefit** | **`***`** | + +**Why:** IntervalCollection manages interval lifecycles with dispose, and the merge-tree has +attribution policy listeners. The test infrastructure (`TestClientLogger`) has explicit dispose +patterns. Moderate benefit for managing the interval lifecycle graph. + +--- + +### `@fluidframework/driver-utils` + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~57 across 16 files | +| AbortController | 13 occurrences | +| **Effection Benefit** | **`***`** | + +**Why:** Provides connection retry utilities and throttling that other drivers depend on. The +`runWithRetry` function with AbortSignal support and `parallelRequests` with timer cleanup are +patterns that structured concurrency makes more composable. + +--- + +### `@fluidframework/devtools-core` (packages/tools/devtools) + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~71 across 11 files | +| dispose patterns | BaseDevtools with window message handlers | +| Event listeners | Window message listeners, container event handlers | +| **Effection Benefit** | **`***`** | + +**Why:** Manages window message handlers, container event subscriptions, and data visualization +graphs with explicit disposal. The `BaseDevtools` class has multiple event handlers that need +coordinated cleanup. Exemplary existing disposal patterns, but would still benefit from scope-based +automation. + +--- + +## Tier 3: Moderate Benefit + +### `@fluidframework/dds/task-manager` + +| Metric | Value | +|---|---| +| Async Density | **Low-Medium** | +| removeListener | 19 occurrences | +| **Effection Benefit** | **`**`** | + +**Why:** Heavy event listener management relative to its size, but limited async operations. + +--- + +### `@fluidframework/dds/map` + `@fluidframework/dds/directory` + +| Metric | Value | +|---|---| +| Async Density | **Low** | +| dispose refs | ~10 | +| **Effection Benefit** | **`**`** | + +**Why:** Directory has dispose patterns (`dispose(error?)`) but mostly synchronous operations. + +--- + +### `@fluidframework/local-driver` + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | 40 across 8 files | +| **Effection Benefit** | **`**`** | + +**Why:** Clean async/await throughout, basic resource cleanup. Simpler I/O patterns than ODSP. + +--- + +### `@fluidframework/fluid-static` (packages/framework/fluid-static) + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~14 | +| dispose patterns | 3 in FluidContainer | +| **Effection Benefit** | **`**`** | + +**Why:** Container creation/loading wrappers with moderate async. Benefits from cleaner container +lifecycle management. + +--- + +### `@fluidframework/presence` + +| Metric | Value | +|---|---| +| Async Density | **Low** (synchronous signal-based architecture) | +| Timer usage | 26 occurrences across 5 files | +| Event cleanup | 22 occurrences across 10 files | +| **Effection Benefit** | **`**`** | + +**Why:** Has a well-designed `TimerManager` abstraction for centralized timer cleanup, but the +heavy timer usage (26 occurrences) still requires manual management. Effection would simplify +the timer lifecycle. + +--- + +### `@fluidframework/test-utils` + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~59 | +| Event listeners | 20+ registrations | +| Timer cleanup | 7 clearTimeout calls | +| **Effection Benefit** | **`**`** | + +**Why:** `LoaderContainerTracker` manages container lifecycle tracking with event listeners. +Comments about memory leaks in test contexts suggest past issues. Structured concurrency would +simplify the test container lifecycle. + +--- + +### `@fluid-experimental/tree` (Legacy SharedTree) + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~20 in source | +| Timer mgmt | heartbeat timer with clearInterval | +| Event cleanup | `.off()` patterns | +| try-catch | 230+ occurrences | +| **Effection Benefit** | **`**`** | + +**Why:** `MergeHealth` has heartbeat timer management, SharedTree has async `loadCore`. Migration +shim manages tree swapping with complex state. Moderate benefit from scope-based resource management. + +--- + +### `@fluidframework/tool-utils` + +| Metric | Value | +|---|---| +| Async Density | **Medium** | +| Async functions | ~23 | +| Lock management | try/finally with async-mutex | +| **Effection Benefit** | **`**`** | + +**Why:** Excellent existing lock management with try/finally. HTTP server for token acquisition +could be modeled as an effection resource. + +--- + +## Tier 4: Low Benefit + +### `@fluidframework/core-utils` (packages/common/core-utils) + +| Metric | Value | +|---|---| +| Async Density | **Low** | +| Key classes | Timer, PromiseTimer, PromiseCache, delay() | +| clearTimeout | 5 occurrences | +| **Effection Benefit** | **`*`** | + +**Why:** Provides the `Timer` and `PromiseCache` primitives that _other_ packages use. These could +potentially be reimplemented as effection resources, which would cascade benefits upward. But the +package itself is simple. Note: `delay()` utility has no cancellation mechanism — a gap that +effection's `sleep()` fills natively. + +--- + +### `@fluidframework/dds/cell`, `counter`, `matrix`, `shared-object-base` + +| Metric | Value | +|---|---| +| Async Density | **Low** | +| **Effection Benefit** | **`*`** | + +**Why:** Mostly synchronous DDS implementations. `shared-object-base` provides foundational +`dispose()` pattern (callbacksHelper + opProcessingHelper cleanup) inherited by all DDSs. +Minimal resource management needed beyond what the base class provides. + +--- + +### `@fluidframework/runtime-utils`, `@fluidframework/id-compressor` + +| Metric | Value | +|---|---| +| Async Density | **Minimal to None** | +| **Effection Benefit** | **`*`** | + +**Why:** `id-compressor` is **entirely synchronous** — pure computation with zero async functions, +zero promises, zero awaits. `runtime-utils` is stateless utilities. No resource management needed. + +--- + +### `@fluidframework/azure-client`, `@fluidframework/odsp-client` + +| Metric | Value | +|---|---| +| Async Density | **Low** | +| **Effection Benefit** | **`*`** | + +**Why:** High-level wrapper APIs that delegate to drivers. The real async complexity lives in the +driver packages underneath. + +--- + +### `@fluidframework/file-driver` + +| Metric | Value | +|---|---| +| Async Density | **Low** | +| Async functions | 15 across 4 files | +| **Effection Benefit** | **`*`** | + +**Why:** Minimal async complexity (file I/O only). Simple `close()` methods. No timer or event +listener management needed. + +--- + +### Azure packages (`azure-local-service`, `azure-service-utils`) + +| Metric | Value | +|---|---| +| Async Density | **None** | +| **Effection Benefit** | **`*`** | + +**Why:** `azure-local-service` is a one-line wrapper around tinylicious. `azure-service-utils` is +pure synchronous JWT generation. No async code at all. + +--- + +### Definition packages + +`container-runtime-definitions`, `datastore-definitions`, `runtime-definitions`, +`driver-definitions`, `core-interfaces`, `container-definitions` + +| **Effection Benefit** | **`*`** (N/A — types/interfaces only) | + +--- + +## Summary Table + +| Package | Async Density | Benefit | Key Reasons | +|---------|:---:|:---:|---| +| **container-runtime** | Very High | `*****` | 132 async fns, 964 awaits, manual cleanup arrays, timers, GC | +| **container-loader** | Very High | `*****` | WebSocket lifecycle, reconnection loops, abort signals | +| **odsp-driver** | Very High | `*****` | Socket pooling w/ ref counting, 1500+ I/O refs, tokens | +| **test-service-load** | High | `****` | Long-running orchestration, fault injection, timer leak | +| **routerlicious-driver** | High | `****` | Empty dispose(), WebSocket, rate limiting | +| **agent-scheduler** | Medium | `***` | No dispose(), quorum listener leak risk | +| **datastore** | Med-High | `***` | Channel lifecycles, deferred loading | +| **dds/tree** | Medium | `***` | Cascading dispose (branches/checkouts/views) | +| **dds/sequence + merge-tree** | Low-Med | `***` | Interval lifecycle, attribution listeners | +| **driver-utils** | Medium | `***` | Retry/throttle utilities, AbortController | +| **devtools-core** | Medium | `***` | Window message handlers, container events | +| **dds/task-manager** | Low-Med | `**` | 19 removeListener calls | +| **dds/map + directory** | Low | `**` | Dispose patterns | +| **local-driver** | Medium | `**` | Clean async, basic cleanup | +| **fluid-static** | Medium | `**` | Container lifecycle wrappers | +| **presence** | Low | `**` | TimerManager abstraction, 26 timer usages | +| **test-utils** | Medium | `**` | Container tracking, memory leak comments | +| **experimental/tree** | Medium | `**` | Heartbeat timer, migration shim | +| **tool-utils** | Medium | `**` | Lock management, HTTP server lifecycle | +| **core-utils** | Low | `*` | Timer/PromiseCache primitives (cascade potential) | +| **cell, counter, matrix** | Low | `*` | Mostly synchronous DDSs | +| **runtime-utils** | Minimal | `*` | Stateless utilities | +| **id-compressor** | None | `*` | Purely synchronous computation | +| **azure/odsp-client** | Low | `*` | High-level wrappers | +| **file-driver** | Low | `*` | Minimal file I/O | +| **azure-local-service** | None | `*` | Tinylicious wrapper | +| **azure-service-utils** | None | `*` | Sync JWT generation | +| **\*-definitions** | N/A | `*` | Types only | + +--- + +## Strategic Recommendation + +The highest ROI for introducing effection would be the **"lifecycle spine"** of the framework: + +### Phase 1: Establish the Pattern + +**Start with `container-loader`** — it's the entry point for all container lifecycles and manages +the most critical resource (the WebSocket connection). Modeling `ConnectionManager` as an effection +`resource()` would establish the pattern. + +Concrete mapping: +- `ConnectionManager` -> `resource()` with auto-dispose on scope exit +- Reconnection loop -> `spawn()` with automatic cancellation on disconnect +- `DeltaManager` -> scoped child that auto-halts with container +- AbortController wiring (13 occurrences) -> eliminated by scope cancellation + +### Phase 2: Core Runtime + +**Then `container-runtime`** — the summarizer, GC, and blob manager subsystems are already +organized as quasi-independent workers. Converting them to `spawn()`-ed tasks within a container +scope would eliminate the manual cleanup arrays. + +Concrete mapping: +- `RunningSummarizer` -> `spawn()` background task with `resource()` for timers +- `GarbageCollection` -> `spawn()` with scoped session expiry timers +- `eventsCleanup: (() => void)[]` pattern -> eliminated entirely by scope teardown +- `Deferred` usage (33 instances) -> effection's native task coordination + +### Phase 3: I/O Layer + +**Then `odsp-driver`** — the socket pool with reference counting is the most fragile resource +management code. An effection resource with scope-based ownership would replace the +`SocketReference` class entirely. + +Concrete mapping: +- `SocketReference` with ref counting -> `resource()` with scope ownership +- `OpsCache` with timer-based flushing -> `spawn()` + `sleep()` loop +- Token refresh timers -> `spawn()` background refresh task +- AbortController propagation (25 occurrences) -> scope cancellation + +### Phase 4: Foundation Primitives + +**`core-utils` Timer/PromiseCache** could be reimplemented as effection primitives, which would +cascade improvements to all packages that use them. + +### Lower Priority + +The DDSs and test packages are lower priority because their async patterns are simpler and more +localized. The experimental packages and high-level client wrappers delegate their complexity to +the packages above. + +--- + +## Methodology + +This analysis was conducted by 7 parallel exploration agents scanning all `.ts` source files +(excluding test files and `.d.ts` where noted) across the client workspace. Each agent searched for: + +- `async` function/method declarations +- `.then()` promise chaining +- `new Promise` constructor usage +- `try/catch/finally` blocks around async code +- Resource management patterns: `dispose`, `close`, `cleanup`, `destroy`, `removeListener`, + `off()`, `removeEventListener`, `finally` blocks +- Timer patterns: `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` +- I/O resource management: WebSocket, HTTP connections, AbortController +- Resource leak indicators: TODO comments, "leak" comments, cleanup warnings + +Date: 2026-02-09 diff --git a/build-tools/.serena/.gitignore b/build-tools/.serena/.gitignore new file mode 100644 index 000000000000..14d86ad62301 --- /dev/null +++ b/build-tools/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/build-tools/.serena/project.yml b/build-tools/.serena/project.yml new file mode 100644 index 000000000000..99aa91f9509c --- /dev/null +++ b/build-tools/.serena/project.yml @@ -0,0 +1,112 @@ +# the name by which the project can be referenced within Serena +project_name: "build-tools" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# powershell python python_jedi r rego +# ruby ruby_solargraph rust scala swift +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in all projects +# same syntax as gitignore, so you can use * and ** +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" diff --git a/build-tools/async-structured-concurrency-analysis.md b/build-tools/async-structured-concurrency-analysis.md new file mode 100644 index 000000000000..6440707b9566 --- /dev/null +++ b/build-tools/async-structured-concurrency-analysis.md @@ -0,0 +1,329 @@ +# Build-Tools Async API Analysis & Effection Structured Concurrency Recommendations + +## Effection 4 Overview (for context) + +Effection is a structured concurrency library for JavaScript/TypeScript that replaces `async/await` with generator functions (`function*` + `yield*`). Key properties: + +| Vanilla JS | Effection | +|---|---| +| `async function` | `function*` | +| `await expr` | `yield* expr` | +| `Promise` | `Operation` | +| `Promise.all()` | `all()` | +| `Promise.race()` | `race()` | +| `new Promise(...)` | `action(function*(resolve) {...})` | +| (nothing) | `spawn()` — structured child tasks | +| (nothing) | `resource()` — lifecycle-managed resources | +| `main().catch(...)` | `main(function*() {...})` — orderly shutdown | + +The core value proposition: **when a scope exits (success, error, or cancellation), all spawned children are automatically torn down.** This eliminates leaked resources, dangling processes, and fire-and-forget patterns. + +### Key Effection 4 APIs + +- **`run(operation)`** — Execute an operation, returns a Task (awaitable + yieldable) +- **`main(operation)`** — Top-level entry point with SIGINT/SIGTERM handling and orderly shutdown +- **`spawn(operation)`** — Run a child operation concurrently, bound to the parent scope +- **`call(operation)`** — Run an operation sequentially in a new scope (scope destroyed on return) +- **`resource(constructor)`** — Create a lifecycle-managed resource with guaranteed cleanup via `provide()` +- **`action(executor)`** — Bridge callback-based APIs into operations (like `new Promise()` but stateless) +- **`sleep(ms)`** — Pause for a duration +- **`suspend()`** — Pause indefinitely until scope ends (used with `try/finally` for cleanup) +- **`ensure(callback)`** — Register guaranteed cleanup when scope ends +- **`race(operations)`** — First to complete wins, losers are halted +- **`all(operations)`** — Wait for all, halt all on any failure +- **`scoped(operation)`** — Explicit scope boundary (new in v4) +- **`useAbortSignal()`** — Scope-bound AbortSignal for integration with fetch, etc. +- **`createSignal()`** — Bridge external callbacks into Effection streams +- **`each(stream)`** — Iterate over a stream with structured concurrency +- **`on(target, event)`** — Create a stream from EventTarget events + +--- + +## Package Rankings (Most → Least Async Complexity) + +### 1. `build-tools` (fluid-build) — CRITICAL complexity, HIGHEST effection benefit + +**Async surface area:** ~57 source files, worker pools, build graph orchestration, child process management + +#### Key Patterns Found + +| Pattern | Location | Issue | +|---|---|---| +| Worker pool with threads + child processes | `tasks/workers/workerPool.ts` | No timeout on `once("message")` — can hang forever | +| Fire-and-forget `.then()` | `tasks/workers/worker.ts:60,66` | `messageHandler(message).then(parentPort.postMessage)` — no `.catch()` | +| `Promise.all` for parallel task runs | `buildGraph.ts:428`, `groupTask.ts:95` | Build tasks run concurrently without cancellation support | +| Custom `AsyncPriorityQueue` | `tasks/task.ts` | Queue draining with `await new Promise(setImmediate)` yield pattern | +| `execAsync` wrapping `child_process` | `common/utils.ts:47-68` | Never rejects — errors captured in result object | +| File hash cache promise chains | `fileHashCache.ts:28` | `readFile(path).then(hash)` with no `.catch()` | +| Memory-pressure worker killing | `workerPool.ts:115-137` | Heuristic-based (free mem < 4GB), no task priority awareness | +| Event listener cleanup utility | `workerPool.ts:54-61` | Good pattern: `installTemporaryListener` with cleanup array | +| `uncaughtException`/`unhandledRejection` | `worker.ts:68-79` | Caught in worker process, but `process.exit(-1)` is abrupt | +| Top-level `main().catch()` | `fluidBuild.ts:165-170` | Good, but no SIGINT/SIGTERM handling | + +#### Why Effection Would Help Most Here + +- **`resource()`** would perfectly model the worker pool lifecycle — workers are created, used, and automatically cleaned up when the build scope ends +- **`spawn()`** for each build task within the build graph scope — if the build is cancelled (Ctrl+C), all spawned tasks and workers tear down automatically +- **`main()`** replaces the manual `main().catch()` + missing signal handlers with built-in orderly shutdown +- The fire-and-forget `.then()` in worker.ts would become a `spawn()` within a structured scope, ensuring errors propagate + +#### Concrete Example: Worker Pool as Resource + +```typescript +// Current pattern (manual lifecycle) +const workerPool = new WorkerPool(); +try { + await buildGraph.build(workerPool); +} finally { + workerPool.reset(); // manual cleanup +} + +// Effection pattern (automatic lifecycle) +function* workerPoolResource(options): Operation { + return resource(function* (provide) { + const pool = new WorkerPool(options); + try { + yield* provide(pool); + } finally { + pool.reset(); // guaranteed cleanup on scope exit + } + }); +} + +main(function* () { + const pool = yield* workerPoolResource({ concurrency: 8 }); + yield* buildGraph(pool); // Ctrl+C tears down everything +}); +``` + +--- + +### 2. `build-cli` (flub) — HIGH complexity, HIGH effection benefit + +**Async surface area:** 89+ files, oclif command framework, state machines, concurrent package operations + +#### Key Patterns Found + +| Pattern | Location | Issue | +|---|---|---| +| `async.mapLimit` for concurrent packages | `BasePackageCommand.ts:155-171` | Good error collection, but no cancellation | +| State machine with infinite async loop | `stateMachineCommand.ts:97-146` | `eslint-disable no-await-in-loop` — sequential by design, but no SIGTERM | +| `execa.command()` for subprocess | `commands/exec.ts:28` | `stdio: "inherit"`, `shell: true` — no error handling | +| `Promise.allSettled` for changelogs | `vnext/generate/changelog.ts:89-97` | Good — handles partial failures | +| `Promise.all` for bulk file ops | `repoPolicyCheck/npmPackages.ts:737-751, 1793` | Parallel mkdir/read operations | +| Silent `.catch(() => undefined)` | `npmPackages.ts:750` | Config file errors swallowed | +| Git merge with cleanup in catch | `library/git.ts:301-310` | Good: abort merge on conflict | +| No process signal handlers | Global | Long-running state machines won't gracefully shutdown | +| Retry loop for npm publish | `publish/tarballs.ts:129-161` | Sequential, proper retry, but no timeout | + +#### Why Effection Would Help Here + +- State machine commands could use `main()` for automatic SIGTERM/SIGINT handling +- `BasePackageCommand.processPackages` could use `spawn()` per package within a structured scope instead of `async.mapLimit` — same concurrency control but with cancellation built in +- The npm publish retry loop would benefit from `race()` with a timeout operation +- Git operations that need cleanup (merge abort) would be cleaner as `resource()` patterns + +#### Concrete Example: Package Processing with Structured Concurrency + +```typescript +// Current pattern (async.mapLimit) +await async.mapLimit(packages, concurrency, async (pkg) => { + try { + await this.processPackage(pkg); + } catch (error) { + errors.push(error); + } +}); + +// Effection pattern (spawn with structured scope) +function* processPackages(packages, concurrency): Operation { + const errors: string[] = []; + // Effection's spawn + a semaphore pattern for bounded concurrency + const tasks = packages.map((pkg) => + spawn(function* () { + try { + yield* call(async () => processPackage(pkg)); + } catch (error) { + errors.push(String(error)); + } + }) + ); + yield* all(tasks); + return errors; + // On Ctrl+C: all spawned package processing is automatically halted +} +``` + +--- + +### 3. `build-infrastructure` — MODERATE complexity, MODERATE effection benefit + +**Async surface area:** ~20 async functions across git ops, version management, workspace installs + +#### Key Patterns Found + +| Pattern | Location | Issue | +|---|---|---| +| `Promise.all` for parallel saves | `buildProject.ts:286`, `versions.ts:31` | No partial failure handling; reload never runs on rejection | +| `execa` for package manager | `workspace.ts:190` | No timeout, no cancellation, no signal handling | +| Sequential git chains | `git.ts:67-255` | fetch → merge-base → diff, no error handling | +| Unnecessarily async functions | `filter.ts:249`, `package.ts:172`, `commands/list.ts` | `async` keyword with synchronous bodies | +| `.then()` instead of `await` | `packageJsonUtils.ts:106-110` | Inconsistent style | +| Silent catch in tests | `workspace.test.ts:62-66` | `catch { // nothing }` | + +#### Why Effection Would Help Here + +- **`all()`** would replace `Promise.all` with structured error propagation and cleanup +- `workspace.install()` as a **`resource()`** with automatic timeout/cancellation +- `setDependencyRange` would benefit from structured scoping — the reload step could be in a `finally` equivalent that's guaranteed to run + +#### Concrete Example: Safe Parallel Saves + +```typescript +// Current pattern (Promise.all, reload skipped on failure) +const savePromises: Promise[] = []; +for (const pkg of packagesToUpdate) { + savePromises.push(pkg.savePackageJson()); +} +await Promise.all(savePromises); +// If any reject, this never runs: +for (const pkg of packagesToUpdate) { + pkg.reload(); +} + +// Effection pattern (guaranteed reload) +function* updatePackages(packagesToUpdate): Operation { + try { + yield* all(packagesToUpdate.map((pkg) => + call(async () => pkg.savePackageJson()) + )); + } finally { + // Always runs, even if some saves failed + for (const pkg of packagesToUpdate) { + pkg.reload(); + } + } +} +``` + +--- + +### 4. `bundle-size-tools` — LOW complexity, LOW effection benefit + +**Async surface area:** Minimal — Webpack plugin with `tapAsync` callback, synchronous file I/O + +#### Key Patterns Found + +| Pattern | Location | Issue | +|---|---|---| +| Webpack `tapAsync` callback | `BundleBuddyConfigWebpackPlugin.ts:36-75` | Standard plugin pattern, callback-based | +| Synchronous fs operations | Throughout | `existsSync`, `mkdirSync`, `writeFileSync` | +| `execSync` for git commands | `gitCommands.ts:6-18` | Blocks event loop, no error handling | +| `Promise.all` for config loading | `getBundleBuddyConfigMap.ts:15-34` | No error handling on the Promise.all | +| Nested `Promise.all` for bundles | `getBundleSummaries.ts:28-52` | Good parallelization | +| Stream-to-buffer with events | `unzipStream.ts:9-27` | Proper error/close handlers, but no timeout | +| ADO fallback loop | `AdoSizeComparator.ts:89-201` | Complex state, `.catch(() => undefined)` error suppression | + +#### Why Effection Isn't Particularly Helpful + +This package does almost no async work. It's mostly synchronous data processing (webpack stats analysis) with a callback-based webpack plugin API that isn't easily replaceable. The ADO integration code could benefit marginally, but the scope is small. + +--- + +### 5. `version-tools` — MINIMAL complexity, NO effection benefit + +**Async surface area:** Only oclif command boilerplate (`async run()`, `await this.parse()`) + +#### Key Patterns Found + +| Pattern | Location | Issue | +|---|---|---| +| oclif `async run()` | `commands/version.ts:91`, `commands/version/latest.ts:53` | Standard framework pattern | +| Entry point IIFE | `bin/run.js:8-11` | `(async () => { await oclif.execute() })()` | +| `execSync` for git tags | `versions.ts:173` | No error handling | + +#### Why Effection Isn't Helpful + +This is essentially a synchronous library (semver manipulation) wrapped in oclif's async command infrastructure. There's nothing to manage or clean up. + +--- + +## Summary Ranking + +| Rank | Package | Async Complexity | Effection Benefit | Key Opportunity | +|:---:|---|:---:|:---:|---| +| **1** | `build-tools` | Critical | **Highest** | Worker pool lifecycle, build task graph, process cleanup, signal handling | +| **2** | `build-cli` | High | **High** | State machine commands, concurrent package processing, subprocess management | +| **3** | `build-infrastructure` | Moderate | **Moderate** | Parallel save operations, package manager subprocess management | +| **4** | `bundle-size-tools` | Low | **Low** | Nearly all synchronous; webpack callback API not a good fit | +| **5** | `version-tools` | Minimal | **None** | Pure computation, no async to manage | + +--- + +## Cross-Cutting Issues Found Across All Packages + +### No Signal Handling Anywhere + +None of the five packages handle SIGINT or SIGTERM. This means: +- Long-running builds (`build-tools`) can't clean up workers on Ctrl+C +- State machine commands (`build-cli`) can't persist state on termination +- Package manager installs (`build-infrastructure`) can't abort cleanly + +Effection's `main()` solves this universally. + +### No Cancellation/Timeout Support + +No package uses `AbortController`, `AbortSignal`, or timeout mechanisms. This means: +- Worker messages can hang forever (`build-tools`) +- `execa` subprocesses can hang forever (`build-infrastructure`, `build-cli`) +- ADO API calls can hang forever (`bundle-size-tools`) + +Effection's `useAbortSignal()` and `race()` with `sleep()` solve this. + +### Inconsistent Error Handling on Concurrent Operations + +- `build-tools`: Fire-and-forget `.then()` in worker message handlers +- `build-cli`: `.catch(() => undefined)` swallowing config read errors +- `build-infrastructure`: `Promise.all` with no partial failure handling +- `bundle-size-tools`: `.catch()` converting errors to undefined in ADO comparator + +Effection's structured error propagation (errors in children propagate to parents) eliminates these patterns. + +--- + +## Top Effection Adoption Recommendations + +### Highest-Impact Opportunities (build-tools) + +1. **Worker Pool as a `resource()`** — The worker pool (`workerPool.ts`) creates/manages/destroys thread workers and child processes. As an effection resource, workers would automatically terminate when the build scope ends, eliminating the manual `reset()` calls and the missing SIGTERM cleanup. + +2. **Build Graph tasks as `spawn()`** — `buildGraph.ts` pushes tasks into `Promise.all` arrays. With effection, each task could be `spawn()`-ed within the build scope. Cancelling the build (Ctrl+C) would automatically cancel all running tasks and their subprocess children. + +3. **`main()` for entry points** — Both `fluidBuild.ts` and oclif bin scripts use `main().catch()`. Effection's `main()` provides orderly shutdown with SIGINT/SIGTERM handling for free. + +### High-Impact Opportunities (build-cli) + +4. **`BasePackageCommand.processPackages` with structured concurrency** — Replace `async.mapLimit` with a spawning pattern that provides the same bounded concurrency but adds cancellation and automatic cleanup when the command exits. + +5. **State machine lifecycle** — `StateMachineCommand` runs an infinite async loop. Wrapping this in effection's `main()` gives free signal handling and ensures all async operations in each state are cleaned up when transitioning. + +6. **Subprocess management** — Every `execa` call could be wrapped as a `resource()` that kills the child process on scope exit, eliminating the class of bugs where subprocesses outlive their parent context. + +### Moderate-Impact Opportunities (build-infrastructure) + +7. **Parallel saves with guaranteed reload** — `setDependencyRange()` and `setVersion()` use `Promise.all` followed by a reload loop that's skipped on failure. Effection's `try/finally` within a structured scope guarantees the reload always runs. + +8. **Package manager install with timeout** — `workspace.install()` spawns npm/pnpm/yarn with no timeout. As an effection operation, it could use `race()` with `sleep()` to add timeout, and `useAbortSignal()` to pass cancellation to the subprocess. + +--- + +## Migration Strategy + +If adopting effection, the recommended order would be: + +1. **Start with `build-tools`** — highest ROI, most complex async patterns, most bugs to fix +2. **Then `build-cli`** — high ROI, builds on patterns established in build-tools +3. **Then `build-infrastructure`** — moderate ROI, straightforward conversions +4. **Skip `bundle-size-tools` and `version-tools`** — not worth the migration cost + +Within each package, start with the entry points (`main()` adoption) and work inward toward the most complex async patterns (worker pools, build graphs, state machines). From 07153786452c48d63d5fb16c1d1ff1528b85a7c4 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 16:05:07 -0800 Subject: [PATCH 2/7] feat(container-loader): introduce effection-based structured concurrency Add EffectionScope, EffectionTimer, and bridge utilities (createScopedAbortController, createScopedDelay) to container-loader. Integrate scoped lifecycle management into ConnectionManager, DeltaManager, and Container so that AbortControllers and async delays are automatically cancelled when components are disposed. - ConnectionManager: scoped AbortController for connectCore(), scoped delays replace raw setTimeout in retry/reconnect paths - DeltaManager: scoped AbortControllers eliminate manual signal chaining in getDeltas(), scope cleanup on close/dispose - Container: scope-based safety-net cleanup for DOM visibility listener - Fix generator double-wrapping type error in both container-loader and container-runtime copies of EffectionScope --- packages/loader/container-loader/package.json | 1 + .../container-loader/src/connectionManager.ts | 22 ++- .../loader/container-loader/src/container.ts | 15 ++ .../container-loader/src/deltaManager.ts | 31 +++- .../src/structuredConcurrency.ts | 139 ++++++++++++++++++ .../src/test/deltaManager.spec.ts | 4 +- .../src/structuredConcurrency.ts | 87 +++++++++++ pnpm-lock.yaml | 9 ++ 8 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 packages/loader/container-loader/src/structuredConcurrency.ts create mode 100644 packages/runtime/container-runtime/src/structuredConcurrency.ts diff --git a/packages/loader/container-loader/package.json b/packages/loader/container-loader/package.json index a197a586c5f8..bbe319abd761 100644 --- a/packages/loader/container-loader/package.json +++ b/packages/loader/container-loader/package.json @@ -190,6 +190,7 @@ "@ungap/structured-clone": "^1.2.0", "debug": "^4.3.4", "double-ended-queue": "^2.1.0-0", + "effection": "^4.0.2", "events_pkg": "npm:events@^3.1.0", "uuid": "^11.1.0" }, diff --git a/packages/loader/container-loader/src/connectionManager.ts b/packages/loader/container-loader/src/connectionManager.ts index 0ebb6e7334bd..c3d7ac4f98ef 100644 --- a/packages/loader/container-loader/src/connectionManager.ts +++ b/packages/loader/container-loader/src/connectionManager.ts @@ -65,6 +65,11 @@ import { import { DeltaQueue } from "./deltaQueue.js"; import { FrozenDeltaStream, isFrozenDeltaStreamConnection } from "./frozenServices.js"; import { SignalType } from "./protocol.js"; +import { + EffectionScope, + createScopedAbortController, + createScopedDelay, +} from "./structuredConcurrency.js"; import { isDeltaStreamConnectionForbiddenError } from "./utils.js"; // We double this value in first try in when we calculate time to wait for in "calculateMaxWaitTime" function. @@ -192,6 +197,8 @@ export class ConnectionManager implements IConnectionManager { private _disposed = false; + private readonly scope = new EffectionScope(); + private readonly _outbound: DeltaQueue; public get connectionVerboseProps(): Record { @@ -389,6 +396,11 @@ export class ConnectionManager implements IConnectionManager { // to switch to a mode where user edits are not accepted this.set_readonlyPermissions(true, oldReadonlyValue, disconnectReason); } + + // Close the scope — this auto-aborts any scoped AbortControllers + // and cancels any pending scoped delays. Fire-and-forget since all + // registered cleanups are synchronous. + this.scope.close().catch(() => {}); } /** @@ -523,7 +535,7 @@ export class ConnectionManager implements IConnectionManager { let lastError: unknown; - const abortController = new AbortController(); + const abortController = createScopedAbortController(this.scope); const abortSignal = abortController.signal; this.pendingConnection = { abort: (): void => { @@ -644,8 +656,8 @@ export class ConnectionManager implements IConnectionManager { this.props.reconnectionDelayHandler(delayMs, origError); } - await new Promise((resolve) => { - setTimeout(resolve, delayMs); + await createScopedDelay(this.scope, delayMs).catch(() => { + // Swallow cancellation — scope was closed during delay (dispose) }); // If we believe we're offline, we assume there's no point in trying until we at least think we're online. @@ -999,8 +1011,8 @@ export class ConnectionManager implements IConnectionManager { const delayMs = getRetryDelayFromError(reason.error); if (reason.error !== undefined && delayMs !== undefined) { this.props.reconnectionDelayHandler(delayMs, reason.error); - await new Promise((resolve) => { - setTimeout(resolve, delayMs); + await createScopedDelay(this.scope, delayMs).catch(() => { + // Swallow cancellation — scope was closed during delay (dispose) }); } diff --git a/packages/loader/container-loader/src/container.ts b/packages/loader/container-loader/src/container.ts index f6035607f005..4d4a73ad8c0c 100644 --- a/packages/loader/container-loader/src/container.ts +++ b/packages/loader/container-loader/src/container.ts @@ -126,6 +126,7 @@ import { getPackageName, } from "./contracts.js"; import { DeltaManager, type IConnectionArgs } from "./deltaManager.js"; +import { EffectionScope } from "./structuredConcurrency.js"; import type { ILoaderServices } from "./loader.js"; import { RelativeLoader } from "./loader.js"; import { @@ -532,6 +533,8 @@ export class Container private readonly storageAdapter: ContainerStorageAdapter; + private readonly _effectionScope = new EffectionScope(); + private readonly _deltaManager: DeltaManager; private service: IDocumentService | undefined; @@ -995,6 +998,12 @@ export class Container } }; document.addEventListener("visibilitychange", this.visibilityEventHandler); + // Register scope-based safety-net cleanup for the DOM listener + this._effectionScope.addCleanup(() => { + if (this.visibilityEventHandler !== undefined) { + document.removeEventListener("visibilitychange", this.visibilityEventHandler); + } + }); } } @@ -1076,6 +1085,9 @@ export class Container this.emit("closed", error); + // Close the scope — triggers safety-net cleanup (e.g. DOM listeners). + this._effectionScope.close().catch(() => {}); + if (this.visibilityEventHandler !== undefined) { document.removeEventListener("visibilitychange", this.visibilityEventHandler); } @@ -1133,6 +1145,9 @@ export class Container this.emit("disposed", error); + // Close the scope — triggers safety-net cleanup (e.g. DOM listeners). + this._effectionScope.close().catch(() => {}); + this.removeAllListeners(); if (this.visibilityEventHandler !== undefined) { document.removeEventListener("visibilitychange", this.visibilityEventHandler); diff --git a/packages/loader/container-loader/src/deltaManager.ts b/packages/loader/container-loader/src/deltaManager.ts index 25219c650dc1..9bcad0358f97 100644 --- a/packages/loader/container-loader/src/deltaManager.ts +++ b/packages/loader/container-loader/src/deltaManager.ts @@ -56,6 +56,10 @@ import type { } from "./contracts.js"; import { DeltaQueue } from "./deltaQueue.js"; import { ThrottlingWarning } from "./error.js"; +import { + EffectionScope, + createScopedAbortController, +} from "./structuredConcurrency.js"; export interface IConnectionArgs { mode?: ConnectionMode; @@ -231,7 +235,8 @@ export class DeltaManager private readonly throttlingIdSet = new Set(); private timeTillThrottling: number = 0; - public readonly closeAbortController = new AbortController(); + private readonly scope = new EffectionScope(); + public readonly closeAbortController: AbortController; private readonly deltaStorageDelayId = uuid(); private readonly deltaStreamDelayId = uuid(); @@ -434,6 +439,12 @@ export class DeltaManager ); this.close(normalizeError(error)); }); + + // Create a scoped AbortController that auto-aborts when the scope closes. + // This replaces the plain `new AbortController()` so that scope-based + // cancellation cascades to all operations that use this signal. + this.closeAbortController = createScopedAbortController(this.scope); + const props: IConnectionManagerFactoryArgs = { incomingOpHandler: (messages: ISequencedDocumentMessage[], reason: string) => { try { @@ -708,7 +719,9 @@ export class DeltaManager op.sequenceNumber >= lastExpectedOp; } - const controller = new AbortController(); + // Use a scoped AbortController — it will auto-abort when the scope + // closes, eliminating the need to manually chain it to closeAbortController. + const controller = createScopedAbortController(this.scope); let opsFromFetch = false; const opListener = (op: ISequencedDocumentMessage): void => { @@ -724,10 +737,6 @@ export class DeltaManager try { this._inbound.on("push", opListener); - assert(this.closeAbortController.signal.onabort === null, 0x1e8 /* "reentrancy" */); - this.closeAbortController.signal.addEventListener("abort", () => - controller.abort(this.closeAbortController.signal.reason), - ); const stream = this.deltaStorage.fetchMessages( from, // inclusive @@ -759,8 +768,6 @@ export class DeltaManager reason: controller.signal.reason, }); } - // eslint-disable-next-line unicorn/no-null, unicorn/prefer-add-event-listener - this.closeAbortController.signal.onabort = null; this._inbound.off("push", opListener); assert(!opsFromFetch, 0x289 /* "logic error" */); } @@ -780,6 +787,11 @@ export class DeltaManager } this._closed = true; + // Close the scope — auto-aborts all scoped AbortControllers and + // cancels any pending scoped operations. Fire-and-forget since + // registered cleanups are synchronous. + this.scope.close().catch(() => {}); + this.connectionManager.dispose(error, true /* switchToReadonly */); this.clearQueues(); this.emit("closed", error); @@ -804,6 +816,9 @@ export class DeltaManager this._disposed = true; this._closed = true; // We consider "disposed" as a further state than "closed" + // Close the scope — auto-aborts all scoped AbortControllers. + this.scope.close().catch(() => {}); + this.connectionManager.dispose(error, false /* switchToReadonly */); this.clearQueues(); diff --git a/packages/loader/container-loader/src/structuredConcurrency.ts b/packages/loader/container-loader/src/structuredConcurrency.ts new file mode 100644 index 000000000000..192e70cfab60 --- /dev/null +++ b/packages/loader/container-loader/src/structuredConcurrency.ts @@ -0,0 +1,139 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createScope, + ensure, + sleep, + suspend, + type Operation, + type Scope, + type Task, +} from "effection"; + +type CleanupFn = () => void; + +/** + * Wraps effection's scope API into a class-based interface suitable for + * integration with existing imperative lifecycle patterns. + * + * Provides three key capabilities: + * - `run()`: Execute an effection operation within this scope + * - `addCleanup()`: Register a synchronous cleanup function that runs on scope close + * - `close()`: Destroy the scope, halting all tasks and running all cleanups + */ +export class EffectionScope { + private readonly scope: Scope; + private readonly destroy: () => Promise; + private closed = false; + + public constructor() { + [this.scope, this.destroy] = createScope(); + } + + public run(operation: () => Operation): Task { + return this.scope.run(operation); + } + + public addCleanup(cleanup: CleanupFn): void { + this.scope.run(function* () { + yield* ensure(function* () { + cleanup(); + }); + yield* suspend(); + }); + } + + public async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.destroy(); + } +} + +/** + * Timer implementation backed by effection's `sleep()` operation. + * Timers are automatically cancelled when their owning scope closes. + */ +export class EffectionTimer { + private task: Task | undefined; + + public constructor( + private readonly scope: EffectionScope, + private readonly defaultTimeoutMs: number, + private readonly defaultCallback: () => void, + ) {} + + public get hasTimer(): boolean { + return this.task !== undefined; + } + + public start( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.clear(); + this.task = this.scope.run(function* () { + yield* sleep(timeoutMs); + callback(); + }); + } + + public restart( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.start(timeoutMs, callback); + } + + public clear(): void { + if (this.task === undefined) { + return; + } + this.task.halt(); + this.task = undefined; + } +} + +// ── Bridge utilities ───────────────────────────────────────────────────────── + +/** + * Creates an AbortController that automatically aborts when the owning + * scope closes. This bridges effection's cooperative cancellation with + * existing AbortSignal-based patterns. + * + * @param scope - The EffectionScope that owns this controller's lifetime. + * @returns An AbortController that will abort when `scope.close()` is called. + */ +export function createScopedAbortController(scope: EffectionScope): AbortController { + const controller = new AbortController(); + scope.addCleanup(() => { + if (!controller.signal.aborted) { + controller.abort("Scope closed"); + } + }); + return controller; +} + +/** + * Creates a promise that resolves after a delay, but rejects if the owning + * scope closes first. This is a scope-aware replacement for + * `new Promise(resolve => setTimeout(resolve, delayMs))`. + * + * @param scope - The EffectionScope that can cancel this delay. + * @param delayMs - Delay in milliseconds. + * @returns Promise that resolves after delay or rejects on scope cancellation. + */ +export function createScopedDelay(scope: EffectionScope, delayMs: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, delayMs); + scope.addCleanup(() => { + clearTimeout(timeoutId); + reject(new Error("Delay cancelled by scope closure")); + }); + }); +} diff --git a/packages/loader/container-loader/src/test/deltaManager.spec.ts b/packages/loader/container-loader/src/test/deltaManager.spec.ts index 27496db2e77d..9a71cc28f4c5 100644 --- a/packages/loader/container-loader/src/test/deltaManager.spec.ts +++ b/packages/loader/container-loader/src/test/deltaManager.spec.ts @@ -540,11 +540,11 @@ describe("Loader", () => { mockLogger.assertMatch([ { eventName: "DeltaManager_GetDeltasAborted", - reason: "DeltaManager is closed", + reason: "Scope closed", }, { eventName: "GetDeltas_Exception", - error: "DeltaManager is closed", + error: "Scope closed", }, ]); }); diff --git a/packages/runtime/container-runtime/src/structuredConcurrency.ts b/packages/runtime/container-runtime/src/structuredConcurrency.ts new file mode 100644 index 000000000000..f5ed2b88cb2e --- /dev/null +++ b/packages/runtime/container-runtime/src/structuredConcurrency.ts @@ -0,0 +1,87 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createScope, + ensure, + sleep, + suspend, + type Operation, + type Scope, + type Task, +} from "effection"; + +type CleanupFn = () => void; + +export class EffectionScope { + private readonly scope: Scope; + private readonly destroy: () => Promise; + private closed = false; + + public constructor() { + [this.scope, this.destroy] = createScope(); + } + + public run(operation: () => Operation): Task { + return this.scope.run(operation); + } + + public addCleanup(cleanup: CleanupFn): void { + this.scope.run(function* () { + yield* ensure(function* () { + cleanup(); + }); + yield* suspend(); + }); + } + + public async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.destroy(); + } +} + +export class EffectionTimer { + private task: Task | undefined; + + public constructor( + private readonly scope: EffectionScope, + private readonly defaultTimeoutMs: number, + private readonly defaultCallback: () => void, + ) {} + + public get hasTimer(): boolean { + return this.task !== undefined; + } + + public start( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.clear(); + this.task = this.scope.run(function* () { + yield* sleep(timeoutMs); + callback(); + }); + } + + public restart( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.start(timeoutMs, callback); + } + + public clear(): void { + if (this.task === undefined) { + return; + } + this.task.halt(); + this.task = undefined; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 075e2d7ff090..6f1fff8d4380 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12531,6 +12531,9 @@ importers: double-ended-queue: specifier: ^2.1.0-0 version: 2.1.0-0 + effection: + specifier: ^4.0.2 + version: 4.0.2 events_pkg: specifier: npm:events@^3.1.0 version: events@3.3.0 @@ -22271,6 +22274,10 @@ packages: effect@3.19.11: resolution: {integrity: sha512-UTEj3c1s41Ha3uzSPKKvFBZaDjZ8ez00Q2NYWVm2mKh2LXeX8j6LTg1HcQHnmdUhOjr79KHmhVWYB/zbegLO1A==} + effection@4.0.2: + resolution: {integrity: sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==} + engines: {node: '>= 16'} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -36042,6 +36049,8 @@ snapshots: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 + effection@4.0.2: {} + ejs@3.1.10: dependencies: jake: 10.9.2 From f4ac138ef55aa18629d13d7aefc95f05b440f806 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 16:13:55 -0800 Subject: [PATCH 3/7] enable sonda --- examples/utils/bundle-size-tests/package.json | 1 + .../bundle-size-tests/webpack.config.cjs | 149 ++++++++++-------- pnpm-lock.yaml | 114 ++++++++++++++ 3 files changed, 195 insertions(+), 69 deletions(-) diff --git a/examples/utils/bundle-size-tests/package.json b/examples/utils/bundle-size-tests/package.json index 6126194973df..ae3c9211176b 100644 --- a/examples/utils/bundle-size-tests/package.json +++ b/examples/utils/bundle-size-tests/package.json @@ -67,6 +67,7 @@ "mocha": "^10.8.2", "puppeteer": "^23.6.0", "rimraf": "^6.1.2", + "sonda": "^0.10.2", "source-map-explorer": "^2.5.3", "source-map-loader": "^5.0.0", "string-replace-loader": "^3.1.0", diff --git a/examples/utils/bundle-size-tests/webpack.config.cjs b/examples/utils/bundle-size-tests/webpack.config.cjs index e40061eb14b8..ec5feed839cf 100644 --- a/examples/utils/bundle-size-tests/webpack.config.cjs +++ b/examples/utils/bundle-size-tests/webpack.config.cjs @@ -65,73 +65,84 @@ webpackModuleRules.push( }, ); -module.exports = { - entry: { - aqueduct: "./src/aqueduct", - azureClient: "./src/azureClient", - connectionState: "./src/connectionState", - containerRuntime: "./src/containerRuntimeBundle", - debugAssert: "./src/debugAssert", - directory: "./src/sharedDirectory", - experimentalSharedTree: "./src/experimentalSharedTree", - fluidFramework: "./src/fluidFramework", - loader: "./src/loader", - map: "./src/sharedMap", - matrix: "./src/sharedMatrix", - odspClient: "./src/odspClient", - odspDriver: "./src/odspDriver", - odspPrefetchSnapshot: "./src/odspPrefetchSnapshot", - sharedString: "./src/sharedString", - sharedTree: "./src/sharedTree", - sharedTreeAttributes: "./src/sharedTreeAttributes", - }, - mode: "production", - module: { - rules: webpackModuleRules, - }, - resolve: { - extensions: [".tsx", ".ts", ".js"], - }, - output: { - path: path.resolve(__dirname, "build"), - library: "bundle", - }, - node: false, - plugins: [ - new BannedModulesPlugin({ - bannedModules: [ - { - moduleName: "assert", - reason: - "This module is very large when bundled in browser facing Javascript, instead use the assert API in @fluidframework/common-utils", - }, - ], - }), - new DuplicatePackageCheckerPlugin({ - // Also show module that is requiring each duplicate package - verbose: true, - // Emit errors instead of warnings - emitError: true, - /** - * We try to avoid duplicate packages, but sometimes we have to allow them since the duplication is coming from a third party library we do not control - * IMPORTANT: Do not add any new exceptions to this list without first doing a deep investigation on why a PR adds a new duplication, this hides a bundle size issue - */ - exclude: (instance) => false, - }), - new BundleAnalyzerPlugin({ - analyzerMode: "static", - reportFilename: path.resolve(process.cwd(), "bundleAnalysis/report.html"), - openAnalyzer: false, - generateStatsFile: true, - statsFilename: path.resolve(process.cwd(), "bundleAnalysis/report.json"), - }), - // Plugin that generates a compressed version of the stats file that can be uploaded to blob storage - new BundleComparisonPlugin({ - // File to create, relative to the webpack build output path: - file: path.resolve(process.cwd(), "bundleAnalysis/bundleStats.msp.gz"), - }), - ], - // Enabling source maps allows using source-map-explorer to investigate bundle contents, - // which provides more fine grained details than BundleAnalyzerPlugin, so its nice for manual investigations. - devtool: "source-map", +module.exports = async () => { + const { default: Sonda } = await import("sonda/webpack"); + return { + entry: { + aqueduct: "./src/aqueduct", + azureClient: "./src/azureClient", + connectionState: "./src/connectionState", + containerRuntime: "./src/containerRuntimeBundle", + debugAssert: "./src/debugAssert", + directory: "./src/sharedDirectory", + experimentalSharedTree: "./src/experimentalSharedTree", + fluidFramework: "./src/fluidFramework", + loader: "./src/loader", + map: "./src/sharedMap", + matrix: "./src/sharedMatrix", + odspClient: "./src/odspClient", + odspDriver: "./src/odspDriver", + odspPrefetchSnapshot: "./src/odspPrefetchSnapshot", + sharedString: "./src/sharedString", + sharedTree: "./src/sharedTree", + sharedTreeAttributes: "./src/sharedTreeAttributes", + }, + mode: "production", + module: { + rules: webpackModuleRules, + }, + resolve: { + extensions: [".tsx", ".ts", ".js"], + }, + output: { + path: path.resolve(__dirname, "build"), + library: "bundle", + }, + node: false, + plugins: [ + new BannedModulesPlugin({ + bannedModules: [ + { + moduleName: "assert", + reason: + "This module is very large when bundled in browser facing Javascript, instead use the assert API in @fluidframework/common-utils", + }, + ], + }), + new DuplicatePackageCheckerPlugin({ + // Also show module that is requiring each duplicate package + verbose: true, + // Emit errors instead of warnings + emitError: true, + /** + * We try to avoid duplicate packages, but sometimes we have to allow them since the duplication is coming from a third party library we do not control + * IMPORTANT: Do not add any new exceptions to this list without first doing a deep investigation on why a PR adds a new duplication, this hides a bundle size issue + */ + exclude: (instance) => false, + }), + new BundleAnalyzerPlugin({ + analyzerMode: "static", + reportFilename: path.resolve(process.cwd(), "bundleAnalysis/report.html"), + openAnalyzer: false, + generateStatsFile: true, + statsFilename: path.resolve(process.cwd(), "bundleAnalysis/report.json"), + }), + // Plugin that generates a compressed version of the stats file that can be uploaded to blob storage + new BundleComparisonPlugin({ + // File to create, relative to the webpack build output path: + file: path.resolve(process.cwd(), "bundleAnalysis/bundleStats.msp.gz"), + }), + new Sonda({ + open: false, + outputDir: path.resolve(process.cwd(), "bundleAnalysis"), + filename: "sonda", + format: "html", + gzip: true, + brotli: true, + }), + ], + // Enabling source maps allows using source-map-explorer to investigate bundle contents, + // which provides more fine grained details than BundleAnalyzerPlugin, so its nice for manual investigations. + devtool: "source-map", + }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1fff8d4380..fbf2421e6ffc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5019,6 +5019,9 @@ importers: rimraf: specifier: ^6.1.2 version: 6.1.2 + sonda: + specifier: ^0.10.2 + version: 0.10.2 source-map-explorer: specifier: ^2.5.3 version: 2.5.3 @@ -19159,6 +19162,9 @@ packages: resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -21231,6 +21237,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -22026,6 +22036,14 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + default-gateway@6.0.3: resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==} engines: {node: '>= 10'} @@ -22045,6 +22063,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -23727,6 +23749,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -23771,6 +23798,15 @@ packages: engines: {node: '>=18'} hasBin: true + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-installed-globally@0.4.0: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} @@ -23915,6 +23951,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + is-yarn-global@0.4.1: resolution: {integrity: sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==} engines: {node: '>=12'} @@ -25445,6 +25485,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -25908,6 +25952,10 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prebuild-install@7.1.2: resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} engines: {node: '>=10'} @@ -26548,6 +26596,10 @@ packages: rtl-css-js@1.16.1: resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel-limit@1.1.0: resolution: {integrity: sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==} @@ -26902,6 +26954,11 @@ packages: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonda@0.10.2: + resolution: {integrity: sha512-LYStDIhOipXMRC9BQGL05SUZrYqfm//hfccCS+jo0HhJc3N4uChZXap0cqqyOE6iWWUs0uJbHgX/+2bin0FqPg==} + engines: {node: '>=20.19 || >=22.12'} + hasBin: true + sort-json@2.0.1: resolution: {integrity: sha512-s8cs2bcsQCzo/P2T/uoU6Js4dS/jnX8+4xunziNoq9qmSpZNCrRIAIvp4avsz0ST18HycV4z/7myJ7jsHWB2XQ==} hasBin: true @@ -28255,6 +28312,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xcase@2.0.1: resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} @@ -32446,6 +32507,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} @@ -34954,6 +35020,10 @@ snapshots: builtin-modules@3.3.0: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} bytewise-core@1.2.3: @@ -35807,6 +35877,13 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + default-gateway@6.0.3: dependencies: execa: 5.1.1 @@ -35826,6 +35903,8 @@ snapshots: define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -37803,6 +37882,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -37836,6 +37917,12 @@ snapshots: is-in-ci@0.1.0: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-installed-globally@0.4.0: dependencies: global-dirs: 3.0.1 @@ -37949,6 +38036,10 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + is-yarn-global@0.4.1: {} isarray@0.0.1: {} @@ -40161,6 +40252,15 @@ snapshots: dependencies: mimic-function: 5.0.1 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -40665,6 +40765,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prebuild-install@7.1.2: dependencies: detect-libc: 2.1.2 @@ -41480,6 +41582,8 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 + run-applescript@7.1.0: {} + run-parallel-limit@1.1.0: dependencies: queue-microtask: 1.2.3 @@ -41933,6 +42037,11 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 + sonda@0.10.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + open: 11.0.0 + sort-json@2.0.1: dependencies: detect-indent: 5.0.0 @@ -43692,6 +43801,11 @@ snapshots: ws@8.18.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.0 + powershell-utils: 0.1.0 + xcase@2.0.1: {} xdg-basedir@5.1.0: {} From dc2909c7ab10ace7d0b2080f32211cd98f9d1d82 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 16:17:47 -0800 Subject: [PATCH 4/7] lockfile and format --- .../container-loader/src/deltaManager.ts | 5 +- pnpm-lock.yaml | 139 +++++++++++++++++- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/packages/loader/container-loader/src/deltaManager.ts b/packages/loader/container-loader/src/deltaManager.ts index 9bcad0358f97..d8d30e866ccb 100644 --- a/packages/loader/container-loader/src/deltaManager.ts +++ b/packages/loader/container-loader/src/deltaManager.ts @@ -56,10 +56,7 @@ import type { } from "./contracts.js"; import { DeltaQueue } from "./deltaQueue.js"; import { ThrottlingWarning } from "./error.js"; -import { - EffectionScope, - createScopedAbortController, -} from "./structuredConcurrency.js"; +import { EffectionScope, createScopedAbortController } from "./structuredConcurrency.js"; export interface IConnectionArgs { mode?: ConnectionMode; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbf2421e6ffc..64f23e17010f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: link:packages/tools/changelog-generator-wrapper '@fluid-tools/build-cli': specifier: ^0.63.0 - version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) + version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13) '@fluid-tools/markdown-magic': specifier: workspace:~ version: link:tools/markdown-magic @@ -12558,7 +12558,7 @@ importers: version: link:../test-loader-utils '@fluid-tools/build-cli': specifier: ^0.63.0 - version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) + version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13) '@fluidframework/build-common': specifier: ^2.0.3 version: 2.0.3 @@ -30413,6 +30413,81 @@ snapshots: transitivePeerDependencies: - supports-color + '@fluid-tools/build-cli@0.63.0(@types/node@20.19.30)(encoding@0.1.13)': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@fluid-tools/build-infrastructure': 0.63.0(@types/node@20.19.30) + '@fluid-tools/version-tools': 0.63.0(@types/node@20.19.30) + '@fluidframework/build-tools': 0.63.0(@types/node@20.19.30) + '@fluidframework/bundle-size-tools': 0.63.0 + '@inquirer/prompts': 8.0.2(@types/node@20.19.30) + '@microsoft/api-extractor': 7.55.1(@types/node@20.19.30) + '@oclif/core': 4.8.0 + '@oclif/plugin-autocomplete': 3.2.39 + '@oclif/plugin-commands': 4.1.38 + '@oclif/plugin-help': 6.2.36 + '@oclif/plugin-not-found': 3.2.73(@types/node@20.19.30) + '@octokit/core': 7.0.6 + '@octokit/rest': 22.0.1 + '@rushstack/node-core-library': 5.19.0(@types/node@20.19.30) + async: 3.2.6 + azure-devops-node-api: 11.2.0 + change-case: 5.4.4 + danger: 13.0.5(encoding@0.1.13) + date-fns: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + execa: 5.1.1 + fflate: 0.8.2 + fs-extra: 11.3.2 + github-slugger: 2.0.0 + globby: 11.1.0 + gray-matter: 4.0.3 + human-id: 4.1.3 + issue-parser: 7.0.1 + json5: 2.2.3 + jssm: 5.104.2 + jszip: 3.10.1 + latest-version: 9.0.0 + lilconfig: 3.1.3 + mdast: 3.0.0 + mdast-util-heading-range: 4.0.0 + mdast-util-to-string: 4.0.0 + minimatch: 10.1.1 + npm-check-updates: 16.14.20 + oclif: 4.22.52(@types/node@20.19.30) + picocolors: 1.1.1 + prettier: 3.2.5 + prompts: 2.4.2 + read-pkg-up: 7.0.1 + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-github: 12.0.0 + remark-github-beta-blockquote-admonitions: 3.1.1 + remark-toc: 9.0.0 + replace-in-file: 7.2.0 + resolve.exports: 2.0.3 + semver: 7.7.3 + simple-git: 3.30.0 + sort-json: 2.0.1 + sort-package-json: 1.57.0 + strip-ansi: 7.1.2 + table: 6.9.0 + ts-morph: 22.0.0 + unist-util-visit: 5.0.0 + xml2js: 0.6.2 + transitivePeerDependencies: + - '@swc/core' + - '@types/node' + - bluebird + - bufferutil + - encoding + - esbuild + - react-devtools-core + - supports-color + - uglify-js + - utf-8-validate + - webpack-cli + '@fluid-tools/build-cli@0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4)': dependencies: '@andrewbranch/untar.js': 1.0.3 @@ -30705,6 +30780,20 @@ snapshots: - supports-color - utf-8-validate + '@fluidframework/bundle-size-tools@0.63.0': + dependencies: + azure-devops-node-api: 11.2.0 + fflate: 0.8.2 + jszip: 3.10.1 + msgpack-lite: 0.1.26 + typescript: 5.4.5 + webpack: 5.103.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + '@fluidframework/bundle-size-tools@0.63.0(webpack-cli@5.1.4)': dependencies: azure-devops-node-api: 11.2.0 @@ -34284,7 +34373,7 @@ snapshots: '@vvago/vale@3.12.0': dependencies: - axios: 1.13.2(debug@4.4.3) + axios: 1.13.2 rimraf: 5.0.10 tar: 6.2.1 unzipper: 0.10.14 @@ -34736,6 +34825,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.13.2(debug@4.3.7): dependencies: follow-redirects: 1.15.11(debug@4.3.7) @@ -36973,6 +37070,8 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.11: {} + follow-redirects@1.15.11(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -42558,7 +42657,7 @@ snapshots: schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.103.0(webpack-cli@5.1.4) + webpack: 5.103.0 terser@5.37.0: dependencies: @@ -43588,6 +43687,38 @@ snapshots: webpack-sources@3.3.3: {} + webpack@5.103.0: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.15(webpack@5.103.0) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.103.0(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.7 From ae501faa2d9fffa66ad830a7cd2b87641bb09c56 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 16:26:00 -0800 Subject: [PATCH 5/7] fix(container-loader): resolve eslint errors in structured concurrency code - Fix import order: move structuredConcurrency.js import after serializedStateManager.js - Fix require-yield: use plain function instead of generator for ensure() callback - Fix no-floating-promises: add eslint-disable with explanations for intentional fire-and-forget - Fix promise-function-async: add async to createScopedDelay --- packages/loader/container-loader/src/container.ts | 2 +- .../container-loader/src/structuredConcurrency.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/loader/container-loader/src/container.ts b/packages/loader/container-loader/src/container.ts index 4d4a73ad8c0c..a2b270c0ec52 100644 --- a/packages/loader/container-loader/src/container.ts +++ b/packages/loader/container-loader/src/container.ts @@ -126,7 +126,6 @@ import { getPackageName, } from "./contracts.js"; import { DeltaManager, type IConnectionArgs } from "./deltaManager.js"; -import { EffectionScope } from "./structuredConcurrency.js"; import type { ILoaderServices } from "./loader.js"; import { RelativeLoader } from "./loader.js"; import { @@ -154,6 +153,7 @@ import { type IPendingDetachedContainerState, SerializedStateManager, } from "./serializedStateManager.js"; +import { EffectionScope } from "./structuredConcurrency.js"; import { combineAppAndProtocolSummary, combineSnapshotTreeAndSnapshotBlobs, diff --git a/packages/loader/container-loader/src/structuredConcurrency.ts b/packages/loader/container-loader/src/structuredConcurrency.ts index 192e70cfab60..3fb28102c2bb 100644 --- a/packages/loader/container-loader/src/structuredConcurrency.ts +++ b/packages/loader/container-loader/src/structuredConcurrency.ts @@ -38,8 +38,11 @@ export class EffectionScope { } public addCleanup(cleanup: CleanupFn): void { + // Task is intentionally spawned into the scope's lifetime; awaiting it would + // block forever (it calls suspend()). The scope owns the task and tears it down on close(). + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.scope.run(function* () { - yield* ensure(function* () { + yield* ensure(() => { cleanup(); }); yield* suspend(); @@ -94,6 +97,9 @@ export class EffectionTimer { if (this.task === undefined) { return; } + // We only need to initiate the halt; the scope's structured concurrency + // guarantees proper teardown regardless of whether we await the result. + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.task.halt(); this.task = undefined; } @@ -128,7 +134,7 @@ export function createScopedAbortController(scope: EffectionScope): AbortControl * @param delayMs - Delay in milliseconds. * @returns Promise that resolves after delay or rejects on scope cancellation. */ -export function createScopedDelay(scope: EffectionScope, delayMs: number): Promise { +export async function createScopedDelay(scope: EffectionScope, delayMs: number): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(resolve, delayMs); scope.addCleanup(() => { From 77306404a543c5b4d901882b9e8b3351cb2b3198 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 18:10:38 -0800 Subject: [PATCH 6/7] add note --- async-structured-concurrency-analysis.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/async-structured-concurrency-analysis.md b/async-structured-concurrency-analysis.md index 00e424b0018e..b574a1ba0360 100644 --- a/async-structured-concurrency-analysis.md +++ b/async-structured-concurrency-analysis.md @@ -46,6 +46,11 @@ removeListener bookkeeping, and AbortController propagation. | Timer usage | 20 across 6 files | | **Effection Benefit** | **`*****`** | +> **Note (2026-02-11):** While this package has the highest potential benefit, its sheer complexity +> (132 async functions, 964 awaits, deeply nested subsystems) makes it a poor candidate for early +> adoption. The risk of regressions across summarization, GC, and blob management is high. Priority +> is lowered until the effection patterns are well-established in simpler packages. + **Why:** This is the most complex async package in the entire workspace. It manages summarization (RunningSummarizer, SummaryManager), garbage collection with session timers, blob management, PendingStateManager, and data store lifecycles. It has hand-rolled cleanup arrays From a4e9161bf70d216993562470b84f56025934dff0 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Wed, 11 Feb 2026 18:40:08 -0800 Subject: [PATCH 7/7] feat(odsp-driver): introduce structured concurrency with SafeScope and SafeTimer Replace manual setTimeout/clearTimeout patterns across five classes with scope-aware SafeTimer backed by effection structured concurrency: - OpsCache: flush debounce timer - OdspDelayLoadedDeltaStream: join session refresh timer - SocketReference: 2-second grace period delay-delete timer - OdspDocumentDeltaConnection: 15-second diagnostic timer - OdspDocumentService: scope for future disposal cascade SafeTimer clears its task reference before invoking the callback to match setTimeout re-entrancy semantics. --- packages/drivers/odsp-driver/package.json | 1 + .../src/odspDelayLoadedDeltaStream.ts | 24 +-- .../src/odspDocumentDeltaConnection.ts | 57 ++++--- .../odsp-driver/src/odspDocumentService.ts | 5 + .../drivers/odsp-driver/src/opsCaching.ts | 25 +-- .../odsp-driver/src/structuredConcurrency.ts | 152 ++++++++++++++++++ pnpm-lock.yaml | 142 +--------------- 7 files changed, 232 insertions(+), 174 deletions(-) create mode 100644 packages/drivers/odsp-driver/src/structuredConcurrency.ts diff --git a/packages/drivers/odsp-driver/package.json b/packages/drivers/odsp-driver/package.json index 02e0bbb1a157..a08aeae63386 100644 --- a/packages/drivers/odsp-driver/package.json +++ b/packages/drivers/odsp-driver/package.json @@ -124,6 +124,7 @@ "@fluidframework/odsp-doclib-utils": "workspace:~", "@fluidframework/odsp-driver-definitions": "workspace:~", "@fluidframework/telemetry-utils": "workspace:~", + "effection": "^4.0.2", "socket.io-client": "~4.7.5", "uuid": "^11.1.0" }, diff --git a/packages/drivers/odsp-driver/src/odspDelayLoadedDeltaStream.ts b/packages/drivers/odsp-driver/src/odspDelayLoadedDeltaStream.ts index 6625879c53d4..aa8635be01e1 100644 --- a/packages/drivers/odsp-driver/src/odspDelayLoadedDeltaStream.ts +++ b/packages/drivers/odsp-driver/src/odspDelayLoadedDeltaStream.ts @@ -47,6 +47,7 @@ import { getWithRetryForTokenRefresh, } from "./odspUtils.js"; import { pkgVersion as driverVersion } from "./packageVersion.js"; +import { SafeScope, SafeTimer } from "./structuredConcurrency.js"; import { fetchJoinSession } from "./vroom.js"; /** @@ -54,8 +55,8 @@ import { fetchJoinSession } from "./vroom.js"; * as they are not on critical path of loading a container. */ export class OdspDelayLoadedDeltaStream { - // Timer which runs and executes the join session call after intervals. - private joinSessionRefreshTimer: ReturnType | undefined; + private readonly _scope = new SafeScope(); + private readonly _joinSessionRefreshTimer: SafeTimer; private readonly joinSessionKey: string; @@ -102,6 +103,8 @@ export class OdspDelayLoadedDeltaStream { private readonly socketReferenceKeyPrefix?: string, ) { this.joinSessionKey = getJoinSessionCacheKey(this.odspResolvedUrl); + // Default callback is a no-op; scheduleJoinSessionRefresh always supplies an explicit callback. + this._joinSessionRefreshTimer = new SafeTimer(this._scope, 0, () => {}); } public get resolvedUrl(): IResolvedUrl { @@ -291,10 +294,8 @@ export class OdspDelayLoadedDeltaStream { }; private clearJoinSessionTimer(): void { - if (this.joinSessionRefreshTimer !== undefined) { - clearTimeout(this.joinSessionRefreshTimer); - this.joinSessionRefreshTimer = undefined; - } + // Cancel any pending join session refresh so stale refreshes don't fire after disconnect. + this._joinSessionRefreshTimer.clear(); } private async scheduleJoinSessionRefresh( @@ -303,7 +304,7 @@ export class OdspDelayLoadedDeltaStream { clientId: string | undefined, displayName: string | undefined, ): Promise { - if (this.joinSessionRefreshTimer !== undefined) { + if (this._joinSessionRefreshTimer.hasTimer) { this.clearJoinSessionTimer(); // TODO: use a stronger type // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any @@ -321,9 +322,7 @@ export class OdspDelayLoadedDeltaStream { } await new Promise((resolve, reject) => { - this.joinSessionRefreshTimer = setTimeout(() => { - this.clearJoinSessionTimer(); - // Clear the timer as it is going to be scheduled again as part of refreshing join session. + this._joinSessionRefreshTimer.start(delta, () => { getWithRetryForTokenRefresh(async (options) => { await this.joinSession( requestSocketToken, @@ -337,7 +336,7 @@ export class OdspDelayLoadedDeltaStream { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(error); }); - }, delta); + }); }); } @@ -579,6 +578,9 @@ export class OdspDelayLoadedDeltaStream { public dispose(error?: unknown): void { this.clearJoinSessionTimer(); + // Fire-and-forget: scope.close() is async but dispose() is synchronous; safe because + // the join session refresh timer is already cleared above. + this._scope.close().catch(() => {}); this.currentConnection?.dispose(); this.currentConnection = undefined; } diff --git a/packages/drivers/odsp-driver/src/odspDocumentDeltaConnection.ts b/packages/drivers/odsp-driver/src/odspDocumentDeltaConnection.ts index 4718d4fdad2b..f6a992e9c30f 100644 --- a/packages/drivers/odsp-driver/src/odspDocumentDeltaConnection.ts +++ b/packages/drivers/odsp-driver/src/odspDocumentDeltaConnection.ts @@ -24,6 +24,7 @@ import { type ITelemetryLoggerExt, loggerToMonitoringContext, } from "@fluidframework/telemetry-utils/internal"; +import { sleep } from "effection"; import type { Socket } from "socket.io-client"; import { v4 as uuid } from "uuid"; @@ -32,6 +33,7 @@ import type { EpochTracker } from "./epochTracker.js"; import { errorObjectFromSocketError } from "./odspError.js"; import { pkgVersion } from "./packageVersion.js"; import { SocketIOClientStatic } from "./socketModule.js"; +import { SafeScope, SafeTimer } from "./structuredConcurrency.js"; const protocolVersions = ["^0.4.0", "^0.3.0", "^0.2.0", "^0.1.0"]; const feature_get_ops = "api_get_ops"; @@ -55,7 +57,8 @@ interface ISocketEvents extends IEvent { class SocketReference extends TypedEventEmitter { private references: number = 1; - private delayDeleteTimeout: ReturnType | undefined; + private readonly _scope = new SafeScope(); + private readonly _delayDeleteTimer: SafeTimer; private _socket: Socket | undefined; // When making decisions about socket reuse, we do not reuse disconnected socket. @@ -102,12 +105,10 @@ class SocketReference extends TypedEventEmitter { return; } - if (this.references === 0 && this.delayDeleteTimeout === undefined) { - this.delayDeleteTimeout = setTimeout(() => { - // We should not get here with active users. - assert(this.references === 0, 0x0a0 /* "Unexpected socketIO references on timeout" */); - this.closeSocket(); - }, socketReferenceBufferTime); + if (this.references === 0 && !this._delayDeleteTimer.hasTimer) { + // Start the grace period timer; if no new references arrive within + // socketReferenceBufferTime ms, the socket will be closed. + this._delayDeleteTimer.start(); } } @@ -125,6 +126,11 @@ class SocketReference extends TypedEventEmitter { super(); this._socket = socket; + this._delayDeleteTimer = new SafeTimer(this._scope, socketReferenceBufferTime, () => { + // We should not get here with active users. + assert(this.references === 0, 0x0a0 /* "Unexpected socketIO references on timeout" */); + this.closeSocket(); + }); assert(!SocketReference.socketIoSockets.has(key), 0x220 /* "socket key collision" */); SocketReference.socketIoSockets.set(key, this); @@ -159,10 +165,8 @@ class SocketReference extends TypedEventEmitter { }; private clearTimer(): void { - if (this.delayDeleteTimeout !== undefined) { - clearTimeout(this.delayDeleteTimeout); - this.delayDeleteTimeout = undefined; - } + // Cancel pending grace-period socket cleanup, if any. + this._delayDeleteTimer.clear(); } public closeSocket(error?: IAnyDriverError): void { @@ -172,6 +176,9 @@ class SocketReference extends TypedEventEmitter { this._socket.off("server_disconnect", this.serverDisconnectEventHandler); this.clearTimer(); + // Fire-and-forget: scope.close() is async but closeSocket() is synchronous; safe because + // the delay-delete timer is already cleared above. + this._scope.close().catch(() => {}); assert( SocketReference.socketIoSockets.get(this.key) === this, @@ -351,7 +358,8 @@ export class OdspDocumentDeltaConnection extends DocumentDeltaConnection { new Map(); private flushOpNonce: string | undefined; private flushDeferred: Deferred | undefined; - private connectionNotYetDisposedTimeout: ReturnType | undefined; + private readonly _connectionScope = new SafeScope(); + private connectionNotYetDisposedDiagnosticStarted = false; // Due to socket reuse(multiplexing), we can get "disconnect" event from other clients in the socket reference. // So, a race condition could happen, where this client is establishing connection and listening for "connect_document_success" // on the socket among other events, but we get "disconnect" event on the socket reference from other clients, in which case, @@ -750,18 +758,27 @@ export class OdspDocumentDeltaConnection extends DocumentDeltaConnection { if (!(this._disposed || this.socket.connected)) { // Send error event if this connection is not yet disposed after socket is disconnected for 15s. // eslint-disable-next-line unicorn/no-lonely-if, @typescript-eslint/prefer-nullish-coalescing -- using ??= could change behavior if value is falsy - if (this.connectionNotYetDisposedTimeout === undefined) { - this.connectionNotYetDisposedTimeout = setTimeout(() => { - if (!this._disposed) { - this.logger.sendErrorEvent({ + if (!this.connectionNotYetDisposedDiagnosticStarted) { + this.connectionNotYetDisposedDiagnosticStarted = true; + const logger = this.logger; + const isDisposed = (): boolean => this._disposed; + const getProps = (): Record => this.getConnectionDetailsProps(); + // Fire-and-forget diagnostic task: logs a warning if the connection hasn't been + // disposed 15 seconds after the socket disconnects. Automatically halted when + // _connectionScope is closed during disconnectCore(). + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._connectionScope.run(function* () { + yield* sleep(15000); + if (!isDisposed()) { + logger.sendErrorEvent({ eventName: "ConnectionNotYetDisposed", driverVersion: pkgVersion, details: JSON.stringify({ - ...this.getConnectionDetailsProps(), + ...getProps(), }), }); } - }, 15000); + }); } } return this._disposed; @@ -833,6 +850,10 @@ export class OdspDocumentDeltaConnection extends DocumentDeltaConnection { assert(socket !== undefined, 0x0a2 /* "reentrancy not supported!" */); this.socketReference = undefined; + // Fire-and-forget: scope.close() is async but disconnectCore() is synchronous; halts the + // diagnostic timer task if it was started. + this._connectionScope.close().catch(() => {}); + socket.off("disconnect", this.disconnectHandler); if (this.hasDetails) { // tell the server we are disconnecting this client from the document diff --git a/packages/drivers/odsp-driver/src/odspDocumentService.ts b/packages/drivers/odsp-driver/src/odspDocumentService.ts index 257492e8f98b..460dea4b8afd 100644 --- a/packages/drivers/odsp-driver/src/odspDocumentService.ts +++ b/packages/drivers/odsp-driver/src/odspDocumentService.ts @@ -42,6 +42,7 @@ import { hasOdcOrigin } from "./odspUrlHelper.js"; import { getOdspResolvedUrl } from "./odspUtils.js"; import { OpsCache } from "./opsCaching.js"; import { RetryErrorsStorageAdapter } from "./retryErrorsStorageAdapter.js"; +import { SafeScope } from "./structuredConcurrency.js"; /** * The DocumentService manages the Socket.IO connection and manages routing requests to connected @@ -52,6 +53,7 @@ export class OdspDocumentService implements IDocumentService { private readonly _policies: IDocumentServicePolicies; + private readonly _scope = new SafeScope(); // Promise to load socket module only once. private socketModuleP: Promise | undefined; @@ -313,6 +315,9 @@ export class OdspDocumentService this._opsCache?.dispose(); // Only need to dipose this, if it is already loaded. this.odspDelayLoadedDeltaStream?.dispose(); + // Fire-and-forget: scope.close() is async but dispose() is synchronous; tears down + // any future scope-managed tasks added to this service. + this._scope.close().catch(() => {}); } protected get opsCache(): OpsCache | undefined { diff --git a/packages/drivers/odsp-driver/src/opsCaching.ts b/packages/drivers/odsp-driver/src/opsCaching.ts index d70d59d1a5ee..e144483b81cf 100644 --- a/packages/drivers/odsp-driver/src/opsCaching.ts +++ b/packages/drivers/odsp-driver/src/opsCaching.ts @@ -6,6 +6,8 @@ import { performanceNow } from "@fluid-internal/client-utils"; import type { ITelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal"; +import { SafeScope, SafeTimer } from "./structuredConcurrency.js"; + // ISequencedDocumentMessage export interface IMessage { sequenceNumber: number; @@ -30,7 +32,8 @@ export interface ICache { export class OpsCache { private readonly batches: Map = new Map(); - private timer: ReturnType | undefined; + private readonly _scope = new SafeScope(); + private readonly _flushTimer: SafeTimer; constructor( startingSequenceNumber: number, @@ -40,6 +43,9 @@ export class OpsCache { private readonly timerGranularity: number, private totalOpsToCache: number, ) { + this._flushTimer = new SafeTimer(this._scope, timerGranularity, () => { + this.flushOps(); + }); /** * Initial batch is a special case because it will never be full - all ops prior (inclusive) to * `startingSequenceNumber` are never going to show up (undefined) @@ -57,10 +63,11 @@ export class OpsCache { public dispose(): void { this.batches.clear(); - if (this.timer !== undefined) { - clearTimeout(this.timer); - this.timer = undefined; - } + // Cancel any pending flush timer so no writes happen after dispose. + this._flushTimer.clear(); + // Fire-and-forget: scope.close() is async but dispose() is synchronous; swallow errors + // since all managed tasks are already halted by timer.clear() above. + this._scope.close().catch(() => {}); } public flushOps(): void { @@ -211,11 +218,9 @@ export class OpsCache { } protected scheduleTimer(): void { - if (!this.timer && this.timerGranularity > 0) { - this.timer = setTimeout(() => { - this.timer = undefined; - this.flushOps(); - }, this.timerGranularity); + if (!this._flushTimer.hasTimer && this.timerGranularity > 0) { + // Debounce: start a single flush timer; when it fires, all dirty batches are flushed. + this._flushTimer.start(); } } diff --git a/packages/drivers/odsp-driver/src/structuredConcurrency.ts b/packages/drivers/odsp-driver/src/structuredConcurrency.ts new file mode 100644 index 000000000000..27a7c40a2859 --- /dev/null +++ b/packages/drivers/odsp-driver/src/structuredConcurrency.ts @@ -0,0 +1,152 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createScope, + ensure, + sleep, + suspend, + type Operation, + type Scope, + type Task, +} from "effection"; + +type CleanupFn = () => void; + +/** + * Wraps a structured concurrency scope into a class-based interface suitable + * for integration with existing imperative lifecycle patterns. + * + * Provides three key capabilities: + * - `run()`: Execute an operation within this scope + * - `addCleanup()`: Register a synchronous cleanup function that runs on scope close + * - `close()`: Destroy the scope, halting all tasks and running all cleanups + */ +export class SafeScope { + private readonly scope: Scope; + private readonly destroy: () => Promise; + private closed = false; + + public constructor() { + [this.scope, this.destroy] = createScope(); + } + + public run(operation: () => Operation): Task { + return this.scope.run(operation); + } + + public addCleanup(cleanup: CleanupFn): void { + // Task is intentionally spawned into the scope's lifetime; awaiting it would + // block forever (it calls suspend()). The scope owns the task and tears it down on close(). + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.scope.run(function* () { + yield* ensure(() => { + cleanup(); + }); + yield* suspend(); + }); + } + + public async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.destroy(); + } +} + +/** + * Timer implementation backed by structured concurrency sleep. + * Timers are automatically cancelled when their owning scope closes. + */ +export class SafeTimer { + private task: Task | undefined; + + public constructor( + private readonly scope: SafeScope, + private readonly defaultTimeoutMs: number, + private readonly defaultCallback: () => void, + ) {} + + public get hasTimer(): boolean { + return this.task !== undefined; + } + + public start( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.clear(); + const clearTask = (): void => { + this.task = undefined; + }; + this.task = this.scope.run(function* () { + yield* sleep(timeoutMs); + // Clear task reference before invoking the callback so that hasTimer + // returns false during re-entrant scheduling (matching setTimeout behavior + // where the timer id is invalid after the callback fires). + clearTask(); + callback(); + }); + } + + public restart( + timeoutMs: number = this.defaultTimeoutMs, + callback: () => void = this.defaultCallback, + ): void { + this.start(timeoutMs, callback); + } + + public clear(): void { + if (this.task === undefined) { + return; + } + // We only need to initiate the halt; the scope's structured concurrency + // guarantees proper teardown regardless of whether we await the result. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.task.halt(); + this.task = undefined; + } +} + +// ── Bridge utilities ───────────────────────────────────────────────────────── + +/** + * Creates an AbortController that automatically aborts when the owning + * scope closes. This bridges cooperative cancellation with existing + * AbortSignal-based patterns. + * + * @param scope - The SafeScope that owns this controller's lifetime. + * @returns An AbortController that will abort when `scope.close()` is called. + */ +export function createScopedAbortController(scope: SafeScope): AbortController { + const controller = new AbortController(); + scope.addCleanup(() => { + if (!controller.signal.aborted) { + controller.abort("Scope closed"); + } + }); + return controller; +} + +/** + * Creates a promise that resolves after a delay, but rejects if the owning + * scope closes first. This is a scope-aware replacement for + * `new Promise(resolve => setTimeout(resolve, delayMs))`. + * + * @param scope - The SafeScope that can cancel this delay. + * @param delayMs - Delay in milliseconds. + * @returns Promise that resolves after delay or rejects on scope cancellation. + */ +export async function createScopedDelay(scope: SafeScope, delayMs: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, delayMs); + scope.addCleanup(() => { + clearTimeout(timeoutId); + reject(new Error("Delay cancelled by scope closure")); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64f23e17010f..081d21516d65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: link:packages/tools/changelog-generator-wrapper '@fluid-tools/build-cli': specifier: ^0.63.0 - version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13) + version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) '@fluid-tools/markdown-magic': specifier: workspace:~ version: link:tools/markdown-magic @@ -10244,6 +10244,9 @@ importers: '@fluidframework/telemetry-utils': specifier: workspace:~ version: link:../../utils/telemetry-utils + effection: + specifier: ^4.0.2 + version: 4.0.2 socket.io-client: specifier: ~4.7.5 version: 4.7.5 @@ -12558,7 +12561,7 @@ importers: version: link:../test-loader-utils '@fluid-tools/build-cli': specifier: ^0.63.0 - version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13) + version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) '@fluidframework/build-common': specifier: ^2.0.3 version: 2.0.3 @@ -30413,81 +30416,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@fluid-tools/build-cli@0.63.0(@types/node@20.19.30)(encoding@0.1.13)': - dependencies: - '@andrewbranch/untar.js': 1.0.3 - '@fluid-tools/build-infrastructure': 0.63.0(@types/node@20.19.30) - '@fluid-tools/version-tools': 0.63.0(@types/node@20.19.30) - '@fluidframework/build-tools': 0.63.0(@types/node@20.19.30) - '@fluidframework/bundle-size-tools': 0.63.0 - '@inquirer/prompts': 8.0.2(@types/node@20.19.30) - '@microsoft/api-extractor': 7.55.1(@types/node@20.19.30) - '@oclif/core': 4.8.0 - '@oclif/plugin-autocomplete': 3.2.39 - '@oclif/plugin-commands': 4.1.38 - '@oclif/plugin-help': 6.2.36 - '@oclif/plugin-not-found': 3.2.73(@types/node@20.19.30) - '@octokit/core': 7.0.6 - '@octokit/rest': 22.0.1 - '@rushstack/node-core-library': 5.19.0(@types/node@20.19.30) - async: 3.2.6 - azure-devops-node-api: 11.2.0 - change-case: 5.4.4 - danger: 13.0.5(encoding@0.1.13) - date-fns: 3.6.0 - debug: 4.4.3(supports-color@8.1.1) - execa: 5.1.1 - fflate: 0.8.2 - fs-extra: 11.3.2 - github-slugger: 2.0.0 - globby: 11.1.0 - gray-matter: 4.0.3 - human-id: 4.1.3 - issue-parser: 7.0.1 - json5: 2.2.3 - jssm: 5.104.2 - jszip: 3.10.1 - latest-version: 9.0.0 - lilconfig: 3.1.3 - mdast: 3.0.0 - mdast-util-heading-range: 4.0.0 - mdast-util-to-string: 4.0.0 - minimatch: 10.1.1 - npm-check-updates: 16.14.20 - oclif: 4.22.52(@types/node@20.19.30) - picocolors: 1.1.1 - prettier: 3.2.5 - prompts: 2.4.2 - read-pkg-up: 7.0.1 - remark: 15.0.1 - remark-gfm: 4.0.1 - remark-github: 12.0.0 - remark-github-beta-blockquote-admonitions: 3.1.1 - remark-toc: 9.0.0 - replace-in-file: 7.2.0 - resolve.exports: 2.0.3 - semver: 7.7.3 - simple-git: 3.30.0 - sort-json: 2.0.1 - sort-package-json: 1.57.0 - strip-ansi: 7.1.2 - table: 6.9.0 - ts-morph: 22.0.0 - unist-util-visit: 5.0.0 - xml2js: 0.6.2 - transitivePeerDependencies: - - '@swc/core' - - '@types/node' - - bluebird - - bufferutil - - encoding - - esbuild - - react-devtools-core - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - '@fluid-tools/build-cli@0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4)': dependencies: '@andrewbranch/untar.js': 1.0.3 @@ -30780,20 +30708,6 @@ snapshots: - supports-color - utf-8-validate - '@fluidframework/bundle-size-tools@0.63.0': - dependencies: - azure-devops-node-api: 11.2.0 - fflate: 0.8.2 - jszip: 3.10.1 - msgpack-lite: 0.1.26 - typescript: 5.4.5 - webpack: 5.103.0 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack-cli - '@fluidframework/bundle-size-tools@0.63.0(webpack-cli@5.1.4)': dependencies: azure-devops-node-api: 11.2.0 @@ -34373,7 +34287,7 @@ snapshots: '@vvago/vale@3.12.0': dependencies: - axios: 1.13.2 + axios: 1.13.2(debug@4.4.3) rimraf: 5.0.10 tar: 6.2.1 unzipper: 0.10.14 @@ -34825,14 +34739,6 @@ snapshots: transitivePeerDependencies: - debug - axios@1.13.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.13.2(debug@4.3.7): dependencies: follow-redirects: 1.15.11(debug@4.3.7) @@ -37070,8 +36976,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.11(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -42657,7 +42561,7 @@ snapshots: schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.103.0 + webpack: 5.103.0(webpack-cli@5.1.4) terser@5.37.0: dependencies: @@ -43687,38 +43591,6 @@ snapshots: webpack-sources@3.3.3: {} - webpack@5.103.0: - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.15(webpack@5.103.0) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.103.0(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.7