Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri İşletim Sistemi Temelleri — #02

Process Nedir? Address Space, PID, Exit Code ve .NET'te Child Process Çalıştırmak

Mert Özen 14 Kas 2026 14 dk 1 görüntülenme
Process Nedir? Address Space, PID, Exit Code ve .NET'te Child Process Çalıştırmak

Process, çalışan bir programın kernel'deki karşılığıdır: kendi address space'i, file descriptor tablosu, environment'ı ve en az bir thread'i olan, PID ile tanınan bir birim. /proc ile bir .NET process'inin içine bakıyor, fork/exec ve zombie'yi strace ve ps ile görüyor, Process.Start'ta stdout ve stderr'i ayrı ayrı okumanın 64 KB pipe buffer'ında nasıl deadlock ürettiğini ve timeout, exit code, shell injection ile birlikte nasıl düzeltildiğini gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı. Linux gözlemleri Ubuntu 24.04 tabanlı mcr.microsoft.com/dotnet/sdk:10.0 container'ında (Linux 6.x kernel) ps, /proc ve strace 6.8 ile yapıldı. Windows davranışları Microsoft Learn dokümanlarına ve dotnet/runtime kaynak koduna dayanır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) satış raporunu kendisi üretmiyor; ekibin yazdığı bir komut satırı aracını Process.Start ile çağırıyor. Araç CSV'yi (virgülle ayrılmış düz metin tablo) stdout'a, "şu kadar satır yazıldı" mesajlarını stderr'e basıyor. Test ortamında 1000 satırlık raporla her şey yolunda. Üretimde 5000 satırlık bir rapor istendiğinde istek hiç dönmüyor: CPU sıfır, hata yok, log yok, iki process de öylece bekliyor. Neden?

İkinci soru: bu kilitlenmeyi bir timeout ile kesip aracı öldürdüğünde Process.ExitCode Linux'ta kaç döner? Windows'ta kaç? Cevaplar bu yazıda, kernel'in process'i nasıl gördüğüne bakarak geliyor.

Process Nedir?

Program diskte duran bir dosyadır; Bookstore.OsLab.dll ve onu çalıştıran dotnet host'u. Process ise o programın çalışan bir örneğidir ve kernel ona üç şey verir. Birincisi kendine ait bir address space: process'in gördüğü bütün bellek adresleri yalnız ona aittir, başka bir process aynı adresi kullansa bile farklı fiziksel belleğe gider. İkincisi kaynaklar: açık dosyaların ve socket'lerin listesi (Linux'ta file descriptor tablosu, Windows'ta handle tablosu), environment variable'lar, çalışma dizini ve hangi kullanıcı adına çalıştığı. Üçüncüsü en az bir thread; kodu asıl çalıştıran, scheduler'ın CPU'ya koyduğu şey thread'dir ve bir sonraki yazının konusu. Kernel process'i PID (process ID; kernel'in her process'e verdiği numara) ile tanır.

Linux'ta bunların hepsini /proc/<pid>/ altında dosya gibi okuyabilirsin. Deney uygulamasına PID'ini yazıp bekleyen bir komut ekledim:

case "proc-info":
    Console.WriteLine($"pid={Environment.ProcessId}, sleeping {args[1]} s");
    Thread.Sleep(TimeSpan.FromSeconds(int.Parse(args[1])));
    break;

Container'ı bu kez adıyla ve --init ile başlat; --init, container'da PID 1 olarak küçük bir init process'i çalıştırır, nedenini zombie bölümünde göreceğiz. Bir terminalde uygulamayı çalıştır, ikincisinde docker exec -it oslab bash ile aynı container'a gir:

docker run --rm -it --init --name oslab --cap-add=SYS_PTRACE -v ${PWD}:/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 bash
dotnet publish src/Bookstore.OsLab -c Release -o /app && cd /app
dotnet Bookstore.OsLab.dll proc-info 30
grep -E "^(Name|State|Tgid|PPid|Threads|VmSize|VmRSS)" /proc/1133/status
Name:	dotnet
State:	S (sleeping)
Tgid:	1133
PPid:	1131
VmSize:	17838308 kB
VmRSS:	   27356 kB
Threads:	8

