@abelxiaoxing/cadence 2.0.0 → 2.0.2
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 +61 -41
- package/docs/reuse-and-distribution.md +2 -1
- package/docs/service-management.md +50 -3
- package/package.json +9 -10
- package/src/install-config.mjs +25 -0
- package/src/native-client.ts +31 -3
- package/src/operator-cli.mjs +52 -16
- package/src/service/coordination.ts +35 -16
- package/src/service/private-files.ts +128 -23
- package/src/service-cli.mjs +734 -144
- package/src/service-host.mjs +839 -313
- package/src/task-shell.ts +16 -6
- package/src/task-store.ts +104 -25
- package/src/worker-tools.mjs +42 -17
- package/web/dist/assets/cadence.js +1 -1
- package/web/dist/manifest.json +3 -3
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
text,
|
|
20
20
|
} from "./contracts/core.ts";
|
|
21
21
|
import {
|
|
22
|
+
DirectoryWalks,
|
|
22
23
|
encoded,
|
|
23
24
|
type FileSnapshot,
|
|
24
25
|
hash,
|
|
@@ -282,8 +283,8 @@ export class ServiceCoordination {
|
|
|
282
283
|
},
|
|
283
284
|
};
|
|
284
285
|
}
|
|
285
|
-
assertNamespace(): void {
|
|
286
|
-
if (!this.files.directory(this.directory)) refused();
|
|
286
|
+
assertNamespace(walks?: DirectoryWalks): void {
|
|
287
|
+
if (!this.files.directory(this.directory, false, walks)) refused();
|
|
287
288
|
names(this.directory, [
|
|
288
289
|
"format.json",
|
|
289
290
|
"config.json",
|
|
@@ -295,13 +296,18 @@ export class ServiceCoordination {
|
|
|
295
296
|
"host-config.json",
|
|
296
297
|
"history-v2",
|
|
297
298
|
]);
|
|
298
|
-
this.files.directory(join(this.directory, "history-v2"));
|
|
299
|
+
this.files.directory(join(this.directory, "history-v2"), false, walks);
|
|
299
300
|
}
|
|
300
301
|
private selected(): Selected | undefined {
|
|
301
|
-
|
|
302
|
-
this.
|
|
303
|
-
|
|
304
|
-
const
|
|
302
|
+
const walks = new DirectoryWalks();
|
|
303
|
+
if (!this.files.directory(this.directory, false, walks)) return undefined;
|
|
304
|
+
this.assertNamespace(walks);
|
|
305
|
+
const manifest = this.files.read(
|
|
306
|
+
this.path("format.json"),
|
|
307
|
+
undefined,
|
|
308
|
+
walks,
|
|
309
|
+
);
|
|
310
|
+
const config = this.files.read(this.path("config.json"), undefined, walks);
|
|
305
311
|
if (!manifest || !config) refused();
|
|
306
312
|
const f = closed(json(manifest), [
|
|
307
313
|
"schema",
|
|
@@ -314,12 +320,24 @@ export class ServiceCoordination {
|
|
|
314
320
|
literal(f.stateHome, this.stateHome);
|
|
315
321
|
literal(f.configIdentity, config.identity);
|
|
316
322
|
const c = decodeOwnerConfig(json(config));
|
|
317
|
-
const hostConfig = this.files.read(
|
|
323
|
+
const hostConfig = this.files.read(
|
|
324
|
+
this.path("host-config.json"),
|
|
325
|
+
undefined,
|
|
326
|
+
walks,
|
|
327
|
+
);
|
|
318
328
|
if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
|
|
319
|
-
const registration = this.files.read(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
329
|
+
const registration = this.files.read(
|
|
330
|
+
this.path("registration.json"),
|
|
331
|
+
undefined,
|
|
332
|
+
walks,
|
|
333
|
+
);
|
|
334
|
+
const owner = this.files.read(this.path("owner.lock"), undefined, walks);
|
|
335
|
+
const capability = this.files.read(
|
|
336
|
+
this.path("local-capability.json"),
|
|
337
|
+
undefined,
|
|
338
|
+
walks,
|
|
339
|
+
);
|
|
340
|
+
const checkout = this.files.read(this.store.lockPath, 8192, walks);
|
|
323
341
|
const value = registration
|
|
324
342
|
? decodeRegistration(json(registration))
|
|
325
343
|
: undefined;
|
|
@@ -808,8 +826,9 @@ export class OwnerLease {
|
|
|
808
826
|
this.selected.value.host !== hostname()
|
|
809
827
|
)
|
|
810
828
|
throw new ContractError("conflict");
|
|
811
|
-
const c = this.coordination
|
|
812
|
-
|
|
829
|
+
const c = this.coordination,
|
|
830
|
+
walks = new DirectoryWalks();
|
|
831
|
+
c.assertNamespace(walks);
|
|
813
832
|
for (const [name, snapshot] of [
|
|
814
833
|
["config.json", this.selected.config],
|
|
815
834
|
["host-config.json", this.selected.hostConfig],
|
|
@@ -818,8 +837,8 @@ export class OwnerLease {
|
|
|
818
837
|
["owner.lock", this.selected.owner],
|
|
819
838
|
["local-capability.json", this.selected.capability],
|
|
820
839
|
] as const)
|
|
821
|
-
c.files.assert(c.path(name), snapshot);
|
|
822
|
-
c.files.assert(c.store.lockPath, this.selected.checkout, 8192);
|
|
840
|
+
c.files.assert(c.path(name), snapshot, undefined, walks);
|
|
841
|
+
c.files.assert(c.store.lockPath, this.selected.checkout, 8192, walks);
|
|
823
842
|
c.store.assertOwner(this.checkout);
|
|
824
843
|
}
|
|
825
844
|
phase(phase: OwnerPhase): void {
|
|
@@ -5,14 +5,16 @@ import {
|
|
|
5
5
|
fstatSync,
|
|
6
6
|
fsyncSync,
|
|
7
7
|
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
8
9
|
openSync,
|
|
10
|
+
readSync,
|
|
9
11
|
renameSync,
|
|
10
12
|
type Stats,
|
|
11
13
|
unlinkSync,
|
|
12
14
|
writeFileSync,
|
|
13
15
|
} from "node:fs";
|
|
14
16
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
-
import {
|
|
17
|
+
import { retryWindowsFileOperation, safeDirectory } from "../task-store.ts";
|
|
16
18
|
import { ContractError, parseWireJson } from "./contracts/core.ts";
|
|
17
19
|
|
|
18
20
|
export const METADATA_BYTES = 16 * 1024;
|
|
@@ -46,37 +48,128 @@ function same(a: Stats, b: Stats): boolean {
|
|
|
46
48
|
function missing(error: unknown): boolean {
|
|
47
49
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
48
50
|
}
|
|
51
|
+
// Advances once per job, at its first microtask checkpoint. Nothing queued
|
|
52
|
+
// retains a scope, so a dropped read budget is collected as usual.
|
|
53
|
+
let job = 0,
|
|
54
|
+
advancing = false;
|
|
55
|
+
function currentJob(): number {
|
|
56
|
+
if (!advancing) {
|
|
57
|
+
advancing = true;
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
job++;
|
|
60
|
+
advancing = false;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return job;
|
|
64
|
+
}
|
|
65
|
+
/** Directories that ONE synchronous operation already walked. Reuse re-observes
|
|
66
|
+
* the directory itself, no-follow, with the walked dev/ino and private mode: an
|
|
67
|
+
* ancestor substituted for other content resolves elsewhere and walks again. It
|
|
68
|
+
* goes stale at the next microtask checkpoint, so an await or a later call
|
|
69
|
+
* always walks; an ancestor's mode change inside one operation is seen by the
|
|
70
|
+
* next. Absence, refusal and any changed identity fall back to the full walk.
|
|
71
|
+
*/
|
|
72
|
+
export class DirectoryWalks {
|
|
73
|
+
private readonly job = currentJob();
|
|
74
|
+
private readonly walked = new Map<string, { dev: number; ino: number }>();
|
|
75
|
+
private open(): boolean {
|
|
76
|
+
if (this.job === job) return true;
|
|
77
|
+
this.walked.clear();
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
remember(path: string, st: Stats): void {
|
|
81
|
+
if (this.open()) this.walked.set(path, { dev: st.dev, ino: st.ino });
|
|
82
|
+
}
|
|
83
|
+
reuse(path: string): boolean {
|
|
84
|
+
const walked = this.open() ? this.walked.get(path) : undefined;
|
|
85
|
+
if (!walked) return false;
|
|
86
|
+
let st: Stats;
|
|
87
|
+
try {
|
|
88
|
+
st = lstatSync(path);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!missing(error)) throw error;
|
|
91
|
+
this.walked.delete(path);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
!st.isDirectory() ||
|
|
96
|
+
st.isSymbolicLink() ||
|
|
97
|
+
st.dev !== walked.dev ||
|
|
98
|
+
st.ino !== walked.ino
|
|
99
|
+
) {
|
|
100
|
+
this.walked.delete(path);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
privateMode(st);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
49
107
|
/** Strengthens the existing helpers only inside the owned private namespace.
|
|
50
108
|
* Ambient ancestors retain TaskStore's canonicalization policy. No directory CAS
|
|
51
109
|
* or hostile-same-user guarantee; failed temporaries are deliberately preserved.
|
|
52
110
|
*/
|
|
53
111
|
export class PrivateFiles {
|
|
54
112
|
constructor(readonly namespace: string) {}
|
|
55
|
-
directory(path: string, create = false): boolean {
|
|
113
|
+
directory(path: string, create = false, walks?: DirectoryWalks): boolean {
|
|
56
114
|
const tail = relative(this.namespace, path);
|
|
57
115
|
if (isAbsolute(tail) || tail === ".." || tail.startsWith(`..${sep}`))
|
|
58
116
|
refused();
|
|
59
117
|
if (!create) {
|
|
118
|
+
const full = resolve(path);
|
|
119
|
+
if (walks?.reuse(full)) return true;
|
|
60
120
|
const namespace = resolve(this.namespace);
|
|
61
|
-
let inside = false
|
|
62
|
-
|
|
121
|
+
let inside = false,
|
|
122
|
+
last: Stats | undefined;
|
|
123
|
+
const found = safeDirectory(path, false, (at, st) => {
|
|
63
124
|
// Exact segment boundary, not a textual prefix. One synchronous walk
|
|
64
125
|
// checks every ambient ancestor and each existing private ancestor,
|
|
65
|
-
// including an unsafe parent before a missing child.
|
|
126
|
+
// including an unsafe parent before a missing child. Only the caller's
|
|
127
|
+
// DirectoryWalks keeps the result, for that same synchronous operation.
|
|
66
128
|
if (at === namespace) inside = true;
|
|
67
129
|
if (inside) privateMode(st);
|
|
130
|
+
last = st;
|
|
68
131
|
});
|
|
132
|
+
if (found && last) walks?.remember(full, last);
|
|
133
|
+
return found;
|
|
69
134
|
}
|
|
135
|
+
// Walk the namespace and its ambient ancestors once, then check each
|
|
136
|
+
// private segment exactly once, creating a missing one privately.
|
|
137
|
+
if (!safeDirectory(this.namespace, true)) return false;
|
|
138
|
+
privateMode(lstatSync(this.namespace));
|
|
70
139
|
let at = this.namespace;
|
|
71
|
-
for (const part of
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
140
|
+
for (const part of tail.split(sep).filter(Boolean)) {
|
|
141
|
+
at = join(at, part);
|
|
142
|
+
let st: Stats;
|
|
143
|
+
try {
|
|
144
|
+
st = lstatSync(at);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!missing(error)) throw error;
|
|
147
|
+
try {
|
|
148
|
+
mkdirSync(at, { mode: 0o700 });
|
|
149
|
+
} catch (mkdirError) {
|
|
150
|
+
if (
|
|
151
|
+
!(
|
|
152
|
+
mkdirError instanceof Error &&
|
|
153
|
+
"code" in mkdirError &&
|
|
154
|
+
mkdirError.code === "EEXIST"
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
throw mkdirError;
|
|
158
|
+
}
|
|
159
|
+
st = lstatSync(at);
|
|
160
|
+
}
|
|
161
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
162
|
+
throw new Error("Unsafe directory");
|
|
163
|
+
privateMode(st);
|
|
75
164
|
}
|
|
76
165
|
return true;
|
|
77
166
|
}
|
|
78
|
-
read(
|
|
79
|
-
|
|
167
|
+
read(
|
|
168
|
+
path: string,
|
|
169
|
+
maximum = METADATA_BYTES,
|
|
170
|
+
walks?: DirectoryWalks,
|
|
171
|
+
): FileSnapshot | undefined {
|
|
172
|
+
if (!this.directory(dirname(path), false, walks)) return undefined;
|
|
80
173
|
let before: Stats;
|
|
81
174
|
try {
|
|
82
175
|
before = lstatSync(path);
|
|
@@ -91,15 +184,20 @@ export class PrivateFiles {
|
|
|
91
184
|
try {
|
|
92
185
|
const opened = fstatSync(fd);
|
|
93
186
|
if (!same(before, opened)) refused();
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
187
|
+
// Read this already opened, no-follow, identity-checked descriptor: its
|
|
188
|
+
// parents were walked just above, so a second walk and open add nothing.
|
|
189
|
+
// Bound the read to the selected size (+1 growth sentinel) and keep the
|
|
190
|
+
// final descriptor/path identity checks.
|
|
191
|
+
const buffer = Buffer.alloc(opened.size + 1);
|
|
192
|
+
let size = 0;
|
|
193
|
+
while (size < buffer.length) {
|
|
194
|
+
const n = readSync(fd, buffer, size, buffer.length - size, null);
|
|
195
|
+
if (!n) break;
|
|
196
|
+
size += n;
|
|
197
|
+
}
|
|
198
|
+
const bytes = buffer.subarray(0, size);
|
|
100
199
|
if (
|
|
101
|
-
|
|
102
|
-
bytes.length !== opened.size ||
|
|
200
|
+
size !== opened.size ||
|
|
103
201
|
!same(opened, fstatSync(fd)) ||
|
|
104
202
|
!same(opened, lstatSync(path))
|
|
105
203
|
)
|
|
@@ -113,8 +211,9 @@ export class PrivateFiles {
|
|
|
113
211
|
path: string,
|
|
114
212
|
expected: FileSnapshot | undefined,
|
|
115
213
|
maximum = METADATA_BYTES,
|
|
214
|
+
walks?: DirectoryWalks,
|
|
116
215
|
): void {
|
|
117
|
-
const current = this.read(path, maximum);
|
|
216
|
+
const current = this.read(path, maximum, walks);
|
|
118
217
|
if (
|
|
119
218
|
current?.identity !== expected?.identity ||
|
|
120
219
|
current?.stamp !== expected?.stamp
|
|
@@ -124,7 +223,7 @@ export class PrivateFiles {
|
|
|
124
223
|
create(path: string, bytes: string, maximum = METADATA_BYTES): FileSnapshot {
|
|
125
224
|
if (Buffer.byteLength(bytes) > maximum) throw new ContractError("oversize");
|
|
126
225
|
this.directory(dirname(path), true);
|
|
127
|
-
const fd = openSync(path, "wx", 0o600);
|
|
226
|
+
const fd = retryWindowsFileOperation(() => openSync(path, "wx", 0o600));
|
|
128
227
|
try {
|
|
129
228
|
writeFileSync(fd, bytes);
|
|
130
229
|
fsyncSync(fd);
|
|
@@ -146,7 +245,10 @@ export class PrivateFiles {
|
|
|
146
245
|
const temporary = join(dirname(path), `pending-${randomUUID()}.json`);
|
|
147
246
|
this.create(temporary, bytes, maximum);
|
|
148
247
|
this.assert(path, expected, maximum);
|
|
149
|
-
|
|
248
|
+
retryWindowsFileOperation(
|
|
249
|
+
() => renameSync(temporary, path),
|
|
250
|
+
() => this.assert(path, expected, maximum),
|
|
251
|
+
);
|
|
150
252
|
this.syncDirectory(dirname(path));
|
|
151
253
|
const result = this.read(path, maximum);
|
|
152
254
|
if (!result || result.identity !== hash(bytes)) refused();
|
|
@@ -154,7 +256,10 @@ export class PrivateFiles {
|
|
|
154
256
|
}
|
|
155
257
|
remove(path: string, expected: FileSnapshot, maximum = METADATA_BYTES): void {
|
|
156
258
|
this.assert(path, expected, maximum);
|
|
157
|
-
|
|
259
|
+
retryWindowsFileOperation(
|
|
260
|
+
() => unlinkSync(path),
|
|
261
|
+
() => this.assert(path, expected, maximum),
|
|
262
|
+
);
|
|
158
263
|
this.syncDirectory(dirname(path));
|
|
159
264
|
}
|
|
160
265
|
private syncDirectory(path: string): void {
|