@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d
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/dist/auth/manager.js +1 -1
- package/dist/filesystem/__tests__/search-cache.test.js +5 -5
- package/dist/filesystem/search-cache.js +1 -1
- package/dist/index.js +42 -0
- package/dist/index.test.js +40 -10
- package/dist/opencode-update/service.js +150 -0
- package/dist/opencode-update/service.test.js +107 -0
- package/dist/permissions/auto-accept-manager.js +165 -0
- package/dist/permissions/auto-accept-manager.test.js +208 -0
- package/dist/permissions/auto-accept-store.js +5 -2
- package/dist/permissions/opencode-yolo-metadata.js +81 -0
- package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
- package/dist/server/__tests__/remote-proxy.test.js +64 -56
- package/dist/server/http-server.js +12 -15
- package/dist/server/remote-proxy.js +68 -98
- package/dist/server/routes/opencode-update.js +37 -0
- package/dist/server/routes/opencode-update.test.js +34 -0
- package/dist/server/routes/remote-servers.js +1 -1
- package/dist/server/routes/workspaces.test.js +38 -0
- package/dist/server/routes/worktrees.js +19 -0
- package/dist/server/routes/yolo.js +2 -1
- package/dist/server/tls.js +2 -2
- package/dist/settings/binaries.test.js +2 -7
- package/dist/shutdown.js +67 -59
- package/dist/shutdown.test.js +115 -78
- package/dist/ui/remote-ui.js +2 -2
- package/dist/usage/providers/api-key.js +3 -3
- package/dist/usage/providers/google.js +1 -1
- package/dist/usage/providers/oauth.js +1 -36
- package/dist/usage/providers/special.js +3 -3
- package/dist/usage/service.js +1 -1
- package/dist/usage/service.test.js +5 -3
- package/dist/usage/shared.js +4 -4
- package/dist/usage/shared.test.js +4 -4
- package/dist/workspaces/__tests__/spawn.test.js +2 -2
- package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
- package/dist/workspaces/binary-path.test.js +10 -0
- package/dist/workspaces/instance-client.js +4 -3
- package/dist/workspaces/launch-cleanup.test.js +21 -25
- package/dist/workspaces/manager.js +300 -351
- package/dist/workspaces/manager.test.js +209 -237
- package/dist/workspaces/process-identity.darwin.test.js +88 -0
- package/dist/workspaces/process-identity.js +172 -149
- package/dist/workspaces/process-identity.test.js +86 -140
- package/dist/workspaces/runtime.js +261 -373
- package/dist/workspaces/runtime.test.js +222 -547
- package/package.json +1 -1
- package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
- package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
- package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
- package/public/assets/StatusTab-D3toOefp.js +1 -0
- package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
- package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
- package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
- package/public/assets/index-3dx-hhNQ.js +1 -0
- package/public/assets/index-BKwfZLzw.js +1 -0
- package/public/assets/index-C5IioEFT.js +1 -0
- package/public/assets/index-CZYc0VcU.js +2 -0
- package/public/assets/index-CdCoXYXP.js +1 -0
- package/public/assets/index-CwAVops2.js +1 -0
- package/public/assets/index-CzvCOwKc.js +1 -0
- package/public/assets/index-D-loOWPv.js +1 -0
- package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
- package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
- package/public/assets/index-L7fX3W8k.css +1 -0
- package/public/assets/index-w2KdHEME.js +1 -0
- package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
- package/public/assets/main-CaTCcck1.js +68 -0
- package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
- package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
- package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
- package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
- package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/dist/workspaces/launch-cleanup.js +0 -132
- package/public/assets/StatusTab-D_IE79r-.js +0 -1
- package/public/assets/index-B3YjNCh4.js +0 -1
- package/public/assets/index-B3ZD2_SU.js +0 -1
- package/public/assets/index-Bh2ofaQo.js +0 -1
- package/public/assets/index-CM0c9xRZ.js +0 -1
- package/public/assets/index-CdDxouJl.js +0 -2
- package/public/assets/index-D06Y2cFq.js +0 -1
- package/public/assets/index-PmE51vPh.js +0 -1
- package/public/assets/index-Qj63IiSj.css +0 -1
- package/public/assets/index-VY_zwmsG.js +0 -1
- package/public/assets/index-qiiH7egV.js +0 -1
- package/public/assets/main-CTN6Wwz1.js +0 -68
|
@@ -2,8 +2,8 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import pino from "pino";
|
|
4
4
|
import { EventBus } from "../events/bus";
|
|
5
|
-
import {
|
|
6
|
-
import { WorkspaceLaunchCancelledError,
|
|
5
|
+
import { WorkspaceWindowsTreeCleanupIncompleteError, } from "./runtime";
|
|
6
|
+
import { WorkspaceCleanupTimeoutError, WorkspaceLaunchCancelledError, WorkspaceLaunchTimeoutError, WorkspaceManager, WorkspaceShutdownError, } from "./manager";
|
|
7
7
|
function deferred() {
|
|
8
8
|
let resolve;
|
|
9
9
|
let reject;
|
|
@@ -17,302 +17,274 @@ class ControlledRuntime {
|
|
|
17
17
|
constructor() {
|
|
18
18
|
this.launchResult = deferred();
|
|
19
19
|
this.launchCalled = deferred();
|
|
20
|
-
this.cancellation = deferred();
|
|
21
20
|
this.active = new Set();
|
|
22
|
-
this.stopCalls =
|
|
23
|
-
this.launchCalls = 0;
|
|
21
|
+
this.stopCalls = 0;
|
|
24
22
|
this.failStops = 0;
|
|
25
23
|
this.launch = (options) => {
|
|
26
|
-
this.launchCalls += 1;
|
|
27
24
|
this.active.add(options.workspaceId);
|
|
25
|
+
this.onExit = options.onExit;
|
|
28
26
|
this.launchCalled.resolve(options.workspaceId);
|
|
27
|
+
options.signal?.addEventListener("abort", () => this.launchResult.reject(options.signal?.reason), { once: true });
|
|
29
28
|
return this.launchResult.promise;
|
|
30
29
|
};
|
|
31
30
|
this.stop = async (workspaceId) => {
|
|
32
|
-
this.stopCalls
|
|
33
|
-
this.
|
|
34
|
-
if (this.failStops > 0) {
|
|
35
|
-
this.failStops -= 1;
|
|
31
|
+
this.stopCalls += 1;
|
|
32
|
+
if (this.failStops-- > 0)
|
|
36
33
|
throw new Error("controlled stop failure");
|
|
37
|
-
}
|
|
38
34
|
this.active.delete(workspaceId);
|
|
39
35
|
};
|
|
40
36
|
}
|
|
41
|
-
resolveLaunch(
|
|
37
|
+
resolveLaunch() {
|
|
42
38
|
this.launchResult.resolve({
|
|
43
39
|
pid: 1234,
|
|
44
40
|
port: 4321,
|
|
45
41
|
exitPromise: new Promise(() => undefined),
|
|
46
|
-
cancellationPromise: this.cancellation.promise,
|
|
47
42
|
getLastOutput: () => "",
|
|
48
43
|
});
|
|
49
44
|
}
|
|
50
45
|
}
|
|
51
|
-
function createHarness(
|
|
46
|
+
function createHarness(options = {}) {
|
|
52
47
|
const eventBus = new EventBus();
|
|
53
48
|
const runtime = new ControlledRuntime();
|
|
54
49
|
const readiness = deferred();
|
|
55
50
|
const started = [];
|
|
56
|
-
|
|
57
|
-
eventBus.on("workspace.created", (event) => {
|
|
58
|
-
createdId = event.workspace.id;
|
|
59
|
-
});
|
|
51
|
+
const stopped = [];
|
|
60
52
|
eventBus.on("workspace.started", (event) => started.push(event.workspace.id));
|
|
53
|
+
eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId));
|
|
61
54
|
const manager = new WorkspaceManager({
|
|
62
55
|
rootDir: process.cwd(),
|
|
63
56
|
settings: { getOwner: () => ({}) },
|
|
64
|
-
binaryResolver: {
|
|
65
|
-
resolve: () => ({ path: "test-opencode", label: "test-opencode" }),
|
|
66
|
-
},
|
|
57
|
+
binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) },
|
|
67
58
|
eventBus,
|
|
68
59
|
logger: pino({ level: "silent" }),
|
|
69
60
|
getServerBaseUrl: () => "http://127.0.0.1:4000",
|
|
70
61
|
runtime,
|
|
71
|
-
...
|
|
62
|
+
...options,
|
|
72
63
|
});
|
|
73
|
-
manager.waitForWorkspaceReadiness = () =>
|
|
74
|
-
|
|
64
|
+
manager.waitForWorkspaceReadiness = ({ signal }) => Promise.race([
|
|
65
|
+
readiness.promise,
|
|
66
|
+
new Promise((_resolve, reject) => {
|
|
67
|
+
const cancel = () => reject(signal?.reason);
|
|
68
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
69
|
+
if (signal?.aborted)
|
|
70
|
+
cancel();
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
return { manager, runtime, readiness, started, stopped };
|
|
75
74
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assert.strictEqual(firstDelete, secondDelete);
|
|
104
|
-
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
105
|
-
harness.runtime.resolveLaunch(workspaceId);
|
|
106
|
-
await assert.rejects(creation, WorkspaceLaunchCancelledError);
|
|
107
|
-
await Promise.all([firstDelete, secondDelete]);
|
|
108
|
-
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
109
|
-
assert.deepEqual(harness.started, []);
|
|
110
|
-
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
111
|
-
assert.deepEqual(harness.manager.list(), []);
|
|
112
|
-
});
|
|
113
|
-
it("does not publish or retain a workspace when mandatory identity launch fails", async () => {
|
|
114
|
-
const events = [];
|
|
115
|
-
const eventBus = new EventBus();
|
|
116
|
-
eventBus.on("workspace.created", () => events.push("created"));
|
|
117
|
-
eventBus.on("workspace.started", () => events.push("started"));
|
|
118
|
-
eventBus.on("workspace.error", () => events.push("error"));
|
|
119
|
-
const runtime = {
|
|
120
|
-
launch: ((options) => Promise.reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable"))),
|
|
121
|
-
stop: (() => Promise.resolve()),
|
|
122
|
-
};
|
|
123
|
-
const manager = new WorkspaceManager({
|
|
124
|
-
rootDir: process.cwd(),
|
|
125
|
-
settings: { getOwner: () => ({}) },
|
|
126
|
-
binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) },
|
|
127
|
-
eventBus,
|
|
128
|
-
logger: pino({ level: "silent" }),
|
|
129
|
-
getServerBaseUrl: () => "http://127.0.0.1:4000",
|
|
130
|
-
runtime,
|
|
131
|
-
});
|
|
132
|
-
await assert.rejects(manager.create(process.cwd()), WorkspaceRuntimeIdentityCaptureError);
|
|
133
|
-
assert.deepEqual(events, []);
|
|
134
|
-
assert.deepEqual(manager.list(), []);
|
|
135
|
-
});
|
|
136
|
-
it("retains an unpublished cleanup record when identity-failure cleanup cannot be proven", async () => {
|
|
137
|
-
let workspaceId = "";
|
|
138
|
-
let stopAttempts = 0;
|
|
139
|
-
const runtime = {
|
|
140
|
-
launch: ((options) => {
|
|
141
|
-
workspaceId = options.workspaceId;
|
|
142
|
-
return Promise.reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable"));
|
|
143
|
-
}),
|
|
144
|
-
stop: (async () => {
|
|
145
|
-
stopAttempts += 1;
|
|
146
|
-
if (stopAttempts === 1)
|
|
147
|
-
throw new Error("cleanup proof unavailable");
|
|
148
|
-
}),
|
|
149
|
-
};
|
|
150
|
-
const manager = new WorkspaceManager({
|
|
151
|
-
rootDir: process.cwd(),
|
|
152
|
-
settings: { getOwner: () => ({}) },
|
|
153
|
-
binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) },
|
|
154
|
-
eventBus: new EventBus(),
|
|
155
|
-
logger: pino({ level: "silent" }),
|
|
156
|
-
getServerBaseUrl: () => "http://127.0.0.1:4000",
|
|
157
|
-
runtime,
|
|
75
|
+
async function createReady(harness) {
|
|
76
|
+
const creation = harness.manager.create(process.cwd());
|
|
77
|
+
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
78
|
+
harness.runtime.resolveLaunch();
|
|
79
|
+
harness.readiness.resolve(undefined);
|
|
80
|
+
await creation;
|
|
81
|
+
return workspaceId;
|
|
82
|
+
}
|
|
83
|
+
describe("workspace manager lifecycle", () => {
|
|
84
|
+
for (const boundary of ["launch", "readiness", "shutdown"]) {
|
|
85
|
+
it(`cancels and cleans a workspace during ${boundary}`, async () => {
|
|
86
|
+
const harness = createHarness();
|
|
87
|
+
const creation = harness.manager.create(process.cwd());
|
|
88
|
+
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
89
|
+
let cleanup;
|
|
90
|
+
if (boundary === "readiness") {
|
|
91
|
+
harness.runtime.resolveLaunch();
|
|
92
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
93
|
+
cleanup = harness.manager.delete(workspaceId);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
cleanup = boundary === "shutdown" ? harness.manager.shutdown() : harness.manager.delete(workspaceId);
|
|
97
|
+
harness.runtime.resolveLaunch();
|
|
98
|
+
}
|
|
99
|
+
await assert.rejects(creation, WorkspaceLaunchCancelledError);
|
|
100
|
+
await cleanup;
|
|
101
|
+
assert.deepEqual([harness.runtime.active.size, harness.started, harness.manager.list(), harness.stopped], [0, [], [], boundary === "readiness" ? [workspaceId] : []]);
|
|
158
102
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
assert.equal(manager.get(workspaceId), undefined);
|
|
162
|
-
await manager.delete(workspaceId);
|
|
163
|
-
assert.equal(stopAttempts, 3);
|
|
164
|
-
});
|
|
165
|
-
it("actively cancels pending readiness without publishing workspace.started", async () => {
|
|
103
|
+
}
|
|
104
|
+
it("shares failed cleanup and allows a later delete retry", async () => {
|
|
166
105
|
const harness = createHarness();
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
harness.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await
|
|
173
|
-
|
|
106
|
+
const workspaceId = await createReady(harness);
|
|
107
|
+
harness.runtime.failStops = 2;
|
|
108
|
+
const first = harness.manager.delete(workspaceId);
|
|
109
|
+
const concurrent = harness.manager.delete(workspaceId);
|
|
110
|
+
assert.strictEqual(first, concurrent);
|
|
111
|
+
const failures = await Promise.allSettled([first, concurrent]);
|
|
112
|
+
assert.deepEqual(failures.map((result) => result.status), ["rejected", "rejected"]);
|
|
113
|
+
assert.equal(harness.runtime.active.has(workspaceId), true);
|
|
114
|
+
await harness.manager.delete(workspaceId);
|
|
174
115
|
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
175
|
-
assert.deepEqual(harness.started, []);
|
|
176
116
|
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
177
117
|
});
|
|
178
|
-
it("
|
|
118
|
+
it("retries cancellation deletion for an already-cancelled request", async () => {
|
|
179
119
|
const harness = createHarness();
|
|
180
|
-
const creation = harness.manager.create(process.cwd());
|
|
120
|
+
const creation = harness.manager.create(process.cwd(), undefined, { requestId: "retry-cancel" });
|
|
181
121
|
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
182
|
-
|
|
183
|
-
harness.
|
|
184
|
-
await
|
|
185
|
-
|
|
186
|
-
assert.
|
|
187
|
-
assert.
|
|
188
|
-
assert.deepEqual(harness.manager.list(), []);
|
|
189
|
-
});
|
|
190
|
-
it("still starts and deletes an ordinary ready workspace", async () => {
|
|
191
|
-
const harness = createHarness();
|
|
192
|
-
const creation = harness.manager.create(process.cwd(), "ordinary");
|
|
193
|
-
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
194
|
-
harness.runtime.resolveLaunch(workspaceId);
|
|
195
|
-
harness.readiness.resolve("2.0.0");
|
|
196
|
-
const { workspace } = await creation;
|
|
197
|
-
assert.equal(workspace.id, harness.getCreatedId());
|
|
198
|
-
assert.equal(workspace.status, "ready");
|
|
199
|
-
assert.equal(workspace.binaryVersion, "2.0.0");
|
|
200
|
-
assert.deepEqual(harness.started, [workspaceId]);
|
|
122
|
+
harness.runtime.resolveLaunch();
|
|
123
|
+
harness.readiness.resolve(undefined);
|
|
124
|
+
await creation;
|
|
125
|
+
harness.runtime.failStops = 2;
|
|
126
|
+
await assert.rejects(harness.manager.cancelCreationRequest("retry-cancel"), /controlled stop failure/);
|
|
127
|
+
assert.equal(harness.manager.get(workspaceId)?.id, workspaceId);
|
|
201
128
|
assert.equal(harness.runtime.active.has(workspaceId), true);
|
|
202
|
-
await harness.manager.
|
|
203
|
-
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
129
|
+
await harness.manager.cancelCreationRequest("retry-cancel");
|
|
204
130
|
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
131
|
+
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
132
|
+
assert.deepEqual(harness.stopped, [workspaceId]);
|
|
133
|
+
});
|
|
134
|
+
it("gives release and cancellation one terminal winner", async () => {
|
|
135
|
+
const cancelled = createHarness();
|
|
136
|
+
const cancelledCreation = cancelled.manager.create(process.cwd(), undefined, { requestId: "cancel-wins" });
|
|
137
|
+
const cancelledId = await cancelled.runtime.launchCalled.promise;
|
|
138
|
+
cancelled.runtime.resolveLaunch();
|
|
139
|
+
cancelled.readiness.resolve(undefined);
|
|
140
|
+
await cancelledCreation;
|
|
141
|
+
const stopStarted = deferred();
|
|
142
|
+
const finishStop = deferred();
|
|
143
|
+
const originalStop = cancelled.runtime.stop;
|
|
144
|
+
cancelled.runtime.stop = async (workspaceId) => {
|
|
145
|
+
stopStarted.resolve();
|
|
146
|
+
await finishStop.promise;
|
|
147
|
+
await originalStop(workspaceId);
|
|
148
|
+
};
|
|
149
|
+
const cancellation = cancelled.manager.cancelCreationRequest("cancel-wins");
|
|
150
|
+
await stopStarted.promise;
|
|
151
|
+
assert.equal(cancelled.manager.releaseCreationRequest(cancelledId, "cancel-wins"), false);
|
|
152
|
+
assert.equal(cancelled.manager.get(cancelledId)?.id, cancelledId);
|
|
153
|
+
finishStop.resolve();
|
|
154
|
+
await cancellation;
|
|
155
|
+
assert.equal(cancelled.manager.get(cancelledId), undefined);
|
|
156
|
+
const released = createHarness();
|
|
157
|
+
const releasedCreation = released.manager.create(process.cwd(), undefined, { requestId: "release-wins" });
|
|
158
|
+
const releasedId = await released.runtime.launchCalled.promise;
|
|
159
|
+
released.runtime.resolveLaunch();
|
|
160
|
+
released.readiness.resolve(undefined);
|
|
161
|
+
await releasedCreation;
|
|
162
|
+
assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true);
|
|
163
|
+
await released.manager.cancelCreationRequest("release-wins");
|
|
164
|
+
assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true);
|
|
165
|
+
assert.equal(released.manager.get(releasedId)?.id, releasedId);
|
|
166
|
+
assert.equal(released.runtime.active.has(releasedId), true);
|
|
205
167
|
});
|
|
206
|
-
it("
|
|
168
|
+
it("retains unresolved pre-creation cancellation until its delayed create", async () => {
|
|
207
169
|
const harness = createHarness();
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
harness.
|
|
213
|
-
|
|
214
|
-
harness.readiness.reject(new Error("readiness failed"));
|
|
215
|
-
await assert.rejects(creation, /readiness failed/);
|
|
216
|
-
assert.deepEqual(stopped, [workspaceId]);
|
|
217
|
-
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
218
|
-
assert.deepEqual(harness.manager.list(), []);
|
|
170
|
+
const requestIds = Array.from({ length: 1_025 }, (_, index) => `pending-cancel-${index}`);
|
|
171
|
+
await Promise.all(requestIds.map((requestId) => harness.manager.cancelCreationRequest(requestId)));
|
|
172
|
+
assert.equal(harness.manager.cancelledCreationRequests.size, requestIds.length);
|
|
173
|
+
await assert.rejects(harness.manager.create(process.cwd(), undefined, { requestId: requestIds[0] }), /was cancelled/);
|
|
174
|
+
assert.equal(harness.manager.cancelledCreationRequests.has(requestIds[0]), false);
|
|
175
|
+
assert.equal(harness.manager.cancelledCreationRequests.size, requestIds.length - 1);
|
|
219
176
|
});
|
|
220
|
-
it("
|
|
177
|
+
it("returns scoped correlation while an ordinary shared launch remains retained", async () => {
|
|
221
178
|
const harness = createHarness();
|
|
222
|
-
const
|
|
179
|
+
const ordinary = harness.manager.create(process.cwd());
|
|
223
180
|
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
224
|
-
harness.
|
|
181
|
+
const scoped = harness.manager.create(process.cwd(), undefined, { requestId: "restore-shared" });
|
|
182
|
+
harness.runtime.resolveLaunch();
|
|
225
183
|
harness.readiness.resolve(undefined);
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
assert.
|
|
231
|
-
|
|
232
|
-
assert.equal(
|
|
233
|
-
assert.equal(
|
|
234
|
-
if (failedDeletes[0].status === "rejected" && failedDeletes[1].status === "rejected") {
|
|
235
|
-
assert.strictEqual(failedDeletes[0].reason, failedDeletes[1].reason);
|
|
236
|
-
assert.match(String(failedDeletes[0].reason), /controlled stop failure/);
|
|
237
|
-
}
|
|
238
|
-
assert.equal(harness.manager.get(workspaceId)?.status, "ready");
|
|
184
|
+
const [ordinaryResult, scopedResult] = await Promise.all([ordinary, scoped]);
|
|
185
|
+
assert.equal(ordinaryResult.created, true);
|
|
186
|
+
assert.equal(ordinaryResult.workspace.requestId, undefined);
|
|
187
|
+
assert.equal(scopedResult.created, false);
|
|
188
|
+
assert.equal(scopedResult.workspace.id, workspaceId);
|
|
189
|
+
assert.equal(scopedResult.workspace.requestId, "restore-shared");
|
|
190
|
+
assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-shared"), true);
|
|
191
|
+
assert.equal(harness.manager.get(workspaceId)?.id, workspaceId);
|
|
239
192
|
assert.equal(harness.runtime.active.has(workspaceId), true);
|
|
240
|
-
const
|
|
241
|
-
assert.
|
|
242
|
-
await
|
|
243
|
-
assert.equal(harness.
|
|
244
|
-
assert.equal(harness.
|
|
193
|
+
const reused = await harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" });
|
|
194
|
+
assert.equal(reused.workspace.requestId, "restore-reused");
|
|
195
|
+
await harness.manager.cancelCreationRequest("restore-reused");
|
|
196
|
+
assert.equal(harness.manager.get(workspaceId)?.id, workspaceId);
|
|
197
|
+
assert.equal(harness.runtime.active.has(workspaceId), true);
|
|
198
|
+
await assert.rejects(harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }), /was cancelled/);
|
|
199
|
+
assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-reused"), false);
|
|
245
200
|
});
|
|
246
|
-
|
|
201
|
+
for (const boundary of ["runtime launch", "health readiness"]) {
|
|
202
|
+
it(`applies one shared end-to-end deadline during ${boundary} and cleans up`, async () => {
|
|
203
|
+
const deadlines = [];
|
|
204
|
+
const harness = createHarness({
|
|
205
|
+
launchTimeoutMs: 25,
|
|
206
|
+
setTimeout: ((callback) => {
|
|
207
|
+
const timer = { active: true };
|
|
208
|
+
deadlines.push(() => { if (timer.active)
|
|
209
|
+
callback(); });
|
|
210
|
+
return timer;
|
|
211
|
+
}),
|
|
212
|
+
clearTimeout: ((timer) => { timer.active = false; }),
|
|
213
|
+
});
|
|
214
|
+
const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" });
|
|
215
|
+
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
216
|
+
const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" });
|
|
217
|
+
while ([...harness.manager.pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) {
|
|
218
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
219
|
+
}
|
|
220
|
+
if (boundary === "health readiness") {
|
|
221
|
+
harness.runtime.resolveLaunch();
|
|
222
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
223
|
+
}
|
|
224
|
+
for (const fire of deadlines)
|
|
225
|
+
fire();
|
|
226
|
+
const outcomes = await Promise.allSettled([first, shared]);
|
|
227
|
+
assert.deepEqual(outcomes.map((outcome) => outcome.status), ["rejected", "rejected"]);
|
|
228
|
+
assert.ok(outcomes.every((outcome) => outcome.status === "rejected" && outcome.reason instanceof WorkspaceLaunchTimeoutError));
|
|
229
|
+
assert.strictEqual(outcomes[0].reason, outcomes[1].reason);
|
|
230
|
+
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
231
|
+
assert.equal(harness.runtime.stopCalls >= 1, true);
|
|
232
|
+
assert.deepEqual(harness.manager.list(), []);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
it("bounds shutdown instead of waiting forever", async () => {
|
|
247
236
|
let fireDeadline;
|
|
248
|
-
|
|
237
|
+
let cleared = 0;
|
|
249
238
|
const harness = createHarness({
|
|
250
239
|
shutdownTimeoutMs: 25,
|
|
251
|
-
setTimeout: (callback) => {
|
|
240
|
+
setTimeout: ((callback) => {
|
|
252
241
|
fireDeadline = callback;
|
|
253
|
-
return
|
|
254
|
-
},
|
|
255
|
-
clearTimeout: () =>
|
|
242
|
+
return {};
|
|
243
|
+
}),
|
|
244
|
+
clearTimeout: () => { cleared += 1; },
|
|
256
245
|
});
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
harness.runtime.resolveLaunch(workspaceId);
|
|
260
|
-
harness.readiness.resolve(undefined);
|
|
261
|
-
await creation;
|
|
246
|
+
const workspaceId = await createReady(harness);
|
|
247
|
+
cleared = 0;
|
|
262
248
|
harness.runtime.stop = () => new Promise(() => undefined);
|
|
263
249
|
const shutdown = harness.manager.shutdown();
|
|
264
|
-
assert.ok(fireDeadline);
|
|
265
250
|
fireDeadline();
|
|
266
|
-
await assert.rejects(shutdown,
|
|
267
|
-
assert.equal(harness.manager.get(workspaceId)?.status, "ready");
|
|
268
|
-
});
|
|
269
|
-
it("rejects failed shutdown cleanup and allows a later delete retry", async () => {
|
|
270
|
-
const harness = createHarness();
|
|
271
|
-
const creation = harness.manager.create(process.cwd());
|
|
272
|
-
const workspaceId = await harness.runtime.launchCalled.promise;
|
|
273
|
-
harness.runtime.resolveLaunch(workspaceId);
|
|
274
|
-
harness.readiness.resolve(undefined);
|
|
275
|
-
await creation;
|
|
276
|
-
harness.runtime.failStops = 1;
|
|
277
|
-
await assert.rejects(harness.manager.shutdown(), WorkspaceShutdownError);
|
|
251
|
+
await assert.rejects(shutdown, WorkspaceCleanupTimeoutError);
|
|
278
252
|
assert.equal(harness.manager.get(workspaceId)?.status, "ready");
|
|
279
|
-
assert.equal(
|
|
280
|
-
await harness.manager.delete(workspaceId);
|
|
281
|
-
assert.equal(harness.manager.get(workspaceId), undefined);
|
|
282
|
-
assert.equal(harness.runtime.active.has(workspaceId), false);
|
|
253
|
+
assert.equal(cleared, 1);
|
|
283
254
|
});
|
|
284
|
-
it("
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
assert.
|
|
255
|
+
it("publishes stopped exactly once for normal exit, readiness failure, and manager cleanup", async () => {
|
|
256
|
+
const normal = createHarness();
|
|
257
|
+
const normalId = await createReady(normal);
|
|
258
|
+
normal.runtime.onExit?.({ workspaceId: normalId, code: 0, signal: null, requested: false });
|
|
259
|
+
await normal.manager.delete(normalId);
|
|
260
|
+
assert.deepEqual(normal.stopped, [normalId]);
|
|
261
|
+
const failed = createHarness();
|
|
262
|
+
const failedCreation = failed.manager.create(process.cwd());
|
|
263
|
+
const failedId = await failed.runtime.launchCalled.promise;
|
|
264
|
+
failed.runtime.resolveLaunch();
|
|
265
|
+
failed.readiness.reject(new Error("not ready"));
|
|
266
|
+
await assert.rejects(failedCreation, /not ready/);
|
|
267
|
+
assert.deepEqual(failed.stopped, [failedId]);
|
|
268
|
+
const cleaned = createHarness();
|
|
269
|
+
const cleanedId = await createReady(cleaned);
|
|
270
|
+
await cleaned.manager.shutdown();
|
|
271
|
+
assert.deepEqual(cleaned.stopped, [cleanedId]);
|
|
301
272
|
});
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
273
|
+
for (const [name, failure] of [
|
|
274
|
+
["cleanup failures", new Error("stop failed")],
|
|
275
|
+
["incomplete Windows tree cleanup", new WorkspaceWindowsTreeCleanupIncompleteError("workspace", 4242, ["taskkill failed"])],
|
|
276
|
+
]) {
|
|
277
|
+
it(`aggregates ${name} during shutdown`, async () => {
|
|
278
|
+
const harness = createHarness();
|
|
279
|
+
const workspaceId = await createReady(harness);
|
|
280
|
+
harness.runtime.stop = async () => { throw failure; };
|
|
281
|
+
await assert.rejects(harness.manager.shutdown(), (error) => {
|
|
282
|
+
assert.ok(error instanceof WorkspaceShutdownError);
|
|
283
|
+
assert.strictEqual(error.errors[0], failure);
|
|
284
|
+
return true;
|
|
285
|
+
});
|
|
286
|
+
assert.equal(harness.manager.get(workspaceId)?.status, "ready");
|
|
287
|
+
assert.equal(harness.runtime.active.has(workspaceId), true);
|
|
312
288
|
});
|
|
313
|
-
|
|
314
|
-
assert.ok(fireDeadline);
|
|
315
|
-
fireDeadline();
|
|
316
|
-
await assert.rejects(deletion, WorkspaceLaunchSettlementTimeoutError);
|
|
317
|
-
});
|
|
289
|
+
}
|
|
318
290
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { it } from "node:test";
|
|
6
|
+
import { LAUNCH_CLEANUP_TOKEN_ENV, probePosixProcesses, signalOwnedPosixProcessGroup, signalPosixProcesses, } from "./process-identity";
|
|
7
|
+
const darwinOnly = { skip: process.platform !== "darwin", timeout: 10_000 };
|
|
8
|
+
async function spawnDetachedGroup(cleanupToken) {
|
|
9
|
+
const leader = spawn(process.execPath, ["-e", `
|
|
10
|
+
const { spawn } = require("node:child_process")
|
|
11
|
+
spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" })
|
|
12
|
+
process.stdout.write("ready\\n")
|
|
13
|
+
setInterval(() => {}, 1000)
|
|
14
|
+
`], {
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
env: { ...process.env, ...(cleanupToken ? { [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } : {}) },
|
|
18
|
+
});
|
|
19
|
+
assert.ok(leader.pid);
|
|
20
|
+
await once(leader.stdout, "data");
|
|
21
|
+
return leader;
|
|
22
|
+
}
|
|
23
|
+
async function assertGroupGone(groupId) {
|
|
24
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
25
|
+
const remaining = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId });
|
|
26
|
+
if (remaining.ok && remaining.processes.size === 0)
|
|
27
|
+
return;
|
|
28
|
+
await delay(50);
|
|
29
|
+
}
|
|
30
|
+
assert.fail("owned Darwin process group remained alive after signaling");
|
|
31
|
+
}
|
|
32
|
+
it("uses real Darwin ps identities to stop an owned detached process group", darwinOnly, async () => {
|
|
33
|
+
const leader = await spawnDetachedGroup();
|
|
34
|
+
try {
|
|
35
|
+
const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", {
|
|
36
|
+
pids: [leader.pid],
|
|
37
|
+
groupId: leader.pid,
|
|
38
|
+
});
|
|
39
|
+
assert.equal(snapshot.ok, true);
|
|
40
|
+
assert.equal(snapshot.ok && snapshot.processes.get(leader.pid)?.groupId, leader.pid);
|
|
41
|
+
assert.equal(snapshot.ok && snapshot.processes.size >= 2, true);
|
|
42
|
+
const signaled = signalOwnedPosixProcessGroup(spawnSync, leader.pid, "SIGTERM", 1_000);
|
|
43
|
+
assert.equal(signaled.ok && signaled.matched, true);
|
|
44
|
+
assert.equal(signaled.ok && signaled.signalSent, true);
|
|
45
|
+
await assertGroupGone(leader.pid);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-leader.pid, "SIGKILL");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// The successful path has already removed the process group.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
it("uses a retained real Darwin identity anchor after the group leader exits", darwinOnly, async () => {
|
|
57
|
+
const cleanupToken = "darwin-integration-cleanup-token";
|
|
58
|
+
const leader = await spawnDetachedGroup(cleanupToken);
|
|
59
|
+
try {
|
|
60
|
+
const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId: leader.pid });
|
|
61
|
+
assert.equal(snapshot.ok, true);
|
|
62
|
+
const leaderIdentity = snapshot.ok ? snapshot.processes.get(leader.pid) : undefined;
|
|
63
|
+
assert.ok(leaderIdentity);
|
|
64
|
+
assert.equal(snapshot.ok && snapshot.processes.size >= 2, true);
|
|
65
|
+
leader.kill("SIGTERM");
|
|
66
|
+
if (leader.exitCode === null)
|
|
67
|
+
await once(leader, "exit");
|
|
68
|
+
const signaled = signalPosixProcesses(spawnSync, {
|
|
69
|
+
leader: leaderIdentity,
|
|
70
|
+
groupId: leader.pid,
|
|
71
|
+
members: [],
|
|
72
|
+
signal: "SIGTERM",
|
|
73
|
+
allowLeaderlessGroup: true,
|
|
74
|
+
cleanupToken,
|
|
75
|
+
}, 1_000, "darwin");
|
|
76
|
+
assert.equal(signaled.ok && signaled.matched, true);
|
|
77
|
+
assert.equal(signaled.ok && signaled.signalSent, true);
|
|
78
|
+
await assertGroupGone(leader.pid);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(-leader.pid, "SIGKILL");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// The successful path has already removed the process group.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|