@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.8
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 +37 -0
- package/README.md +20 -2
- package/package.json +5 -4
- package/src/bridge/kernel-tools-protocol.ts +8 -0
- package/src/bridge/reserved.ts +10 -0
- package/src/bridges/eval-virtual-schemas.ts +100 -0
- package/src/bridges/handle-bridge.ts +72 -0
- package/src/bridges/reserved-dispatch.ts +20 -2
- package/src/bridges/schema-bridge.ts +4 -0
- package/src/completion/handle.ts +69 -0
- package/src/completion/tool-bridge.ts +31 -12
- package/src/environments/editable-check.ts +116 -0
- package/src/environments/install-lock.ts +223 -0
- package/src/environments/py-environment.ts +98 -0
- package/src/environments/py-installer.ts +187 -0
- package/src/environments/revision-store.ts +95 -0
- package/src/extension/bridge-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/host-facts.ts +18 -0
- package/src/extension/kernel-registration.ts +5 -0
- package/src/extension/removed-tool-hints.ts +15 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +32 -5
- package/src/extension/start-recovery.ts +111 -0
- package/src/handles/completion-handles.ts +202 -0
- package/src/handles/handle-args.ts +103 -0
- package/src/handles/handle-registry.ts +164 -0
- package/src/handles/wait.ts +214 -0
- package/src/handles/watch-queue.ts +102 -0
- package/src/index.ts +99 -94
- package/src/kernels/jl/handles.jl +124 -0
- package/src/kernels/jl/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +6 -1
- package/src/kernels/js/handles.js +93 -0
- package/src/kernels/js/kernel-tools-define.d.ts +6 -0
- package/src/kernels/js/kernel-tools-define.js +3 -1
- package/src/kernels/js/kernel-tools-errors.js +2 -0
- package/src/kernels/js/kernel-tools-errors.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +18 -9
- package/src/kernels/js/kernel-tools-schema.js +6 -2
- package/src/kernels/js/worker-runtime.js +10 -1
- package/src/kernels/py/host-cell.ts +42 -0
- package/src/kernels/py/kernel-contract.ts +13 -2
- package/src/kernels/py/kernel-tools-host.ts +97 -0
- package/src/kernels/py/kernel.ts +88 -12
- package/src/kernels/py/prelude.py +797 -11
- package/src/kernels/py/process.ts +15 -4
- package/src/kernels/py/startup.ts +58 -20
- package/src/kernels/py/transport.ts +36 -7
- package/src/kernels/rb/handles.rb +97 -0
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +7 -7
- package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
- package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
- package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
- package/src/kernels/shared/kernel-tools-pump.ts +6 -0
- package/src/kernels/shared/process-cpu.ts +69 -0
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/cell-handler.ts +21 -18
- package/src/tool/cell-reserved-dispatch.ts +51 -0
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/run-eval-cell.ts +2 -0
- package/src/tool/types.ts +15 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Refuses a staged revision that links to code outside itself: a PEP 660 editable install
|
|
7
|
+
* (`direct_url.json` with `dir_info.editable`), a legacy `setup.py develop` (`.egg-link`), a `.pth` path
|
|
8
|
+
* line that resolves outside the revision, or a symlink anywhere in it that does. `.pth` lines starting
|
|
9
|
+
* with `import` are code that runs at startup, like any installed package's code, and are not inspected.
|
|
10
|
+
* A refused revision is never published.
|
|
11
|
+
*/
|
|
12
|
+
export async function assertNoEditableInstalls(staging: string): Promise<void> {
|
|
13
|
+
const root = await realpath(staging).catch(() => resolve(staging));
|
|
14
|
+
let entries: string[];
|
|
15
|
+
try {
|
|
16
|
+
entries = await readdir(staging);
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const path = join(staging, entry);
|
|
22
|
+
if (entry.endsWith(".egg-link"))
|
|
23
|
+
refuse(entry.replace(/\.egg-link$/u, ""), "a legacy editable install (.egg-link)");
|
|
24
|
+
if (
|
|
25
|
+
entry.endsWith(".dist-info") &&
|
|
26
|
+
isEditable(await readFile(join(path, "direct_url.json"), "utf8").catch(() => ""))
|
|
27
|
+
) {
|
|
28
|
+
refuse(entry.replace(/\.dist-info$/u, ""), "an editable install");
|
|
29
|
+
}
|
|
30
|
+
if (entry.endsWith(".pth")) {
|
|
31
|
+
for (const line of (await readFile(path, "utf8").catch(() => "")).split(/\r?\n/u)) {
|
|
32
|
+
const text = line.trim();
|
|
33
|
+
if (text === "" || text.startsWith("#") || text.startsWith("import ")) continue;
|
|
34
|
+
if (!(await inside(root, resolve(staging, text))))
|
|
35
|
+
refuse(entry, `a path outside the environment (${text})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await refuseOutsideLinks(root, staging);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every symlink in the revision, at any depth, must resolve inside it; links are checked, not followed. */
|
|
43
|
+
async function refuseOutsideLinks(root: string, dir: string): Promise<void> {
|
|
44
|
+
for (const entry of await readdir(dir)) {
|
|
45
|
+
const path = join(dir, entry);
|
|
46
|
+
const info = await lstat(path);
|
|
47
|
+
if (info.isSymbolicLink()) {
|
|
48
|
+
if (!(await inside(root, path))) refuse(relative(root, path), "a link that points outside the environment");
|
|
49
|
+
} else if (info.isDirectory()) {
|
|
50
|
+
await refuseOutsideLinks(root, path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* pip reports what it installed; each of those distributions must be in the revision. A configuration
|
|
57
|
+
* that redirects pip elsewhere (a target, root or prefix from any config file or variable, on any pip
|
|
58
|
+
* version) leaves them missing here, so the install fails instead of publishing an empty revision.
|
|
59
|
+
*/
|
|
60
|
+
export async function assertInstalledInRevision(stdout: string, staging: string): Promise<void> {
|
|
61
|
+
const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
|
|
62
|
+
if (line === undefined) return;
|
|
63
|
+
const present = new Set((await readdir(staging).catch(() => [] as string[])).flatMap(installedName));
|
|
64
|
+
for (const token of line.slice("Successfully installed ".length).trim().split(/\s+/u)) {
|
|
65
|
+
const name = token.slice(0, token.lastIndexOf("-"));
|
|
66
|
+
if (!present.has(canonicalName(name))) {
|
|
67
|
+
throw new EnvironmentError(
|
|
68
|
+
"environment_install_failed",
|
|
69
|
+
`${name} was installed outside the session's environment (pip's configuration redirected it); nothing was published`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The project a metadata entry records: `name-version.dist-info` (wheels), or `name-version[-pyX.Y].egg-info`
|
|
77
|
+
* (a setup.py project pip <= 23.0 installs without the wheel package). Both escape `-` in the name to `_`.
|
|
78
|
+
*/
|
|
79
|
+
function installedName(entry: string): string[] {
|
|
80
|
+
if (entry.endsWith(".dist-info")) {
|
|
81
|
+
const stem = entry.slice(0, -".dist-info".length);
|
|
82
|
+
return [canonicalName(stem.slice(0, stem.lastIndexOf("-")))];
|
|
83
|
+
}
|
|
84
|
+
if (entry.endsWith(".egg-info")) return [canonicalName(entry.split("-")[0] ?? "")];
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function canonicalName(name: string): string {
|
|
89
|
+
return name.toLowerCase().replace(/[-_.]+/gu, "_");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function inside(root: string, path: string): Promise<boolean> {
|
|
93
|
+
const real = await realpath(path).catch(() => resolve(path));
|
|
94
|
+
const rel = relative(root, real);
|
|
95
|
+
return (
|
|
96
|
+
rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(sep) && !/^[A-Za-z]:/u.test(rel))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refuse(name: string, what: string): never {
|
|
101
|
+
throw new EnvironmentError(
|
|
102
|
+
"environment_install_failed",
|
|
103
|
+
`${name} was installed as ${what}; packages must live in the session's environment root, so editable and linked installs are not allowed`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isEditable(text: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
const value: unknown = JSON.parse(text);
|
|
110
|
+
if (typeof value !== "object" || value === null || !("dir_info" in value)) return false;
|
|
111
|
+
const dirInfo = value.dir_info;
|
|
112
|
+
return typeof dirInfo === "object" && dirInfo !== null && "editable" in dirInfo && dirInfo.editable === true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { link, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const LOCK_FILE = ".install.lock";
|
|
8
|
+
const REAP_PREFIX = ".install.lock.reap.";
|
|
9
|
+
const RECHECK_MS = 500;
|
|
10
|
+
// An unparseable lock can only come from a crash in an older version's create-then-write; it is stale once old.
|
|
11
|
+
const UNREADABLE_STALE_MS = 5_000;
|
|
12
|
+
const WAIT_NOTICE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
type Holder = { readonly pid: number; readonly host: string; readonly nonce: string };
|
|
15
|
+
|
|
16
|
+
export type LockWaitNotice = {
|
|
17
|
+
readonly holder: { readonly pid: number; readonly host: string } | undefined;
|
|
18
|
+
readonly waitedMs: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serialises installs into one environment root across sessions and processes.
|
|
23
|
+
*
|
|
24
|
+
* The lock file is published complete (written to a unique temp file, then `link`ed into place, which fails
|
|
25
|
+
* if a lock exists), so it is never seen empty. A lock whose holder is a dead process on this host, or
|
|
26
|
+
* that is unreadable and old, is stale. Each lock incarnation has its own key (its holder's nonce), and
|
|
27
|
+
* only the waiter that exclusively creates the reap claim for that key may remove it, after re-reading the
|
|
28
|
+
* lock under the claim and finding the same incarnation. A stale incarnation's holder is dead and nobody
|
|
29
|
+
* else may remove it, so it can't change between that re-read and the removal: a live lock is never
|
|
30
|
+
* deleted. A claimer that dies passes the claim on to the next claim in the chain.
|
|
31
|
+
*/
|
|
32
|
+
export async function withRootLock<T>(
|
|
33
|
+
base: string,
|
|
34
|
+
fn: () => Promise<T>,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
onWait?: (notice: LockWaitNotice) => void,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const path = join(base, LOCK_FILE);
|
|
39
|
+
const mine: Holder = { pid: process.pid, host: hostname(), nonce: randomUUID() };
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
let noticed = false;
|
|
42
|
+
for (;;) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
if (await tryCreate(base, path, mine)) break;
|
|
45
|
+
if (await takeOverIfStale(base, path)) continue;
|
|
46
|
+
if (!noticed && Date.now() - started >= WAIT_NOTICE_MS) {
|
|
47
|
+
noticed = true;
|
|
48
|
+
const holder = await readHolder(path);
|
|
49
|
+
onWait?.({ holder: holder && { pid: holder.pid, host: holder.host }, waitedMs: Date.now() - started });
|
|
50
|
+
}
|
|
51
|
+
await waitForRelease(base, path, signal);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await fn();
|
|
55
|
+
} finally {
|
|
56
|
+
const holder = await readHolder(path);
|
|
57
|
+
if (holder?.nonce === mine.nonce) await rm(path, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function tryCreate(base: string, path: string, holder: Holder): Promise<boolean> {
|
|
62
|
+
const temp = join(base, `.install.lock.${holder.nonce}`);
|
|
63
|
+
await writeFile(temp, JSON.stringify(holder), { mode: 0o600 });
|
|
64
|
+
try {
|
|
65
|
+
await link(temp, path);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (errorCode(error) === "EEXIST") return false;
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(temp, { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function takeOverIfStale(base: string, path: string): Promise<boolean> {
|
|
76
|
+
const key = await staleKey(path);
|
|
77
|
+
if (key === undefined) return false;
|
|
78
|
+
const claim = await claimReap(base, key);
|
|
79
|
+
if (claim === undefined) return false;
|
|
80
|
+
try {
|
|
81
|
+
// Under the claim nobody else may remove this incarnation, and its holder is dead, so it is still the
|
|
82
|
+
// lock judged stale exactly when it still has the same key.
|
|
83
|
+
if ((await staleKey(path)) !== key) return false;
|
|
84
|
+
await rm(path, { force: true });
|
|
85
|
+
await removeDeadClaims(base, key);
|
|
86
|
+
return true;
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(claim, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Once the incarnation is gone, its claims only matter to waiters that will find a different lock. */
|
|
93
|
+
async function removeDeadClaims(base: string, key: string): Promise<void> {
|
|
94
|
+
const prefix = `${REAP_PREFIX}${key}.`;
|
|
95
|
+
for (const name of (await readdir(base)).filter((entry) => entry.startsWith(prefix))) {
|
|
96
|
+
const holder = await readHolder(join(base, name));
|
|
97
|
+
if (holder !== undefined && holder.host === hostname() && !isAlive(holder.pid))
|
|
98
|
+
await rm(join(base, name), { force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates the next free reap claim for one lock incarnation: `<key>.0`, or the claim after the last one
|
|
104
|
+
* whose holder died. Returns undefined when a live waiter already holds it.
|
|
105
|
+
*/
|
|
106
|
+
async function claimReap(base: string, key: string): Promise<string | undefined> {
|
|
107
|
+
for (let depth = 0; ; depth++) {
|
|
108
|
+
const claim = join(base, `${REAP_PREFIX}${key}.${depth}`);
|
|
109
|
+
if (await tryCreate(base, claim, { pid: process.pid, host: hostname(), nonce: randomUUID() })) return claim;
|
|
110
|
+
const holder = await readHolder(claim);
|
|
111
|
+
// Gone again (its holder finished): the caller re-reads the lock and retries.
|
|
112
|
+
if (holder === undefined) return undefined;
|
|
113
|
+
if (holder.host !== hostname() || isAlive(holder.pid)) return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function reapInProgress(base: string, key: string): Promise<boolean> {
|
|
118
|
+
const prefix = `${REAP_PREFIX}${key}.`;
|
|
119
|
+
let names: string[];
|
|
120
|
+
try {
|
|
121
|
+
names = (await readdir(base)).filter((name) => name.startsWith(prefix));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
const holder = await readHolder(join(base, name));
|
|
127
|
+
if (holder !== undefined && holder.host === hostname() && isAlive(holder.pid)) return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The incarnation key of a stale lock (its holder's nonce, or its inode when unreadable), else undefined. */
|
|
133
|
+
async function staleKey(path: string): Promise<string | undefined> {
|
|
134
|
+
let content: string;
|
|
135
|
+
try {
|
|
136
|
+
content = await readFile(path, "utf8");
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const holder = parseHolder(content);
|
|
141
|
+
if (holder !== undefined && (holder.host !== hostname() || isAlive(holder.pid))) return undefined;
|
|
142
|
+
if (holder !== undefined && /^[\w-]+$/u.test(holder.nonce)) return holder.nonce;
|
|
143
|
+
try {
|
|
144
|
+
const info = await stat(path);
|
|
145
|
+
// A dead holder's lock without a usable nonce, or an unreadable old one, is keyed by its inode.
|
|
146
|
+
return holder !== undefined || Date.now() - info.mtimeMs > UNREADABLE_STALE_MS ? `ino${info.ino}` : undefined;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readHolder(path: string): Promise<Holder | undefined> {
|
|
153
|
+
try {
|
|
154
|
+
return parseHolder(await readFile(path, "utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseHolder(content: string): Holder | undefined {
|
|
161
|
+
let value: unknown;
|
|
162
|
+
try {
|
|
163
|
+
value = JSON.parse(content);
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
168
|
+
const pid = "pid" in value ? value.pid : undefined;
|
|
169
|
+
const host = "host" in value ? value.host : undefined;
|
|
170
|
+
const nonce = "nonce" in value ? value.nonce : undefined;
|
|
171
|
+
return typeof pid === "number" && typeof host === "string"
|
|
172
|
+
? { pid, host, nonce: typeof nonce === "string" ? nonce : "" }
|
|
173
|
+
: undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isAlive(pid: number): boolean {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return errorCode(error) !== "ESRCH";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function errorCode(error: unknown): string | undefined {
|
|
186
|
+
return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Wakes when the lock file goes away; the periodic recheck covers dropped file-watch events and staleness. */
|
|
190
|
+
function waitForRelease(base: string, path: string, signal?: AbortSignal): Promise<void> {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const watcher = watch(base, () => check());
|
|
193
|
+
const timer = setInterval(() => check(), RECHECK_MS);
|
|
194
|
+
const onAbort = () => done(() => reject(signal?.reason));
|
|
195
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
196
|
+
let settled = false;
|
|
197
|
+
function done(settle: () => void): void {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
200
|
+
watcher.close();
|
|
201
|
+
clearInterval(timer);
|
|
202
|
+
signal?.removeEventListener("abort", onAbort);
|
|
203
|
+
settle();
|
|
204
|
+
}
|
|
205
|
+
function check(): void {
|
|
206
|
+
void stat(path).then(
|
|
207
|
+
// A stale lock is worth retrying only when no live waiter is already reaping it; otherwise wait.
|
|
208
|
+
() =>
|
|
209
|
+
staleKey(path).then(
|
|
210
|
+
async (key) => key !== undefined && !(await reapInProgress(base, key)) && done(resolve),
|
|
211
|
+
),
|
|
212
|
+
() => done(resolve),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
// An abort during the claim inspection that led here fired before this listener existed.
|
|
216
|
+
if (signal?.aborted) {
|
|
217
|
+
onAbort();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// The holder may have released between the failed create and the watcher starting; no event would follow.
|
|
221
|
+
check();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { assertInstalledInRevision, assertNoEditableInstalls } from "./editable-check.ts";
|
|
6
|
+
import { parsePipRequirements, runPipInstall } from "./py-installer.ts";
|
|
7
|
+
import { publishNextRevision, type Revision } from "./revision-store.ts";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export type EnvironmentMode = "managed" | "project";
|
|
12
|
+
|
|
13
|
+
export interface InstallReceipt {
|
|
14
|
+
readonly manager: "pip";
|
|
15
|
+
readonly mode: EnvironmentMode;
|
|
16
|
+
readonly root: string;
|
|
17
|
+
readonly revision: number;
|
|
18
|
+
readonly requested: readonly string[];
|
|
19
|
+
readonly resolved: readonly string[];
|
|
20
|
+
readonly changed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function managedEnvironmentBase(artifactsDir: string, language: "py", abiTag: string): string {
|
|
24
|
+
return join(artifactsDir, "environments", language, abiTag);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function projectPythonBase(cwd: string): string {
|
|
28
|
+
return join(cwd, ".senpi", "python-packages");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pythonAbiTag(interpreter: string): Promise<string> {
|
|
32
|
+
const { stdout } = await execFileAsync(interpreter, [
|
|
33
|
+
"-c",
|
|
34
|
+
"import sys, sysconfig; print(sys.implementation.cache_tag, sysconfig.get_platform(), sys.executable)",
|
|
35
|
+
]);
|
|
36
|
+
return createHash("sha256").update(stdout.trim()).digest("hex").slice(0, 12);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function installPythonPackages(input: {
|
|
40
|
+
readonly base: string;
|
|
41
|
+
readonly mode: EnvironmentMode;
|
|
42
|
+
readonly interpreter: string;
|
|
43
|
+
readonly requirements: string;
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
readonly signal: AbortSignal;
|
|
46
|
+
readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
|
|
47
|
+
}): Promise<InstallReceipt> {
|
|
48
|
+
const requested = parsePipRequirements(input.requirements);
|
|
49
|
+
let stdout = "";
|
|
50
|
+
const { revision } = await publishNextRevision(
|
|
51
|
+
input.base,
|
|
52
|
+
async (staging) => {
|
|
53
|
+
await runPipInstall({
|
|
54
|
+
interpreter: input.interpreter,
|
|
55
|
+
root: staging,
|
|
56
|
+
args: requested,
|
|
57
|
+
cwd: input.cwd,
|
|
58
|
+
signal: input.signal,
|
|
59
|
+
onOutput: (stream, data) => {
|
|
60
|
+
if (stream === "stdout") stdout += data;
|
|
61
|
+
input.onOutput?.(stream, data);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
await assertInstalledInRevision(stdout, staging);
|
|
65
|
+
await assertNoEditableInstalls(staging);
|
|
66
|
+
},
|
|
67
|
+
input.signal,
|
|
68
|
+
({ holder, waitedMs }) =>
|
|
69
|
+
input.onOutput?.(
|
|
70
|
+
"stderr",
|
|
71
|
+
`[senpi] waiting ${Math.round(waitedMs / 1000)}s for another install into this environment${holder === undefined ? "" : ` (pid ${holder.pid} on ${holder.host})`}\n`,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
const resolved = installedDistributions(stdout);
|
|
75
|
+
return receipt(input.mode, revision, requested, resolved);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function receipt(
|
|
79
|
+
mode: EnvironmentMode,
|
|
80
|
+
revision: Revision,
|
|
81
|
+
requested: readonly string[],
|
|
82
|
+
resolved: readonly string[],
|
|
83
|
+
): InstallReceipt {
|
|
84
|
+
return {
|
|
85
|
+
manager: "pip",
|
|
86
|
+
mode,
|
|
87
|
+
root: revision.dir,
|
|
88
|
+
revision: revision.number,
|
|
89
|
+
requested,
|
|
90
|
+
resolved,
|
|
91
|
+
changed: resolved.length > 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function installedDistributions(stdout: string): string[] {
|
|
96
|
+
const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
|
|
97
|
+
return line === undefined ? [] : line.slice("Successfully installed ".length).trim().split(/\s+/);
|
|
98
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { devNull } from "node:os";
|
|
3
|
+
import { terminateProcessTrees } from "../kernels/js/process-tree-host.ts";
|
|
4
|
+
|
|
5
|
+
export type EnvironmentErrorCode =
|
|
6
|
+
| "environment_install_failed"
|
|
7
|
+
| "environment_install_timeout"
|
|
8
|
+
| "environment_install_cancelled"
|
|
9
|
+
| "environment_installer_unavailable"
|
|
10
|
+
| "environment_resolution_conflict"
|
|
11
|
+
| "environment_language_mismatch";
|
|
12
|
+
|
|
13
|
+
export class EnvironmentError extends Error {
|
|
14
|
+
readonly name = "EnvironmentError";
|
|
15
|
+
readonly code: EnvironmentErrorCode;
|
|
16
|
+
|
|
17
|
+
constructor(code: EnvironmentErrorCode, message: string) {
|
|
18
|
+
super(`${code}: ${message}`);
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// pip accepts any unambiguous prefix of a long option and grouped short flags, so a denylist of destination
|
|
24
|
+
// options can't be complete. Only these options are accepted, each spelled out in full.
|
|
25
|
+
const ALLOWED_FLAGS = new Set([
|
|
26
|
+
"--upgrade",
|
|
27
|
+
"--no-deps",
|
|
28
|
+
"--pre",
|
|
29
|
+
"--force-reinstall",
|
|
30
|
+
"--no-index",
|
|
31
|
+
"--no-cache-dir",
|
|
32
|
+
"--only-binary",
|
|
33
|
+
"--no-binary",
|
|
34
|
+
"--prefer-binary",
|
|
35
|
+
"--index-url",
|
|
36
|
+
"--extra-index-url",
|
|
37
|
+
"--find-links",
|
|
38
|
+
"--constraint",
|
|
39
|
+
"--requirement",
|
|
40
|
+
"--quiet",
|
|
41
|
+
"--verbose",
|
|
42
|
+
]);
|
|
43
|
+
const FLAGS_WITH_VALUE = new Set([
|
|
44
|
+
"--only-binary",
|
|
45
|
+
"--no-binary",
|
|
46
|
+
"--index-url",
|
|
47
|
+
"--extra-index-url",
|
|
48
|
+
"--find-links",
|
|
49
|
+
"--constraint",
|
|
50
|
+
"--requirement",
|
|
51
|
+
]);
|
|
52
|
+
const SHORT_ALIASES: Readonly<Record<string, string>> = {
|
|
53
|
+
"-U": "--upgrade",
|
|
54
|
+
"-i": "--index-url",
|
|
55
|
+
"-f": "--find-links",
|
|
56
|
+
"-c": "--constraint",
|
|
57
|
+
"-r": "--requirement",
|
|
58
|
+
"-q": "--quiet",
|
|
59
|
+
"-v": "--verbose",
|
|
60
|
+
};
|
|
61
|
+
const STDERR_TAIL_BYTES = 4_096;
|
|
62
|
+
const PIP_TREE_GRACE_MS = 2_000;
|
|
63
|
+
|
|
64
|
+
export function parsePipRequirements(text: string): string[] {
|
|
65
|
+
const args = text
|
|
66
|
+
.trim()
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.filter((arg) => arg !== "");
|
|
69
|
+
const command = args[0] === "install" ? args.slice(1) : undefined;
|
|
70
|
+
if (command === undefined) {
|
|
71
|
+
throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
|
|
72
|
+
}
|
|
73
|
+
if (command.length === 0) throw new EnvironmentError("environment_install_failed", "name at least one requirement");
|
|
74
|
+
const normalized: string[] = [];
|
|
75
|
+
for (let index = 0; index < command.length; index++) {
|
|
76
|
+
const arg = command[index] ?? "";
|
|
77
|
+
if (!arg.startsWith("-")) {
|
|
78
|
+
normalized.push(arg);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const [spelled = arg, attached] = arg.split(/=(.*)/su, 2);
|
|
82
|
+
const flag = SHORT_ALIASES[spelled] ?? spelled;
|
|
83
|
+
if (!ALLOWED_FLAGS.has(flag)) {
|
|
84
|
+
throw new EnvironmentError(
|
|
85
|
+
"environment_install_failed",
|
|
86
|
+
`${spelled} is not allowed: %pip accepts ${[...ALLOWED_FLAGS].join(", ")} (each spelled out in full); packages always install into the session's environment root`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!FLAGS_WITH_VALUE.has(flag)) {
|
|
90
|
+
if (attached !== undefined)
|
|
91
|
+
throw new EnvironmentError("environment_install_failed", `${spelled} takes no value`);
|
|
92
|
+
normalized.push(flag);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const value = attached ?? command[++index];
|
|
96
|
+
if (value === undefined || value === "" || value.startsWith("-")) {
|
|
97
|
+
throw new EnvironmentError("environment_install_failed", `${spelled} needs a value`);
|
|
98
|
+
}
|
|
99
|
+
normalized.push(`${flag}=${value}`);
|
|
100
|
+
}
|
|
101
|
+
const named = normalized.some((arg) => !arg.startsWith("-") || arg.startsWith("--requirement="));
|
|
102
|
+
if (!named) throw new EnvironmentError("environment_install_failed", "name at least one requirement");
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* pip's environment with every PIP_* variable removed and its config file pointed at nothing: `--isolated`
|
|
108
|
+
* alone still honours PIP_CONFIG_FILE, and a configured target, root or prefix would install outside the revision.
|
|
109
|
+
*/
|
|
110
|
+
export function isolatedPipEnv(): NodeJS.ProcessEnv {
|
|
111
|
+
const env: NodeJS.ProcessEnv = {};
|
|
112
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
113
|
+
if (!key.toUpperCase().startsWith("PIP_")) env[key] = value;
|
|
114
|
+
}
|
|
115
|
+
// pip skips every config file when PIP_CONFIG_FILE equals Python's os.devnull: "nul" on Windows, not Node's "\\\\.\\nul".
|
|
116
|
+
return { ...env, PIP_CONFIG_FILE: process.platform === "win32" ? "nul" : devNull, PYTHONNOUSERSITE: "1" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function runPipInstall(input: {
|
|
120
|
+
readonly interpreter: string;
|
|
121
|
+
readonly root: string;
|
|
122
|
+
readonly args: readonly string[];
|
|
123
|
+
readonly cwd: string;
|
|
124
|
+
readonly signal: AbortSignal;
|
|
125
|
+
readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
|
|
126
|
+
}): Promise<void> {
|
|
127
|
+
const argv = [
|
|
128
|
+
"-m",
|
|
129
|
+
"pip",
|
|
130
|
+
"install",
|
|
131
|
+
// Ignore pip's config files and PIP_* variables: a configured `root` or `prefix` would write outside the revision.
|
|
132
|
+
"--isolated",
|
|
133
|
+
"--disable-pip-version-check",
|
|
134
|
+
"--no-input",
|
|
135
|
+
"--target",
|
|
136
|
+
input.root,
|
|
137
|
+
...input.args,
|
|
138
|
+
];
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
if (input.signal.aborted) {
|
|
141
|
+
reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const child = spawn(input.interpreter, argv, {
|
|
145
|
+
cwd: input.cwd,
|
|
146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
147
|
+
env: isolatedPipEnv(),
|
|
148
|
+
});
|
|
149
|
+
let stderrTail = "";
|
|
150
|
+
child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
|
|
151
|
+
child.stderr.setEncoding("utf8").on("data", (data: string) => {
|
|
152
|
+
stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
|
|
153
|
+
input.onOutput?.("stderr", data);
|
|
154
|
+
});
|
|
155
|
+
// pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
|
|
156
|
+
const onAbort = () => {
|
|
157
|
+
const pid = child.pid;
|
|
158
|
+
if (pid === undefined) child.kill("SIGKILL");
|
|
159
|
+
else void terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => child.kill("SIGKILL"));
|
|
160
|
+
};
|
|
161
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
162
|
+
child.once("error", (error) => {
|
|
163
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
164
|
+
reject(
|
|
165
|
+
new EnvironmentError("environment_installer_unavailable", `${input.interpreter} -m pip: ${error.message}`),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
child.once("close", (code, signal) => {
|
|
169
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
170
|
+
if (input.signal.aborted) {
|
|
171
|
+
reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"));
|
|
172
|
+
} else if (code === 0) resolve();
|
|
173
|
+
else if (/No module named pip/.test(stderrTail)) {
|
|
174
|
+
reject(new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`));
|
|
175
|
+
} else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
|
|
176
|
+
reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
|
|
177
|
+
} else {
|
|
178
|
+
reject(
|
|
179
|
+
new EnvironmentError(
|
|
180
|
+
"environment_install_failed",
|
|
181
|
+
stderrTail.trim() || `pip exited with ${code ?? signal}`,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|