Skip to content
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ are in [`docs/compatibility.md`](docs/compatibility.md).
| Area | Current support | Durable limitations |
|---|---|---|
| Simulation kernel | Virtual clock, seeded randomness, simulated network, cooperative scheduler lanes, node lifecycle, diagnostics, rendezvous primitives, and controlled scheduling. | Application hosting and transport models are consumer-owned; Clockwork ships no dedicated hosting or HTTP package. |
| Build and CLI instrumentation | Opt-in, out-of-place Cecil rewriting through `Clockwork.Instrumentation.Build` and `Clockwork.Tool`; direct-call analyzers; deterministic manifests and content-verified incremental outputs. | ReadyToRun inputs are reduced to IL before rewriting; instrument before single-file bundling, trimming, or NativeAOT. Authenticode is not re-applied; signed inputs require a matching strong-name key. |
| Build and CLI instrumentation | Opt-in, out-of-place Cecil rewriting through `Clockwork.Instrumentation.Build` and `Clockwork.Tool`; direct-call analyzers; deterministic manifests and content-verified incremental outputs. | ReadyToRun inputs are reduced to IL before rewriting; instrument before single-file bundling, trimming, or NativeAOT. Rewritten strong names and closure references are stripped automatically; Authenticode is not re-applied. |
| Deterministic BCL rules | `clockwork.bcl.deterministic` controls the exact time, identity, and random signatures in the generated inventory. | APIs outside the inventory retain no determinism claim. |
| Controlled concurrency | `clockwork.tasks.controlled` controls async builders/awaiters, task combinators and waits, `Task.Run`, all .NET 10 `TaskFactory`/`TaskFactory<T>.StartNew` overloads, `Thread`, `ThreadPool`, `Parallel`, `Monitor`, `System.Threading.Lock`, and `SemaphoreSlim`. Debug and Release lowering are conformance-tested. | Synchronous `ValueTask` blocking, custom task schedulers, unsupported task-creation options, native-overlapped thread-pool work, and OS-specific thread controls are rejected. |
| Synchronization | Full .NET 10 `Interlocked` and `Volatile`; `SpinWait`; events and wait handles; registered waits; `ReaderWriterLockSlim`; `ManualResetEventSlim`; unnamed kernel `Mutex`/`Semaphore`; `SpinLock`; `ExecutionContext`; `SynchronizationContext`; `Barrier`; and `CountdownEvent`. | Named/cross-process primitives, open-existing APIs, raw handles, raw `SynchronizationContext.Wait`, and `WaitAll` arrays containing a `Mutex` are rejected. |
Expand All @@ -51,6 +51,32 @@ dotnet pack src/Clockwork/Clockwork.csproj --configuration Release

The NuGet package ID is `Clockwork.Simulation`. Until packages are published, clone the repository or add it as a Git submodule and reference `src/Clockwork/Clockwork.csproj`.

## Instrumented simulation test projects

Keep ordinary and simulation tests in separate projects. Only simulation test projects reference
`Clockwork.Instrumentation.Build` and opt into staged execution:

```xml
<PropertyGroup>
<ClockworkInstrumentedTestProject>true</ClockworkInstrumentedTestProject>
<ClockworkUseBuiltInRules>true</ClockworkUseBuiltInRules>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Clockwork.Instrumentation.Build" PrivateAssets="all" />
</ItemGroup>
```

Clockwork snapshots the project's ordinary test output under `obj`, rewrites its complete eligible
managed closure out of place, and then deploys it to the simulation test project's `bin` directory.
Strong-name identities, intra-closure references, and friend-assembly key qualifiers are stripped
automatically from rewritten assemblies. Test-host implementation assemblies (Microsoft Testing
Platform, xUnit, NUnit, MSTest, and TUnit), Clockwork's simulation kernel, and the test entry assembly
are copied unchanged because they execute before a simulation exists. Consequently, `dotnet build` followed by
`dotnet test --no-build` runs the rewritten test copy naturally. Production project outputs and
projects without the opt-in remain ordinary IL. Do not enable instrumentation globally at the
solution command line.

