@1agh/maude 0.53.2 → 0.55.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/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +226 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +461 -40
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud/endpoints.ts +48 -6
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +983 -983
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +159 -13
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-endpoints.test.ts +231 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +54 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Cloud Phase 27 D2 — the concurrency test the plan asks for by name:
|
|
2
|
+
// "a concurrency test that runs an autocommit against a live canvas write."
|
|
3
|
+
//
|
|
4
|
+
// REAL GIT, REAL FILES, REAL CONTENTION. Everything else in this suite injects
|
|
5
|
+
// its git runner, which is right for asserting what the code MEANS to do and
|
|
6
|
+
// useless for asserting what two processes over one index actually DO. The
|
|
7
|
+
// failure this exists to catch does not reproduce against a fake: it is
|
|
8
|
+
// `.git/index.lock` colliding, or a commit landing between another commit's
|
|
9
|
+
// `add` and its `commit`.
|
|
10
|
+
//
|
|
11
|
+
// What is asserted, in the order it matters:
|
|
12
|
+
// 1. NOTHING IS LOST. Every byte written during the storm is either committed
|
|
13
|
+
// or still in the working tree at the end. That is the tenant's data.
|
|
14
|
+
// 2. THE REPO IS NOT CORRUPT. `git fsck` and a clean `git status` afterwards.
|
|
15
|
+
// 3. NO LOCK IS LEFT BEHIND. Neither git's nor ours — a leaked lock is a
|
|
16
|
+
// history that silently stops.
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { REPO_LOCK_FILE, withRepoLock } from '../git/repo-lock.ts';
|
|
24
|
+
import { createAutoCommit } from '../sync/autocommit.ts';
|
|
25
|
+
|
|
26
|
+
/** The same shape the cell uses on both sides: shell out to system git. */
|
|
27
|
+
async function run(args: string[], { cwd }: { cwd: string }) {
|
|
28
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
29
|
+
const [stdout, stderr] = await Promise.all([
|
|
30
|
+
new Response(proc.stdout).text(),
|
|
31
|
+
new Response(proc.stderr).text(),
|
|
32
|
+
]);
|
|
33
|
+
return { code: await proc.exited, stdout, stderr };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function makeRepo(): Promise<string> {
|
|
37
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-repo-concurrency-'));
|
|
38
|
+
await run(['init', '-b', 'main'], { cwd: root });
|
|
39
|
+
await run(['config', 'user.name', 'Test'], { cwd: root });
|
|
40
|
+
await run(['config', 'user.email', 'test@example.com'], { cwd: root });
|
|
41
|
+
mkdirSync(join(root, '.design', 'ui'), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, '.design', 'ui', 'seed.tsx'), 'export const seed = 0;\n');
|
|
43
|
+
await run(['add', '-A'], { cwd: root });
|
|
44
|
+
await run(['commit', '-m', 'seed'], { cwd: root });
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Atomic, exactly like every real writer in this codebase. */
|
|
49
|
+
function writeCanvas(root: string, rel: string, body: string): void {
|
|
50
|
+
const abs = join(root, rel);
|
|
51
|
+
const tmp = `${abs}.tmp`;
|
|
52
|
+
writeFileSync(tmp, body);
|
|
53
|
+
Bun.spawnSync(['mv', tmp, abs]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('two writers, one checkout', () => {
|
|
57
|
+
test('an autocommit against a live canvas write loses nothing and corrupts nothing', async () => {
|
|
58
|
+
const root = await makeRepo();
|
|
59
|
+
try {
|
|
60
|
+
const auto = createAutoCommit({
|
|
61
|
+
repoRoot: root,
|
|
62
|
+
run,
|
|
63
|
+
debounceMs: 5,
|
|
64
|
+
log: { warn() {}, error() {}, log() {} },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The storm: twelve rounds of "a designer typed, the doc flushed to disk,
|
|
68
|
+
// the hub noticed" — with a SECOND writer (the studio's own git verbs,
|
|
69
|
+
// here standing in as a lock-taking committer) cutting in throughout.
|
|
70
|
+
const rounds = 12;
|
|
71
|
+
const expected = new Map<string, string>();
|
|
72
|
+
const flights: Promise<unknown>[] = [];
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < rounds; i++) {
|
|
75
|
+
const rel = `.design/ui/canvas-${i % 4}.tsx`;
|
|
76
|
+
const body = `export const v = ${i};\n`;
|
|
77
|
+
writeCanvas(root, rel, body);
|
|
78
|
+
expected.set(rel, body);
|
|
79
|
+
auto.note(rel, { name: 'Alice', email: 'alice@example.com' });
|
|
80
|
+
|
|
81
|
+
if (i % 3 === 0) {
|
|
82
|
+
// The other process, doing what it does: taking the lock for a whole
|
|
83
|
+
// sequence and running git inside it.
|
|
84
|
+
flights.push(
|
|
85
|
+
withRepoLock(root, 'studio:commit', async () => {
|
|
86
|
+
await run(['add', '--', '.design'], { cwd: root });
|
|
87
|
+
await run(['commit', '-m', `studio ${i}`, '--allow-empty'], { cwd: root });
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (i % 2 === 0) flights.push(auto.flush());
|
|
92
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await Promise.all(flights);
|
|
96
|
+
await auto.flush();
|
|
97
|
+
auto.stop();
|
|
98
|
+
|
|
99
|
+
// 1. NOTHING IS LOST — every last write is on disk with the bytes we wrote.
|
|
100
|
+
for (const [rel, body] of expected) {
|
|
101
|
+
expect(readFileSync(join(root, rel), 'utf8')).toBe(body);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. THE REPO IS NOT CORRUPT.
|
|
105
|
+
const fsck = await run(['fsck', '--no-progress'], { cwd: root });
|
|
106
|
+
expect(fsck.code).toBe(0);
|
|
107
|
+
const log = await run(['log', '--oneline'], { cwd: root });
|
|
108
|
+
expect(log.code).toBe(0);
|
|
109
|
+
expect(log.stdout.trim().split('\n').length).toBeGreaterThan(1);
|
|
110
|
+
|
|
111
|
+
// 3. NO LOCK LEFT BEHIND — neither git's nor ours.
|
|
112
|
+
expect(existsSync(join(root, '.git', 'index.lock'))).toBe(false);
|
|
113
|
+
expect(existsSync(join(root, '.git', REPO_LOCK_FILE))).toBe(false);
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(root, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}, 30_000);
|
|
118
|
+
|
|
119
|
+
test('a commit sequence is never interleaved by another writer', async () => {
|
|
120
|
+
// The half-staged commit, precisely. Without a lock held ACROSS the
|
|
121
|
+
// sequence, the second writer's commit lands between the first's `add` and
|
|
122
|
+
// its `commit` and takes the staged file with it. `index.lock` cannot stop
|
|
123
|
+
// this — it is released the moment `add` returns.
|
|
124
|
+
const root = await makeRepo();
|
|
125
|
+
try {
|
|
126
|
+
const order: string[] = [];
|
|
127
|
+
const sequence = (name: string) =>
|
|
128
|
+
withRepoLock(root, `studio:${name}`, async () => {
|
|
129
|
+
order.push(`${name}:add`);
|
|
130
|
+
await run(['add', '-A'], { cwd: root });
|
|
131
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
132
|
+
order.push(`${name}:commit`);
|
|
133
|
+
await run(['commit', '-m', name, '--allow-empty'], { cwd: root });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
writeCanvas(root, '.design/ui/a.tsx', 'export const a = 1;\n');
|
|
137
|
+
await Promise.all([sequence('one'), sequence('two')]);
|
|
138
|
+
|
|
139
|
+
// add/commit/add/commit — never add/add/commit/commit.
|
|
140
|
+
expect(order[1]).toBe(order[0].replace(':add', ':commit'));
|
|
141
|
+
expect(order[3]).toBe(order[2].replace(':add', ':commit'));
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(root, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}, 20_000);
|
|
146
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Cloud Phase 27 D2 — one writer at a time on the workspace checkout.
|
|
2
|
+
//
|
|
3
|
+
// The lock is the thing standing between "two processes over one working tree"
|
|
4
|
+
// and the preserved dissent's 3 a.m. event, so the properties asserted here are
|
|
5
|
+
// the ones that make it a lock rather than a suggestion: mutual exclusion,
|
|
6
|
+
// release on throw, and a stale holder that unwedges itself without a human.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import {
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
utimesSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { HEARTBEAT_MS, REPO_LOCK_FILE, STALE_LOCK_MS, withRepoLock } from '../git/repo-lock.ts';
|
|
22
|
+
|
|
23
|
+
function makeRepo({ git = true } = {}): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-repo-lock-'));
|
|
25
|
+
if (git) mkdirSync(join(root, '.git'), { recursive: true });
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lockFile = (root: string) => join(root, '.git', REPO_LOCK_FILE);
|
|
30
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
describe('repo lock', () => {
|
|
33
|
+
test('two holders never overlap — the loser waits rather than failing', async () => {
|
|
34
|
+
const root = makeRepo();
|
|
35
|
+
try {
|
|
36
|
+
const order: string[] = [];
|
|
37
|
+
let concurrent = 0;
|
|
38
|
+
let maxConcurrent = 0;
|
|
39
|
+
|
|
40
|
+
const contend = (name: string) =>
|
|
41
|
+
withRepoLock(root, name, async () => {
|
|
42
|
+
concurrent += 1;
|
|
43
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
44
|
+
order.push(`${name}:in`);
|
|
45
|
+
await sleep(30);
|
|
46
|
+
order.push(`${name}:out`);
|
|
47
|
+
concurrent -= 1;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await Promise.all([contend('hub:autocommit'), contend('studio:checkout')]);
|
|
51
|
+
|
|
52
|
+
// The point of the lock, stated twice: never two inside, and never an
|
|
53
|
+
// interleaved in/in/out/out — the second entry follows the first exit.
|
|
54
|
+
expect(maxConcurrent).toBe(1);
|
|
55
|
+
expect(order[1]).toBe(order[0].replace(':in', ':out'));
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the lock is released when the operation throws', async () => {
|
|
62
|
+
const root = makeRepo();
|
|
63
|
+
try {
|
|
64
|
+
await expect(
|
|
65
|
+
withRepoLock(root, 'studio:commit', async () => {
|
|
66
|
+
throw new Error('git exploded');
|
|
67
|
+
})
|
|
68
|
+
).rejects.toThrow('git exploded');
|
|
69
|
+
|
|
70
|
+
// Not merely "the file is gone" — the next caller gets in, which is the
|
|
71
|
+
// property that matters. A lock leaked by a failed commit would stop
|
|
72
|
+
// every autosave after it.
|
|
73
|
+
let ran = false;
|
|
74
|
+
await withRepoLock(root, 'hub:autocommit', async () => {
|
|
75
|
+
ran = true;
|
|
76
|
+
});
|
|
77
|
+
expect(ran).toBe(true);
|
|
78
|
+
expect(() => readFileSync(lockFile(root))).toThrow();
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a repo with no .git runs unlocked instead of creating one', async () => {
|
|
85
|
+
const root = makeRepo({ git: false });
|
|
86
|
+
try {
|
|
87
|
+
let ran = false;
|
|
88
|
+
await withRepoLock(root, 'hub:seed', async () => {
|
|
89
|
+
ran = true;
|
|
90
|
+
});
|
|
91
|
+
expect(ran).toBe(true);
|
|
92
|
+
// It must not have become a repo initializer on the way past.
|
|
93
|
+
expect(() => readFileSync(join(root, '.git', REPO_LOCK_FILE))).toThrow();
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(root, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a stale lock is stolen — a cell that crashed mid-commit unwedges itself', async () => {
|
|
100
|
+
const root = makeRepo();
|
|
101
|
+
try {
|
|
102
|
+
// A holder that still EXISTS as a process (this one) so the steal can only
|
|
103
|
+
// be attributed to the age, not to the liveness check.
|
|
104
|
+
writeFileSync(
|
|
105
|
+
lockFile(root),
|
|
106
|
+
JSON.stringify({ pid: process.pid, holder: 'hub:autocommit', at: 0, token: 'old' })
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
let ran = false;
|
|
110
|
+
const warned: string[] = [];
|
|
111
|
+
await withRepoLock(
|
|
112
|
+
root,
|
|
113
|
+
'studio:commit',
|
|
114
|
+
async () => {
|
|
115
|
+
ran = true;
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
waitMs: 1000,
|
|
119
|
+
// Age it past the window without waiting for it.
|
|
120
|
+
now: () => Date.now() + STALE_LOCK_MS + 5_000,
|
|
121
|
+
log: { warn: (m: string) => warned.push(m), log() {} },
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
expect(ran).toBe(true);
|
|
125
|
+
expect(warned.join(' ')).toContain('hub:autocommit');
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a lock whose process is gone is stolen without waiting out the window', async () => {
|
|
132
|
+
const root = makeRepo();
|
|
133
|
+
try {
|
|
134
|
+
// pid 1 is tini in a cell and always alive, so a pid that cannot exist is
|
|
135
|
+
// the honest fixture: 2^22 is above every default pid_max.
|
|
136
|
+
writeFileSync(
|
|
137
|
+
lockFile(root),
|
|
138
|
+
JSON.stringify({ pid: 4194304, holder: 'studio:checkout', at: Date.now(), token: 'dead' })
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let ran = false;
|
|
142
|
+
await withRepoLock(
|
|
143
|
+
root,
|
|
144
|
+
'hub:autocommit',
|
|
145
|
+
async () => {
|
|
146
|
+
ran = true;
|
|
147
|
+
},
|
|
148
|
+
{ waitMs: 500, log: { warn() {}, log() {} } }
|
|
149
|
+
);
|
|
150
|
+
expect(ran).toBe(true);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(root, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('giving up names the holder rather than hanging', async () => {
|
|
157
|
+
const root = makeRepo();
|
|
158
|
+
try {
|
|
159
|
+
writeFileSync(
|
|
160
|
+
lockFile(root),
|
|
161
|
+
JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'live' })
|
|
162
|
+
);
|
|
163
|
+
await expect(
|
|
164
|
+
withRepoLock(root, 'hub:autocommit', async () => undefined, { waitMs: 120 })
|
|
165
|
+
).rejects.toThrow(/studio:fold/);
|
|
166
|
+
} finally {
|
|
167
|
+
rmSync(root, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('releasing never unlinks a lock that was stolen from us', async () => {
|
|
172
|
+
// The one way a lock can be worse than no lock: a slow holder finishes,
|
|
173
|
+
// unlinks, and pulls the lock out from under whoever legitimately took it
|
|
174
|
+
// after the steal.
|
|
175
|
+
const root = makeRepo();
|
|
176
|
+
try {
|
|
177
|
+
const finished = withRepoLock(root, 'hub:autocommit', async () => {
|
|
178
|
+
// Simulate the steal: somebody reaped us and took it.
|
|
179
|
+
writeFileSync(
|
|
180
|
+
lockFile(root),
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
pid: process.pid,
|
|
183
|
+
holder: 'studio:checkout',
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
token: 'theirs',
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
await finished;
|
|
190
|
+
const after = JSON.parse(readFileSync(lockFile(root), 'utf8'));
|
|
191
|
+
expect(after.holder).toBe('studio:checkout');
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(root, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
test("a lost steal race does not unlink the winner's fresh lock", async () => {
|
|
197
|
+
// THE MUTUAL-EXCLUSION FAILURE THIS LOCK EXISTS TO PREVENT, REINTRODUCED BY
|
|
198
|
+
// ITS OWN RECOVERY PATH. Two waiters both see holder A as stale; the first
|
|
199
|
+
// unlinks and acquires; the second — still acting on its read of A —
|
|
200
|
+
// unlinks the FIRST's fresh lock and acquires too. Both then run their
|
|
201
|
+
// critical section over one git index.
|
|
202
|
+
//
|
|
203
|
+
// The fixture is aged through its MTIME rather than through a fake clock,
|
|
204
|
+
// and that distinction is the test: a fake `now` makes every lock look
|
|
205
|
+
// ancient, including the winner's brand-new one, which is not a state the
|
|
206
|
+
// real world can produce and would assert something nobody needs.
|
|
207
|
+
const root = makeRepo();
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(
|
|
210
|
+
lockFile(root),
|
|
211
|
+
JSON.stringify({ pid: process.pid, holder: 'hub:autocommit', at: 0, token: 'stale' })
|
|
212
|
+
);
|
|
213
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
214
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
215
|
+
|
|
216
|
+
let concurrent = 0;
|
|
217
|
+
let maxConcurrent = 0;
|
|
218
|
+
const contend = (name: string) =>
|
|
219
|
+
withRepoLock(
|
|
220
|
+
root,
|
|
221
|
+
name,
|
|
222
|
+
async () => {
|
|
223
|
+
concurrent += 1;
|
|
224
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
225
|
+
await sleep(60);
|
|
226
|
+
concurrent -= 1;
|
|
227
|
+
},
|
|
228
|
+
{ waitMs: 5000, log: { warn() {}, log() {} } }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await Promise.all([contend('one'), contend('two'), contend('three')]);
|
|
232
|
+
expect(maxConcurrent).toBe(1);
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(root, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}, 20_000);
|
|
237
|
+
|
|
238
|
+
test('a long-but-live holder is not stolen from — the heartbeat says it is alive', async () => {
|
|
239
|
+
// `pull`, `fold` and `resolve` came under this lock in D2, and they do
|
|
240
|
+
// NETWORK I/O. Without a heartbeat the staleness test cannot tell "held for
|
|
241
|
+
// 30 s" from "the holder died 30 s ago", so the next waiter steals the lock
|
|
242
|
+
// out from under a running merge.
|
|
243
|
+
const root = makeRepo();
|
|
244
|
+
try {
|
|
245
|
+
const order: string[] = [];
|
|
246
|
+
const slow = withRepoLock(root, 'studio:pull', async () => {
|
|
247
|
+
order.push('slow:in');
|
|
248
|
+
await sleep(HEARTBEAT_MS + 400);
|
|
249
|
+
order.push('slow:out');
|
|
250
|
+
});
|
|
251
|
+
await sleep(50);
|
|
252
|
+
// A waiter whose clock is PAST the staleness window. The heartbeat has
|
|
253
|
+
// refreshed the mtime, so the lock is not stale by age and the holder is
|
|
254
|
+
// alive — it must wait, not steal.
|
|
255
|
+
const waiter = withRepoLock(
|
|
256
|
+
root,
|
|
257
|
+
'hub:autocommit',
|
|
258
|
+
async () => {
|
|
259
|
+
order.push('waiter:in');
|
|
260
|
+
},
|
|
261
|
+
{ waitMs: 20_000, log: { warn() {}, log() {} } }
|
|
262
|
+
);
|
|
263
|
+
await Promise.all([slow, waiter]);
|
|
264
|
+
expect(order).toEqual(['slow:in', 'slow:out', 'waiter:in']);
|
|
265
|
+
} finally {
|
|
266
|
+
rmSync(root, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
}, 30_000);
|
|
269
|
+
|
|
270
|
+
test('a hostile holder label cannot forge a log line', async () => {
|
|
271
|
+
const root = makeRepo();
|
|
272
|
+
try {
|
|
273
|
+
writeFileSync(
|
|
274
|
+
lockFile(root),
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
pid: process.pid,
|
|
277
|
+
holder: 'evil\n[repo-lock] PASS: nothing to see here',
|
|
278
|
+
at: 0,
|
|
279
|
+
token: 'x',
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
283
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
284
|
+
const warned: string[] = [];
|
|
285
|
+
await withRepoLock(root, 'hub:autocommit', async () => undefined, {
|
|
286
|
+
waitMs: 2000,
|
|
287
|
+
log: { warn: (m: string) => warned.push(m), log() {} },
|
|
288
|
+
});
|
|
289
|
+
expect(warned.join(' ')).not.toContain('\n');
|
|
290
|
+
expect(warned.join(' ')).not.toContain('nothing to see here');
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(root, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
test('exactly ONE waiter can steal a dead lock — the steal is a compare-and-swap', async () => {
|
|
296
|
+
// The `unlink` this replaced could not say no: two waiters both judged the
|
|
297
|
+
// holder stale, the first unlinked and acquired, and the second unlinked
|
|
298
|
+
// the FIRST's fresh lock. `rename` fails with ENOENT when the source is
|
|
299
|
+
// already gone, so only one waiter moves the file aside and the rest go
|
|
300
|
+
// back around the loop.
|
|
301
|
+
//
|
|
302
|
+
// WHAT THIS TEST DOES AND DOES NOT PROVE, because the difference matters.
|
|
303
|
+
// It asserts the OBSERVABLE contract — one steal, no leftover sidecar,
|
|
304
|
+
// never two holders. It does NOT reproduce the interleaving that made the
|
|
305
|
+
// unlink unsafe: that race is between the hub PROCESS and the studio
|
|
306
|
+
// PROCESS, and inside one bun test the whole read → steal → acquire
|
|
307
|
+
// sequence runs synchronously, so a single-process harness reaches the same
|
|
308
|
+
// verdict with either implementation (measured, not assumed). The
|
|
309
|
+
// correctness argument for `rename` is its atomicity — exactly one caller
|
|
310
|
+
// can move a given file — and this test guards the contract around it.
|
|
311
|
+
const root = makeRepo();
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(
|
|
314
|
+
lockFile(root),
|
|
315
|
+
JSON.stringify({ pid: 4194304, holder: 'hub:autocommit', at: 0, token: 'dead' })
|
|
316
|
+
);
|
|
317
|
+
const long_ago = new Date(Date.now() - STALE_LOCK_MS * 4);
|
|
318
|
+
utimesSync(lockFile(root), long_ago, long_ago);
|
|
319
|
+
|
|
320
|
+
const steals: string[] = [];
|
|
321
|
+
let concurrent = 0;
|
|
322
|
+
let maxConcurrent = 0;
|
|
323
|
+
const contend = (name: string) =>
|
|
324
|
+
withRepoLock(
|
|
325
|
+
root,
|
|
326
|
+
name,
|
|
327
|
+
async () => {
|
|
328
|
+
concurrent += 1;
|
|
329
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
330
|
+
await sleep(40);
|
|
331
|
+
concurrent -= 1;
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
waitMs: 5000,
|
|
335
|
+
log: { warn: (m: string) => steals.push(m), log() {} },
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
await Promise.all([contend('a'), contend('b'), contend('c'), contend('d')]);
|
|
340
|
+
|
|
341
|
+
expect(maxConcurrent).toBe(1);
|
|
342
|
+
// One steal, not four: the other three found the file already gone.
|
|
343
|
+
expect(steals.filter((m) => m.includes('stole a')).length).toBe(1);
|
|
344
|
+
// And no sidecar left behind.
|
|
345
|
+
expect(readdirSync(join(root, '.git')).filter((f) => f.includes('.stale-'))).toEqual([]);
|
|
346
|
+
} finally {
|
|
347
|
+
rmSync(root, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
}, 20_000);
|
|
350
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { rootIdentity } from '../http.ts';
|
|
6
7
|
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
7
8
|
|
|
8
9
|
describe('server lifecycle', () => {
|
|
@@ -13,9 +14,18 @@ describe('server lifecycle', () => {
|
|
|
13
14
|
try {
|
|
14
15
|
const h = await fetch(`http://localhost:${port}/_health`);
|
|
15
16
|
expect(h.status).toBe(200);
|
|
16
|
-
const body = (await h.json()) as { ok: boolean; app: string };
|
|
17
|
+
const body = (await h.json()) as { ok: boolean; app: string; rootId: string };
|
|
17
18
|
expect(body.ok).toBe(true);
|
|
18
19
|
expect(body.app).toBe('design');
|
|
20
|
+
// Cloud Phase 27 D5 — the supervisor asks this route WHICH TREE it is
|
|
21
|
+
// serving, and kills a child that answers about another one. That check
|
|
22
|
+
// is only worth anything if both ends compute the identity the same way,
|
|
23
|
+
// which is what this asserts: the hash the server reports for the root it
|
|
24
|
+
// resolved is the hash the supervisor would compute for the root it
|
|
25
|
+
// handed over. And it is a hash, not the path — this route is on the
|
|
26
|
+
// untrusted canvas origin's allowlist.
|
|
27
|
+
expect(body.rootId).toBe(rootIdentity(root));
|
|
28
|
+
expect(body.rootId).not.toContain('/');
|
|
19
29
|
|
|
20
30
|
const info = await Bun.file(join(designRoot, '_server.json')).json();
|
|
21
31
|
expect(info.port).toBe(port);
|