tmux-ide 2.9.11 → 2.9.13
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/bin/cli.js +6036 -3047
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +6 -0
- package/packages/contracts/src/pane-stream.ts +77 -0
- package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
- package/packages/contracts/src/terminal-delivery.ts +19 -3
- package/packages/contracts/src/terminal-graphics-compressed.ts +66 -0
- package/packages/contracts/src/terminal-graphics.ts +73 -0
- package/packages/contracts/src/terminal-projection.ts +153 -0
- package/packages/contracts/src/terminal-surfaces.ts +137 -0
- package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/terminal-delivery-binary.ts +160 -0
- package/packages/core/src/terminal-delivery.ts +377 -16
- package/packages/core/src/terminal-projection-vectors.mjs +82 -0
- package/packages/core/src/terminal-projection.ts +476 -0
- package/packages/core/src/terminal-replica.ts +34 -1
- package/packages/daemon/dist/command-center/resources/saved-machine-route.js +2 -0
- package/packages/daemon/dist/command-center/resources/saved-machine-watch.js +117 -0
- package/packages/daemon/dist/command-center/server.js +13 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +9 -0
- package/packages/daemon/dist/lib/daemon-embed.js +71 -4
- package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
- package/packages/daemon/dist/lib/native-surface-context.js +96 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +10 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/server/native-surface-provider-upgrade.js +88 -0
- package/packages/daemon/dist/terminal/graphics/compress-chunk.js +40 -0
- package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
- package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-demand.js +99 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider-channel.js +162 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider.js +275 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-viewer.js +146 -0
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +3 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +32 -9
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +369 -16
- package/packages/daemon/dist/terminal/pane-stream/runtime.js +10 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +2 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
- package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +3 -4
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +28 -3
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
- package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
- package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
- package/packages/daemon/src/command-center/resources/saved-machine-route.ts +4 -0
- package/packages/daemon/src/command-center/resources/saved-machine-watch.ts +132 -0
- package/packages/daemon/src/command-center/server.ts +18 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +14 -0
- package/packages/daemon/src/lib/daemon-embed.ts +81 -3
- package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
- package/packages/daemon/src/lib/native-surface-context.ts +123 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +12 -0
- package/packages/daemon/src/server/native-surface-provider-upgrade.ts +103 -0
- package/packages/daemon/src/terminal/graphics/compress-chunk.ts +49 -0
- package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
- package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
- package/packages/daemon/src/terminal/graphics/native-surface-demand.ts +88 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider-channel.ts +161 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider.ts +308 -0
- package/packages/daemon/src/terminal/graphics/native-surface-viewer.ts +154 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +5 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +31 -10
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +446 -15
- package/packages/daemon/src/terminal/pane-stream/runtime.ts +14 -1
- package/packages/daemon/src/terminal/session-runtime/registry.ts +2 -0
- package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
- package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +3 -4
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +26 -4
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
- package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
- package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
- package/packages/daemon-client/src/pane-stream-client.ts +75 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Server, IncomingMessage } from "node:http";
|
|
2
|
+
import type { Socket } from "node:net";
|
|
3
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
import { TERMINAL_SURFACE_MAX_CONTROL_BYTES } from "@tmux-ide/contracts";
|
|
5
|
+
import { ownerBearerMatches } from "../command-center/owner-authority.ts";
|
|
6
|
+
import type { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.ts";
|
|
7
|
+
import { NativeSurfaceProviderChannel } from "../terminal/graphics/native-surface-provider-channel.ts";
|
|
8
|
+
|
|
9
|
+
export const NATIVE_SURFACE_PROVIDER_PATH = "/v1/terminal/native-surface-provider";
|
|
10
|
+
const PROTOCOL = "tmux-ide-native-surface-provider-v1";
|
|
11
|
+
|
|
12
|
+
/** Separate owner-authenticated producer authority. Loopback, PID and Origin do
|
|
13
|
+
* not grant access. At most one connection (including registration) is admitted.
|
|
14
|
+
* Native kernel proof remains required independently of this control channel. */
|
|
15
|
+
export function attachNativeSurfaceProvider(
|
|
16
|
+
server: Server,
|
|
17
|
+
registry: NativeSurfaceProviderRegistry,
|
|
18
|
+
ownerToken: string | null,
|
|
19
|
+
isCurrent: () => boolean,
|
|
20
|
+
) {
|
|
21
|
+
const wss = new WebSocketServer({
|
|
22
|
+
noServer: true,
|
|
23
|
+
clientTracking: false,
|
|
24
|
+
perMessageDeflate: false,
|
|
25
|
+
maxPayload: TERMINAL_SURFACE_MAX_CONTROL_BYTES,
|
|
26
|
+
handleProtocols: (values) => (values.size === 1 && values.has(PROTOCOL) ? PROTOCOL : false),
|
|
27
|
+
});
|
|
28
|
+
let active: WebSocket | null = null;
|
|
29
|
+
let channel: NativeSurfaceProviderChannel | null = null;
|
|
30
|
+
let closed = false;
|
|
31
|
+
const upgrade = (request: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
32
|
+
if (request.url?.split("?", 1)[0] !== NATIVE_SURFACE_PROVIDER_PATH) return;
|
|
33
|
+
const headers = (name: string) =>
|
|
34
|
+
request.rawHeaders.filter((_, i) => i % 2 === 0).filter((key) => key.toLowerCase() === name)
|
|
35
|
+
.length;
|
|
36
|
+
const authorized =
|
|
37
|
+
headers("authorization") === 1 &&
|
|
38
|
+
ownerBearerMatches(request.headers.authorization, ownerToken);
|
|
39
|
+
const status = !authorized
|
|
40
|
+
? 403
|
|
41
|
+
: closed || !isCurrent()
|
|
42
|
+
? 410
|
|
43
|
+
: active || registry.available
|
|
44
|
+
? 409
|
|
45
|
+
: request.url !== NATIVE_SURFACE_PROVIDER_PATH ||
|
|
46
|
+
headers("sec-websocket-protocol") !== 1 ||
|
|
47
|
+
request.headers["sec-websocket-protocol"] !== PROTOCOL
|
|
48
|
+
? 400
|
|
49
|
+
: 0;
|
|
50
|
+
if (status) {
|
|
51
|
+
socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
55
|
+
active = ws;
|
|
56
|
+
const owner = new NativeSurfaceProviderChannel(
|
|
57
|
+
registry,
|
|
58
|
+
(message) => {
|
|
59
|
+
if (
|
|
60
|
+
!isCurrent() ||
|
|
61
|
+
ws.readyState !== WebSocket.OPEN ||
|
|
62
|
+
ws.bufferedAmount + Buffer.byteLength(message) > 64 * 1024
|
|
63
|
+
)
|
|
64
|
+
return false;
|
|
65
|
+
ws.send(message, (error) => {
|
|
66
|
+
if (error) owner.close();
|
|
67
|
+
});
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
() => ws.terminate(),
|
|
71
|
+
);
|
|
72
|
+
channel = owner;
|
|
73
|
+
const timer = setTimeout(() => owner.close(), 5000);
|
|
74
|
+
timer.unref();
|
|
75
|
+
ws.on("message", (data, binary) => {
|
|
76
|
+
if (binary || !isCurrent()) {
|
|
77
|
+
owner.close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
owner.receive(data.toString());
|
|
81
|
+
if (registry.available) clearTimeout(timer);
|
|
82
|
+
});
|
|
83
|
+
ws.on("error", () => owner.close());
|
|
84
|
+
ws.on("close", () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
owner.close();
|
|
87
|
+
if (active === ws) {
|
|
88
|
+
active = null;
|
|
89
|
+
channel = null;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
server.on("upgrade", upgrade);
|
|
95
|
+
return {
|
|
96
|
+
close: () => {
|
|
97
|
+
closed = true;
|
|
98
|
+
server.off("upgrade", upgrade);
|
|
99
|
+
channel?.close();
|
|
100
|
+
wss.close();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { deflate } from "node:zlib";
|
|
2
|
+
import {
|
|
3
|
+
encodeTerminalGraphicsCompressed,
|
|
4
|
+
TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES,
|
|
5
|
+
type TerminalGraphicsBinaryHeader,
|
|
6
|
+
} from "@tmux-ide/contracts";
|
|
7
|
+
|
|
8
|
+
// Across panes, windows and connections: no queued compression backlog and at
|
|
9
|
+
// most two chunk inputs/outputs retained by libuv. Never compress on the JS loop.
|
|
10
|
+
let active = 0;
|
|
11
|
+
// Prefer flat-color UI chunks. A bounded, spread-out sample avoids spending a
|
|
12
|
+
// libuv job on noisy frames. False negatives only choose the raw fallback.
|
|
13
|
+
function likelyCompressible(bytes: Uint8Array): boolean {
|
|
14
|
+
const counts = new Uint16Array(256);
|
|
15
|
+
const samples = 4096;
|
|
16
|
+
for (let i = 0; i < samples; i++) {
|
|
17
|
+
const value = bytes[Math.floor((i * (bytes.length - 1)) / (samples - 1))]!;
|
|
18
|
+
if (++counts[value]! >= samples / 32) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export async function compressGraphicsChunk(
|
|
23
|
+
header: TerminalGraphicsBinaryHeader,
|
|
24
|
+
bytes: Uint8Array,
|
|
25
|
+
): Promise<Uint8Array | null> {
|
|
26
|
+
if (
|
|
27
|
+
active >= 2 ||
|
|
28
|
+
bytes.length < 4096 ||
|
|
29
|
+
bytes.length > TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES ||
|
|
30
|
+
(header.blob.format !== "rgb" && header.blob.format !== "rgba")
|
|
31
|
+
)
|
|
32
|
+
return null;
|
|
33
|
+
if (!likelyCompressible(bytes)) return null;
|
|
34
|
+
active++;
|
|
35
|
+
try {
|
|
36
|
+
const encoded = await new Promise<Buffer | null>((resolve) => {
|
|
37
|
+
deflate(bytes, { level: 1 }, (error, result) => resolve(error ? null : result));
|
|
38
|
+
});
|
|
39
|
+
// Savings must cover metadata and be material; incompressible images retain
|
|
40
|
+
// the original binary path. PNG/producer-zlib are never recompressed.
|
|
41
|
+
if (!encoded || encoded.length + 128 > bytes.length * 0.875) return null;
|
|
42
|
+
return encodeTerminalGraphicsCompressed(
|
|
43
|
+
{ ...header, compression: "zlib", decodedBytes: bytes.length },
|
|
44
|
+
encoded,
|
|
45
|
+
);
|
|
46
|
+
} finally {
|
|
47
|
+
active--;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, type Stats } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
rmdir,
|
|
12
|
+
unlink,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { LocalImageStore, type LocalImageLease } from "./local-image-store.ts";
|
|
17
|
+
|
|
18
|
+
const OwnerSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
generation: z.uuid(),
|
|
21
|
+
pid: z.number().int().positive().max(2147483647),
|
|
22
|
+
startedAt: z.iso.datetime(),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
export type LocalImageOwnerIdentity = z.infer<typeof OwnerSchema>;
|
|
26
|
+
const FileSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
dev: z.number().nonnegative(),
|
|
29
|
+
ino: z.number().nonnegative(),
|
|
30
|
+
mode: z.number().int(),
|
|
31
|
+
size: z.number().nonnegative(),
|
|
32
|
+
mtimeMs: z.number(),
|
|
33
|
+
ctimeMs: z.number(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
const ManifestSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
version: z.literal(1),
|
|
39
|
+
owner: OwnerSchema,
|
|
40
|
+
directory: FileSchema,
|
|
41
|
+
store: z
|
|
42
|
+
.object({ name: z.string().regex(/^local-images-[A-Za-z0-9]+$/u), identity: FileSchema })
|
|
43
|
+
.strict(),
|
|
44
|
+
files: z
|
|
45
|
+
.array(z.object({ name: z.string().regex(/^[a-f0-9]{64}$/u), identity: FileSchema }).strict())
|
|
46
|
+
.max(512),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
type Manifest = z.infer<typeof ManifestSchema>;
|
|
50
|
+
const record = (s: Stats) => ({
|
|
51
|
+
dev: s.dev,
|
|
52
|
+
ino: s.ino,
|
|
53
|
+
mode: s.mode,
|
|
54
|
+
size: s.size,
|
|
55
|
+
mtimeMs: s.mtimeMs,
|
|
56
|
+
ctimeMs: s.ctimeMs,
|
|
57
|
+
});
|
|
58
|
+
const same = (a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">) =>
|
|
59
|
+
a.dev === b.dev && a.ino === b.ino;
|
|
60
|
+
const privateDir = (s: Stats) =>
|
|
61
|
+
s.isDirectory() && s.uid === process.getuid?.() && (s.mode & 0o077) === 0;
|
|
62
|
+
const privateFile = (s: Stats) =>
|
|
63
|
+
s.isFile() && s.uid === process.getuid?.() && (s.mode & 0o077) === 0;
|
|
64
|
+
async function directory(path: string, expected?: Pick<Stats, "dev" | "ino">) {
|
|
65
|
+
const stat = await lstat(path);
|
|
66
|
+
if (!privateDir(stat) || (expected && !same(stat, expected)))
|
|
67
|
+
throw new Error("Local image owner directory replaced");
|
|
68
|
+
return stat;
|
|
69
|
+
}
|
|
70
|
+
async function readManifest(path: string): Promise<{ manifest: Manifest; identity: Stats }> {
|
|
71
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
72
|
+
try {
|
|
73
|
+
const stat = await file.stat();
|
|
74
|
+
if (!privateFile(stat) || stat.size > 256 * 1024)
|
|
75
|
+
throw new Error("Invalid local image manifest file");
|
|
76
|
+
const bytes = Buffer.alloc(stat.size);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
while (offset < bytes.length) {
|
|
79
|
+
const read = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
80
|
+
if (!read.bytesRead) throw new Error("Truncated local image manifest");
|
|
81
|
+
offset += read.bytesRead;
|
|
82
|
+
}
|
|
83
|
+
const current = await lstat(path);
|
|
84
|
+
if (!same(stat, current) || current.size !== stat.size || current.mtimeMs !== stat.mtimeMs)
|
|
85
|
+
throw new Error("Local image manifest replaced");
|
|
86
|
+
const manifest = ManifestSchema.parse(JSON.parse(bytes.toString("utf8")));
|
|
87
|
+
if (new Set(manifest.files.map((f) => f.name)).size !== manifest.files.length)
|
|
88
|
+
throw new Error("Duplicate local image manifest file");
|
|
89
|
+
return { manifest, identity: stat };
|
|
90
|
+
} finally {
|
|
91
|
+
await file.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
interface Options extends LocalImageOwnerIdentity {
|
|
95
|
+
/** Already proven private canonical root, while holding the startup claim. */
|
|
96
|
+
root: string;
|
|
97
|
+
store?: Pick<
|
|
98
|
+
Parameters<typeof LocalImageStore.create>[0],
|
|
99
|
+
"maxBytes" | "maxFiles" | "maxLeases" | "ttlMs"
|
|
100
|
+
>;
|
|
101
|
+
}
|
|
102
|
+
/** Per-generation lifecycle wrapper. The lease is never published before its
|
|
103
|
+
* owned file identity is persisted. A crash before journal publication leaves
|
|
104
|
+
* an unrecorded file, which cleanup deliberately preserves rather than guesses.
|
|
105
|
+
*/
|
|
106
|
+
export class LocalImageOwner {
|
|
107
|
+
#tail: Promise<unknown> = Promise.resolve();
|
|
108
|
+
#disposal: Promise<void> | undefined;
|
|
109
|
+
#manifestIdentity: Stats | undefined;
|
|
110
|
+
private constructor(
|
|
111
|
+
readonly path: string,
|
|
112
|
+
readonly identity: LocalImageOwnerIdentity,
|
|
113
|
+
private readonly store: LocalImageStore,
|
|
114
|
+
readonly root: string,
|
|
115
|
+
readonly rootIdentity: Stats,
|
|
116
|
+
readonly pathIdentity: Stats,
|
|
117
|
+
readonly storeIdentity: Stats,
|
|
118
|
+
) {}
|
|
119
|
+
static async create(options: Options): Promise<LocalImageOwner> {
|
|
120
|
+
const identity = OwnerSchema.parse({
|
|
121
|
+
generation: options.generation,
|
|
122
|
+
pid: options.pid,
|
|
123
|
+
startedAt: options.startedAt,
|
|
124
|
+
});
|
|
125
|
+
const rootIdentity = await directory(options.root);
|
|
126
|
+
const root = await realpath(options.root);
|
|
127
|
+
await directory(root, rootIdentity);
|
|
128
|
+
const path = join(root, `local-images-${identity.generation}`);
|
|
129
|
+
await mkdir(path, { mode: 0o700 }); // Exclusive: never adopt another instance directory.
|
|
130
|
+
const pathIdentity = await directory(path);
|
|
131
|
+
const store = await LocalImageStore.create({
|
|
132
|
+
parent: path,
|
|
133
|
+
generation: identity.generation,
|
|
134
|
+
...options.store,
|
|
135
|
+
});
|
|
136
|
+
const owner = new LocalImageOwner(
|
|
137
|
+
path,
|
|
138
|
+
identity,
|
|
139
|
+
store,
|
|
140
|
+
root,
|
|
141
|
+
rootIdentity,
|
|
142
|
+
pathIdentity,
|
|
143
|
+
await directory(store.directory),
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
await owner.#persist();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await store.dispose().catch(() => {});
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return owner;
|
|
152
|
+
}
|
|
153
|
+
#serial<T>(run: () => Promise<T>): Promise<T> {
|
|
154
|
+
const result = this.#tail.then(run);
|
|
155
|
+
this.#tail = result.catch(() => {});
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
async #check() {
|
|
159
|
+
await directory(this.root, this.rootIdentity);
|
|
160
|
+
await directory(this.path, this.pathIdentity);
|
|
161
|
+
}
|
|
162
|
+
async #persist() {
|
|
163
|
+
await this.#check();
|
|
164
|
+
const files = await this.store.ownedFiles();
|
|
165
|
+
const manifest: Manifest = {
|
|
166
|
+
version: 1,
|
|
167
|
+
owner: this.identity,
|
|
168
|
+
directory: record(this.pathIdentity),
|
|
169
|
+
store: { name: basename(this.store.directory), identity: record(this.storeIdentity) },
|
|
170
|
+
files: files.map((f) => ({ name: basename(f.path), identity: record(f.identity) })),
|
|
171
|
+
};
|
|
172
|
+
const encoded = JSON.stringify(ManifestSchema.parse(manifest));
|
|
173
|
+
const path = join(this.path, "owner.json");
|
|
174
|
+
if (this.#manifestIdentity) {
|
|
175
|
+
const current = await readManifest(path);
|
|
176
|
+
if (
|
|
177
|
+
JSON.stringify(record(current.identity)) !== JSON.stringify(record(this.#manifestIdentity))
|
|
178
|
+
)
|
|
179
|
+
throw new Error("Local image manifest replaced");
|
|
180
|
+
// Leases are in-memory; only the owned file set is crash-recovery data.
|
|
181
|
+
// Reuse its durable record after checking identity, including on shared leases.
|
|
182
|
+
if (JSON.stringify(current.manifest) === encoded) return;
|
|
183
|
+
}
|
|
184
|
+
const temporary = join(this.path, `.owner-${randomUUID()}`);
|
|
185
|
+
const file = await open(
|
|
186
|
+
temporary,
|
|
187
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
188
|
+
0o600,
|
|
189
|
+
);
|
|
190
|
+
const stat = await file.stat();
|
|
191
|
+
try {
|
|
192
|
+
await file.writeFile(encoded);
|
|
193
|
+
await file.sync();
|
|
194
|
+
await this.#check();
|
|
195
|
+
if (!same(await lstat(temporary), stat))
|
|
196
|
+
throw new Error("Local image manifest staging replaced");
|
|
197
|
+
if (this.#manifestIdentity) await rename(temporary, path);
|
|
198
|
+
else {
|
|
199
|
+
await link(temporary, path);
|
|
200
|
+
await unlink(temporary);
|
|
201
|
+
}
|
|
202
|
+
this.#manifestIdentity = await lstat(path);
|
|
203
|
+
if (!same(this.#manifestIdentity, stat))
|
|
204
|
+
throw new Error("Local image manifest publication replaced");
|
|
205
|
+
} finally {
|
|
206
|
+
await file.close();
|
|
207
|
+
await this.#removeStaging(temporary, stat);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async #removeStaging(path: string, identity: Stats): Promise<void> {
|
|
211
|
+
await this.#check();
|
|
212
|
+
let remaining: Stats;
|
|
213
|
+
try {
|
|
214
|
+
remaining = await lstat(path);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (!remaining.isFile() || !same(remaining, identity))
|
|
220
|
+
throw new Error("Local image manifest cleanup refused replacement");
|
|
221
|
+
await unlink(path);
|
|
222
|
+
}
|
|
223
|
+
acquire(input: Parameters<LocalImageStore["acquire"]>[0]): Promise<LocalImageLease> {
|
|
224
|
+
// Store admission snapshots mutable bytes synchronously and bounds queued memory.
|
|
225
|
+
const lease = this.store.acquire(input);
|
|
226
|
+
void lease.catch(() => {});
|
|
227
|
+
return this.#serial(async () => {
|
|
228
|
+
const result = await lease;
|
|
229
|
+
try {
|
|
230
|
+
await this.#persist();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await this.store.release(result).catch(() => {});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
release(scope: Parameters<LocalImageStore["release"]>[0]): Promise<boolean> {
|
|
239
|
+
return this.#serial(async () => {
|
|
240
|
+
const result = await this.store.release(scope);
|
|
241
|
+
await this.#persist();
|
|
242
|
+
return result;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
releaseClient(scope: Parameters<LocalImageStore["releaseClient"]>[0]): Promise<void> {
|
|
246
|
+
return this.#serial(async () => {
|
|
247
|
+
await this.store.releaseClient(scope);
|
|
248
|
+
await this.#persist();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
dispose(): Promise<void> {
|
|
252
|
+
return (this.#disposal ??= this.#serial(async () => {
|
|
253
|
+
await this.#check();
|
|
254
|
+
await this.store.dispose();
|
|
255
|
+
const path = join(this.path, "owner.json");
|
|
256
|
+
const current = await readManifest(path);
|
|
257
|
+
if (
|
|
258
|
+
!this.#manifestIdentity ||
|
|
259
|
+
JSON.stringify(record(current.identity)) !== JSON.stringify(record(this.#manifestIdentity))
|
|
260
|
+
)
|
|
261
|
+
throw new Error("Local image manifest replaced");
|
|
262
|
+
await unlink(path);
|
|
263
|
+
await rmdir(this.path);
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** No directory discovery. Caller supplies the exact predecessor read under the
|
|
269
|
+
* canonical claim, and its existing authoritative dead-owner check. PID reuse
|
|
270
|
+
* or uncertainty retains files even if generation/start identity differs.
|
|
271
|
+
*/
|
|
272
|
+
export async function cleanupLocalImageOwner(options: {
|
|
273
|
+
root: string;
|
|
274
|
+
owner: LocalImageOwnerIdentity;
|
|
275
|
+
proveDead: (owner: LocalImageOwnerIdentity) => Promise<boolean>;
|
|
276
|
+
}): Promise<boolean> {
|
|
277
|
+
const owner = OwnerSchema.parse(options.owner);
|
|
278
|
+
const rootIdentity = await directory(options.root);
|
|
279
|
+
const path = join(options.root, `local-images-${owner.generation}`);
|
|
280
|
+
let pathIdentity: Stats;
|
|
281
|
+
try {
|
|
282
|
+
pathIdentity = await directory(path);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
const manifestPath = join(path, "owner.json");
|
|
288
|
+
const { manifest, identity: manifestIdentity } = await readManifest(manifestPath);
|
|
289
|
+
if (
|
|
290
|
+
JSON.stringify(manifest.owner) !== JSON.stringify(owner) ||
|
|
291
|
+
!same(manifest.directory, pathIdentity)
|
|
292
|
+
)
|
|
293
|
+
return false;
|
|
294
|
+
if (!(await options.proveDead(owner))) return false;
|
|
295
|
+
try {
|
|
296
|
+
process.kill(owner.pid, 0);
|
|
297
|
+
return false;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false;
|
|
300
|
+
}
|
|
301
|
+
await directory(options.root, rootIdentity);
|
|
302
|
+
await directory(path, pathIdentity);
|
|
303
|
+
const storePath = join(path, manifest.store.name);
|
|
304
|
+
let storePresent = true;
|
|
305
|
+
try {
|
|
306
|
+
await directory(storePath, manifest.store.identity);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
309
|
+
storePresent = false;
|
|
310
|
+
}
|
|
311
|
+
for (const entry of storePresent ? manifest.files : []) {
|
|
312
|
+
await directory(options.root, rootIdentity);
|
|
313
|
+
await directory(path, pathIdentity);
|
|
314
|
+
await directory(storePath, manifest.store.identity);
|
|
315
|
+
const file = join(storePath, entry.name);
|
|
316
|
+
let stat: Stats;
|
|
317
|
+
try {
|
|
318
|
+
stat = await lstat(file);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
if (!privateFile(stat) || JSON.stringify(record(stat)) !== JSON.stringify(entry.identity))
|
|
324
|
+
return false;
|
|
325
|
+
await unlink(file);
|
|
326
|
+
}
|
|
327
|
+
// Unknown/staging/replaced files make rmdir fail; none are removed speculatively.
|
|
328
|
+
try {
|
|
329
|
+
if (storePresent) await rmdir(storePath);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if ((error as NodeJS.ErrnoException).code === "ENOTEMPTY") return false;
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
const current = await readManifest(manifestPath);
|
|
335
|
+
if (JSON.stringify(record(current.identity)) !== JSON.stringify(record(manifestIdentity)))
|
|
336
|
+
return false;
|
|
337
|
+
if ((await readdir(path)).some((name) => name !== "owner.json")) return false;
|
|
338
|
+
await unlink(manifestPath);
|
|
339
|
+
try {
|
|
340
|
+
await rmdir(path);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if ((error as NodeJS.ErrnoException).code === "ENOTEMPTY") return false;
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|