From 29870318b344dca5049e9450bb665f0131694039 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 17:47:51 -0700 Subject: [PATCH 1/2] fix(http-client-csharp): improve diagnostics for intermittent root namespace deserialization failures The playground server intermittently returns 500s when the generator fails to deserialize the root InputNamespace with the opaque message "The JSON value could not be converted to InputNamespace" (name was null). Because parsing a fixed document is deterministic, non-deterministic failures for a spec that always declares a namespace indicate the code model is being mangled/truncated before it reaches the generator. This adds the diagnostics needed to localize the corruption and makes the failure self-explanatory: - Generator: replace the bare `throw new JsonException()` in InputNamespaceConverter with a descriptive message that names the missing 'name' property, while preserving the System.Text.Json Path/line/byte info. - Playground server: log a SHA-256 of the received code model so identical specs that yield different hashes (transport mangling) become visible, and add a write-integrity check that flags when the code model written to disk differs from the received payload (server-side mangling). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/InputNamespaceConverter.cs | 4 ++- .../test/InputNamespaceTests.cs | 14 ++++++++++ .../playground-server/Program.cs | 26 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs index 028e969e489..ab1d30f3235 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs @@ -61,7 +61,9 @@ public override void Write(Utf8JsonWriter writer, InputNamespace value, JsonSeri clients ??= []; return new InputNamespace( - name ?? throw new JsonException(), + name ?? throw new JsonException( + "Required property 'name' was missing or null on the root InputNamespace. " + + "The input code model is incomplete or was corrupted in transit."), apiVersions, constants, enums, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs index de162c40d12..e52dc4d551b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs @@ -3,6 +3,7 @@ using Microsoft.TypeSpec.Generator.Tests.Common; using NUnit.Framework; +using System.Text.Json; namespace Microsoft.TypeSpec.Generator.Input.Tests { @@ -22,5 +23,18 @@ public void CalculatesAllClients() Assert.AreEqual(1, inputNamespace.RootClients.Count); Assert.AreEqual("Client1", inputNamespace.RootClients[0].Name); } + + [Test] + public void DeserializeThrowsDescriptiveErrorWhenNameMissing() + { + // A code model whose root object has no "name" property (e.g. an incomplete or + // truncated payload) must fail with an actionable message naming the missing field, + // not a bare "could not be converted to InputNamespace". + var json = "{\n \"$id\": \"1\",\n \"apiVersions\": []\n}"; + + var ex = Assert.Throws(() => TypeSpecSerialization.Deserialize(json)); + + Assert.That(ex!.Message, Does.Contain("name")); + } } } diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 684d95f4967..27d413777d3 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -123,6 +123,10 @@ } Console.WriteLine($"Generator version: {generatorVersion}"); +// Root route exists so Azure App Service platform probes (Always On warm-up +// and availability pings hit "/") get a 200 instead of logging noisy 404s. +app.MapGet("/", () => Results.Ok(new { status = "ok", service = "csharp-playground-server" })); + app.MapGet("/health", () => { string dotnetVersion; @@ -193,6 +197,11 @@ void TrackGenerateEvent(GenerateOutcome outcome) var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; telemetryProperties["generatorName"] = generatorName; telemetryProperties["codeModelSizeBytes"] = body.CodeModel.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); + // Hash the received code model so non-deterministic failures can be triaged: identical + // specs must yield an identical hash. A "same" spec that produces a different hash/size + // across requests indicates the code model is being mangled in transport before it reaches us. + telemetryProperties["codeModelSha256"] = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(body.CodeModel))); if (!File.Exists(generatorPath)) { @@ -220,9 +229,24 @@ void TrackGenerateEvent(GenerateOutcome outcome) try { // Write the input files the generator expects - await File.WriteAllTextAsync(Path.Combine(tempDir, "tspCodeModel.json"), body.CodeModel); + var codeModelPath = Path.Combine(tempDir, "tspCodeModel.json"); + await File.WriteAllTextAsync(codeModelPath, body.CodeModel); await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); + // Integrity check: confirm the code model on disk matches what we received. A mismatch + // localizes "mangling" to the server's write/encoding path rather than transport or the + // generator, and explains intermittent root-level deserialization failures. + var writtenCodeModel = await File.ReadAllTextAsync(codeModelPath); + if (!string.Equals(writtenCodeModel, body.CodeModel, StringComparison.Ordinal)) + { + telemetryProperties["codeModelWriteMismatch"] = + $"receivedChars={body.CodeModel.Length},writtenChars={writtenCodeModel.Length}"; + telemetryClient?.TrackTrace( + "Code model written to disk does not match the received payload.", + SeverityLevel.Error, + telemetryProperties); + } + // Run the .NET generator as a subprocess Console.WriteLine($"Starting generator: dotnet --roll-forward Major {generatorPath} {tempDir} -g {generatorName} --new-project"); Console.WriteLine($"Code model size: {body.CodeModel!.Length} chars"); From ab4c109456ea0d999da4759836f1edf069e02dec Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 5 Jun 2026 21:46:01 -0700 Subject: [PATCH 2/2] fix(http-client-csharp): surface clear diagnostic when client namespace cannot be resolved When the emitter could not resolve a namespace, it produced a code model with no `name` and still sent it to the generator, which failed with an opaque root-level deserialization error (a 500 on the playground server). Report an actionable `unresolved-client-namespace` diagnostic and skip generation instead. Also remove the pointless server-side write-integrity check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-csharp/emitter/src/emitter.ts | 39 +++++++++++-------- .../emitter/src/lib/client-model-builder.ts | 19 ++++++++- .../http-client-csharp/emitter/src/lib/lib.ts | 6 +++ .../test/Unit/client-model-builder.test.ts | 24 ++++++++++++ .../playground-server/Program.cs | 18 --------- 5 files changed, 70 insertions(+), 36 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index c49591c53d2..1aaa9c698f2 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -81,24 +81,31 @@ export async function emitCodeModel( const updatedRoot = updateCodeModel ? updateCodeModel(root, sdkContext) : root; const namespace = updatedRoot.name; - const configurations: Configuration = createConfiguration(options, namespace, sdkContext); - // Serialize code model and configuration - const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); - const configJson = JSON.stringify(configurations, null, 2) + "\n"; + // If the namespace could not be resolved, createModel has already reported an + // actionable diagnostic. Skip generation so we don't send an unnamed code model that + // fails to deserialize in the .NET generator with an opaque root-level error. + // See https://github.com/microsoft/typespec/issues/10914. + if (namespace) { + const configurations: Configuration = createConfiguration(options, namespace, sdkContext); - // Generate C# code via platform-specific implementation. - // In Node.js this runs the .NET generator locally. - // In the browser this sends the code model to a playground server. - await generate(sdkContext, codeModelJson, configJson, { - outputFolder, - packageName: configurations["package-name"] ?? "", - generatorName: options["generator-name"], - newProject: options["new-project"], - debug: options.debug ?? false, - saveInputs: options["save-inputs"] ?? false, - emitterExtensionPath: options["emitter-extension-path"], - }); + // Serialize code model and configuration + const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); + const configJson = JSON.stringify(configurations, null, 2) + "\n"; + + // Generate C# code via platform-specific implementation. + // In Node.js this runs the .NET generator locally. + // In the browser this sends the code model to a playground server. + await generate(sdkContext, codeModelJson, configJson, { + outputFolder, + packageName: configurations["package-name"] ?? "", + generatorName: options["generator-name"], + newProject: options["new-project"], + debug: options.debug ?? false, + saveInputs: options["save-inputs"] ?? false, + emitterExtensionPath: options["emitter-extension-path"], + }); + } } } diff --git a/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts b/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts index c0d45fc6e67..f1165eff1a6 100644 --- a/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts +++ b/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts @@ -6,11 +6,12 @@ import { SdkEnumType, SdkHttpOperation, } from "@azure-tools/typespec-client-generator-core"; -import { createDiagnosticCollector, Diagnostic } from "@typespec/compiler"; +import { createDiagnosticCollector, Diagnostic, NoTarget } from "@typespec/compiler"; import { CSharpEmitterContext } from "../sdk-context.js"; import { CodeModel } from "../type/code-model.js"; import { InputEnumType, InputLiteralType, InputModelType } from "../type/input-type.js"; import { fromSdkClients } from "./client-converter.js"; +import { createDiagnostic } from "./lib.js"; import { fromSdkNamespaces } from "./namespace-converter.js"; import { processServiceAuthentication } from "./service-authentication.js"; import { fromSdkType } from "./type-converter.js"; @@ -97,8 +98,22 @@ export function createModel(sdkContext: CSharpEmitterContext): [CodeModel, reado // Fix naming conflicts for constants, enums, and models fixNamingConflicts(models, constants); + // Resolve the root namespace. When this cannot be determined the generated code model + // has no name, which the .NET generator rejects with an opaque root-level deserialization + // failure. Surface a clear, actionable diagnostic here instead and let the caller skip + // generation. See https://github.com/microsoft/typespec/issues/10914. + const clientNamespace = getClientNamespaceString(sdkContext); + if (clientNamespace === undefined) { + diagnostics.add( + createDiagnostic({ + code: "unresolved-client-namespace", + target: NoTarget, + }), + ); + } + const clientModel: CodeModel = { - name: getClientNamespaceString(sdkContext)!, + name: clientNamespace ?? "", apiVersions: rootApiVersions, enums: enums, constants: constants, diff --git a/packages/http-client-csharp/emitter/src/lib/lib.ts b/packages/http-client-csharp/emitter/src/lib/lib.ts index 1d0fd759545..c93ac07963d 100644 --- a/packages/http-client-csharp/emitter/src/lib/lib.ts +++ b/packages/http-client-csharp/emitter/src/lib/lib.ts @@ -107,6 +107,12 @@ const diags: { [code: string]: DiagnosticDefinition } = { default: paramMessage`Unsupported continuation location for operation ${"crossLanguageDefinitionId"}.`, }, }, + "unresolved-client-namespace": { + severity: "error", + messages: { + default: `Unable to determine a namespace for the generated client. Ensure the spec defines a service namespace (using the \`@service\` decorator) or set the \`package-name\` emitter option.`, + }, + }, }; /** diff --git a/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts b/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts index 5ce773fe969..954a7296b4b 100644 --- a/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts @@ -568,4 +568,28 @@ describe("createModel diagnostic collection", () => { ok(diagnostics !== undefined, "Diagnostics should not be undefined"); ok(Array.isArray(diagnostics), "Diagnostics should be an array"); }); + + it("reports an unresolved-client-namespace diagnostic when no namespace can be resolved", async () => { + const program = await typeSpecCompile( + ` + @route("/test") + op test(): void; + `, + runner, + { IsNamespaceNeeded: false, IsVersionNeeded: false }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [codeModel, diagnostics] = createModel(sdkContext); + + ok( + diagnostics.some( + (d) => d.code === "@typespec/http-client-csharp/unresolved-client-namespace", + ), + "Expected an unresolved-client-namespace diagnostic", + ); + // The code model name is empty so callers can skip generation instead of + // sending an unnamed model that fails to deserialize in the generator. + strictEqual(codeModel.name, "", "CodeModel name should be empty when unresolved"); + }); }); diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 27d413777d3..1eac52a2845 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -197,11 +197,6 @@ void TrackGenerateEvent(GenerateOutcome outcome) var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; telemetryProperties["generatorName"] = generatorName; telemetryProperties["codeModelSizeBytes"] = body.CodeModel.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); - // Hash the received code model so non-deterministic failures can be triaged: identical - // specs must yield an identical hash. A "same" spec that produces a different hash/size - // across requests indicates the code model is being mangled in transport before it reaches us. - telemetryProperties["codeModelSha256"] = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(body.CodeModel))); if (!File.Exists(generatorPath)) { @@ -233,19 +228,6 @@ void TrackGenerateEvent(GenerateOutcome outcome) await File.WriteAllTextAsync(codeModelPath, body.CodeModel); await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); - // Integrity check: confirm the code model on disk matches what we received. A mismatch - // localizes "mangling" to the server's write/encoding path rather than transport or the - // generator, and explains intermittent root-level deserialization failures. - var writtenCodeModel = await File.ReadAllTextAsync(codeModelPath); - if (!string.Equals(writtenCodeModel, body.CodeModel, StringComparison.Ordinal)) - { - telemetryProperties["codeModelWriteMismatch"] = - $"receivedChars={body.CodeModel.Length},writtenChars={writtenCodeModel.Length}"; - telemetryClient?.TrackTrace( - "Code model written to disk does not match the received payload.", - SeverityLevel.Error, - telemetryProperties); - } // Run the .NET generator as a subprocess Console.WriteLine($"Starting generator: dotnet --roll-forward Major {generatorPath} {tempDir} -g {generatorName} --new-project");