Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler config init --local
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler clone -c local --skip-private-repositories
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler build -c local --skip-private-repositories
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler serve-static &
dotnet run --project ../docs-builder/src/tooling/docs-builder -- assembler serve &

- name: Wait for docs
working-directory: src/Elastic.Documentation.Site
Expand Down
2 changes: 1 addition & 1 deletion aspire/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ internal static async Task Run(
.WithEnvironment("LLM_GATEWAY_FUNCTION_URL", llmUrl)
.WithEnvironment("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH", llmServiceAccountPath)
.WithHttpEndpoint(port: 4000, isProxied: false)
.WithArgs(["assembler", "serve-static", .. GlobalArguments])
.WithArgs(["assembler", "serve", .. GlobalArguments])
.WithHttpHealthCheck("/", 200)
.WaitForCompletion(buildAll)
.WithParentRelationship(cloneAll);
Expand Down
2 changes: 0 additions & 2 deletions build/CommandLine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ type Build =

| [<CliPrefix(CliPrefix.None);SubCommand>] Format of ParseResults<FormatArgs>
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch
| [<CliPrefix(CliPrefix.None);SubCommand>] Watch_Full

| [<CliPrefix(CliPrefix.None);Hidden;SubCommand>] Lint of ParseResults<LintArgs>
| [<CliPrefix(CliPrefix.None);Hidden;SubCommand>] PristineCheck
Expand Down Expand Up @@ -85,7 +84,6 @@ with
| Format _ -> "runs dotnet format"

| Watch -> "runs dotnet watch to continuous build code/templates and web assets on the fly"
| Watch_Full -> "runs assembler serve with dotnet watch — watches checkout dirs and live-reloads assembled docs"

// steps
| Lint _ -> "runs dotnet format --verify-no-changes"
Expand Down
3 changes: 0 additions & 3 deletions build/Targets.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ let private format (formatArgs: ParseResults<FormatArgs>) =

let private watch _ = exec { run "dotnet" "watch" "--project" "src/tooling/docs-builder" "--configuration" "debug" "--" "serve" "--watch" }

let private watchFull _ = exec { run "dotnet" "watch" "--project" "src/tooling/docs-builder" "--configuration" "debug" "--" "assembler" "serve" }

let private lint (lintArgs: ParseResults<LintArgs>) =
let includeFiles = lintArgs.TryGetResult LintArgs.Include |> Option.defaultValue []
let includeArgs =
Expand Down Expand Up @@ -258,7 +256,6 @@ let Setup (parsed:ParseResults<Build>) =

| Format formatArgs -> Build.Step (fun _ -> format formatArgs)
| Watch -> Build.Step watch
| Watch_Full -> Build.Step watchFull

// steps
| Lint lintArgs -> Build.Step (fun _ -> lint lintArgs)
Expand Down
80 changes: 2 additions & 78 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1329,85 +1329,9 @@
"assembler"
],
"name": "serve",
"summary": "Serve assembled documentation with live reload and on-demand per-request rendering. Requires assembler clone to have been run first. No prior build is needed. Pages are rendered on demand; file changes invalidate the repo and trigger a live reload.",
"notes": null,
"usage": "docs-builder assembler serve [options]",
"examples": [],
"parameters": [
{
"role": "flag",
"name": "port",
"shortName": null,
"type": "integer",
"required": false,
"summary": "Port to listen on. Default: 4000.",
"defaultValue": "4000"
},
{
"role": "flag",
"name": "environment",
"shortName": null,
"type": "string",
"required": false,
"summary": "Named deployment target. Determines which repositories are used."
},
{
"role": "flag",
"name": "no-watch-md",
"shortName": null,
"type": "boolean",
"required": false,
"summary": "Disable watching checkout directories for markdown changes. Static asset live reload still works. Useful when doing frontend (CSS/JS) work.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "log-level",
"shortName": "l",
"type": "enum",
"required": false,
"summary": "Minimum log level. Default: information",
"enumValues": [
"trace",
"debug",
"information",
"warning",
"error",
"critical",
"none"
]
},
{
"role": "flag",
"name": "config-source",
"shortName": "c",
"type": "enum",
"required": false,
"summary": "Override the configuration source: local, remote",
"enumValues": [
"local",
"remote",
"embedded"
]
},
{
"role": "flag",
"name": "skip-private-repositories",
"shortName": null,
"type": "boolean",
"required": false,
"summary": "Skip cloning private repositories"
}
]
},
{
"path": [
"assembler"
],
"name": "serve-static",
"summary": "Serve the output of a completed assembler build at http://localhost:4000.",
"notes": "Run after assembler build. Does not watch for file changes.",
"usage": "docs-builder assembler serve-static [options]",
"usage": "docs-builder assembler serve [options]",
"examples": [],
"parameters": [
{
Expand All @@ -1423,7 +1347,7 @@
"name": "path",
"type": "string",
"required": false,
"summary": "Path to the built site. Defaults to .artifacts/assembly/.",
"summary": "Path to the built site. Defaults to .artifacts/docs/.",
"validations": [
{
"kind": "rejectSymbolicLinks"
Expand Down
8 changes: 1 addition & 7 deletions src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace Elastic.Documentation.LinkIndex;

public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elastic-docs-link-index", string registryKey = "link-index.json") : ILinkIndexReader, IDisposable
public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elastic-docs-link-index", string registryKey = "link-index.json") : ILinkIndexReader
{

// <summary>
Expand Down Expand Up @@ -52,10 +52,4 @@ public async Task<RepositoryLinks> GetRepositoryLinks(string key, Cancel cancell
}

public string RegistryUrl { get; } = $"https://{bucketName}.s3.{s3Client.Config.RegionEndpoint.SystemName}.amazonaws.com/{registryKey}";

public void Dispose()
{
s3Client.Dispose();
GC.SuppressFinalize(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,9 @@ public record FetchedCrossLinks
};
}

public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null, bool ownsReader = false) : IDisposable
public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable
{
Comment on lines +61 to 62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print the relevant parts of CrossLinkFetcher.cs (class header + Dispose region)
FILE="src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "=== Class declaration (around 55-75) ==="
nl -ba "$FILE" | sed -n '55,80p'

echo
echo "=== Dispose() implementation (around 180-240) ==="
nl -ba "$FILE" | sed -n '180,240p'

echo
echo "=== Search for Dispose calls / IDisposable implementation in this file ==="
rg -n "IDisposable|Dispose\(" "$FILE"

Repository: elastic/docs-builder

Length of output: 167


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "=== Class declaration (lines 55-80) ==="
awk 'NR>=55 && NR<=80 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "=== Dispose() implementation (lines 180-240) ==="
awk 'NR>=180 && NR<=240 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "=== Search for IDisposable/Dispose in this file ==="
rg -n "IDisposable|Dispose\(" "$FILE"

Repository: elastic/docs-builder

Length of output: 2558


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find derived types of CrossLinkFetcher
rg -n ":\s*CrossLinkFetcher" -S src

# Find any calls to base.Dispose() in derived classes
rg -n "base\.Dispose\(\)" -S src

# Find Dispose overrides/implementations that might interact with CrossLinkFetcher
rg -n "override\s+void\s+Dispose|void\s+Dispose\(" -S src | rg -n "CrossLinkFetcher|CrossLink" || true

Repository: elastic/docs-builder

Length of output: 605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for Dispose patterns in all classes that derive from CrossLinkFetcher
rg -n "class\s+.+:\s*CrossLinkFetcher|:\s*CrossLinkFetcher" src

echo "---- Derived Dispose overrides (override/Dispose methods) ----"
rg -n "override\s+void\s+Dispose\s*\(|\bvoid\s+Dispose\s*\(" src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs

echo "---- base.Dispose() calls in those derived files ----"
rg -n "base\.Dispose\s*\(" src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs

echo "---- Check if derived types explicitly implement IDisposable beyond the base ----"
rg -n "IDisposable" src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs

Repository: elastic/docs-builder

Length of output: 668


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs"
  "src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs"
  "src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs"
)

echo "---- Other derived declarations across src (': CrossLinkFetcher') ----"
rg -n ":\s*CrossLinkFetcher" src || true

echo "---- Dispose( occurrences in known derived files ----"
for f in "${files[@]}"; do
  if [ -f "$f" ]; then
    echo "== $f =="
    rg -n "Dispose\s*\(" "$f" || true
  else
    echo "Missing $f"
  fi
done

Repository: elastic/docs-builder

Length of output: 984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find all registrations/usages mentioning CrossLinkFetcher or its derived types
rg -n "CrossLinkFetcher|AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher" src -S -g'*.cs'

echo
echo "---- check ScopedFileSystem type ----"
# Find the declaration file for ScopedFileSystem
fd -a "ScopedFileSystem" src || true
rg -n "class\s+ScopedFileSystem|record\s+ScopedFileSystem|struct\s+ScopedFileSystem|interface\s+ScopedFileSystem" src -S -g'*.cs' || true

echo
echo "---- check whether ScopedFileSystem implements IDisposable ----"
rg -n "ScopedFileSystem.*IDisposable|IDisposable.*ScopedFileSystem" src -S -g'*.cs' || true

echo
echo "---- inspect derived fetcher files for constructors/fields ----"
for f in \
  "src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs" \
  "src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs" \
  "src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs"
do
  if [ -f "$f" ]; then
    echo "== $f (top 140 lines) =="
    head -n 140 "$f" | nl -ba | sed -n '1,140p'
  fi
done

Repository: elastic/docs-builder

Length of output: 4181


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Find definition of ScopedFileSystem ==="
rg -n "(\bclass\b|\brecord\b|\bstruct\b|\binterface\b)\s+ScopedFileSystem\b" src -S -g'*.cs' || true

echo
echo "=== Check whether ScopedFileSystem is IDisposable ==="
rg -n "ScopedFileSystem.*IDisposable|IDisposable.*ScopedFileSystem" src -S -g'*.cs' || true

echo
echo "=== Show usages of CrossLinkFetcher-derived types to see how they are disposed ==="
rg -n -C 3 "new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" src -S -g'*.cs'

echo
echo "=== Search for explicit Dispose() calls on those fetchers ==="
rg -n "(\.Dispose\(\)|using\s+var)\s*(fetcher|crossLinkFetcher|_crossLinkFetcher)\b" src -S -g'*.cs' || true

Repository: elastic/docs-builder

Length of output: 9552


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Files likely involved in lifecycle/disposal
targets=(
  "src/services/Elastic.Documentation.Assembler/AssembleSources.cs"
  "src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs"
  "src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs"
  "src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs"
  "src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs"
  "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs"
)

for f in "${targets[@]}"; do
  echo "==== $f (around instantiation) ===="
  rg -n "new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" "$f" -S || true
  # show a window around each match
  rg -n -S "new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" "$f" | cut -d: -f1 | head -n 5 | while read -r line; do
    start=$((line-15)); end=$((line+15))
    sed -n "${start},${end}p" "$f" | sed "s/^/[$f:$start] /" >/dev/null
  done
  # simpler: just print a safe chunk near the first match line if exists
  first=$(rg -n -S "new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" "$f" | head -n1 | cut -d: -f1 || true)
  if [ -n "${first:-}" ]; then
    start=$((first-30)); end=$((first+60))
    [ $start -lt 1 ] && start=1
    echo "---- context $start-$end ----"
    sed -n "${start},${end}p" "$f"
  fi
  echo
done

echo "==== ReloadableGeneratorState dispose implementation ===="
rg -n "Dispose\s*\(" src/tooling/docs-builder/Http/ReloadableGeneratorState.cs -S || true
echo
if [ -f "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs" ]; then
  sed -n '1,220p' src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
fi

Repository: elastic/docs-builder

Length of output: 367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

show_chunk () {
  local file="$1" start="$2" end="$3"
  echo "---- $file [$start-$end] ----"
  awk -v s="$start" -v e="$end" 'NR>=s && NR<=e {printf "%d\t%s\n", NR, $0}' "$file"
  echo
}

targets=(
  "src/services/Elastic.Documentation.Assembler/AssembleSources.cs"
  "src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs"
  "src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs"
  "src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs"
  "src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs"
  "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs"
  "src/Elastic.Codex/Building/CodexBuildService.cs"
)

patterns=(
  "new[[:space:]]+AssemblerCrossLinkFetcher"
  "new[[:space:]]+LinksIndexCrossLinkFetcher"
  "new[[:space:]]+DocSetConfigurationCrossLinkFetcher"
)

# Print first instantiation context for each known file
for f in "${targets[@]}"; do
  [ -f "$f" ] || continue
  for p in "${patterns[@]}"; do
    line=$(rg -n -S "$p" "$f" | head -n1 | cut -d: -f1 || true)
    if [ -n "${line:-}" ]; then
      start=$((line-25)); end=$((line+60))
      [ $start -lt 1 ] && start=1
      show_chunk "$f" "$start" "$end"
    fi
  done
done

echo "==== ReloadableGeneratorState.cs Dispose implementation ===="
[ -f "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs" ] && rg -n "Dispose\s*\(" src/tooling/docs-builder/Http/ReloadableGeneratorState.cs -S || true
[ -f "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs" ] && show_chunk "src/tooling/docs-builder/Http/ReloadableGeneratorState.cs" 1 220

echo "==== Any Dispose calls on CrossLinkFetcher instances ===="
rg -n -S "\.Dispose\s*\(\s*\)\s*;|using\s+var\s+\w+\s*=\s*new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)" src -g'*.cs' || true

echo "==== Any disposal of ILoggerFactory instances (logFactory.Dispose) ===="
rg -n -S "logFactory\.Dispose\(|_logFactory\.Dispose\(|ILoggerFactory.*Dispose\(" src -g'*.cs' || true

Repository: elastic/docs-builder

Length of output: 39105


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs"
echo "=== CrossLinkFetcher.cs (around class header/fields/constructor params) ==="
awk 'NR>=55 && NR<=120 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo
echo "=== CrossLinkFetcher.cs (around end / Dispose / owned resources) ==="
awk 'NR>=120 && NR<=230 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "=== Search for disposal of specific fetcher variables ==="
rg -n -S "(crossLinkFetcher|_crossLinkFetcher)\.Dispose\(" src -g'*.cs' || true
rg -n -S "using\s+var\s+\w+\s*=\s*new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" src -g'*.cs' || true
rg -n -S "\bDispose\(\)\s*;\s*.*CrossLinkFetcher|CrossLinkFetcher.*Dispose\(" src -g'*.cs' || true

echo
echo "=== Search for any cast/dispose of CrossLinkFetcher instances ==="
rg -n -S "(\(IDisposable\).*)?CrossLinkFetcher.*\.Dispose\(" src -g'*.cs' || true
rg -n -S "\b(IDisposable)\s*\)\s*.*CrossLinkFetcher" src -g'*.cs' || true

echo
echo "=== CrossLinkFetcher constructor ownership: look for stored members ==="
rg -n -S "_fileSystem|linkIndexProvider|_linkIndexProvider|_cachedLinkReferences|_linkIndex" "$FILE"

Repository: elastic/docs-builder

Length of output: 8200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Any using-var or explicit Dispose for CrossLinkFetcher-derived types ==="
rg -n -S "using\s+var\s+\w+\s*=\s*new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" src -g'*.cs' || true
rg -n -S "\b(new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b).*\bDispose\s*\(" src -g'*.cs' || true
rg -n -S "(CrossLinkFetcher|AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b" src -g'*.cs' | head -n 200
echo
echo "=== Any Dispose calls where the variable type is IDisposable and created from CrossLinkFetcher ==="
rg -n -S "(IDisposable)\s+\w+\s*=\s*new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b|new\s+(AssemblerCrossLinkFetcher|LinksIndexCrossLinkFetcher|DocSetConfigurationCrossLinkFetcher)\b\s*\)|\.Dispose\s*\(\s*\)" src -g'*.cs' || true

echo
echo "=== Any casts to IDisposable around CrossLinkFetcher instances ==="
rg -n -S "\(IDisposable\)\s*\w+.*CrossLinkFetcher|CrossLinkFetcher.*\(IDisposable\)" src -g'*.cs' || true

Repository: elastic/docs-builder

Length of output: 7006


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Look for explicit LoggerFactory creation ==="
rg -n -S "new\s+LoggerFactory|LoggerFactory\.Create" src -g'*.cs' || true

echo
echo "=== Look for explicit ILoggerFactory.Dispose calls outside CrossLinkFetcher ==="
rg -n -S "(\.Dispose\(\)\s*;|\bDispose\(\)\b)\s*$" src -g'*.cs' | rg -n "ILoggerFactory|logFactory" || true

echo
echo "=== Look for any using/Dispose of CrossLinkFetcher instances (not just fetcher type) ==="
rg -n -S "using\s+var\s+\w+\s*=\s*new\s+.*CrossLinkFetcher|\.Dispose\(\)\s*;.*CrossLinkFetcher|_crossLinkFetcher\s*\.Dispose" src -g'*.cs' || true

Repository: elastic/docs-builder

Length of output: 378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Call sites for AssembleSources.AssembleAsync( ... , ILoggerFactory logFactory, ...) ==="
rg -n "AssembleSources\.AssembleAsync\s*\(" src -g'*.cs' || true

echo
echo "=== Call sites for ReloadableGeneratorState constructor ==="
rg -n "new\s+ReloadableGeneratorState\s*\(" src -g'*.cs' || true

echo
echo "=== DI registrations for ReloadableGeneratorState (if any) ==="
rg -n "AddSingleton<\s*ReloadableGeneratorState|AddScoped<\s*ReloadableGeneratorState|AddTransient<\s*ReloadableGeneratorState" src -g'*.cs' || true

echo
echo "=== Show ReloadableGeneratorState.Dispose implementation (dispose of _crossLinkFetcher/logFactory?) ==="
rg -n "void\s+Dispose\s*\(" src/tooling/docs-builder/Http/ReloadableGeneratorState.cs -g'*.cs' || true
sed -n '240,310p' src/tooling/docs-builder/Http/ReloadableGeneratorState.cs || true

Repository: elastic/docs-builder

Length of output: 1863


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Any explicit .Dispose() on CrossLinkFetcher types/vars ==="
rg -n -S "\.Dispose\(\)\s*;|using\s+var\s+\w+\s*=\s*new\s+\w*CrossLinkFetcher" src -g'*.cs' || true

echo
echo "=== Any Dispose() call where the receiver is a variable named like *fetcher* that holds a CrossLinkFetcher ==="
rg -n -S "Dispose\(\)\s*;|using\s+var\s+\w+\s*=\s*new\s+.*CrossLinkFetcher" src/services src/tooling -g'*.cs' || true

echo
echo "=== Inspect DocumentationWebHost.cs for ILoggerFactory source ==="
FILE="src/tooling/docs-builder/Http/DocumentationWebHost.cs"
if [ -f "$FILE" ]; then
  sed -n '1,220p' "$FILE"
else
  echo "Missing $FILE"
fi

Repository: elastic/docs-builder

Length of output: 12591


Do not dispose the injected ILoggerFactory in CrossLinkFetcher
CrossLinkFetcher implements IDisposable and calls logFactory.Dispose() in Dispose() (around lines 207-210), but ILoggerFactory is injected and not owned by this class—disposing it can break logging for other components sharing the same factory. Remove the base Dispose()/IDisposable (or at least stop disposing logFactory).

🔧 Minimal fix
-public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable
+public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null)
 {
@@
-	public void Dispose()
-	{
-		logFactory.Dispose();
-		GC.SuppressFinalize(this);
-	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs` around lines
61 - 62, CrossLinkFetcher is disposing the injected ILoggerFactory (logFactory)
in its Dispose implementation which it does not own; remove the call that
disposes logFactory inside CrossLinkFetcher.Dispose (or remove the IDisposable
implementation if the class has no other owned disposable state). Ensure Dispose
still disposes only owned resources (e.g., ScopedFileSystem/fileSystem) and
suppress finalization if appropriate, but do not call logFactory.Dispose() or
otherwise assume ownership of ILoggerFactory.

protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher));
protected ILinkIndexReader LinkIndexProvider => linkIndexProvider;
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData;
private LinkRegistry? _linkIndex;

Expand Down Expand Up @@ -207,11 +206,7 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR

public void Dispose()
{
// Only dispose linkIndexProvider when this fetcher created it (ownsReader = true).
// When the reader was injected by the caller, the caller retains ownership and must dispose it.
// logFactory is always injected — never disposed here.
if (ownsReader && linkIndexProvider is IDisposable disposableReader)
disposableReader.Dispose();
logFactory.Dispose();
GC.SuppressFinalize(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ public class DocSetConfigurationCrossLinkFetcher(
ConfigurationFile configuration,
ILinkIndexReader? linkIndexProvider = null,
ILinkIndexReader? codexLinkIndexReader = null)
: CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous(), ownsReader: linkIndexProvider is null)
: CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse a single public link-index reader.

Line 19 and Line 34 each call Aws3LinkIndexReader.CreateAnonymous() when no reader is supplied. That creates two separate readers; only the second one is used here, so the base instance is dead weight and its cache never helps.

🔧 One way to keep a single reader
 public class DocSetConfigurationCrossLinkFetcher(
 	ILoggerFactory logFactory,
 	ConfigurationFile configuration,
 	ILinkIndexReader? linkIndexProvider = null,
 	ILinkIndexReader? codexLinkIndexReader = null)
-	: CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous())
+	: CrossLinkFetcher(logFactory, linkIndexProvider ??= Aws3LinkIndexReader.CreateAnonymous())
 {
+	private readonly ILinkIndexReader _publicReader = linkIndexProvider;
 	private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher));
 	private readonly ILinkIndexReader? _codexReader = codexLinkIndexReader;
@@
-		var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous();
+		var publicReader = _publicReader;

Also applies to: 34-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs`
at line 19, The constructor for DocSetConfigurationCrossLinkFetcher currently
calls Aws3LinkIndexReader.CreateAnonymous() inline in multiple places, producing
two distinct readers where only one should be used; fix by creating a single
local variable (e.g., var reader = linkIndexProvider ??
Aws3LinkIndexReader.CreateAnonymous()) and pass that same reader instance
wherever the code currently calls Aws3LinkIndexReader.CreateAnonymous() (notably
the base CrossLinkFetcher(...) call and the later usage around lines 34–35) so
the same reader and its cache are reused by DocSetConfigurationCrossLinkFetcher.

{
private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher));
// _codexReader is injected by the caller who retains ownership and is responsible for disposal.
// ReloadableGeneratorState, the primary caller, disposes it directly in its own Dispose().
private readonly ILinkIndexReader? _codexReader = codexLinkIndexReader;

public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
Expand All @@ -33,7 +31,7 @@ public override async Task<FetchedCrossLinks> FetchCrossLinks(Cancel ctx)
var codexRepositories = new HashSet<string>();
var declaredRepositories = new HashSet<string>();

var publicReader = LinkIndexProvider;
var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous();
var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null;

// Fetch each registry once up front so per-repository lookups don't trigger N S3 round-trips.
Expand Down
14 changes: 1 addition & 13 deletions src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,26 +217,14 @@ public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown)
}

private bool _resolved;
private long _version;

public void InvalidateResolved()
{
_ = Interlocked.Increment(ref _version);
_resolved = false;
}

public async Task ResolveDirectoryTree(Cancel ctx)
{
if (_resolved)
return;

// Capture the version before parsing so that if InvalidateResolved() fires
// mid-flight we do not incorrectly mark the (now stale) result as resolved.
var capturedVersion = Interlocked.Read(ref _version);
await Parallel.ForEachAsync(MarkdownFiles, ctx, async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token));

if (Interlocked.Read(ref _version) == capturedVersion)
_resolved = true;
_resolved = true;
}

public RepositoryLinks CreateLinkReference()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,13 @@ Cancel ctx
{
var logger = logFactory.CreateLogger<AssembleSources>();

var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous();
var navigationTocMappings = GetTocMappings(context);
var uriResolver = new PublishEnvironmentUriResolver(navigationTocMappings, context.Environment);

var sw = System.Diagnostics.Stopwatch.StartNew();
FetchedCrossLinks crossLinks;
// Use a separate using for the reader so ownership is explicit: the caller (this method)
// disposes it, not the fetcher (ownsReader stays false/default on AssemblerCrossLinkFetcher).
using var linkIndexReader = Aws3LinkIndexReader.CreateAnonymous();
using (var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader))
crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexProvider);
var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx);
var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver);
logger.LogInformation(" AssembleAsync: FetchCrossLinks in {Elapsed:mm\\:ss\\.fff}", sw.Elapsed);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@ public async Task BuildAllAsync(FrozenDictionary<string, AssemblerDocumentationS
continue;
}

