Skip to content

feat: add request/response support to server ActionDispatcher#131

Merged
martin-fleck-at merged 9 commits intomainfrom
feature/server-side-requests
Apr 27, 2026
Merged

feat: add request/response support to server ActionDispatcher#131
martin-fleck-at merged 9 commits intomainfrom
feature/server-side-requests

Conversation

@martin-fleck-at
Copy link
Copy Markdown
Contributor

@martin-fleck-at martin-fleck-at commented Apr 9, 2026

What it does

Enable the server to send requests to the client and await responses, and to issue requests handled locally by server-side handlers. Complements the client-side changes in glsp-client.

  • Add request() and requestUntil() to ActionDispatcher
  • Intercept responses in dispatch() and doDispatch() to resolve pending requests
  • Translate RejectAction responses to promise rejections
  • Bypass action queue for nested requests to prevent deadlocks
  • Tighten shouldForwardToClient() to use hasValidResponseId()
  • Add async handleClientRequest() in DefaultGLSPServer.process()
  • Add tests for request/response, deadlocks, timeouts, late responses, and dispose cleanup

Relates to eclipse-glsp/glsp#607

How to test

Follow-ups

Changelog

  • This PR should be mentioned in the changelog
  • This PR introduces a breaking change (if yes, provide more details below for the changelog and the migration guide)

It adds two methods to the server-side action dispatcher. We do have default implementations but if adopters have their own implementation, they will be missing those methods.

Note: Will break until the protocol changes in eclipse-glsp/glsp-client#480 are merged.

Enable the server to send requests to the client and await responses,
and to issue requests handled locally by server-side handlers.
Complements the client-side changes in glsp-client.

- Add `request()` and `requestUntil()` to ActionDispatcher
- Intercept responses in `dispatch()` and `doDispatch()` to resolve
  pending requests
- Translate `RejectAction` responses to promise rejections
- Bypass action queue for nested requests to prevent deadlocks
- Tighten `shouldForwardToClient()` to use `hasValidResponseId()`
- Add async `handleClientRequest()` in `DefaultGLSPServer.process()`
- Add tests for request/response, deadlocks, timeouts, late responses,
  and dispose cleanup

Relates to eclipse-glsp/glsp#607
Copy link
Copy Markdown
Contributor

@tortmayr tortmayr left a comment

Choose a reason for hiding this comment

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

Here are my review comments after a first high level review and quick testing.

One additional issue:

We currently have no actual server side request in the example code i.e. no way to test the feature e2e or manually. We currently solely rely on unit tests.
Maybe it would be a good to exetend the workflow example with handler that actually uses the new request handling. Could be something simlple like retrieving the current editor context from the client and logging it.

Comment thread packages/server/src/common/actions/action-dispatcher.ts
}

