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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user