## Optional race exploration instrumentation

Race exploration is a build-time opt-in separate from ordinary controlled rewriting:
Expand Down
38 changes: 28 additions & 10 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,24 @@ schema version 3; `copiedAssets` entries are objects containing `relativePath` a
is rejected rather than interpreted through a compatibility shape. Manifests are limited to 16 MiB,
4,096 assembly entries, 65,536 copied assets, and 8,192 UTF-16 characters per string.

**Instrumented test projects.** Executable test projects can set
`ClockworkInstrumentedTestProject=true`. After an ordinary project build, the package snapshots the
complete test output under `obj` and automatically selects the test assembly plus every
eligible managed assembly in its resolved closure. It rewrites that complete simulation closure out
of place and validates every manifest assembly and rewrite signature before deploying the result to
that simulation test project's `bin` directory. Strong-name identities are stripped automatically
from rewritten assemblies, together with their intra-closure reference tokens and
`InternalsVisibleTo` public-key qualifiers. Test-host implementation assemblies are automatically
excluded because discovery and runner startup execute before a simulation exists. The test entry
assembly and Clockwork kernel are also copied unchanged so async test methods can create the
simulation before controlled code runs; the complete application/dependency closure remains eligible
for rewriting. The next build restores the pristine snapshot first, so
incremental compilation and instrumentation-mode changes never consume a previously rewritten input.
Because the rewritten test copy occupies the project's normal module path, `dotnet build` followed
by `dotnet test --no-build` uses it without runner-specific dispatch hooks. Production project
outputs and non-opted-in test projects remain ordinary IL. Instrumentation must be selected per
project, never using a solution-wide `ClockworkInstrumentationEnabled` global property.

**Task package requires the .NET 10 SDK.** The task and its Cecil-based engine target
`net10.0` and load only under `dotnet build` / `dotnet msbuild`; .NET Framework MSBuild
(classic `msbuild.exe`) cannot host them. The `Clockwork.Tool` CLI exposes `rewrite`
Expand All @@ -227,16 +245,16 @@ validated strictly for schema, types, and signatures; **no arbitrary code is exe
from configuration**. Multiple rule sets merge deterministically by a defined precedence shared
by built-in, application, and third-party rules. Instrumentation configuration files must declare
`"schemaVersion": 2`. Its exact optional fields are `ruleSets`, `mode`, `builtInRuleSets`,
`builtInIncludeFamilies`, `builtInExcludeFamilies`, `include`, `exclude`, `targetRuntime`, and
`strongNameKeyPath`; unknown fields are rejected. Version 1 is rejected, and there are no migration
`builtInIncludeFamilies`, `builtInExcludeFamilies`, `include`, `exclude`, and `targetRuntime`;
unknown fields are rejected. Version 1 is rejected, and there are no migration
or compatibility aliases.

