Editor utility that manages a per-editor-window temporary workspace under the mounted .source2/temp/prism folder. It creates/adopts session folders, writes files atomically (text and binary), tracks owned files, sweeps pending or stale artifacts, collects garbage sessions, and removes the session on Dispose.
using Editor.Prism.Core;
using System.IO;
namespace Editor.Prism.Toolchain;
/// <summary>
/// A per-window scratch folder under <c><sbox>/.source2/temp/prism/</c>.
/// <para>
/// <c>.source2/temp/</c> is a real mounted game search path (<c>mod_temp</c>), so a shader written to
/// <c>.source2/temp/prism/<session>/preview.shader</c> and compiled in place is loadable at
/// runtime by the relative path <c>prism/<session>/preview.shader</c> — no asset registration in
/// the user's project, no source-control noise, no <c>.vmat</c>. The engine hardcodes the compiled
/// output path to <c><source>.shader_c</c> beside the source, which is precisely why the source
/// has to live somewhere scratch in the first place.
/// </para>
/// <para>
/// Every write is atomic (temp file plus replace), every file written is tracked so a cancelled
/// compile can be swept clean, and stale sessions left behind by a crash are garbage collected on
/// startup. Nothing here throws: a workspace that could not be created reports
/// <see cref="IsValid"/> false and every operation degrades to a no-op.
/// </para>
/// </summary>
public sealed class TempWorkspace : IDisposable
{
/// <summary>Extension used for the in-flight half of an atomic write.</summary>
const string PendingSuffix = ".prism-tmp";
/// <summary>Suffix the engine's resource compiler appends to a compiled shader.</summary>
const string CompiledSuffix = "_c";
readonly HashSet<string> _tracked = new( StringComparer.OrdinalIgnoreCase );
readonly object _lock = new();
bool _disposed;
/// <summary>
/// Create (or adopt) the scratch folder for a session. Passing null uses
/// <see cref="PrismConstants.SessionId"/>, which is minted once per editor process.
/// </summary>
public TempWorkspace( string sessionId = null )
{
SessionId = Sanitize( string.IsNullOrWhiteSpace( sessionId ) ? PrismConstants.SessionId : sessionId );
RootRelative = $"{PrismConstants.TempRoot}/{SessionId}";
ContentRelative = $"{ContentPrefix}/{SessionId}";
RootAbsolute = PrismLog.Guard( "Resolving the Prism scratch folder", () =>
{
var fs = Editor.FileSystem.Root;
if ( fs is null ) return null;
fs.CreateDirectory( RootRelative );
var full = fs.GetFullPath( RootRelative );
if ( string.IsNullOrWhiteSpace( full ) ) return null;
Directory.CreateDirectory( full );
return full.Replace( '\\', '/' ).TrimEnd( '/' );
}, null );
IsValid = !string.IsNullOrWhiteSpace( RootAbsolute );
if ( !IsValid )
{
PrismLog.Warn( "Prism could not create a scratch workspace; preview compiles are disabled." );
return;
}
// Adopting a folder a previous run left behind: a pending write is by definition an interrupted
// one, and nothing will ever come back for it.
SweepPending();
}
/// <summary>
/// Delete the half-written files an interrupted atomic write leaves behind. Unlike
/// <see cref="Sweep"/> this touches nothing else, so it is safe on a folder whose real contents are
/// still in use.
/// </summary>
public int SweepPending()
{
if ( !IsValid ) return 0;
return PrismLog.Guard( "Sweeping interrupted Prism scratch writes", () =>
{
var removed = 0;
foreach ( var file in Directory.EnumerateFiles( RootAbsolute, "*" + PendingSuffix ) )
{
try
{
File.Delete( file );
removed++;
}
catch ( IOException )
{
}
catch ( UnauthorizedAccessException )
{
}
}
return removed;
}, 0 );
}
/// <summary>The folder name Prism owns inside the mounted temp search path.</summary>
public const string ContentPrefix = "prism";
/// <summary>The session this workspace belongs to. Two open windows never share one.</summary>
public string SessionId { get; }
/// <summary>The session folder relative to <c>Editor.FileSystem.Root</c>.</summary>
public string RootRelative { get; }
/// <summary>
/// The session folder as the runtime sees it, i.e. relative to the mounted <c>.source2/temp</c>
/// search path. This is the form <c>mat_reloadshaders</c> and <c>Material.Create</c> want.
/// </summary>
public string ContentRelative { get; }
/// <summary>Absolute path of the session folder, or null when it could not be created.</summary>
public string RootAbsolute { get; }
/// <summary>False when the workspace could not be created. Every operation then no-ops.</summary>
public bool IsValid { get; }
/// <summary>Files this workspace has written and still owns, as bare file names.</summary>
public IReadOnlyCollection<string> Files
{
get
{
lock ( _lock ) return _tracked.ToArray();
}
}
// ---- path helpers ----------------------------------------------------
/// <summary>The path of a file in this session, relative to <c>Editor.FileSystem.Root</c>.</summary>
public string Relative( string fileName ) => $"{RootRelative}/{Clean( fileName )}";
/// <summary>The path of a file in this session as the runtime resolves it.</summary>
public string ContentPath( string fileName ) => $"{ContentRelative}/{Clean( fileName )}";
/// <summary>The absolute path of a file in this session, or null when the workspace is invalid.</summary>
public string Absolute( string fileName ) =>
IsValid ? $"{RootAbsolute}/{Clean( fileName )}" : null;
// ---- io --------------------------------------------------------------
/// <summary>
/// Write a text file atomically: the bytes go to a sibling temp file first and only then replace the
/// target, so a cancelled or crashed write can never leave a half-written shader for the compiler to
/// choke on. Returns false when nothing was written.
/// </summary>
public bool Write( string fileName, string text )
{
if ( !IsValid || _disposed ) return false;
var target = Absolute( fileName );
if ( string.IsNullOrEmpty( target ) ) return false;
return PrismLog.Guard( $"Writing {fileName}", () =>
{
var pending = target + PendingSuffix;
Directory.CreateDirectory( Path.GetDirectoryName( target ) );
File.WriteAllText( pending, text ?? string.Empty, System.Text.Encoding.UTF8 );
// File.Replace demands an existing destination; Move with overwrite is atomic enough on
// NTFS and is the only form that works for the first write.
File.Move( pending, target, true );
Track( fileName );
return true;
}, false );
}
/// <summary>Write a binary file atomically. Returns false when nothing was written.</summary>
public bool WriteBytes( string fileName, byte[] bytes )
{
if ( !IsValid || _disposed ) return false;
var target = Absolute( fileName );
if ( string.IsNullOrEmpty( target ) ) return false;
return PrismLog.Guard( $"Writing {fileName}", () =>
{
var pending = target + PendingSuffix;
Directory.CreateDirectory( Path.GetDirectoryName( target ) );
File.WriteAllBytes( pending, bytes ?? Array.Empty<byte>() );
File.Move( pending, target, true );
Track( fileName );
return true;
}, false );
}
/// <summary>Read a file back, or null when it is absent or unreadable.</summary>
public string Read( string fileName )
{
var target = Absolute( fileName );
if ( string.IsNullOrEmpty( target ) ) return null;
return PrismLog.Guard( $"Reading {fileName}", () =>
File.Exists( target ) ? File.ReadAllText( target ) : null, null );
}
/// <summary>True when the file exists in this session.</summary>
public bool Exists( string fileName )
{
var target = Absolute( fileName );
return !string.IsNullOrEmpty( target ) && PrismLog.Guard( "Probing the scratch workspace",
() => File.Exists( target ), false );
}
/// <summary>
/// Delete a file, its compiled <c>_c</c> sibling and any leftover pending write, and stop tracking
/// it. Safe to call for something that was never written.
/// </summary>
public bool Delete( string fileName )
{
var target = Absolute( fileName );
if ( string.IsNullOrEmpty( target ) ) return false;
lock ( _lock ) _tracked.Remove( Clean( fileName ) );
return PrismLog.Guard( $"Deleting {fileName}", () =>
{
var removed = false;
foreach ( var candidate in new[] { target, target + CompiledSuffix, target + PendingSuffix } )
{
if ( !File.Exists( candidate ) ) continue;
File.Delete( candidate );
removed = true;
}
return removed;
}, false );
}
/// <summary>Record a file as owned by this workspace without writing it.</summary>
public void Track( string fileName )
{
if ( string.IsNullOrWhiteSpace( fileName ) ) return;
lock ( _lock ) _tracked.Add( Clean( fileName ) );
}
/// <summary>Stop tracking a file, leaving it on disk.</summary>
public void Forget( string fileName )
{
if ( string.IsNullOrWhiteSpace( fileName ) ) return;
lock ( _lock ) _tracked.Remove( Clean( fileName ) );
}
/// <summary>
/// Delete everything in the session folder that this workspace does not currently own — the
/// half-finished artifacts a cancelled compile leaves behind. Returns how many files went.
/// </summary>
public int Sweep()
{
if ( !IsValid ) return 0;
return PrismLog.Guard( "Sweeping the Prism scratch workspace", () =>
{
HashSet<string> keep;
lock ( _lock ) keep = new HashSet<string>( _tracked, StringComparer.OrdinalIgnoreCase );
var removed = 0;
foreach ( var file in Directory.EnumerateFiles( RootAbsolute ) )
{
var name = Path.GetFileName( file );
// A pending write is never owned by anyone: it is by definition an interrupted write.
if ( !name.EndsWith( PendingSuffix, StringComparison.OrdinalIgnoreCase ) )
{
var owner = name.EndsWith( CompiledSuffix, StringComparison.Ordinal )
? name[..^CompiledSuffix.Length]
: name;
if ( keep.Contains( owner ) ) continue;
}
try
{
File.Delete( file );
removed++;
}
catch ( IOException )
{
// Held open by the compiler; it will go on the next sweep or with the session.
}
}
return removed;
}, 0 );
}
/// <summary>Delete every file in the session folder and stop tracking all of them.</summary>
public int Clear()
{
lock ( _lock ) _tracked.Clear();
return Sweep();
}
/// <summary>Remove the session folder entirely. Called when the owning window closes.</summary>
public void Dispose()
{
if ( _disposed ) return;
_disposed = true;
if ( !IsValid ) return;
PrismLog.Guard( "Removing the Prism scratch workspace", () =>
{
if ( Directory.Exists( RootAbsolute ) ) Directory.Delete( RootAbsolute, true );
} );
lock ( _lock ) _tracked.Clear();
}
// ---- static maintenance ----------------------------------------------
/// <summary>Absolute path of <c>.source2/temp/prism</c>, or null when it cannot be resolved.</summary>
public static string RootPath => PrismLog.Guard( "Resolving the Prism scratch root", () =>
{
var fs = Editor.FileSystem.Root;
if ( fs is null ) return null;
fs.CreateDirectory( PrismConstants.TempRoot );
var full = fs.GetFullPath( PrismConstants.TempRoot );
return string.IsNullOrWhiteSpace( full ) ? null : full.Replace( '\\', '/' ).TrimEnd( '/' );
}, null );
/// <summary>
/// Delete session folders older than <paramref name="lifetimeHours"/>, plus any folder that is
/// obviously not one of ours. Run once on editor start: a crash leaves the session folder behind and
/// nobody ever comes back for it.
/// </summary>
public static int CollectGarbage( int lifetimeHours = PrismConstants.TempSessionLifetimeHours,
string keepSessionId = null )
{
var root = RootPath;
if ( string.IsNullOrWhiteSpace( root ) ) return 0;
var keep = string.IsNullOrWhiteSpace( keepSessionId ) ? PrismConstants.SessionId : Sanitize( keepSessionId );
return PrismLog.Guard( "Collecting stale Prism scratch sessions", () =>
{
var cutoff = DateTime.UtcNow.AddHours( -Math.Max( 0, lifetimeHours ) );
var removed = 0;
foreach ( var directory in Directory.EnumerateDirectories( root ) )
{
// One unreadable session must never stop the others being collected, so the whole
// per-folder decision is isolated rather than only the two calls that usually fail.
try
{
var name = Path.GetFileName( directory );
if ( string.Equals( name, keep, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( Newest( directory, cutoff ) > cutoff ) continue;
Directory.Delete( directory, true );
removed++;
}
catch ( IOException )
{
// Another editor instance still owns it. Leave it; it will age out later.
}
catch ( UnauthorizedAccessException )
{
}
}
// A stray file directly under the root is not a session and nothing will ever claim it.
foreach ( var file in Directory.EnumerateFiles( root ) )
{
try
{
if ( File.GetLastWriteTimeUtc( file ) > cutoff ) continue;
File.Delete( file );
removed++;
}
catch ( IOException )
{
}
catch ( UnauthorizedAccessException )
{
}
}
if ( removed > 0 ) PrismLog.Trace( $"Removed {removed} stale Prism scratch item(s)" );
return removed;
}, 0 );
}
/// <summary>
/// The most recent write time anywhere inside a folder, including the folder itself. Stops as soon
/// as it finds something newer than the cutoff, because the only question being asked is "is any of
/// this still live?" and a compiled shader tree can hold thousands of files.
/// </summary>
static DateTime Newest( string directory, DateTime? stopAfter = null )
{
var newest = Directory.GetLastWriteTimeUtc( directory );
if ( stopAfter is { } cutoff && newest > cutoff ) return newest;
foreach ( var file in Directory.EnumerateFiles( directory, "*", SearchOption.AllDirectories ) )
{
var stamp = File.GetLastWriteTimeUtc( file );
if ( stamp > newest ) newest = stamp;
if ( stopAfter is { } limit && newest > limit ) break;
}
return newest;
}
// ---- naming ----------------------------------------------------------
/// <summary>Strip anything that could escape the session folder or upset the resource compiler.</summary>
static string Clean( string fileName )
{
if ( string.IsNullOrWhiteSpace( fileName ) ) return "unnamed";
var name = Path.GetFileName( fileName.Replace( '\\', '/' ) );
if ( string.IsNullOrWhiteSpace( name ) ) return "unnamed";
var builder = new System.Text.StringBuilder( name.Length );
foreach ( var c in name )
{
builder.Append( char.IsLetterOrDigit( c ) || c is '.' or '_' or '-' ? c : '_' );
}
var cleaned = builder.ToString().Trim( '.', '_', '-' );
return cleaned.Length == 0 ? "unnamed" : cleaned;
}
/// <summary>Session ids become folder names, so they get the same treatment.</summary>
static string Sanitize( string sessionId )
{
var builder = new System.Text.StringBuilder( sessionId.Length );
foreach ( var c in sessionId )
{
builder.Append( char.IsLetterOrDigit( c ) || c is '_' or '-' ? c : '_' );
}
var cleaned = builder.ToString().Trim( '_', '-' );
return cleaned.Length == 0 ? "session" : cleaned;
}
/// <inheritdoc/>
public override string ToString() => IsValid ? RootRelative : $"{RootRelative} (unavailable)";
}