var documentInferrer = CreateInferrer(set);
// Create inferrer per-repository with git context
var documentInferrer = new DocumentInferrerService(
context.ProductsConfiguration,
context.VersionsConfiguration,
context.LegacyUrlMappings,
set.DocumentationSet.Configuration,
set.DocumentationSet.Context.Git
);

var stopwatch = Stopwatch.StartNew();
try
Expand Down Expand Up @@ -150,33 +157,6 @@ string Resolve(string path)
}
}

private DocumentInferrerService CreateInferrer(AssemblerDocumentationSet set) =>
new(
context.ProductsConfiguration,
context.VersionsConfiguration,
context.LegacyUrlMappings,
set.DocumentationSet.Configuration,
set.DocumentationSet.Context.Git
);

public DocumentationGenerator CreateGenerator(AssemblerDocumentationSet set)
{
SetFeatureFlags(set);
return new DocumentationGenerator(
set.DocumentationSet,
logFactory, NavigationTraversable, HtmlWriter,
pathProvider,
legacyUrlMapper: LegacyUrlMapper,
documentInferrer: CreateInferrer(set)
);
}

public async Task BuildOneAsync(AssemblerDocumentationSet set, Cancel ctx)
{
await set.DocumentationSet.ResolveDirectoryTree(ctx);
_ = await BuildAsync(set, null, CreateInferrer(set), ctx);
}

