feat: thunk loop debugging

This commit is contained in:
2026-01-11 14:53:05 +08:00
parent 160b59b8bf
commit 5b1750b1ba
13 changed files with 103 additions and 89 deletions

View File

@@ -53,9 +53,7 @@ export const addDrvOutputDependencies = (value: NixValue): NixString => {
const context = getStringContext(s); const context = getStringContext(s);
if (context.size !== 1) { if (context.size !== 1) {
throw new Error( throw new Error(`context of string '${strValue}' must have exactly one element, but has ${context.size}`);
`context of string '${strValue}' must have exactly one element, but has ${context.size}`,
);
} }
const [encoded] = context; const [encoded] = context;

View File

@@ -5,11 +5,7 @@
import type { NixValue, NixString } from "../types"; import type { NixValue, NixString } from "../types";
import { isStringWithContext } from "../types"; import { isStringWithContext } from "../types";
import { force } from "../thunk"; import { force } from "../thunk";
import { import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context";
type NixStringContext,
mkStringWithContext,
addBuiltContext,
} from "../string-context";
import { forceFunction } from "../type-assert"; import { forceFunction } from "../type-assert";
const convertJsonToNix = (json: unknown): NixValue => { const convertJsonToNix = (json: unknown): NixValue => {
@@ -44,18 +40,14 @@ const convertJsonToNix = (json: unknown): NixValue => {
export const fromJSON = (e: NixValue): NixValue => { export const fromJSON = (e: NixValue): NixValue => {
const str = force(e); const str = force(e);
if (typeof str !== "string" && !isStringWithContext(str)) { if (typeof str !== "string" && !isStringWithContext(str)) {
throw new TypeError( throw new TypeError(`builtins.fromJSON: expected a string, got ${typeName(str)}`);
`builtins.fromJSON: expected a string, got ${typeName(str)}`,
);
} }
const jsonStr = isStringWithContext(str) ? str.value : str; const jsonStr = isStringWithContext(str) ? str.value : str;
try { try {
const parsed = JSON.parse(jsonStr); const parsed = JSON.parse(jsonStr);
return convertJsonToNix(parsed); return convertJsonToNix(parsed);
} catch (err) { } catch (err) {
throw new SyntaxError( throw new SyntaxError(`builtins.fromJSON: ${err instanceof Error ? err.message : String(err)}`);
`builtins.fromJSON: ${err instanceof Error ? err.message : String(err)}`,
);
} }
}; };

View File

@@ -80,11 +80,7 @@ const extractArgs = (attrs: NixAttrs, outContext: NixStringContext): string[] =>
return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false, outContext)); return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false, outContext));
}; };
const nixValueToJson = ( const nixValueToJson = (value: NixValue, seen = new Set<object>(), outContext?: NixStringContext): any => {
value: NixValue,
seen = new Set<object>(),
outContext?: NixStringContext,
): any => {
const v = force(value); const v = force(value);
if (v === null) return null; if (v === null) return null;

View File

@@ -2,7 +2,7 @@
* Functional programming builtin functions * Functional programming builtin functions
*/ */
import { CatchableError, type NixValue } from "../types"; import { CatchableError, HAS_CONTEXT, type NixValue } from "../types";
import { force } from "../thunk"; import { force } from "../thunk";
import { forceString } from "../type-assert"; import { forceString } from "../type-assert";
@@ -15,8 +15,18 @@ export const seq =
export const deepSeq = export const deepSeq =
(e1: NixValue) => (e1: NixValue) =>
(e2: NixValue): never => { (e2: NixValue): NixValue => {
throw new Error("Not implemented: deepSeq"); 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 => { export const abort = (s: NixValue): never => {

View File

@@ -73,11 +73,7 @@ const normalizeUrlInput = (
const attrs = forceAttrs(args); const attrs = forceAttrs(args);
const url = forceString(attrs.url); const url = forceString(attrs.url);
const hash = const hash =
"sha256" in attrs "sha256" in attrs ? forceString(attrs.sha256) : "hash" in attrs ? forceString(attrs.hash) : undefined;
? forceString(attrs.sha256)
: "hash" in attrs
? forceString(attrs.hash)
: undefined;
const name = "name" in attrs ? forceString(attrs.name) : undefined; const name = "name" in attrs ? forceString(attrs.name) : undefined;
const executable = "executable" in attrs ? forceBool(attrs.executable) : false; const executable = "executable" in attrs ? forceBool(attrs.executable) : false;
return { url, hash, name, executable }; return { url, hash, name, executable };
@@ -96,26 +92,14 @@ export const fetchurl = (args: NixValue): string => {
export const fetchTarball = (args: NixValue): string => { export const fetchTarball = (args: NixValue): string => {
const { url, hash, name } = normalizeUrlInput(args); const { url, hash, name } = normalizeUrlInput(args);
const result: FetchTarballResult = Deno.core.ops.op_fetch_tarball( const result: FetchTarballResult = Deno.core.ops.op_fetch_tarball(url, hash ?? null, name ?? null);
url,
hash ?? null,
name ?? null,
);
return result.store_path; return result.store_path;
}; };
export const fetchGit = (args: NixValue): NixAttrs => { export const fetchGit = (args: NixValue): NixAttrs => {
const forced = force(args); const forced = force(args);
if (typeof forced === "string") { if (typeof forced === "string") {
const result: FetchGitResult = Deno.core.ops.op_fetch_git( const result: FetchGitResult = Deno.core.ops.op_fetch_git(forced, null, null, false, false, false, null);
forced,
null,
null,
false,
false,
false,
null,
);
return { return {
outPath: result.out_path, outPath: result.out_path,
rev: result.rev, rev: result.rev,
@@ -206,12 +190,7 @@ export const fetchTree = (args: NixValue): NixAttrs => {
const fetchGitForge = (forge: string, attrs: NixAttrs): NixAttrs => { const fetchGitForge = (forge: string, attrs: NixAttrs): NixAttrs => {
const owner = forceString(attrs.owner); const owner = forceString(attrs.owner);
const repo = forceString(attrs.repo); const repo = forceString(attrs.repo);
const rev = const rev = "rev" in attrs ? forceString(attrs.rev) : "ref" in attrs ? forceString(attrs.ref) : "HEAD";
"rev" in attrs
? forceString(attrs.rev)
: "ref" in attrs
? forceString(attrs.ref)
: "HEAD";
const baseUrls: Record<string, string> = { const baseUrls: Record<string, string> = {
github: "https://github.com", github: "https://github.com",

View File

@@ -2,23 +2,23 @@
* Type checking builtin functions * Type checking builtin functions
*/ */
import type { import {
NixAttrs, HAS_CONTEXT,
NixBool, type NixAttrs,
NixFloat, type NixBool,
NixFunction, type NixFloat,
NixInt, type NixFunction,
NixList, type NixInt,
NixNull, type NixList,
NixStrictValue, type NixNull,
NixString, type NixString,
NixValue, type NixValue,
} from "../types"; } from "../types";
import { force } from "../thunk"; import { force } from "../thunk";
export const isAttrs = (e: NixValue): e is NixAttrs => { export const isAttrs = (e: NixValue): e is NixAttrs => {
const val = force(e); 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"; export const isBool = (e: NixValue): e is NixBool => typeof force(e) === "boolean";

View File

@@ -4,7 +4,7 @@
* All functionality is exported via the global `Nix` object * 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 { import {
select, select,
selectWithDefault, selectWithDefault,
@@ -30,6 +30,7 @@ export const Nix = {
isThunk, isThunk,
IS_THUNK, IS_THUNK,
HAS_CONTEXT, HAS_CONTEXT,
DEBUG_THUNKS,
call, call,
hasAttr, hasAttr,

View File

@@ -7,12 +7,7 @@ import type { NixValue, NixList, NixAttrs, NixString } from "./types";
import { isStringWithContext } from "./types"; import { isStringWithContext } from "./types";
import { force } from "./thunk"; import { force } from "./thunk";
import { forceNumeric, forceList, forceAttrs, coerceNumeric } from "./type-assert"; import { forceNumeric, forceList, forceAttrs, coerceNumeric } from "./type-assert";
import { import { getStringValue, getStringContext, mergeContexts, mkStringWithContext } from "./string-context";
getStringValue,
getStringContext,
mergeContexts,
mkStringWithContext,
} from "./string-context";
import { coerceToString, StringCoercionMode } from "./builtins/conversion"; import { coerceToString, StringCoercionMode } from "./builtins/conversion";
const isNixString = (v: unknown): v is NixString => { const isNixString = (v: unknown): v is NixString => {

View File

@@ -27,7 +27,9 @@ export interface StringWithContext {
} }
export const isStringWithContext = (v: unknown): v is 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 => { export const mkStringWithContext = (value: string, context: NixStringContext): StringWithContext => {

View File

@@ -11,6 +11,10 @@ import type { NixValue, NixThunkInterface, NixStrictValue } from "./types";
*/ */
export const IS_THUNK = Symbol("is_thunk"); export const IS_THUNK = Symbol("is_thunk");
const forceStack: NixThunk[] = [];
export const DEBUG_THUNKS = { enabled: false };
/** /**
* NixThunk class - represents a lazy, unevaluated expression * NixThunk class - represents a lazy, unevaluated expression
* *
@@ -27,10 +31,23 @@ export class NixThunk implements NixThunkInterface {
readonly [IS_THUNK] = true as const; readonly [IS_THUNK] = true as const;
func: (() => NixValue) | undefined; func: (() => NixValue) | undefined;
result: NixStrictValue | undefined; result: NixStrictValue | undefined;
readonly label: string | undefined;
readonly creationStack: string | undefined;
constructor(func: () => NixValue) { constructor(func: () => NixValue, label?: string) {
this.func = func; this.func = func;
this.result = undefined; 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; return value;
} }
// Check if already evaluated or in blackhole state
if (value.func === undefined) { if (value.func === undefined) {
// Blackhole: func is undefined but result is also undefined
if (value.result === 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; return value.result;
} }
// Save func and enter blackhole state BEFORE calling func() const thunk = value as NixThunk;
const func = value.func; const func = thunk.func!;
value.func = undefined; thunk.func = undefined;
// result stays undefined - this is the blackhole state
// Evaluate and cache forceStack.push(thunk);
const result = force(func()); try {
value.result = result; const result = force(func());
thunk.result = result;
return result; return result;
} finally {
forceStack.pop();
}
}; };
/** /**
* Create a new thunk from a function * Create a new thunk from a function
* @param func - Function that produces a value when called * @param func - Function that produces a value when called
* @param label - Optional label for debugging
* @returns A new NixThunk wrapping the function * @returns A new NixThunk wrapping the function
*/ */
export const createThunk = (func: () => NixValue): NixThunkInterface => { export const createThunk = (func: () => NixValue, label?: string): NixThunkInterface => {
return new NixThunk(func); return new NixThunk(func, label);
}; };

View File

@@ -67,11 +67,7 @@ declare global {
all_refs: boolean, all_refs: boolean,
name: string | null, name: string | null,
): FetchGitResult; ): FetchGitResult;
function op_fetch_hg( function op_fetch_hg(url: string, rev: string | null, name: string | null): FetchHgResult;
url: string,
rev: string | null,
name: string | null,
): FetchHgResult;
} }
} }
} }

View File

@@ -189,7 +189,13 @@ impl Ctx {
.downgrade_ctx() .downgrade_ctx()
.downgrade(root.tree().expr().unwrap())?; .downgrade(root.tree().expr().unwrap())?;
let code = self.get_ir(root).compile(self); 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)] #[cfg(debug_assertions)]
eprintln!("[DEBUG] generated code: {}", &code); eprintln!("[DEBUG] generated code: {}", &code);
Ok(code) Ok(code)

View File

@@ -92,6 +92,8 @@ fn op_import<Ctx: RuntimeCtx>(
let content = std::fs::read_to_string(&absolute_path) let content = std::fs::read_to_string(&absolute_path)
.map_err(|e| format!("Failed to read {}: {}", absolute_path.display(), e))?; .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 mut guard = ctx.push_path_stack(absolute_path);
let ctx = guard.deref_mut(); let ctx = guard.deref_mut();