arcane-os 0.83.1 → 0.84.0
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/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/docs/reference/core-native-packaging.md +21 -0
- package/docs/reference/core-runtime.md +28 -1
- package/docs/reference/core-shared-host.md +71 -8
- package/docs/reference/inventory/package-api.json +20 -6
- package/docs/reference/sdk-api.md +87 -3
- package/package.json +1 -1
- package/src/core/host.mjs +1 -1
- package/src/core/runtime-connection.mjs +165 -0
- package/src/core/runtime.mjs +10 -6
- package/src/core/shared-host.mjs +270 -70
- package/src/core/stdio.mjs +18 -3
- package/src/native/portable-layout.mjs +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.84.0
|
|
4
|
+
|
|
5
|
+
- Add `startCoreListener({runtime, endpoint, onError})` through
|
|
6
|
+
`arcane-os/core/host` so local clients can use an application's existing
|
|
7
|
+
window-owned Core without creating another runtime or changing its profile,
|
|
8
|
+
service instances or saved-data location. Native packages select it with
|
|
9
|
+
`native.launchContext.coreListener: {endpoint}`.
|
|
10
|
+
- Keep response correlation, cancellation and current-state replay scoped to
|
|
11
|
+
each connection. Preserve complete application payloads, expose the original
|
|
12
|
+
handler `requestId` and actual internal `coreRequestId`, and prevent retired
|
|
13
|
+
request events from attaching to a later reuse of a client ID.
|
|
14
|
+
- Closing a client or listener leaves accepted service-owned work with its
|
|
15
|
+
runtime. Window shutdown drains Core and then closes its listener. Existing
|
|
16
|
+
explicitly selected headless shared-host behavior remains available. This
|
|
17
|
+
capability does not claim to correct or verify model inference.
|
|
18
|
+
|
|
3
19
|
## 0.83.1
|
|
4
20
|
|
|
5
21
|
- Preserve complete failure-only native ONNX diagnostics in `error.onnxRun`,
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
|
|
|
25
25
|
share the theme, packaging, event, cancellation,
|
|
26
26
|
and browser run contracts while retaining their selected application layout.
|
|
27
27
|
|
|
28
|
-
This checkout defines the `0.
|
|
28
|
+
This checkout defines the `0.84.0` SDK contract. Development applications declare
|
|
29
29
|
`arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
|
|
30
30
|
designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
|
|
31
31
|
to refresh and compare the installed SDK and lockfile with the published channel;
|
|
@@ -234,6 +234,27 @@ Windows and macOS launchers use the same packaged defaults for native state
|
|
|
234
234
|
selection, so an ordinary executable launch and external MCP can select the
|
|
235
235
|
same app-owned Core.
|
|
236
236
|
|
|
237
|
+
To expose the actual window-owned Core to an external local client while
|
|
238
|
+
preserving that stdio lifetime, select
|
|
239
|
+
`native.launchContext:{coreListener:{endpoint:"APPLICATION-SELECTED-LOCAL-ENDPOINT"}}`
|
|
240
|
+
instead. The generated entry calls
|
|
241
|
+
`startCoreListener({runtime:host.runtime,endpoint})` after creating its ordinary
|
|
242
|
+
stdio host. It reuses that exact service set and launch context, including the
|
|
243
|
+
existing origin, profile and state root; no headless runtime is created. The
|
|
244
|
+
listener endpoint has no default and its parent directory must already exist
|
|
245
|
+
where required by the operating system. `coreListener` and `sharedHost` are
|
|
246
|
+
alternative selections; supplying both reports `TypeError`.
|
|
247
|
+
|
|
248
|
+
Clients use `connectSharedCoreHost({endpoint})` with `start` omitted and close
|
|
249
|
+
only their own connection. This listener provides no host-shutdown RPC. Closing
|
|
250
|
+
the window still drains its Core, including accepted service-owned operations,
|
|
251
|
+
and then closes the listener. Signal-driven host shutdown remains observed while
|
|
252
|
+
the listener is binding; a bind failure drains the newly created stdio host and
|
|
253
|
+
reports the failure. The generated entry joins listener closure without changing
|
|
254
|
+
the existing shared-host/headless branches. See
|
|
255
|
+
[the existing-runtime listener contract](core-shared-host.md#attach-to-an-existing-window-owned-core)
|
|
256
|
+
for direct composition, correlation, cancellation and complete error behavior.
|
|
257
|
+
|
|
237
258
|
`resolveNativeLaunchContext({appId,context={}})` from `arcane-os/core/host` is
|
|
238
259
|
the public, synchronous location owner for native applications and external MCP.
|
|
239
260
|
It returns a new complete context and fills only omitted fields:
|
|
@@ -67,7 +67,7 @@ complete diagnostic serialization; the startup promise retains its original
|
|
|
67
67
|
rejection value.
|
|
68
68
|
|
|
69
69
|
Each lifecycle hook receives `{application, service, emit, getService}`. Each request handler
|
|
70
|
-
receives `(parameters, {application, service, emit, getService, requestId, signal})`, with
|
|
70
|
+
receives `(parameters, {application, service, emit, getService, requestId, coreRequestId, signal})`, with
|
|
71
71
|
`this` bound to its service definition. A hook or handler may publish a complete
|
|
72
72
|
service-owned event through `emit(event, data)`.
|
|
73
73
|
|
|
@@ -132,6 +132,18 @@ The shared `arcane-os/core/contracts` module owns `CORE_PROTOCOL` (`arcane/1`),
|
|
|
132
132
|
availability errors remain observable; unavailable services are not replaced
|
|
133
133
|
with browser behavior.
|
|
134
134
|
|
|
135
|
+
A transport that assigns internal dispatch IDs may call
|
|
136
|
+
`runtime.handle(frame, {contextRequestId})`. This optional nonempty string sets
|
|
137
|
+
only the service handler's `context.requestId`. `context.coreRequestId` always
|
|
138
|
+
exposes the actual `frame.id`; it distinguishes simultaneous requests whose
|
|
139
|
+
clients selected equal IDs. The supplied `frame.id` still
|
|
140
|
+
owns active-request state, cancellation lookup, response correlation and the
|
|
141
|
+
request event's top-level envelope. Parameters and event data remain unchanged.
|
|
142
|
+
Ordinary `handle(frame)` keeps its existing behavior: `context.requestId` equals
|
|
143
|
+
`frame.id`, and `coreRequestId` equals that same ID. Stdio and
|
|
144
|
+
the borrowed IPC listener use this metadata to preserve the original client ID
|
|
145
|
+
in service-facing data while their transport owner routes unique dispatch IDs.
|
|
146
|
+
|
|
135
147
|
A request with no registered handler retains code `METHOD_NOT_ALLOWED` and
|
|
136
148
|
adds the exact `method`, its first dotted `namespace`, and a `reason`.
|
|
137
149
|
`core-namespace-unavailable` means the dispatcher has neither a service with
|
|
@@ -222,6 +234,21 @@ reach `onError` and reject `closed`. The transport detaches
|
|
|
222
234
|
its listeners and pauses its input; it does not end caller-owned output streams
|
|
223
235
|
or take ownership of the host process.
|
|
224
236
|
|
|
237
|
+
Stdio routes responses and request-context events only for its own requests.
|
|
238
|
+
`request.cancel`, `requests.cancelAll` and `runtime.replay` are connection-scoped;
|
|
239
|
+
they do not cancel or replay another attached connection's work. The protocol
|
|
240
|
+
owner gives every accepted request a fresh internal ID and restores its client
|
|
241
|
+
ID on responses, event envelopes and stdio's own `core.state` entries. Handler
|
|
242
|
+
`context.requestId` and complete application payloads keep their original values.
|
|
243
|
+
Uncorrelated runtime/service events remain shared.
|
|
244
|
+
|
|
245
|
+
`startCoreListener({runtime,endpoint,onError})` from `arcane-os/core/host` can
|
|
246
|
+
attach additional local clients to this same runtime. Its explicit `close()`
|
|
247
|
+
closes only that listener; stdio EOF still closes the runtime and drains all
|
|
248
|
+
accepted service work, then the listener closes automatically. Observe the
|
|
249
|
+
listener's `closed` as well as the stdio owner's `closed` to join both transport
|
|
250
|
+
lifecycles. See [existing-runtime listeners](core-shared-host.md#attach-to-an-existing-window-owned-core).
|
|
251
|
+
|
|
225
252
|
## Platform and delivery boundary
|
|
226
253
|
|
|
227
254
|
These modules use portable Node and JavaScript facilities for Windows, Linux
|
|
@@ -13,6 +13,69 @@ event history. Application methods and policy stay in the application services.
|
|
|
13
13
|
Use the [MCP server](mcp-stdio.md) for external MCP framing; local Core continues
|
|
14
14
|
to use the existing `arcane/1` protocol and Content-Length stream framing.
|
|
15
15
|
|
|
16
|
+
## Attach to an existing window-owned Core
|
|
17
|
+
|
|
18
|
+
`startCoreListener({runtime,endpoint,onError})` adds a local IPC listener to an
|
|
19
|
+
existing Core runtime. It resolves `{endpoint,closed,close}` after binding the
|
|
20
|
+
selected endpoint. It neither constructs nor starts a runtime or service. The
|
|
21
|
+
existing host retains its application, services, origin, profile, state location
|
|
22
|
+
and shutdown ownership.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import {startCoreListener} from 'arcane-os/core/host';
|
|
26
|
+
|
|
27
|
+
// host is the application's existing startCoreHost/startCoreStdio handle.
|
|
28
|
+
const listener = await startCoreListener({runtime: host.runtime, endpoint: selectedEndpoint});
|
|
29
|
+
try {
|
|
30
|
+
await host.closed;
|
|
31
|
+
} finally {
|
|
32
|
+
await listener.close();
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
An external client uses `connectSharedCoreHost({endpoint: selectedEndpoint})`
|
|
37
|
+
with `start` omitted, invokes the application's existing methods through
|
|
38
|
+
`connection.client.invoke(method, parameters)`, and calls `connection.close()`
|
|
39
|
+
when finished. This connection uses the existing SDK framing, correlation and
|
|
40
|
+
current-runtime replay; it does not launch another Core or replay requests.
|
|
41
|
+
The listener does not implement or intercept `core.host.shutdown`.
|
|
42
|
+
`connection.shutdown()` therefore has no listener-owned shutdown operation;
|
|
43
|
+
without an application method of that name it returns the ordinary
|
|
44
|
+
`METHOD_NOT_ALLOWED` error. The window's host remains the shutdown owner.
|
|
45
|
+
|
|
46
|
+
Disconnect and `listener.close()` cancel only the affected connections'
|
|
47
|
+
request-lifetime work. Explicit listener close drains queued output and releases
|
|
48
|
+
the endpoint without closing the runtime or waiting for accepted service-lifetime
|
|
49
|
+
work; that work remains tracked by its service/runtime owner. When the runtime
|
|
50
|
+
itself begins shutdown, the listener rejects new requests and retains its endpoint
|
|
51
|
+
through runtime drain/disposal, then closes its connections automatically.
|
|
52
|
+
Observe both the host's `closed` and the listener's `closed` for their respective
|
|
53
|
+
completion or failure. `close()` is idempotent. Transport errors reach `onError`
|
|
54
|
+
(complete console diagnostics by default); terminal listener failures reject
|
|
55
|
+
`closed`. Bind failures reject startup with their actual native error and leave
|
|
56
|
+
the supplied runtime alive. A draining/closed runtime rejects attachment with
|
|
57
|
+
`CORE_CLOSING`.
|
|
58
|
+
|
|
59
|
+
Stdio and each IPC connection have independent request correlation and
|
|
60
|
+
cancellation. Handlers reached through stdio or this listener receive the
|
|
61
|
+
original client ID in `context.requestId`; `context.coreRequestId` exposes the
|
|
62
|
+
fresh internal ID used by runtime dispatch and protocol routing. Responses and
|
|
63
|
+
top-level event correlation return to their originating connection. The new listener leaves all event data,
|
|
64
|
+
including any authored `data.requestId`, and all parameters/results unchanged.
|
|
65
|
+
Its runtime-state snapshot retains the runtime's internal active IDs. Late events
|
|
66
|
+
from a retired request never attach to a later reuse of that client's ID.
|
|
67
|
+
Uncorrelated service events remain shared. See the
|
|
68
|
+
[runtime metadata contract](core-runtime.md#state-and-frames).
|
|
69
|
+
|
|
70
|
+
For a generated native entry, select
|
|
71
|
+
`native.launchContext.coreListener:{endpoint}` and omit `sharedHost`. This adds
|
|
72
|
+
the listener to the ordinary window/stdio runtime; closing the native window
|
|
73
|
+
still drains Core. The endpoint is explicit, with no inferred headless startup
|
|
74
|
+
or new default location. Its parent directory must already exist on platforms
|
|
75
|
+
that require one. Selecting both `coreListener` and `sharedHost` reports
|
|
76
|
+
`TypeError` because they select different runtime lifetimes. See
|
|
77
|
+
[native launch locations](core-native-packaging.md#launch-time-locations).
|
|
78
|
+
|
|
16
79
|
## Host entry
|
|
17
80
|
|
|
18
81
|
```js
|
|
@@ -107,8 +170,8 @@ running. `closed` observes connection termination and rejects on a transport
|
|
|
107
170
|
error. A subsequent connection uses the still-running owner. Reconnection is
|
|
108
171
|
explicit; failed or uncertain requests are never resubmitted.
|
|
109
172
|
|
|
110
|
-
`await connection.shutdown(invokeOptions)` explicitly
|
|
111
|
-
`core.host.shutdown` operation. It drains the shared runtime, returns
|
|
173
|
+
For an owning shared host, `await connection.shutdown(invokeOptions)` explicitly
|
|
174
|
+
invokes the host-owned `core.host.shutdown` operation. It drains the shared runtime, returns
|
|
112
175
|
`{state:'closed'}` after successful service cleanup, then closes the connection.
|
|
113
176
|
A drain failure returns the full Core error. Shutdown affects **all** clients
|
|
114
177
|
of that app endpoint and belongs to the application owner's explicit lifecycle,
|
|
@@ -118,8 +181,8 @@ after shutdown acceptance does not undo the shared shutdown.
|
|
|
118
181
|
|
|
119
182
|
## Correlation, events, and state
|
|
120
183
|
|
|
121
|
-
Each connection has its own request-ID space. The
|
|
122
|
-
internal Core request ID and restores the originating ID on responses and
|
|
184
|
+
Each connection has its own request-ID space. The owning shared-host adapter
|
|
185
|
+
substitutes an internal Core request ID and restores the originating ID on responses and
|
|
123
186
|
the optional top-level event `requestId`. Existing `data.requestId` values that
|
|
124
187
|
carry that same Core correlation ID are restored too. `parameters`, returned results, schemas,
|
|
125
188
|
documents, and content remain complete and unchanged. A handler's
|
|
@@ -137,10 +200,10 @@ connection's IDs; other connections' active requests retain Core-side IDs.
|
|
|
137
200
|
requests. A disconnect performs the same scoped cancellation. The underlying
|
|
138
201
|
runtime continues to preserve service-lifetime acceptance.
|
|
139
202
|
|
|
140
|
-
`runtime.replay`
|
|
141
|
-
`core.state`, and `core.service.state` snapshot. Optional
|
|
142
|
-
`getReplayEvents()`
|
|
143
|
-
service owners. It is for current domain state, not historical chunks or
|
|
203
|
+
Both listener forms handle `runtime.replay` by sending only that connection the
|
|
204
|
+
current `core.ready`, `core.state`, and `core.service.state` snapshot. Optional
|
|
205
|
+
synchronous `getReplayEvents()` on the owning shared host returns current
|
|
206
|
+
`{event,data}` records from their actual service owners. It is for current domain state, not historical chunks or
|
|
144
207
|
retired requests. Subscribe before requesting a domain snapshot when its live
|
|
145
208
|
events may change; applications own those domain methods and state.
|
|
146
209
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"sdkVersion": "0.
|
|
3
|
+
"sdkVersion": "0.83.0",
|
|
4
4
|
"environment": {
|
|
5
5
|
"runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
|
|
6
6
|
"minimumVersion": "22.23.2 for Node entrypoints",
|
|
7
7
|
"moduleSystem": "ESM"
|
|
8
8
|
},
|
|
9
|
-
"memberCount":
|
|
9
|
+
"memberCount": 303,
|
|
10
10
|
"runtimeSubpathPatterns": {
|
|
11
11
|
"arcane-os/modules/*": "./runtime/arcane/modules/*",
|
|
12
12
|
"arcane-os/entities/*": "./runtime/arcane/entities/*"
|
|
@@ -4374,7 +4374,7 @@
|
|
|
4374
4374
|
"summary": "Creates an app-neutral Core dispatcher with explicit services, request lifetimes, events and graceful drain.",
|
|
4375
4375
|
"availability": "Node on Windows, Linux and macOS native hosts; Android requires its host adaptation",
|
|
4376
4376
|
"protocol": "arcane/1 dispatcher and service lifecycle",
|
|
4377
|
-
"normalization": "Starts services independently; runtime.getService(name) and context.getService(name) return the same registered object after only its shared startup, without a renderer or global barrier. Lookup is readiness, not a lifetime lease; new lookups reject during shutdown. Preserves complete results/errors, cancels request-lifetime work and drains accepted service work; runtime.replay publishes current dispatcher/service state without restart and preserves reentrant lifecycle publication order. Missing dispatch retains METHOD_NOT_ALLOWED with exact method/namespace and core-namespace-unavailable or core-method-unavailable; named services, registered methods and built-in namespaces establish presence. Handler/startup/permission errors remain unchanged."
|
|
4377
|
+
"normalization": "Starts services independently; runtime.getService(name) and context.getService(name) return the same registered object after only its shared startup, without a renderer or global barrier. Lookup is readiness, not a lifetime lease; new lookups reject during shutdown. runtime.handle(frame,{contextRequestId}) optionally sets handler requestId while coreRequestId remains frame.id; omitted metadata exposes both equal. Internal IDs continue to own cancellation/state/event envelopes and payloads remain unchanged. Preserves complete results/errors, cancels request-lifetime work and drains accepted service work; runtime.replay publishes current dispatcher/service state without restart and preserves reentrant lifecycle publication order. Missing dispatch retains METHOD_NOT_ALLOWED with exact method/namespace and core-namespace-unavailable or core-method-unavailable; named services, registered methods and built-in namespaces establish presence. Handler/startup/permission errors remain unchanged."
|
|
4378
4378
|
},
|
|
4379
4379
|
{
|
|
4380
4380
|
"id": "core-stdio:encodeCoreFrame",
|
|
@@ -4416,7 +4416,7 @@
|
|
|
4416
4416
|
"summary": "Attaches an existing Core runtime to framed input and output with concurrent dispatch and graceful shutdown.",
|
|
4417
4417
|
"availability": "Node with readable input and writable output streams",
|
|
4418
4418
|
"protocol": "Content-Length framed arcane/1 stdio",
|
|
4419
|
-
"normalization": "Returns {runtime,closed,close};
|
|
4419
|
+
"normalization": "Returns {runtime,closed,close}; scopes responses/request events, cancellation and current-state replay to its connection, preserving original service-facing requestId and complete payloads while routing unique internal IDs. Preserves queued output order, reports parser and stream failures, drains accepted runtime work on EOF and never exits the host or ends caller-owned output"
|
|
4420
4420
|
},
|
|
4421
4421
|
{
|
|
4422
4422
|
"id": "native-portable-provider:createPortableNativeProvider",
|
|
@@ -4598,7 +4598,7 @@
|
|
|
4598
4598
|
"summary": "Resolves matching per-app native state, workspace and optional shared endpoint defaults for packaged UI and external MCP.",
|
|
4599
4599
|
"availability": "Node native locations on Windows/Linux/macOS; Android and other hosts supply stateRoot and native adaptation",
|
|
4600
4600
|
"protocol": "Pure native launch-context resolution and existing local IPC",
|
|
4601
|
-
"normalization": "Returns a new complete context with only omitted stateRoot/workspaceRoot and selected sharedHost endpoint/log fields filled; preserves explicit values and additional payloads. Native launchContext presence opts generated entry into resolution; omitted sharedHost retains stdio. No I/O, saved-state discovery, process startup, migration or ArcaneData/Repos change. Malformed selected appId/stateRoot/sharedHost inputs report TypeError; platform IPC errors remain operation-owned."
|
|
4601
|
+
"normalization": "Returns a new complete context with only omitted stateRoot/workspaceRoot and selected sharedHost endpoint/log fields filled; preserves explicit values and additional payloads. Native launchContext presence opts generated entry into resolution; omitted sharedHost retains stdio. Optional coreListener:{endpoint} is preserved without endpoint defaults and attaches to that same window-owned runtime in the generated entry; selecting it together with sharedHost reports TypeError there. No I/O, saved-state discovery, process startup, migration or ArcaneData/Repos change. Malformed selected appId/stateRoot/sharedHost inputs report TypeError; platform IPC errors remain operation-owned."
|
|
4602
4602
|
},
|
|
4603
4603
|
{
|
|
4604
4604
|
"id": "core-host:readCoreLaunchContext",
|
|
@@ -4628,6 +4628,20 @@
|
|
|
4628
4628
|
"protocol": "arcane/1 over Content-Length framed stdio",
|
|
4629
4629
|
"normalization": "Returns the existing {runtime,closed,close} transport owner; an optional abort signal invokes the same idempotent drain without killing the process, replacing service readiness or cancelling accepted service-lifetime work"
|
|
4630
4630
|
},
|
|
4631
|
+
{
|
|
4632
|
+
"id": "core-host:startCoreListener",
|
|
4633
|
+
"name": "startCoreListener",
|
|
4634
|
+
"displayName": "startCoreListener()",
|
|
4635
|
+
"kind": "function",
|
|
4636
|
+
"signature": "startCoreListener({runtime,endpoint,onError}={})",
|
|
4637
|
+
"entrypoints": ["arcane-os/core/host"],
|
|
4638
|
+
"primaryImport": "arcane-os/core/host",
|
|
4639
|
+
"group": "Shared Core host",
|
|
4640
|
+
"summary": "Attaches local IPC to an existing window-owned Core runtime without changing its services or lifetime.",
|
|
4641
|
+
"availability": "Node named pipes on Windows and Unix-domain sockets on Linux/macOS; Android requires its host adaptation",
|
|
4642
|
+
"protocol": "arcane/1 over local IPC with per-connection correlation, cancellation and current-state replay",
|
|
4643
|
+
"normalization": "Returns {endpoint,closed,close}; never constructs, starts or closes the supplied runtime. Explicit close flushes queued output and disconnects its clients while accepted service work remains runtime-owned. Runtime shutdown retains the endpoint through service drain then closes the listener. Preserves parameters/results/event data, original handler requestId and internal coreRequestId; no host shutdown RPC. Bind errors reject startup; transport errors reach onError and terminal errors reject closed. Native launchContext.coreListener:{endpoint} selects the existing stdio branch; no sharedHost/headless selection or inferred endpoint."
|
|
4644
|
+
},
|
|
4631
4645
|
{
|
|
4632
4646
|
"id": "core-host:startSharedCoreHost",
|
|
4633
4647
|
"name": "startSharedCoreHost",
|
|
@@ -4654,7 +4668,7 @@
|
|
|
4654
4668
|
"summary": "Connects to one selected host and optionally starts an explicit headless entry when absent.",
|
|
4655
4669
|
"availability": "Node local IPC and optional explicit child process with diagnostic file",
|
|
4656
4670
|
"protocol": "arcane/1 Core client; optional Node startup IPC handshake",
|
|
4657
|
-
"normalization": "Returns {client,endpoint,closed,close,shutdown}. Only ENOENT/ECONNREFUSED selects start {command,args,cwd,env,logFile}; no request retry. Close cancels this connection's request work while preserving the host and accepted service work. Explicit shutdown drains
|
|
4671
|
+
"normalization": "Returns {client,endpoint,closed,close,shutdown}. Only ENOENT/ECONNREFUSED selects start {command,args,cwd,env,logFile}; no request retry. Close cancels this connection's request work while preserving the host and accepted service work. Explicit shutdown drains an owning shared host for all clients; cancelling a launch wait does not kill the host. Connect to startCoreListener with start omitted; that borrowed listener implements no core.host.shutdown operation and its runtime owner retains shutdown."
|
|
4658
4672
|
},
|
|
4659
4673
|
{
|
|
4660
4674
|
"id": "core-host:runSharedCoreHost",
|
|
@@ -427,6 +427,7 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
|
|
|
427
427
|
| `arcaneNativeBuilderProvider` | singleton | `arcane-os/native/portable-provider` | Portable native provider | Node; produces a portable payload rather than an executable host |
|
|
428
428
|
| `arcaneNativeBuilderProvider default export` | singleton | `arcane-os/native/portable-provider` | Portable native provider | Node; produces a portable payload rather than an executable host |
|
|
429
429
|
| `startCoreHost()` | function | `arcane-os/core/host` | Native Core host composition | Node with readable input and writable output streams |
|
|
430
|
+
| `startCoreListener()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC attached to an existing runtime |
|
|
430
431
|
| `startSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC on Windows, Linux and macOS |
|
|
431
432
|
| `connectSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC and optional explicit headless startup |
|
|
432
433
|
| `connectAppControl()` | function | `arcane-os/core/app-control` | Native application control | Node local IPC; current window adapter is Windows WebView2 |
|
|
@@ -7394,6 +7395,14 @@ Import it from `arcane-os/core/runtime`. The runtime exposes `current()`,
|
|
|
7394
7395
|
`subscribe()`, `onFrame()`, `emit()`, `registerService()`, `getService()`, `start()`, `handle()`
|
|
7395
7396
|
and idempotent `close()`.
|
|
7396
7397
|
|
|
7398
|
+
`runtime.handle(frame, {contextRequestId})` optionally preserves a transport's
|
|
7399
|
+
original client ID in handler `context.requestId`, while `context.coreRequestId`
|
|
7400
|
+
always exposes the actual internal `frame.id`. Active requests, cancellation,
|
|
7401
|
+
response IDs and request-event envelopes retain that internal ID. Ordinary
|
|
7402
|
+
`handle(frame)` exposes both context IDs equal to `frame.id`. This metadata
|
|
7403
|
+
does not change parameters or event data; transports own client-facing protocol
|
|
7404
|
+
correlation. See [the runtime contract](core-runtime.md#state-and-frames).
|
|
7405
|
+
|
|
7397
7406
|
`await runtime.getService(name)` and service `context.getService(name)` return
|
|
7398
7407
|
the actual registered service after its shared startup. They wait for that
|
|
7399
7408
|
dependency alone, may start it before `runtime.start()`, and add no renderer or
|
|
@@ -7551,6 +7560,13 @@ Import it from `arcane-os/core/stdio`. The result is
|
|
|
7551
7560
|
`{runtime, closed, close}`. EOF or input close starts graceful shutdown;
|
|
7552
7561
|
`closed` settles only after accepted runtime work and queued output settle.
|
|
7553
7562
|
|
|
7563
|
+
Responses, request events, cancellation controls and current-state replay are
|
|
7564
|
+
connection-scoped even when an additional IPC listener shares the runtime.
|
|
7565
|
+
Stdio preserves original handler `requestId` values while routing fresh internal
|
|
7566
|
+
IDs; uncorrelated service events remain shared. EOF retains its existing
|
|
7567
|
+
runtime-wide shutdown ownership. Additional clients attach through
|
|
7568
|
+
[`startCoreListener()`](#startcorelistener).
|
|
7569
|
+
|
|
7554
7570
|
### Availability and normalization
|
|
7555
7571
|
|
|
7556
7572
|
**Node with readable input and writable output streams.** Parser, input, output
|
|
@@ -7995,6 +8011,14 @@ the caller's working-directory meaning. `appId` and the selected state path
|
|
|
7995
8011
|
must be nonempty strings; a selected `sharedHost` must be an object. Malformed
|
|
7996
8012
|
inputs report `TypeError`; native endpoint failures belong to the IPC operation.
|
|
7997
8013
|
|
|
8014
|
+
`native.launchContext.coreListener:{endpoint}` instead attaches local IPC to
|
|
8015
|
+
the existing window/stdio runtime. Its endpoint remains explicit and receives
|
|
8016
|
+
no default. The generated entry rejects simultaneous `coreListener` and
|
|
8017
|
+
`sharedHost` selections. Client disconnect affects that connection; window
|
|
8018
|
+
shutdown still drains Core and closes the additional listener. See
|
|
8019
|
+
[`startCoreListener()`](#startcorelistener) and
|
|
8020
|
+
[native launch locations](core-native-packaging.md#launch-time-locations).
|
|
8021
|
+
|
|
7998
8022
|
```javascript
|
|
7999
8023
|
import {resolveNativeLaunchContext} from 'arcane-os/core/host';
|
|
8000
8024
|
|
|
@@ -8793,6 +8817,59 @@ async function bundleMoonCheeseRuntime(outputRoot){
|
|
|
8793
8817
|
The ordinary provider invokes this owner only for an explicit native local-AI
|
|
8794
8818
|
selection. See [selected local-AI runtimes](core-native-packaging.md#selected-local-ai-runtimes).
|
|
8795
8819
|
|
|
8820
|
+
## startCoreListener()
|
|
8821
|
+
|
|
8822
|
+
### Overview and signature
|
|
8823
|
+
|
|
8824
|
+
Attaches a local IPC listener to an existing Core runtime, without creating,
|
|
8825
|
+
starting or taking ownership of that runtime or its services.
|
|
8826
|
+
|
|
8827
|
+
```text
|
|
8828
|
+
startCoreListener({runtime,endpoint,onError}={})
|
|
8829
|
+
```
|
|
8830
|
+
|
|
8831
|
+
Import from `arcane-os/core/host`. Resolves `{endpoint,closed,close}` after the
|
|
8832
|
+
selected endpoint binds. `close()` is idempotent, cancels the listener's
|
|
8833
|
+
request-lifetime work, flushes queued output and closes its connections. It
|
|
8834
|
+
does not call `runtime.close()` or wait for accepted service-lifetime work;
|
|
8835
|
+
that work remains tracked by the existing runtime. Runtime shutdown rejects
|
|
8836
|
+
new listener requests, retains the endpoint through service drain/disposal,
|
|
8837
|
+
and closes the listener automatically. Observe both owners' `closed` promises.
|
|
8838
|
+
Bind errors reject startup without closing the supplied runtime. Transport
|
|
8839
|
+
errors reach `onError`, with complete console diagnostics by default; terminal
|
|
8840
|
+
listener errors reject `closed`. Attaching to a draining/closed runtime reports
|
|
8841
|
+
`CORE_CLOSING`.
|
|
8842
|
+
|
|
8843
|
+
Node supports named pipes on Windows and Unix-domain sockets on Linux/macOS;
|
|
8844
|
+
Android requires its host adaptation. Supply a nonempty endpoint and an existing
|
|
8845
|
+
parent directory where required. Connect with `connectSharedCoreHost({endpoint})`
|
|
8846
|
+
and omit `start`. Its current-runtime replay does not replay requests. The
|
|
8847
|
+
borrowed listener implements no `core.host.shutdown` operation; use client
|
|
8848
|
+
`close()` for disconnection and the existing runtime's owner for host shutdown.
|
|
8849
|
+
|
|
8850
|
+
Each connection owns response/event correlation and request cancellation.
|
|
8851
|
+
Handler `requestId` preserves the client ID; `coreRequestId` is the distinct
|
|
8852
|
+
internal dispatch ID. Parameters, results and event data, including
|
|
8853
|
+
`data.requestId`, remain unchanged. Runtime-state snapshots retain internal
|
|
8854
|
+
active IDs; no service event is invented or retained for later replay.
|
|
8855
|
+
|
|
8856
|
+
```js
|
|
8857
|
+
import {startCoreListener} from 'arcane-os/core/host';
|
|
8858
|
+
|
|
8859
|
+
const listener = await startCoreListener({runtime: host.runtime, endpoint: selectedEndpoint});
|
|
8860
|
+
try {
|
|
8861
|
+
await host.closed;
|
|
8862
|
+
} finally {
|
|
8863
|
+
await listener.close();
|
|
8864
|
+
}
|
|
8865
|
+
```
|
|
8866
|
+
|
|
8867
|
+
`host` is the application's existing stdio host. Generated native entries
|
|
8868
|
+
select this attachment with `native.launchContext.coreListener:{endpoint}`,
|
|
8869
|
+
preserving their actual runtime, service set, profile, origin and state root.
|
|
8870
|
+
Omit `sharedHost`; the two options describe alternative lifetimes. Full
|
|
8871
|
+
contract: [existing-runtime listener](core-shared-host.md#attach-to-an-existing-window-owned-core).
|
|
8872
|
+
|
|
8796
8873
|
## startSharedCoreHost()
|
|
8797
8874
|
|
|
8798
8875
|
### Overview
|
|
@@ -8871,6 +8948,12 @@ not kill a host another client may use. No startup timeout is imposed; supply
|
|
|
8871
8948
|
before disconnecting. It affects every client; cancellation after acceptance
|
|
8872
8949
|
does not reverse it. A drain failure returns the full Core error.
|
|
8873
8950
|
|
|
8951
|
+
The same connector can attach to `startCoreListener()` with `start` omitted.
|
|
8952
|
+
There, `close()` still disconnects only this client, and `shutdown()` has no
|
|
8953
|
+
listener-owned operation: absent an application method of that name, the
|
|
8954
|
+
ordinary dispatcher returns `METHOD_NOT_ALLOWED`. The existing window/runtime
|
|
8955
|
+
owner remains responsible for shutdown.
|
|
8956
|
+
|
|
8874
8957
|
```js
|
|
8875
8958
|
import {connectSharedCoreHost} from 'arcane-os/core/host';
|
|
8876
8959
|
|
|
@@ -8967,9 +9050,10 @@ stdio-owned Core lifetime. See [native shared-host launch](core-shared-host.md#n
|
|
|
8967
9050
|
|
|
8968
9051
|
## startCoreHost()
|
|
8969
9052
|
|
|
8970
|
-
For
|
|
8971
|
-
[
|
|
8972
|
-
|
|
9053
|
+
For an external client of the actual window-owned runtime, attach
|
|
9054
|
+
[`startCoreListener()`](#startcorelistener) to `host.runtime`. For an explicitly
|
|
9055
|
+
independent headless lifetime, use the [shared host functions](#startsharedcorehost).
|
|
9056
|
+
Ordinary `startCoreHost()` retains its existing stdio-owned lifetime.
|
|
8973
9057
|
|
|
8974
9058
|
### Overview
|
|
8975
9059
|
|
package/package.json
CHANGED
package/src/core/host.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import Is from 'strong-type';
|
|
|
5
5
|
import {createCoreRuntime} from './runtime.mjs';
|
|
6
6
|
import {startCoreStdio} from './stdio.mjs';
|
|
7
7
|
|
|
8
|
-
export {startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startSharedCoreBridge} from './shared-host.mjs';
|
|
8
|
+
export {startCoreListener, startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startSharedCoreBridge} from './shared-host.mjs';
|
|
9
9
|
|
|
10
10
|
const is = new Is(false);
|
|
11
11
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import Is from 'strong-type';
|
|
3
|
+
import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
|
|
5
|
+
const is = new Is(false);
|
|
6
|
+
const owners = new WeakMap();
|
|
7
|
+
|
|
8
|
+
function failure(code, message) {
|
|
9
|
+
return new CoreError({code, message});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function event(event, data) {
|
|
13
|
+
return {protocol: CORE_PROTOCOL, type: 'event', event, data, time: new Date().toISOString()};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function runtimeConnections(runtime) {
|
|
17
|
+
const existing = owners.get(runtime);
|
|
18
|
+
if (existing) return existing;
|
|
19
|
+
const connections = new Set();
|
|
20
|
+
const requests = new Map();
|
|
21
|
+
const prefix = `connection-${randomUUID()}:`;
|
|
22
|
+
let sequence = 0;
|
|
23
|
+
|
|
24
|
+
function route(frame) {
|
|
25
|
+
if (frame.type === 'response') {
|
|
26
|
+
const record = requests.get(frame.id);
|
|
27
|
+
if (record) {
|
|
28
|
+
record.release();
|
|
29
|
+
record.connection.send({...frame, id: record.id}, record);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (frame.type !== 'event') return;
|
|
34
|
+
if (Object.hasOwn(frame, 'requestId')) {
|
|
35
|
+
const record = requests.get(frame.requestId);
|
|
36
|
+
if (record) record.connection.send({...frame, requestId: record.id}, record);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const connection of connections) connection.send(frame);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const unsubscribe = runtime.onFrame(route);
|
|
43
|
+
const owner = {
|
|
44
|
+
connections,
|
|
45
|
+
requests,
|
|
46
|
+
allocate(requestPrefix) {
|
|
47
|
+
// Every acceptance gets a fresh transport correlation. A retired
|
|
48
|
+
// service context must never target a later use of its client ID.
|
|
49
|
+
const active = runtime.current().activeRequests;
|
|
50
|
+
function inUse(candidate) {
|
|
51
|
+
return requests.has(candidate) || active.some(
|
|
52
|
+
function activeRequest(request) { return request.id === candidate; }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
let internalId;
|
|
56
|
+
do {
|
|
57
|
+
internalId = `${requestPrefix ?? ''}${prefix}${++sequence}`;
|
|
58
|
+
} while (inUse(internalId));
|
|
59
|
+
return internalId;
|
|
60
|
+
},
|
|
61
|
+
release() {
|
|
62
|
+
if (connections.size || requests.size) return;
|
|
63
|
+
unsubscribe();
|
|
64
|
+
owners.delete(runtime);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
owners.set(runtime, owner);
|
|
68
|
+
return owner;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One transport connection borrows dispatch; only its host owns runtime.close. */
|
|
72
|
+
export function createCoreRuntimeConnection({
|
|
73
|
+
runtime, send, requestPrefix, mapFrame, getReplayEvents, preserveContextRequestId = true
|
|
74
|
+
}) {
|
|
75
|
+
const owner = runtimeConnections(runtime);
|
|
76
|
+
const requests = new Map();
|
|
77
|
+
const internalRequests = new Map();
|
|
78
|
+
let disconnected = false;
|
|
79
|
+
|
|
80
|
+
function deliver(frame, record) {
|
|
81
|
+
if (disconnected) return;
|
|
82
|
+
const outgoing = mapFrame ? mapFrame(frame, record) : frame;
|
|
83
|
+
if (outgoing !== undefined) send(outgoing);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cancel(record) {
|
|
87
|
+
return runtime.handle(
|
|
88
|
+
{protocol: CORE_PROTOCOL, type: 'control', control: 'request.cancel', requestId: record.internalId}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cancelAll() {
|
|
93
|
+
return Promise.all([...requests.values()].map(cancel));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function replay() {
|
|
97
|
+
const state = runtime.current();
|
|
98
|
+
if (state.state === 'ready') deliver(event('core.ready', {version: state.version, app: state.application}));
|
|
99
|
+
deliver(event('core.state', state));
|
|
100
|
+
for (const service of state.services) deliver(event('core.service.state', service));
|
|
101
|
+
// Only the existing owner's current snapshots, never requests/history.
|
|
102
|
+
for (const snapshot of getReplayEvents?.() ?? []) deliver(event(snapshot.event, snapshot.data));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handle(frame) {
|
|
106
|
+
if (disconnected) throw failure('CORE_CONNECTION_CLOSED', 'The Core connection is closed.');
|
|
107
|
+
if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
|
|
108
|
+
if (frame.type === 'control') {
|
|
109
|
+
if (frame.control === 'runtime.replay') return replay();
|
|
110
|
+
if (frame.control === 'requests.cancelAll') return cancelAll();
|
|
111
|
+
if (frame.control === 'request.cancel') {
|
|
112
|
+
const record = requests.get(frame.requestId);
|
|
113
|
+
return record ? cancel(record) : undefined;
|
|
114
|
+
}
|
|
115
|
+
throw failure('INVALID_RPC_CONTROL', 'Unknown Core control.');
|
|
116
|
+
}
|
|
117
|
+
if (frame.type !== 'request' || !is.string(frame.id) || !frame.id || !is.string(frame.method)) {
|
|
118
|
+
throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
|
|
119
|
+
}
|
|
120
|
+
if (requests.has(frame.id)) {
|
|
121
|
+
const answer = {protocol: CORE_PROTOCOL, type: 'response', id: frame.id, ok: false,
|
|
122
|
+
error: serializeCoreError(failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active on this connection.')),
|
|
123
|
+
time: new Date().toISOString()};
|
|
124
|
+
deliver(answer);
|
|
125
|
+
return answer;
|
|
126
|
+
}
|
|
127
|
+
const record = {connection, id: frame.id, internalId: owner.allocate(requestPrefix), release: releaseRequest};
|
|
128
|
+
function releaseRequest() {
|
|
129
|
+
if (requests.get(record.id) === record) requests.delete(record.id);
|
|
130
|
+
internalRequests.delete(record.internalId);
|
|
131
|
+
owner.requests.delete(record.internalId);
|
|
132
|
+
owner.release();
|
|
133
|
+
}
|
|
134
|
+
requests.set(record.id, record);
|
|
135
|
+
internalRequests.set(record.internalId, record);
|
|
136
|
+
owner.requests.set(record.internalId, record);
|
|
137
|
+
try {
|
|
138
|
+
return await runtime.handle(
|
|
139
|
+
{...frame, id: record.internalId},
|
|
140
|
+
preserveContextRequestId ? {contextRequestId: record.id} : undefined
|
|
141
|
+
);
|
|
142
|
+
} finally {
|
|
143
|
+
releaseRequest();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function close() {
|
|
148
|
+
if (disconnected) return Promise.resolve();
|
|
149
|
+
disconnected = true;
|
|
150
|
+
owner.connections.delete(connection);
|
|
151
|
+
const cancellation = cancelAll();
|
|
152
|
+
owner.release();
|
|
153
|
+
return cancellation;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const connection = {
|
|
157
|
+
handle,
|
|
158
|
+
close,
|
|
159
|
+
send: deliver,
|
|
160
|
+
has(id) { return requests.has(id); },
|
|
161
|
+
request(internalId) { return internalRequests.get(internalId); }
|
|
162
|
+
};
|
|
163
|
+
owner.connections.add(connection);
|
|
164
|
+
return connection;
|
|
165
|
+
}
|
package/src/core/runtime.mjs
CHANGED
|
@@ -218,7 +218,7 @@ class CoreRuntime {
|
|
|
218
218
|
return this.current();
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
async handle(frame) {
|
|
221
|
+
async handle(frame, {contextRequestId} = {}) {
|
|
222
222
|
if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
|
|
223
223
|
if (frame.type === 'control') {
|
|
224
224
|
if (frame.control === 'runtime.replay') {
|
|
@@ -235,7 +235,10 @@ class CoreRuntime {
|
|
|
235
235
|
if (frame.type !== 'request' || !is.string(frame.id) || !frame.id || !is.string(frame.method)) {
|
|
236
236
|
throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
|
|
237
237
|
}
|
|
238
|
-
|
|
238
|
+
if (contextRequestId !== undefined && (!is.string(contextRequestId) || !contextRequestId)) {
|
|
239
|
+
throw failure('INVALID_RPC_REQUEST', 'A transport context request id must be a nonempty string.');
|
|
240
|
+
}
|
|
241
|
+
const task = this.#answer(frame, contextRequestId);
|
|
239
242
|
this.#responses.add(task);
|
|
240
243
|
try {
|
|
241
244
|
return await task;
|
|
@@ -244,7 +247,7 @@ class CoreRuntime {
|
|
|
244
247
|
}
|
|
245
248
|
}
|
|
246
249
|
|
|
247
|
-
async #answer(frame) {
|
|
250
|
+
async #answer(frame, contextRequestId) {
|
|
248
251
|
let result;
|
|
249
252
|
let error;
|
|
250
253
|
let ok = true;
|
|
@@ -254,7 +257,7 @@ class CoreRuntime {
|
|
|
254
257
|
if (frame.method === 'system.ping') result = {ok: true};
|
|
255
258
|
else if (frame.method === 'version.current') result = this.#version;
|
|
256
259
|
else if (frame.method === 'app.current') result = this.#application;
|
|
257
|
-
else result = await this.#request(frame);
|
|
260
|
+
else result = await this.#request(frame, contextRequestId);
|
|
258
261
|
} catch (caught) {
|
|
259
262
|
ok = false;
|
|
260
263
|
error = caught;
|
|
@@ -270,7 +273,7 @@ class CoreRuntime {
|
|
|
270
273
|
return response;
|
|
271
274
|
}
|
|
272
275
|
|
|
273
|
-
#request(frame) {
|
|
276
|
+
#request(frame, contextRequestId) {
|
|
274
277
|
const registration = this.#methods.get(frame.method);
|
|
275
278
|
if (!registration) {
|
|
276
279
|
const namespace = frame.method.split('.')[0];
|
|
@@ -300,7 +303,8 @@ class CoreRuntime {
|
|
|
300
303
|
const result = await handle.call(
|
|
301
304
|
service.definition,
|
|
302
305
|
frame.parameters,
|
|
303
|
-
{...runtime.#serviceContext(service, frame.id),
|
|
306
|
+
{...runtime.#serviceContext(service, frame.id),
|
|
307
|
+
requestId: contextRequestId ?? frame.id, coreRequestId: frame.id, signal: controller.signal}
|
|
304
308
|
);
|
|
305
309
|
aborted(controller);
|
|
306
310
|
return result;
|
package/src/core/shared-host.mjs
CHANGED
|
@@ -2,10 +2,14 @@ import {spawn} from 'node:child_process';
|
|
|
2
2
|
import {open} from 'node:fs/promises';
|
|
3
3
|
import {createServer} from 'node:net';
|
|
4
4
|
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import Is from 'strong-type';
|
|
5
6
|
import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
6
7
|
import {createCoreRuntime} from './runtime.mjs';
|
|
7
8
|
import {createCoreFrameDecoder, encodeCoreFrame} from './stdio.mjs';
|
|
8
9
|
import {connectCoreSocket, createCoreSocketConnection, writeCoreSocket as write} from './socket-connection.mjs';
|
|
10
|
+
import {createCoreRuntimeConnection} from './runtime-connection.mjs';
|
|
11
|
+
|
|
12
|
+
const is = new Is(false);
|
|
9
13
|
|
|
10
14
|
function failure(code, message) { return new CoreError({code, message}); }
|
|
11
15
|
function reportError(error) { console.error('Shared Arcane Core failed:', error); }
|
|
@@ -23,6 +27,233 @@ function response(id, result, error) {
|
|
|
23
27
|
time: new Date().toISOString()};
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** Add local IPC to an existing runtime without acquiring its lifetime. */
|
|
31
|
+
export async function startCoreListener({runtime, endpoint, onError = reportError} = {}) {
|
|
32
|
+
if (!is.string(endpoint) || !endpoint) throw new TypeError('A Core listener endpoint must be a nonempty local pipe/socket path.');
|
|
33
|
+
if (!runtime || !is.function(runtime.handle) || !is.function(runtime.onFrame)
|
|
34
|
+
|| !is.function(runtime.current) || !is.function(runtime.subscribe)) {
|
|
35
|
+
throw new TypeError('A Core listener requires an existing Core runtime.');
|
|
36
|
+
}
|
|
37
|
+
if (['draining', 'closed'].includes(runtime.current().state)) throw failure('CORE_CLOSING', 'Core is closing.');
|
|
38
|
+
const peers = new Set();
|
|
39
|
+
const prefix = `listener-${randomUUID()}:`;
|
|
40
|
+
let accepting = true;
|
|
41
|
+
let closing;
|
|
42
|
+
let terminalError;
|
|
43
|
+
let resolveClosed;
|
|
44
|
+
let rejectClosed;
|
|
45
|
+
let resolveRuntimeClosed;
|
|
46
|
+
const runtimeClosed = new Promise(
|
|
47
|
+
function observeRuntimeClosure(resolve) { resolveRuntimeClosed = resolve; }
|
|
48
|
+
);
|
|
49
|
+
const closed = new Promise(
|
|
50
|
+
function ownListener(resolve, reject) {
|
|
51
|
+
resolveClosed = resolve;
|
|
52
|
+
rejectClosed = reject;
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
closed.catch(function observeReportedListenerFailure() {});
|
|
56
|
+
|
|
57
|
+
function diagnostic(error) {
|
|
58
|
+
report(onError, error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function disconnect(peer) {
|
|
62
|
+
if (peer.closed) return;
|
|
63
|
+
peer.closed = true;
|
|
64
|
+
peers.delete(peer);
|
|
65
|
+
peer.connection.close().catch(diagnostic);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function send(peer, frame) {
|
|
69
|
+
if (peer.closed) return;
|
|
70
|
+
let content;
|
|
71
|
+
try {
|
|
72
|
+
content = encodeCoreFrame(frame);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
diagnostic(error);
|
|
75
|
+
peer.socket.destroy();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
peer.writes = peer.writes.then(
|
|
79
|
+
function writeNext() {
|
|
80
|
+
if (!peer.closed) return write(peer.socket, content);
|
|
81
|
+
}
|
|
82
|
+
).catch(
|
|
83
|
+
function outputFailed(error) {
|
|
84
|
+
diagnostic(error);
|
|
85
|
+
peer.socket.destroy();
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function accept(socket) {
|
|
91
|
+
const peer = {socket, closed: false, writes: Promise.resolve(), connection: null};
|
|
92
|
+
peers.add(peer);
|
|
93
|
+
peer.connection = createCoreRuntimeConnection(
|
|
94
|
+
{
|
|
95
|
+
runtime,
|
|
96
|
+
requestPrefix: prefix,
|
|
97
|
+
send(frame) { send(peer, frame); }
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
const decoder = createCoreFrameDecoder(
|
|
101
|
+
function receiveFrame(frame) {
|
|
102
|
+
if (peer.closed) return;
|
|
103
|
+
if (!accepting && frame?.type === 'request') {
|
|
104
|
+
send(peer, response(frame.id, undefined, failure('CORE_CLOSING', 'The Core listener is closing.')));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// No owning-host shutdown command or application dispatcher.
|
|
108
|
+
peer.connection.handle(frame).catch(
|
|
109
|
+
function requestFailed(error) {
|
|
110
|
+
if (frame?.type === 'request') send(peer, response(frame.id, undefined, error));
|
|
111
|
+
else send(peer, event('core.error', serializeCoreError(error)));
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
socket.on(
|
|
117
|
+
'data',
|
|
118
|
+
function receiveData(chunk) {
|
|
119
|
+
try {
|
|
120
|
+
decoder.push(chunk);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
diagnostic(error);
|
|
123
|
+
socket.destroy();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
socket.on('error', diagnostic);
|
|
128
|
+
socket.on(
|
|
129
|
+
'end',
|
|
130
|
+
function inputEnded() {
|
|
131
|
+
try {
|
|
132
|
+
decoder.finish();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
diagnostic(error);
|
|
135
|
+
}
|
|
136
|
+
disconnect(peer);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
socket.on('close', function connectionClosed() { disconnect(peer); });
|
|
140
|
+
socket.resume();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const server = createServer({pauseOnConnect: true}, accept);
|
|
144
|
+
const binding = new Promise(
|
|
145
|
+
function claimEndpoint(resolve, reject) {
|
|
146
|
+
function failed(error) {
|
|
147
|
+
server.off('listening', listening);
|
|
148
|
+
reject(error);
|
|
149
|
+
}
|
|
150
|
+
function listening() {
|
|
151
|
+
server.off('error', failed);
|
|
152
|
+
resolve();
|
|
153
|
+
}
|
|
154
|
+
server.once('error', failed);
|
|
155
|
+
server.once('listening', listening);
|
|
156
|
+
server.listen({path: endpoint});
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
function close() {
|
|
161
|
+
if (closing) return closed;
|
|
162
|
+
accepting = false;
|
|
163
|
+
closing = Promise.resolve().then(
|
|
164
|
+
async function closeListener() {
|
|
165
|
+
try {
|
|
166
|
+
await binding;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
terminalError ??= error;
|
|
169
|
+
}
|
|
170
|
+
// Window/Core shutdown retains the endpoint through service
|
|
171
|
+
// drain. Closing this listener never initiates runtime.close().
|
|
172
|
+
if (runtime.current().state === 'draining') await runtimeClosed;
|
|
173
|
+
const stopped = new Promise(
|
|
174
|
+
function stopListening(resolve, reject) {
|
|
175
|
+
server.close(
|
|
176
|
+
function listenerClosed(error) {
|
|
177
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
|
|
178
|
+
else resolve();
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
stopped.catch(function observeListenerStopFailure() {});
|
|
184
|
+
const connected = [...peers];
|
|
185
|
+
const cancellations = await Promise.allSettled(
|
|
186
|
+
connected.map(
|
|
187
|
+
function detachConnection(peer) {
|
|
188
|
+
peer.socket.pause();
|
|
189
|
+
return peer.connection.close();
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
for (const outcome of cancellations) {
|
|
194
|
+
if (outcome.status === 'rejected') {
|
|
195
|
+
terminalError = terminalError
|
|
196
|
+
? new AggregateError([terminalError, outcome.reason], 'Core listener closure failed.')
|
|
197
|
+
: outcome.reason;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
await Promise.all(connected.map(function flushConnection(peer) { return peer.writes; }));
|
|
201
|
+
for (const peer of connected) {
|
|
202
|
+
peer.socket.resume();
|
|
203
|
+
peer.socket.end();
|
|
204
|
+
}
|
|
205
|
+
await stopped;
|
|
206
|
+
if (terminalError) throw terminalError;
|
|
207
|
+
return {state: 'closed', endpoint};
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
closing.then(
|
|
211
|
+
function listenerClosed(result) {
|
|
212
|
+
unsubscribe();
|
|
213
|
+
resolveClosed(result);
|
|
214
|
+
},
|
|
215
|
+
function listenerFailed(error) {
|
|
216
|
+
unsubscribe();
|
|
217
|
+
diagnostic(error);
|
|
218
|
+
rejectClosed(error);
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
return closed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const unsubscribe = runtime.subscribe(
|
|
225
|
+
function runtimeChanged(state) {
|
|
226
|
+
if (state.state === 'draining') accepting = false;
|
|
227
|
+
if (state.state === 'closed') {
|
|
228
|
+
resolveRuntimeClosed();
|
|
229
|
+
close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
server.on(
|
|
234
|
+
'error',
|
|
235
|
+
function listenerFailed(error) {
|
|
236
|
+
terminalError = error;
|
|
237
|
+
close();
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
try {
|
|
241
|
+
await binding;
|
|
242
|
+
if (!accepting) {
|
|
243
|
+
await close();
|
|
244
|
+
throw failure('CORE_CLOSING', 'Core closed while its listener was starting.');
|
|
245
|
+
}
|
|
246
|
+
return {endpoint, closed, close};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
try {
|
|
249
|
+
await close();
|
|
250
|
+
} catch (closeError) {
|
|
251
|
+
if (closeError !== error) throw new AggregateError([error, closeError], 'Core listener startup and cleanup failed.');
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
26
257
|
/** Claim one app-selected pipe/socket before importing or creating services. */
|
|
27
258
|
export async function startSharedCoreHost({
|
|
28
259
|
endpoint, application, version, configure, getReplayEvents, signal, onError = reportError
|
|
@@ -30,13 +261,10 @@ export async function startSharedCoreHost({
|
|
|
30
261
|
signal?.throwIfAborted();
|
|
31
262
|
if (typeof endpoint !== 'string' || !endpoint) throw new TypeError('A shared Core endpoint must be a nonempty local pipe/socket path.');
|
|
32
263
|
const peers = new Set();
|
|
33
|
-
const requests = new Map();
|
|
34
264
|
const incoming = new Set();
|
|
35
265
|
const lifetime = new AbortController();
|
|
36
266
|
const prefix = `shared-${randomUUID()}:`;
|
|
37
|
-
let sequence = 0;
|
|
38
267
|
let runtime;
|
|
39
|
-
let unsubscribe;
|
|
40
268
|
let closing;
|
|
41
269
|
let preparation = Promise.resolve();
|
|
42
270
|
let ready = false;
|
|
@@ -65,89 +293,61 @@ export async function startSharedCoreHost({
|
|
|
65
293
|
if (!peer.closed) return write(peer.socket, content);
|
|
66
294
|
}).catch(function outputFailed(error) { diagnostic(error); peer.socket.destroy(); });
|
|
67
295
|
}
|
|
68
|
-
function cancel(record) {
|
|
69
|
-
observe(runtime.handle({protocol: CORE_PROTOCOL, type: 'control',
|
|
70
|
-
control: 'request.cancel', requestId: record.internalId}));
|
|
71
|
-
}
|
|
72
296
|
function disconnect(peer) {
|
|
73
297
|
if (peer.closed) return;
|
|
74
298
|
peer.closed = true;
|
|
75
299
|
peers.delete(peer);
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
function stateFor(peer, state) {
|
|
79
|
-
return {...state, activeRequests: state.activeRequests.map(function requestState(record) {
|
|
80
|
-
const owned = requests.get(record.id);
|
|
81
|
-
return owned?.peer === peer ? {...record, id: owned.id} : record;
|
|
82
|
-
})};
|
|
83
|
-
}
|
|
84
|
-
function replay(peer) {
|
|
85
|
-
const state = runtime.current();
|
|
86
|
-
if (state.state === 'ready') send(peer, event('core.ready', {version: state.version, app: state.application}));
|
|
87
|
-
send(peer, event('core.state', stateFor(peer, state)));
|
|
88
|
-
for (const service of state.services) send(peer, event('core.service.state', service));
|
|
89
|
-
// Domain snapshots belong to the app/service owner, never event history.
|
|
90
|
-
for (const snapshot of getReplayEvents?.() ?? []) send(peer, event(snapshot.event, snapshot.data));
|
|
300
|
+
if (peer.connection) observe(peer.connection.close());
|
|
91
301
|
}
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
302
|
+
function attach(peer) {
|
|
303
|
+
peer.connection = createCoreRuntimeConnection(
|
|
304
|
+
{
|
|
305
|
+
runtime,
|
|
306
|
+
requestPrefix: prefix,
|
|
307
|
+
preserveContextRequestId: false,
|
|
308
|
+
getReplayEvents,
|
|
309
|
+
send(frame) { send(peer, frame); },
|
|
310
|
+
mapFrame(frame, record) {
|
|
311
|
+
// Preserve this owning adapter's established data correlation.
|
|
312
|
+
// The borrowed listener and stdio never apply this mapping.
|
|
313
|
+
if (record) {
|
|
314
|
+
return frame.type === 'event' && frame.data?.requestId === record.internalId
|
|
315
|
+
? {...frame, data: {...frame.data, requestId: record.id}} : frame;
|
|
316
|
+
}
|
|
317
|
+
if (frame.event === 'core.state') {
|
|
318
|
+
return {...frame, data: {...frame.data, activeRequests: frame.data.activeRequests.map(
|
|
319
|
+
function requestState(request) {
|
|
320
|
+
const owned = peer.connection.request(request.id);
|
|
321
|
+
return owned ? {...request, id: owned.id} : request;
|
|
322
|
+
}
|
|
323
|
+
)}};
|
|
324
|
+
}
|
|
325
|
+
const id = frame.data?.requestId;
|
|
326
|
+
const owned = peer.connection.request(id);
|
|
327
|
+
if (owned) return {...frame, data: {...frame.data, requestId: owned.id}};
|
|
328
|
+
// A retired transport correlation is not a new app-wide event.
|
|
329
|
+
if (is.string(id) && id.startsWith(prefix)) return;
|
|
330
|
+
return frame;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
);
|
|
115
334
|
}
|
|
116
335
|
async function dispatch(peer, frame) {
|
|
117
336
|
if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
|
|
118
337
|
if (frame.type === 'control') {
|
|
119
|
-
|
|
120
|
-
if (frame.control === 'request.cancel') {
|
|
121
|
-
const record = peer.requests.get(frame.requestId);
|
|
122
|
-
if (record) cancel(record);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (frame.control === 'requests.cancelAll') {
|
|
126
|
-
for (const record of peer.requests.values()) cancel(record);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
throw failure('INVALID_RPC_CONTROL', 'Unknown Core control.');
|
|
338
|
+
return peer.connection.handle(frame);
|
|
130
339
|
}
|
|
131
340
|
if (frame.type !== 'request' || typeof frame.id !== 'string' || !frame.id || typeof frame.method !== 'string') {
|
|
132
341
|
throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
|
|
133
342
|
}
|
|
134
|
-
if (peer.
|
|
343
|
+
if (peer.connection.has(frame.id)) throw failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active on this connection.');
|
|
135
344
|
if (frame.method === 'core.host.shutdown') {
|
|
136
345
|
peer.shutdownIds.push(frame.id);
|
|
137
346
|
close();
|
|
138
347
|
return;
|
|
139
348
|
}
|
|
140
349
|
if (closing) throw failure('CORE_CLOSING', 'Core is closing.');
|
|
141
|
-
|
|
142
|
-
peer.requests.set(record.id, record);
|
|
143
|
-
requests.set(record.internalId, record);
|
|
144
|
-
try {
|
|
145
|
-
// Only protocol correlation changes; parameters remain untouched.
|
|
146
|
-
await runtime.handle({...frame, id: record.internalId});
|
|
147
|
-
} finally {
|
|
148
|
-
peer.requests.delete(record.id);
|
|
149
|
-
requests.delete(record.internalId);
|
|
150
|
-
}
|
|
350
|
+
return peer.connection.handle(frame);
|
|
151
351
|
}
|
|
152
352
|
function receive(peer, frame) {
|
|
153
353
|
if (peer.closed) return;
|
|
@@ -157,8 +357,9 @@ export async function startSharedCoreHost({
|
|
|
157
357
|
}));
|
|
158
358
|
}
|
|
159
359
|
function accept(socket) {
|
|
160
|
-
const peer = {socket,
|
|
360
|
+
const peer = {socket, connection: null, shutdownIds: [], closed: false, writes: Promise.resolve()};
|
|
161
361
|
peers.add(peer);
|
|
362
|
+
if (runtime) attach(peer);
|
|
162
363
|
const decoder = createCoreFrameDecoder(function receiveFrame(frame) { receive(peer, frame); });
|
|
163
364
|
socket.on('data', function data(chunk) {
|
|
164
365
|
try { decoder.push(chunk); } catch (error) { diagnostic(error); socket.destroy(); }
|
|
@@ -196,7 +397,6 @@ export async function startSharedCoreHost({
|
|
|
196
397
|
await Promise.all([...peers].map(function drainWrites(peer) { return peer.writes; }));
|
|
197
398
|
for (const peer of peers) { peer.socket.resume(); peer.socket.end(); }
|
|
198
399
|
await stopped;
|
|
199
|
-
unsubscribe?.();
|
|
200
400
|
signal?.removeEventListener('abort', abortHost);
|
|
201
401
|
if (error) throw error;
|
|
202
402
|
return runtime?.current() ?? {state: 'closed'};
|
|
@@ -222,7 +422,7 @@ export async function startSharedCoreHost({
|
|
|
222
422
|
// The endpoint is already owned. A losing launcher never calls configure.
|
|
223
423
|
signal?.throwIfAborted();
|
|
224
424
|
runtime = createCoreRuntime({application, version});
|
|
225
|
-
|
|
425
|
+
for (const peer of peers) attach(peer);
|
|
226
426
|
signal?.addEventListener('abort', abortHost, {once: true});
|
|
227
427
|
preparation = Promise.resolve().then(function configureClaimedHost() {
|
|
228
428
|
lifetime.signal.throwIfAborted();
|
package/src/core/stdio.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {finished} from 'node:stream';
|
|
2
2
|
import Is from 'strong-type';
|
|
3
3
|
import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
import {createCoreRuntimeConnection} from './runtime-connection.mjs';
|
|
4
5
|
|
|
5
6
|
const is = new Is(false);
|
|
6
7
|
|
|
@@ -116,10 +117,24 @@ export function startCoreStdio({runtime, input = process.stdin, output = process
|
|
|
116
117
|
).catch(failOutput);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
const
|
|
120
|
+
const connection = createCoreRuntimeConnection(
|
|
121
|
+
{
|
|
122
|
+
runtime,
|
|
123
|
+
send: queueFrame,
|
|
124
|
+
mapFrame(frame) {
|
|
125
|
+
if (frame.event !== 'core.state') return frame;
|
|
126
|
+
return {...frame, data: {...frame.data, activeRequests: frame.data.activeRequests.map(
|
|
127
|
+
function requestState(request) {
|
|
128
|
+
const owned = connection.request(request.id);
|
|
129
|
+
return owned ? {...request, id: owned.id} : request;
|
|
130
|
+
}
|
|
131
|
+
)}};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
);
|
|
120
135
|
|
|
121
136
|
function receiveFrame(frame) {
|
|
122
|
-
const task =
|
|
137
|
+
const task = connection.handle(frame);
|
|
123
138
|
pending.add(task);
|
|
124
139
|
task.then(
|
|
125
140
|
function requestSettled() {
|
|
@@ -157,7 +172,7 @@ export function startCoreStdio({runtime, input = process.stdin, output = process
|
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
async function releaseTransportListeners() {
|
|
160
|
-
|
|
175
|
+
await connection.close();
|
|
161
176
|
output.off('error', failOutput);
|
|
162
177
|
// destroy(error) marks a stream destroyed before its scheduled error
|
|
163
178
|
// and close events. The native completion owner observes their delivery;
|
|
@@ -203,7 +203,7 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
203
203
|
"import path from 'node:path';",
|
|
204
204
|
"import {isSea} from 'node:sea';",
|
|
205
205
|
...(launchDefaults === undefined ? [] : ["import {mkdir} from 'node:fs/promises';"]),
|
|
206
|
-
"import {readCoreLaunchContext, startCoreHost, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
|
|
206
|
+
"import {readCoreLaunchContext, startCoreHost, startCoreListener, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
|
|
207
207
|
'',
|
|
208
208
|
...(localAI === undefined ? [] : [
|
|
209
209
|
'// Bundled runtime paths are artifact-root-relative POSIX paths.',
|
|
@@ -249,6 +249,10 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
249
249
|
: ` ...await readCoreLaunchContext({appId: ${JSON.stringify(application.id)}, defaults: JSON.parse(${JSON.stringify(JSON.stringify(launchDefaults))})})`,
|
|
250
250
|
'};',
|
|
251
251
|
'',
|
|
252
|
+
'if (context.coreListener !== undefined && context.sharedHost !== undefined) {',
|
|
253
|
+
" throw new TypeError('coreListener attaches to the window-owned Core; sharedHost selects its separate owning host.');",
|
|
254
|
+
'}',
|
|
255
|
+
'',
|
|
252
256
|
...(launchDefaults === undefined ? [] : [
|
|
253
257
|
'// The shared endpoint and diagnostic log require their selected state directory.',
|
|
254
258
|
'if (context.sharedHost !== undefined) await mkdir(context.stateRoot, {recursive: true});',
|
|
@@ -284,10 +288,29 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
284
288
|
`const application = JSON.parse(${JSON.stringify(JSON.stringify(application))});`,
|
|
285
289
|
`const version = ${JSON.stringify(version)};`,
|
|
286
290
|
'let host;',
|
|
291
|
+
'let listener;',
|
|
287
292
|
'if (context.sharedHost === undefined) {',
|
|
288
293
|
' const services = [];',
|
|
289
294
|
' await createServices(function register(service) { services.push(service); });',
|
|
290
295
|
' host = startCoreHost({application, version, services});',
|
|
296
|
+
' if (context.coreListener !== undefined) {',
|
|
297
|
+
" process.on('SIGINT', closeCore);",
|
|
298
|
+
" process.on('SIGTERM', closeCore);",
|
|
299
|
+
' try {',
|
|
300
|
+
' listener = await startCoreListener({runtime: host.runtime, endpoint: context.coreListener?.endpoint});',
|
|
301
|
+
' listener.closed.catch(function listenerFailed() { process.exitCode = 1; });',
|
|
302
|
+
' } catch (error) {',
|
|
303
|
+
' try {',
|
|
304
|
+
' await host.close();',
|
|
305
|
+
' } catch (closeError) {',
|
|
306
|
+
" throw new AggregateError([error, closeError], 'Core listener startup and host drain failed.');",
|
|
307
|
+
' }',
|
|
308
|
+
' throw error;',
|
|
309
|
+
' } finally {',
|
|
310
|
+
" process.off('SIGINT', closeCore);",
|
|
311
|
+
" process.off('SIGTERM', closeCore);",
|
|
312
|
+
' }',
|
|
313
|
+
' }',
|
|
291
314
|
"} else if (process.argv.includes('--arcane-core-headless')) {",
|
|
292
315
|
' host = await runSharedCoreHost({',
|
|
293
316
|
' endpoint: context.sharedHost.endpoint, application, version,',
|
|
@@ -319,6 +342,12 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
319
342
|
" console.error('Arcane Core host failed:', error);",
|
|
320
343
|
' process.exitCode = 1;',
|
|
321
344
|
'} finally {',
|
|
345
|
+
' try {',
|
|
346
|
+
' await listener?.close();',
|
|
347
|
+
' } catch {',
|
|
348
|
+
' // The listener reports its complete failure through onError.',
|
|
349
|
+
' process.exitCode = 1;',
|
|
350
|
+
' }',
|
|
322
351
|
" process.off('SIGINT', closeCore);",
|
|
323
352
|
" process.off('SIGTERM', closeCore);",
|
|
324
353
|
'}',
|