Race condition v SafeStream?

phpfm
Člen | 9
+
0
-

SafeStream.php lze nalézt následující kód:

	/**
	 * Closes file.
	 */
	public function stream_close(): void
	{
		if (!$this->tempFile) { // 'r' mode
			flock($this->tempHandle, LOCK_UN);
			fclose($this->tempHandle);
			return;
		}

		flock($this->handle, LOCK_UN);
		fclose($this->handle);
		fclose($this->tempHandle);

		if ($this->writeError || !rename($this->tempFile, $this->file)) { // try to rename temp file
			unlink($this->tempFile); // otherwise delete temp file
			if ($this->deleteFile) {
				unlink($this->file);
			}
		}
	}

IMHO problém je v té mezeře mezi flock(LOCK_UN) a rename(). V té době se k tomu starému nezamčenému souboru může dostat jiný proces, získat LOCK_EX a začít s ním pracovat. Následný rename() našeho procesu potom dle situace může proběhnout až poté, co ten jiný proces dokončil práci a provedl svůj rename(), čímž potenciálně starší verze souboru přepíše nověji vygenerovanou. Podobná mezera je při otvírání souborů mezi fopen() a flock(LOCK_SH/EX), tam to může vést mimo jiné k tomu, že vícero procesů bude současně pracovat nad tou samou cestou.

Na druhou stranu díky tomu, že zapisování se provádí do dočasného souboru s náhodně vygenerovaným jménem, který se na konci přesune pomocí rename(), nedochází k tomu, že by si procesy přepisovaly ta samá data. Takže atomické to je, nicméně tohle “Isolation: No one can start to read a file that is not yet fully written.” neplatí, pokud “file” chápeme jako cestu k souboru. Jinými slovy je možné, aby vícero procesů mělo současně otevřeno k zápisu fopen("nette.safe://path/to/file") a pracovali s ním, přičem až všichni skončí, tak pod tou cestou zůstane jedna verze dat (nebo žádná, pokud ten poslední proces skončí chybou a soubor smaže).

phpfm
Člen | 9
+
0
-

Vlastně ani s tou atomicitou to nebude tak úplně pravda. Může se stát, že jeden proces vytvoří prázdný soubor, a než ho stačí uzamknout a uložit do něj data, tak ostatní ten prázdný soubor začnou číst/modifikovat.

David Grudl
Nette Core | 7769
+
0
-

IMHO problém je v té mezeře mezi flock(LOCK_UN) a rename(). V té době se k tomu starému nezamčenému souboru může dostat jiný proces, získat LOCK_EX a začít s ním pracovat. Následný rename() našeho procesu potom dle situace může proběhnout až poté, co ten jiný proces dokončil práci a provedl svůj rename(), čímž potenciálně starší verze souboru přepíše nověji vygenerovanou.

Řekl bych, že rename() proběhne hned, navíc na Windows selže.

phpfm
Člen | 9
+
0
-

Nevím, co je myšleno tím „hned“, ale kombo flock(); fclose(); fclose(); rename() není jedna procesorová instrukce chráněná lockem, takže nepochybně se během toho může udát mnoho věcí. Jako například, že OS ten php proces přeruší a pustí jiný.

Na windowsech jsem to nezkoušel, ale velmi pochybuji o tom, že ten rename() selže, nemá proč. Pokud zdrojový soubor existuje, přístupová práva to umožňují, atd., tak je to validní operace. Každopádně na linuxu neselže určitě.

David Grudl
Nette Core | 7769
+
0
-

Jasně, stát se to může, že závod vyhraje proces, který začal později.

Na Windows (resp. na NTFS) nejde mazat nebo přejmenovávat soubory, pokud jsou otevřené, na rozdíl od Linuxu, takže proto to selže.

uživatel-p
Člen | 553
+
-1
-

David Grudl napsal(a):

Na Windows (resp. na NTFS) nejde mazat nebo přejmenovávat soubory, pokud jsou otevřené, na rozdíl od Linuxu, takže proto to selže.

Což je imho úplně jedno, když 90% serverů jede na linuxech.

phpfm
Člen | 9
+
+1
-

No ale ten soubor se před tím uzavře, takže ani na windowsech by neměl selhat. Jako je možné, že na windowsech je celý ten postup korektní, to netuším. Hádám ale, že hodně lidí má produkční kód na linuxu a mít tam tiché a náhodně se projevující bugy asi není ideální…