**Strong naming (build/tool scope).** Signed, public-signed, and delay-signed inputs are
detected. A signed input is re-signed when `ClockworkStrongNameKeyPath` (or the JSON/CLI
equivalent) supplies usable private-key material whose public-key token matches the input;
otherwise the build fails clearly rather than emitting a broken identity. Unsigned inputs remain
unsigned even when a key is configured. Public-key-token consistency across a rewritten dependency
closure is verified. **Authenticode** signatures are detected
detected. Clockwork automatically strips strong-name identities from every rewritten assembly and
removes matching public-key tokens from references within the rewritten closure. Friend-assembly
public-key qualifiers are removed at the same time, so the transformed closure remains internally
consistent without signing keys. This is safe for isolated simulation/test artifacts; instrumented
assemblies are not production replacements. **Authenticode** signatures are detected
and reported as unsupported - they are never re-applied, and a rewritten assembly does not
retain its Authenticode signature; re-sign such outputs with your own toolchain after
instrumentation.
Expand Down Expand Up @@ -591,9 +609,9 @@ cancels all remaining registrations without invoking user callbacks.
completed bundles or native images. Instrument the resolved IL closure before single-file
bundling, trimming, crossgen/ReadyToRun, or NativeAOT. Rewriting an already bundled, trimmed,
ReadyToRun, or NativeAOT output is unsupported.
- **Signed assemblies.** Rewriting invalidates existing signatures. The build/tool path can fail or
re-sign a strong-named closure with a supplied key and verifies public-key-token consistency.
Authenticode is detected but not re-applied; consumers must apply it after instrumentation.
- **Signed assemblies.** Rewritten strong-name identities and matching closure references are
stripped automatically. Authenticode is detected but not re-applied; consumers must apply it
after instrumentation if an instrumented artifact must be redistributed.
- **Nondeterministic BCL surface beyond the rule inventory.** Only the exact signatures in
[`rule-inventory.md`](rule-inventory.md) are rewritten. Documented holes include `Stopwatch`
instance APIs and `GetElapsedTime(long, long)`; unlisted `RandomNumberGenerator` overloads; and
Expand Down
24 changes: 18 additions & 6 deletions src/Clockwork.Instrumentation.Build/ClockworkInstrumentTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ public sealed class ClockworkInstrumentTask : MSBuildTask
/// <summary>Gets or sets the instrumentation mode (<c>Controlled</c> or <c>RaceExploration</c>).</summary>
public string InstrumentationMode { get; set; } = nameof(Configuration.InstrumentationMode.Controlled);

/// <summary>Gets or sets the strong-name key path used to re-sign signed inputs.</summary>
public string? StrongNameKeyPath { get; set; }

/// <summary>Gets or sets the target runtime version rules are evaluated against, or empty to disable filtering.</summary>
public string? TargetRuntime { get; set; }

Expand Down Expand Up @@ -161,13 +158,28 @@ private InstrumentationConfiguration BuildConfiguration()
? InstrumentationConfigurationLoader.Load(path)
: new InstrumentationConfiguration
{
IncludePatterns = ToPatternArray(IncludePatterns),
ExcludePatterns = ToPatternArray(ExcludePatterns),
Mode = ParseEnum<InstrumentationMode>(InstrumentationMode, nameof(InstrumentationMode)),
TargetRuntime = ParseVersion(TargetRuntime),
StrongNameKeyPath = NullIfEmpty(StrongNameKeyPath),
};

System.Collections.Immutable.ImmutableArray<string> taskIncludes = ToPatternArray(IncludePatterns);
if (!taskIncludes.IsDefaultOrEmpty)
{
configuration = configuration with
{
IncludePatterns = [.. configuration.IncludePatterns, .. taskIncludes],
};
}

System.Collections.Immutable.ImmutableArray<string> taskExcludes = ToPatternArray(ExcludePatterns);
if (!taskExcludes.IsDefaultOrEmpty)
{
configuration = configuration with
{
ExcludePatterns = [.. configuration.ExcludePatterns, .. taskExcludes],
};
}

System.Collections.Immutable.ImmutableArray<string> taskRuleSets = ToPatternArray(RuleSetPaths);
if (!taskRuleSets.IsDefaultOrEmpty)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using Clockwork.Instrumentation.Inspection;
using Clockwork.Instrumentation.Orchestration;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using MSBuildTask = Microsoft.Build.Utilities.Task;

namespace Clockwork.Instrumentation.Build;

