Skip to content

Commit 943f6af

Browse files
committed
feat(toilscript): @daemon/@scheduled codegen - daemon_start + scheduled_tick exports (M1 Phase 0)
Increment 3: cold-artifact codegen. injectDaemonHandler mirrors injectRestController. - Emits self-contained exports daemon_start()->i32 and scheduled_tick(i32 task_id)->i64, holding the box-lifetime instance in a module singleton; daemon_start runs the optional onStart once, scheduled_tick dispatches by task_id. - task_id -> @scheduled method mapping is in lockstep with the toildaemon.catalog ordering (same source-order walk + @scheduled filter); verified end-to-end by instantiating the wasm. - Diagnostics wired: 9012 (scheduled handler must be no-arg/void), 9008 (daemon with no @scheduled tasks warns but compiles). - DEVIATION from doc 03 sec 5.2: the external 'Daemon' runtime-registry import does not resolve in the toilscript compile env (an export referencing it is a hard TS6054), so exports are self-contained + a module singleton instead. This matches Part 2 (host calls the exports directly); a prunable register() wiring can be layered in once the toiljs runtime resolves. Doc 03 sec 5.2 should be corrected to this model. - tests/streams/codegen.mjs (wasm export walker + instantiation dispatch checks) green; full streams + compiler suites green. package.json (npm test:streams wiring) left unstaged to avoid committing unrelated in-repo WIP; suites run via node tests/streams/*.mjs.
1 parent 70fbd6c commit 943f6af

2 files changed

Lines changed: 355 additions & 0 deletions

File tree

src/parser.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,6 +2590,8 @@ export class Parser extends DiagnosticEmitter {
25902590
this.injectRestController(declaration, decorators[i]);
25912591
} else if (dk == DecoratorKind.Database) {
25922592
this.injectDatabaseBinding(declaration);
2593+
} else if (dk == DecoratorKind.Daemon) {
2594+
this.injectDaemonHandler(declaration);
25932595
}
25942596
}
25952597
}
@@ -2810,6 +2812,122 @@ export class Parser extends DiagnosticEmitter {
28102812
"__toilRest.register((__q: __toilReq): __toilResp | null => new " + className + "().__tryRoute(__q));\n");
28112813
}
28122814

2815+
/**
2816+
* Synthesize the cold-artifact daemon entry for a `@daemon` class (spec 03
2817+
* sections 5.2 / 5.6 / 5.7, Reconciliation Part 2 cold exports). Mirrors
2818+
* `injectRestController`: it scans the class methods once (same source-order
2819+
* walk the `toildaemon.catalog` builder uses, so `task_index` <-> dispatch
2820+
* index stay in lockstep), synthesizes a `__tick(task)` dispatcher onto the
2821+
* class, and emits the two canonical cold module-level exports:
2822+
*
2823+
* `daemon_start(): i32` - runs once at cold-box boot; instantiates the
2824+
* `@daemon` class, holds the single box-lifetime
2825+
* instance, runs the optional `onStart()`, and
2826+
* returns 0 (negative = Part 3 error bridge).
2827+
* `scheduled_tick(task_id: i32): i64` - dispatches `instance.__tick(task_id)`
2828+
* by switching on the catalog `task_index`;
2829+
* returns 0 (negative = Part 3 error bridge).
2830+
*
2831+
* Unlike `injectRestController`, the exports are self-contained (they do NOT
2832+
* route through an external runtime `Daemon` registry import). The injected
2833+
* `register(...)` in `injectRestController` is pruned when nothing reachable
2834+
* references it, but a top-level EXPORT that referenced an unresolved runtime
2835+
* import would be a hard compile error (TS6054), so the host-called exports
2836+
* are synthesized as plain top-level `export function`s and keep the one
2837+
* box-lifetime instance in a module-level singleton (doc 03 D1 / section 2:
2838+
* per-domain state lives in instance fields for the box lifetime).
2839+
*
2840+
* Fires diagnostic 9012 for a `@scheduled` method with a non-empty parameter
2841+
* list or a non-void return type, and the 9008 warning for a `@daemon` class
2842+
* with zero `@scheduled` tasks (a daemon may legitimately run only `onStart`).
2843+
*/
2844+
private injectDaemonHandler(declaration: ClassDeclaration): void {
2845+
let className = declaration.name.text;
2846+
let members = declaration.members;
2847+
2848+
// Collect the `@scheduled` task method names in SOURCE DECLARATION ORDER,
2849+
// applying exactly the same filter the `toildaemon.catalog` builder uses
2850+
// (method member that carries `@scheduled`), so the i-th task here gets the
2851+
// same `task_index = i` the catalog emits. The catalog additionally skips a
2852+
// task whose spec is missing/unparseable, but those are HARD ERRORS (9010 /
2853+
// 9011) so no artifact is produced when they diverge; on any successful
2854+
// compile the two filters select the identical set in the identical order.
2855+
let dispatchArms = "";
2856+
let scheduledCount = 0;
2857+
let hasOnStart = false;
2858+
for (let i = 0, k = members.length; i < k; ++i) {
2859+
let member = members[i];
2860+
if (member.kind != NodeKind.MethodDeclaration) continue;
2861+
let method = <MethodDeclaration>member;
2862+
let methodName = method.name.text;
2863+
if (methodName == "onStart" && !this.hasDecoratorKind(method.decorators, DecoratorKind.Scheduled)) {
2864+
hasOnStart = true;
2865+
}
2866+
if (!this.hasDecoratorKind(method.decorators, DecoratorKind.Scheduled)) continue;
2867+
// 9012: a `@scheduled` handler takes no arguments and returns void (spec
2868+
// 03 section 3.5 / 4.5). A non-empty parameter list or a non-`void` return
2869+
// is rejected; the method is still added to the dispatcher so a single
2870+
// diagnostic per offending handler is emitted (the compile fails anyway).
2871+
if (!this.isVoidNoArgSignature(method.signature)) {
2872+
this.error(
2873+
DiagnosticCode.Scheduled_handler_0_must_take_no_arguments_and_return_void,
2874+
method.signature.range, methodName
2875+
);
2876+
}
2877+
let taskIndex = scheduledCount;
2878+
dispatchArms += (taskIndex == 0 ? "if" : "else if") +
2879+
" (__task == " + taskIndex.toString() + ") this." + methodName + "();";
2880+
++scheduledCount;
2881+
}
2882+
2883+
// 9008: a `@daemon` with zero `@scheduled` tasks is a WARNING, not an error
2884+
// (a daemon may run only a long-lived `onStart` loop; spec 03 section 4.5).
2885+
if (scheduledCount == 0) {
2886+
this.warning(
2887+
DiagnosticCode.Daemon_class_0_declares_no_scheduled_tasks,
2888+
declaration.name.range, className
2889+
);
2890+
}
2891+
2892+
// Synthesize the per-task tick dispatcher onto the class. `task` indices are
2893+
// assigned in `@scheduled` source-declaration order and MUST equal the
2894+
// per-task `task_index` of `toildaemon.catalog` (section 7), so the host's
2895+
// `scheduled_tick(task_id)` maps the catalog index 1:1 onto `__tick`.
2896+
this.injectClassMember(declaration,
2897+
"__tick(__task: i32): void{" + dispatchArms + "}");
2898+
2899+
// The two canonical cold module-level exports (Reconciliation Part 2). They
2900+
// are self-contained (no external runtime import) and share the one
2901+
// box-lifetime instance via a module-level singleton. `onStart()` is invoked
2902+
// only when the class declares it (it is a convention method, not decorated).
2903+
let instVar = "__toilDaemonInstance";
2904+
let onStartCall = hasOnStart ? "__inst.onStart();" : "";
2905+
this.injectTopLevelStatements(declaration,
2906+
"// @ts-ignore: injected daemon entry (spec 03 section 5.2)\n" +
2907+
"let " + instVar + ": " + className + " | null = null;\n");
2908+
this.injectTopLevelStatements(declaration,
2909+
"export function daemon_start(): i32{" +
2910+
"let __inst = new " + className + "();" + instVar + " = __inst;" +
2911+
onStartCall + "return 0;}\n");
2912+
this.injectTopLevelStatements(declaration,
2913+
"export function scheduled_tick(__task_id: i32): i64{" +
2914+
"let __inst = " + instVar + ";" +
2915+
"if (__inst == null){__inst = new " + className + "();" + instVar + " = __inst;}" +
2916+
"__inst.__tick(__task_id);return 0;}\n");
2917+
}
2918+
2919+
/** True if a function signature takes no parameters and returns `void` (the
2920+
* required `@scheduled` handler shape, spec 03 section 3.5). A missing or
2921+
* non-`void`-named return type, or any parameter, is false. */
2922+
private isVoidNoArgSignature(signature: FunctionTypeNode): bool {
2923+
if (signature.parameters.length != 0) return false;
2924+
let returnType = signature.returnType;
2925+
if (!(returnType instanceof NamedTypeNode)) return false;
2926+
let named = <NamedTypeNode>returnType;
2927+
if (named.isNullable) return false;
2928+
return named.name.identifier.text == "void";
2929+
}
2930+
28132931
/**
28142932
* Bind a `@user` class to `AuthService.getUser()` typing. The lib declares
28152933
* `getUser(): AuthUser | null`; here we inject a `@global` `AuthUser` that

tests/streams/codegen.mjs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Codegen tests for the @daemon / @scheduled COLD exports (spec 03 sections 5.2 /
2+
// 5.6 / 5.7, Reconciliation Part 2: `daemon_start() -> i32`,
3+
// `scheduled_tick(i32 task_id) -> i64`). This is the THIRD increment: the cold
4+
// artifact exports synthesized by `injectDaemonHandler` and the daemon-side
5+
// front-end checks (9012 handler signature, 9008 no-scheduled-tasks warning).
6+
//
7+
// It compiles @daemon fixtures under --targetMode cold, asserts the emitted
8+
// module EXPORTS `daemon_start` + `scheduled_tick`, instantiates the wasm and
9+
// drives the exports to prove `scheduled_tick(task_id)` dispatches to the method
10+
// the `toildaemon.catalog` assigned that task_index to (the lockstep contract),
11+
// and asserts the 9012 / 9008 diagnostics fire. Mirrors the run.mjs / catalog.mjs
12+
// harness style (spawn the local toilscript bin, inspect status / output / wasm).
13+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
14+
import { tmpdir } from "node:os";
15+
import { join, dirname } from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
import { spawnSync } from "node:child_process";
18+
19+
const here = dirname(fileURLToPath(import.meta.url));
20+
const root = join(here, "..", "..");
21+
const bin = join(root, "bin", "toilscript.js");
22+
23+
let failures = 0;
24+
function check(name, cond, detail) {
25+
if (cond) { console.log(` ok ${name}`); }
26+
else { failures++; console.error(` FAIL ${name}${detail ? ": " + detail : ""}`); }
27+
}
28+
29+
// Compile `source` under `mode`, return { status, output, wasm:Buffer|null, exports:string[] }.
30+
function compile(source, mode) {
31+
const tmp = mkdtempSync(join(tmpdir(), "daemoncg-"));
32+
const app = join(tmp, "app.ts");
33+
const out = join(tmp, "out.wasm");
34+
writeFileSync(app, source);
35+
const args = [bin, app, "-o", out, "--runtime", "stub"];
36+
if (mode != null) args.push("--targetMode", mode);
37+
const r = spawnSync("node", args, { encoding: "utf8" });
38+
let wasm = null;
39+
try { wasm = readFileSync(out); } catch { /* no output on failure */ }
40+
rmSync(tmp, { recursive: true, force: true });
41+
return { status: r.status, output: (r.stdout || "") + (r.stderr || ""), wasm, exports: wasm ? wasmExports(wasm) : [] };
42+
}
43+
44+
// --- wasm export-section walker (the hostile-safe parser; mirrors the host) ---
45+
function leb(buf, pos) {
46+
let result = 0, shift = 0, p = pos;
47+
for (;;) { const b = buf[p++]; result |= (b & 0x7f) << shift; if ((b & 0x80) === 0) break; shift += 7; }
48+
return [result >>> 0, p];
49+
}
50+
// Return the list of EXPORTED names (export section, id = 7).
51+
function wasmExports(buf) {
52+
let pos = 8; // past "\0asm" magic + version u32
53+
const names = [];
54+
while (pos < buf.length) {
55+
const id = buf[pos++];
56+
let size; [size, pos] = leb(buf, pos);
57+
const end = pos + size;
58+
if (id === 7) { // export section
59+
let count, p = pos; [count, p] = leb(buf, p);
60+
for (let i = 0; i < count; i++) {
61+
let nlen; [nlen, p] = leb(buf, p);
62+
names.push(buf.toString("utf8", p, p + nlen));
63+
p += nlen;
64+
p += 1; // export kind byte
65+
let idx; [idx, p] = leb(buf, p); // export index
66+
}
67+
}
68+
pos = end;
69+
}
70+
return names;
71+
}
72+
73+
// toildaemon.catalog reader (just enough to recover the task_index ordering).
74+
function findSection(buf, wanted) {
75+
let pos = 8;
76+
while (pos < buf.length) {
77+
const id = buf[pos++];
78+
let size; [size, pos] = leb(buf, pos);
79+
const end = pos + size;
80+
if (id === 0) {
81+
let nl, np; [nl, np] = leb(buf, pos);
82+
if (buf.toString("latin1", np, np + nl) === wanted) return buf.subarray(np + nl, end);
83+
}
84+
pos = end;
85+
}
86+
return null;
87+
}
88+
function catalogTaskOrder(buf) {
89+
const sec = findSection(buf, "toildaemon.catalog");
90+
if (!sec) return null;
91+
let pos = 0;
92+
const u16 = () => { const v = sec[pos] | (sec[pos + 1] << 8); pos += 2; return v; };
93+
const u8 = () => sec[pos++];
94+
const u32 = () => { pos += 4; };
95+
const u64 = () => { pos += 8; };
96+
const str = () => { const n = (sec[pos] | (sec[pos + 1] << 8) | (sec[pos + 2] << 16) | (sec[pos + 3] << 24)) >>> 0; pos += 4; const s = sec.toString("utf8", pos, pos + n); pos += n; return s; };
97+
u16(); u8(); const n = u16();
98+
const tasks = [];
99+
for (let i = 0; i < n; i++) {
100+
const name = str(); const idx = u16(); u8(); u64(); u64(); u32(); u32(); u16(); u8(); u8(); u8(); u64();
101+
tasks.push({ name, idx });
102+
}
103+
return tasks;
104+
}
105+
106+
// Instantiate a stub-runtime cold module with a minimal `env`.
107+
function instantiate(wasm) {
108+
const imports = { env: { abort: () => { throw new Error("wasm abort"); }, trace: () => {}, seed: () => Date.now() } };
109+
return new WebAssembly.Instance(new WebAssembly.Module(wasm), imports).exports;
110+
}
111+
112+
console.log("daemon cold-export codegen (spec 03 sections 5.2/5.6/5.7):");
113+
114+
// ===========================================================================
115+
// 1. A @daemon with 2+ @scheduled tasks EXPORTS daemon_start + scheduled_tick.
116+
// ===========================================================================
117+
{
118+
const src = `
119+
@daemon
120+
class Jobs {
121+
onStart(): void {}
122+
@scheduled("30s") fast(): void {}
123+
@scheduled("0 */6 * * *") sixHourly(): void {}
124+
}
125+
export function probe(): i32 { return 1; }
126+
`;
127+
const r = compile(src, "cold");
128+
check("cold @daemon (2 tasks) compiles", r.status === 0, `status ${r.status}\n${r.output}`);
129+
check("exports daemon_start", r.exports.includes("daemon_start"), r.exports.join(","));
130+
check("exports scheduled_tick", r.exports.includes("scheduled_tick"), r.exports.join(","));
131+
check("does NOT export a separate init (folded into daemon_start)", !r.exports.includes("init"));
132+
}
133+
134+
// ===========================================================================
135+
// 2. scheduled_tick(task_id) dispatches to the method the catalog task_index
136+
// names, in source-declaration order (the lockstep contract). Verified by
137+
// actually invoking the emitted wasm.
138+
// ===========================================================================
139+
{
140+
const src = `
141+
let __log: i32 = 0;
142+
let __started: i32 = 0;
143+
export function logv(): i32 { return __log; }
144+
export function started(): i32 { return __started; }
145+
@daemon
146+
class Jobs {
147+
private seq: i32 = 100;
148+
onStart(): void { __started = __started + 1; this.seq = 200; }
149+
@scheduled("30s") alpha(): void { __log = this.seq + 0; } // task 0
150+
@scheduled("5m") beta(): void { __log = this.seq + 1; } // task 1
151+
@scheduled("0 0 * * *") gamma(): void { __log = this.seq + 2; } // task 2
152+
}
153+
export function probe(): i32 { return 1; }
154+
`;
155+
const r = compile(src, "cold");
156+
check("cold @daemon (3 tasks) compiles", r.status === 0, r.output);
157+
if (r.status === 0 && r.wasm) {
158+
const order = catalogTaskOrder(r.wasm);
159+
check("catalog task order = alpha,beta,gamma",
160+
!!order && order.map((t) => `${t.idx}:${t.name}`).join(",") === "0:alpha,1:beta,2:gamma",
161+
order ? order.map((t) => `${t.idx}:${t.name}`).join(",") : "no catalog");
162+
const ex = instantiate(r.wasm);
163+
const startRc = ex.daemon_start(); // i32 -> JS Number; call ONCE
164+
check("daemon_start() returns 0", startRc === 0, `${startRc}`);
165+
check("onStart ran exactly once after daemon_start", ex.started() === 1, `${ex.started()}`);
166+
// Each tick reads instance.seq (200, set by onStart). If a fresh instance
167+
// were created per tick, seq would be the field initializer 100 since
168+
// onStart never re-runs. So 200+idx proves the single box-lifetime
169+
// instance is reused AND that task_id maps to the catalog's method.
170+
for (const t of order) {
171+
ex.scheduled_tick(t.idx);
172+
check(`scheduled_tick(${t.idx}) dispatches ${t.name} (log=${200 + t.idx})`,
173+
ex.logv() === 200 + t.idx, `log=${ex.logv()}`);
174+
}
175+
check("onStart still ran once (single instance across ticks)", ex.started() === 1, `${ex.started()}`);
176+
// Out-of-range task id is a safe no-op (no dispatch arm matches).
177+
const before = ex.logv();
178+
ex.scheduled_tick(99);
179+
check("scheduled_tick(out-of-range) is a no-op", ex.logv() === before);
180+
}
181+
}
182+
183+
// ===========================================================================
184+
// 3. 9012: a @scheduled handler must take no arguments and return void.
185+
// ===========================================================================
186+
{
187+
const withArg = compile(`
188+
@daemon
189+
class Jobs { @scheduled("30s") bad(x: i32): void {} }
190+
export function probe(): i32 { return 1; }
191+
`, "cold");
192+
check("9012: @scheduled with an argument rejected",
193+
withArg.status !== 0 && /must take no arguments and return void/.test(withArg.output),
194+
withArg.output.slice(0, 200));
195+
196+
const nonVoid = compile(`
197+
@daemon
198+
class Jobs { @scheduled("30s") bad(): i32 { return 1; } }
199+
export function probe(): i32 { return 1; }
200+
`, "cold");
201+
check("9012: @scheduled with non-void return rejected",
202+
nonVoid.status !== 0 && /must take no arguments and return void/.test(nonVoid.output),
203+
nonVoid.output.slice(0, 200));
204+
205+
// A correct void/no-arg handler must NOT trip 9012.
206+
const good = compile(`
207+
@daemon
208+
class Jobs { @scheduled("30s") ok(): void {} }
209+
export function probe(): i32 { return 1; }
210+
`, "cold");
211+
check("valid void/no-arg @scheduled does NOT trip 9012",
212+
good.status === 0 && !/must take no arguments and return void/.test(good.output), good.output.slice(0, 200));
213+
}
214+
215+
// ===========================================================================
216+
// 4. 9008: a @daemon with zero @scheduled tasks is a WARNING (compiles), and
217+
// still emits daemon_start + scheduled_tick (the daemon may run only onStart).
218+
// ===========================================================================
219+
{
220+
const r = compile(`
221+
@daemon
222+
class Loop {
223+
onStart(): void {}
224+
}
225+
export function probe(): i32 { return 1; }
226+
`, "cold");
227+
check("9008: zero-@scheduled @daemon WARNS but compiles",
228+
r.status === 0 && /declares no scheduled tasks/.test(r.output), `status ${r.status}\n${r.output}`);
229+
check("zero-task daemon still exports daemon_start", r.exports.includes("daemon_start"));
230+
check("zero-task daemon still exports scheduled_tick", r.exports.includes("scheduled_tick"));
231+
}
232+
233+
if (failures) {
234+
console.error(`\ndaemon codegen: ${failures} failure(s)`);
235+
process.exit(1);
236+
}
237+
console.log("\ndaemon codegen: all cases passed");

0 commit comments

Comments
 (0)