Skip to content

Commit d950601

Browse files
stephentoubCopilot
andauthored
Defer sessionId to server for cloud sessions (#1479)
When a caller creates a session with `cloud` set and does not supply a `sessionId`, the SDK now omits `sessionId` from the `session.create` request and lets the CLI/server assign one. The returned id is captured synchronously when the response is parsed (via an inline response callback) so that any subsequent `session.event` notification routes to the registered session without race. For all other cases (no `cloud`, or caller supplied `sessionId`), the SDK continues to generate a UUID client-side (when none is supplied) and pre-register the session BEFORE issuing the RPC. Pre-registration is required because the CLI may issue session-scoped requests (e.g. `sessionFs.writeFile` for workspace metadata) during `session.create` processing, before it has sent the response — without pre-registration those requests would have no handler. A caller-supplied `sessionId` is always passed to the server so the server can validate it; mismatched returned ids fail the call with a clear error. In addition, the .NET implementation extracts the session-init local function from CreateSessionAsync into a private InitializeSession helper that is now shared by both CreateSessionAsync and ResumeSessionAsync, eliminating duplicated handler-wiring code. Per-SDK summary: .NET (dotnet/src/Client.cs): New private InitializeSession helper shared by CreateSessionAsync and ResumeSessionAsync. CreateSessionAsync branches on (Cloud != null && SessionId == null): the cloud/no-id path registers the session lazily from an `onResponseInline` callback; all other paths pre-register before the RPC. Validates that the server- returned id matches a caller-supplied id. Python (python/copilot/client.py): create_session uses the same conditional. Cloud+no-id installs an `on_response_inline` callback that registers the session as soon as the response is parsed. Otherwise the session is pre-registered via uuid.uuid4() (or the caller-supplied id). Go (go/client.go): createSession uses the same conditional. Cloud+no-id installs an inline callback that registers from the read loop the instant the response arrives. Otherwise the session is pre-registered via uuid.NewString() (or the caller-supplied id). Rust (rust/src/session.rs): create_session uses the same conditional. Cloud+no-id installs an InlineResponseCallback that validates the returned id and registers in the router. Otherwise the session is pre-registered via uuid::Uuid::new_v4() (or the caller-supplied id). SessionConfig::into_wire now takes the local session id (or None when the server is generating one). Java (java/src/main/java/com/github/copilot/CopilotClient.java): createSession uses the same conditional. Cloud+no-id defers registration to the .thenCompose continuation (race-free because CompletableFuture sync continuations run on the JSON-RPC reader thread). Otherwise the session is pre-registered via UUID.randomUUID() (or the caller-supplied id) before the RPC. Node.js (nodejs/src/client.ts): createSession uses the same conditional. Cloud+no-id lazy-initializes after the response (race-free because vscode-jsonrpc dispatches each message via setImmediate, so microtasks flush before the next message is read). Otherwise the session is pre-registered via randomUUID() (or the caller-supplied id). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1121577 commit d950601

21 files changed

Lines changed: 1081 additions & 499 deletions

File tree

dotnet/src/Client.cs

Lines changed: 171 additions & 115 deletions
Large diffs are not rendered by default.

dotnet/src/JsonRpc.cs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,22 @@ public void StartListening()
7373
/// <summary>
7474
/// Sends a JSON-RPC request and waits for the response.
7575
/// </summary>
76-
public async Task<T> InvokeAsync<T>(string method, object?[]? args, CancellationToken cancellationToken)
76+
/// <param name="method">The JSON-RPC method name.</param>
77+
/// <param name="args">Positional arguments for the call.</param>
78+
/// <param name="cancellationToken">Cancellation token.</param>
79+
/// <param name="onResponseInline">
80+
/// Optional callback invoked synchronously from the read loop after the
81+
/// response is parsed but before the awaiter resumes. Use this when you
82+
/// need to mutate client-side state (for example, register a server-assigned
83+
/// session id) before any subsequent notification on the same connection is
84+
/// dispatched. The callback receives the raw JSON-RPC <c>result</c> element.
85+
/// If the callback throws, the exception is propagated to the awaiter.
86+
/// </param>
87+
public async Task<T> InvokeAsync<T>(string method, object?[]? args, CancellationToken cancellationToken, Action<JsonElement>? onResponseInline = null)
7788
{
7889
var timingTimestamp = Stopwatch.GetTimestamp();
7990
var id = Interlocked.Increment(ref _nextId);
80-
var pending = new PendingRequest();
91+
var pending = new PendingRequest(onResponseInline);
8192
_pendingRequests[id] = pending;
8293

8394
CancellationTokenRegistration cancelRegistration = default;
@@ -166,7 +177,7 @@ private void LogInvokeTiming(
166177
/// </summary>
167178
public void SetLocalRpcMethod(string methodName, Delegate handler, bool singleObjectParam = false)
168179
{
169-
_methods[methodName] = new MethodRegistration(handler, singleObjectParam);
180+
_methods[methodName] = new(handler, singleObjectParam);
170181
}
171182

172183
/// <inheritdoc />
@@ -447,7 +458,24 @@ private void HandleResponse(JsonElement message, JsonElement idProp)
447458
}
448459
else if (message.TryGetProperty("result", out var resultProp))
449460
{
450-
pending.TrySetResult(resultProp.Clone());
461+
var cloned = resultProp.Clone();
462+
if (pending.OnResultInline is { } inline)
463+
{
464+
// Run the inline callback synchronously in the read loop so any
465+
// state it mutates (e.g. session registration) is visible before
466+
// the read loop dispatches the next message.
467+
try
468+
{
469+
inline(cloned);
470+
}
471+
catch (Exception ex)
472+
{
473+
_logger.LogWarning(ex, "Inline response callback for request {RequestId} threw", id);
474+
pending.TrySetException(ex);
475+
return;
476+
}
477+
}
478+
pending.TrySetResult(cloned);
451479
}
452480
else
453481
{
@@ -765,7 +793,17 @@ await SendMessageAsync(new JsonRpcNotification
765793
}
766794
}
767795

768-
private sealed class PendingRequest() : TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
796+
private sealed class PendingRequest(Action<JsonElement>? onResultInline = null) : TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously)
797+
{
798+
/// <summary>
799+
/// Optional callback invoked synchronously from the read loop after the
800+
/// response is parsed but before the awaiter resumes. Used to perform
801+
/// state changes that must happen before any subsequent notification on
802+
/// the same connection is dispatched (e.g. registering a session whose
803+
/// id was assigned by the server in the response).
804+
/// </summary>
805+
public Action<JsonElement>? OnResultInline { get; } = onResultInline;
806+
}
769807

770808
private static readonly MethodInfo s_taskGetResult = typeof(Task<>).GetProperty(nameof(Task<int>.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!;
771809
private static readonly MethodInfo s_valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask<int>.AsTask), BindingFlags.Instance | BindingFlags.Public)!;

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,19 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
307307

308308
private Dictionary<string, object?> CreateSessionResult(JsonElement request)
309309
{
310-
_lastSessionId = request
311-
.GetProperty("params")
312-
.GetProperty("sessionId")
313-
.GetString();
310+
string? sessionId = null;
311+
if (request.TryGetProperty("params", out var paramsProp)
312+
&& paramsProp.ValueKind == JsonValueKind.Object
313+
&& paramsProp.TryGetProperty("sessionId", out var sidProp)
314+
&& sidProp.ValueKind == JsonValueKind.String)
315+
{
316+
sessionId = sidProp.GetString();
317+
}
318+
if (string.IsNullOrEmpty(sessionId))
319+
{
320+
sessionId = Guid.NewGuid().ToString();
321+
}
322+
_lastSessionId = sessionId;
314323

315324
return new Dictionary<string, object?>
316325
{

dotnet/test/Unit/JsonRpcTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ public async Task<T> InvokeAsync<T>(string methodName, object?[]? args, Cancella
190190
.GetMethod("InvokeAsync")!
191191
.MakeGenericMethod(typeof(T));
192192

193-
var task = (Task<T>)method.Invoke(_instance, [methodName, args, cancellationToken])!;
193+
// Pass null for the optional onResponseInline parameter.
194+
var task = (Task<T>)method.Invoke(_instance, [methodName, args, cancellationToken, null])!;
194195
return await task.ConfigureAwait(false);
195196
}
196197

go/client.go

Lines changed: 137 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -689,83 +689,161 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
689689
req.Traceparent = traceparent
690690
req.Tracestate = tracestate
691691

692-
sessionID := config.SessionID
693-
if sessionID == "" {
694-
sessionID = uuid.New().String()
695-
}
696-
req.SessionID = sessionID
692+
// For cloud sessions, let the CLI/server assign the session id and
693+
// register the session lazily once the response arrives. For non-cloud
694+
// sessions we generate the id client-side (when the caller didn't
695+
// supply one) so the session can be registered BEFORE the RPC — the
696+
// CLI may issue session-scoped requests (e.g. sessionFs.writeFile for
697+
// workspace metadata) during session.create processing, before it has
698+
// sent the response.
699+
useServerGeneratedID := config.Cloud != nil && config.SessionID == ""
700+
var localSessionID string
701+
if useServerGeneratedID {
702+
localSessionID = ""
703+
} else if config.SessionID != "" {
704+
localSessionID = config.SessionID
705+
} else {
706+
localSessionID = uuid.NewString()
707+
}
708+
req.SessionID = localSessionID
709+
710+
// initializeSession creates the session, wires up handlers, and registers
711+
// it in the sessions map. Invoked from the read loop the instant the
712+
// session.create response arrives (synchronously, before the next
713+
// message is dispatched) so notifications for the new session id are
714+
// routed to a registered session.
715+
initializeSession := func(sessionID string) (*Session, error) {
716+
s := newSession(sessionID, c.client, "")
717+
718+
s.registerTools(config.Tools)
719+
s.registerPermissionHandler(config.OnPermissionRequest)
720+
if config.OnUserInputRequest != nil {
721+
s.registerUserInputHandler(config.OnUserInputRequest)
722+
}
723+
if config.Hooks != nil {
724+
s.registerHooks(config.Hooks)
725+
}
726+
if transformCallbacks != nil {
727+
s.registerTransformCallbacks(transformCallbacks)
728+
}
729+
if config.OnEvent != nil {
730+
s.On(config.OnEvent)
731+
}
732+
if len(config.Commands) > 0 {
733+
s.registerCommands(config.Commands)
734+
}
735+
if config.OnElicitationRequest != nil {
736+
s.registerElicitationHandler(config.OnElicitationRequest)
737+
}
738+
if config.OnExitPlanModeRequest != nil {
739+
s.registerExitPlanModeHandler(config.OnExitPlanModeRequest)
740+
}
741+
if config.OnAutoModeSwitchRequest != nil {
742+
s.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest)
743+
}
744+
if config.CanvasHandler != nil {
745+
s.registerCanvasHandler(config.CanvasHandler)
746+
}
697747

698-
// Create and register the session before issuing the RPC so that
699-
// events emitted by the CLI (e.g. session.start) are not dropped.
700-
session := newSession(sessionID, c.client, "")
748+
c.sessionsMux.Lock()
749+
c.sessions[sessionID] = s
750+
c.sessionsMux.Unlock()
701751

702-
session.registerTools(config.Tools)
703-
session.registerPermissionHandler(config.OnPermissionRequest)
704-
if config.OnUserInputRequest != nil {
705-
session.registerUserInputHandler(config.OnUserInputRequest)
706-
}
707-
if config.Hooks != nil {
708-
session.registerHooks(config.Hooks)
709-
}
710-
if transformCallbacks != nil {
711-
session.registerTransformCallbacks(transformCallbacks)
712-
}
713-
if config.OnEvent != nil {
714-
session.On(config.OnEvent)
715-
}
716-
if len(config.Commands) > 0 {
717-
session.registerCommands(config.Commands)
718-
}
719-
if config.OnElicitationRequest != nil {
720-
session.registerElicitationHandler(config.OnElicitationRequest)
721-
}
722-
if config.OnExitPlanModeRequest != nil {
723-
session.registerExitPlanModeHandler(config.OnExitPlanModeRequest)
724-
}
725-
if config.OnAutoModeSwitchRequest != nil {
726-
session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest)
727-
}
728-
if config.CanvasHandler != nil {
729-
session.registerCanvasHandler(config.CanvasHandler)
752+
if c.options.SessionFs != nil {
753+
if config.CreateSessionFsProvider == nil {
754+
c.sessionsMux.Lock()
755+
delete(c.sessions, sessionID)
756+
c.sessionsMux.Unlock()
757+
return nil, fmt.Errorf("CreateSessionFsProvider is required in session config when SessionFs is enabled in client options")
758+
}
759+
provider := config.CreateSessionFsProvider(s)
760+
if c.options.SessionFs.Capabilities != nil && c.options.SessionFs.Capabilities.Sqlite {
761+
if _, ok := provider.(SessionFsSqliteProvider); !ok {
762+
c.sessionsMux.Lock()
763+
delete(c.sessions, sessionID)
764+
c.sessionsMux.Unlock()
765+
return nil, fmt.Errorf("SessionFs capabilities declare SQLite support but the provider does not implement SessionFsSqliteProvider")
766+
}
767+
}
768+
s.clientSessionApis.SessionFs = newSessionFsAdapter(provider)
769+
}
770+
return s, nil
730771
}
731772

732-
c.sessionsMux.Lock()
733-
c.sessions[sessionID] = session
734-
c.sessionsMux.Unlock()
773+
var session *Session
774+
var registeredSessionID string
735775

736-
if c.options.SessionFs != nil {
737-
if config.CreateSessionFsProvider == nil {
738-
c.sessionsMux.Lock()
739-
delete(c.sessions, sessionID)
740-
c.sessionsMux.Unlock()
741-
return nil, fmt.Errorf("CreateSessionFsProvider is required in session config when SessionFs is enabled in client options")
776+
// Pre-register non-cloud sessions BEFORE issuing the RPC so any
777+
// session-scoped requests the CLI emits during session.create processing
778+
// (e.g. sessionFs.writeFile for workspace metadata) can be routed to the
779+
// correct handlers.
780+
if localSessionID != "" {
781+
s, err := initializeSession(localSessionID)
782+
if err != nil {
783+
return nil, err
742784
}
743-
provider := config.CreateSessionFsProvider(session)
744-
if c.options.SessionFs.Capabilities != nil && c.options.SessionFs.Capabilities.Sqlite {
745-
if _, ok := provider.(SessionFsSqliteProvider); !ok {
746-
c.sessionsMux.Lock()
747-
delete(c.sessions, sessionID)
748-
c.sessionsMux.Unlock()
749-
return nil, fmt.Errorf("SessionFs capabilities declare SQLite support but the provider does not implement SessionFsSqliteProvider")
785+
session = s
786+
registeredSessionID = localSessionID
787+
}
788+
789+
// For the server-assigned (cloud) path, register the session
790+
// synchronously from the read loop the instant the response arrives,
791+
// before the read loop dispatches the next message. Without this hook
792+
// the awaiter goroutine may not run until after the read loop has
793+
// dispatched the first session.event notification, which would be
794+
// silently dropped because the session id isn't yet in the lookup
795+
// table. Non-cloud sessions are already registered above.
796+
var inlineCb func(raw json.RawMessage) error
797+
if session == nil {
798+
inlineCb = func(raw json.RawMessage) error {
799+
var early struct {
800+
SessionID string `json:"sessionId"`
801+
}
802+
if err := json.Unmarshal(raw, &early); err != nil {
803+
return fmt.Errorf("failed to parse sessionId from response: %w", err)
804+
}
805+
if early.SessionID == "" {
806+
return fmt.Errorf("session.create response did not include a sessionId")
750807
}
808+
s, err := initializeSession(early.SessionID)
809+
if err != nil {
810+
return err
811+
}
812+
session = s
813+
registeredSessionID = early.SessionID
814+
return nil
751815
}
752-
session.clientSessionApis.SessionFs = newSessionFsAdapter(provider)
753816
}
754817

755-
result, err := c.client.Request("session.create", req)
818+
result, err := c.client.RequestWithInlineResponse("session.create", req, inlineCb)
756819
if err != nil {
757-
c.sessionsMux.Lock()
758-
delete(c.sessions, sessionID)
759-
c.sessionsMux.Unlock()
820+
if registeredSessionID != "" {
821+
c.sessionsMux.Lock()
822+
delete(c.sessions, registeredSessionID)
823+
c.sessionsMux.Unlock()
824+
}
760825
return nil, fmt.Errorf("failed to create session: %w", err)
761826
}
762827

763828
var response createSessionResponse
764829
if err := json.Unmarshal(result, &response); err != nil {
830+
if registeredSessionID != "" {
831+
c.sessionsMux.Lock()
832+
delete(c.sessions, registeredSessionID)
833+
c.sessionsMux.Unlock()
834+
}
835+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
836+
}
837+
838+
if session == nil {
839+
return nil, fmt.Errorf("session.create response did not include a sessionId")
840+
}
841+
842+
if localSessionID != "" && response.SessionID != "" && response.SessionID != localSessionID {
765843
c.sessionsMux.Lock()
766-
delete(c.sessions, sessionID)
844+
delete(c.sessions, registeredSessionID)
767845
c.sessionsMux.Unlock()
768-
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
846+
return nil, fmt.Errorf("session.create returned sessionId %s but the caller requested %s", response.SessionID, localSessionID)
769847
}
770848

771849
session.workspacePath = response.WorkspacePath

0 commit comments

Comments
 (0)