David Grudl
Nette Core | 7769
+
0
-

Nepíšu tu přece, že na Windows to funguje korektně nebo že servery jedou na Windows. Jen doplňuju, že přepsání starším obsahem je velmi nepravděpodobné

uživatel-p
Člen | 553
+
0
-

To z toho ale pořád nedělá relevantní informaci.

David Grudl
Nette Core | 7769
+
0
-

mít tam tiché a náhodně se projevující bugy asi není ideální…

To samozřejmě není. Myslíš bugem to, že race condition vyhraje starší obsah? Na Windows nevím jak to vyřešit, na Linuxu by asi šlo fclose() posunout za rename(). Nebo nějaké jiné bugy? Jak se stane, že se vytvoří a čte prázdný soubor?

Hele je to víc než deset let starý kód, já si houby pamatuju o co tam jde. Jestli jsou tam chyby, budu rád za opravu, jestli je to komplikované, tak tu knihovnu klidně zruším.

Milo
Nette Core | 1255
+
0
-

Mně to nepřijde jako bug. Je to v kódu stream_close, takže při zavírání souboru. V tu chvíli už daný proces nemůže spoléhat na to, že je obsah konzistentní. To mohl, dokud byl soubor otevřený. Po zavření už je jedno, jaký proces vyhrál.

David Grudl
Nette Core | 7769
+
0
-

Konzistentní s čím? Asi to potřebuju přesně vysvětlit jak dítěti…

Milo
Nette Core | 1255
+
0
-

Použil jsem blbý slovo. Myslel jsem tím, že proces po zavření souboru nemůže spoléhat na obsah, který do něj sám zapsal.

David Grudl
Nette Core | 7769
+
0
-

Od toho má právě unikátní název, aby do něj nemohl psát nikdo jiný.

Milo
Nette Core | 1255
+
0
-

Zkusím to jinak. @phpfm žádná race condition imho není, protože rename se spustí až po uzavření souboru a je jedno, jaký rename vyhraje.

David Grudl
Nette Core | 7769
+
0
-

Já to chápal tak, že @phpfm vadí, že potenciálně starší verze souboru přepíše nověji vygenerovanou. Tedy ten kdo získal jako první zámek při otevírání souboru nemusí být ten, kdo jako první přejmenuje.

Což je fakt, byť velmi málo pravděpodobný, ale já to jako problém nevidím, prostě při souběžném běhu někdo vyhraje a je mi jedno kdo. Kdyby to problém byl, šlo by to pro Linux řešit voláním rename() dřív.

phpfm
Člen | 9
+
0
-

OK, dáme si názornou ukázku. Pokud ani fopen("nette.safe://...", "a") ani následné zápisy nevyhodí chybu, měly by zapsaná data v souboru být. Následující kód spustí procesy, které současně do stejného souboru přidávají řádky.

	define('WORKERS', 2);
	define('MESSAGES', 10);
	define('FILE_PATH', 'test_SafeStream_' . rand() . '.txt');

	$children = [];
	for($i = 0; $i < WORKERS; ++$i) {
		$pid = pcntl_fork();
		if( $pid == -1 ) {
			echo "Fork failed, bailing out...\n";
			break;
		} elseif( $pid == 0 ) {
			// child

			// "seed" lcg_value() generator so SafeStream doesn't generate
			// the same .tmp filename (and then fails to open it)
			for($j = 0; $j < (WORKERS - $i) * MESSAGES; ++$j)
				lcg_value();

			for($j = 0; $j < MESSAGES; ++$j) {
				// open file for append, write message and close

				if( !$f = fopen('nette.safe://' . FILE_PATH, 'a') )
					die("Worker $i failed to open " . FILE_PATH . " for message $j\n");

				$msg =  "Worker $i, message $j\n";
				$r = fwrite($f, $msg);
				if( $r === false || $r != strlen($msg) )
					die("Worker $i failed to append message $j\n");

				if( !fclose($f) )
					die("Worker $i failed to close file properly\n");
			}

			// child proces ends here
			exit(0);
		} else {
			// main process

			$children[] = $pid;
		}
	}

	while( !empty($children) ) {
		$child_pid = pcntl_wait($status);

		$children = array_filter($children, fn($pid) => $pid != $child_pid);
	}

	echo "Expected " . (WORKERS * MESSAGES) . " messages, got " . substr_count(file_get_contents(FILE_PATH), "\n") . "\n";

	unlink(FILE_PATH);

