Skip to content
Merged
92 changes: 53 additions & 39 deletions nix/lib/aspects/fx/assemble-pipes.nix
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,23 @@ let
);
readsParentArg = a: builtins.any (k: a ? ${k}) parentArgNames;

# Detect config-dependent thunks: functions taking `config` (the producer's
# class config) and/or a registered parent-config arg (the enclosing owner
# config). Both bind to the evalModules fixpoint, so they are deferred there.
# Detect config-dependent thunks: functions taking arguments not provided by the
# current evaluation scope context (e.g., config, pkgs, inputs, system). These
# are deferred to the NixOS/Home-Manager module system via __configThunk.
isConfigDependent =
val:
scopeCtx: val:
builtins.isFunction val
&& (
let
a = builtins.functionArgs val;
allowedKeys = [ "lib" ] ++ builtins.attrNames scopeCtx;
in
a ? config || readsParentArg a
builtins.any (k: !(builtins.elem k allowedKeys)) (builtins.attrNames a)
Comment thread
fmway marked this conversation as resolved.
);

# Pipeline-parametric values require pipeline context args (host, user, etc.)
# but neither config nor a parent-config arg. Resolved eagerly via scope context.
isPipelineParametric =
val:
builtins.isFunction val
&& (
let
a = builtins.functionArgs val;
in
!(a ? config) && !(readsParentArg a)
);
isPipelineParametric = scopeCtx: val: builtins.isFunction val && !(isConfigDependent scopeCtx val);

# Resolve a local pipeline-parametric value eagerly using scope context.
# These are quirk values like `{ host, ... }: { addr = host.addr; }` that
Expand All @@ -68,7 +61,7 @@ let
# providing the missing args (e.g., perSystem CRD build pipeline provides pkgs).
resolveLocalParametric =
scopeCtx: val:
if isPipelineParametric val then
if isPipelineParametric scopeCtx val then
let
thunkArgs = builtins.functionArgs val;
requiredArgs = builtins.filter (k: !(thunkArgs.${k} or false) && k != "lib") (
Expand All @@ -94,20 +87,20 @@ let
# module wrapper resolves it against the producing class + scope's config —
# not the consuming module's. Already-marked values (re-mark on an exposed/
# inherited path) pass through unchanged, keeping their original producer tag.
markConfigThunk =
producer: v:
if isConfigDependent v then
{
__configThunk = true;
__fn = v;
__producerClass = producer.class or null;
__producerName = producer.name or null;
}
else
v;

# Mark all config-dependent entries in a value list with their producer.
markConfigThunks = producer: map (markConfigThunk producer);
markConfigThunks =
scopeCtx: producer: values:
builtins.map (
v:
if isConfigDependent scopeCtx v then
{
__configThunk = true;
__fn = v;
__producerClass = producer.class or null;
__producerName = producer.name or null;
}
else
v
) values;

# Producer tag (class + name) for a scope, read from pipeline state. The class
# selects the producer's config-resolution route via den.classes.<class>.parentPath.
Expand Down Expand Up @@ -188,7 +181,10 @@ let
# config under the class's parentArg. Returns a list (auto-flattens lists).
resolveEntry =
hostConfigs: producerConfigFor: scopeContexts: sourceScopeId: entry:
if isConfigDependent entry then
let
scopeCtx = scopeContexts.${sourceScopeId} or { };
in
if isConfigDependent scopeCtx entry then
if hostConfigs == null then
# No host configs on this crossing path: defer the config-dependent emit.
# The local evalModules fixpoint resolves it (via __configThunk). Collected
Expand All @@ -214,7 +210,7 @@ let
);
in
if builtins.isList result then result else [ result ]
else if isPipelineParametric entry then
else if isPipelineParametric scopeCtx entry then
let
thunkArgs = builtins.functionArgs entry;
scopeCtx = scopeContexts.${sourceScopeId} or { };
Expand Down Expand Up @@ -748,7 +744,7 @@ let
# idempotently via mkCombinedBase, but marking at the source keeps
# multi-level expose chains correct without relying on every consumer
# to re-mark). Mirrors mkCombinedBase on the local path.
resolvedBase = markConfigThunks (producerOf scopeEntityClass scopeContexts scopeId) (
resolvedBase = markConfigThunks scopeCtx (producerOf scopeEntityClass scopeContexts scopeId) (
builtins.concatMap (resolveLocalParametric scopeCtx) baseValues
);
# Child-exposed data is already concrete — each child resolved its
Expand Down Expand Up @@ -867,14 +863,32 @@ let
scopeEntityClass ? { },
hostConfigs ? null,
}:
let
# Inherit parent scope contexts recursively
enrichedScopeContexts = lib.genAttrs (builtins.attrNames scopeContexts) (
scopeId:
let
ownCtx = scopeContexts.${scopeId} or { };
pid = scopeParent.${scopeId} or null;
in
if pid == null || pid == scopeId then
ownCtx
else
let
parentCtx = enrichedScopeContexts.${pid} or { };
inherited = lib.filterAttrs (k: _: !(ownCtx ? ${k})) parentCtx;
in
ownCtx // inherited
);
Comment thread
fmway marked this conversation as resolved.
in
if pipeNames == [ ] then
scopeContexts
enrichedScopeContexts
else
let
# Pass 1: Collect all exposed data bottom-up.
allExposed = collectAllExposed {
scopeContexts = enrichedScopeContexts;
inherit
scopeContexts
scopedClassImports
scopedPipeEffects
scopeParent
Expand All @@ -884,8 +898,8 @@ let

# Pass 1b: Distribute broadcast data laterally (push, fleet-wide).
allBroadcast = collectAllBroadcast {
scopeContexts = enrichedScopeContexts;
inherit
scopeContexts
scopedClassImports
scopedPipeEffects
scopeParent
Expand Down Expand Up @@ -940,10 +954,10 @@ let
resolvedBase = builtins.concatMap (resolveLocalParametric scopeCtx) baseValues;
# Own emits are produced at THIS scope; exposed values keep the
# producer tag set at their exposing node (re-mark is a no-op).
producer = producerOf scopeEntityClass scopeContexts scopeId;
markedBase = markConfigThunks producer resolvedBase;
producer = producerOf scopeEntityClass enrichedScopeContexts scopeId;
markedBase = markConfigThunks scopeCtx producer resolvedBase;
exposedValues = exposedForScope.${pn} or [ ];
markedExposed = markConfigThunks producer exposedValues;
markedExposed = markConfigThunks scopeCtx producer exposedValues;
in
markedBase ++ markedExposed;

Expand Down Expand Up @@ -1135,7 +1149,7 @@ let
// pipeData
// lib.optionalAttrs hasTargeted { __pipeTargeted = pipeTargeted; }
// lib.optionalAttrs hasConfigThunks { __pipeConfigThunks = pipeConfigThunks; }
) scopeContexts;
) enrichedScopeContexts;
in
assembled;
in
Expand Down
65 changes: 51 additions & 14 deletions nix/lib/aspects/fx/class-module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -181,16 +181,34 @@ let
|| (pa != null && (builtins.functionArgs (m.__fn or (_: { }))) ? ${pa});
needsOwner = consumerNested && hasConfigThunks && builtins.any markerNeedsOwner allMarkers;