// Dont queue actions that are just delegated to the client
if (this.clientActionForwarder.shouldForwardToClient(action)) {
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.

Not directly related to this PR, but maybe an issue that we should track in a ticket:

This queue skipping for client actions could be problematic if the server has also registered an handler for this action kind.

In that case, the server handled actions escapes the sequential order is dispatched directly before any other queued server actions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I adapted the code now that client actions do not skip the queue automatically but instead we have a proper dispatchDirectly to dispatch actions without the queue, e.g., for progress reporting.

Comment thread packages/server/src/common/actions/action-dispatcher.ts
Comment thread packages/server/src/common/protocol/glsp-server.ts Outdated
- Document single-handler assumption and late/extra response
  behavior on request()
- Shorten RejectAction/postUpdateQueue comment
- Add dispatchDirectly() to bypass the action queue for actions
  that need immediate processing (e.g. progress notifications)
- Use dispatchDirectly() for handler responses and nested requests
- Remove client-action queue bypass from dispatch(); all actions
  are now enqueued to preserve sequential ordering
- Extract sendResponseToClient() hook in DefaultGLSPServer
@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

@tortmayr I updated the PR and now we have a more explicit handling of actions:

  • dispatch: Dispatches actions in order, i.e., queues them. That is true for server actions and client actions.
  • dispatchDirectly: Dispatches actions immediately without queue. That is true for handler return actions or if called explicitly (e.g., progress reporting).
  • interceptPendingResponse (internal): Responses to requests do not need to be re-dispatched but simply resolve the request. If there is no matching request, we queue the action.

As for the example, there really is no good place in the workflow example to do something semantically useful. However, with the upcoming MCP feature, we will need it for the tool functions and there the usage will be well-tested. Is that enough?

Copy link
Copy Markdown
Contributor

@tortmayr tortmayr left a comment

Choose a reason for hiding this comment

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

Following our discussion offline, I would like to see the following changes

  • Revert the introduction of dispatchDirectly. Instead use the same approach as
    in the Java Server. (Actions that are dispatched/returned from within an action handler bypass the queue). AsyncLocalStorage can be used for this
  • Replace custom PromiseQueue. Our current custom promise queue is a dated custom implementation that has a couple of issues in particular with error delegation/swalloing and unnecessary overheads.The current PromiseQueue should be replaced with a channel + consumer loop pattern that mirrors the Java architecture directly.
    The pattern could look like this:
class ActionChannel<T> {
    private queue: Array<{
        item: T;
        resolve: (v: void) => void;
        reject: (e: any) => void;
    }> = [];
    private notify: (() => void) | null = null;
    private stopped = false;

    push(item: T): Promise<void> {
        if (this.stopped) {
            return Promise.reject(new Error('Channel is closed'));
        }
        return new Promise((resolve, reject) => {
            this.queue.push({ item, resolve, reject });
            this.notify?.();
        });
    }

    async *consume(): AsyncGenerator<{
        item: T;
        resolve: (v: void) => void;
        reject: (e: any) => void;
    }> {
        while (!this.stopped || this.queue.length > 0) {
            while (this.queue.length > 0) {
                yield this.queue.shift()!;
            }
            if (!this.stopped) {
                await new Promise<void>(r => { this.notify = r; });
            }
        }
    }

    stop(): void {
        this.stopped = true;
        this.notify?.();
    }

    rejectPending(): void {
        for (const entry of this.queue) {
            entry.reject(new Error('Channel cleared'));
        }
        this.queue = [];
    }

    get size(): number { return this.queue.length; }
}

In addition, we should get rid of the second ephemeral queue we use for ordering responses. It is completely unnecessary instead we can just use a for-each await loop.

Comment thread packages/server/src/common/actions/action-dispatcher.ts Outdated
Flow:
- External dispatch(): queued to ActionChannel, processed by consumer loop.
- Reentrant dispatch() (inside a handler): runs inline via AsyncLocalStorage.
- Handler responses / post-update drain: reentrant, dispatched inline in order.
- request() response: intercepted at dispatch() entry to avoid deadlock when a
  handler is awaiting the request.

Delta vs Java:
- AsyncLocalStorage replaces Thread.currentThread() for reentrancy detection.
- ActionChannel + consumer loop replaces BlockingQueue + dedicated thread; no
  backpressure yet.
- interceptPendingResponse() is Node-only; Java has no request/response.
- dispose() rejects queued actions and pending requests instead of draining;
  Disposable.dispose() is synchronous so JoinAction/.join() has no equivalent.
@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

  • Revert the introduction of dispatchDirectly. Instead use the same approach as
    in the Java Server. (Actions that are dispatched/returned from within an action handler bypass the queue). AsyncLocalStorage can be used for this

Done, the action dispatcher is now much more aligned with the Java behavior! I was not aware of AsyncLocalStorage but that is pretty cool!

  • Replace custom PromiseQueue. Our current custom promise queue is a dated custom implementation that has a couple of issues in particular with error delegation/swalloing and unnecessary overheads.The current PromiseQueue should be replaced with a channel + consumer loop pattern that mirrors the Java architecture directly.

I replaced the existing PromiseQueue and marked it deprecated.

Copy link
Copy Markdown
Contributor

@tortmayr tortmayr left a comment

Choose a reason for hiding this comment

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

In general, changes look good to me.

We now have one major issue. async-hooks are a node-specfic and cannot be used in the browser context.
We need to move out the node specifics of the action dispatcher from the common package and also
think about how we implement this for the browser-only side since AsyncLocalStorage is not available there.

Comment thread packages/server/src/common/actions/action-dispatcher.ts Outdated
Comment thread packages/server/src/common/actions/action-dispatcher.ts
Comment thread packages/server/src/common/actions/action-dispatcher.ts Outdated
Comment thread packages/server/src/common/actions/action-dispatcher.ts Outdated
Comment thread packages/server/src/common/actions/action-dispatcher.ts Outdated
Comment thread packages/server/src/common/utils/action-channel.ts Outdated
Comment thread packages/server/src/common/utils/action-channel.ts Outdated
@tortmayr
Copy link
Copy Markdown
Contributor

tortmayr commented Apr 22, 2026

To follow up on the browser issue:

Proposed fix: Inject AsyncLocalStorage via DI instead of importing it directly.

  • Define a DI token in src/common/di/service-identifiers.ts (e.g. AsyncLocalStorageProvider)
  • src/node/ binds the native AsyncLocalStorage from async_hooks
  • src/browser/ binds AsyncLocalStorage from https://www.npmjs.com/package/als-browser — a zero-dependency, drop-in polyfill b
    by Node.js core contributor Stephen Belanger (used transitively by dc-browser at ~245k weekly downloads)

This keeps action-dispatcher.ts in src/common/ with no code duplication.

- Trim dispatch() interface JSDoc; move reentrancy/queue prose to class doc
- Drop unused consumerLoop field; start loop fire-and-forget
- Drop verbose comments on generateRequestId and dispose
- Replace dispatch() order-matters block with per-branch one-liners
- Simplify ActionChannel JSDoc; drop Java reference
- Guard ActionChannel against multiple consumers (throw + JSDoc)
- Introduce ActionDispatchContext interface + DI token in common
- Dispatcher injects the context instance directly (toDynamicValue)
- Node app-module binds native AsyncLocalStorage from async_hooks
- Browser app-module binds AsyncLocalStorage from als-browser polyfill
- Keeps the common package runtime-neutral for browser bundling
@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

@tortmayr I pushed an update that should fix the issues you mentioned! Great idea with the browser-variant of the AsyncLocalStorage!

@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

CI is failing because we need to merge eclipse-glsp/glsp-client#480 first.

Refactor ActionDispatchContext and implementation for the browser side.
The used ALS polyfill is not reliable and does not work with all
browsers, v8 engines.
@tortmayr
Copy link
Copy Markdown
Contributor

@martin-fleck-at Thanks for the update. During review I noticed that we currently have no browser-based workflow example to test if the dispatching changes work in the browser flow.
We should create a follow-up for that.
In addition, we also need a follow-up to apply the request-response changes to the java-server

I created a browser-based workflow example locally for testing, which I can contribute once cleaned up.

During testing I noticed, that unfortunately the used ALS polyfill does not work reliably in all browser/js engines.
In particular it breaks for chromes V8 engine. When used in chrome direct response actions of an handler are treated as out-of-context i.e. they are queued and create a deadloop.

Unfortunately, there are not real ALS alternatives available so we have to use a custom solution.
I pushed a commit with a simple flag based solution for the browser, that gives us more or less the same behavior for the ActionDispatcher as ALS would.
There is one minor corner case where the current solution would work differently (it is noted in the documentation of BrowserDispatchContext. However I think its worth the tradeoff.

We can discuss this in more detail face-to-face.

WDYT?

- Remove unused `als-browser` dependency
- Fix browser scope leaking `active=true` on sync throw
- Add grace-period cleanup for stale request-timeout markers (memory leak)
- Add `.catch()` to fire-and-forget dispatches in `processActionQueue` and `interceptPendingResponse`
- Harden `handleClientRequest` catch: wrap rejection-send in inner try/catch
- Make `dispatchAll` sequential to give reentrant calls deterministic ordering
- Tighten JSDoc on the browser scope; document residual interleaving risk
- Note action mutation in `request()`/`requestUntil()` JSDoc
- Document serial-invocation assumption on `BrowserActionDispatchScope.enter()`
- Reword "Node-only" comment to "no equivalent in the Java GLSP server"
- Rename `ActionDispatchContext` -> `ActionDispatchScope` (interface, symbol, impls)
- Rename `run` -> `enter`, `isInContext` -> `isReentrant`
- Rename fields: `dispatchContext` -> `dispatchScope`, `channel` -> `actionQueue`, `timeouts` -> `requestTimeouts`
- Rename `runConsumerLoop` -> `processActionQueue`
- Rename `executeHandler` param `request` -> `action`
- Switch scope bindings to `to(Class)` form (transient on purpose: per-session isolation)
- Add `BrowserActionDispatchScope` unit tests (sync/async/throw/reject/ClientAction)
@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

martin-fleck-at commented Apr 27, 2026

@tortmayr Overall the code looked good to me. I still removed the als-browser dependency and cleaned up some other things, especially in naming things. Do you want to have another look?

Copy link
Copy Markdown
Contributor

@tortmayr tortmayr left a comment

Choose a reason for hiding this comment

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

Thanks. I have a few minor inline comments, but other than that LGTM.

protected dispatchScope: ActionDispatchScope;

protected channel = new ActionChannel<Action>();
protected actionQueue = new ActionChannel<Action>();
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.

What about the class name itself should we also rename this to ActionQueue?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, you are right, let's do that! good catch!!

}
// Node-only: responses to server-initiated requests are resolved here instead of going
// through action handlers. No Java equivalent.
// Responses to server-initiated requests are resolved here instead of going through
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.

Is the Java-related statement even true? We don't know yet right since the java-implementation is a follow-up.
We should probably remove it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, we do plan to tackle that shortly, so I'll remove it.

return new ContainerModule((bind, unbind, isBound, rebind) => {
bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container);
bind(ActionDispatchContext).toDynamicValue(() => new NodeDispatchContext());
// Transient on purpose: kept symmetric with the browser binding, which cannot share a
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.

Is symmetry here really important?
If we can use one singleton implementation on the node side thanks to ALS why shouldn't we do it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm fine either way, it does not really make a difference and I thought for re-binding it might be simpler but I'll change it.

- Rename `ActionChannel` -> `ActionQueue` (class, file, all refs)
- Drop redundant comment + Java reference in `interceptPendingResponse`
- Use `inSingletonScope` for the Node `ActionDispatchScope` binding
@martin-fleck-at
Copy link
Copy Markdown
Contributor Author

@tortmayr I think all points should be addressed in the new commit.

Copy link
Copy Markdown
Contributor

@tortmayr tortmayr left a comment

Choose a reason for hiding this comment

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

LGTM! 👍🏼

@martin-fleck-at martin-fleck-at merged commit 1492d67 into main Apr 27, 2026
5 checks passed
@martin-fleck-at martin-fleck-at deleted the feature/server-side-requests branch April 27, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants