@zhin.js/isolate 1.0.7 → 1.0.10

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 CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  下一代 Zhin Runtime 的可选 Node 隔离适配器。它为每个 `runtime: isolated` Plugin generation 创建独立 Worker 或 child process,并只通过 structured-clone RPC 与 Host 通信。
4
4
 
5
- > 当前包属于 `feature/next` 绿地实现,版本仍为 `0.0.0` 且未作为稳定 API 发布。
6
-
7
5
  ## 为什么独立成包
8
6
 
9
7
  `@zhin.js/runtime` 只定义 `IsolatedPluginRuntimePort`,不强制默认 IM 安装携带隔离实现。需要故障或并发隔离的 Root 显式安装本包;trusted Plugin 的启动路径和生产闭包不变。
@@ -71,13 +69,12 @@ Host 从对应 owner 的 Resource snapshot 取得 `isolatedPluginToken`,再调
71
69
 
72
70
  ## 代际切换
73
71
 
74
- 适配器参与 Runtime generation handoff:
72
+ 隔离进程参与 Runtime generation readiness:
75
73
 
76
74
  1. `prepare` 创建候选 isolate、import entry 并校验 definition,但不执行 setup。
77
- 2. `quiescePrevious` 停止旧 handle 接收新调用,并等待所有已接收 RPC 完成。
78
- 3. `activateNext` 在候选 isolate 内执行 setup;失败会清理候选并恢复旧 handle。
79
- 4. Runtime 原子提交 snapshot 后,`openNext` 才开放新 handle。
80
- 5. 旧 generation 的最后一个 lease 释放后,Scope disposer 终止旧 isolate。
75
+ 2. `activateNext(signal)` 在候选 isolate 内执行 setup;失败会清理候选,旧代从未被触碰。
76
+ 3. Runtime 原子提交 snapshot 后,新请求才能从新 snapshot 取得 handle。
77
+ 4. 旧 generation 的在途请求继续持有旧 lease;最后一个 lease 释放后,Scope disposer 终止旧 isolate。
81
78
 
82
79
  RPC 超时会把整个实例标记为 failed 并终止 transport,因为远端工作可能仍在运行,不能把超时伪装成安全 drain。Worker/进程意外退出会拒绝全部 pending RPC,并通过 `onCrash` 上报;当前 generation 内不静默重启。
83
80
 
@@ -1,5 +1,5 @@
1
1
  import { type Dispose, type PluginId } from '@zhin.js/plugin-runtime';
2
- export type IsolatedPluginStatus = 'prepared' | 'active' | 'quiesced' | 'failed' | 'closed';
2
+ export type IsolatedPluginStatus = 'prepared' | 'active' | 'failed' | 'closed';
3
3
  export interface IsolatedPluginEvent {
4
4
  readonly name: string;
5
5
  readonly payload: unknown;
package/lib/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginId } from '@zhin.js/plugin-runtime';
1
+ import { type PluginId } from '@zhin.js/plugin-runtime';
2
2
  import type { IsolatedPluginPrepareRequest, IsolatedPluginRuntimePort, PreparedIsolatedPlugin } from '@zhin.js/runtime';
3
3
  import { type IsolateMode } from './transport.js';
4
4
  export interface IsolatedHostCallContext {
@@ -15,5 +15,5 @@ export interface NodeIsolatedPluginRuntimeOptions {
15
15
  export declare class NodeIsolatedPluginRuntime implements IsolatedPluginRuntimePort {
16
16
  #private;
17
17
  constructor(options?: NodeIsolatedPluginRuntimeOptions);
18
- prepare(request: IsolatedPluginPrepareRequest): Promise<PreparedIsolatedPlugin>;
18
+ prepare(request: IsolatedPluginPrepareRequest, signal: AbortSignal): Promise<PreparedIsolatedPlugin>;
19
19
  }
package/lib/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { createGenerationAdmissionGate, generationAdmissionSource, } from '@zhin.js/plugin-runtime';
2
3
  import { isolatedPluginToken, } from './contracts.js';
3
4
  import { deserializeError, serializeError, } from './protocol.js';
4
5
  import { createTransport } from './transport.js';
@@ -8,43 +9,28 @@ export class NodeIsolatedPluginRuntime {
8
9
  #requestTimeoutMs;
9
10
  #hostMethods;
10
11
  #onCrash;
11
- #quiescedPrevious = new Map();
12
12
  constructor(options = {}) {
13
13
  this.#mode = options.mode ?? 'worker';
14
14
  this.#requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
15
15
  this.#hostMethods = Object.freeze({ ...options.hostMethods });
16
16
  this.#onCrash = options.onCrash;
17
17
  }
18
- async prepare(request) {
18
+ async prepare(request, signal) {
19
+ signal.throwIfAborted();
19
20
  assertCloneable(request.config, `Config for ${request.owner}`);
20
- const instance = new IsolatedInstance(request.owner, createTransport(this.#mode), this.#requestTimeoutMs, this.#hostMethods, this.#onCrash);
21
+ const admission = createGenerationAdmissionGate();
22
+ const instance = new IsolatedInstance(request.owner, createTransport(this.#mode), this.#requestTimeoutMs, this.#hostMethods, this.#onCrash, admission);
21
23
  try {
22
- const descriptor = await instance.request('prepare', {
24
+ const descriptor = await instance.requestAbortable('prepare', {
23
25
  ...request,
24
26
  entry: pathToFileURL(request.entry).href,
25
- });
27
+ }, signal);
26
28
  return {
27
29
  descriptor,
28
30
  resources: [{ token: isolatedPluginToken, value: instance }],
29
31
  handoff: {
30
- quiescePrevious: async (previous) => {
31
- const handle = previous.resources.get(request.owner)?.get(isolatedPluginToken.id);
32
- if (!(handle instanceof IsolatedInstance))
33
- return;
34
- await handle.quiesce();
35
- this.#quiescedPrevious.set(request.owner, handle);
36
- },
37
- activateNext: () => instance.activate(),
32
+ activateNext: (activationSignal) => instance.activate(activationSignal),
38
33
  deactivateNext: () => instance.deactivate(),
39
- resumePrevious: () => {
40
- const previous = this.#quiescedPrevious.get(request.owner);
41
- this.#quiescedPrevious.delete(request.owner);
42
- previous?.resume();
43
- },
44
- openNext: () => {
45
- instance.open();
46
- this.#quiescedPrevious.delete(request.owner);
47
- },
48
34
  },
49
35
  dispose: () => instance.close(),
50
36
  };
@@ -61,25 +47,28 @@ class IsolatedInstance {
61
47
  timeoutMs;
62
48
  hostMethods;
63
49
  onCrash;
50
+ admission;
51
+ [generationAdmissionSource];
64
52
  #status = 'prepared';
65
53
  #nextRequest = 1;
66
- #inFlight = 0;
67
- #drainWaiters = [];
68
54
  #closed = false;
69
55
  #pending = new Map();
70
56
  #listeners = new Set();
71
- constructor(owner, transport, timeoutMs, hostMethods, onCrash) {
57
+ constructor(owner, transport, timeoutMs, hostMethods, onCrash, admission = createGenerationAdmissionGate()) {
72
58
  this.owner = owner;
73
59
  this.transport = transport;
74
60
  this.timeoutMs = timeoutMs;
75
61
  this.hostMethods = hostMethods;
76
62
  this.onCrash = onCrash;
63
+ this.admission = admission;
64
+ this[generationAdmissionSource] = Object.freeze([admission]);
77
65
  transport.onMessage((message) => this.#onMessage(message));
78
66
  transport.onExit((error) => this.#fail(error));
79
67
  }
80
68
  get status() { return this.#status; }
81
- async activate() {
82
- await this.request('activate');
69
+ async activate(signal) {
70
+ await this.requestAbortable('activate', undefined, signal);
71
+ this.#status = 'active';
83
72
  }
84
73
  async deactivate() {
85
74
  if (this.#closed || this.#status === 'failed')
@@ -87,40 +76,14 @@ class IsolatedInstance {
87
76
  await this.request('deactivate');
88
77
  this.#status = 'prepared';
89
78
  }
90
- open() {
91
- if (this.#closed || this.#status === 'failed')
92
- throw new Error(`Cannot open ${this.owner}`);
93
- this.#status = 'active';
94
- }
95
- async quiesce() {
96
- if (this.#status !== 'active')
97
- return;
98
- this.#status = 'quiesced';
99
- if (this.#inFlight === 0)
100
- return;
101
- await new Promise((resolve) => this.#drainWaiters.push(resolve));
102
- }
103
- resume() {
104
- if (this.#status === 'quiesced')
105
- this.#status = 'active';
106
- }
107
79
  async call(method, input) {
108
80
  if (this.#status !== 'active') {
109
81
  throw new Error(`Isolated Plugin ${this.owner} is not accepting calls: ${this.#status}`);
110
82
  }
111
83
  assertMethod(method);
112
84
  assertCloneable(input, `Input for ${method}`);
113
- this.#inFlight += 1;
114
- try {
115
- return await this.request('call', { method, input });
116
- }
117
- finally {
118
- this.#inFlight -= 1;
119
- if (this.#inFlight === 0) {
120
- for (const resolve of this.#drainWaiters.splice(0))
121
- resolve();
122
- }
123
- }
85
+ return this.admission.enter(() => this.request('call', { method, input }))
86
+ ?? Promise.reject(new Error(`Isolated Plugin ${this.owner} generation is not admitted`));
124
87
  }
125
88
  onEvent(listener) {
126
89
  this.#listeners.add(listener);
@@ -148,6 +111,26 @@ class IsolatedInstance {
148
111
  this.transport.send({ type: 'request', id, method, payload });
149
112
  });
150
113
  }
114
+ async requestAbortable(method, payload, signal) {
115
+ signal.throwIfAborted();
116
+ let abort;
117
+ const aborted = new Promise((_resolve, reject) => {
118
+ abort = () => {
119
+ const reason = signal.reason instanceof Error
120
+ ? signal.reason
121
+ : new Error(`Isolated request aborted: ${method}`);
122
+ this.#fail(reason);
123
+ void this.transport.close().then(() => reject(reason), (closeError) => reject(new AggregateError([reason, closeError], `Isolated request cancellation failed: ${method}`)));
124
+ };
125
+ });
126
+ signal.addEventListener('abort', abort, { once: true });
127
+ try {
128
+ return await Promise.race([this.request(method, payload), aborted]);
129
+ }
130
+ finally {
131
+ signal.removeEventListener('abort', abort);
132
+ }
133
+ }
151
134
  async close() {
152
135
  if (this.#closed)
153
136
  return;
@@ -176,21 +159,29 @@ class IsolatedInstance {
176
159
  return;
177
160
  }
178
161
  if (message.type === 'event') {
179
- const event = Object.freeze({ name: message.name, payload: message.payload });
180
- for (const listener of this.#listeners)
181
- listener(event);
162
+ this.admission.enter(() => {
163
+ const event = Object.freeze({ name: message.name, payload: message.payload });
164
+ for (const listener of this.#listeners)
165
+ listener(event);
166
+ });
182
167
  return;
183
168
  }
184
169
  void this.#handleHostCall(message);
185
170
  }
186
171
  async #handleHostCall(message) {
187
- const method = this.hostMethods[message.method];
188
172
  try {
189
- if (!method)
190
- throw new Error(`Host RPC method is not allowed: ${message.method}`);
191
- const value = await method(message.input, { owner: this.owner });
192
- assertCloneable(value, `Host RPC result for ${message.method}`);
193
- this.transport.send({ type: 'host-result', id: message.id, ok: true, value });
173
+ const handled = this.admission.enter(async () => {
174
+ const method = this.hostMethods[message.method];
175
+ if (!method)
176
+ throw new Error(`Host RPC method is not allowed: ${message.method}`);
177
+ const value = await method(message.input, { owner: this.owner });
178
+ assertCloneable(value, `Host RPC result for ${message.method}`);
179
+ this.transport.send({ type: 'host-result', id: message.id, ok: true, value });
180
+ });
181
+ if (!handled) {
182
+ throw new Error(`Isolated Plugin ${this.owner} generation is not admitted`);
183
+ }
184
+ await handled;
194
185
  }
195
186
  catch (error) {
196
187
  this.transport.send({
@@ -206,8 +197,6 @@ class IsolatedInstance {
206
197
  return;
207
198
  this.#status = 'failed';
208
199
  this.#rejectPending(error);
209
- for (const resolve of this.#drainWaiters.splice(0))
210
- resolve();
211
200
  this.onCrash?.(this.owner, error);
212
201
  }
213
202
  #rejectPending(error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/isolate",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "description": "Worker and child-process isolation adapter for the Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -16,8 +16,8 @@
16
16
  "lib"
17
17
  ],
18
18
  "dependencies": {
19
- "@zhin.js/plugin-runtime": "1.1.3",
20
- "@zhin.js/runtime": "1.0.7"
19
+ "@zhin.js/plugin-runtime": "1.1.6",
20
+ "@zhin.js/runtime": "1.0.10"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^26.1.2",