/// <summary>
/// Verifies that every managed assembly selected from an instrumented test closure was successfully
/// rewritten and that its staged entry assembly is runnable.
/// </summary>
public sealed class ClockworkValidateInstrumentedTestTask : MSBuildTask
{
/// <summary>Gets or sets the staged instrumented closure directory.</summary>
[Required]
public string StagingDirectory { get; set; } = string.Empty;

/// <summary>Gets or sets the closure manifest path.</summary>
[Required]
public string ManifestPath { get; set; } = string.Empty;

/// <summary>Gets or sets the expected closure-relative entry assembly path.</summary>
[Required]
public string EntryAssemblyName { get; set; } = string.Empty;

/// <inheritdoc/>
public override bool Execute()
{
ClosureManifest manifest;
try
{
manifest = ClosureManifestJson.Read(ManifestPath, out _);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or ClosureManifestFormatException)
{
Log.LogError(
null,
"CWR0202",
null,
ManifestPath,
0,
0,
0,
0,
$"Clockwork could not validate the instrumented test manifest: {exception.Message}");
return false;
}

string stagingRoot = Path.GetFullPath(StagingDirectory);
string expectedEntry = NormalizeRelative(
EntryAssemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? EntryAssemblyName
: EntryAssemblyName + ".dll");
if (!string.Equals(manifest.EntryRelativePath, expectedEntry, StringComparison.Ordinal))
{
LogError(
"CWR0203",
$"Instrumented test manifest entry '{manifest.EntryRelativePath ?? "<none>"}' does not match '{expectedEntry}'.");
}

foreach (ClosureManifestEntry entry in manifest.Assemblies)
{
if ((!entry.WasRewritten && !entry.WasNoOp) || entry.ErrorCount != 0)
{
LogError(
"CWR0204",
$"Assembly '{entry.RelativePath}' was not successfully instrumented according to '{ManifestPath}'.");
continue;
}

if (!TryResolveWithinRoot(stagingRoot, entry.RelativePath, out string stagedPath))
{
LogError("CWR0205", $"Manifest assembly path '{entry.RelativePath}' escapes the staged closure.");
continue;
}

if (!File.Exists(stagedPath))
{
LogError("CWR0206", $"Staged test assembly '{stagedPath}' was not found.");
continue;
}

if (!AssemblyInspector.TryReadMarker(stagedPath, out _))
{
LogError(
"CWR0207",
$"Staged test assembly '{stagedPath}' does not carry a Clockwork rewrite signature.");
}
}

bool entryRewritten = manifest.Assemblies.Any(entry =>
string.Equals(entry.RelativePath, expectedEntry, StringComparison.Ordinal));
bool entryCopied = manifest.CopiedAssets.Any(entry =>
string.Equals(entry.RelativePath, expectedEntry, StringComparison.Ordinal));
if (!entryRewritten && !entryCopied)
{
LogError("CWR0208", $"Entry assembly '{expectedEntry}' was neither instrumented nor copied.");
}

return !Log.HasLoggedErrors;
}

private void LogError(string code, string message) =>
Log.LogError(null, code, null, null, 0, 0, 0, 0, message);

private static string NormalizeRelative(string path) =>
path.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');

private static bool TryResolveWithinRoot(string root, string relativePath, out string fullPath)
{
fullPath = string.Empty;
if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath))
{
return false;
}

string candidate = Path.GetFullPath(Path.Combine(root, relativePath));
string relative = Path.GetRelativePath(root, candidate);
if (relative == ".." ||
relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) ||
Path.IsPathRooted(relative))
{
return false;
}

fullPath = candidate;
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<PropertyGroup>
<!-- Master opt-in switch. Never instrument unless this is explicitly true. -->
<ClockworkInstrumentationEnabled Condition="'$(ClockworkInstrumentationEnabled)' == ''">false</ClockworkInstrumentationEnabled>
<!-- Test-project opt-in. The targets turn this into instrumentation plus staged execution. -->
<ClockworkInstrumentedTestProject Condition="'$(ClockworkInstrumentedTestProject)' == ''">false</ClockworkInstrumentedTestProject>
<ClockworkInstrumentationMode Condition="'$(ClockworkInstrumentationMode)' == ''">Controlled</ClockworkInstrumentationMode>

<!--
Expand Down
Loading