@exagone313/dsh-podman 0.2.0-rc.3 → 0.2.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/README.md +14 -2
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +154 -42
- package/dist/card-route.test.js +85 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +92 -50
- package/dist/client/card-protocol.d.ts +6 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +16 -18
- package/dist/client/container-card-controller.js +92 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13141 -2036
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +106 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.d.ts +1 -0
- package/dist/dsh-version.js +46 -0
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +11 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +38 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/version-compat.d.ts +4 -0
- package/dist/version-compat.js +29 -0
- package/dist/version-compat.test.d.ts +1 -0
- package/dist/version-compat.test.js +27 -0
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +19 -10
- package/dist/workspace-binding.js +252 -78
- package/dist/workspace-binding.test.js +202 -27
- package/docs/architecture.md +71 -20
- package/docs/architecture.zh.md +62 -12
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +184 -29
- package/docs/development.zh.md +165 -26
- package/docs/install-dsh-and-dsh-podman.md +24 -21
- package/docs/install-dsh-and-dsh-podman.zh.md +20 -21
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
package/dist/spill-store.test.js
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { createSpillStore } from "./spill-store.js";
|
|
6
|
+
import { createSpillStore, sessionSegment } from "./spill-store.js";
|
|
7
7
|
import { guestFileRecorder } from "./test-support.js";
|
|
8
8
|
const SESSION_ID = "2c573001-4171-4900-904b-12a5cc02737a";
|
|
9
|
+
const SEGMENT = sessionSegment(SESSION_ID);
|
|
9
10
|
function fakeCtx(sessions) {
|
|
10
11
|
return {
|
|
11
|
-
get: (name) => name === "sessions"
|
|
12
|
-
? { get: (id) => sessions[id] }
|
|
13
|
-
: undefined,
|
|
12
|
+
get: (name) => name === "sessions" ? { get: (id) => sessions[id] } : undefined,
|
|
14
13
|
};
|
|
15
14
|
}
|
|
16
15
|
function withSession() {
|
|
@@ -22,13 +21,18 @@ test("saveText writes a tool-result spill inside the session container", async (
|
|
|
22
21
|
const content = "x".repeat(60000);
|
|
23
22
|
const ref = await store.saveText({
|
|
24
23
|
owner: { sessionId: SESSION_ID },
|
|
25
|
-
source: {
|
|
24
|
+
source: {
|
|
25
|
+
kind: "tool",
|
|
26
|
+
toolName: "web_fetch",
|
|
27
|
+
callId: "c1",
|
|
28
|
+
label: "result",
|
|
29
|
+
},
|
|
26
30
|
suggestedName: "web_fetch.txt",
|
|
27
31
|
content,
|
|
28
32
|
});
|
|
29
|
-
assert.equal(mkdirs[0], `/tmp/dsh-podman/spill/${
|
|
33
|
+
assert.equal(mkdirs[0], `/tmp/dsh-podman/spill/${SEGMENT}`);
|
|
30
34
|
assert.equal(writes.length, 1);
|
|
31
|
-
assert.match(writes[0].path, new RegExp(`^/tmp/dsh-podman/spill/${
|
|
35
|
+
assert.match(writes[0].path, new RegExp(`^/tmp/dsh-podman/spill/${SEGMENT}/[0-9a-f]{32}-web_fetch\\.txt$`));
|
|
32
36
|
assert.equal(ref.locator, writes[0].path);
|
|
33
37
|
assert.equal(ref.bytes, content.length);
|
|
34
38
|
assert.equal(writes[0].content, content, "the full text is persisted verbatim");
|
|
@@ -43,10 +47,29 @@ test("saveText never lets the suggested name escape the spill directory", async
|
|
|
43
47
|
suggestedName: "../../etc/passwd",
|
|
44
48
|
content: "x",
|
|
45
49
|
});
|
|
46
|
-
assert.ok(ref.locator.startsWith(`/tmp/dsh-podman/spill/${
|
|
50
|
+
assert.ok(ref.locator.startsWith(`/tmp/dsh-podman/spill/${SEGMENT}/`));
|
|
47
51
|
assert.ok(!ref.locator.includes(".."));
|
|
48
52
|
assert.equal(writes.length, 1);
|
|
49
53
|
});
|
|
54
|
+
test("sessionSegment neutralizes path-shaped session ids", () => {
|
|
55
|
+
// A session id is not necessarily a UUID, so it must never survive as a path
|
|
56
|
+
// segment that can traverse or collide.
|
|
57
|
+
for (const id of ["..", ".", "../../etc", "a/b", "session-1", "sess..ion", ""]) {
|
|
58
|
+
const segment = sessionSegment(id);
|
|
59
|
+
assert.notEqual(segment, "");
|
|
60
|
+
assert.notEqual(segment, ".");
|
|
61
|
+
assert.notEqual(segment, "..");
|
|
62
|
+
assert.doesNotMatch(segment, /[^A-Za-z0-9_-]/);
|
|
63
|
+
assert.ok(!segment.includes(".."), `${JSON.stringify(id)} -> ${segment}`);
|
|
64
|
+
}
|
|
65
|
+
// Distinct ids never share a directory, even when their readable prefixes do.
|
|
66
|
+
assert.notEqual(sessionSegment("session-1"), sessionSegment("session_1"));
|
|
67
|
+
// The readable prefix is kept for debugging.
|
|
68
|
+
assert.match(sessionSegment("session-42"), /^session-42-/);
|
|
69
|
+
// A UUID-shaped id stays recognizable.
|
|
70
|
+
assert.match(SEGMENT, /^2c573001-4171-4900-904b-[0-9a-f]+$/);
|
|
71
|
+
assert.equal(sessionSegment(SESSION_ID), SEGMENT);
|
|
72
|
+
});
|
|
50
73
|
test("saveText rejects when the session has no working directory", async () => {
|
|
51
74
|
const { resolver } = guestFileRecorder();
|
|
52
75
|
const store = createSpillStore(fakeCtx({}), resolver);
|
package/dist/subprocess.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type WorkspaceResolver } from "./workspace-binding.js";
|
|
2
2
|
export interface SubprocessProvider {
|
|
3
3
|
resolveExecutable(command: string): Promise<string>;
|
|
4
|
+
terminalEnvironment(signal?: AbortSignal): Promise<{
|
|
5
|
+
platform: "posix";
|
|
6
|
+
defaultShell: string;
|
|
7
|
+
}>;
|
|
4
8
|
spawn(spec: any): any;
|
|
5
9
|
spawnTerminal(spec: any): Promise<any>;
|
|
6
10
|
dispose(): void;
|
package/dist/subprocess.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
|
-
import { discardUnneededSpill, outputReader, spillTargetFor, splitEnv } from "./output-reader.js";
|
|
4
|
+
import { discardUnneededSpill, outputReader, spillTargetFor, splitEnv, } from "./output-reader.js";
|
|
5
|
+
import { openGuestTerminal } from "./guest-terminal.js";
|
|
5
6
|
import { globCwd, remoteArgv, unaryGuest } from "./guest-rpc.js";
|
|
6
7
|
import { metadata } from "./workspace-binding.js";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { PassThrough } from "node:stream";
|
|
9
|
+
import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
|
|
9
10
|
export function createSubprocessProvider(resolver) {
|
|
10
11
|
// Live termination handles, so service disposal can stop every process this
|
|
11
12
|
// provider started (the harness's disposal contract).
|
|
@@ -17,12 +18,24 @@ export function createSubprocessProvider(resolver) {
|
|
|
17
18
|
},
|
|
18
19
|
// The harness's resolveExecutable carries no cwd, and this provider's
|
|
19
20
|
// execution world is per-workspace (each workspace is its own container),
|
|
20
|
-
// so
|
|
21
|
-
//
|
|
22
|
-
//
|
|
21
|
+
// so a bare command name cannot be looked up here. An absolute path is
|
|
22
|
+
// returned unchanged: the guest resolves and spawns it inside the target
|
|
23
|
+
// container, which is the only place it can be checked. A bare name is
|
|
24
|
+
// reported in the harness's "not found" form, so shell discovery skips a
|
|
25
|
+
// candidate the images do not carry instead of failing the whole list.
|
|
23
26
|
resolveExecutable: async (command) => {
|
|
24
|
-
|
|
27
|
+
if (typeof command === "string" && command.startsWith("/")) {
|
|
28
|
+
return command;
|
|
29
|
+
}
|
|
30
|
+
throw new SubprocessExecutableNotFoundError(`cannot resolve executable ${JSON.stringify(command)}: no workspace context is available`);
|
|
25
31
|
},
|
|
32
|
+
// Shell selection happens before a workspace is chosen, so the provider
|
|
33
|
+
// reports the family every container it starts belongs to. `/bin/sh` is the
|
|
34
|
+
// portable default; a configured shell overrides it.
|
|
35
|
+
terminalEnvironment: async (_signal) => ({
|
|
36
|
+
platform: "posix",
|
|
37
|
+
defaultShell: "/bin/sh",
|
|
38
|
+
}),
|
|
26
39
|
spawn: (spec) => {
|
|
27
40
|
// A pre-aborted spawn is rejected synchronously with a stable Error,
|
|
28
41
|
// mirroring the local backend, rather than starting a process that would
|
|
@@ -40,8 +53,9 @@ export function createSubprocessProvider(resolver) {
|
|
|
40
53
|
if (!Array.isArray(spec.argv) ||
|
|
41
54
|
spec.argv.length === 0 ||
|
|
42
55
|
typeof spec.argv[0] !== "string" ||
|
|
43
|
-
spec.argv[0] === "")
|
|
56
|
+
spec.argv[0] === "") {
|
|
44
57
|
throw new Error("argv must contain a program");
|
|
58
|
+
}
|
|
45
59
|
const stdoutSpill = spillTargetFor(spec.stdio?.stdout, "stdout");
|
|
46
60
|
const stderrSpill = spillTargetFor(spec.stdio?.stderr, "stderr");
|
|
47
61
|
const stdoutReader = outputReader(spec.stdio?.stdout, stdoutSpill.spec);
|
|
@@ -80,6 +94,36 @@ export function createSubprocessProvider(resolver) {
|
|
|
80
94
|
killTimer.unref?.();
|
|
81
95
|
}
|
|
82
96
|
};
|
|
97
|
+
let settled = false;
|
|
98
|
+
// A piped consumer that falls behind applies backpressure: the guest
|
|
99
|
+
// stream is paused until every outstanding drain (or close) has fired, so
|
|
100
|
+
// a fast producer cannot grow a PassThrough without bound.
|
|
101
|
+
let pendingDrains = 0;
|
|
102
|
+
const applyBackpressure = (stream, target) => {
|
|
103
|
+
pendingDrains++;
|
|
104
|
+
stream.pause?.();
|
|
105
|
+
let released = false;
|
|
106
|
+
const release = () => {
|
|
107
|
+
if (released)
|
|
108
|
+
return;
|
|
109
|
+
released = true;
|
|
110
|
+
target.removeListener("drain", release);
|
|
111
|
+
target.removeListener("close", release);
|
|
112
|
+
pendingDrains--;
|
|
113
|
+
if (pendingDrains === 0)
|
|
114
|
+
stream.resume?.();
|
|
115
|
+
};
|
|
116
|
+
target.once("drain", release);
|
|
117
|
+
// A destroyed target never drains; its close must release the pause or
|
|
118
|
+
// the exit message would stay stuck behind it.
|
|
119
|
+
target.once("close", release);
|
|
120
|
+
};
|
|
121
|
+
const writeChunk = (stream, target, data) => {
|
|
122
|
+
if (target === undefined)
|
|
123
|
+
return;
|
|
124
|
+
if (!target.write(data))
|
|
125
|
+
applyBackpressure(stream, target);
|
|
126
|
+
};
|
|
83
127
|
// A ripgrep discovery listing runs from its search root, so a pattern
|
|
84
128
|
// containing "/" anchors to the path the harness passed; the workspace
|
|
85
129
|
// binding still resolves from the caller's cwd.
|
|
@@ -87,13 +131,43 @@ export function createSubprocessProvider(resolver) {
|
|
|
87
131
|
const done = resolver.resolve(spec.cwd).then((binding) => new Promise((resolveDone, reject) => {
|
|
88
132
|
processBinding = binding;
|
|
89
133
|
const stream = binding.guest.exec(metadata(binding.token));
|
|
134
|
+
// finish settles the handle exactly once and tears it down however
|
|
135
|
+
// the process ends: an exit message, a stream error, or a stream
|
|
136
|
+
// that ends without one. The output pipes and spill files are
|
|
137
|
+
// resolved on every path, so a failed exec leaves no half-open
|
|
138
|
+
// handle, no hung reader, and no orphaned spill.
|
|
139
|
+
const finish = (error, exit) => {
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
settled = true;
|
|
143
|
+
exited = true;
|
|
144
|
+
if (killTimer !== undefined) {
|
|
145
|
+
clearTimeout(killTimer);
|
|
146
|
+
killTimer = undefined;
|
|
147
|
+
}
|
|
148
|
+
// A pause applied for backpressure must not outlive the process,
|
|
149
|
+
// or the exit/error message could stay stuck behind it.
|
|
150
|
+
if (pendingDrains > 0) {
|
|
151
|
+
pendingDrains = 0;
|
|
152
|
+
stream.resume?.();
|
|
153
|
+
}
|
|
154
|
+
state.stdout?.end();
|
|
155
|
+
state.stderr?.end();
|
|
156
|
+
discardUnneededSpill(binding, stdoutReader);
|
|
157
|
+
discardUnneededSpill(binding, stderrReader);
|
|
158
|
+
if (error === undefined)
|
|
159
|
+
resolveDone(exit);
|
|
160
|
+
else
|
|
161
|
+
reject(error);
|
|
162
|
+
};
|
|
90
163
|
stream.on("data", (output) => {
|
|
91
164
|
if (output.stdoutChunk) {
|
|
92
165
|
const data = Buffer.from(output.stdoutChunk);
|
|
93
166
|
stdoutReader?.append(data);
|
|
94
|
-
state.stdout
|
|
95
|
-
if (spec.stdio?.stdout === "inherit")
|
|
167
|
+
writeChunk(stream, state.stdout, data);
|
|
168
|
+
if (spec.stdio?.stdout === "inherit") {
|
|
96
169
|
process.stdout.write(data);
|
|
170
|
+
}
|
|
97
171
|
}
|
|
98
172
|
if (output.processId) {
|
|
99
173
|
state.pid = Number(output.processId);
|
|
@@ -103,27 +177,35 @@ export function createSubprocessProvider(resolver) {
|
|
|
103
177
|
if (output.stderrChunk) {
|
|
104
178
|
const data = Buffer.from(output.stderrChunk);
|
|
105
179
|
stderrReader?.append(data);
|
|
106
|
-
state.stderr
|
|
107
|
-
if (spec.stdio?.stderr === "inherit")
|
|
180
|
+
writeChunk(stream, state.stderr, data);
|
|
181
|
+
if (spec.stdio?.stderr === "inherit") {
|
|
108
182
|
process.stderr.write(data);
|
|
183
|
+
}
|
|
109
184
|
}
|
|
110
185
|
if (output.exit) {
|
|
111
|
-
exited = true;
|
|
112
|
-
if (killTimer !== undefined)
|
|
113
|
-
clearTimeout(killTimer);
|
|
114
186
|
stdoutReader?.setSpillValid(Boolean(output.exit.stdoutSpillValid));
|
|
115
187
|
stderrReader?.setSpillValid(Boolean(output.exit.stderrSpillValid));
|
|
116
|
-
|
|
117
|
-
state.stderr?.end();
|
|
118
|
-
discardUnneededSpill(binding, stdoutReader);
|
|
119
|
-
discardUnneededSpill(binding, stderrReader);
|
|
120
|
-
resolveDone({
|
|
188
|
+
finish(undefined, {
|
|
121
189
|
exitCode: output.exit.exitCode,
|
|
122
190
|
signal: output.exit.signaled ? output.exit.signal : null,
|
|
123
191
|
});
|
|
124
192
|
}
|
|
125
193
|
});
|
|
126
|
-
stream.on("error",
|
|
194
|
+
stream.on("error", (error) => {
|
|
195
|
+
// The guest connection failed: stop the process if it is still
|
|
196
|
+
// reachable, then release the caller instead of leaving it
|
|
197
|
+
// pending on a stream that will never carry an exit message.
|
|
198
|
+
terminate();
|
|
199
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
200
|
+
});
|
|
201
|
+
stream.on("end", () => {
|
|
202
|
+
// A stream that ends without an exit message (guest restart,
|
|
203
|
+
// dropped socket) must not leave the caller pending forever.
|
|
204
|
+
if (settled)
|
|
205
|
+
return;
|
|
206
|
+
terminate();
|
|
207
|
+
finish(new Error("exec stream ended before the process exited"));
|
|
208
|
+
});
|
|
127
209
|
const env = splitEnv(spec.env);
|
|
128
210
|
stream.write({
|
|
129
211
|
start: {
|
|
@@ -161,6 +243,9 @@ export function createSubprocessProvider(resolver) {
|
|
|
161
243
|
void done.finally(() => live.delete(terminate)).catch(() => { });
|
|
162
244
|
return {
|
|
163
245
|
...state,
|
|
246
|
+
// The harness's SubprocessHandle declares a control channel; this
|
|
247
|
+
// provider runs every process over gRPC, so none exists.
|
|
248
|
+
control: undefined,
|
|
164
249
|
collected: {
|
|
165
250
|
...(stdoutReader === undefined ? {} : { stdout: stdoutReader }),
|
|
166
251
|
...(stderrReader === undefined ? {} : { stderr: stderrReader }),
|
|
@@ -195,162 +280,50 @@ export function createSubprocessProvider(resolver) {
|
|
|
195
280
|
if (!Array.isArray(spec.argv) ||
|
|
196
281
|
spec.argv.length === 0 ||
|
|
197
282
|
typeof spec.argv[0] !== "string" ||
|
|
198
|
-
spec.argv[0] === "")
|
|
283
|
+
spec.argv[0] === "") {
|
|
199
284
|
throw new Error("argv must contain a program");
|
|
285
|
+
}
|
|
200
286
|
spec.signal?.throwIfAborted();
|
|
201
287
|
const binding = await resolver.resolveForPath(spec.cwd, spec.cwd);
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
resolveExit = resolveDone;
|
|
210
|
-
rejectExit = rejectDone;
|
|
211
|
-
});
|
|
212
|
-
// A caller that never receives the handle (startup failure) must not
|
|
213
|
-
// leave `done` as an unhandled rejection.
|
|
214
|
-
done.catch(() => { });
|
|
215
|
-
let resolveStarted;
|
|
216
|
-
let rejectStarted;
|
|
217
|
-
const started = new Promise((resolvePid, rejectPid) => {
|
|
218
|
-
resolveStarted = resolvePid;
|
|
219
|
-
rejectStarted = rejectPid;
|
|
288
|
+
const terminal = openGuestTerminal(binding, {
|
|
289
|
+
argv: spec.argv,
|
|
290
|
+
cwd: spec.cwd,
|
|
291
|
+
env: spec.env,
|
|
292
|
+
cols: spec.cols,
|
|
293
|
+
rows: spec.rows,
|
|
294
|
+
terminalType: spec.terminalType,
|
|
220
295
|
});
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (message.stdoutChunk) {
|
|
233
|
-
output.write(Buffer.from(message.stdoutChunk));
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (message.exit) {
|
|
237
|
-
exited = true;
|
|
238
|
-
output.end();
|
|
239
|
-
resolveExit({
|
|
240
|
-
exitCode: message.exit.exitCode,
|
|
241
|
-
signal: message.exit.signaled ? message.exit.signal : null,
|
|
242
|
-
});
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if (message.foreground) {
|
|
246
|
-
const waiter = pending.get(message.foreground.requestId);
|
|
247
|
-
if (waiter !== undefined) {
|
|
248
|
-
pending.delete(message.foreground.requestId);
|
|
249
|
-
waiter.resolve(message.foreground);
|
|
250
|
-
}
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (message.signalled) {
|
|
254
|
-
const waiter = pending.get(message.signalled.requestId);
|
|
255
|
-
if (waiter !== undefined) {
|
|
256
|
-
pending.delete(message.signalled.requestId);
|
|
257
|
-
waiter.resolve(message.signalled);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
call.on("error", (error) => {
|
|
262
|
-
rejectStarted(error);
|
|
263
|
-
if (!exited) {
|
|
264
|
-
exited = true;
|
|
296
|
+
const output = new PassThrough();
|
|
297
|
+
terminal.onOutput((chunk) => output.write(chunk));
|
|
298
|
+
// Startup failure rejects here, before the caller receives a handle.
|
|
299
|
+
const pid = await terminal.started;
|
|
300
|
+
const done = terminal.done;
|
|
301
|
+
// The handle's output ends however the stream settles, so a consumer
|
|
302
|
+
// reading it is released on exit exactly as it was on a clean close.
|
|
303
|
+
const endOutput = () => {
|
|
304
|
+
if (!output.writableEnded)
|
|
265
305
|
output.end();
|
|
266
|
-
rejectExit(error);
|
|
267
|
-
}
|
|
268
|
-
failPending(error);
|
|
269
|
-
});
|
|
270
|
-
call.on("end", () => {
|
|
271
|
-
if (exited)
|
|
272
|
-
return;
|
|
273
|
-
const error = new Error("terminal stream ended before the process exited");
|
|
274
|
-
rejectStarted(error);
|
|
275
|
-
exited = true;
|
|
276
|
-
output.end();
|
|
277
|
-
rejectExit(error);
|
|
278
|
-
failPending(error);
|
|
279
|
-
});
|
|
280
|
-
call.write({
|
|
281
|
-
start: {
|
|
282
|
-
argv: remoteArgv(spec.argv),
|
|
283
|
-
cwd: spec.cwd,
|
|
284
|
-
env: spec.env ?? {},
|
|
285
|
-
rows: spec.rows,
|
|
286
|
-
cols: spec.cols,
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
const pid = await started;
|
|
290
|
-
const request = (message) => {
|
|
291
|
-
const requestId = randomUUID();
|
|
292
|
-
return new Promise((resolveWaiter, rejectWaiter) => {
|
|
293
|
-
pending.set(requestId, { resolve: resolveWaiter, reject: rejectWaiter });
|
|
294
|
-
try {
|
|
295
|
-
call.write(message(requestId));
|
|
296
|
-
}
|
|
297
|
-
catch (error) {
|
|
298
|
-
pending.delete(requestId);
|
|
299
|
-
rejectWaiter(error);
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
306
|
};
|
|
307
|
+
void done.then(endOutput, endOutput).catch(() => { });
|
|
303
308
|
const handle = {
|
|
304
309
|
pid,
|
|
305
310
|
output,
|
|
306
311
|
done,
|
|
307
312
|
write: async (data) => {
|
|
308
|
-
|
|
309
|
-
throw new Error("terminal has exited");
|
|
310
|
-
call.write({ stdinChunk: Buffer.from(data) });
|
|
311
|
-
},
|
|
312
|
-
inspectForeground: async () => {
|
|
313
|
-
if (exited)
|
|
314
|
-
return undefined;
|
|
315
|
-
const response = await request((requestId) => ({
|
|
316
|
-
inspectRequestId: requestId,
|
|
317
|
-
}));
|
|
318
|
-
return response.found
|
|
319
|
-
? {
|
|
320
|
-
processGroupId: response.processGroupId,
|
|
321
|
-
inputWaiting: response.inputWaiting,
|
|
322
|
-
}
|
|
323
|
-
: undefined;
|
|
313
|
+
terminal.write(data);
|
|
324
314
|
},
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
throw new Error("terminal has exited");
|
|
328
|
-
const response = await request((requestId) => ({
|
|
329
|
-
signal: { signal, requestId },
|
|
330
|
-
}));
|
|
331
|
-
if (!response.found)
|
|
332
|
-
throw new Error(`no foreground process group to signal ${signal}`);
|
|
333
|
-
return response.processGroupId;
|
|
315
|
+
resize: async (cols, rows) => {
|
|
316
|
+
terminal.resize(cols, rows);
|
|
334
317
|
},
|
|
318
|
+
inspectActivity: async () => ({
|
|
319
|
+
state: "unknown",
|
|
320
|
+
revision: terminal.activityRevision(),
|
|
321
|
+
}),
|
|
322
|
+
inspectForeground: async () => terminal.inspectForeground(),
|
|
323
|
+
signalForeground: async (signal) => terminal.signalForeground(signal),
|
|
335
324
|
terminate: async () => {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
terminated = true;
|
|
339
|
-
try {
|
|
340
|
-
call.write({ close: true });
|
|
341
|
-
call.end();
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
// A finished call cannot be closed again; nothing to do.
|
|
345
|
-
}
|
|
346
|
-
try {
|
|
347
|
-
await done;
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
// Termination is idempotent and never rejects.
|
|
351
|
-
}
|
|
352
|
-
if (!output.writableEnded)
|
|
353
|
-
output.end();
|
|
325
|
+
await terminal.terminate();
|
|
326
|
+
endOutput();
|
|
354
327
|
},
|
|
355
328
|
};
|
|
356
329
|
const onAbort = () => {
|
|
@@ -362,6 +335,19 @@ export function createSubprocessProvider(resolver) {
|
|
|
362
335
|
else
|
|
363
336
|
spec.signal.addEventListener("abort", onAbort, { once: true });
|
|
364
337
|
}
|
|
338
|
+
// Register the terminal with the provider's live handles, so disposing
|
|
339
|
+
// the service stops a terminal the caller never closed. Termination is
|
|
340
|
+
// idempotent and never rejects.
|
|
341
|
+
const stop = () => {
|
|
342
|
+
try {
|
|
343
|
+
void handle.terminate();
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Terminal teardown is best-effort.
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
live.add(stop);
|
|
350
|
+
void done.finally(() => live.delete(stop)).catch(() => { });
|
|
365
351
|
return handle;
|
|
366
352
|
},
|
|
367
353
|
};
|
package/dist/subprocess.test.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { FakeTerminalCall, fakeTerminalResolver, spawnGuest, spawnSpec } from "./test-support.js";
|
|
7
|
-
import { createSubprocessProvider, globCwd, outputReader, remoteArgv } from "./index.js";
|
|
6
|
+
import { FakeTerminalCall, fakeTerminalResolver, spawnGuest, spawnSpec, } from "./test-support.js";
|
|
7
|
+
import { createSubprocessProvider, globCwd, outputReader, remoteArgv, } from "./index.js";
|
|
8
|
+
import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
|
|
8
9
|
test("globCwd runs a discovery listing from its absolute search root", () => {
|
|
9
10
|
assert.equal(globCwd(["rg", "--files", "--glob=sub/b.txt", "--", "/tmp/globtest"], "/projects/team"), "/tmp/globtest");
|
|
10
11
|
assert.equal(globCwd(["rg", "--files", "--glob=a", "--", "sub"], "/projects/team"), undefined, "a relative root keeps the caller's cwd");
|
|
@@ -102,6 +103,26 @@ test("outputReader drops bytes beyond the retention window", () => {
|
|
|
102
103
|
assert.equal(read.nextOffset, 10);
|
|
103
104
|
assert.equal(read.lossy, true);
|
|
104
105
|
});
|
|
106
|
+
test("outputReader with a zero cap retains nothing", () => {
|
|
107
|
+
const spill = { path: "/tmp/dsh-podman/x.stdout", maxBytes: 64 };
|
|
108
|
+
const reader = outputReader({ maxBytes: 0 }, spill);
|
|
109
|
+
reader.append(Buffer.from("abcdef"));
|
|
110
|
+
// `subarray(-0)` is the whole buffer, so a zero cap must be special-cased.
|
|
111
|
+
assert.equal(reader.readFrom(0).text, "");
|
|
112
|
+
assert.equal(reader.readFrom(0).lossy, true);
|
|
113
|
+
assert.equal(reader.spillNeeded, true);
|
|
114
|
+
assert.equal(reader.readFrom(0).spillPath, spill.path);
|
|
115
|
+
});
|
|
116
|
+
test("outputReader keeps the exact tail across many small chunks", () => {
|
|
117
|
+
const reader = outputReader({ maxBytes: 4 });
|
|
118
|
+
for (const character of "0123456789") {
|
|
119
|
+
reader.append(Buffer.from(character));
|
|
120
|
+
}
|
|
121
|
+
const read = reader.readFrom(0);
|
|
122
|
+
assert.equal(read.text, "6789");
|
|
123
|
+
assert.equal(read.nextOffset, 10);
|
|
124
|
+
assert.equal(read.lossy, true);
|
|
125
|
+
});
|
|
105
126
|
test("outputReader honors in-window offsets", () => {
|
|
106
127
|
const reader = outputReader({ maxBytes: 5 });
|
|
107
128
|
reader.append(Buffer.from("abcdefghij"));
|
|
@@ -112,12 +133,22 @@ test("outputReader honors in-window offsets", () => {
|
|
|
112
133
|
assert.equal(pastEnd.text, "");
|
|
113
134
|
assert.equal(pastEnd.lossy, false);
|
|
114
135
|
});
|
|
115
|
-
test("resolveExecutable
|
|
136
|
+
test("resolveExecutable returns absolute paths and reports bare names as not found", async () => {
|
|
116
137
|
const provider = createSubprocessProvider({});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
assert.equal(await provider.resolveExecutable("/usr/bin/ls"), "/usr/bin/ls");
|
|
139
|
+
assert.equal(await provider.resolveExecutable("/bin/sh"), "/bin/sh");
|
|
140
|
+
// The harness treats only this class as "not installed", so shell discovery
|
|
141
|
+
// skips a candidate the images do not carry instead of failing.
|
|
142
|
+
for (const command of ["", "bin/tool", "ls", "zsh"]) {
|
|
143
|
+
await assert.rejects(() => provider.resolveExecutable(command), SubprocessExecutableNotFoundError);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
test("terminalEnvironment reports the container's shell family", async () => {
|
|
147
|
+
const provider = createSubprocessProvider({});
|
|
148
|
+
assert.deepEqual(await provider.terminalEnvironment(), {
|
|
149
|
+
platform: "posix",
|
|
150
|
+
defaultShell: "/bin/sh",
|
|
151
|
+
});
|
|
121
152
|
});
|
|
122
153
|
test("spawnTerminal drives the guest terminal stream", async () => {
|
|
123
154
|
const fake = new FakeTerminalCall();
|
|
@@ -127,15 +158,22 @@ test("spawnTerminal drives the guest terminal stream", async () => {
|
|
|
127
158
|
cwd: "/projects/team",
|
|
128
159
|
rows: 24,
|
|
129
160
|
cols: 80,
|
|
161
|
+
terminalType: "xterm-256color",
|
|
130
162
|
graceMs: 1000,
|
|
131
163
|
});
|
|
132
164
|
assert.equal(handle.pid, 42);
|
|
165
|
+
assert.equal(fake.start.env.TERM, "xterm-256color");
|
|
133
166
|
const chunks = [];
|
|
134
167
|
handle.output.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
135
168
|
const outputEnded = new Promise((resolve) => handle.output.on("end", resolve));
|
|
136
169
|
fake.emitStdout(Buffer.from("hello"));
|
|
137
170
|
await handle.write("ls\n");
|
|
138
171
|
assert.deepEqual(fake.stdinChunks.map((chunk) => chunk.toString("utf8")), ["ls\n"]);
|
|
172
|
+
await handle.resize(100, 40);
|
|
173
|
+
assert.deepEqual(fake.resizes, [{ rows: 40, cols: 100 }]);
|
|
174
|
+
const activity = await handle.inspectActivity();
|
|
175
|
+
assert.equal(activity.state, "unknown");
|
|
176
|
+
assert.ok(activity.revision > 0, "observed activity must advance the revision");
|
|
139
177
|
assert.deepEqual(await handle.inspectForeground(), {
|
|
140
178
|
processGroupId: 7,
|
|
141
179
|
inputWaiting: true,
|
|
@@ -149,7 +187,12 @@ test("spawnTerminal drives the guest terminal stream", async () => {
|
|
|
149
187
|
});
|
|
150
188
|
test("spawnTerminal rejects an empty argv", async () => {
|
|
151
189
|
const provider = createSubprocessProvider(fakeTerminalResolver(new FakeTerminalCall()));
|
|
152
|
-
await assert.rejects(() => provider.spawnTerminal({
|
|
190
|
+
await assert.rejects(() => provider.spawnTerminal({
|
|
191
|
+
argv: [],
|
|
192
|
+
cwd: "/projects/team",
|
|
193
|
+
rows: 24,
|
|
194
|
+
cols: 80,
|
|
195
|
+
}), /argv must contain a program/);
|
|
153
196
|
});
|
|
154
197
|
test("subprocess provider terminates live processes on dispose", async () => {
|
|
155
198
|
const { signals, resolver } = spawnGuest({ emitExit: false });
|
|
@@ -232,3 +275,70 @@ test("subprocess provider keeps a spill that holds dropped output", async () =>
|
|
|
232
275
|
assert.equal(read.lossy, true);
|
|
233
276
|
assert.equal(read.spillPath, fake.starts[0].start.spillStdout.path);
|
|
234
277
|
});
|
|
278
|
+
test("subprocess provider tears down a failed exec stream", async () => {
|
|
279
|
+
const fake = spawnGuest({ emitExit: false });
|
|
280
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
281
|
+
const handle = provider.spawn(spawnSpec({
|
|
282
|
+
stdio: {
|
|
283
|
+
stdin: "ignore",
|
|
284
|
+
stdout: "pipe",
|
|
285
|
+
stderr: { maxBytes: 4, spill: { maxBytes: 64 } },
|
|
286
|
+
},
|
|
287
|
+
}));
|
|
288
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
289
|
+
const failed = assert.rejects(handle.done, /guest vanished/);
|
|
290
|
+
// A paused PassThrough only emits "end" once a consumer reads it to EOF.
|
|
291
|
+
handle.stdout.resume();
|
|
292
|
+
const outputEnded = new Promise((resolve) => handle.stdout.on("end", () => resolve()));
|
|
293
|
+
fake.streams[0].emit("error", new Error("guest vanished"));
|
|
294
|
+
await outputEnded;
|
|
295
|
+
await failed;
|
|
296
|
+
assert.ok(fake.signals.some((signal) => signal.signal === "SIGTERM"), "the process is asked to stop when its stream fails");
|
|
297
|
+
const spill = fake.starts[0].start.spillStderr.path;
|
|
298
|
+
assert.ok(fake.deletes.some((request) => request.path === spill), "the spill is deleted on the failure path");
|
|
299
|
+
});
|
|
300
|
+
test("subprocess provider rejects a stream that ends without an exit", async () => {
|
|
301
|
+
const fake = spawnGuest({ emitExit: false });
|
|
302
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
303
|
+
const handle = provider.spawn(spawnSpec());
|
|
304
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
305
|
+
const failed = assert.rejects(handle.done, /ended before the process exited/);
|
|
306
|
+
fake.streams[0].emit("end");
|
|
307
|
+
await failed;
|
|
308
|
+
assert.ok(fake.signals.some((signal) => signal.signal === "SIGTERM"), "a lost stream still asks the process to stop");
|
|
309
|
+
});
|
|
310
|
+
test("subprocess provider pauses a piped stream while the consumer is behind", async () => {
|
|
311
|
+
const chunk = "x".repeat(64 * 1024);
|
|
312
|
+
const fake = spawnGuest({ emitExit: false, stdout: chunk });
|
|
313
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
314
|
+
const handle = provider.spawn(spawnSpec({ stdio: { stdin: "ignore", stdout: "pipe", stderr: "pipe" } }));
|
|
315
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
316
|
+
const stream = fake.streams[0];
|
|
317
|
+
assert.ok(stream.paused > 0, "a full PassThrough pauses the guest stream");
|
|
318
|
+
const chunks = [];
|
|
319
|
+
await new Promise((resolve) => handle.stdout.on("data", (data) => {
|
|
320
|
+
chunks.push(Buffer.from(data));
|
|
321
|
+
if (Buffer.concat(chunks).length >= chunk.length)
|
|
322
|
+
resolve();
|
|
323
|
+
}));
|
|
324
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
325
|
+
assert.ok(stream.resumed > 0, "draining the consumer resumes the stream");
|
|
326
|
+
const exited = handle.done;
|
|
327
|
+
stream.emitData({ exit: { exitCode: 0, signaled: false } });
|
|
328
|
+
assert.deepEqual(await exited, { exitCode: 0, signal: null });
|
|
329
|
+
});
|
|
330
|
+
test("subprocess provider disposes a live terminal", async () => {
|
|
331
|
+
const fake = new FakeTerminalCall();
|
|
332
|
+
const provider = createSubprocessProvider(fakeTerminalResolver(fake));
|
|
333
|
+
const handle = await provider.spawnTerminal({
|
|
334
|
+
argv: ["bash"],
|
|
335
|
+
cwd: "/projects/team",
|
|
336
|
+
rows: 24,
|
|
337
|
+
cols: 80,
|
|
338
|
+
graceMs: 1000,
|
|
339
|
+
});
|
|
340
|
+
assert.equal(handle.pid, 42);
|
|
341
|
+
provider.dispose();
|
|
342
|
+
assert.equal(fake.closed, true, "disposal closes the terminal");
|
|
343
|
+
assert.equal(fake.ended, true, "disposal ends the terminal stream");
|
|
344
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|