private async Task<GenerationResult> BuildAsync(AssemblerDocumentationSet set, IMarkdownExporter[]? markdownExporters, IDocumentInferrerService documentInferrer, Cancel ctx)
{
SetFeatureFlags(set);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,15 @@
using Actions.Core.Services;
using Documentation.Builder.Http;
using Elastic.Documentation;
using Elastic.Documentation.Assembler;
using Elastic.Documentation.Assembler.Building;
using Elastic.Documentation.Assembler.Navigation;
using Elastic.Documentation.Assembler.Sourcing;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Assembler;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.LegacyDocs;
using Elastic.Documentation.Navigation.Assembler;
using Elastic.Documentation.Services;
using Microsoft.Extensions.Logging;
using Nullean.Argh;
using Nullean.Argh.Documentation;
using Nullean.ScopedFileSystem;

namespace Documentation.Builder.Commands.Assembler;

Expand Down Expand Up @@ -161,60 +155,13 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.
return await serviceInvoker.InvokeAsync(ct);
}

/// <summary>
/// Serve assembled documentation with live reload and on-demand per-request rendering.
/// Requires <c>assembler clone</c> to have been run first. No prior build is needed.
/// Pages are rendered on demand; file changes invalidate the repo and trigger a live reload.
/// </summary>
/// <param name="port">Port to listen on. Default: 4000.</param>
/// <param name="environment">Named deployment target. Determines which repositories are used.</param>
/// <param name="noWatchMd">Disable watching checkout directories for markdown changes. Static asset live reload still works. Useful when doing frontend (CSS/JS) work.</param>
[NoOptionsInjection]
public async Task Serve(int port = 4000, string? environment = null, bool noWatchMd = false, CancellationToken ct = default)
{
environment ??= "dev";
var readFs = FileSystemFactory.RealRead;
var writeFs = FileSystemFactory.RealWrite;

var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null);

