fix(site-runtime): reject invalid runtime actor names at the site gate and never let ActorOf escalate to the Deployment Manager (review 2026-10-21 01/SR-C1)

An instance unique name Akka refuses as a path element (space, '#', '/',
non-ASCII) made Context.ActorOf throw on the singleton's own thread; the
default Restart stopped every Instance Actor on the site. DeployCompileValidator
now checks the instance + script/alarm/native-source actor names with
ActorPath.IsValidPathElement (the Commons ActorNameRules mirror is stricter
than Akka for " ( ), pinned by a parity sweep), CreateInstanceActor catches
InvalidActorNameException and every caller reports it, and the Instance Actor
skips a refused child name like a compile failure.
This commit is contained in:
Joseph Doherty
2026-10-22 06:24:28 -04:00
parent b5cebf9fa6
commit f5090fcd9e
8 changed files with 606 additions and 23 deletions
@@ -840,8 +840,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
for (var i = startIdx; i < endIdx; i++)
{
var config = state.Configs[i];
// CreateInstanceActor ensures this config's DCL connections (register row 85).
CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson, config.RevisionHash);
// CreateInstanceActor ensures this config's DCL connections (register row 85). A
// refused actor name is logged inside and skips just that instance (01/SR-C1).
CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson, config.RevisionHash, out _);
}
UpdateInstanceCounts();
@@ -1051,17 +1052,24 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// (SiteScriptCompileCache, owned by the ScriptCompilationService singleton this
// actor shares with every Instance Actor) too, and the Instance Actor's PreStart
// reuses the gate's compile (N4).
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
//
// The gate also rejects any instance/script/alarm/native-source name that is not a legal
// Akka actor name (review 2026-10-21 01/SR-C1) — such a name used to throw out of
// Context.ActorOf on THIS actor's thread and restart the singleton with every instance.
var errors = _deployCompileValidator.Validate(
command.FlattenedConfigurationJson, command.InstanceUniqueName);
if (errors.Count > 0)
{
var instanceName = command.InstanceUniqueName;
var joined = string.Join(" | ", errors);
var nameRejected = errors.Any(DeployCompileValidator.IsActorNameError);
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy rejected — site compile validation failed (deploymentId={command.DeploymentId})",
$"Instance {instanceName} deploy rejected — site {(nameRejected ? "deploy" : "compile")} validation failed (deploymentId={command.DeploymentId})",
joined);
replyTo.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed,
"Script compilation failed on site: " + joined, DateTimeOffset.UtcNow));
(nameRejected ? "Site deploy validation failed: " : "Script compilation failed on site: ") + joined,
DateTimeOffset.UtcNow));
return;
}
@@ -1341,6 +1349,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// Review 2026-10-21 01/SR-C1, defence in depth behind the compile gate: never dispatch
// the static-state reset or persist a row for a name Context.ActorOf will refuse.
if (!ActorPath.IsValidPathElement(instanceName))
{
FailDeployForInvalidName(command, sender);
return;
}
// Static overrides and mirrored native alarm state are reset on redeployment per
// design decision — the new config may bind different sources, and the source snapshot
// re-seeds the mirror on (re)subscribe. Dispatched HERE, BEFORE the successor exists,
@@ -1359,9 +1375,20 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Create the Instance Actor immediately. CreateInstanceActor first ensures the DCL
// connections this config's data-sourced attributes subscribe through (register row 85).
CreateInstanceActor(
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
staticStateResetGate);
if (!CreateInstanceActor(
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
out var createError, staticStateResetGate))
{
// Unreachable while the name pre-check above holds; kept so an ActorOf refusal can
// never escape to the supervisor and restart the singleton.
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy failed (deploymentId={command.DeploymentId})", createError);
sender.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed,
createError, DateTimeOffset.UtcNow));
return;
}
if (!isRedeploy)
_deployedInstanceNames.Add(instanceName);
UpdateInstanceCounts();
@@ -1415,6 +1442,24 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}).PipeTo(Self);
}
/// <summary>
/// Replies <see cref="DeploymentStatus.Failed"/> for an instance name Akka refuses as an
/// actor name (review 2026-10-21 01/SR-C1). Nothing has been applied when this runs.
/// </summary>
private void FailDeployForInvalidName(DeployInstanceCommand command, IActorRef sender)
{
var instanceName = command.InstanceUniqueName;
var error = DescribeInvalidInstanceName(instanceName);
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy rejected (deploymentId={command.DeploymentId})", error);
sender.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed, error, DateTimeOffset.UtcNow));
}
private static string DescribeInvalidInstanceName(string instanceName) =>
$"Instance name '{instanceName}' cannot be used as a runtime actor name on the site " +
"(spaces, '#', '/', '[', ']', '%' and non-ASCII letters are not allowed, nor a leading '$').";
/// <summary>
/// Records the persistence half of the deploy-success join. On a persistence
/// failure the optimistically-created Instance Actor is stopped and the counter
@@ -1661,9 +1706,17 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return;
}
if (!_instanceActors.ContainsKey(instanceName))
if (!_instanceActors.ContainsKey(instanceName) &&
!CreateInstanceActor(instanceName, result.Config.ConfigJson, result.Config.RevisionHash,
out var createError))
{
CreateInstanceActor(instanceName, result.Config.ConfigJson, result.Config.RevisionHash);
// Review 2026-10-21 01/SR-C1: the enable path bypasses the deploy gate, so a stored
// row whose name Akka refuses (deployed before the gate checked names) lands here.
// Report it instead of letting ActorOf restart the singleton.
LogDeploymentEvent("Error", instanceName, $"Instance {instanceName} enable failed", createError);
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, false, createError, DateTimeOffset.UtcNow));
return;
}
UpdateInstanceCounts();
@@ -1831,7 +1884,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = config.InstanceUniqueName;
CreateInstanceActor(instanceName, config.ConfigJson, config.RevisionHash);
if (!CreateInstanceActor(instanceName, config.ConfigJson, config.RevisionHash, out var createError))
{
// Review 2026-10-21 01/SR-C1: surfaced, never escalated. The sweep claims one attempt
// per (instance, revision), so this is reported once rather than every tick.
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} could not be started by {reason} self-heal", createError);
return;
}
_deployedInstanceNames.Add(instanceName);
UpdateInstanceCounts();
@@ -3462,16 +3523,25 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// the single creation funnel, so recording it here covers every path that can ever start an
/// Instance Actor.
/// </param>
/// <param name="error">
/// Why the actor could not be created, or <see langword="null"/> on success (including the
/// already-exists no-op).
/// </param>
/// <param name="staticStateResetGate">
/// The deploy path's static-override / native-alarm reset, dispatched BEFORE this call so the
/// new actor's PreStart override load can be ordered after it.
/// <see langword="null"/> on every non-deploy start (boot batch, enable, self-heal), where
/// nothing is being reset and the load runs immediately as before.
/// </param>
internal void CreateInstanceActor(
string instanceName, string configJson, string revisionHash,
/// <returns>
/// <see langword="false"/> when Akka refused the actor name (review 2026-10-21 01/SR-C1);
/// nothing was created or recorded. Callers turn that into a Failed reply / site event.
/// </returns>
internal bool CreateInstanceActor(
string instanceName, string configJson, string revisionHash, out string? error,
Task? staticStateResetGate = null)
{
error = null;
// Deferred register row 85 — an Instance Actor is useless without the DCL connection
// actors its data-sourced attributes subscribe through, so ensuring them belongs in the
// single creation funnel rather than at each caller.
@@ -3500,7 +3570,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (_instanceActors.ContainsKey(instanceName))
{
_logger.LogWarning("Instance Actor {Instance} already exists, skipping creation", instanceName);
return;
return true;
}
// Reuse the shared, host-configured logger factory
@@ -3520,7 +3590,26 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
null,
staticStateResetGate));
var actorRef = Context.ActorOf(props, instanceName);
// Review 2026-10-21 01/SR-C1: ActorOf throws InvalidActorNameException SYNCHRONOUSLY on
// this actor's thread for a name Akka refuses (space, '#', '/', non-ASCII, ...). Escaping
// the handler it would hit this singleton's own supervisor, whose default Restart stops
// EVERY Instance Actor on the site and drops all in-flight deploy bookkeeping. The deploy
// gate rejects such names up front; this catch covers the paths that bypass it (boot
// load, enable, self-heal of a row stored before the gate checked names).
IActorRef actorRef;
try
{
actorRef = Context.ActorOf(props, instanceName);
}
catch (InvalidActorNameException ex)
{
error = ActorPath.IsValidPathElement(instanceName)
? $"Instance Actor '{instanceName}' could not be created: {ex.Message}"
: DescribeInvalidInstanceName(instanceName);
_logger.LogError(ex, "Instance Actor {Instance} could not be created", instanceName);
return false;
}
_instanceActors[instanceName] = actorRef;
_runningRevisions[instanceName] = revisionHash;
// Watch every Instance Actor so an unexpected termination (init failure or
@@ -3530,6 +3619,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Context.Watch(actorRef);
_logger.LogDebug("Created Instance Actor for {Instance}", instanceName);
return true;
}
/// <summary>