FileSystemWatcherを-Action付きで使う場合、event queueをWait-Eventで待ちません。Register-ObjectEventが返すexact PSEventJobを、-WaitなしのReceive-Job -Keepで短い間隔だけpollし、RunId・SourceIdentifier・FullPath・ChangeTypeが一致した証拠をbounded deadline内に得ます。timeoutでもfinallyへ必ず進みます。
run固有のcontrolled fixtureとowner fileを作る
GUID付きSourceIdentifier、専用temp directory、exact watched leaf、CreateNewしたowner fileを用意します。rootやleafが既存・reparse pointなら停止し、他のwatcherやjobとidentityを共有しません。
$ErrorActionPreference='Stop'
$runId=[guid]::NewGuid().ToString('D')
$sourceIdentifier="ITtrip.FileChanged.$runId"
$fixtureRoot=Join-Path ([IO.Path]::GetTempPath()) "ITtrip-Watcher-$runId"
$ownerPath=Join-Path $fixtureRoot 'owner.json'
$watchedPath=Join-Path $fixtureRoot 'watched.txt'
if(Test-Path -LiteralPath $fixtureRoot){throw 'controlled fixture root already exists'}
$fixtureRootCreated=$false;$ownerCreated=$false;$watchedCreated=$false
$watcher=$null;$eventJob=$null;$eventJobId=$null;$eventJobInstanceId=$null;$eventJobName=$null;$eventJobCommand=$null;$subscriptionId=$null
[pscustomobject]@{RunId=$runId;SourceIdentifier=$sourceIdentifier;WatchedPath=$watchedPath;Stage='ControlledFixturePlanned';MutationPerformed=$false}
-Action jobをbounded Receive-Jobで観測しfinallyでexact cleanupする
Register-ObjectEvent -Actionの返り値からId・InstanceId・Name・Commandを保存します。一回のcontrolled append後、Receive-Job -Id … -Keepを10秒deadlineまでpollし、完全一致したoutputだけを証拠にします。Wait-EventもReceive-Job -Waitも使いません。
$fixtureResult=$null
$cleanupErrors=[Collections.Generic.List[string]]::new()
try{
$null=New-Item -ItemType Directory -Path $fixtureRoot -ErrorAction Stop;$fixtureRootCreated=$true
$rootItem=Get-Item -LiteralPath $fixtureRoot -Force -ErrorAction Stop
if($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'fixture root is a reparse point'}
$owner=[ordered]@{RunId=$runId;SourceIdentifier=$sourceIdentifier;FixtureRoot=$fixtureRoot;WatchedPath=$watchedPath;CreatedUtc=(Get-Date).ToUniversalTime().ToString('o')}
$ownerBytes=[Text.Encoding]::UTF8.GetBytes(($owner|ConvertTo-Json -Compress))
$ownerStream=[IO.File]::Open($ownerPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{$ownerStream.Write($ownerBytes,0,$ownerBytes.Length);$ownerStream.Flush($true)}finally{$ownerStream.Dispose()};$ownerCreated=$true
$watchedStream=[IO.File]::Open($watchedPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read)
try{$seed=[Text.Encoding]::UTF8.GetBytes("seed-$runId"+[Environment]::NewLine);$watchedStream.Write($seed,0,$seed.Length);$watchedStream.Flush($true)}finally{$watchedStream.Dispose()};$watchedCreated=$true
$watcher=[IO.FileSystemWatcher]::new($fixtureRoot,'watched.txt')
$watcher.IncludeSubdirectories=$false
$watcher.NotifyFilter=[IO.NotifyFilters]::LastWrite
$message=[pscustomobject]@{RunId=$runId;SourceIdentifier=$sourceIdentifier;ExpectedPath=[IO.Path]::GetFullPath($watchedPath)}
$eventJob=Register-ObjectEvent -InputObject $watcher -EventName Changed -SourceIdentifier $sourceIdentifier -MessageData $message -Action {
$args=$Event.SourceEventArgs
[pscustomobject]@{RunId=$Event.MessageData.RunId;SourceIdentifier=$Event.MessageData.SourceIdentifier;FullPath=[IO.Path]::GetFullPath([string]$args.FullPath);ChangeType=[string]$args.ChangeType;ObservedUtc=(Get-Date).ToUniversalTime().ToString('o')}
}
$eventJobId=$eventJob.Id;$eventJobInstanceId=$eventJob.InstanceId;$eventJobName=$eventJob.Name;$eventJobCommand=$eventJob.Command
$subscribers=@(Get-EventSubscriber -SourceIdentifier $sourceIdentifier -ErrorAction Stop)
if($subscribers.Count -ne 1){throw "exact subscriber count is $($subscribers.Count)"}
$subscriptionId=$subscribers[0].SubscriptionId
$watcher.EnableRaisingEvents=$true
[IO.File]::AppendAllText($watchedPath,"controlled-change-$runId"+[Environment]::NewLine,[Text.UTF8Encoding]::new($false))
$deadline=(Get-Date).ToUniversalTime().AddSeconds(10)
$matched=@()
do{
$received=@(Receive-Job -Id $eventJob.Id -Keep -ErrorAction Stop)
$matched=@($received|Where-Object{$_.RunId -ceq $runId -and $_.SourceIdentifier -ceq $sourceIdentifier -and $_.FullPath -ceq [IO.Path]::GetFullPath($watchedPath) -and $_.ChangeType -ceq 'Changed'})
if($matched.Count -ge 1){break}
Start-Sleep -Milliseconds 100
}while((Get-Date).ToUniversalTime() -lt $deadline)
if($matched.Count -lt 1){throw 'bounded Receive-Job polling timed out without exact evidence'}
$fixtureResult=[pscustomobject]@{Status='ControlledChangeObserved';RunId=$runId;SourceIdentifier=$sourceIdentifier;FullPath=[IO.Path]::GetFullPath($watchedPath);ChangeType='Changed';EvidenceCount=$matched.Count;DeadlineSeconds=10}
}finally{
if($null -ne $watcher){try{$watcher.EnableRaisingEvents=$false}catch{$cleanupErrors.Add("disable watcher: $($_.Exception.Message)")}}
if($null -ne $subscriptionId){
try{
$ownedSubscribers=@(Get-EventSubscriber -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue|Where-Object{$_.SubscriptionId -eq $subscriptionId})
if($ownedSubscribers.Count -eq 1){Unregister-Event -SubscriptionId $subscriptionId -ErrorAction Stop}elseif($ownedSubscribers.Count -ne 0){$cleanupErrors.Add('subscriber ownership became non-unique')}
}catch{$cleanupErrors.Add("subscriber cleanup: $($_.Exception.Message)")}
}
try{@(Get-Event -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue)|ForEach-Object{Remove-Event -EventIdentifier $_.EventIdentifier -ErrorAction Stop}}catch{$cleanupErrors.Add("queued event cleanup: $($_.Exception.Message)")}
if($null -ne $eventJobId){
try{
$ownedJobs=@(Get-Job -Id $eventJobId -ErrorAction SilentlyContinue|Where-Object{$_.InstanceId -eq $eventJobInstanceId -and $_.Name -ceq $eventJobName -and $_.Command -ceq $eventJobCommand})
if($ownedJobs.Count -eq 1){Remove-Job -Id $eventJobId -Force -ErrorAction Stop}elseif($ownedJobs.Count -ne 0){$cleanupErrors.Add('event job ownership became non-unique')}
}catch{$cleanupErrors.Add("event job cleanup: $($_.Exception.Message)")}
}
if($null -ne $watcher){try{$watcher.Dispose()}catch{$cleanupErrors.Add("watcher dispose: $($_.Exception.Message)")}}
try{
if($ownerCreated){
$ownerItem=Get-Item -LiteralPath $ownerPath -Force -ErrorAction Stop
if($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'owner file became a reparse point'}
$saved=Get-Content -LiteralPath $ownerPath -Raw -ErrorAction Stop|ConvertFrom-Json
if($saved.RunId -cne $runId -or $saved.SourceIdentifier -cne $sourceIdentifier -or $saved.FixtureRoot -cne $fixtureRoot -or $saved.WatchedPath -cne $watchedPath){throw 'fixture ownership changed'}
}
foreach($leaf in @($watchedPath,$ownerPath)){if(Test-Path -LiteralPath $leaf){$item=Get-Item -LiteralPath $leaf -Force -ErrorAction Stop;if($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw "refuse non-leaf cleanup: $leaf"};Remove-Item -LiteralPath $leaf -ErrorAction Stop}}
if($fixtureRootCreated -and (Test-Path -LiteralPath $fixtureRoot)){$remaining=@([IO.Directory]::EnumerateFileSystemEntries($fixtureRoot));if($remaining.Count){throw 'fixture root is not empty after exact leaf cleanup'};Remove-Item -LiteralPath $fixtureRoot -ErrorAction Stop}
}catch{$cleanupErrors.Add("fixture cleanup: $($_.Exception.Message)")}
}
if($cleanupErrors.Count){throw "exact finally cleanup failed: $($cleanupErrors -join '; ')"}
$fixtureResult
実対象へ移す前にrootとleafを別承認する
controlled fixtureが合格した後だけ、fully-qualified rootと一つのleafを明示します。production監視でも同じAction+bounded polling modelとexact finally cleanupを使い、wildcard job cleanupやrecursive deleteへ広げません。
$approvedRoot='C:\Logs\ApprovedApp'
$approvedLeaf='activity.log'
$exactPath=Join-Path $approvedRoot $approvedLeaf
if(-not [IO.Path]::IsPathFullyQualified($approvedRoot)){throw 'approved root must be fully qualified'}
if([IO.Path]::GetFileName($exactPath) -cne $approvedLeaf){throw 'leaf identity mismatch'}
if(-not (Test-Path -LiteralPath $approvedRoot -PathType Container)){throw 'approved root is absent'}
$root=Get-Item -LiteralPath $approvedRoot -Force -ErrorAction Stop
if($root.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'approved root is a reparse point'}
$token="APPROVE-WATCH ROOT=$approvedRoot LEAF=$approvedLeaf MODEL=ACTION-BOUNDED-RECEIVE"
if((Read-Host "controlled fixture合格後、実対象を監視する場合だけ入力: $token") -cne $token){throw 'operational watcher was not approved'}
[pscustomobject]@{ApprovedRoot=$approvedRoot;ApprovedLeaf=$approvedLeaf;ExactPath=$exactPath;DeliveryModel='Register-ObjectEvent -Action plus bounded Receive-Job polling';Guardrails='no queue wait; no terminal-state job wait; no wildcard cleanup; no recursive delete'}
timeoutと無関係eventの扱い
deadlineまでexact evidenceがなければhangせず失敗します。別RunId、別SourceIdentifier、別FullPath、別ChangeTypeのoutputは合格に数えません。finallyはsubscriber、queued event、job、watcher、run-owned leafの順にidentityを照合し、unrelated resourceを残したまま自身だけを片付けます。
受け入れ条件
一つのcontrolled exact-path changeが期限内に一致し、subscriber・event・job・watcher・fixtureが残らないことが合格です。no-event fixtureは期限内に失敗して同じcleanupを完了し、unrelated job/eventは証拠にも削除対象にもなりません。
FileSystemWatcherの限界
短時間の大量変更ではbuffer overflowや重複通知が起こり得ます。通知を監査台帳そのものとせず、検出後にexact pathの状態を再取得し、必要ならUSN Journalやアプリケーション側logなど別のdurable evidenceを組み合わせます。

コメント