Skip to content

Commit d2238eb

Browse files
pekkahclaude
andauthored
fix: resolve stream disposal regression in v1.0.1 (#247)
Fixes "Cannot access a closed Stream" errors during HTML composition caused by StreamWriter/StreamReader disposing underlying streams when UTF-8 encoding was added in v1.0.1. Changes: - Add leaveOpen: true to all StreamReader/StreamWriter constructors in DocsMarkdownService - Preserve UTF-8 encoding support for emoji characters - Add comprehensive unit tests reproducing the regression - Verify fix works with manual testing Fixes #246 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <[email protected]>
1 parent 40d1cd0 commit d2238eb

File tree

2 files changed

+122
-4
lines changed

2 files changed

+122
-4
lines changed

src/DocsTool/Markdown/DocsMarkdownService.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ public MarkdownDocument Parse(string text)
4444

4545
public async Task<PageFrontmatter?> RenderPage(Stream input, Stream output)
4646
{
47-
using var reader = new StreamReader(input, Encoding.UTF8);
48-
await using var writer = new StreamWriter(output, Encoding.UTF8);
47+
using var reader = new StreamReader(input, Encoding.UTF8, leaveOpen: true);
48+
await using var writer = new StreamWriter(output, Encoding.UTF8, leaveOpen: true);
4949

5050
var text = await reader.ReadToEndAsync();
5151
var markdown = Parse(text);
@@ -69,7 +69,7 @@ public MarkdownDocument Parse(string text)
6969

7070
public async Task<(MarkdownDocument Document, PageFrontmatter? Page)> ParsePage(Stream input)
7171
{
72-
using var reader = new StreamReader(input, Encoding.UTF8);
72+
using var reader = new StreamReader(input, Encoding.UTF8, leaveOpen: true);
7373

7474
var text = await reader.ReadToEndAsync();
7575
var markdown = Parse(text);
@@ -93,7 +93,7 @@ public MarkdownDocument Parse(string text)
9393

9494
public async Task<(string Html, PageFrontmatter? Page)> RenderPage(Stream input)
9595
{
96-
using var reader = new StreamReader(input, Encoding.UTF8);
96+
using var reader = new StreamReader(input, Encoding.UTF8, leaveOpen: true);
9797

9898
var text = await reader.ReadToEndAsync();
9999
var markdown = Parse(text);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using System.Threading.Tasks;
5+
using Tanka.DocsTool.Markdown;
6+
using Xunit;
7+
8+
namespace Tanka.DocsTool.Tests.Markdown
9+
{
10+
public class DocsMarkdownServiceFacts
11+
{
12+
[Fact]
13+
public async Task RenderPage_WithMemoryStream_ShouldNotThrowClosedStreamException()
14+
{
15+
// Given - Create a DocsMarkdownService
16+
var service = new DocsMarkdownService(new Markdig.MarkdownPipelineBuilder());
17+
18+
// Create markdown content with emojis (reproducing the original issue)
19+
var markdownContent = @"---
20+
title: Test Page
21+
---
22+
23+
# Test Page 🚀
24+
25+
This is a test page with emoji content that caused the original issue.
26+
27+
Content includes:
28+
- Emoji characters: 🎉 ✨ 📝
29+
- Regular text
30+
- More content to make it substantial
31+
";
32+
33+
// Create input stream (simulating processedStream in PageComposer)
34+
await using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(markdownContent));
35+
await using var outputStream = new MemoryStream();
36+
37+
// When - Call RenderPage (this should reproduce the "Cannot access a closed Stream" error)
38+
var exception = await Record.ExceptionAsync(async () =>
39+
{
40+
await service.RenderPage(inputStream, outputStream);
41+
});
42+
43+
// Then - Should not throw any exception
44+
Assert.Null(exception);
45+
46+
// Verify output was generated
47+
Assert.True(outputStream.Length > 0);
48+
49+
// Verify we can still read from the input stream after RenderPage
50+
inputStream.Position = 0;
51+
using var reader = new StreamReader(inputStream, Encoding.UTF8);
52+
var content = await reader.ReadToEndAsync();
53+
Assert.Contains("Test Page", content);
54+
}
55+
56+
[Fact]
57+
public async Task RenderPage_SimulatePageComposerScenario_ShouldHandleStreamLifetime()
58+
{
59+
// Given - Simulate the exact scenario from PageComposer.ComposePartialHtmlPage
60+
var service = new DocsMarkdownService(new Markdig.MarkdownPipelineBuilder());
61+
62+
var markdownContent = @"---
63+
title: Architecture Overview
64+
---
65+
66+
# Architecture Overview 🏗️
67+
68+
System design includes:
69+
- Pipeline architecture
70+
- Multi-layer file system
71+
- Content processing chain
72+
";
73+
74+
Exception? caughtException = null;
75+
string contentPreview = "";
76+
77+
// When - Simulate the exact flow from PageComposer
78+
await using var processedStream = new MemoryStream(Encoding.UTF8.GetBytes(markdownContent));
79+
processedStream.Position = 0;
80+
81+
await using var outputStream = new MemoryStream();
82+
83+
try
84+
{
85+
var frontmatter = await service.RenderPage(processedStream, outputStream);
86+
// This should succeed without throwing
87+
Assert.NotNull(frontmatter);
88+
Assert.Equal("Architecture Overview", frontmatter.Title);
89+
}
90+
catch (Exception e)
91+
{
92+
caughtException = e;
93+
94+
// Simulate the error handling code from PageComposer that tries to read the stream
95+
try
96+
{
97+
processedStream.Position = 0;
98+
using var debugReader = new StreamReader(processedStream, Encoding.UTF8, leaveOpen: true);
99+
var buffer = new char[100];
100+
var charsRead = await debugReader.ReadAsync(buffer, 0, buffer.Length);
101+
contentPreview = new string(buffer, 0, charsRead);
102+
processedStream.Position = 0;
103+
}
104+
catch (Exception debugEx)
105+
{
106+
// This is where we might see "Cannot access a closed Stream"
107+
contentPreview = $"[Debug failed: {debugEx.Message}]";
108+
}
109+
}
110+
111+
// Then - Should not have any exception
112+
if (caughtException != null)
113+
{
114+
Assert.True(false, $"Unexpected exception: {caughtException.Message}. Content preview: {contentPreview}");
115+
}
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)