From 5b1750b1ba2e3a27b0812d04ae27ab16c5fb3cb0 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sun, 11 Jan 2026 14:53:05 +0800 Subject: [PATCH] feat: thunk loop debugging --- nix-js/runtime-ts/src/builtins/context.ts | 4 +- nix-js/runtime-ts/src/builtins/conversion.ts | 14 +--- nix-js/runtime-ts/src/builtins/derivation.ts | 6 +- nix-js/runtime-ts/src/builtins/functional.ts | 16 ++++- nix-js/runtime-ts/src/builtins/io.ts | 29 ++------ nix-js/runtime-ts/src/builtins/type-check.ts | 24 +++---- nix-js/runtime-ts/src/index.ts | 3 +- nix-js/runtime-ts/src/operators.ts | 7 +- nix-js/runtime-ts/src/string-context.ts | 4 +- nix-js/runtime-ts/src/thunk.ts | 69 +++++++++++++++----- nix-js/runtime-ts/src/types/global.d.ts | 6 +- nix-js/src/context.rs | 8 ++- nix-js/src/runtime.rs | 2 + 13 files changed, 103 insertions(+), 89 deletions(-) diff --git a/nix-js/runtime-ts/src/builtins/context.ts b/nix-js/runtime-ts/src/builtins/context.ts index 0dff291..4ee5e4d 100644 --- a/nix-js/runtime-ts/src/builtins/context.ts +++ b/nix-js/runtime-ts/src/builtins/context.ts @@ -53,9 +53,7 @@ export const addDrvOutputDependencies = (value: NixValue): NixString => { const context = getStringContext(s); if (context.size !== 1) { - throw new Error( - `context of string '${strValue}' must have exactly one element, but has ${context.size}`, - ); + throw new Error(`context of string '${strValue}' must have exactly one element, but has ${context.size}`); } const [encoded] = context; diff --git a/nix-js/runtime-ts/src/builtins/conversion.ts b/nix-js/runtime-ts/src/builtins/conversion.ts index 675329d..19ffb1a 100644 --- a/nix-js/runtime-ts/src/builtins/conversion.ts +++ b/nix-js/runtime-ts/src/builtins/conversion.ts @@ -5,11 +5,7 @@ import type { NixValue, NixString } from "../types"; import { isStringWithContext } from "../types"; import { force } from "../thunk"; -import { - type NixStringContext, - mkStringWithContext, - addBuiltContext, -} from "../string-context"; +import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context"; import { forceFunction } from "../type-assert"; const convertJsonToNix = (json: unknown): NixValue => { @@ -44,18 +40,14 @@ const convertJsonToNix = (json: unknown): NixValue => { export const fromJSON = (e: NixValue): NixValue => { const str = force(e); if (typeof str !== "string" && !isStringWithContext(str)) { - throw new TypeError( - `builtins.fromJSON: expected a string, got ${typeName(str)}`, - ); + throw new TypeError(`builtins.fromJSON: expected a string, got ${typeName(str)}`); } const jsonStr = isStringWithContext(str) ? str.value : str; try { const parsed = JSON.parse(jsonStr); return convertJsonToNix(parsed); } catch (err) { - throw new SyntaxError( - `builtins.fromJSON: ${err instanceof Error ? err.message : String(err)}`, - ); + throw new SyntaxError(`builtins.fromJSON: ${err instanceof Error ? err.message : String(err)}`); } }; diff --git a/nix-js/runtime-ts/src/builtins/derivation.ts b/nix-js/runtime-ts/src/builtins/derivation.ts index 15f81b3..3937f8e 100644 --- a/nix-js/runtime-ts/src/builtins/derivation.ts +++ b/nix-js/runtime-ts/src/builtins/derivation.ts @@ -80,11 +80,7 @@ const extractArgs = (attrs: NixAttrs, outContext: NixStringContext): string[] => return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false, outContext)); }; -const nixValueToJson = ( - value: NixValue, - seen = new Set(), - outContext?: NixStringContext, -): any => { +const nixValueToJson = (value: NixValue, seen = new Set(), outContext?: NixStringContext): any => { const v = force(value); if (v === null) return null; diff --git a/nix-js/runtime-ts/src/builtins/functional.ts b/nix-js/runtime-ts/src/builtins/functional.ts index 063f6a3..5f321c9 100644 --- a/nix-js/runtime-ts/src/builtins/functional.ts +++ b/nix-js/runtime-ts/src/builtins/functional.ts @@ -2,7 +2,7 @@ * Functional programming builtin functions */ -import { CatchableError, type NixValue } from "../types"; +import { CatchableError, HAS_CONTEXT, type NixValue } from "../types"; import { force } from "../thunk"; import { forceString } from "../type-assert"; @@ -15,8 +15,18 @@ export const seq = export const deepSeq = (e1: NixValue) => - (e2: NixValue): never => { - throw new Error("Not implemented: deepSeq"); + (e2: NixValue): NixValue => { + const forced = force(e1); + if (Array.isArray(forced)) { + for (const val of forced) { + deepSeq(val); + } + } else if (typeof forced === "object" && forced !== null && !(HAS_CONTEXT in forced)) { + for (const [_, val] of Object.entries(forced)) { + deepSeq(val); + } + } + return e2; }; export const abort = (s: NixValue): never => { diff --git a/nix-js/runtime-ts/src/builtins/io.ts b/nix-js/runtime-ts/src/builtins/io.ts index fa1a483..986bf00 100644 --- a/nix-js/runtime-ts/src/builtins/io.ts +++ b/nix-js/runtime-ts/src/builtins/io.ts @@ -73,11 +73,7 @@ const normalizeUrlInput = ( const attrs = forceAttrs(args); const url = forceString(attrs.url); const hash = - "sha256" in attrs - ? forceString(attrs.sha256) - : "hash" in attrs - ? forceString(attrs.hash) - : undefined; + "sha256" in attrs ? forceString(attrs.sha256) : "hash" in attrs ? forceString(attrs.hash) : undefined; const name = "name" in attrs ? forceString(attrs.name) : undefined; const executable = "executable" in attrs ? forceBool(attrs.executable) : false; return { url, hash, name, executable }; @@ -96,26 +92,14 @@ export const fetchurl = (args: NixValue): string => { export const fetchTarball = (args: NixValue): string => { const { url, hash, name } = normalizeUrlInput(args); - const result: FetchTarballResult = Deno.core.ops.op_fetch_tarball( - url, - hash ?? null, - name ?? null, - ); + const result: FetchTarballResult = Deno.core.ops.op_fetch_tarball(url, hash ?? null, name ?? null); return result.store_path; }; export const fetchGit = (args: NixValue): NixAttrs => { const forced = force(args); if (typeof forced === "string") { - const result: FetchGitResult = Deno.core.ops.op_fetch_git( - forced, - null, - null, - false, - false, - false, - null, - ); + const result: FetchGitResult = Deno.core.ops.op_fetch_git(forced, null, null, false, false, false, null); return { outPath: result.out_path, rev: result.rev, @@ -206,12 +190,7 @@ export const fetchTree = (args: NixValue): NixAttrs => { const fetchGitForge = (forge: string, attrs: NixAttrs): NixAttrs => { const owner = forceString(attrs.owner); const repo = forceString(attrs.repo); - const rev = - "rev" in attrs - ? forceString(attrs.rev) - : "ref" in attrs - ? forceString(attrs.ref) - : "HEAD"; + const rev = "rev" in attrs ? forceString(attrs.rev) : "ref" in attrs ? forceString(attrs.ref) : "HEAD"; const baseUrls: Record = { github: "https://github.com", diff --git a/nix-js/runtime-ts/src/builtins/type-check.ts b/nix-js/runtime-ts/src/builtins/type-check.ts index 42c6b85..f5c1f79 100644 --- a/nix-js/runtime-ts/src/builtins/type-check.ts +++ b/nix-js/runtime-ts/src/builtins/type-check.ts @@ -2,23 +2,23 @@ * Type checking builtin functions */ -import type { - NixAttrs, - NixBool, - NixFloat, - NixFunction, - NixInt, - NixList, - NixNull, - NixStrictValue, - NixString, - NixValue, +import { + HAS_CONTEXT, + type NixAttrs, + type NixBool, + type NixFloat, + type NixFunction, + type NixInt, + type NixList, + type NixNull, + type NixString, + type NixValue, } from "../types"; import { force } from "../thunk"; export const isAttrs = (e: NixValue): e is NixAttrs => { const val = force(e); - return typeof val === "object" && !Array.isArray(val) && val !== null; + return typeof val === "object" && !Array.isArray(val) && val !== null && !(HAS_CONTEXT in val); }; export const isBool = (e: NixValue): e is NixBool => typeof force(e) === "boolean"; diff --git a/nix-js/runtime-ts/src/index.ts b/nix-js/runtime-ts/src/index.ts index 3e76519..e1dcc55 100644 --- a/nix-js/runtime-ts/src/index.ts +++ b/nix-js/runtime-ts/src/index.ts @@ -4,7 +4,7 @@ * All functionality is exported via the global `Nix` object */ -import { createThunk, force, isThunk, IS_THUNK } from "./thunk"; +import { createThunk, force, isThunk, IS_THUNK, DEBUG_THUNKS } from "./thunk"; import { select, selectWithDefault, @@ -30,6 +30,7 @@ export const Nix = { isThunk, IS_THUNK, HAS_CONTEXT, + DEBUG_THUNKS, call, hasAttr, diff --git a/nix-js/runtime-ts/src/operators.ts b/nix-js/runtime-ts/src/operators.ts index d0b1c35..234a760 100644 --- a/nix-js/runtime-ts/src/operators.ts +++ b/nix-js/runtime-ts/src/operators.ts @@ -7,12 +7,7 @@ import type { NixValue, NixList, NixAttrs, NixString } from "./types"; import { isStringWithContext } from "./types"; import { force } from "./thunk"; import { forceNumeric, forceList, forceAttrs, coerceNumeric } from "./type-assert"; -import { - getStringValue, - getStringContext, - mergeContexts, - mkStringWithContext, -} from "./string-context"; +import { getStringValue, getStringContext, mergeContexts, mkStringWithContext } from "./string-context"; import { coerceToString, StringCoercionMode } from "./builtins/conversion"; const isNixString = (v: unknown): v is NixString => { diff --git a/nix-js/runtime-ts/src/string-context.ts b/nix-js/runtime-ts/src/string-context.ts index 7de5837..265bd0b 100644 --- a/nix-js/runtime-ts/src/string-context.ts +++ b/nix-js/runtime-ts/src/string-context.ts @@ -27,7 +27,9 @@ export interface StringWithContext { } export const isStringWithContext = (v: unknown): v is StringWithContext => { - return typeof v === "object" && v !== null && HAS_CONTEXT in v && (v as StringWithContext)[HAS_CONTEXT] === true; + return ( + typeof v === "object" && v !== null && HAS_CONTEXT in v && (v as StringWithContext)[HAS_CONTEXT] === true + ); }; export const mkStringWithContext = (value: string, context: NixStringContext): StringWithContext => { diff --git a/nix-js/runtime-ts/src/thunk.ts b/nix-js/runtime-ts/src/thunk.ts index e1d8e16..59e5989 100644 --- a/nix-js/runtime-ts/src/thunk.ts +++ b/nix-js/runtime-ts/src/thunk.ts @@ -11,6 +11,10 @@ import type { NixValue, NixThunkInterface, NixStrictValue } from "./types"; */ export const IS_THUNK = Symbol("is_thunk"); +const forceStack: NixThunk[] = []; + +export const DEBUG_THUNKS = { enabled: false }; + /** * NixThunk class - represents a lazy, unevaluated expression * @@ -27,10 +31,23 @@ export class NixThunk implements NixThunkInterface { readonly [IS_THUNK] = true as const; func: (() => NixValue) | undefined; result: NixStrictValue | undefined; + readonly label: string | undefined; + readonly creationStack: string | undefined; - constructor(func: () => NixValue) { + constructor(func: () => NixValue, label?: string) { this.func = func; this.result = undefined; + this.label = label; + if (DEBUG_THUNKS.enabled) { + this.creationStack = new Error().stack?.split("\n").slice(2).join("\n"); + } + } + + toString(): string { + if (this.label) { + return `«thunk ${this.label}»`; + } + return `«thunk»`; } } @@ -61,33 +78,53 @@ export const force = (value: NixValue): NixStrictValue => { return value; } - // Check if already evaluated or in blackhole state if (value.func === undefined) { - // Blackhole: func is undefined but result is also undefined if (value.result === undefined) { - throw new Error("infinite recursion encountered (blackhole)"); + const thunk = value as NixThunk; + let msg = `infinite recursion encountered (blackhole) at ${thunk}\n`; + msg += "Force chain (most recent first):\n"; + for (let i = forceStack.length - 1; i >= 0; i--) { + const t = forceStack[i]; + msg += ` ${i + 1}. ${t}`; + if (DEBUG_THUNKS.enabled && t.creationStack) { + msg += `\n Created at:\n${t.creationStack + .split("\n") + .map((l) => " " + l) + .join("\n")}`; + } + msg += "\n"; + } + if (DEBUG_THUNKS.enabled && thunk.creationStack) { + msg += `\nBlackhole thunk created at:\n${thunk.creationStack + .split("\n") + .map((l) => " " + l) + .join("\n")}`; + } + throw new Error(msg); } - // Already evaluated - return cached result return value.result; } - // Save func and enter blackhole state BEFORE calling func() - const func = value.func; - value.func = undefined; - // result stays undefined - this is the blackhole state + const thunk = value as NixThunk; + const func = thunk.func!; + thunk.func = undefined; - // Evaluate and cache - const result = force(func()); - value.result = result; - - return result; + forceStack.push(thunk); + try { + const result = force(func()); + thunk.result = result; + return result; + } finally { + forceStack.pop(); + } }; /** * Create a new thunk from a function * @param func - Function that produces a value when called + * @param label - Optional label for debugging * @returns A new NixThunk wrapping the function */ -export const createThunk = (func: () => NixValue): NixThunkInterface => { - return new NixThunk(func); +export const createThunk = (func: () => NixValue, label?: string): NixThunkInterface => { + return new NixThunk(func, label); }; diff --git a/nix-js/runtime-ts/src/types/global.d.ts b/nix-js/runtime-ts/src/types/global.d.ts index 88a42ee..123ff7a 100644 --- a/nix-js/runtime-ts/src/types/global.d.ts +++ b/nix-js/runtime-ts/src/types/global.d.ts @@ -67,11 +67,7 @@ declare global { all_refs: boolean, name: string | null, ): FetchGitResult; - function op_fetch_hg( - url: string, - rev: string | null, - name: string | null, - ): FetchHgResult; + function op_fetch_hg(url: string, rev: string | null, name: string | null): FetchHgResult; } } } diff --git a/nix-js/src/context.rs b/nix-js/src/context.rs index 476a80c..da81875 100644 --- a/nix-js/src/context.rs +++ b/nix-js/src/context.rs @@ -189,7 +189,13 @@ impl Ctx { .downgrade_ctx() .downgrade(root.tree().expr().unwrap())?; let code = self.get_ir(root).compile(self); - let code = format!("Nix.force({})", code); + + let debug_prefix = if std::env::var("NIX_JS_DEBUG_THUNKS").is_ok() { + "Nix.DEBUG_THUNKS.enabled=true," + } else { + "" + }; + let code = format!("({}Nix.force({}))", debug_prefix, code); #[cfg(debug_assertions)] eprintln!("[DEBUG] generated code: {}", &code); Ok(code) diff --git a/nix-js/src/runtime.rs b/nix-js/src/runtime.rs index 90c3df5..da209aa 100644 --- a/nix-js/src/runtime.rs +++ b/nix-js/src/runtime.rs @@ -92,6 +92,8 @@ fn op_import( let content = std::fs::read_to_string(&absolute_path) .map_err(|e| format!("Failed to read {}: {}", absolute_path.display(), e))?; + #[cfg(debug_assertions)] + eprintln!("[DEBUG] compiling file: {}", absolute_path.display()); let mut guard = ctx.push_path_stack(absolute_path); let ctx = guard.deref_mut();