@sproutboat/runtime 0.44.0 → 0.46.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/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/src/asset-rules.test.js +51 -30
- package/src/native-fetch-prelude.js +1213 -67
- package/src/r2-lazy.test.js +101 -0
- package/src/transport-broker.js +13 -0
- package/src/transport-embedded.js +534 -47
- package/test/fixtures/asset-redirect-cases.ts +28 -0
- package/test/fixtures/do-evict-app.js +172 -0
- package/test/native-asset-redirects.ts +80 -0
- package/test/native-cc.ts +178 -0
- package/test/native-compile.ts +53 -22
- package/test/native-db-table.ts +244 -0
- package/test/native-do-evict.ts +345 -0
- package/test/native-lto.ts +79 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** What a `_redirects` matcher must answer, from Cloudflare's rules engine: [file text, request path, [status, location] or null]. */
|
|
2
|
+
export type RedirectCase = [text: string, path: string, want: [status: number, location: string] | null];
|
|
3
|
+
|
|
4
|
+
export const REDIRECT_CASES: RedirectCase[] = [
|
|
5
|
+
["/compare /compare/ 308", "/compare", [308, "/compare/"]],
|
|
6
|
+
["/compare /compare/ 308", "/compare/", null],
|
|
7
|
+
["/compare/ /compare 308", "/compare/", [308, "/compare"]],
|
|
8
|
+
["/compare/ /compare 308", "/compare", null],
|
|
9
|
+
["/user/:id /profile/:id", "/user/42", [302, "/profile/42"]],
|
|
10
|
+
["/user/:id /profile/:id", "/user/42/", null],
|
|
11
|
+
["/user/:id /profile/:id", "/user/", null],
|
|
12
|
+
["/user/:id /profile/:id", "/user", null],
|
|
13
|
+
["/blog/* /posts/:splat 301", "/blog/", [301, "/posts/"]],
|
|
14
|
+
["/blog/* /posts/:splat 301", "/blog", null],
|
|
15
|
+
["/blog/* /posts/:splat 301", "/blog/a/b/", [301, "/posts/a/b/"]],
|
|
16
|
+
["/a/*/c /x/:splat", "/a/b/b2/c", [302, "/x/b/b2"]],
|
|
17
|
+
["/a/*/c /x/:splat", "/a/c", null],
|
|
18
|
+
["/:lang/docs /docs/:lang 301", "/fr/docs", [301, "/docs/fr"]],
|
|
19
|
+
["/:lang/docs /docs/:lang 301", "/fr/docs/", null],
|
|
20
|
+
["/files/:name.html /pages/:name", "/files/about.html", [302, "/pages/about"]],
|
|
21
|
+
["/:a-:b /x/:b/:a", "/one-two", [302, "/x/two/one"]],
|
|
22
|
+
["/:a/:a /x", "/p/p", null],
|
|
23
|
+
["/*/*/ /x", "/a/b/", null],
|
|
24
|
+
["/a:1 /b", "/a:1", [302, "/b"]],
|
|
25
|
+
["/blog/* /dynamic/:splat\n/blog/old /static\n/blog/old /static-2", "/blog/old", [302, "/static"]],
|
|
26
|
+
["/blog/* /dynamic/:splat\n/blog/old /static", "/blog/new", [302, "/dynamic/new"]],
|
|
27
|
+
["/x/:a /first\n/x/:b /second", "/x/1", [302, "/first"]],
|
|
28
|
+
];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Durable Object eviction fixture (plan 018): objects that count what survives an eviction. Every handler is a path:
|
|
2
|
+
// /via/ID/OP?... a stub.fetch to object ID of class Obj (or Slow, Flaky) running OP; errors come back as JSON with `overloaded`
|
|
3
|
+
// /release/N resolves the zombie promise N
|
|
4
|
+
// /zombie-result/N what the zombie got when it tried to write after its object was evicted
|
|
5
|
+
const json = (v, status = 200) =>
|
|
6
|
+
new Response(JSON.stringify(v), { status, headers: { "content-type": "application/json" } });
|
|
7
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
globalThis.__release = {};
|
|
9
|
+
globalThis.__zombie = {};
|
|
10
|
+
|
|
11
|
+
export class Obj {
|
|
12
|
+
constructor(state, env) {
|
|
13
|
+
this.state = state;
|
|
14
|
+
this.mem = 0;
|
|
15
|
+
this.ctor = 0;
|
|
16
|
+
// The constructor count lives in storage: it goes up once per wake, not per request.
|
|
17
|
+
state.blockConcurrencyWhile(async () => {
|
|
18
|
+
this.ctor = ((await state.storage.get("ctor")) || 0) + 1;
|
|
19
|
+
await state.storage.put("ctor", this.ctor);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async fetch(request) {
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
const q = (k, d) => (url.searchParams.has(k) ? url.searchParams.get(k) : d);
|
|
25
|
+
const storage = this.state.storage;
|
|
26
|
+
switch (url.pathname.slice(1)) {
|
|
27
|
+
case "mem":
|
|
28
|
+
this.mem++;
|
|
29
|
+
return json({ mem: this.mem, ctor: this.ctor });
|
|
30
|
+
case "put":
|
|
31
|
+
await storage.put(q("k", "k"), q("v", "v"));
|
|
32
|
+
this.state.storage.sql.exec("CREATE TABLE IF NOT EXISTS t (v TEXT)");
|
|
33
|
+
this.state.storage.sql.exec("INSERT INTO t VALUES (?)", q("v", "v"));
|
|
34
|
+
return json({ ok: true });
|
|
35
|
+
case "get": {
|
|
36
|
+
let rows = [];
|
|
37
|
+
try {
|
|
38
|
+
rows = this.state.storage.sql
|
|
39
|
+
.exec("SELECT v FROM t")
|
|
40
|
+
.toArray()
|
|
41
|
+
.map((r) => r.v);
|
|
42
|
+
} catch {}
|
|
43
|
+
return json({ k: (await storage.get(q("k", "k"))) ?? null, rows, ctor: this.ctor, mem: this.mem });
|
|
44
|
+
}
|
|
45
|
+
case "alarm":
|
|
46
|
+
await storage.setAlarm(Date.now() + Number(q("ms", "1000")));
|
|
47
|
+
return json({ ok: true, ctor: this.ctor });
|
|
48
|
+
case "failalarm":
|
|
49
|
+
await storage.put("failAlarm", Number(q("n", "2")));
|
|
50
|
+
await storage.setAlarm(Date.now() + Number(q("ms", "300")));
|
|
51
|
+
return json({ ok: true });
|
|
52
|
+
case "alarmlog":
|
|
53
|
+
return json({
|
|
54
|
+
log: (await storage.get("alarmLog")) || [],
|
|
55
|
+
runs: (await storage.get("alarmRuns")) || 0,
|
|
56
|
+
ctor: this.ctor,
|
|
57
|
+
});
|
|
58
|
+
case "hold":
|
|
59
|
+
await sleep(Number(q("ms", "1000")));
|
|
60
|
+
return json({ mem: this.mem, ctor: this.ctor });
|
|
61
|
+
case "waituntil":
|
|
62
|
+
this.state.waitUntil(sleep(Number(q("ms", "800"))).then(() => storage.put("wu", "done")));
|
|
63
|
+
return json({ ok: true });
|
|
64
|
+
case "timer": {
|
|
65
|
+
const ms = Number(q("ms", "1200"));
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
this.fired = true;
|
|
68
|
+
}, ms);
|
|
69
|
+
return json({ ok: true });
|
|
70
|
+
}
|
|
71
|
+
case "interval":
|
|
72
|
+
this.tick = 0;
|
|
73
|
+
setInterval(() => {
|
|
74
|
+
this.tick++;
|
|
75
|
+
}, 100);
|
|
76
|
+
return json({ ok: true });
|
|
77
|
+
case "txn":
|
|
78
|
+
await storage.transaction(async (txn) => {
|
|
79
|
+
await txn.put("t", 1);
|
|
80
|
+
await sleep(Number(q("ms", "1200")));
|
|
81
|
+
});
|
|
82
|
+
return json({ ok: true });
|
|
83
|
+
case "abort":
|
|
84
|
+
this.state.abort("test abort");
|
|
85
|
+
return json({ unreachable: true });
|
|
86
|
+
case "zombie": {
|
|
87
|
+
const n = q("n", "1");
|
|
88
|
+
const state = this.state;
|
|
89
|
+
// A promise chain the handler forgets: it wakes after the object was evicted and writes as if it were still alive.
|
|
90
|
+
new Promise((resolve) => {
|
|
91
|
+
globalThis.__release[n] = resolve;
|
|
92
|
+
}).then(async () => {
|
|
93
|
+
try {
|
|
94
|
+
await state.storage.put("z", "written by a zombie");
|
|
95
|
+
globalThis.__zombie[n] = "wrote";
|
|
96
|
+
} catch (e) {
|
|
97
|
+
globalThis.__zombie[n] = "rejected: " + e.message;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return json({ ok: true });
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
return json({ error: "no such op" }, 404);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async alarm(info) {
|
|
107
|
+
const storage = this.state.storage;
|
|
108
|
+
const left = (await storage.get("failAlarm")) || 0;
|
|
109
|
+
const runs = ((await storage.get("alarmRuns")) || 0) + 1;
|
|
110
|
+
await storage.put("alarmRuns", runs);
|
|
111
|
+
const log = (await storage.get("alarmLog")) || [];
|
|
112
|
+
log.push({ retryCount: info.retryCount, isRetry: info.isRetry, ctor: this.ctor, mem: this.mem });
|
|
113
|
+
await storage.put("alarmLog", log);
|
|
114
|
+
if (left > 0) {
|
|
115
|
+
await storage.put("failAlarm", left - 1);
|
|
116
|
+
throw new Error("alarm failed on purpose");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A constructor that holds the gate for a while: events queue behind it.
|
|
122
|
+
export class Slow {
|
|
123
|
+
constructor(state) {
|
|
124
|
+
this.state = state;
|
|
125
|
+
state.blockConcurrencyWhile(async () => {
|
|
126
|
+
await sleep(600);
|
|
127
|
+
this.ready = true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async fetch() {
|
|
131
|
+
return json({ ready: this.ready === true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A constructor whose blockConcurrencyWhile rejects the first time: the object is reset and built again.
|
|
136
|
+
export class Flaky {
|
|
137
|
+
constructor(state) {
|
|
138
|
+
this.state = state;
|
|
139
|
+
state.blockConcurrencyWhile(async () => {
|
|
140
|
+
const n = ((await state.storage.get("flaky")) || 0) + 1;
|
|
141
|
+
await state.storage.put("flaky", n);
|
|
142
|
+
if (n === 1) throw new Error("first construction fails");
|
|
143
|
+
this.ok = n;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async fetch() {
|
|
147
|
+
return json({ ok: this.ok });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default {
|
|
152
|
+
async fetch(request, env) {
|
|
153
|
+
const url = new URL(request.url);
|
|
154
|
+
const parts = url.pathname.split("/");
|
|
155
|
+
if (parts[1] === "release") {
|
|
156
|
+
globalThis.__release[parts[2]]();
|
|
157
|
+
await sleep(50);
|
|
158
|
+
return json({ ok: true });
|
|
159
|
+
}
|
|
160
|
+
if (parts[1] === "zombie-result") return json({ result: globalThis.__zombie[parts[2]] ?? null });
|
|
161
|
+
if (parts[1] !== "via") return new Response("ok");
|
|
162
|
+
const cls = url.searchParams.get("cls") || "Obj";
|
|
163
|
+
const ns = cls === "Slow" ? env.SLOW : cls === "Flaky" ? env.FLAKY : env.OBJ;
|
|
164
|
+
try {
|
|
165
|
+
const stub = ns.get(ns.idFromName(parts[2]));
|
|
166
|
+
const res = await stub.fetch(new Request("https://do/" + parts[3] + url.search));
|
|
167
|
+
return new Response(await res.text(), { status: res.status, headers: { "content-type": "application/json" } });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return json({ error: String(e.message), overloaded: e.overloaded === true }, 503);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseRedirects, type RedirectRule } from "../../assets/src/assets";
|
|
6
|
+
import { compileNative } from "./native-compile";
|
|
7
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
8
|
+
import { REDIRECT_CASES } from "./fixtures/asset-redirect-cases";
|
|
9
|
+
|
|
10
|
+
// The `_redirects` matcher the compiled prelude runs (`__sbFindAssetRedirect`, used by env.ASSETS.fetch and the
|
|
11
|
+
// embedded transport's assets.get) against the cases the portable matcher in @sproutboat/assets is held to
|
|
12
|
+
// (packages/runtime/src/asset-rules.test.js reads the same file): Cloudflare's rules, so a trailing slash is
|
|
13
|
+
// significant and `/compare /compare/ 308` is one hop. The handler reaches the prelude's function from inside the
|
|
14
|
+
// module, so what runs is the Porffor-compiled code, not Bun's reading of it.
|
|
15
|
+
const handler = `export default {
|
|
16
|
+
async fetch(request) {
|
|
17
|
+
const { rules, path } = await request.json();
|
|
18
|
+
return Response.json(__sbFindAssetRedirect(rules, path));
|
|
19
|
+
},
|
|
20
|
+
};`;
|
|
21
|
+
|
|
22
|
+
function freePort(): number {
|
|
23
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
24
|
+
const port = listener.port;
|
|
25
|
+
listener.stop(true);
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-asset-redirects-"));
|
|
30
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
31
|
+
try {
|
|
32
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
33
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
34
|
+
const generated = join(workdir, "redirects.generated.js");
|
|
35
|
+
const binary = join(workdir, "redirects-native");
|
|
36
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker)));
|
|
37
|
+
await rm(binary, { force: true });
|
|
38
|
+
await compileNative({ generated, binary, dir: workdir, cacheRoot: workdir });
|
|
39
|
+
|
|
40
|
+
const port = freePort();
|
|
41
|
+
server = Bun.spawn([binary], {
|
|
42
|
+
cwd: workdir,
|
|
43
|
+
env: { ...process.env, PORT: String(port) },
|
|
44
|
+
stdout: "ignore",
|
|
45
|
+
stderr: "inherit",
|
|
46
|
+
});
|
|
47
|
+
const ask = async (rules: RedirectRule[], path: string) => {
|
|
48
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: JSON.stringify({ rules, path }),
|
|
51
|
+
signal: AbortSignal.timeout(20_000),
|
|
52
|
+
});
|
|
53
|
+
return { status: response.status, body: await response.json() };
|
|
54
|
+
};
|
|
55
|
+
let ready = false;
|
|
56
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
await ask([], "/");
|
|
59
|
+
ready = true;
|
|
60
|
+
} catch {
|
|
61
|
+
if (server.exitCode !== null) break;
|
|
62
|
+
await Bun.sleep(100);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
assert(ready, "compiled handler did not start");
|
|
66
|
+
for (const [text, path, want] of REDIRECT_CASES) {
|
|
67
|
+
const answer = await ask(parseRedirects(text), path);
|
|
68
|
+
const label = `${JSON.stringify(text)} ${path}`;
|
|
69
|
+
assert.equal(answer.status, 200, `${label}: status`);
|
|
70
|
+
const got = answer.body === null ? null : [answer.body.status, answer.body.location];
|
|
71
|
+
assert.deepEqual(got, want, label);
|
|
72
|
+
}
|
|
73
|
+
console.log(`Native _redirects matching agrees with Cloudflare on ${REDIRECT_CASES.length} cases`);
|
|
74
|
+
} finally {
|
|
75
|
+
if (server) {
|
|
76
|
+
server.kill(9);
|
|
77
|
+
await server.exited;
|
|
78
|
+
}
|
|
79
|
+
await rm(workdir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { prepareMainUnit } from "../../toolchain/src/a21/standalone";
|
|
7
|
+
import bridgeSource from "../../toolchain/src/a21/native-fetch-a22.c" with { type: "text" };
|
|
8
|
+
import { renderStandaloneUnits } from "./native-compile";
|
|
9
|
+
import { standaloneChannel } from "../../toolchain/src/a21/standalone-channel";
|
|
10
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
11
|
+
|
|
12
|
+
// The calling convention of every call between host C (the bridge) and generated code, on Porffor alpha 23.
|
|
13
|
+
// Alpha 23 prefixes each generated JS function with PORF_CC, which is `__attribute__((swiftcall))` on clang
|
|
14
|
+
// arm64 and nothing elsewhere. A host C file that declares or calls one of those functions without PORF_CC
|
|
15
|
+
// compiles and links, and works until link-time optimisation sees the two sides disagree and turns the call
|
|
16
|
+
// into `unreachable`: the macOS arm64 binary trapped at startup (SIGTRAP in main) calling p__main__23main.
|
|
17
|
+
// The reverse direction matters as much: functions the host defines for generated code to call (the
|
|
18
|
+
// timer and response entry points) are plain C, and must stay so.
|
|
19
|
+
//
|
|
20
|
+
// Every unit of a rendered standalone build, and the bridge, is compiled to LLVM IR for each arm64 and x86-64
|
|
21
|
+
// target a clang-based compiler here can reach, and every function the IR defines, declares or calls must carry
|
|
22
|
+
// swiftcc exactly when its source declaration has PORF_CC (and the target is arm64). Needs clang or zig;
|
|
23
|
+
// without one, the test is skipped, and on CI (CI is set) that is an error.
|
|
24
|
+
const handler = `export default { fetch() { return new Response("hello"); } };`;
|
|
25
|
+
|
|
26
|
+
type Target = { name: string; triple: string; arm64: boolean };
|
|
27
|
+
const TARGETS: Target[] = [
|
|
28
|
+
{ name: "darwin-arm64", triple: "arm64-apple-macos", arm64: true },
|
|
29
|
+
{ name: "linux-arm64", triple: "aarch64-linux-gnu", arm64: true },
|
|
30
|
+
{ name: "linux-arm64-musl", triple: "aarch64-linux-musl", arm64: true },
|
|
31
|
+
{ name: "linux-x64", triple: "x86_64-linux-gnu", arm64: false },
|
|
32
|
+
{ name: "linux-x64-musl", triple: "x86_64-linux-musl", arm64: false },
|
|
33
|
+
{ name: "darwin-x64", triple: "x86_64-apple-macos", arm64: false },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Compilers to try per target, as argv prefixes: zig cc ships every libc's headers, clang only the host's
|
|
37
|
+
// (plus Debian's cross headers when libc6-dev-arm64-cross is installed).
|
|
38
|
+
const COMPILERS: string[][] = [
|
|
39
|
+
["zig", "cc"],
|
|
40
|
+
["clang"],
|
|
41
|
+
["clang", "-isystem", "/usr/aarch64-linux-gnu/include"],
|
|
42
|
+
["cc"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const PROBE = `#include <stdint.h>\n#include <stdlib.h>\n#include <stdio.h>\n#include <string.h>\n#include <time.h>\n#include <signal.h>\n#include <unistd.h>\nint main(void) { return 0; }\n`;
|
|
46
|
+
|
|
47
|
+
async function spawn(argv: string[], cwd?: string) {
|
|
48
|
+
const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
49
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
50
|
+
child.exited,
|
|
51
|
+
new Response(child.stdout).text(),
|
|
52
|
+
new Response(child.stderr).text(),
|
|
53
|
+
]);
|
|
54
|
+
return { exitCode, stdout, stderr };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A clang-based compiler for `triple` that can include the libc headers the units need, or null. */
|
|
58
|
+
/** The LLVM IR of `source` for `triple`, or the compiler's error. zig cc cannot write IR to stdout, so it goes through a file. */
|
|
59
|
+
async function emitIr(cc: string[], triple: string, source: string, args: string[] = []) {
|
|
60
|
+
const out = join(workdir, `ir-${++irCounter}.ll`);
|
|
61
|
+
const run = await spawn([...cc, "-target", triple, "-S", "-emit-llvm", "-w", ...args, "-o", out, source]);
|
|
62
|
+
const ir = run.exitCode === 0 && existsSync(out) ? await readFile(out, "utf8") : "";
|
|
63
|
+
await rm(out, { force: true });
|
|
64
|
+
return { ok: run.exitCode === 0 && ir.includes("target triple"), ir, error: run.stderr };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A clang-based compiler for `triple` that can include the libc headers the units need, or null. */
|
|
68
|
+
async function compilerFor(triple: string, probe: string): Promise<string[] | null> {
|
|
69
|
+
for (const cc of COMPILERS) {
|
|
70
|
+
if (!Bun.which(cc[0])) continue;
|
|
71
|
+
try {
|
|
72
|
+
// gcc named `cc` rejects -target, and only clang's IR has a `target triple` line
|
|
73
|
+
if ((await emitIr(cc, triple, probe)).ok) return cc;
|
|
74
|
+
} catch {
|
|
75
|
+
// not runnable
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The names the sources declare or define with PORF_CC. */
|
|
82
|
+
function swiftNames(texts: string[]): Set<string> {
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const text of texts)
|
|
85
|
+
for (const m of text.matchAll(/^[ \t]*(?:extern[ \t]+|static[ \t]+)?PORF_CC[ \t]+[^;{(\n]*?(\w+)[ \t]*\(/gm))
|
|
86
|
+
names.add(m[1]);
|
|
87
|
+
return names;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every function an IR file defines, declares or calls by name, with whether that site says swiftcc. */
|
|
91
|
+
function irReferences(ir: string): { name: string; swift: boolean; kind: string }[] {
|
|
92
|
+
const out: { name: string; swift: boolean; kind: string }[] = [];
|
|
93
|
+
for (const m of ir.matchAll(/^(define|declare)\b([^@\n]*)@([\w.$]+)\(/gm))
|
|
94
|
+
out.push({ kind: m[1], swift: /\bswiftcc\b/.test(m[2]), name: m[3] });
|
|
95
|
+
for (const m of ir.matchAll(/\bcall\b([^@(\n]*)@([\w.$]+)\(/g))
|
|
96
|
+
out.push({ kind: "call", swift: /\bswiftcc\b/.test(m[1]), name: m[2] });
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (standaloneChannel() !== "alpha-23") {
|
|
101
|
+
// alpha 22's porf.h has no PORF_CC and alpha 19 has no bridge: nothing to compare
|
|
102
|
+
console.log(`SKIPPED: the calling-convention check is for the alpha-23 channel, not ${standaloneChannel()}`);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-cc-"));
|
|
107
|
+
let irCounter = 0;
|
|
108
|
+
try {
|
|
109
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
110
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
111
|
+
const generated = join(workdir, "cc.generated.js");
|
|
112
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker)));
|
|
113
|
+
const units = await renderStandaloneUnits({ generated, binary: join(workdir, "cc-native"), dir: workdir });
|
|
114
|
+
prepareMainUnit(join(units, "porf__main.c"));
|
|
115
|
+
const bridge = join(units, "porf_native_fetch_a22.c");
|
|
116
|
+
await writeFile(bridge, bridgeSource);
|
|
117
|
+
const sources = (await readdir(units)).filter((f) => f.endsWith(".c")).sort();
|
|
118
|
+
assert(sources.includes("porf_native_fetch_a22.c") && sources.includes("porf__main.c"), "units are missing");
|
|
119
|
+
const texts = await Promise.all([
|
|
120
|
+
...sources.map((f) => readFile(join(units, f), "utf8")),
|
|
121
|
+
readFile(join(units, "porf.h"), "utf8"),
|
|
122
|
+
]);
|
|
123
|
+
const swift = swiftNames(texts);
|
|
124
|
+
const probe = join(workdir, "probe.c");
|
|
125
|
+
await writeFile(probe, PROBE);
|
|
126
|
+
|
|
127
|
+
assert(
|
|
128
|
+
swift.has("p__main__23main"),
|
|
129
|
+
"porf.h no longer declares p__main__23main with PORF_CC: has the compiler dropped swiftcall (alpha 24)?",
|
|
130
|
+
);
|
|
131
|
+
const hasMacro = texts[texts.length - 1].includes("#define PORF_CC __attribute__((swiftcall))");
|
|
132
|
+
assert(hasMacro, "porf.h has no PORF_CC swiftcall definition: this test is written for Porffor alpha 23 or later");
|
|
133
|
+
|
|
134
|
+
const checked: string[] = [];
|
|
135
|
+
for (const target of TARGETS) {
|
|
136
|
+
const cc = await compilerFor(target.triple, probe);
|
|
137
|
+
if (!cc) continue;
|
|
138
|
+
let swiftRefs = 0;
|
|
139
|
+
let bridgeCallsMain = false;
|
|
140
|
+
for (const file of sources) {
|
|
141
|
+
const compile = await emitIr(cc, target.triple, join(units, file), ["-O0", "-I", units]);
|
|
142
|
+
assert(compile.ok, `${target.name}: ${file} did not compile:\n${compile.error.slice(-2000)}`);
|
|
143
|
+
for (const ref of irReferences(compile.ir)) {
|
|
144
|
+
const want = target.arm64 && swift.has(ref.name);
|
|
145
|
+
assert.equal(
|
|
146
|
+
ref.swift,
|
|
147
|
+
want,
|
|
148
|
+
`${target.name}: ${file} ${ref.kind}s ${ref.name} ${ref.swift ? "with" : "without"} swiftcc, but the source ` +
|
|
149
|
+
`${swift.has(ref.name) ? "declares it with PORF_CC" : "declares it without PORF_CC"}` +
|
|
150
|
+
(target.arm64 ? "" : " (and this target has no swiftcall)"),
|
|
151
|
+
);
|
|
152
|
+
if (ref.swift) swiftRefs++;
|
|
153
|
+
if (file === "porf_native_fetch_a22.c" && ref.kind === "call" && ref.name === "p__main__23main")
|
|
154
|
+
bridgeCallsMain = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
assert(bridgeCallsMain, `${target.name}: the bridge no longer calls p__main__23main (or the IR scan missed it)`);
|
|
158
|
+
if (target.arm64) assert(swiftRefs > 0, `${target.name}: no swiftcc anywhere: the scan is vacuous`);
|
|
159
|
+
else assert.equal(swiftRefs, 0, `${target.name}: swiftcc on a target without it`);
|
|
160
|
+
checked.push(target.name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Strict only where the job provisions an arm64 compiler (SB_NATIVE_CC_STRICT=1 in ci.yml's native job).
|
|
164
|
+
// The release workflow runs test:native on a stock runner without one; failing there blocked 0.26.1.
|
|
165
|
+
const strict = process.env.SB_NATIVE_CC_STRICT === "1";
|
|
166
|
+
const arm64Hosted = process.platform === "darwin" ? "darwin-arm64" : "linux-arm64";
|
|
167
|
+
if (checked.length === 0 || !checked.includes(arm64Hosted)) {
|
|
168
|
+
const message = `no compiler for ${arm64Hosted} (needs clang with its headers, or zig): checked ${checked.join(", ") || "nothing"}`;
|
|
169
|
+
assert(!strict, message);
|
|
170
|
+
console.log(
|
|
171
|
+
`SKIPPED: ${message}${process.env.CI ? " (set SB_NATIVE_CC_STRICT=1 in a job that installs one)" : ""}`,
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`Host C and generated code agree on the calling convention for ${checked.join(", ")}`);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
await rm(workdir, { recursive: true, force: true });
|
|
178
|
+
}
|
package/test/native-compile.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
STANDALONE_A21_COMPILE_FLAGS,
|
|
11
11
|
} from "../../toolchain/src/a21/standalone";
|
|
12
12
|
import { buildStandaloneA21 } from "../../toolchain/src/a21/standalone-build";
|
|
13
|
-
import { standaloneChannel } from "../../toolchain/src/a21/standalone-channel";
|
|
13
|
+
import { standaloneA21Channel, standaloneChannel } from "../../toolchain/src/a21/standalone-channel";
|
|
14
14
|
import { bearsslEnv } from "./bearssl";
|
|
15
15
|
|
|
16
16
|
export type NativeCompileInput = {
|
|
@@ -30,6 +30,11 @@ export type NativeCompileInput = {
|
|
|
30
30
|
bearssl?: boolean;
|
|
31
31
|
/** `-O` level. Default 0. */
|
|
32
32
|
optimize?: number;
|
|
33
|
+
/** Link-time optimisation, as the CLI's host and release builds have it (`-flto`). Default off. */
|
|
34
|
+
flto?: boolean;
|
|
35
|
+
/** Compilers as argv prefixes (default `cc` and `c++`). */
|
|
36
|
+
cc?: string[];
|
|
37
|
+
cxx?: string[];
|
|
33
38
|
/** Extra environment for the alpha 19 compiler process (the default is `process.env`). */
|
|
34
39
|
env?: Record<string, string>;
|
|
35
40
|
/** Message prefix of the failure assertion. Default "Porffor native compile failed". */
|
|
@@ -112,8 +117,50 @@ export async function compileNative(input: NativeCompileInput): Promise<void> {
|
|
|
112
117
|
return;
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
const a21Channel = standaloneA21Channel(standaloneChannel());
|
|
121
|
+
const unitsDir = await renderStandaloneUnits({
|
|
122
|
+
generated: input.generated,
|
|
123
|
+
binary: input.binary,
|
|
124
|
+
dir,
|
|
125
|
+
cacheRoot: input.cacheRoot,
|
|
126
|
+
env: input.env,
|
|
127
|
+
failure,
|
|
128
|
+
});
|
|
129
|
+
await rm(input.binary, { force: true });
|
|
130
|
+
await buildStandaloneA21({
|
|
131
|
+
unitsDir,
|
|
132
|
+
alpha19Root: alpha19,
|
|
133
|
+
outPath: input.binary,
|
|
134
|
+
cc: input.cc ?? ["cc"],
|
|
135
|
+
cxx: input.cxx ?? ["c++"],
|
|
136
|
+
uwsDir: await uwsDirectory(alpha19),
|
|
137
|
+
extraLink,
|
|
138
|
+
extraCflags,
|
|
139
|
+
optimize,
|
|
140
|
+
flto: input.flto ?? false,
|
|
141
|
+
musl: false,
|
|
142
|
+
strip: true,
|
|
143
|
+
}).catch((error: Error) => {
|
|
144
|
+
throw new Error(`${failure} (${a21Channel} build):\n${error.message}`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type RenderUnitsInput = Pick<
|
|
149
|
+
NativeCompileInput,
|
|
150
|
+
"generated" | "binary" | "dir" | "cacheRoot" | "env" | "failure"
|
|
151
|
+
>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The compiler's half of a standalone build on the alpha 21 line: compose the sources, render the C units
|
|
155
|
+
* with the pinned alpha 22 or 23 compiler, and return the units directory (porf.h, porf__main.c, the units).
|
|
156
|
+
*/
|
|
157
|
+
export async function renderStandaloneUnits(input: RenderUnitsInput): Promise<string> {
|
|
158
|
+
const dir = input.dir ?? dirname(input.binary);
|
|
159
|
+
const failure = input.failure ?? "Porffor native compile failed";
|
|
160
|
+
const alpha19 = await ensurePorffor(input.cacheRoot === undefined ? {} : { cacheRoot: input.cacheRoot });
|
|
115
161
|
const bun = process.execPath;
|
|
116
|
-
const
|
|
162
|
+
const a21Channel = standaloneA21Channel(standaloneChannel());
|
|
163
|
+
const a21 = await ensurePorfforA21(a21Channel);
|
|
117
164
|
const work = join(dir, `a22-${(input.binary.split("/").pop() ?? "bin").replace(/[^\w.-]/g, "_")}`);
|
|
118
165
|
await rm(work, { recursive: true, force: true });
|
|
119
166
|
await mkdir(work, { recursive: true });
|
|
@@ -134,30 +181,14 @@ export async function compileNative(input: NativeCompileInput): Promise<void> {
|
|
|
134
181
|
await writeFile(entry, composed.entry);
|
|
135
182
|
const unitsDir = join(work, "units");
|
|
136
183
|
const render = Bun.spawn(
|
|
137
|
-
[bun, resolve(
|
|
184
|
+
[bun, resolve(a21, "runtime/index.js"), "c", entry, "-o", `${unitsDir}/`, ...STANDALONE_A21_COMPILE_FLAGS],
|
|
138
185
|
{ cwd: work, env: { ...process.env, ...input.env }, stdout: "pipe", stderr: "pipe" },
|
|
139
186
|
);
|
|
140
187
|
rendered = await drain(render);
|
|
141
188
|
} finally {
|
|
142
189
|
release();
|
|
143
190
|
}
|
|
144
|
-
if (rendered.exitCode !== 0)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await buildStandaloneA21({
|
|
148
|
-
unitsDir,
|
|
149
|
-
alpha19Root: alpha19,
|
|
150
|
-
outPath: input.binary,
|
|
151
|
-
cc: ["cc"],
|
|
152
|
-
cxx: ["c++"],
|
|
153
|
-
uwsDir: await uwsDirectory(alpha19),
|
|
154
|
-
extraLink,
|
|
155
|
-
extraCflags,
|
|
156
|
-
optimize,
|
|
157
|
-
flto: false,
|
|
158
|
-
musl: false,
|
|
159
|
-
strip: true,
|
|
160
|
-
}).catch((error: Error) => {
|
|
161
|
-
throw new Error(`${failure} (alpha 22 build):\n${error.message}`);
|
|
162
|
-
});
|
|
191
|
+
if (rendered.exitCode !== 0)
|
|
192
|
+
throw new Error(`${failure} (${a21Channel} render):\n${rendered.stderr}\n${rendered.stdout}`);
|
|
193
|
+
return join(work, "units");
|
|
163
194
|
}
|