var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext);
var checkoutResult = cloner.GetAll();
var checkouts = checkoutResult.Checkouts.ToArray();

if (checkouts.Length == 0)
throw new Exception("No checkouts found. Run 'assembler clone' first.");

var exporters = ExportOptions.Default
.Except([Exporter.DocumentationState])
.ToHashSet();

var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ct);

var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile;
var siteNavigationFile = SiteNavigationFile.Deserialize(await readFs.File.ReadAllTextAsync(navigationFileInfo.FullName, ct));
var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray();
var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix);

var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext);
using var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector);
var legacyPageChecker = new LegacyPageService(logFactory);
var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings);
var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper);

var host = new AssemblerServeWebHost(port, assembleSources, builder, logFactory, watchMarkdown: !noWatchMd);
await host.RunAsync(ct);
await host.StopAsync(ct);
await collector.StopAsync(ct);
}

/// <summary>Serve the output of a completed assembler build at <c>http://localhost:4000</c>.</summary>
/// <remarks>Run after <c>assembler build</c>. Does not watch for file changes.</remarks>
/// <param name="port">Port to listen on. Default: 4000.</param>
/// <param name="path">Path to the built site. Defaults to <c>.artifacts/assembly/</c>.</param>
/// <param name="path">Path to the built site. Defaults to <c>.artifacts/docs/</c>.</param>

[NoOptionsInjection]
[CommandName("serve-static")]
public async Task ServeStatic(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default)
public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default)
{
var host = new StaticWebHost(port, path?.FullName);
await host.RunAsync(ct);
Expand Down
Loading
Loading