Tek satır kod yazmayan bir process için bile tablo dolu. PPid onu başlatan shell'in PID'i; her process'in bir parent'ı var. Threads: 8, yani biz tek bir thread yazmadan runtime yedi thread daha açmış; ilk yazıdaki clone çağrıları bunlardı. VmSize 17 GB'a yakın, VmRSS ise 27 MB. Process'in reserve ettiği adres aralığı ile gerçekten fiziksel bellekte duran kısmı arasındaki bu fark virtual memory yazısının konusu; şimdilik bu 17 GB'ın büyük kısmının bir adres aralığı olduğunu, RAM olmadığını bil. File descriptor tablosu da /proc/1133/fd altında:

ls -l /proc/1133/fd
0 -> /dev/null
1 -> /tmp/pi.txt
2 -> pipe:[1119218]
...
10 -> socket:[1123811]
12 -> /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/System.Private.CoreLib.dll

Toplam 32 descriptor var. 0, 1 ve 2 her process'te stdin, stdout ve stderr'dir; burada stdout bir dosyaya yönlendirilmiş. Socket, .NET'in diagnostics araçları için açtığı IPC (process'ler arası iletişim) kanalı; dotnet-counters ve dotnet-trace process'e bu yoldan bağlanır. Geri kalanların çoğu açılmış assembly dosyaları.

Address Space'in İçi: /proc/<pid>/maps

Address space boş bir alan değil; kernel onu bölgelere ayırır ve her bölgenin bir izni vardır. /proc/<pid>/maps bu bölgeleri listeler. Aynı process'in 240 satırlık listesinden seçtiğim satırlar:

grep -E "Bookstore.OsLab.dll|libSystem.Native.so|\[heap\]|\[stack\]" /proc/1133/maps
aaaaff3b2000-aaaaff52a000 rw-p 00000000 00:00 0                          [heap]
ffffa5280000-ffffa528b000 r--p 00000000 00:42 217758                     /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/libSystem.Native.so
ffffa529a000-ffffa52a7000 r-xp 0000a000 00:42 217758                     /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/libSystem.Native.so
ffffa52b6000-ffffa52b8000 r--p 00016000 00:42 217758                     /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/libSystem.Native.so
ffffa52c7000-ffffa52c8000 rw-p 00017000 00:42 217758                     /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/libSystem.Native.so
ffffa5b8a000-ffffa5b90000 r--s 00000000 00:42 438454                     /app/Bookstore.OsLab.dll
fffff1ee4000-fffff1f05000 rw-p 00000000 00:00 0                          [stack]

