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..b574a1ba0360 --- /dev/null +++ b/async-structured-concurrency-analysis.md @@ -0,0 +1,570 @@ +# 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** | **`*****`** | + +> **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 +(`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). 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/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/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..a2b270c0ec52 100644 --- a/packages/loader/container-loader/src/container.ts +++ b/packages/loader/container-loader/src/container.ts @@ -153,6 +153,7 @@ import { type IPendingDetachedContainerState, SerializedStateManager, } from "./serializedStateManager.js"; +import { EffectionScope } from "./structuredConcurrency.js"; import { combineAppAndProtocolSummary, combineSnapshotTreeAndSnapshotBlobs, @@ -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..d8d30e866ccb 100644 --- a/packages/loader/container-loader/src/deltaManager.ts +++ b/packages/loader/container-loader/src/deltaManager.ts @@ -56,6 +56,7 @@ 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 +232,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 +436,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 +716,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 +734,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 +765,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 +784,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 +813,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..3fb28102c2bb --- /dev/null +++ b/packages/loader/container-loader/src/structuredConcurrency.ts @@ -0,0 +1,145 @@ +/*! + * 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 { + // 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 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; + } + // 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 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 async 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..081d21516d65 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 @@ -10241,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 @@ -12531,6 +12537,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 @@ -19156,6 +19165,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'} @@ -21228,6 +21240,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'} @@ -22023,6 +22039,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'} @@ -22042,6 +22066,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'} @@ -22271,6 +22299,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'} @@ -23720,6 +23752,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'} @@ -23764,6 +23801,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'} @@ -23908,6 +23954,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'} @@ -25438,6 +25488,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'} @@ -25901,6 +25955,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'} @@ -26541,6 +26599,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==} @@ -26895,6 +26957,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 @@ -28248,6 +28315,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==} @@ -32439,6 +32510,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': {} @@ -34947,6 +35023,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: @@ -35800,6 +35880,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 @@ -35819,6 +35906,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 @@ -36042,6 +36131,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 @@ -37794,6 +37885,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -37827,6 +37920,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 @@ -37940,6 +38039,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: {} @@ -40152,6 +40255,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 @@ -40656,6 +40768,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 @@ -41471,6 +41585,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 @@ -41924,6 +42040,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 @@ -43683,6 +43804,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: {}