X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=lib%2FPublicInbox%2FSearchIdx.pm;h=f63e07200b08d9d970d5e43c10cc8f70da0508af;hb=43fd7e7bda1b8eeb32cf43f6fd89568a938aedf5;hp=265403a30664ee8a3000d8c64372f98656dd4564;hpb=fc62e40e4e3d00a00377ba26aeca010880158caa;p=public-inbox.git diff --git a/lib/PublicInbox/SearchIdx.pm b/lib/PublicInbox/SearchIdx.pm index 265403a3..f63e0720 100644 --- a/lib/PublicInbox/SearchIdx.pm +++ b/lib/PublicInbox/SearchIdx.pm @@ -11,17 +11,15 @@ use strict; use warnings; use Fcntl qw(:flock :DEFAULT); use PublicInbox::MIME; -use Email::MIME::ContentType; -$Email::MIME::ContentType::STRICT_PARAMS = 0; use base qw(PublicInbox::Search); -use PublicInbox::MID qw/mid_clean id_compress mid_mime/; +use PublicInbox::MID qw/mid_clean id_compress mid_mime mids references/; use PublicInbox::MsgIter; use Carp qw(croak); use POSIX qw(strftime); require PublicInbox::Git; use constant { - MAX_MID_SIZE => 244, # max term size - 1 in Xapian + MAX_MID_SIZE => 244, # max term size (Xapian limitation) - length('Q') PERM_UMASK => 0, OLD_PERM_GROUP => 1, OLD_PERM_EVERYBODY => 2, @@ -51,26 +49,46 @@ sub git_unquote ($) { } sub new { - my ($class, $inbox, $creat) = @_; - my $git_dir = $inbox; - my $altid; - if (ref $inbox) { - $git_dir = $inbox->{mainrepo}; - $altid = $inbox->{altid}; + my ($class, $ibx, $creat, $part) = @_; + my $mainrepo = $ibx; # for "public-inbox-index" w/o entry in config + my $git_dir = $mainrepo; + my ($altid, $git); + my $version = 1; + if (ref $ibx) { + $mainrepo = $ibx->{mainrepo}; + $altid = $ibx->{altid}; + $version = $ibx->{version} || 1; if ($altid) { require PublicInbox::AltId; $altid = [ map { - PublicInbox::AltId->new($inbox, $_); + PublicInbox::AltId->new($ibx, $_); } @$altid ]; } + $git = $ibx->git; + } else { + $git = PublicInbox::Git->new($git_dir); # v1 only } require Search::Xapian::WritableDatabase; - my $self = bless { git_dir => $git_dir, -altid => $altid }, $class; + my $self = bless { + mainrepo => $mainrepo, + git => $git, + -altid => $altid, + version => $version, + }, $class; my $perm = $self->_git_config_perm; my $umask = _umask_for($perm); $self->{umask} = $umask; - $self->{lock_path} = "$git_dir/ssoma.lock"; - $self->{git} = PublicInbox::Git->new($git_dir); + if ($version == 1) { + $self->{lock_path} = "$mainrepo/ssoma.lock"; + } elsif ($version == 2) { + defined $part or die "partition is required for v2\n"; + # partition is a number or "all" + $self->{partition} = $part; + $self->{lock_path} = undef; + $self->{msgmap_path} = "$mainrepo/msgmap.sqlite3"; + } else { + die "unsupported inbox version=$version\n"; + } $self->{creat} = ($creat || 0) == 1; $self; } @@ -86,7 +104,7 @@ sub _xdb_release { sub _xdb_acquire { my ($self) = @_; croak 'already acquired' if $self->{xdb}; - my $dir = PublicInbox::Search->xdir($self->{git_dir}); + my $dir = $self->xdir; my $flag = Search::Xapian::DB_OPEN; if ($self->{creat}) { require File::Path; @@ -102,14 +120,16 @@ sub _xdb_acquire { sub _lock_acquire { my ($self) = @_; croak 'already locked' if $self->{lockfh}; - sysopen(my $lockfh, $self->{lock_path}, O_WRONLY|O_CREAT) or - die "failed to open lock $self->{lock_path}: $!\n"; + my $lock_path = $self->{lock_path} or return; + sysopen(my $lockfh, $lock_path, O_WRONLY|O_CREAT) or + die "failed to open lock $lock_path: $!\n"; flock($lockfh, LOCK_EX) or die "lock failed: $!\n"; $self->{lockfh} = $lockfh; } sub _lock_release { my ($self) = @_; + return unless $self->{lock_path}; my $lockfh = delete $self->{lockfh} or croak 'not locked'; flock($lockfh, LOCK_UN) or die "unlock failed: $!\n"; close $lockfh or die "close failed: $!\n"; @@ -121,19 +141,20 @@ sub add_val ($$$) { $doc->add_value($col, $num); } -sub add_values ($$$) { - my ($smsg, $bytes, $num) = @_; +sub add_values ($$) { + my ($doc, $values) = @_; - my $ts = $smsg->ts; - my $doc = $smsg->{doc}; - add_val($doc, &PublicInbox::Search::TS, $ts); + my $ts = $values->[PublicInbox::Search::TS]; + add_val($doc, PublicInbox::Search::TS, $ts); - defined($num) and add_val($doc, &PublicInbox::Search::NUM, $num); + my $num = $values->[PublicInbox::Search::NUM]; + defined($num) and add_val($doc, PublicInbox::Search::NUM, $num); - defined($bytes) and add_val($doc, &PublicInbox::Search::BYTES, $bytes); + my $bytes = $values->[PublicInbox::Search::BYTES]; + defined($bytes) and add_val($doc, PublicInbox::Search::BYTES, $bytes); - add_val($doc, &PublicInbox::Search::LINES, - $smsg->{mime}->body_raw =~ tr!\n!\n!); + my $lines = $values->[PublicInbox::Search::LINES]; + add_val($doc, PublicInbox::Search::LINES, $lines); my $yyyymmdd = strftime('%Y%m%d', gmtime($ts)); add_val($doc, PublicInbox::Search::YYYYMMDD, $yyyymmdd); @@ -154,14 +175,19 @@ sub index_users ($$) { $tg->increase_termpos; } -sub index_text_inc ($$$) { - my ($tg, $text, $pfx) = @_; +sub index_diff_inc ($$$$) { + my ($tg, $text, $pfx, $xnq) = @_; + if (@$xnq) { + $tg->index_text(join("\n", @$xnq), 1, 'XNQ'); + $tg->increase_termpos; + @$xnq = (); + } $tg->index_text($text, 1, $pfx); $tg->increase_termpos; } sub index_old_diff_fn { - my ($tg, $seen, $fa, $fb) = @_; + my ($tg, $seen, $fa, $fb, $xnq) = @_; # no renames or space support for traditional diffs, # find the number of leading common paths to strip: @@ -171,7 +197,9 @@ sub index_old_diff_fn { $fa = join('/', @fa); $fb = join('/', @fb); if ($fa eq $fb) { - index_text_inc($tg, $fa,'XDFN') unless $seen->{$fa}++; + unless ($seen->{$fa}++) { + index_diff_inc($tg, $fa, 'XDFN', $xnq); + } return 1; } shift @fa; @@ -184,40 +212,46 @@ sub index_diff ($$$) { my ($tg, $lines, $doc) = @_; my %seen; my $in_diff; + my @xnq; + my $xnq = \@xnq; foreach (@$lines) { if ($in_diff && s/^ //) { # diff context - index_text_inc($tg, $_, 'XDFCTX'); + index_diff_inc($tg, $_, 'XDFCTX', $xnq); } elsif (/^-- $/) { # email signature begins $in_diff = undef; } elsif (m!^diff --git ("?a/.+) ("?b/.+)\z!) { my ($fa, $fb) = ($1, $2); my $fn = (split('/', git_unquote($fa), 2))[1]; - index_text_inc($tg, $fn, 'XDFN') unless $seen{$fn}++; + $seen{$fn}++ or index_diff_inc($tg, $fn, 'XDFN', $xnq); $fn = (split('/', git_unquote($fb), 2))[1]; - index_text_inc($tg, $fn, 'XDFN') unless $seen{$fn}++; + $seen{$fn}++ or index_diff_inc($tg, $fn, 'XDFN', $xnq); $in_diff = 1; # traditional diff: } elsif (m/^diff -(.+) (\S+) (\S+)$/) { my ($opt, $fa, $fb) = ($1, $2, $3); + push @xnq, $_; # only support unified: next unless $opt =~ /[uU]/; - $in_diff = index_old_diff_fn($tg, \%seen, $fa, $fb); + $in_diff = index_old_diff_fn($tg, \%seen, $fa, $fb, + $xnq); } elsif (m!^--- ("?a/.+)!) { my $fn = (split('/', git_unquote($1), 2))[1]; - index_text_inc($tg, $fn, 'XDFN') unless $seen{$fn}++; + $seen{$fn}++ or index_diff_inc($tg, $fn, 'XDFN', $xnq); $in_diff = 1; } elsif (m!^\+\+\+ ("?b/.+)!) { my $fn = (split('/', git_unquote($1), 2))[1]; - index_text_inc($tg, $fn, 'XDFN') unless $seen{$fn}++; + $seen{$fn}++ or index_diff_inc($tg, $fn, 'XDFN', $xnq); $in_diff = 1; } elsif (/^--- (\S+)/) { $in_diff = $1; + push @xnq, $_; } elsif (defined $in_diff && /^\+\+\+ (\S+)/) { - $in_diff = index_old_diff_fn($tg, \%seen, $in_diff, $1); + $in_diff = index_old_diff_fn($tg, \%seen, $in_diff, $1, + $xnq); } elsif ($in_diff && s/^\+//) { # diff added - index_text_inc($tg, $_, 'XDFB'); + index_diff_inc($tg, $_, 'XDFB', $xnq); } elsif ($in_diff && s/^-//) { # diff removed - index_text_inc($tg, $_, 'XDFA'); + index_diff_inc($tg, $_, 'XDFA', $xnq); } elsif (m!^index ([a-f0-9]+)\.\.([a-f0-9]+)!) { my ($ba, $bb) = ($1, $2); index_git_blob_id($doc, 'XDFPRE', $ba); @@ -227,64 +261,73 @@ sub index_diff ($$$) { # traditional diff w/o -p } elsif (/^@@ (?:\S+) (?:\S+) @@\s*(\S+.*)$/) { # hunk header context - index_text_inc($tg, $1, 'XDFHH'); + index_diff_inc($tg, $1, 'XDFHH', $xnq); # ignore the following lines: - } elsif (/^(?:dis)similarity index/) { - } elsif (/^(?:old|new) mode/) { - } elsif (/^(?:deleted|new) file mode/) { - } elsif (/^(?:copy|rename) (?:from|to) /) { - } elsif (/^(?:dis)?similarity index /) { - } elsif (/^\\ No newline at end of file/) { - } elsif (/^Binary files .* differ/) { + } elsif (/^(?:dis)similarity index/ || + /^(?:old|new) mode/ || + /^(?:deleted|new) file mode/ || + /^(?:copy|rename) (?:from|to) / || + /^(?:dis)?similarity index / || + /^\\ No newline at end of file/ || + /^Binary files .* differ/) { + push @xnq, $_; } elsif ($_ eq '') { $in_diff = undef; } else { + push @xnq, $_; warn "non-diff line: $_\n" if DEBUG && $_ ne ''; $in_diff = undef; } } + + $tg->index_text(join("\n", @xnq), 1, 'XNQ'); + $tg->increase_termpos; } sub index_body ($$$) { my ($tg, $lines, $doc) = @_; my $txt = join("\n", @$lines); - $tg->index_text($txt, !!$doc, $doc ? 'XNQ' : 'XQUOT'); - $tg->increase_termpos; - # does it look like a diff? - if ($doc && $txt =~ /^(?:diff|---|\+\+\+) /ms) { - $txt = undef; - index_diff($tg, $lines, $doc); + if ($doc) { + # does it look like a diff? + if ($txt =~ /^(?:diff|---|\+\+\+) /ms) { + $txt = undef; + index_diff($tg, $lines, $doc); + } else { + $tg->index_text($txt, 1, 'XNQ'); + } + } else { + $tg->index_text($txt, 0, 'XQUOT'); } + $tg->increase_termpos; @$lines = (); } sub add_message { my ($self, $mime, $bytes, $num, $blob) = @_; # mime = Email::MIME object - my $db = $self->{xdb}; - - my ($doc_id, $old_tid); - my $mid = mid_clean(mid_mime($mime)); + my $doc_id; + my $mids = mids($mime->header_obj); + my $skel = $self->{skeleton}; eval { - die 'Message-ID too long' if length($mid) > MAX_MID_SIZE; - my $smsg = $self->lookup_message($mid); - if ($smsg) { - # convert a ghost to a regular message - # it will also clobber any existing regular message - $doc_id = $smsg->{doc_id}; - $old_tid = $smsg->thread_id; - } - $smsg = PublicInbox::SearchMsg->new($mime); + my $smsg = PublicInbox::SearchMsg->new($mime); my $doc = $smsg->{doc}; - $doc->add_term('XMID' . $mid); - + foreach my $mid (@$mids) { + # FIXME: may be abused to prevent archival + length($mid) > MAX_MID_SIZE and + die 'Message-ID too long'; + $doc->add_term('Q' . $mid); + } my $subj = $smsg->subject; + my $xpath; if ($subj ne '') { - my $path = $self->subject_path($subj); - $doc->add_term('XPATH' . id_compress($path)); + $xpath = $self->subject_path($subj); + $xpath = id_compress($xpath); + $doc->add_boolean_term('XPATH' . $xpath); } - add_values($smsg, $bytes, $num); + my $lines = $mime->body_raw =~ tr!\n!\n!; + my @values = ($smsg->ts, $num, $bytes, $lines); + add_values($doc, \@values); my $tg = $self->term_generator; @@ -333,26 +376,34 @@ sub add_message { index_body($tg, \@orig, $doc) if @orig; }); - link_message($self, $smsg, $old_tid); - $tg->index_text($mid, 1, 'XM'); - $doc->set_data($smsg->to_doc_data($blob)); - + # populates smsg->references for smsg->to_doc_data + my $refs = parse_references($smsg); + my $data = $smsg->to_doc_data($blob); + foreach my $mid (@$mids) { + $tg->index_text($mid, 1, 'XM'); + } + $doc->set_data($data); if (my $altid = $self->{-altid}) { foreach my $alt (@$altid) { - my $id = $alt->mid2alt($mid); - next unless defined $id; - $doc->add_term($alt->{xprefix} . $id); + my $pfx = $alt->{xprefix}; + foreach my $mid (@$mids) { + my $id = $alt->mid2alt($mid); + next unless defined $id; + $doc->add_boolean_term($pfx . $id); + } } } - if (defined $doc_id) { - $db->replace_document($doc_id, $doc); + if ($skel) { + push @values, $mids, $xpath, $data; + $skel->index_skeleton(\@values); + $doc_id = $self->{xdb}->add_document($doc); } else { - $doc_id = $db->add_document($doc); + $doc_id = link_and_save($self, $doc, $mids, $refs); } }; if ($@) { - warn "failed to index message <$mid>: $@\n"; + warn "failed to index message <".join('> <',@$mids).">: $@\n"; return undef; } $doc_id; @@ -366,7 +417,7 @@ sub remove_message { $mid = mid_clean($mid); eval { - my ($head, $tail) = $self->find_doc_ids('XMID' . $mid); + my ($head, $tail) = $self->find_doc_ids('Q' . $mid); if ($head->equal($tail)) { warn "cannot remove non-existent <$mid>\n"; } @@ -407,58 +458,84 @@ sub next_thread_id { $last_thread_id; } -sub link_message { - my ($self, $smsg, $old_tid) = @_; - my $doc = $smsg->{doc}; - my $mid = $smsg->mid; +sub parse_references ($) { + my ($smsg) = @_; my $mime = $smsg->{mime}; my $hdr = $mime->header_obj; - - # last References should be IRT, but some mail clients do things - # out of order, so trust IRT over References iff IRT exists - my @refs = (($hdr->header_raw('References') || '') =~ /<([^>]+)>/g); - push(@refs, (($hdr->header_raw('In-Reply-To') || '') =~ /<([^>]+)>/g)); - - my $tid; - if (@refs) { - my %uniq = ($mid => 1); - my @orig_refs = @refs; - @refs = (); - - # prevent circular references via References: here: - foreach my $ref (@orig_refs) { - if (length($ref) > MAX_MID_SIZE) { - warn "References: <$ref> too long, ignoring\n"; - } - next if $uniq{$ref}; - $uniq{$ref} = 1; - push @refs, $ref; + my $refs = references($hdr); + return $refs if scalar(@$refs) == 0; + + # prevent circular references via References here: + my %mids = map { $_ => 1 } @{mids($hdr)}; + my @keep; + foreach my $ref (@$refs) { + # FIXME: this is an archive-prevention vector like X-No-Archive + if (length($ref) > MAX_MID_SIZE) { + warn "References: <$ref> too long, ignoring\n"; } + next if $mids{$ref}; + push @keep, $ref; } + $smsg->{references} = '<'.join('> <', @keep).'>' if @keep; + \@keep; +} - if (@refs) { - $smsg->{references} = '<'.join('> <', @refs).'>'; +sub link_doc { + my ($self, $doc, $refs, $old_tid) = @_; + my $tid; + if (@$refs) { # first ref *should* be the thread root, # but we can never trust clients to do the right thing - my $ref = shift @refs; - $tid = $self->_resolve_mid_to_tid($ref); - $self->merge_threads($tid, $old_tid) if defined $old_tid; + my $ref = shift @$refs; + $tid = resolve_mid_to_tid($self, $ref); + merge_threads($self, $tid, $old_tid) if defined $old_tid; # the rest of the refs should point to this tid: - foreach $ref (@refs) { - my $ptid = $self->_resolve_mid_to_tid($ref); + foreach $ref (@$refs) { + my $ptid = resolve_mid_to_tid($self, $ref); merge_threads($self, $tid, $ptid); } } else { $tid = defined $old_tid ? $old_tid : $self->next_thread_id; } - $doc->add_term('G' . $tid); + $doc->add_boolean_term('G' . $tid); + $tid; } -sub index_blob { - my ($self, $mime, $bytes, $num, $blob) = @_; - $self->add_message($mime, $bytes, $num, $blob); +sub link_and_save { + my ($self, $doc, $mids, $refs) = @_; + my $db = $self->{xdb}; + my $old_tid; + my $doc_id; + my $vivified = 0; + foreach my $mid (@$mids) { + $self->each_smsg_by_mid($mid, sub { + my ($cur) = @_; + my $type = $cur->type; + my $cur_tid = $cur->thread_id; + $old_tid = $cur_tid unless defined $old_tid; + if ($type eq 'mail') { + # do not break existing mail messages, + # just merge the threads + merge_threads($self, $old_tid, $cur_tid); + return 1; + } + if ($type ne 'ghost') { + die "<$mid> has a bad type: $type\n"; + } + my $tid = link_doc($self, $doc, $refs, $old_tid); + $old_tid = $tid unless defined $old_tid; + $doc_id = $cur->{doc_id}; + $self->{xdb}->replace_document($doc_id, $doc); + ++$vivified; + 1; + }); + } + # not really important, but we return any vivified ghost docid, here: + return $doc_id if defined $doc_id; + link_doc($self, $doc, $refs, $old_tid); + $self->{xdb}->add_document($doc); } sub index_git_blob_id { @@ -482,9 +559,10 @@ sub index_mm { my $mid = mid_clean(mid_mime($mime)); my $mm = $self->{mm}; my $num = $mm->mid_insert($mid); + return $num if defined $num; # fallback to num_for since filters like RubyLang set the number - defined $num ? $num : $mm->num_for($mid); + $mm->num_for($mid); } sub unindex_mm { @@ -495,7 +573,7 @@ sub unindex_mm { sub index_mm2 { my ($self, $mime, $bytes, $blob) = @_; my $num = $self->{mm}->num_for(mid_clean(mid_mime($mime))); - index_blob($self, $mime, $bytes, $num, $blob); + add_message($self, $mime, $bytes, $num, $blob); } sub unindex_mm2 { @@ -507,7 +585,7 @@ sub unindex_mm2 { sub index_both { my ($self, $mime, $bytes, $blob) = @_; my $num = index_mm($self, $mime); - index_blob($self, $mime, $bytes, $num, $blob); + add_message($self, $mime, $bytes, $num, $blob); } sub unindex_both { @@ -541,6 +619,7 @@ sub batch_adjust ($$$$) { } } +# only for v1 sub rlog { my ($self, $log, $add_cb, $del_cb, $batch_cb) = @_; my $hex = '[a-f0-9]'; @@ -573,9 +652,14 @@ sub rlog { sub _msgmap_init { my ($self) = @_; - $self->{mm} = eval { + $self->{mm} ||= eval { require PublicInbox::Msgmap; - PublicInbox::Msgmap->new($self->{git_dir}, 1); + my $msgmap_path = $self->{msgmap_path}; + if (defined $msgmap_path) { # v2 + PublicInbox::Msgmap->new_file($msgmap_path, 1); + } else { + PublicInbox::Msgmap->new($self->{mainrepo}, 1); + } }; } @@ -668,16 +752,27 @@ sub _index_sync { } } else { # user didn't install DBD::SQLite and DBI - rlog($self, $xlog, *index_blob, *unindex_blob, $cb); + rlog($self, $xlog, *add_message, *unindex_blob, $cb); } } # this will create a ghost as necessary -sub _resolve_mid_to_tid { +sub resolve_mid_to_tid { my ($self, $mid) = @_; + my $tid; + $self->each_smsg_by_mid($mid, sub { + my ($smsg) = @_; + my $cur_tid = $smsg->thread_id; + if (defined $tid) { + merge_threads($self, $tid, $cur_tid); + } else { + $tid = $smsg->thread_id; + } + 1; + }); + return $tid if defined $tid; - my $smsg = $self->lookup_message($mid) || $self->create_ghost($mid); - $smsg->thread_id; + $self->create_ghost($mid)->thread_id; } sub create_ghost { @@ -685,9 +780,9 @@ sub create_ghost { my $tid = $self->next_thread_id; my $doc = Search::Xapian::Document->new; - $doc->add_term('XMID' . $mid); - $doc->add_term('G' . $tid); - $doc->add_term('T' . 'ghost'); + $doc->add_boolean_term('Q' . $mid); + $doc->add_boolean_term('G' . $tid); + $doc->add_boolean_term('T' . 'ghost'); my $smsg = PublicInbox::SearchMsg->wrap($doc, $mid); $self->{xdb}->add_document($doc); @@ -698,22 +793,32 @@ sub create_ghost { sub merge_threads { my ($self, $winner_tid, $loser_tid) = @_; return if $winner_tid == $loser_tid; - my ($head, $tail) = $self->find_doc_ids('G' . $loser_tid); my $db = $self->{xdb}; - for (; $head != $tail; $head->inc) { - my $docid = $head->get_docid; - my $doc = $db->get_document($docid); - $doc->remove_term('G' . $loser_tid); - $doc->add_term('G' . $winner_tid); - $db->replace_document($docid, $doc); + my $batch_size = 1000; # don't let @ids grow too large to avoid OOM + while (1) { + my ($head, $tail) = $self->find_doc_ids('G' . $loser_tid); + return if $head == $tail; + my @ids; + for (; $head != $tail && @ids < $batch_size; $head->inc) { + push @ids, $head->get_docid; + } + foreach my $docid (@ids) { + my $doc = $db->get_document($docid); + $doc->remove_term('G' . $loser_tid); + $doc->add_boolean_term('G' . $winner_tid); + $db->replace_document($docid, $doc); + } } } sub _read_git_config_perm { my ($self) = @_; - my @cmd = qw(config core.sharedRepository); - my $fh = PublicInbox::Git->new($self->{git_dir})->popen(@cmd); + my @cmd = qw(config); + if ($self->{version} == 2) { + push @cmd, "--file=$self->{mainrepo}/inbox-config"; + } + my $fh = $self->{git}->popen(@cmd, 'core.sharedRepository'); local $/ = "\n"; my $perm = <$fh>; chomp $perm if defined $perm; @@ -772,4 +877,20 @@ sub DESTROY { $_[0]->{lockfh} = undef; } +# remote_* subs are only used by SearchIdxPart and SearchIdxSkeleton +sub remote_commit { + my ($self) = @_; + print { $self->{w} } "commit\n" or die "failed to write commit: $!"; +} + +sub remote_close { + my ($self) = @_; + my $pid = delete $self->{pid} or die "no process to wait on\n"; + my $w = delete $self->{w} or die "no pipe to write to\n"; + print $w "close\n" or die "failed to write to pid:$pid: $!\n"; + close $w or die "failed to close pipe for pid:$pid: $!\n"; + waitpid($pid, 0) == $pid or die "remote process did not finish"; + $? == 0 or die ref($self)." pid:$pid exited with: $?"; +} + 1;