Ten stream_close() asi nebyl nejlepší příklad, protože on je problém už při otvírání. Ve skutečnosti pokaždé, když na sebe narazí N procesů nad jednou cestou, tak z N verzí zapsaných souborů zůstane jen jedna náhodná, všechny ostatní se ztratí. Jde o to, že cesta je jen ukazatel na soubor s daty a fopen() vrátí ten ukazatel, který tam zrovna je. Když později rename() nebo unlink() + fopen(..., "c") změní, na jaká data cesta ukazuje, tak se o tom ti, kteří získali předchozí ukazatel, nedozví.

Jinými slovy proces P otveře cestu s daty S1, získá zámek a dá se do práce. Přijde dalších N procesů, otevřou cestu, získají ukazatel na ten samý S1 a zablokují se na zámku. Proces P dokončí práci, uvolní zámek nad S1 a provede rename(), takže nyní cesta ukazuje na soubor S2. Uvolnění zámku nad S1 probudilo jeden z čekajících procesů, který se dá do práce, ovšem nad původními daty S1. O tom, že cesta teď ukazuje na S2 od procesu P, neví. Pracující proces se dopracuje k výsledku S3, uvolní zámek nad S1 a udělá rename(), takže cesta ukazuje na jeho data S3 a S2 jsou fuč. Uvolnění zámku probudí další čekající proces, který ovšem opět drží ukazatel na S1. O S2 ani o S3 neví a dá se vesele do práce…

S tímhle názorná ukázka funguje o něco lépe.

class EvenSaferStream
{
	/** Name of stream protocol - nette.safe:// */
	public const PROTOCOL = 'nette.safe';

	private $path;

	private $work_path;
	private $work_fd;

	private $lock_path;
	private $lock_fd;

	private const OK = 0, Error = -1, Blocked = -2;


	public static function register(): bool
	{
		return stream_wrapper_register(self::PROTOCOL, self::class);
	}


	public function stream_open(string $path, string $mode, int $options): bool
	{
		$path = substr($path, strpos($path, ':') + 3);  // trim protocol nette.safe://

		if( $mode != 'a' ) // support only append
			return false;

		if( $this->lock($path) != self::OK )
			return false;

		$ok = false;
		try {
			$work_path = $path . '.tmp';

			if( file_exists($path) ) {
				if( !$fd = fopen($path, 'r+') )
					return false;

				$copy = stream_get_contents($fd);
				if( $copy === false )
					return false;

				if( file_put_contents($work_path, $copy) !== strlen($copy) )
					return false;

				fclose($fd);
			}

			if( !$work_fd = fopen($work_path, 'a') ) {
				@ unlink($work_path);
				return false;
			}

			$this->path = $path;
			$this->work_path = $work_path;
			$this->work_fd = $work_fd;

			$ok = true;

			return true;
		} finally {
			if( !$ok ) {
				$this->unlock();
			}
		}
	}

	private const Attempts = 10, TokenLength = 10;

	private function lock($path)
	{
		$lock_path = $path . '.lock';

		for($i=0; $i < self::Attempts; ++$i) {
			if( !$lock_fd = fopen($lock_path, 'c') ) {
				return self::Error;
			}

			if( !flock($lock_fd, LOCK_EX) ) {
				fclose($lock_fd);
				return self::Error;
			}

			if( !$stat = fstat($lock_fd) ) {
				fclose($lock_fd);
				return self::Error;
			}

			if( $stat['size'] > 0 ) {
				// op on this lock fd was successfully committed, but we need to
				// check if the lock file was also removed.
				if( file_exists($lock_path) ) {
					// Lock file is still there, so now we need to figure out if $lock_path points
					// to our $lock_fd or it's new (which means there is another process with lock() on this path).
					// We will do it by writing random challenge string into $lock_fd and see if
					// it appears in $lock_path file.
					$challenge = random_bytes(self::TokenLength);
					fwrite($lock_fd, $challenge);
					fflush($lock_fd);

					if( $challenge === @ file_get_contents($lock_path) )
						unlink($lock_path);
				}

				fclose($lock_fd);
				continue;
			}

			$this->lock_path = $lock_path;
			$this->lock_fd = $lock_fd;

			return self::OK;
		}

		return self::Blocked;
	}

