From c0387fdc1c17b6deb1d7d07fcbf76d02700eddd9 Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Tue, 27 Oct 2026 12:45:10 +0300 Subject: [PATCH] Give resident adapters the host's ServerlessOptions A classic adapter gets the host's ServerlessOptions on argv[1] and reads it through Runner.ServerlessOptions, e.g. its cloud-files settings to fetch an attachment from the host's storage. A resident process never got it, so Runner.ServerlessOptions stayed null and such an adapter threw the first time it read it (Traxis's DHL label adapter reads it on every call). Ready now carries it as JSON (serverless_options, field 4), over the socket as the startup values already are, so it stays off the command line. ResidentAdapterHost takes the registered ServerlessOptions (optional; none sends nothing), and the SDK sets Runner.ServerlessOptions from Ready, falling back to defaults when the host sent none, as the classic runner does when argv[1] can't be read. An adapter built before this ignores the field; a host built before sends none. Test: the Ticker sample, resident, reads the host's AdapterRemotePath through Runner.ServerlessOptions. Resident tests 16/16 and compatibility tests 29/29 pass. The full unit suite has the same 7 classic/describe failures on unmodified main on this machine; they pass alone. --- SW.Serverless.Contract/Protos/adapter.proto | 6 ++++++ SW.Serverless.Samples.Ticker/Handler.cs | 7 +++++++ SW.Serverless.Sdk/Resident/ResidentRunner.cs | 1 + SW.Serverless.Sdk/Runner.cs | 15 +++++++++++++++ SW.Serverless.UnitTests/ResidentAdapterTests.cs | 17 +++++++++++++++++ SW.Serverless/Resident/ResidentAdapterHost.cs | 8 +++++++- .../Resident/ResidentAdapterInstance.cs | 6 +++++- docs/protocol.md | 1 + 8 files changed, 59 insertions(+), 2 deletions(-) diff --git a/SW.Serverless.Contract/Protos/adapter.proto b/SW.Serverless.Contract/Protos/adapter.proto index 3d2b7d9..a8ad86d 100644 --- a/SW.Serverless.Contract/Protos/adapter.proto +++ b/SW.Serverless.Contract/Protos/adapter.proto @@ -37,6 +37,12 @@ message Ready { int32 max_in_flight = 1; map startup_values = 2; map adapter_values = 3; + + // The host's ServerlessOptions as JSON: what a classic adapter receives on argv[1] and reads + // through Runner.ServerlessOptions (its cloud-files settings, to read an attachment from the + // host's storage). Empty from a host built before this field, and the SDK then falls back to + // defaults, as the classic runner does when argv[1] can't be read. + string serverless_options = 4; } message Invoke { diff --git a/SW.Serverless.Samples.Ticker/Handler.cs b/SW.Serverless.Samples.Ticker/Handler.cs index b656f87..3ea43c5 100644 --- a/SW.Serverless.Samples.Ticker/Handler.cs +++ b/SW.Serverless.Samples.Ticker/Handler.cs @@ -227,6 +227,13 @@ public Task ReadThroughRunner(string name) => Task.FromResult(ne value = SW.Serverless.Sdk.Runner.StartupValueOf(name) }); + /// What a classic adapter reads from argv[1], read the same way once resident. + public Task ReadServerlessOptions() => Task.FromResult(new + { + remotePath = SW.Serverless.Sdk.Runner.ServerlessOptions?.AdapterRemotePath, + bucket = SW.Serverless.Sdk.Runner.ServerlessOptions?.CloudFilesOptions?.BucketName + }); + /// A result too big to send in one frame. public Task Huge(int megabytes) => Task.FromResult(new string('x', megabytes * 1024 * 1024)); diff --git a/SW.Serverless.Sdk/Resident/ResidentRunner.cs b/SW.Serverless.Sdk/Resident/ResidentRunner.cs index 1f61f78..e69c355 100644 --- a/SW.Serverless.Sdk/Resident/ResidentRunner.cs +++ b/SW.Serverless.Sdk/Resident/ResidentRunner.cs @@ -360,6 +360,7 @@ Task OnReadyAsync(Ready ready) adapterValues = new Dictionary(ready.AdapterValues, StringComparer.OrdinalIgnoreCase); inFlight = new SemaphoreSlim(Math.Max(1, ready.MaxInFlight)); Runner.UseResident(startupValues, adapterValues, ValueOf); + Runner.UseResidentOptions(ready.ServerlessOptions); // Now, and only now, is it safe to construct the handler. handler = handlerFactory(this); diff --git a/SW.Serverless.Sdk/Runner.cs b/SW.Serverless.Sdk/Runner.cs index 31031e0..1953975 100644 --- a/SW.Serverless.Sdk/Runner.cs +++ b/SW.Serverless.Sdk/Runner.cs @@ -34,6 +34,21 @@ internal static void UseResident(IReadOnlyDictionary values, residentValueOf = valueOf; } + /// + /// The host's ServerlessOptions from Ready, so isn't null in a + /// resident adapter. Defaults when the host sent none, as the classic runner falls back. + /// + internal static void UseResidentOptions(string serverlessOptionsJson) + { + ServerlessOptions = null; + if (!string.IsNullOrEmpty(serverlessOptionsJson)) + { + try { ServerlessOptions = JsonConvert.DeserializeObject(serverlessOptionsJson); } + catch (Exception exception) { AdapterLogger.LogWarning(exception, "Failed to parse ServerlessOptions, using defaults instead."); } + } + ServerlessOptions ??= new ServerlessOptions(); + } + /// The default declared with , if any. internal static string ExpectedDefault(string name) => expectedStartupValues.TryGetValue(name, out var expected) ? expected.Default : null; diff --git a/SW.Serverless.UnitTests/ResidentAdapterTests.cs b/SW.Serverless.UnitTests/ResidentAdapterTests.cs index beac278..ae89f7f 100644 --- a/SW.Serverless.UnitTests/ResidentAdapterTests.cs +++ b/SW.Serverless.UnitTests/ResidentAdapterTests.cs @@ -318,6 +318,23 @@ public async Task Per_invocation_properties_reach_the_command() await adapters.StopAsync(TickerId, "percall", drain: false); } + /// + /// A classic adapter reads the host's ServerlessOptions (its cloud-files settings, say, to + /// fetch an attachment) from argv[1]. Resident, the same Runner.ServerlessOptions comes from + /// Ready; before, it stayed null and such an adapter threw the first time it read it. + /// + [TestMethod] + public async Task The_hosts_serverless_options_reach_the_runner() + { + var instance = await StartTicker("options"); + + var options = await instance.InvokeAsync>("ReadServerlessOptions"); + + Assert.AreEqual("adapters", options["remotePath"]); + + await adapters.StopAsync(TickerId, "options", drain: false); + } + /// /// A per-call value wins over the process default, and an adapter called WITHOUT properties /// still sees the startup value — which is what keeps every existing adapter working. diff --git a/SW.Serverless/Resident/ResidentAdapterHost.cs b/SW.Serverless/Resident/ResidentAdapterHost.cs index 3f8b3b7..f283a52 100644 --- a/SW.Serverless/Resident/ResidentAdapterHost.cs +++ b/SW.Serverless/Resident/ResidentAdapterHost.cs @@ -15,6 +15,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using SW.PrimitiveTypes; namespace SW.Serverless.Resident { @@ -43,10 +44,14 @@ public class ResidentAdapterHost : IResidentAdapterHost, IHostedService, IAsyncD readonly IResidentAdapterLocator locator; + // What classic adapters get on argv[1]; resident ones get it in Ready. + readonly string serverlessOptionsJson; + public ResidentAdapterHost(ResidentOptions options, IAdapterEventSink sink, IAdapterStateStore stateStore, IResidentAdapterLocator locator, ILoggerFactory loggerFactory, - Runtimes.AdapterRuntimes runtimes = null) + Runtimes.AdapterRuntimes runtimes = null, ServerlessOptions serverlessOptions = null) { + serverlessOptionsJson = serverlessOptions == null ? null : Newtonsoft.Json.JsonConvert.SerializeObject(serverlessOptions); this.options = options; this.sink = sink; this.stateStore = stateStore; @@ -211,6 +216,7 @@ async Task SpawnAsync(Supervised supervised, CancellationToken cancellationToken spec.StartupValues ?? new Dictionary()), AdapterValues = new Dictionary( spec.AdapterValues ?? new Dictionary()), + ServerlessOptionsJson = serverlessOptionsJson, RestartCount = supervised.RestartCount }; diff --git a/SW.Serverless/Resident/ResidentAdapterInstance.cs b/SW.Serverless/Resident/ResidentAdapterInstance.cs index e30725d..1004bb0 100644 --- a/SW.Serverless/Resident/ResidentAdapterInstance.cs +++ b/SW.Serverless/Resident/ResidentAdapterInstance.cs @@ -127,6 +127,9 @@ internal ResidentAdapterInstance(string adapterId, string instanceKey, string to public int ProtocolVersion { get; internal set; } public IReadOnlyDictionary AdapterValues { get; internal set; } + /// The host's ServerlessOptions as JSON, sent in Ready; null sends none. + internal string ServerlessOptionsJson { get; set; } + public IReadOnlyCollection Diagnostics => diagnostics.ToArray(); /// @@ -211,7 +214,8 @@ internal async Task RunStreamAsync(IAsyncStreamReader input, { MaxInFlight = options.MaxInFlight, StartupValues = { WithoutNulls(StartupValues) }, - AdapterValues = { WithoutNulls(AdapterValues) } + AdapterValues = { WithoutNulls(AdapterValues) }, + ServerlessOptions = ServerlessOptionsJson ?? string.Empty } }); diff --git a/docs/protocol.md b/docs/protocol.md index 4469b20..f23151d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -131,6 +131,7 @@ The host answers with `Ready`: | `max_in_flight` | How many events the adapter may have waiting for an EventAck at once. | | `startup_values` | The settings, name to value. Includes `CorrelationId` for classic sessions. | | `adapter_values` | Extra values from the package's storage metadata and the host. | +| `serverless_options` | The host's `ServerlessOptions` as JSON, what a classic adapter gets on argv[1] (its cloud-files settings, for one). The .NET SDK exposes it as `Runner.ServerlessOptions`. Empty from older hosts; the SDK then uses defaults. | After Ready the host considers the instance ready and may send Invoke at once. An SDK should build the adapter and run its start hook on Ready, before running commands, and must keep reading frames