%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2011-2025 Marc Worrell
%% @doc Simple temporary file handling, deletes the file when the calling process
%% stops or crashes. If a file is created then a watchdog process is started to
%% monitor the calling process. If the calling process is stopped or crashes then
%% the file is deleted.
%%
%% Periodically call cleanup/0 to delete temporary files older than 24 hours.
%%
%% Temporary files are created in the directory specified by the TMP or TEMP environment
%% variable, or /tmp if none is set.
%% @end
%% Copyright 2011-2025 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(z_tempfile).
-author("Marc Worrell <marc@worrell.nl>").
-export([
new/0,
new/1,
copy/1,
monitored_new/0,
monitored_new/1,
monitored_attach/1,
monitored_detach/1,
tempfile/0,
tempfile/1,
is_tempfile/1,
temppath/0,
cleanup/0
]).
% Export for tmp file monitor
-export([
tmpfile_monitor_loop/2
]).
-include_lib("kernel/include/file.hrl").
% Threshold for tmp files to be old and deleted if cleanup/0 is called.
-define(CLEANUP_SECS, 3600*24).
%% @doc Return a new unique filename, start a monitoring process to clean it up after use.
% The file must be created and written within 10 seconds, or it will be deleted.
-spec new() -> file:filename_all().
new() ->
new(<<>>).
%% @doc Return a new unique filename, start a monitoring process to clean it up after use.
% The file must be created and written within 10 seconds, or it will be deleted.
-spec new( string() | binary() ) -> file:filename_all().
new(Extension) ->
{ok, {_Pid, Filename}} = monitored_new(Extension),
Filename.
%% @doc Copy a file to a new tempfile, start a monitoring process to clean it up after use.
-spec copy(File) -> {ok, NewFile} | {error, Reason} when
File :: file:filename_all(),
NewFile :: file:filename_all(),
Reason :: term().
copy(File) ->
{ok, {_Pid, NewFile}} = monitored_new(),
case file:copy(File, NewFile) of
{ok, _BytesCopied} ->
{ok, NewFile};
{error, _} = Error ->
Error
end.
%% @doc Like new/0 but also return the Pid of the monitoring process.
-spec monitored_new() -> {ok, {pid(), file:filename_all()}}.
monitored_new() ->
monitored_new(<<>>).
%% @doc Like new/1 but also return the Pid of the monitoring process.
-spec monitored_new( string()|binary() ) -> {ok, {pid(), file:filename_all()}}.
monitored_new(Extension) ->
Filename = tempfile(Extension),
Self = self(),
Pid = erlang:spawn(fun() -> tmpfile_monitor(Filename, Self) end),
receive
{is_monitoring, Pid} ->
{ok, {Pid, Filename}}
end.
%% @doc Add a process to the tempfile monitor. The tempfile is deleted after all
%% attached processes stopped or are detached.
-spec monitored_attach( pid() ) -> ok.
monitored_attach(MonitorPid) when is_pid(MonitorPid) ->
MonitorPid ! {attach, self()},
ok.
%% @doc Remove a process from the tempfile monitor. The tempfile is deleted after all
%% attached processes stopped or are detached.
-spec monitored_detach( pid() ) -> ok.
monitored_detach(MonitorPid) when is_pid(MonitorPid) ->
MonitorPid ! {detach, self()},
ok.
%% @hidden Monitoring process, delete file when requesting process stops or crashes
tmpfile_monitor(Filename, OwnerPid) ->
process_flag(trap_exit, true),
erlang:monitor(process, OwnerPid),
OwnerPid ! {is_monitoring, self()},
?MODULE:tmpfile_monitor_loop(Filename, [OwnerPid]).
%% @private
tmpfile_monitor_loop(Filename, AttachedPids) ->
Timeout = case AttachedPids of
[] -> 10000;
_ -> 100000
end,
receive
{'DOWN', _MRef, process, Pid, _Reason} ->
?MODULE:tmpfile_monitor_loop(Filename, lists:delete(Pid, AttachedPids));
{detach, Pid} ->
?MODULE:tmpfile_monitor_loop(Filename, lists:delete(Pid, AttachedPids));
{attach, Pid} ->
case lists:member(Pid, AttachedPids) of
false ->
erlang:monitor(process, Pid),
?MODULE:tmpfile_monitor_loop(Filename, [ Pid | AttachedPids ]);
true ->
?MODULE:tmpfile_monitor_loop(Filename, AttachedPids)
end;
{detach_delete, Pid} ->
case lists:delete(Pid, AttachedPids) of
[] ->
file:delete(Filename);
OtherPids ->
?MODULE:tmpfile_monitor_loop(Filename, OtherPids)
end
after Timeout ->
case AttachedPids of
[] -> file:delete(Filename);
_ -> ?MODULE:tmpfile_monitor_loop(Filename, AttachedPids)
end
end.
%% @doc return a unique temporary filename located in the TMP directory.
-spec tempfile() -> file:filename_all().
tempfile() ->
tempfile(<<>>).
%% @doc return a unique temporary filename with the given extension.
-spec tempfile( string()|binary() ) -> file:filename_all().
tempfile(Extension) ->
A = rand:uniform(1000000000),
B = rand:uniform(1000000000),
Filename = filename:join(
temppath(),
iolist_to_binary( io_lib:format("ztmp-~s-~p.~p~s",[node(),A,B,Extension]) )
),
case filelib:is_file(Filename) of
true -> tempfile(Extension);
false -> Filename
end.
%% @doc Check if the file is a temporary filename.
-spec is_tempfile( file:filename_all() ) -> boolean().
is_tempfile(Filename) ->
FilenameParts = filename:split( unicode:characters_to_binary(Filename) ),
TempPathParts = filename:split( temppath() ),
is_tempfile(FilenameParts, TempPathParts).
is_tempfile([ <<"ztmp-", _/binary>> ], []) ->
true;
is_tempfile([ A | As], [ A | Bs ]) ->
is_tempfile(As, Bs);
is_tempfile(_, _) ->
false.
%% @doc Returns the path where to store temporary files.
-spec temppath() -> file:filename_all().
temppath() ->
lists:foldl(
fun
(false, TmpPath) ->
TmpPath;
(Good, _) ->
unicode:characters_to_binary(Good)
end,
<<"/tmp">>,
[ os:getenv("TMP"), os:getenv("TEMP") ]).
%% @doc Delete all tempfiles not modified in the last day.
-spec cleanup() -> ok.
cleanup() ->
Old = calendar:datetime_to_gregorian_seconds( calendar:universal_time() ) - ?CLEANUP_SECS,
Tmp = filename:join(temppath(), <<"/ztmp-*">>),
Files = filelib:wildcard(Tmp),
lists:foreach(
fun(F) ->
case file:read_file_info(F, [ {time, universal} ]) of
{ok, #file_info{
type = regular,
mtime = ModDT
}} ->
ModSecs = calendar:datetime_to_gregorian_seconds( ModDT ),
case ModSecs < Old of
true ->
file:delete(F);
false ->
ok
end;
_ ->
ok
end
end,
Files).