	private function unlock()
	{
		fwrite($this->lock_fd, 'c'); // signal we released the file correctly
		fflush($this->lock_fd);
		unlink($this->lock_path);
		fclose($this->lock_fd); // release lock

		$this->lock_path = null;
		$this->lock_fd = null;
	}


	public function stream_close(): void
	{
		fclose($this->work_fd);
		rename($this->work_path, $this->path);

		$this->work_path = null;
		$this->work_fd = null;

		$this->unlock();
	}

	public function stream_read(int $length)
	{
		return fread($this->work_fd, $length);
	}

	public function stream_write(string $data)
	{
		return fwrite($this->work_fd, $data, strlen($data));
	}

	public function stream_truncate(int $size): bool
	{
		return ftruncate($this->work_fd, $size);
	}

	public function stream_tell(): int
	{
		return ftell($this->work_fd);
	}

	public function stream_eof(): bool
	{
		return feof($this->work_fd);
	}

	public function stream_seek(int $offset, int $whence = SEEK_SET): bool
	{
		return fseek($this->work_fd, $offset, $whence) === 0;
	}

	public function stream_stat()
	{
		return fstat($this->work_fd);
	}
}

(Všechny ukázky byly testovány jen a pouze pod linuxem.)

Editoval phpfm (2. 1. 16:51)

David Grudl
Nette Core | 7769
+
0
-

Rozumím, vychází mi z toho buď pro režim ‚a‘ nepoužívat dočasný soubor, protože při uvolnění zámku následující vlákno pracuje s původním obsahem, nebo při získání zámku detekovat změnu (asi porovnat inode number u fstat() a stat()) a pokud je, otevřít soubor znovu.

Milo
Nette Core | 1255
+
0
-

@DavidGrudl Pokud by se pro append nepoužíval dočasný soubor, už by neplatilo „Atomicity: The file is written either as a whole or not written at all.“ z dokumentace.

David Grudl
Nette Core | 7769
+
0
-

Si říkám, jestli to vůbec platí v případě dočasných souborů. Pokud dojde místo na disku, tak se soubor skutečně nevytvoří, ale že aplikace v půlce zápisu selhala se stream_close() nedozví.

Milo
Nette Core | 1255
+
0
-

To jsem myslel, že řeší stream_write(), kde se kontroluje délka zápisu.

https://github.com/…feStream.php#L199

David Grudl
Nette Core | 7769
+
0
-

Ano, to se kontroluje. Ale že aplikace zapsala co opravdu chtěla a nespadla, nezjistím.

Bez použití dočasného souboru můžu ve stream_close zkrátit soubor na původní délku.

Milo
Nette Core | 1255
+
0
-

Když ale dojde k chybě při zápisu, tak stream_close nikdy dočasný soubor nepřejmenuje na ostrý. S původním se tedy nic nestane.

David Grudl
Nette Core | 7769
+
0
-

Ano, to tady píšu celou dobu. A bez použití dočasného souboru se stejného efektu dá dosáhnout tím, že v stream_close soubor zkrátím na původní délku.

David Grudl
Nette Core | 7769
+
0
-

To používání dočasných souborů jsem tam dával před 11 lety a nevím už přesně proč (tehdy bylo PHP 5.2, časté fatal errory, a fopen neuměl nebo neměl zdokumentovaný režim c, tedy vytvořit bez smazání). Zkusil jsem to revertnout https://github.com/…feStream.php

phpfm
Člen | 9
+
0
-

David Grudl napsal(a):

Rozumím, vychází mi z toho buď pro režim ‚a‘ nepoužívat dočasný soubor, protože při uvolnění zámku následující vlákno pracuje s původním obsahem, nebo při získání zámku detekovat změnu (asi porovnat inode number u fstat() a stat()) a pokud je, otevřít soubor znovu.

Tohle se týká všech režimů čtení+zápis a jak bylo řečeno, bez dočasného souboru tam nebude atomicita. Porovnávat inody by šlo také. Ta detekce změny a znovu otevření má ještě takový drobný detail, že se tím překope FIFO fronta uspaných čekajících a může docházet k předbíhání. Když se sníží počet EvenSaferStream::Attempt, tak lock() na tom občas selže. Možná by se mohlo zjistit, jestli na zámku nevisí někdo další, ale zatím jsem to nezkoušel.