# Extract arguments requested by config thunks that cannot be fulfilled from context.
thunkArgs = lib.foldl' (
acc: m:
let
args = builtins.functionArgs (m.__fn or (_: { }));
pcls = m.__producerClass or null;
pArg = classParentArg pcls;
filteredArgs = removeAttrs args (
[ "config" ]
++ lib.optional (pArg != null) pArg
++ builtins.filter (k: ctx ? ${k}) (builtins.attrNames args)
);
in
acc // filteredArgs
) { } allMarkers;

# If any den args have config thunks, we need `config` (and possibly the
# owner config, via the consumer's parentArg) from the module system to
# resolve them — force the wrapper path even if no other remaining args.
# We also need any specialArgs requested by the thunks themselves.
effectiveRemainingArgs =
if hasConfigThunks then
remainingArgs
// {
config = true;
}
// lib.optionalAttrs (needsOwner && consumerParentArg != null) { ${consumerParentArg} = true; }
// thunkArgs
else
remainingArgs;

Expand All @@ -199,7 +217,7 @@ let
# (the consumer's `config` for a root class, else the fetched owner); a
# nested producer → its config at the registered parentPath of the owner.
resolveMarkers =
config: owner: values:
moduleArgs: config: owner: values:
let
ownerCfg = if consumerNested then owner else config;
in
Expand All @@ -223,18 +241,37 @@ let
config
else
lib.attrByPath (pPath v.__producerName) { } ownerCfg;
result = v.__fn (
ctxArgs
// {
config = producerConfig;
}
// lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; }
// {
inherit lib;
}
);
# Defer evaluation if cross-host config is required but not yet available.
# Only defer if the consuming module system exposes its own identity
# (`config.identity`) AND it differs from the producer — i.e. the producer
# config lives on another host and cannot be resolved here. Otherwise
# (single-host, e.g. Nixidy) evaluate eagerly.
#
# NOTE: no consumer wires `config.identity` yet, so this guard is currently
# INERT (the eager branch always runs). It is the forward hook for the
# cross-host case; wiring + a covering fixture is a follow-up. See PR #625.
in
if builtins.isList result then result else [ result ]
if
v.__producerName != null
&& (ownerCfg.identity or null) != null
&& v.__producerName != ownerCfg.identity
then
[ v ]
else
let
result = v.__fn (
ctxArgs
// moduleArgs
// {
config = producerConfig;
}
// lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; }
// {
inherit lib;
}
);
in
if builtins.isList result then result else [ result ]
else
[ v ]
) values;
Expand Down Expand Up @@ -265,7 +302,7 @@ let
lib.mapAttrs (
k: v:
if builtins.elem k denArgsWithThunks && builtins.isList v then
resolveMarkers (moduleArgs.config or { }) ownerCfg v
resolveMarkers moduleArgs (moduleArgs.config or { }) ownerCfg v
else
v
) denWinsDen
Expand All @@ -277,7 +314,7 @@ let
config = true;
};
validator = mkCollisionValidator policy denArgNames;
advertisedArgs = effectiveRemainingArgs // lib.genAttrs denArgNames (_: true);
advertisedArgs = effectiveRemainingArgs // lib.genAttrs classWinsNames (_: true);
in
{
module = lib.setFunctionArgs wrapper advertisedArgs;
Expand Down
26 changes: 17 additions & 9 deletions nix/lib/aspects/fx/handlers/class-collector.nix
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,26 @@ let
${param.class} = (scopeImportData.${param.class} or [ ]) ++ [ mod ];
};
};
updatedEmittedLocs = emittedLocs // {
${scope} = scopeLocs // {
${loc} = true;
};
};
# These two maps are threaded as `_: value` closures so the effect loop's state
# deepSeq cannot reach the module bodies inside them (den's lazy-state discipline).
# The shield is total, so each emit-class layered a fresh lazy `prev // { ... }`;
# forcing the final map then chained through every prior closure — depth ∝ emit
# count, a C-stack overflow on large fleets. Force just the TOP-LEVEL spine (the
# scope key set — bounded by fleet size, never the module lists) at each step so
# the closure captures an already-evaluated head: the chain collapses to O(1) depth
# per force while the bodies stay unforced. `seq (attrNames m)` is O(scopes), so the
# accumulation stays linear.
forceHead = m: builtins.seq (builtins.attrNames m) m;
in
state
// {
scopedClassImports = _: updatedImports;
scopedEmittedLocs =
_:
emittedLocs
// {
${scope} = scopeLocs // {
${loc} = true;
};
};
scopedClassImports = builtins.seq (forceHead updatedImports) (_: updatedImports);
scopedEmittedLocs = builtins.seq (forceHead updatedEmittedLocs) (_: updatedEmittedLocs);
};
};
};
Expand Down
2 changes: 1 addition & 1 deletion nix/lib/aspects/fx/handlers/push-scope.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ let
prevIsolated = (state.scopeIsolated or (_: { })) null;
prevScopeByEntity = (state.scopeByEntity or (_: { })) null;
updatedContexts = prevContexts // {
${newScopeId} = scopedCtx;
${newScopeId} = prevContexts.${newScopeId} or scopedCtx;
};
# Spec→scope link: record the entity scope this push created, keyed by
# (parentScope, id_hash). The instantiate spec — registered at the same
Expand Down
7 changes: 6 additions & 1 deletion nix/lib/aspects/fx/handlers/scope-widen.nix
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ in
{ param, state }:
let
ctx = param.ctx;
updated = (state.scopeContexts null) // {
${state.currentScope} = (state.scopeContexts null).${state.currentScope} or { } // ctx;
};
in
{
resume = fx.bind (fx.send "drain" ctx) (
Expand All @@ -31,7 +34,9 @@ in
)
) (fx.pure null) satisfiable
);
inherit state;
state = state // {
scopeContexts = _: updated;
};
};
};
}
Loading
Loading