Her satır bir adres aralığı, izinler, dosya içindeki offset ve hangi dosyanın map edildiği. İzinler okunduğu gibi: r okuma, w yazma, x çalıştırma, son harf p private (process'e özel kopya) ya da s shared. Aynı native kütüphane dört parçaya bölünmüş: r-xp parçası code bölgesi, çalıştırılabilir ama yazılamaz; r--p sabit veriler; rw-p kütüphanenin değiştirilebilir global verisi. Klasik ders kitabı şemasındaki code, data, heap ve stack bölgeleri burada gerçek adresleriyle duruyor. Bu çıktı arm64 bir Linux'tan alındı ve adres düzeni CPU mimarisine göre değişir: x64 Linux'ta user space 0x00007fffffffefff'te bittiği için shared library'ler ve stack çoğunlukla 7f ile, program ve [heap] ise 55 ya da 56 ile başlayan adreslerde görünür; yukarıdaki ffff… ve aaaa… adresleri bunların arm64'teki karşılığıdır.

[heap] C tarafının malloc heap'idir; .NET'in managed heap'i değil. [stack] ana thread'in stack'i. Bookstore.OsLab.dll bir dosya gibi okunmamış, address space'e map edilmiş: runtime assembly'leri böyle yükler. Managed heap ise listede şu satırlarda:

fffb7b401000-ffff4c000000 ---p 00000000 00:00 0
ffff66690000-ffffa5200000 ---p 00000000 00:00 0

Hiçbir izni olmayan (---p) ve hiçbir dosyaya bağlı olmayan iki dev bölge: biri yaklaşık 15 GB, diğeri 1 GB. İlki GC'nin (garbage collector; kullanılmayan belleği otomatik toplayan bileşen) managed heap için önceden reserve ettiği adres aralığı. Bunu doğrulamak kolay: DOTNET_GCRegionRange=100000000 (hex, 4 GB) ile başlattığımda aynı bölge 4 GB'a indi ve VmSize 17,8 GB'tan 5,9 GB'a düştü. GC bu aralığın sayfalarını ihtiyaç oldukça kullanıma açar. Adresler her çalıştırmada değişir; senin makinende farklı olacak ve bunun bir güvenlik sebebi var, process memory yazısında konuşacağız.

Bir Process Nasıl Doğar: fork, exec ve CreateProcess

Unix'te yeni bir program iki adımda başlar. fork çağıran process'in neredeyse birebir kopyasını üretir; ardından child execve ile kendi address space'ini atıp yerine yeni programı yükler. Arada kalan kısa anda child, dosya yönlendirme ve environment gibi ayarları kendi üzerinde yapabilir; shell'deki > yönlendirmesi böyle çalışır. Windows'ta bu ayrım yok: CreateProcess tek çağrıda yeni bir process oluşturur ve verilen programı içine yükler; kopyalanan bir parent yoktur, child'a nelerin geçeceği parametrelerle söylenir.

.NET'in Linux'ta ne yaptığını strace gösteriyor. Raporu 10 satırla üretip yalnız process yönetimiyle ilgili syscall'ları izledim (thread oluşturan clone çağrılarını ve birkaç ilgisiz satırı çıkardım):

strace -f -qq -e trace=clone,clone3,execve,wait4,waitid,exit_group dotnet Bookstore.OsLab.dll report 10
384   clone(child_stack=0xffffc082e710, flags=CLONE_VM|CLONE_VFORK|SIGCHLD <unfinished ...>
392   execve("/usr/share/dotnet/dotnet", ["dotnet", "/app/Bookstore.ReportTool.dll", "sales", "10"], 0xaaab154548c0 /* 19 vars */ <unfinished ...>
384   <... clone resumed>)              = 392
392   <... execve resumed>)             = 0
392   exit_group(0 <unfinished ...>
404   --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=392, si_uid=0, si_status=0, si_utime=0, si_stime=1 /* 0.01 s */} ---
391   waitid(P_ALL, 0, {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=392, si_uid=0, si_status=0, si_utime=0, si_stime=0}, WNOHANG|WEXITED|WNOWAIT, NULL) = 0
391   wait4(392, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 392

CLONE_VM|CLONE_VFORK bayrakları bunun klasik bir fork değil, vfork olduğunu söylüyor: child, parent'ın belleğini kopyalamadan paylaşır ve çağıran thread, child execve yapana kadar bekler. Runtime'ın kaynak kodundaki açıklamaya göre kazanç, kernel'in parent'taki bütün yazılabilir sayfaları copy-on-write olarak işaretlemek zorunda kalmamasından geliyor; macOS'ta bu yol kullanılmıyor. Child execve ile dotnet host'una dönüşüyor, işini bitirip çıkıyor. Kernel parent'a SIGCHLD gönderiyor ve .NET'in bir thread'i wait4 ile child'ın çıkış durumunu topluyor.

Process State'leri, Zombie ve Orphan

Bir process hayatı boyunca birkaç durumdan geçer ve ps'in STAT sütunu bunu tek harfle gösterir: R çalışıyor ya da CPU sırası bekliyor, S bir olayı bekliyor (uyuyor ama sinyalle uyandırılabilir), D genellikle disk I/O'su için kesintisiz bekliyor, T durdurulmuş, Z zombie. Yukarıdaki proc-info process'i Thread.Sleep içinde olduğu için S durumundaydı.

Zombie, çıkmış ama çıkış kodu parent tarafından henüz okunmamış process'tir. Belleği ve dosyaları serbest bırakılmıştır, kernel yalnız PID'ini ve çıkış durumunu saklar; parent wait çağırınca kaydı silinir. Bunu görmek için çocuğunu hiç beklemeyen bir parent yeter:

sh -c "sleep 1 & exec sleep 20" &
sleep 3
ps -o pid,ppid,stat,args --ppid $(pgrep -f "^sleep 20") -p $(pgrep -f "^sleep 20")
    PID    PPID STAT COMMAND
    416     410 S    sleep 20
    418     416 Z    [sleep] <defunct>

Shell arka planda sleep 1'i başlatıp kendini exec ile sleep 20'ye dönüştürdü. sleep 20 çocuğu olduğundan habersiz, onu hiç beklemiyor; bir saniyede biten sleep 1 bu yüzden Z olarak kalıyor. Tek bir zombie zararsızdır; binlercesi PID tablosunu doldurur. Bir .NET parent'ta bu olmaz, çünkü strace'te gördüğümüz gibi runtime SIGCHLD geldiğinde çocuklarını kendisi toplar.

Orphan ise tersi: parent'ı child'dan önce ölmüş process. Kernel onu PID 1'e (ya da subreaper olarak işaretlenmiş bir atasına) bağlar:

sh -c "sleep 15 & echo parent=\$\$ child=\$!; exit"
ps -o pid,ppid,stat,args -p $(pgrep -f "^sleep 15")
parent=422 child=423
    PID    PPID STAT COMMAND
    423       1 S    sleep 15

Yeni parent PID 1 olduğu için yetim çocuğun zombie'sini toplama işi de ona düşer. Normal bir Linux makinede PID 1 systemd'dir ve bunu yapar. Container'da ise PID 1 senin uygulamandır; --init bu yüzden var. Bunun signal tarafını I/O modelleri ve IPC yazısında konuşacağız. Windows'ta zombie kavramı bu biçimde yok ama benzeri var: Microsoft'un dokümanına göre process bittiğinde process object, ona açık handle kalmayana kadar yaşar ve çıkış kodu bu sayede okunabilir. Aynı doküman önemli bir şey daha söylüyor: bir process'i sonlandırmak onun başlattığı child process'leri sonlandırmaz. Linux'ta da öyle; bunu birazdan kullanacağız.

Bookstore Rapor Aracını Çağırıyor: Pipe Deadlock'u

Şimdi giriş sorusuna dönelim. Rapor aracı, Bookstore.ReportTool, gerçek araçların çoğu gibi veriyi stdout'a, ilerleme mesajlarını stderr'e yazıyor:

case "sales":
    int rows = int.Parse(args[1]);
    Console.Out.WriteLine("isbn,title,qty,total");
    for (int i = 1; i <= rows; i++)
    {
        Console.Out.WriteLine($"9780441172719,Dune,{i % 5 + 1},{(i % 5 + 1) * 420.00m:F2}");
        Console.Error.WriteLine($"progress: row {i} of {rows} written");
    }
    if (args.Contains("--fail"))
    {
        Console.Error.WriteLine("error: warehouse data is incomplete");
        return 2;
    }
    return 0;

Bookstore tarafındaki ilk versiyon, internetteki örneklerin çoğunda görülen hâli:

// YANLIŞ: önce stdout'u sonuna kadar okur, stderr'e kimse bakmaz
public static string RunNaive(int rows)
{
    ProcessStartInfo psi = new("dotnet")
    {
        RedirectStandardOutput = true,
        RedirectStandardError = true,
    };
    foreach (string argument in Args("sales", rows.ToString()))
    {
        psi.ArgumentList.Add(argument);
    }

    using Process process = Process.Start(psi)!;
    string csv = process.StandardOutput.ReadToEnd();
    string log = process.StandardError.ReadToEnd();
    process.WaitForExit();
    return csv;
}
dotnet Bookstore.OsLab.dll report-naive 1000
time (timeout 10 dotnet Bookstore.OsLab.dll report-naive 5000; echo "exit code: $?")
rows=1000
exit code: 124

real	0m10.007s
user	0m0.021s
sys	0m0.009s

1000 satır anında bitiyor; 5000 satırı timeout 10 saniye sonra öldürüyor ve bu sürede toplam CPU kullanımı 21 milisaniye. Takılı kaldığı sırada ikinci terminalden bakınca durum net:

ps -o pid,ppid,stat,wchan:12,args -p 277,285
    PID    PPID STAT WCHAN        COMMAND
    277     276 Sl   pipe_read    dotnet Bookstore.OsLab.dll report-naive 5000
    285     277 Sl   pipe_write   dotnet /app/Bookstore.ReportTool.dll sales 5000

WCHAN sütunu process'in kernel'in hangi fonksiyonunda beklediğini söylüyor. Parent bir pipe'tan okumayı, child bir pipe'a yazmayı bekliyor. RedirectStandardOutput ve RedirectStandardError iki ayrı pipe oluşturur ve pipe'ın kernel'de sınırlı bir buffer'ı vardır; pipe(7) man sayfasına göre Linux'ta varsayılan kapasite 65.536 byte'tır ve buffer doluyken yapılan write bloklanır. Sıra şöyle işliyor: parent stdout'u sonuna kadar okumaya başlıyor. Child her satırda stderr'e de 35 byte civarı yazıyor ama stderr'i okuyan kimse yok. Yaklaşık 1900 satırdan sonra stderr pipe'ı doluyor ve child'ın write çağrısı bloklanıyor. Child artık ilerleyemediği için stdout'u da hiç kapatmıyor, parent'ın beklediği EOF (end of file; okunacak verinin bittiği işareti) da gelmiyor. İki process birbirini bekliyor ve ikisi de haklı.

1000 satırda neden sorun yok? Çünkü stderr toplamı 33.893 byte; 64 KB'a sığıyor. Hata verinin boyutuna bağlı ve bu yüzden test ortamında görünmüyor. Windows'taki anonymous pipe'ların buffer'ı da sınırlı; mekanizma aynı. Process'ler arası iletişimin diğer yolları I/O modelleri ve IPC yazısında.

Doğru Yol: İki Pipe'ı Aynı Anda Okumak, Timeout ve Process Tree

Çözümün özü iki stream'i aynı anda boşaltmak. Bookstore'un kullandığı küçük runner:

using System.Diagnostics;

namespace Bookstore.OsLab;

public sealed record ProcessResult(int ExitCode, string StdOut, string StdErr, bool TimedOut);

public static class ProcessRunner
{
    public static async Task<ProcessResult> RunAsync(
        string fileName,
        IEnumerable<string> arguments,
        TimeSpan timeout,
        IEnumerable<string>? hiddenVariables = null)
    {
        ProcessStartInfo psi = new(fileName)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };
        foreach (string argument in arguments)
        {
            psi.ArgumentList.Add(argument); // shell yok, her argüman olduğu gibi gider
        }
        foreach (string name in hiddenVariables ?? [])
        {
            psi.Environment.Remove(name); // child'a miras kalmasın
        }

        using Process process = Process.Start(psi)!;

        // İki pipe aynı anda boşaltılır; hiçbiri dolup child'ı bloklayamaz
        Task<string> stdout = process.StandardOutput.ReadToEndAsync();
        Task<string> stderr = process.StandardError.ReadToEndAsync();

        bool timedOut = false;
        using (CancellationTokenSource cts = new(timeout))
        {
            try
            {
                await process.WaitForExitAsync(cts.Token);
            }
            catch (OperationCanceledException)
            {
                timedOut = true;
                process.Kill(entireProcessTree: true);
                await process.WaitForExitAsync();
            }
        }

        return new ProcessResult(process.ExitCode, await stdout, await stderr, timedOut);
    }
}
dotnet Bookstore.OsLab.dll report 5000
dotnet Bookstore.OsLab.dll report 20 --fail
dotnet Bookstore.OsLab.dll report-timeout
exit=0 timedOut=False stdout=143021 B stderr=173893 B in 39 ms
last stderr line: progress: row 5000 of 5000 written
exit=2 timedOut=False stdout=593 B stderr=647 B in 14 ms
last stderr line: error: warehouse data is incomplete
exit=137 timedOut=True after 2030 ms

5000 satır artık 39 milisaniyede bitiyor; stderr 173 KB olsa da sorun yok. Exit code'lar process'in parent'a bıraktığı tek sayıdır ve yorumu senin işin. 0 başarı demektir; 0 dışındaki değerlerin anlamını araç belirler, bizimki eksik depo verisinde 2 dönüyor. report-timeout 30 saniye uyuyan aracı 2 saniyede kesiyor ve exit code 137 geliyor: Linux'ta bir process sinyalle öldürüldüğünde .NET, shell'lerle aynı geleneği izleyip 128 ile sinyal numarasını toplar; Kill SIGKILL (9) gönderdiği için 128 + 9 = 137. Windows'ta Kill, dotnet/runtime kaynağına göre TerminateProcess'i -1 ile çağırır; orada aynı durumda -1 görürsün. Bu yüzden "137 ise OOM'dur (out of memory; bellek yetmediği için kernel'in process'i öldürmesi)" gibi kestirmeler yapma: 137 yalnız "SIGKILL ile öldü" demektir, kimin öldürdüğünü söylemez.

entireProcessTree: true süs değil. Araç kendi altında bir process başlatırsa ve o torun process stdout pipe'ını miras alırsa, yalnız child'ı öldürmek pipe'ı kapatmaz ve ReadToEndAsync sonsuza kadar bekler. Bunu bir xUnit testiyle sabitledim: /bin/sh -c "sleep 30 & sleep 30" komutunu 1 saniyelik timeout ile çalıştıran test, entireProcessTree: false ile 30 saniye bekleyip kırmızıya düştü, true ile geçti. Runner'ın altı testi büyük çıktı, exit code, timeout, torun process, argüman ve environment davranışını kapsıyor:

dotnet test --solution Bookstore.OsLab.slnx -c Release
Test run summary: Passed!
  total: 7
  failed: 0
  succeeded: 7
  skipped: 0

Üretimde Dikkat

Her dış process çağrısına bir timeout koy. Dış araç takılırsa senin request'in de takılır ve ASP.NET Core'da bekleyen her istek bir bağlantı ve bellek tutar. Timeout'u request'in kendi CancellationToken'ı ile birleştirmek, istemci vazgeçtiğinde aracın da durmasını sağlar.

ReadToEndAsync bütün çıktıyı belleğe alır. 5000 satır için 143 KB sorun değil ama araç gigabaytlarca veri üretebiliyorsa stdout'u doğrudan bir dosyaya ya da response stream'ine kopyala, stderr'in de yalnız son kısmını tut.

Process başlatmak pahalı bir syscall zinciridir: yeni bir address space, programın yüklenmesi ve .NET aracıysa bir runtime açılışı. Saniyede yüzlerce istek alan bir endpoint'te her istekte process başlatma; mümkünse işi bir library olarak process içinde yap ya da aynı anda çalışan araç sayısını SemaphoreSlim ile sınırla. Container image'ında aracın gerçekten kurulu olduğundan ve PATH'te bulunduğundan emin ol; image tarafı ilerideki Docker serisinin konusu.

Sık Düşülen Tuzaklar

Shell injection. Kullanıcıdan gelen bir değeri bir shell komutunun içine string olarak gömersen, o değerdeki ;, && ya da $( ) shell için komut olur. Yazar adına göre rapor üreten bir endpoint'i düşün; aynı girdiyi iki yoldan araca verdim:

// YANLIŞ: kullanıcı girdisi bir shell komutunun içine gömülüyor
ProcessResult viaShell = await ProcessRunner.RunAsync("/bin/sh",
    ["-c", $"dotnet {ReportTool.DllPath} args {author}"], TimeSpan.FromSeconds(10));

// DOĞRU: shell yok; author tek bir argüman olarak gider
ProcessResult direct = await ProcessRunner.RunAsync("dotnet",
    ReportTool.Args("args", author), TimeSpan.FromSeconds(10));
dotnet Bookstore.OsLab.dll shell-demo "Ada; echo INJECTED: the shell ran a second command"
via /bin/sh -c:
[Ada]
INJECTED: the shell ran a second command
via ArgumentList:
[Ada; echo INJECTED: the shell ran a second command]

İlk yolda shell girdiyi iki komut olarak çalıştırdı; zararsız bir echo yerine herhangi bir komut olabilirdi. ArgumentList ile shell devreye girmez, değer child'ın argv'sine tek parça gider. Windows'ta cmd.exe /c ile aynı risk var. Tek string alan ProcessStartInfo.Arguments da güvenli bir alternatif değil: shell çalıştırmasa bile string'i boşluk ve tırnak kurallarına göre parçalar, "Ada Lovelace" iki argüman olur.

Environment'ın child'a sızması. Child, parent'ın bütün environment variable'larını miras alır; connection string'ler ve API key'ler dahil:

ConnectionStrings__BookstoreDb="Server=sql;Database=BookstoreDb;Password=<secret>" dotnet Bookstore.OsLab.dll env-demo
default:  ConnectionStrings__BookstoreDb=Server=sql;Database=BookstoreDb;Password=<secret>
hidden:   ConnectionStrings__BookstoreDb is not set

Dış bir aracı, özellikle üçüncü parti bir aracı çağırırken ihtiyacı olmayan değişkenleri psi.Environment.Remove ile çıkar. Açık dosyalar için bu sorun daha az: .NET dosyaları O_CLOEXEC ile açtığı için ilk yazıda gördüğümüz descriptor'lar child'a geçmez.

WaitForExit'i okumadan önce çağırmak. Pipe deadlock'unun en kısa hâli budur: parent çıkışı bekler, child dolan pipe'a yazmayı bekler. Önce okumaya başla, sonra bekle. Son olarak Process nesnesini using ile kapat; arkasında bir OS handle'ı ve pipe'lar var.

Küçük Bir Deneme

★☆☆ report-naive ile 1000'den başlayıp satır sayısını artır ve kilitlenmenin başladığı ilk değeri bul. Stderr satır uzunluğuyla çarpıp 65.536 ile karşılaştır.

★★☆ ProcessRunner'a dışarıdan bir CancellationToken parametresi ekle ve timeout token'ıyla CancellationTokenSource.CreateLinkedTokenSource üzerinden birleştir. Dışarıdan iptal edildiğinde de aracın öldürüldüğünü bir xUnit testiyle doğrula.

★★★ proc-info çalışırken /proc/<pid>/maps içindeki satırları izinlerine göre grupla ve r-xp, rw-p ve ---p bölgelerinin toplam boyutunu hesaplayan küçük bir C# komutu yaz. Toplamı VmSize ile karşılaştır.

Bu yazıda /proc/<pid>/status bize sekiz thread olduğunu söyledi ve strace'teki her satırın başında farklı bir numara vardı. Bir sonraki yazıda o numaraların sahiplerine bakıyoruz: thread nedir, bir .NET thread'i kernel'de neye karşılık gelir, stack'i ne kadar büyüktür ve [ThreadStatic] ile AsyncLocal<T> neden farklı davranır?