Ono záleží na tom, co přesně by SafeStream měl dělat a co od toho lidi očekávají. Motivační příklad v dokumentaci řeší situaci, kdy si dva procesy čtou/přepisují neúplná data. Pokud jde jen o tohle a nezáleží na pořadí, v jakém se změny projeví, tak to zamykání je zbytečné a postačí dočasný soubor + rename() na závěr. Ale netuším k čemu a jestli vůbec to lidi používají, protože třeba ten append sežere data pokaždé, když dojde k souběhu více procesů, a toho by si za deset let asi někdo všiml?

Milo
Nette Core | 1255
+
0
-

Ve stream_truncate() zbyl tempHandle, ale to jen pro info.

Aha. Já myslel, že ta vychytávka, je právě ten temp file. Že když se kdykoliv mezi otevřením a zavřením souboru něco podělá, tak původní soubor zůstane nedotčen, a že právě rename() vyvolaný při close zajistí celkovou konzistenci souboru.

David Grudl
Nette Core | 7769
+
0
-

Že když se kdykoliv mezi otevřením a zavřením souboru něco podělá, tak původní soubor zůstane nedotčen

Jde o to co přesně myslíš tím „něco se podělá“. rename() se nezavolá pouze tehdy, pokud fwrite() selhal, což znamená, že došlo místo na disku (nenapadá mě jiný důvod). A nebo se PHP neukončí korektně, tedy nějaký segfault, ale to se v podstatě nestává.

David Grudl
Nette Core | 7769
+
0
-

Na atomicitu rezignuji, protože mi dochází, že ji nelze rozlišit na low-level úrovni. Ověřím úspěšnost fwrite(), ale jen aplikace ví, kolik těch fwrite() mělo proběhnout.

Tahle třída vznikla primárně kvůli izolaci, aby nezapisovalo více vláken zároveň nebo někdo nečetl nedopsaná data. Dočasné soubory řeší situaci, kdy někdo získá zámek ke čtení dřív než pro zápis a přečte prázdný soubor, ale přidávají mnohem víc komplikací, takže bych je asi opustil.

phpfm
Člen | 9
+
0
-

Na to je standardní pattern dočasný soubor + rename(). Výhoda je, že při vytváření nové verze souboru se neblokují čtenáři a selhání zápisu se elegantně řeší samo. Akorát že tady PHP zavolá stream_close() sám od sebe vždy, takže ta druhá výhoda odpadá. Možná bych u SafeStream umožnil pouze čistý zápis nebo čisté čtení a u ostatních režimů vyhodil chybu, protože stejně nedělají, co se od nich asi čeká. A když už, tak kolem tmp+rename() napsat další třídu, která to udělá korektně a bude umožňovat explicitní commit/rollback.

Editoval phpfm (3. 1. 19:22)

phpfm
Člen | 9
+
+2
-

Tak pro úplnost verze nette.safe:// s atomicitou a serializací. Možno užívat, jak je libo. (Testováno na linuxu.)

<?php

class EvenSaferStream
{
	/** Name of stream protocol - nette.safe:// */
	public const PROTOCOL = 'nette.safe';

	// True if opened in r mode.
	private bool $readOnly;

	// Path to the file.
	private ?string $path;

	// Path & handle to working file that will be moved to $path at the end,
	// except in case of read only mode, where $workHandle points to opened
	// $path and $workPath is null.
	private ?string $workPath;
	private $workHandle;

	// Path & handle to lock, if opened in any other mode than read only.
	private ?string $lockPath;
	private $lockHandle;


	private static function getTemporaryPath($path)
	{
		$dir = dirname($path);
		return ($dir !== '' ? $dir . '/' : '') . '~~' . basename($path);
	}

	// Locking is done on separate $path.lock file, which preferably should be removed after
	// work is over. But it also should be there as long as possible to not disturb the FIFO
	// front of processes waiting on it for their turn. For that, before attempt to acquire
	// lock is made, one byte is appended to the opened lock file. This is done without
	// synchronization and some data may be lost, so after acquiring lock the process needs
	// to check $lock_path still points to the opened lock handle and it wasn't unlinked by
	// previous lock holder who thought there is no one else waiting.

	private const OK = 0, Error = -1, Blocked = -2;
	private const Attempts = 3;

