@monosmith/plugin-sdk 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/codec/index.d.ts +1 -1
- package/codec/index.js +1 -1
- package/codec/method-codec.algorithm.js +4 -4
- package/codec/wire-message.mapper.d.ts +3 -1
- package/codec/wire-message.mapper.js +5 -1
- package/concurrency/concurrency.policy.d.ts +21 -0
- package/concurrency/concurrency.policy.js +30 -0
- package/concurrency/index.d.ts +1 -0
- package/concurrency/index.js +1 -0
- package/conformance/check-plugin.use-case.d.ts +2 -1
- package/conformance/check-plugin.use-case.js +4 -1
- package/conformance/check-scheduling.use-case.d.ts +11 -0
- package/conformance/check-scheduling.use-case.js +98 -0
- package/host/call-interrupted.error.d.ts +16 -0
- package/host/call-interrupted.error.js +20 -0
- package/host/host-options.contract.d.ts +6 -0
- package/host/host-options.contract.js +2 -0
- package/host/index.d.ts +3 -1
- package/host/index.js +2 -1
- package/host/plugin-client.client.d.ts +26 -7
- package/host/plugin-client.client.js +180 -49
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/protocol/protocol-schema.contract.js +76 -17
- package/protocol/protocol-types.contract.d.ts +52 -0
- package/protocol/protocol-types.contract.js +7 -0
- package/protocol/schema-document.contract.d.ts +4 -1
- package/serve/capabilities-of.algorithm.d.ts +2 -1
- package/serve/capabilities-of.algorithm.js +4 -1
- package/serve/dispatch-request.use-case.d.ts +3 -2
- package/serve/dispatch-request.use-case.js +4 -3
- package/serve/index.d.ts +1 -1
- package/serve/plugin.contract.d.ts +31 -16
- package/serve/request-pool.store.d.ts +49 -0
- package/serve/request-pool.store.js +110 -0
- package/serve/serve.use-case.d.ts +8 -4
- package/serve/serve.use-case.js +109 -22
|
@@ -29,6 +29,13 @@ export declare const ERROR_CODES: {
|
|
|
29
29
|
* method of the Go SDK returns becomes this code.
|
|
30
30
|
*/
|
|
31
31
|
readonly PluginFailure: -32000;
|
|
32
|
+
/**
|
|
33
|
+
* The plugin stopped the request because the host sent a cancel notification for its id. A plugin
|
|
34
|
+
* that had finished answers with the result instead. Only a plugin that advertised the cancel
|
|
35
|
+
* capability sends it; when a cancelled request fails with any error, the SDKs answer with this
|
|
36
|
+
* code.
|
|
37
|
+
*/
|
|
38
|
+
readonly RequestCancelled: -32800;
|
|
32
39
|
};
|
|
33
40
|
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
|
34
41
|
/**
|
|
@@ -117,6 +124,14 @@ export interface CIResult {
|
|
|
117
124
|
*/
|
|
118
125
|
authenticate?: Toolchain[];
|
|
119
126
|
}
|
|
127
|
+
/** CancelParams are the params of the cancel notification. */
|
|
128
|
+
export interface CancelParams {
|
|
129
|
+
/**
|
|
130
|
+
* ID is the id of the in-flight request to stop. An id the plugin does not know, or whose request
|
|
131
|
+
* is already answered, is ignored.
|
|
132
|
+
*/
|
|
133
|
+
id: number;
|
|
134
|
+
}
|
|
120
135
|
/** Capabilities are the optional methods a plugin implements. */
|
|
121
136
|
export interface Capabilities {
|
|
122
137
|
discover?: boolean;
|
|
@@ -137,6 +152,12 @@ export interface Capabilities {
|
|
|
137
152
|
declarations?: boolean;
|
|
138
153
|
/** Latest reports the newest releases of dependencies and pinned tools. */
|
|
139
154
|
latest?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Cancel says the plugin stops the work of a request when the host sends a cancel notification
|
|
157
|
+
* with its id, then answers that request with its result (if the work was already done) or the
|
|
158
|
+
* request-cancelled error. A host never sends the notification to a plugin without it.
|
|
159
|
+
*/
|
|
160
|
+
cancel?: boolean;
|
|
140
161
|
}
|
|
141
162
|
/**
|
|
142
163
|
* ContributeParams hands a plugin the projects it applies to: those whose stack includes it, or,
|
|
@@ -368,6 +389,13 @@ export interface GenerateResult {
|
|
|
368
389
|
files: string[];
|
|
369
390
|
/** Notes are printed to the user after generation. */
|
|
370
391
|
notes?: string[];
|
|
392
|
+
/**
|
|
393
|
+
* InstallPending is true when the plugin changed what the workspace depends on and left
|
|
394
|
+
* installing it to the host. A project is made by several plugins in turn; the host asks each
|
|
395
|
+
* plugin that reported this to install once (dependencies/install with Quiet set) after the last
|
|
396
|
+
* of them has generated, instead of once per plugin.
|
|
397
|
+
*/
|
|
398
|
+
installPending?: boolean;
|
|
371
399
|
}
|
|
372
400
|
/** InitializeParams is sent once, first, by the host. */
|
|
373
401
|
export interface InitializeParams {
|
|
@@ -384,6 +412,14 @@ export interface InitializeResult {
|
|
|
384
412
|
plugin: Peer;
|
|
385
413
|
kind: PluginKind;
|
|
386
414
|
capabilities: Capabilities;
|
|
415
|
+
/**
|
|
416
|
+
* Concurrency is how many requests the plugin can work on at the same time; the host never has
|
|
417
|
+
* more in flight. Absent or 0 means 1, which is what every plugin that says nothing does: the
|
|
418
|
+
* host sends the next request only after the previous one is answered. N above 1 allows that
|
|
419
|
+
* many, and ConcurrencyUnlimited (-1) leaves the number to the host. Any other negative value is
|
|
420
|
+
* invalid and read as 1.
|
|
421
|
+
*/
|
|
422
|
+
concurrency?: number;
|
|
387
423
|
/** Languages are the values a user passes to --lang to reach this plugin. */
|
|
388
424
|
languages?: string[];
|
|
389
425
|
/**
|
|
@@ -440,6 +476,11 @@ export interface InstallParams {
|
|
|
440
476
|
* without one says so in a note.
|
|
441
477
|
*/
|
|
442
478
|
dev?: boolean;
|
|
479
|
+
/**
|
|
480
|
+
* Quiet keeps the package manager's output unless it fails: the host sets it when it installs as
|
|
481
|
+
* one step of a larger job (smith add).
|
|
482
|
+
*/
|
|
483
|
+
quiet?: boolean;
|
|
443
484
|
}
|
|
444
485
|
/**
|
|
445
486
|
* LatestParams asks for the newest stable release of each dependency, and, with Tools, how the
|
|
@@ -913,6 +954,17 @@ export interface ProtocolMethods {
|
|
|
913
954
|
result: Empty;
|
|
914
955
|
capability: null;
|
|
915
956
|
};
|
|
957
|
+
/**
|
|
958
|
+
* A notification, so it gets no response: the host asks the plugin to stop the in-flight request
|
|
959
|
+
* with the given id. The plugin then answers that request, with its result if the work was
|
|
960
|
+
* already done or with the request-cancelled error. An id that is unknown or already answered is
|
|
961
|
+
* ignored. The host sends it only to a plugin that advertised the capability.
|
|
962
|
+
*/
|
|
963
|
+
cancel: {
|
|
964
|
+
params: CancelParams;
|
|
965
|
+
result: null;
|
|
966
|
+
capability: 'cancel';
|
|
967
|
+
};
|
|
916
968
|
/**
|
|
917
969
|
* Describes the projects behind manifests the host found; a language plugin's way into the
|
|
918
970
|
* workspace.
|
|
@@ -33,4 +33,11 @@ export const ERROR_CODES = {
|
|
|
33
33
|
* method of the Go SDK returns becomes this code.
|
|
34
34
|
*/
|
|
35
35
|
PluginFailure: -32000,
|
|
36
|
+
/**
|
|
37
|
+
* The plugin stopped the request because the host sent a cancel notification for its id. A plugin
|
|
38
|
+
* that had finished answers with the result instead. Only a plugin that advertised the cancel
|
|
39
|
+
* capability sends it; when a cancelled request fails with any error, the SDKs answer with this
|
|
40
|
+
* code.
|
|
41
|
+
*/
|
|
42
|
+
RequestCancelled: -32800,
|
|
36
43
|
};
|
|
@@ -19,9 +19,12 @@ export interface SchemaNode {
|
|
|
19
19
|
export interface MethodRow {
|
|
20
20
|
description: string;
|
|
21
21
|
params: SchemaNode;
|
|
22
|
-
|
|
22
|
+
/** The shape of the answer; `null` for a notification, which gets none. */
|
|
23
|
+
result: SchemaNode | null;
|
|
23
24
|
/** The capability a plugin advertises to be asked the method; `null` when it is always available. */
|
|
24
25
|
capability: string | null;
|
|
26
|
+
/** Whether the host sends the method without an id, so that it gets no response. */
|
|
27
|
+
notification: boolean;
|
|
25
28
|
}
|
|
26
29
|
export interface ErrorCodeRow {
|
|
27
30
|
name: string;
|
|
@@ -6,7 +6,8 @@ export declare function implementsMethod(plugin: SmithPlugin, method: string): b
|
|
|
6
6
|
* The capabilities a plugin advertises, derived from the methods it
|
|
7
7
|
* implements: a plugin cannot advertise a method it lacks, or implement one
|
|
8
8
|
* it does not advertise. The capability of each method is read from the
|
|
9
|
-
* schema's methods table
|
|
9
|
+
* schema's methods table; `cancel` comes from the plugin saying it is
|
|
10
|
+
* `cancellable`.
|
|
10
11
|
*/
|
|
11
12
|
export declare function capabilitiesOf(plugin: SmithPlugin): Capabilities;
|
|
12
13
|
/** Methods a plugin half-implements: a member of a pair (`install` without `uninstall`) that is not advertised for lack of the other. */
|
|
@@ -9,7 +9,8 @@ export function implementsMethod(plugin, method) {
|
|
|
9
9
|
* The capabilities a plugin advertises, derived from the methods it
|
|
10
10
|
* implements: a plugin cannot advertise a method it lacks, or implement one
|
|
11
11
|
* it does not advertise. The capability of each method is read from the
|
|
12
|
-
* schema's methods table
|
|
12
|
+
* schema's methods table; `cancel` comes from the plugin saying it is
|
|
13
|
+
* `cancellable`.
|
|
13
14
|
*/
|
|
14
15
|
export function capabilitiesOf(plugin) {
|
|
15
16
|
const capabilities = {};
|
|
@@ -18,6 +19,8 @@ export function capabilitiesOf(plugin) {
|
|
|
18
19
|
if (row.capability !== null && implementsMethod(plugin, method))
|
|
19
20
|
capabilities[row.capability] = true;
|
|
20
21
|
}
|
|
22
|
+
if (plugin.cancellable === true)
|
|
23
|
+
capabilities.cancel = true;
|
|
21
24
|
return capabilities;
|
|
22
25
|
}
|
|
23
26
|
/** Methods a plugin half-implements: a member of a pair (`install` without `uninstall`) that is not advertised for lack of the other. */
|
|
@@ -9,6 +9,7 @@ export declare function messageOf(thrown: unknown): string;
|
|
|
9
9
|
* MethodNotFound for a method the plugin does not implement, InvalidParams
|
|
10
10
|
* for params of the wrong shape, PluginFailure for an exception the method
|
|
11
11
|
* threw (its message is shown to the user), InternalError for a result that
|
|
12
|
-
* cannot be written as the schema describes.
|
|
12
|
+
* cannot be written as the schema describes. `signal` is passed to the method as its
|
|
13
|
+
* second argument; it aborts when the host cancels the request.
|
|
13
14
|
*/
|
|
14
|
-
export declare function dispatchRequest(plugin: SmithPlugin, request: ReceivedRequest): Promise<RequestOutcome>;
|
|
15
|
+
export declare function dispatchRequest(plugin: SmithPlugin, request: ReceivedRequest, signal?: AbortSignal): Promise<RequestOutcome>;
|
|
@@ -13,9 +13,10 @@ export function messageOf(thrown) {
|
|
|
13
13
|
* MethodNotFound for a method the plugin does not implement, InvalidParams
|
|
14
14
|
* for params of the wrong shape, PluginFailure for an exception the method
|
|
15
15
|
* threw (its message is shown to the user), InternalError for a result that
|
|
16
|
-
* cannot be written as the schema describes.
|
|
16
|
+
* cannot be written as the schema describes. `signal` is passed to the method as its
|
|
17
|
+
* second argument; it aborts when the host cancels the request.
|
|
17
18
|
*/
|
|
18
|
-
export async function dispatchRequest(plugin, request) {
|
|
19
|
+
export async function dispatchRequest(plugin, request, signal = new AbortController().signal) {
|
|
19
20
|
if (request.method === 'shutdown')
|
|
20
21
|
return { ok: true, resultJson: '{}' };
|
|
21
22
|
let params;
|
|
@@ -36,7 +37,7 @@ export async function dispatchRequest(plugin, request) {
|
|
|
36
37
|
}
|
|
37
38
|
else if (implementsMethod(plugin, request.method)) {
|
|
38
39
|
const member = PLUGIN_MEMBERS[request.method].member;
|
|
39
|
-
result = await plugin[member].call(plugin, params);
|
|
40
|
+
result = await plugin[member].call(plugin, params, signal);
|
|
40
41
|
}
|
|
41
42
|
else {
|
|
42
43
|
return { ok: false, code: ERROR_CODES.MethodNotFound, message: `method not supported: ${request.method}` };
|
package/serve/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { serve, serveStreams } from './serve.use-case.ts';
|
|
2
2
|
export { capabilitiesOf, implementsMethod } from './capabilities-of.algorithm.ts';
|
|
3
|
-
export type { Awaitable, CIContributor, Contributor, DeclarationEditor, DependencyManager, Discoverer, DoctorContributor, Generator, LatestReader, Plugin, PluginDescription, Publisher, RegistryReader, Remover, SmithPlugin, Versioner, } from './plugin.contract.ts';
|
|
3
|
+
export type { Awaitable, Cancellable, CIContributor, Contributor, DeclarationEditor, DependencyManager, Discoverer, DoctorContributor, Generator, LatestReader, Plugin, PluginDescription, Publisher, RegistryReader, Remover, SmithPlugin, Versioner, } from './plugin.contract.ts';
|
|
4
4
|
export type { FrameSink, ServeStreams } from './protocol-streams.contract.ts';
|
|
@@ -10,56 +10,71 @@ export interface Plugin {
|
|
|
10
10
|
}
|
|
11
11
|
/** `discover`: describes the projects behind manifests. */
|
|
12
12
|
export interface Discoverer {
|
|
13
|
-
discover: (params: DiscoverParams) => Awaitable<DiscoverResult>;
|
|
13
|
+
discover: (params: DiscoverParams, signal: AbortSignal) => Awaitable<DiscoverResult>;
|
|
14
14
|
}
|
|
15
15
|
/** `generate`: scaffolds a project. */
|
|
16
16
|
export interface Generator {
|
|
17
|
-
generate: (params: GenerateParams) => Awaitable<GenerateResult>;
|
|
17
|
+
generate: (params: GenerateParams, signal: AbortSignal) => Awaitable<GenerateResult>;
|
|
18
18
|
}
|
|
19
19
|
/** `version/write`: records a release version in a manifest. */
|
|
20
20
|
export interface Versioner {
|
|
21
|
-
writeVersion: (params: VersionParams) => Awaitable<VersionResult>;
|
|
21
|
+
writeVersion: (params: VersionParams, signal: AbortSignal) => Awaitable<VersionResult>;
|
|
22
22
|
}
|
|
23
23
|
/** `publish`: publishes a released project. */
|
|
24
24
|
export interface Publisher {
|
|
25
|
-
publish: (params: PublishParams) => Awaitable<PublishResult>;
|
|
25
|
+
publish: (params: PublishParams, signal: AbortSignal) => Awaitable<PublishResult>;
|
|
26
26
|
}
|
|
27
27
|
/** `published`: the newest version a registry has of a project. */
|
|
28
28
|
export interface RegistryReader {
|
|
29
|
-
published: (params: PublishedParams) => Awaitable<PublishedResult>;
|
|
29
|
+
published: (params: PublishedParams, signal: AbortSignal) => Awaitable<PublishedResult>;
|
|
30
30
|
}
|
|
31
31
|
/** `ci`: what a pipeline needs for the plugin's projects. */
|
|
32
32
|
export interface CIContributor {
|
|
33
|
-
ci: (params: CIParams) => Awaitable<CIResult>;
|
|
33
|
+
ci: (params: CIParams, signal: AbortSignal) => Awaitable<CIResult>;
|
|
34
34
|
}
|
|
35
35
|
/** `doctor`: checks the plugin's projects. */
|
|
36
36
|
export interface DoctorContributor {
|
|
37
|
-
doctor: (params: DoctorParams) => Awaitable<DoctorResult>;
|
|
37
|
+
doctor: (params: DoctorParams, signal: AbortSignal) => Awaitable<DoctorResult>;
|
|
38
38
|
}
|
|
39
39
|
/** `remove`: cleans up after a project is removed. */
|
|
40
40
|
export interface Remover {
|
|
41
|
-
remove: (params: RemoveParams) => Awaitable<RemoveResult>;
|
|
41
|
+
remove: (params: RemoveParams, signal: AbortSignal) => Awaitable<RemoveResult>;
|
|
42
42
|
}
|
|
43
43
|
/** `dependencies/install` and `dependencies/uninstall`: both, or the capability is not advertised. */
|
|
44
44
|
export interface DependencyManager {
|
|
45
|
-
install: (params: InstallParams) => Awaitable<DependencyResult>;
|
|
46
|
-
uninstall: (params: UninstallParams) => Awaitable<DependencyResult>;
|
|
45
|
+
install: (params: InstallParams, signal: AbortSignal) => Awaitable<DependencyResult>;
|
|
46
|
+
uninstall: (params: UninstallParams, signal: AbortSignal) => Awaitable<DependencyResult>;
|
|
47
47
|
}
|
|
48
48
|
/** `dependencies/declared` and `dependencies/rewrite`: both, or the capability is not advertised. */
|
|
49
49
|
export interface DeclarationEditor {
|
|
50
|
-
declared: (params: DeclaredParams) => Awaitable<DeclaredResult>;
|
|
51
|
-
rewrite: (params: RewriteParams) => Awaitable<DependencyResult>;
|
|
50
|
+
declared: (params: DeclaredParams, signal: AbortSignal) => Awaitable<DeclaredResult>;
|
|
51
|
+
rewrite: (params: RewriteParams, signal: AbortSignal) => Awaitable<DependencyResult>;
|
|
52
52
|
}
|
|
53
53
|
/** `dependencies/latest`: the newest releases of dependencies and pinned tools. */
|
|
54
54
|
export interface LatestReader {
|
|
55
|
-
latest: (params: LatestParams) => Awaitable<LatestResult>;
|
|
55
|
+
latest: (params: LatestParams, signal: AbortSignal) => Awaitable<LatestResult>;
|
|
56
56
|
}
|
|
57
57
|
/** `contribute`: a convention adds tasks to the projects it applies to. */
|
|
58
58
|
export interface Contributor {
|
|
59
|
-
contribute: (params: ContributeParams) => Awaitable<ContributeResult>;
|
|
59
|
+
contribute: (params: ContributeParams, signal: AbortSignal) => Awaitable<ContributeResult>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `cancellable: true` says the plugin's methods stop their work when the
|
|
63
|
+
* `AbortSignal` they are passed as their second argument aborts, which the
|
|
64
|
+
* serve loop does when the host sends the `cancel` notification for the
|
|
65
|
+
* request. The plugin then advertises the `cancel` capability. A method that
|
|
66
|
+
* stops throws (any error is answered with the request-cancelled code) or
|
|
67
|
+
* returns the result it already has. Without it the signal never aborts and
|
|
68
|
+
* the host is never sent a cancel: it waits for the answer, or kills the
|
|
69
|
+
* plugin.
|
|
70
|
+
*/
|
|
71
|
+
export interface Cancellable {
|
|
72
|
+
cancellable: true;
|
|
60
73
|
}
|
|
61
74
|
/**
|
|
62
75
|
* A plugin as {@link serve} takes it: `describe`, and the methods it implements.
|
|
63
|
-
* The capabilities it advertises are derived from which methods it has
|
|
76
|
+
* The capabilities it advertises are derived from which methods it has, and
|
|
77
|
+
* from `cancellable`. How many requests it can work on at once is declared in
|
|
78
|
+
* `describe`'s `concurrency`: omit it for one at a time, in order.
|
|
64
79
|
*/
|
|
65
|
-
export type SmithPlugin = Plugin & Partial<Discoverer & Generator & Versioner & Publisher & RegistryReader & CIContributor & DoctorContributor & Remover & DependencyManager & DeclarationEditor & LatestReader & Contributor>;
|
|
80
|
+
export type SmithPlugin = Plugin & Partial<Discoverer & Generator & Versioner & Publisher & RegistryReader & CIContributor & DoctorContributor & Remover & DependencyManager & DeclarationEditor & LatestReader & Contributor & Cancellable>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReceivedRequest } from '../codec/index.ts';
|
|
2
|
+
/** The requests waiting for a worker are limited to this many; the host never has more in flight than the plugin declared, so more means a host that breaks the protocol. */
|
|
3
|
+
export declare const MAX_QUEUED = 1024;
|
|
4
|
+
/** One request from the moment it is read until it is answered. */
|
|
5
|
+
export interface PooledRequest {
|
|
6
|
+
readonly request: ReceivedRequest & {
|
|
7
|
+
id: number;
|
|
8
|
+
};
|
|
9
|
+
/** Aborted when the host cancels the request (or the plugin is shutting down). */
|
|
10
|
+
readonly controller: AbortController;
|
|
11
|
+
/** Whether the host asked for the request to stop. */
|
|
12
|
+
cancelled: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** What {@link RequestPool.submit} decided. */
|
|
15
|
+
export type Admission = 'admitted' | 'duplicate' | 'full';
|
|
16
|
+
/**
|
|
17
|
+
* The state of the serve loop: the requests in flight, the ones waiting for a
|
|
18
|
+
* worker, and a bounded number of workers running them. Requests start in the
|
|
19
|
+
* order they arrive and may finish in any order. The Go SDK's request pool,
|
|
20
|
+
* with the same rules.
|
|
21
|
+
*/
|
|
22
|
+
export declare class RequestPool {
|
|
23
|
+
#private;
|
|
24
|
+
/** `run` answers one request; if it throws, the first error is kept in {@link failure} and the pool goes on. */
|
|
25
|
+
constructor(run: (pooled: PooledRequest) => Promise<void>);
|
|
26
|
+
/** The first error a run threw, if any. */
|
|
27
|
+
get failure(): unknown;
|
|
28
|
+
/** How many workers may run at once. Raising it starts waiting requests; lowering it lets running ones finish. */
|
|
29
|
+
setLimit(limit: number): void;
|
|
30
|
+
/** Takes a request: it runs at once on a free worker, or waits its turn. */
|
|
31
|
+
submit(request: ReceivedRequest & {
|
|
32
|
+
id: number;
|
|
33
|
+
}): Admission;
|
|
34
|
+
/**
|
|
35
|
+
* Forgets an answered request; call it before the answer is written, so a
|
|
36
|
+
* host that reuses the id right away is not refused as a duplicate.
|
|
37
|
+
*/
|
|
38
|
+
release(pooled: PooledRequest): void;
|
|
39
|
+
/**
|
|
40
|
+
* Stops the request with an id. One that had not started is taken out of
|
|
41
|
+
* the queue and returned, because no worker will ever answer it. An unknown
|
|
42
|
+
* id is ignored.
|
|
43
|
+
*/
|
|
44
|
+
cancel(id: number): PooledRequest | undefined;
|
|
45
|
+
/** Stops every request. The ones that had not started are returned, to be answered. */
|
|
46
|
+
cancelAll(): PooledRequest[];
|
|
47
|
+
/** Resolves once no worker is running. */
|
|
48
|
+
idle(): Promise<void>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** The requests waiting for a worker are limited to this many; the host never has more in flight than the plugin declared, so more means a host that breaks the protocol. */
|
|
2
|
+
export const MAX_QUEUED = 1024;
|
|
3
|
+
/**
|
|
4
|
+
* The state of the serve loop: the requests in flight, the ones waiting for a
|
|
5
|
+
* worker, and a bounded number of workers running them. Requests start in the
|
|
6
|
+
* order they arrive and may finish in any order. The Go SDK's request pool,
|
|
7
|
+
* with the same rules.
|
|
8
|
+
*/
|
|
9
|
+
export class RequestPool {
|
|
10
|
+
#limit = 1;
|
|
11
|
+
#running = 0;
|
|
12
|
+
#queue = [];
|
|
13
|
+
#inflight = new Map();
|
|
14
|
+
#idle = [];
|
|
15
|
+
#run;
|
|
16
|
+
#failure;
|
|
17
|
+
/** `run` answers one request; if it throws, the first error is kept in {@link failure} and the pool goes on. */
|
|
18
|
+
constructor(run) {
|
|
19
|
+
this.#run = run;
|
|
20
|
+
}
|
|
21
|
+
#start(first) {
|
|
22
|
+
this.#running++;
|
|
23
|
+
void this.#work(first);
|
|
24
|
+
}
|
|
25
|
+
async #work(first) {
|
|
26
|
+
for (let pooled = first; pooled !== undefined; pooled = this.#next()) {
|
|
27
|
+
try {
|
|
28
|
+
await this.#run(pooled);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.#failure ??= error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
#next() {
|
|
36
|
+
if (this.#queue.length > 0 && this.#running <= this.#limit)
|
|
37
|
+
return this.#queue.shift();
|
|
38
|
+
this.#running--;
|
|
39
|
+
if (this.#running === 0)
|
|
40
|
+
for (const resolve of this.#idle.splice(0))
|
|
41
|
+
resolve();
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/** The first error a run threw, if any. */
|
|
45
|
+
get failure() {
|
|
46
|
+
return this.#failure;
|
|
47
|
+
}
|
|
48
|
+
/** How many workers may run at once. Raising it starts waiting requests; lowering it lets running ones finish. */
|
|
49
|
+
setLimit(limit) {
|
|
50
|
+
this.#limit = Math.max(1, limit);
|
|
51
|
+
while (this.#running < this.#limit && this.#queue.length > 0)
|
|
52
|
+
this.#start(this.#queue.shift());
|
|
53
|
+
}
|
|
54
|
+
/** Takes a request: it runs at once on a free worker, or waits its turn. */
|
|
55
|
+
submit(request) {
|
|
56
|
+
if (this.#inflight.has(request.id))
|
|
57
|
+
return 'duplicate';
|
|
58
|
+
if (this.#running >= this.#limit && this.#queue.length >= MAX_QUEUED)
|
|
59
|
+
return 'full';
|
|
60
|
+
const pooled = { request, controller: new AbortController(), cancelled: false };
|
|
61
|
+
this.#inflight.set(request.id, pooled);
|
|
62
|
+
if (this.#running >= this.#limit)
|
|
63
|
+
this.#queue.push(pooled);
|
|
64
|
+
else
|
|
65
|
+
this.#start(pooled);
|
|
66
|
+
return 'admitted';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Forgets an answered request; call it before the answer is written, so a
|
|
70
|
+
* host that reuses the id right away is not refused as a duplicate.
|
|
71
|
+
*/
|
|
72
|
+
release(pooled) {
|
|
73
|
+
this.#inflight.delete(pooled.request.id);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stops the request with an id. One that had not started is taken out of
|
|
77
|
+
* the queue and returned, because no worker will ever answer it. An unknown
|
|
78
|
+
* id is ignored.
|
|
79
|
+
*/
|
|
80
|
+
cancel(id) {
|
|
81
|
+
const pooled = this.#inflight.get(id);
|
|
82
|
+
if (pooled === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
pooled.cancelled = true;
|
|
85
|
+
pooled.controller.abort();
|
|
86
|
+
const at = this.#queue.indexOf(pooled);
|
|
87
|
+
if (at === -1)
|
|
88
|
+
return undefined;
|
|
89
|
+
this.#queue.splice(at, 1);
|
|
90
|
+
this.#inflight.delete(id);
|
|
91
|
+
return pooled;
|
|
92
|
+
}
|
|
93
|
+
/** Stops every request. The ones that had not started are returned, to be answered. */
|
|
94
|
+
cancelAll() {
|
|
95
|
+
for (const pooled of this.#inflight.values()) {
|
|
96
|
+
pooled.cancelled = true;
|
|
97
|
+
pooled.controller.abort();
|
|
98
|
+
}
|
|
99
|
+
const waiting = this.#queue.splice(0);
|
|
100
|
+
for (const pooled of waiting)
|
|
101
|
+
this.#inflight.delete(pooled.request.id);
|
|
102
|
+
return waiting;
|
|
103
|
+
}
|
|
104
|
+
/** Resolves once no worker is running. */
|
|
105
|
+
idle() {
|
|
106
|
+
if (this.#running === 0)
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
return new Promise((resolve) => { this.#idle.push(resolve); });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -19,9 +19,13 @@ export declare function serve(plugin: SmithPlugin): Promise<never>;
|
|
|
19
19
|
* or the input ends, then resolves. This is the loop {@link serve} runs, on
|
|
20
20
|
* streams you choose: a test serves a plugin in-process over pipes with it.
|
|
21
21
|
*
|
|
22
|
-
* Semantics of the Go SDK's `serve.Serve`: requests are answered
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Semantics of the Go SDK's `serve.Serve`: requests are answered by a bounded
|
|
23
|
+
* pool of as many at a time as the plugin declared in `concurrency` (one, in
|
|
24
|
+
* order, when it said nothing), and the answers may be written in any order;
|
|
25
|
+
* a request before `initialize` is refused (InvalidRequest); a notification
|
|
26
|
+
* (no `id`) gets no answer; an unreadable frame body is answered with
|
|
27
|
+
* ParseError and the loop goes on; an unreadable header ends it with an error.
|
|
28
|
+
* A `cancellable` plugin has the signal of a request aborted when the host
|
|
29
|
+
* sends `cancel` for it, and at shutdown or the end of the input.
|
|
26
30
|
*/
|
|
27
31
|
export declare function serveStreams(plugin: SmithPlugin, streams: ServeStreams): Promise<void>;
|
package/serve/serve.use-case.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { MessageParseError, parseRequest, writeFailure, writeResult } from '../codec/index.js';
|
|
1
|
+
import { MessageParseError, ParamsError, decodeParams, parseRequest, writeFailure, writeResult } from '../codec/index.js';
|
|
2
|
+
import { workerLimit } from '../concurrency/index.js';
|
|
2
3
|
import { FrameReader, frameMessage } from '../framing/index.js';
|
|
3
4
|
import { ERROR_CODES } from '../protocol/index.js';
|
|
4
5
|
import { halfImplemented } from './capabilities-of.algorithm.js';
|
|
5
6
|
import { claimStdout } from './claim-stdout.use-case.js';
|
|
6
7
|
import { dispatchRequest } from './dispatch-request.use-case.js';
|
|
8
|
+
import { RequestPool } from './request-pool.store.js';
|
|
7
9
|
/**
|
|
8
10
|
* Runs a plugin on the process's standard input and output, and exits when the
|
|
9
11
|
* host sends `shutdown` or closes the stream. Use it as the whole body of the
|
|
@@ -36,10 +38,14 @@ export async function serve(plugin) {
|
|
|
36
38
|
* or the input ends, then resolves. This is the loop {@link serve} runs, on
|
|
37
39
|
* streams you choose: a test serves a plugin in-process over pipes with it.
|
|
38
40
|
*
|
|
39
|
-
* Semantics of the Go SDK's `serve.Serve`: requests are answered
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* Semantics of the Go SDK's `serve.Serve`: requests are answered by a bounded
|
|
42
|
+
* pool of as many at a time as the plugin declared in `concurrency` (one, in
|
|
43
|
+
* order, when it said nothing), and the answers may be written in any order;
|
|
44
|
+
* a request before `initialize` is refused (InvalidRequest); a notification
|
|
45
|
+
* (no `id`) gets no answer; an unreadable frame body is answered with
|
|
46
|
+
* ParseError and the loop goes on; an unreadable header ends it with an error.
|
|
47
|
+
* A `cancellable` plugin has the signal of a request aborted when the host
|
|
48
|
+
* sends `cancel` for it, and at shutdown or the end of the input.
|
|
43
49
|
*/
|
|
44
50
|
export async function serveStreams(plugin, streams) {
|
|
45
51
|
const log = streams.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
@@ -47,17 +53,27 @@ export async function serveStreams(plugin, streams) {
|
|
|
47
53
|
log(`plugin: ${member} needs its pair (install with uninstall, declared with rewrite) to be advertised; it will not be called`);
|
|
48
54
|
}
|
|
49
55
|
const reader = new FrameReader();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const pool = new RequestPool(async (pooled) => { await run(session, pooled); });
|
|
57
|
+
const session = { initialized: false, plugin, output: streams.output, pool, cancellable: plugin.cancellable === true };
|
|
58
|
+
try {
|
|
59
|
+
for await (const chunk of streams.input) {
|
|
60
|
+
for (const body of reader.push(chunk)) {
|
|
61
|
+
if (pool.failure !== undefined)
|
|
62
|
+
throw pool.failure;
|
|
63
|
+
const shutdown = await receive(session, body);
|
|
64
|
+
if (shutdown)
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
finally {
|
|
70
|
+
await drain(session);
|
|
71
|
+
}
|
|
72
|
+
if (pool.failure !== undefined)
|
|
73
|
+
throw pool.failure;
|
|
58
74
|
}
|
|
59
|
-
/**
|
|
60
|
-
async function
|
|
75
|
+
/** Handles one frame. Returns whether it was `shutdown`, after which the loop ends. */
|
|
76
|
+
async function receive(session, body) {
|
|
61
77
|
let request;
|
|
62
78
|
try {
|
|
63
79
|
request = parseRequest(body);
|
|
@@ -65,21 +81,92 @@ async function answer(plugin, output, session, body) {
|
|
|
65
81
|
catch (error) {
|
|
66
82
|
if (!(error instanceof MessageParseError))
|
|
67
83
|
throw error;
|
|
68
|
-
await send(output, writeFailure(null, ERROR_CODES.ParseError, error.message));
|
|
84
|
+
await send(session.output, writeFailure(null, ERROR_CODES.ParseError, error.message));
|
|
69
85
|
return false;
|
|
70
86
|
}
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
if (request.id === null) {
|
|
88
|
+
await notified(session, request);
|
|
73
89
|
return false;
|
|
90
|
+
}
|
|
91
|
+
const id = request.id;
|
|
74
92
|
if (!session.initialized && request.method !== 'initialize') {
|
|
75
|
-
await send(output, writeFailure(
|
|
93
|
+
await send(session.output, writeFailure(id, ERROR_CODES.InvalidRequest, 'initialize must come first'));
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (request.method === 'initialize') {
|
|
97
|
+
const outcome = await dispatchRequest(session.plugin, request);
|
|
98
|
+
if (outcome.ok) {
|
|
99
|
+
session.initialized = true;
|
|
100
|
+
session.pool.setLimit(workerLimit(JSON.parse(outcome.resultJson).concurrency));
|
|
101
|
+
}
|
|
102
|
+
await send(session.output, outcome.ok ? writeResult(id, outcome.resultJson) : writeFailure(id, outcome.code, outcome.message));
|
|
76
103
|
return false;
|
|
77
104
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
session.
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
if (request.method === 'shutdown') {
|
|
106
|
+
// Everything read before shutdown, started or still waiting, is answered with its result first, as in the Go SDK; only a cancel message stops a request.
|
|
107
|
+
await session.pool.idle();
|
|
108
|
+
await send(session.output, writeResult(id, '{}'));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
switch (session.pool.submit({ ...request, id })) {
|
|
112
|
+
case 'duplicate': {
|
|
113
|
+
await send(session.output, writeFailure(id, ERROR_CODES.InvalidRequest, `request id ${id} is already in flight`));
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'full': {
|
|
117
|
+
await send(session.output, writeFailure(id, ERROR_CODES.InvalidRequest, 'too many requests in flight: the host must not send more than the plugin\'s declared concurrency'));
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'admitted': {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Handles a message without an `id`. `cancel` is the only one the protocol
|
|
128
|
+
* defines for a plugin; every other is ignored, and so is a `cancel` to a
|
|
129
|
+
* plugin that is not `cancellable`, or one that names no request.
|
|
130
|
+
*/
|
|
131
|
+
async function notified(session, request) {
|
|
132
|
+
if (request.method !== 'cancel' || !session.cancellable)
|
|
133
|
+
return;
|
|
134
|
+
let params;
|
|
135
|
+
try {
|
|
136
|
+
params = decodeParams('cancel', request.params);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof ParamsError)
|
|
140
|
+
return;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
const waiting = session.pool.cancel(params.id);
|
|
144
|
+
if (waiting !== undefined)
|
|
145
|
+
await answerCancelled(session.output, waiting);
|
|
146
|
+
}
|
|
147
|
+
/** A worker's job: handle one request and answer it. */
|
|
148
|
+
async function run(session, pooled) {
|
|
149
|
+
const outcome = await dispatchRequest(session.plugin, pooled.request, pooled.controller.signal);
|
|
150
|
+
const answer = !outcome.ok && outcome.code === ERROR_CODES.PluginFailure && pooled.cancelled
|
|
151
|
+
? { ok: false, code: ERROR_CODES.RequestCancelled, message: `request cancelled: ${outcome.message}` }
|
|
152
|
+
: outcome;
|
|
153
|
+
session.pool.release(pooled);
|
|
154
|
+
await send(session.output, answer.ok ? writeResult(pooled.request.id, answer.resultJson) : writeFailure(pooled.request.id, answer.code, answer.message));
|
|
155
|
+
}
|
|
156
|
+
function answerCancelled(output, pooled) {
|
|
157
|
+
return send(output, writeFailure(pooled.request.id, ERROR_CODES.RequestCancelled, 'request cancelled before it started'));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Ends the work in flight when the loop returns (the input ended, the host is gone): a
|
|
161
|
+
* cancellable plugin is asked to stop and what had not started is refused;
|
|
162
|
+
* any other finishes what it is doing.
|
|
163
|
+
*/
|
|
164
|
+
async function drain(session) {
|
|
165
|
+
if (session.cancellable) {
|
|
166
|
+
for (const waiting of session.pool.cancelAll())
|
|
167
|
+
await answerCancelled(session.output, waiting);
|
|
168
|
+
}
|
|
169
|
+
await session.pool.idle();
|
|
83
170
|
}
|
|
84
171
|
function send(output, body) {
|
|
85
172
|
return new Promise((resolve, reject) => {
|