	private function lock($path)
	{
		$lock_path = self::getTemporaryPath($path) . '.lock';

		for($i=0; $i < self::Attempts; ++$i) {
			if( !$lock_handle = fopen($lock_path, 'a') )
				return self::Error;

			// Append hint to lock file signaling there is someone waiting on it.
			fwrite($lock_handle, 'o');
			fflush($lock_handle);

			if( !flock($lock_handle, LOCK_EX) ) {
				fclose($lock_handle);
				return self::Error;
			}

			$lock_stat = fstat($lock_handle);

			// Decrement string of waiting hints.
			ftruncate($lock_handle, $lock_stat['size'] > 0 ? $lock_stat['size'] - 1 : 0);

			// Now check the lock we acquired is the same as under $lock_path. If inodes
			// are different (or $lock_path doesn't exists), it means someone else deleted
			// the lock and we have to throw it away and start again from current $lock_path.

			clearstatcache(true, $lock_path);

			$lock_path_ino = @ fileinode($lock_path);
			if( $lock_path_ino === false || $lock_path_ino != $lock_stat['ino'] ) {
				fclose($lock_handle);
				continue;
			}

			$this->lockPath = $lock_path;
			$this->lockHandle = $lock_handle;

			return self::OK;
		}

		return self::Blocked;
	}

	private function unlock()
	{
		$others_wait_on_lock = fstat($this->lockHandle)['size'] > 0;

		// This is rough approximation, it's possible there are others waiting on
		// the lock even if this says there are none. However when they wake up
		// they should detect that in lock() and run another attempt.
		if( !$others_wait_on_lock )
			unlink($this->lockPath);

		fclose($this->lockHandle); // release the lock

		$this->lockPath = null;
		$this->lockHandle = null;
	}


	public static function register(): bool
	{
		return stream_wrapper_register(self::PROTOCOL, self::class);
	}

	public function stream_open(string $path, string $mode, int $options): bool
	{
		$path = substr($path, strlen(self::PROTOCOL) + 3);  // trim protocol nette.safe://

		$tb_flag = ltrim($mode, 'acrxw+');
		$mode = rtrim($mode, 'tb');
		$use_include_path = (bool) (STREAM_USE_PATH & $options);

		if( $mode == 'r' ) {
			// for read only mode no need to lock anything, just use what is there
			// under $path
			if( !$fd = fopen($path, 'r' . $tb_flag, $use_include_path) )
				return false;

			$this->readOnly = true;
			$this->workHandle = $fd;

			return true;
		}

		$this->readOnly = false;

		if( $this->lock($path) != self::OK )
			return false;

		$ok = false;
		try {
			$work_path = self::getTemporaryPath($path) . '.tmp';

			if( $mode[0] != 'w' && $mode[0] != 'x' ) {
				// w & x mode truncate file, others don't => need to copy content
				// to working file
				clearstatcache(true, $path);

				// FIXME: this doesn't use $use_include_path
				if( file_exists($path) ) {
					if( !copy($path, $work_path) )
						return false;
				} elseif( $mode[0] == 'r' ) {
					return false;
				}
			} elseif( $mode[0] == 'x' ) {
				// make sure we are first to create $path
				if( !fopen($path, $mode . $tb_flag, $use_include_path) )
					return false;
			}

			if( !$work_handle = fopen($work_path, $mode . $tb_flag, $use_include_path) ) {
				@ unlink($work_path);
				return false;
			}

			$this->path = $path;
			$this->workPath = $work_path;
			$this->workHandle = $work_handle;

			$ok = true;

			return true;
		} finally {
			if( !$ok ) {
				$this->unlock();
			}
		}
	}

	public function stream_close(): void
	{
		fclose($this->workHandle);

		if( $this->readOnly )
			return;

		rename($this->workPath, $this->path);

		$this->workPath = null;
		$this->workHandle = null;

		$this->unlock();
	}

	public function stream_read(int $length)
	{
		return fread($this->workHandle, $length);
	}

	public function stream_write(string $data)
	{
		return fwrite($this->workHandle, $data, strlen($data));
	}

	public function stream_truncate(int $size): bool
	{
		return ftruncate($this->workHandle, $size);
	}

	public function stream_tell(): int
	{
		return ftell($this->workHandle);
	}

	public function stream_eof(): bool
	{
		return feof($this->workHandle);
	}

	public function stream_seek(int $offset, int $whence = SEEK_SET): bool
	{
		return fseek($this->workHandle, $offset, $whence) === 0;
	}

	public function stream_stat()
	{
		return fstat($this->workHandle);
	}
}

?>

Editoval phpfm (5. 1. 23:31)